mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-27 21:03:16 +00:00
让**外部 agent** 也能按分类树用这套知识库。HomeAgent 自己的 agent 仍
直接调内部方法(knowledge_search/create 等),走进程内直调,不经此服务。
一、内核树视图(internal/knowledge/tree.go)
为什么不复用 TreeIndex:那个是**内部导出物**,面向 .index.json 落盘,
每个条目带 top-20 的 TF-IDF 特征向量。直接序列化给外部有三个问题:
体积(200 条时 .index.json 已 246KB 且冗余存了 preview,而正本在
content.md)、泄漏(稀疏特征表 = 分词/IDF 内部表示)、语义错位
(外部要的是"有哪些分类、每类下有什么")。
新增 TreeView/Subtree/Categories/CategoryCounts:不含向量,带条目数
与可读摘要,支持 MaxDepth 懒加载、IncludeItems 只看结构。
节点 Name 是**本级段名**("go")、Path 是完整路径("tech/go")——
最初把全路径写进 Name,前端拼层级会得到 "tech/tech/go",已修。
二、kbtree 插件:独立 HTTP 服务(默认 127.0.0.1:9892)
为何不挂在 WebUI 的 /api/v1/knowledge* 下:
1. 不共享鉴权与端口。WebUI 的 api_key 是给人操作界面用的,把它分发给
外部 agent 等于把管理面凭据扩散出去。本服务用**独立 token** +
独立端口,可单独关闭(token 未配置则启动时随机生成)。
2. 只读。写入要决定分类归属与媒体处理,外部自行拼装容易造出越界/重名
条目 —— 写入留给内核工具。
3. 形状按树组织,而不是平铺搜索接口。
端点:/tree(可指定 category/depth/items)、/categories、/counts、
/search、/ (自述)。全部需 token(X-API-Key / Bearer / ?token=),
非 GET 一律 405。无知识库时 Start 直接失败,不占端口。
鉴权与 Slowloris/超时设置照 remotedevice 范式。
三、agent 技能(assets/skills/knowledge-base/SKILL.md)
指令文档型 skill:教模型"先看树 → 定位分类 → 分类内检索",并列出
易错点(name 已含分类别再拼、只看第一条、404 附现有分类)。
加载与校验由 internal/plugin/skill_bundled_test.go 守住 —— 这条断言
的由来:非白名单的二级标题会被 extractToolDefs 当成工具定义,报错
"invalid tool name",而提示与真正原因(标题层级)毫无关联。
kbtree 的测试还会校验文档提到的端点与代码一致,防漂移。
四、WebUI 树浏览(前端真正用起来,而非留一个没人调的端点)
面板加可折叠的分类树:逐级点选即把搜索范围切到该子树(原先是让人
手打分类名)。当前范围有可见标签与「全库」复位。
253 lines
7.5 KiB
Go
253 lines
7.5 KiB
Go
package knowledge
|
||
|
||
import (
|
||
"encoding/json"
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
func seedTreeStore(t *testing.T) *Store {
|
||
t.Helper()
|
||
s := NewStore(t.TempDir())
|
||
if err := s.Start(); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
t.Cleanup(s.Stop)
|
||
for _, e := range []struct{ name, body string }{
|
||
{"tech/go/并发", "goroutine 调度 GMP 抢占"},
|
||
{"tech/go/context", "context 取消 超时"},
|
||
{"tech/rust/所有权", "borrow checker move 语义"},
|
||
{"life/sleep", "作息 褪黑素"},
|
||
{"cook/coffee", "手冲 烘焙"},
|
||
{"顶层条目", "没有分类的条目"},
|
||
} {
|
||
if err := s.Add(e.name, e.body); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
return s
|
||
}
|
||
|
||
// 树视图必须呈现正确的层级、计数与挂载点。
|
||
func TestTreeViewShape(t *testing.T) {
|
||
s := seedTreeStore(t)
|
||
root := s.Tree(TreeOptions{IncludeItems: true})
|
||
if root == nil {
|
||
t.Fatal("Tree 返回 nil")
|
||
}
|
||
if root.Name != "root" || root.Path != "" {
|
||
t.Errorf("根节点应为 root/空 path,实为 %q/%q", root.Name, root.Path)
|
||
}
|
||
// 顶层无分类条目挂在 root.Items
|
||
if root.ItemCount != 1 || len(root.Items) != 1 || root.Items[0].Name != "顶层条目" {
|
||
t.Errorf("root 应挂 1 条无分类条目,实为 %d 条 %v", root.ItemCount, itemNames(root.Items))
|
||
}
|
||
// 子分类按名排序
|
||
var kids []string
|
||
for _, c := range root.Children {
|
||
kids = append(kids, c.Name)
|
||
}
|
||
if strings.Join(kids, ",") != "cook,life,tech" {
|
||
t.Errorf("子分类应按名排序为 cook,life,tech,实为 %v", kids)
|
||
}
|
||
// tech 有两个子分类
|
||
tech := findChild(root, "tech")
|
||
if tech == nil {
|
||
t.Fatal("缺少 tech 节点")
|
||
}
|
||
if tech.TotalCount != 3 {
|
||
t.Errorf("tech 子树应有 3 条(go 2 + rust 1),实为 %d", tech.TotalCount)
|
||
}
|
||
// tech/go 是 tech 的**子节点**(层级体现在树上,不是平铺)
|
||
goNode := findChild(tech, "go")
|
||
if goNode == nil {
|
||
t.Fatal("缺少 tech/go 节点")
|
||
}
|
||
if goNode.ItemCount != 2 || goNode.Path != "tech/go" {
|
||
t.Errorf("tech/go 应有 2 条且 path 正确,实为 %d / %q", goNode.ItemCount, goNode.Path)
|
||
}
|
||
// 条目视图字段完整
|
||
for _, it := range goNode.Items {
|
||
if it.Preview == "" || it.Size == 0 {
|
||
t.Errorf("%s 缺 preview/size: %+v", it.Name, it)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 树视图**不得**带向量(那是内部导出物的内容,见 tree.go 头部说明)。
|
||
func TestTreeViewCarriesNoVectors(t *testing.T) {
|
||
s := seedTreeStore(t)
|
||
data := mustJSON(t, s.Tree(TreeOptions{IncludeItems: true}))
|
||
for _, forbidden := range []string{`"vector"`, `"Vector"`} {
|
||
if strings.Contains(data, forbidden) {
|
||
t.Errorf("树视图不应含 %s(会把稀疏特征表泄漏给外部调用方)", forbidden)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MaxDepth 剪枝:只给计数不下钻,且 TotalCount 仍准确。
|
||
func TestTreeMaxDepthPrunesButKeepsCount(t *testing.T) {
|
||
s := seedTreeStore(t)
|
||
full := s.Tree(TreeOptions{})
|
||
shallow := s.Tree(TreeOptions{MaxDepth: 1})
|
||
|
||
if full.TotalCount != shallow.TotalCount {
|
||
t.Errorf("剪枝不应改变 TotalCount:%d vs %d", full.TotalCount, shallow.TotalCount)
|
||
}
|
||
if full.TotalCount != 6 {
|
||
t.Errorf("总条目应为 6,实为 %d", full.TotalCount)
|
||
}
|
||
// depth=1 ⇒ 根的直接子分类有节点,但它们的 Children 为空
|
||
for _, c := range shallow.Children {
|
||
if len(c.Children) != 0 {
|
||
t.Errorf("depth=1 时 %s 下不应再展开子节点,实为 %d 个", c.Name, len(c.Children))
|
||
}
|
||
if c.TotalCount == 0 {
|
||
t.Errorf("depth=1 时 %s 的 TotalCount 仍应是真实值", c.Name)
|
||
}
|
||
}
|
||
tech := findChild(shallow, "tech")
|
||
if tech != nil && tech.TotalCount != 3 {
|
||
t.Errorf("剪枝后 tech 的 TotalCount 应仍为 3,实为 %d", tech.TotalCount)
|
||
}
|
||
}
|
||
|
||
// Subtree 只返回那棵子树。
|
||
func TestTreeSubtree(t *testing.T) {
|
||
s := seedTreeStore(t)
|
||
view := s.Subtree("tech/go", TreeOptions{IncludeItems: true})
|
||
if view == nil {
|
||
t.Fatal("Subtree 返回 nil")
|
||
}
|
||
// Name 是**本级段名**,Path 是完整路径(前端拼层级用 Name,定位用 Path)
|
||
if view.Path != "tech/go" {
|
||
t.Errorf("子树根 Path 应为 tech/go,实为 %q", view.Path)
|
||
}
|
||
if view.Name != "go" {
|
||
t.Errorf("子树根 Name 应为本级段名 go,实为 %q", view.Name)
|
||
}
|
||
if view.ItemCount != 2 {
|
||
t.Errorf("tech/go 应有 2 条,实为 %d", view.ItemCount)
|
||
}
|
||
// 空分类 → 整棵树
|
||
if r := s.Subtree("", TreeOptions{}); r == nil || r.Name != "root" {
|
||
t.Errorf("空分类应返回整棵树,实为 %+v", r)
|
||
}
|
||
// 不存在的分类 → nil(调用方据此给 404)
|
||
if s.Subtree("no/such", TreeOptions{}) != nil {
|
||
t.Error("不存在的分类应返回 nil")
|
||
}
|
||
// 前后斜杠应被归一化
|
||
if s.Subtree("/tech/go/", TreeOptions{}) == nil {
|
||
t.Error("前后斜杠应被归一化后仍能命中")
|
||
}
|
||
}
|
||
|
||
// IncludeItems=false 时只给计数,不给条目。
|
||
func TestTreeWithoutItems(t *testing.T) {
|
||
s := seedTreeStore(t)
|
||
view := s.Tree(TreeOptions{IncludeItems: false})
|
||
if view.ItemCount != 1 {
|
||
t.Errorf("ItemCount 应仍为 1,实为 %d", view.ItemCount)
|
||
}
|
||
if len(view.Items) != 0 {
|
||
t.Errorf("IncludeItems=false 不应返回条目,实为 %v", itemNames(view.Items))
|
||
}
|
||
}
|
||
|
||
// Categories 应含中间层(tech/go/并发 ⇒ tech、tech/go 都在列表里)。
|
||
func TestTreeCategoriesIncludesIntermediate(t *testing.T) {
|
||
s := seedTreeStore(t)
|
||
cats := s.Categories()
|
||
want := map[string]bool{"tech": true, "tech/go": true, "tech/rust": true, "life": true, "cook": true}
|
||
for _, c := range cats {
|
||
delete(want, c)
|
||
}
|
||
if len(want) != 0 {
|
||
t.Errorf("缺少分类 %v,实际列表 %v", want, cats)
|
||
}
|
||
// 已排序
|
||
if !isSorted(cats) {
|
||
t.Errorf("Categories 应有序,实为 %v", cats)
|
||
}
|
||
}
|
||
|
||
// CategoryCounts 按数量倒序,且中间层计入其后代。
|
||
func TestTreeCategoryCounts(t *testing.T) {
|
||
s := seedTreeStore(t)
|
||
counts := s.CategoryCounts()
|
||
byName := map[string]int{}
|
||
for _, c := range counts {
|
||
byName[c.Category] = c.Count
|
||
}
|
||
if byName["tech"] != 3 {
|
||
t.Errorf("tech 应聚合 3 条,实为 %d", byName["tech"])
|
||
}
|
||
if byName["tech/go"] != 2 {
|
||
t.Errorf("tech/go 应为 2 条,实为 %d", byName["tech/go"])
|
||
}
|
||
// 倒序:首项应是条目最多的分类
|
||
if len(counts) == 0 || counts[0].Category != "tech" {
|
||
t.Errorf("首项应为条目最多的 tech,实为 %+v", counts)
|
||
}
|
||
}
|
||
|
||
// 预览按 rune 截断,不能把多字节字符切坏。
|
||
func TestTreePreviewRuneSafe(t *testing.T) {
|
||
dir := t.TempDir()
|
||
s := NewStore(dir)
|
||
if err := s.Start(); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer s.Stop()
|
||
if err := s.Add("zh", strings.Repeat("知识库条目内容", 100)); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
view := s.Tree(TreeOptions{IncludeItems: true, PreviewLimit: 10})
|
||
it := view.Items[0]
|
||
if strings.ContainsRune(it.Preview, 0xFFFD) {
|
||
t.Errorf("预览含替换字符,说明按字节切了多字节字符: %q", it.Preview)
|
||
}
|
||
if runeLen(it.Preview) > 11 { // 10 字 + 省略号
|
||
t.Errorf("预览长度应约 11 rune,实为 %d: %q", runeLen(it.Preview), it.Preview)
|
||
}
|
||
}
|
||
|
||
// findChild 按**本级段名**在子节点里查找(Name 语义见 tree.go 的说明)。
|
||
func findChild(v *TreeView, name string) *TreeView {
|
||
for i := range v.Children {
|
||
if v.Children[i].Name == name {
|
||
return &v.Children[i]
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func itemNames(items []TreeItemView) []string {
|
||
out := make([]string, len(items))
|
||
for i, it := range items {
|
||
out[i] = it.Name
|
||
}
|
||
return out
|
||
}
|
||
|
||
func isSorted(s []string) bool {
|
||
for i := 1; i < len(s); i++ {
|
||
if s[i-1] > s[i] {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
func runeLen(s string) int { return len([]rune(s)) }
|
||
|
||
func mustJSON(t *testing.T, v interface{}) string {
|
||
t.Helper()
|
||
b, err := json.Marshal(v)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return string(b)
|
||
}
|