feat(kbtree): 知识库分类树的独立只读服务 + agent 技能 + WebUI 树浏览

让**外部 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 树浏览(前端真正用起来,而非留一个没人调的端点)
  面板加可折叠的分类树:逐级点选即把搜索范围切到该子树(原先是让人
  手打分类名)。当前范围有可见标签与「全库」复位。
This commit is contained in:
JianFeeeee
2026-09-26 14:19:34 +08:00
parent ce694bc1a1
commit 41d754334e
14 changed files with 2071 additions and 9 deletions

278
internal/knowledge/tree.go Normal file
View File

@ -0,0 +1,278 @@
package knowledge
import (
"sort"
"strings"
)
// timeLayout 是树视图对外暴露时间戳的格式(RFC3339,秒精度)。
// 显式声明而非用 time.RFC3339 常量:调用方按字面量解析,格式变了会静默错解。
const timeLayout = "2006-01-02T15:04:05Z07:00"
// 树形只读视图(供 HTTP/Skill 等外部消费者使用)。
//
// 与 TreeIndex 的区别,也是本文件存在的理由:TreeIndex 是**内部导出物**,
// 面向 .index.json 落盘,每个 IndexItem 带着 top-20 的 TF-IDF 特征向量。
// 把那个结构直接序列化给外部会有三个问题:
// 1. 体积:每条几百个浮点数(实测 200 条时 .index.json 已 246KB,且里面
// 还冗余存了 preview,而正本在 content.md);
// 2. 泄漏:Vector 是稀疏特征表,等于把分词/IDF 统计细节对外暴露;
// 3. 语义错位:外部调用方要的是「有哪些分类、每个分类下有什么」,
// 而不是一个分词器内部表示。
//
// 所以这里定义**面向服务**的视图:不带向量,带条目数与可读摘要,
// 并支持按需只取某棵子树(懒加载),避免一次性吐出整棵树。
// TreeView 是分类树的一个节点。
type TreeView struct {
// Name 是本节点名。根节点为 "root"。
Name string `json:"name"`
// Path 是从根到本节点的分类路径(不含根),如 "tech/go"。根节点为空。
Path string `json:"path"`
// ItemCount 是本节点**直接挂载**的条目数(不含子节点下的)。
ItemCount int `json:"item_count"`
// TotalCount 是本节点整棵子树下的条目总数(含后代)。前端做懒加载时
// 只给 ItemCount 也能显示"展开前有多少",TotalCount 则能一眼看出规模。
TotalCount int `json:"total_count"`
// Items 是直接挂在本节点的条目。depth=0 时不填(只给计数)。
Items []TreeItemView `json:"items,omitempty"`
// Children 是子分类,按名字排序(map 迭代无序,不排序则响应不可复现)。
Children []TreeView `json:"children,omitempty"`
}
// TreeItemView 是一个知识条目的只读视图。
type TreeItemView struct {
Name string `json:"name"`
Preview string `json:"preview"`
Size int `json:"size"`
Tags []string `json:"tags,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
// Media 是该条目挂载的媒体(多模态:条目可被"以图搜"召回)。
Media []TreeMediaView `json:"media,omitempty"`
}
// TreeMediaView 是媒体引用摘要。
type TreeMediaView struct {
Digest string `json:"digest"`
MIME string `json:"mime"`
Kind string `json:"kind,omitempty"`
}
// TreeOptions 控制 TreeView 的取舍。
type TreeOptions struct {
// MaxDepth 限制返回的层数。0 = 不限;1 = 只根 + 第一层分类。
// 分类很多时用它做懒加载,避免单次响应膨胀。
MaxDepth int
// IncludeItems 是否填充 Items。只看结构时可关掉以减小响应。
IncludeItems bool
// PreviewLimit 是预览字数上限,0 用默认 120。
PreviewLimit int
}
// 树形视图的默认预览长度。
const defaultTreePreview = 120
// Tree 返回面向服务的分类树视图。
func (s *Store) Tree(opt TreeOptions) *TreeView {
s.mu.RLock()
defer s.mu.RUnlock()
return s.treeLocked("", opt, 1)
}
// Subtree 返回某棵子树的视图。category 为空时等价于 Tree。
func (s *Store) Subtree(category string, opt TreeOptions) *TreeView {
s.mu.RLock()
defer s.mu.RUnlock()
cat := strings.Trim(strings.TrimSpace(category), "/")
if cat == "" {
return s.treeLocked("", opt, 1)
}
return s.treeLocked(cat, opt, 1)
}
// nodeSegment 取路径的**最后一段**作为本级段名。
func nodeSegment(category, root string) string {
if i := strings.LastIndex(category, "/"); i >= 0 {
return category[i+1:]
}
if category != "" {
return category
}
return root
}
// treeLocked 是 Tree/Subtree 的实现(调用方须持读锁)。
//
// category 非空时返回那棵子树(根节点 Path 即 category);
// 为空时返回整棵树(根节点 Name "root",Path 空)。
//
// 节点 Name 是**本级段名**(如 "go"),Path 是完整路径(如 "tech/go")。
// 两者分工:Name 给前端渲染层级标签,Path 给程序定位与再请求。
// 曾经 Name 直接写全路径,于是 tech 的子节点叫 "tech/go",前端拿它拼
// 层级会得到 "tech/tech/go",且与根节点 "root" 的语义不一致。
func (s *Store) treeLocked(category string, opt TreeOptions, depth int) *TreeView {
previewLimit := opt.PreviewLimit
if previewLimit <= 0 {
previewLimit = defaultTreePreview
}
// 该子树是否存在
if category != "" {
if !s.hasInScopeLocked(category) {
return nil
}
}
out := &TreeView{Name: nodeSegment(category, "root"), Path: category}
// 收集本节点直接挂载的条目(Category == category)
for _, k := range s.items {
if k.Category != category {
continue
}
out.ItemCount++
if !opt.IncludeItems {
continue
}
item := TreeItemView{
Name: k.Name,
Preview: previewOf(k.Content, previewLimit),
Size: len(k.Content),
Tags: k.Tags,
UpdatedAt: k.UpdatedAt.Format(timeLayout),
}
for _, m := range k.Media {
item.Media = append(item.Media, TreeMediaView{Digest: m.Digest, MIME: m.MIME, Kind: m.Kind})
}
out.Items = append(out.Items, item)
}
sort.Slice(out.Items, func(i, j int) bool { return out.Items[i].Name < out.Items[j].Name })
// 收集直接子分类:Category == category+"/xxx"(即只有一层深)
seen := make(map[string]struct{})
prefix := category
if prefix != "" {
prefix += "/"
}
for _, k := range s.items {
if k.Category == category || !strings.HasPrefix(k.Category, prefix) {
continue
}
rest := strings.TrimPrefix(k.Category, prefix)
child := rest
if i := strings.Index(rest, "/"); i >= 0 {
child = rest[:i] // 只取第一段 ⇒ 直接子分类
}
if child == "" {
continue
}
seen[child] = struct{}{}
}
names := make([]string, 0, len(seen))
for n := range seen {
names = append(names, n)
}
sort.Strings(names)
// MaxDepth 剪枝:depth > MaxDepth 时只给计数,不再下钻
if opt.MaxDepth > 0 && depth >= opt.MaxDepth {
for _, n := range names {
sub := s.treeLocked(categoryPath(category, n), TreeOptions{MaxDepth: 0, IncludeItems: false}, depth+1)
out.TotalCount += sub.TotalCount
}
out.TotalCount += out.ItemCount
return out
}
for _, n := range names {
childPath := categoryPath(category, n)
sub := s.treeLocked(childPath, opt, depth+1)
if sub == nil {
continue
}
out.Children = append(out.Children, *sub)
out.TotalCount += sub.TotalCount
}
out.TotalCount += out.ItemCount
return out
}
// categoryPath 拼子分类路径(根为空时返回裸名)。
func categoryPath(parent, child string) string {
if parent == "" {
return child
}
return parent + "/" + child
}
// previewOf 按 rune 截断预览(按字节切会把多字节字符切成乱码)。
func previewOf(s string, limit int) string {
if s == "" {
return ""
}
r := []rune(s)
if len(r) <= limit {
return s
}
return string(r[:limit]) + "…"
}
// Categories 列出全部分类路径(去重、排序)。给"只想要平铺分类列表"的调用方。
func (s *Store) Categories() []string {
s.mu.RLock()
defer s.mu.RUnlock()
seen := map[string]struct{}{}
for _, k := range s.items {
if k.Category == "" {
continue
}
// 一并给出中间层(tech/go/并发 ⇒ tech、tech/go 都要出现)
parts := strings.Split(k.Category, "/")
for i := range parts {
seen[strings.Join(parts[:i+1], "/")] = struct{}{}
}
}
out := make([]string, 0, len(seen))
for c := range seen {
out = append(out, c)
}
sort.Strings(out)
return out
}
// CategoryCounts 给出每个分类(含中间层)下的条目总数,按数量倒序。
// 给"哪些分类最值得先看"这类场景。
func (s *Store) CategoryCounts() []CategoryCount {
s.mu.RLock()
defer s.mu.RUnlock()
exact := map[string]int{}
for _, k := range s.items {
if k.Category != "" {
exact[k.Category]++
}
}
agg := map[string]int{}
for cat, n := range exact {
parts := strings.Split(cat, "/")
for i := range parts {
agg[strings.Join(parts[:i+1], "/")] += n
}
}
out := make([]CategoryCount, 0, len(agg))
for c, n := range agg {
out = append(out, CategoryCount{Category: c, Count: n})
}
sort.Slice(out, func(i, j int) bool {
if out[i].Count != out[j].Count {
return out[i].Count > out[j].Count
}
return out[i].Category < out[j].Category
})
return out
}
// CategoryCount 是一个分类的条目数。
type CategoryCount struct {
Category string `json:"category"`
Count int `json:"count"`
}

View File

@ -0,0 +1,252 @@
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)
}