From 6c557c1d105a459843fb59ba1173245d45f7640b Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sat, 26 Sep 2026 15:31:15 +0800 Subject: [PATCH] =?UTF-8?q?feat(kbtree):=20=E7=9F=A5=E8=AF=86=E5=BA=93?= =?UTF-8?q?=E6=9A=B4=E9=9C=B2=E8=8C=83=E5=9B=B4=E9=85=8D=E7=BD=AE=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20=E6=8C=89=E6=A0=91=E7=8A=B6=E5=8F=AA?= =?UTF-8?q?=E6=9A=B4=E9=9C=B2=E6=8C=87=E5=AE=9A=E5=88=86=E7=B1=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 问题 kbtree 是**唯一**把知识库开放给外部进程的通道(HomeAgent 自己的 agent 走进程内直调 knowledge_* 内核工具,不经此),但它只有 listen_addr 与 token 两个配置,**没有任何范围限制**:拿到 token 的任何 agent 都能 /tree 列出全部条目、/search 取回任意条目全文。 本机库里混着个人内容(航空发动机教材摘录、课表、身份合并规则), 不该 broadly 可读。 ## 改动 1. `internal/plugins/kbtree/scope.go`(新):暴露范围语义 - 留空 = 全部可见(范围是"限制"不是"必填",留空保持既有行为) - 前缀按**路径分段**匹配:public 命中 public 与 public/tech, 但**不**命中 publication(否则 publication 意外暴露) - 根下无分类的条目在范围非空时不可见 —— 它没有分类可匹配, 放行等于范围形同虚设 - 分隔符容忍逗号/分号/空白/换行/竖线:这是给人手填的字段 2. `plugin.go`:注册 `expose_categories` 配置项,接入**全部四个端点** - /tree 服务端裁剪子树(就地改,不重建:TreeView 字段多) - /categories 过滤路径列表 - /counts 过滤计数并**重算 total**(数量本身也是信息泄露) - /search ★ 过滤结果条目;这处最关键: 只过滤 /tree 而放过 /search 等于范围形同虚设(换个 ?q= 就能拿到全文)。 同时修正 limit 语义 —— 范围外条目不占名额,范围内条目不会被挤掉。 3. SDK 契约补 `Knowledge.Category`(纯增量) - 此前 `sdk.Knowledge` 只有 Name/Content,内核明明返回了 Category 却在 knowledge_impl 的拷贝里丢掉 ⇒ 外部服务无法按分类判定, 范围过滤在 SDK 层根本做不了。 - Name/Content 均保留,无删除。 ## 判据(8 条 + 4 组变异) 范围过滤最容易"只做一半",所以每个端点都单独钉。 ★ 判据补强一处:初版只查条目名(priv1),结果「/categories 不过滤」 这个变异**完全逃过** —— 分类端点返回的是路径不是条目名。 补上分类路径断言(private)后判红。 变异验证: - /search 不过滤 → 泄露 priv1 全文 ✓ 判红 - /categories 不过滤 → 泄露 private 分类路径 ✓ 判红(补强后) - /counts 不过滤 → TestCategoriesAndCountsEndpoint 判红 - 前缀退化为字符串前缀 → publication 被误暴露 ✓ 判红 ★ 过程中我的 fake 有两处与真实内核不符,先修 fake 再修实现: 1. 漏了内核 treeLocked 的"子分类提升一层" ⇒ 得到 children=0 的假空树 2. filterTree 无差别清空 t.Items ⇒ 整棵树只剩空壳节点 (第一版的实现是"看着测试红就改",实际是 fake 在骗我) 全量 41 包绿。SDK 接口纯增量,未发布故无需冻结检查。 --- internal/plugins/kbtree/expose_scope_test.go | 348 +++++++++++++++++++ internal/plugins/kbtree/plugin.go | 49 ++- internal/plugins/kbtree/scope.go | 140 ++++++++ internal/sdk/knowledge_impl.go | 4 +- third_party/homeagent-sdk/sdk/knowledge.go | 12 +- 5 files changed, 535 insertions(+), 18 deletions(-) create mode 100644 internal/plugins/kbtree/expose_scope_test.go create mode 100644 internal/plugins/kbtree/scope.go diff --git a/internal/plugins/kbtree/expose_scope_test.go b/internal/plugins/kbtree/expose_scope_test.go new file mode 100644 index 0000000..541c7cd --- /dev/null +++ b/internal/plugins/kbtree/expose_scope_test.go @@ -0,0 +1,348 @@ +package kbtree + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "sort" + "strings" + "testing" + + sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" +) + +// ===== 知识库对外暴露范围 ===== +// +// 现状缺陷:kbtree 只有 listen_addr 与 token 两个配置,**没有任何范围过滤**。 +// 拿到 token 的任何 agent 都能 /tree 拿到全部条目、/search 拿到全文。 +// 而本机知识库里混着个人内容(教材摘录、课表、身份合并规则), +// 不该 broadly 可读。 +// +// 本测试钉住:暴露范围配置存在,且**四个端点全部受它约束**。 +// +// ★ 为什么要覆盖 /search:这是最容易漏的一处。只过滤 /tree 而不过滤 +// /search,等于范围形同虚设 —— 外部 agent 只要换个 ?q= 关键词就能 +// 搜到范围外的条目全文。所以下面每条判据都同时查 /tree 与 /search。 + +type fakeKn struct { + sdk.KnowledgeAPI + items []struct{ name, category, content string } +} + +// Subtree 复刻内核 treeLocked 的语义(internal/knowledge/tree.go): +// - Category == 本节点 的条目挂在本节点; +// - Category 以 本节点+"/" 开头的,取**第一段**作为直接子分类(提升一层)。 +// +// 这层"提升"是内核既有行为,fake 必须照抄,否则测的是 fake 的形状 +// 而不是产品行为(我第一版就因为漏了它,得到 children=0 的假空树)。 +func (f *fakeKn) Subtree(category string, opt sdk.KnowledgeTreeOptions) (*sdk.KnowledgeTreeView, error) { + cat := strings.Trim(strings.TrimSpace(category), "/") + node := &sdk.KnowledgeTreeView{Name: "root"} + if cat != "" { + seg := cat + if i := strings.LastIndex(cat, "/"); i >= 0 { + seg = cat[i+1:] + } + node = &sdk.KnowledgeTreeView{Name: seg, Path: cat} + } + for _, it := range f.items { + if it.category != cat { + continue + } + node.ItemCount++ + node.TotalCount++ + if opt.IncludeItems { + node.Items = append(node.Items, sdk.KnowledgeTreeItemView{ + Name: it.name, Preview: it.content, + }) + } + } + prefix := cat + if prefix != "" { + prefix += "/" + } + seen := map[string]struct{}{} + for _, it := range f.items { + if it.category == cat || !strings.HasPrefix(it.category, prefix) { + continue + } + rest := strings.TrimPrefix(it.category, prefix) + seg := rest + if i := strings.Index(rest, "/"); i >= 0 { + seg = rest[:i] + } + if seg == "" { + continue + } + seen[seg] = struct{}{} + } + names := make([]string, 0, len(seen)) + for n := range seen { + names = append(names, n) + } + sort.Strings(names) + for _, n := range names { + childPath := n + if cat != "" { + childPath = cat + "/" + n + } + sub, _ := f.Subtree(childPath, opt) + if sub == nil { + continue + } + node.Children = append(node.Children, *sub) + node.TotalCount += sub.TotalCount + } + return node, nil +} + +func (f *fakeKn) SearchIn(query, category string, topK int) ([]*sdk.Knowledge, error) { + var out []*sdk.Knowledge + for _, it := range f.items { + if category != "" && !strings.HasPrefix(it.category, category) { + continue + } + if query == "" || strings.Contains(it.content, query) { + out = append(out, &sdk.Knowledge{ + Name: it.name, Category: it.category, Content: it.content, + }) + } + } + if len(out) > topK { + out = out[:topK] + } + return out, nil +} + +func (f *fakeKn) Categories() ([]string, error) { + seen := map[string]bool{} + for _, it := range f.items { + if it.category != "" { + seen[it.category] = true + } + } + var out []string + for c := range seen { + out = append(out, c) + } + return out, nil +} + +func (f *fakeKn) CategoryCounts() ([]sdk.KnowledgeCategoryCount, error) { + m := map[string]int{} + var order []string + for _, it := range f.items { + if _, ok := m[it.category]; !ok { + order = append(order, it.category) + } + m[it.category]++ + } + var out []sdk.KnowledgeCategoryCount + for _, c := range order { + out = append(out, sdk.KnowledgeCategoryCount{Category: c, Count: m[c]}) + } + return out, nil +} + +// 样本:public/ 下 2 条,private/ 下 1 条(含敏感内容)。 +func newScopedFixture() *fakeKn { + return &fakeKn{items: []struct{ name, category, content string }{ + {"pub1", "public", "公开的架构说明"}, + {"pub2", "public/tech", "公开的并发笔记"}, + {"priv1", "private", "绝密:个人身份证号 123"}, + }} +} + +func newScopedServer(t *testing.T, expose string) *httptest.Server { + t.Helper() + p := &Plugin{name: "kbtree", token: "tok", expose: newScope(expose), mux: http.NewServeMux()} + p.kn = newScopedFixture() + p.registerRoutes() + s := httptest.NewServer(p.mux) + t.Cleanup(s.Close) + return s +} + +func get(t *testing.T, s *httptest.Server, path string) (int, string) { + t.Helper() + req, err := http.NewRequest(http.MethodGet, s.URL+path, nil) + if err != nil { + t.Fatal(err) + } + req.Header.Set("X-API-Key", "tok") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + var buf strings.Builder + _, _ = buf.WriteString("") + b := make([]byte, 32*1024) + n, _ := resp.Body.Read(b) + return resp.StatusCode, string(b[:n]) +} + +// 暴露范围生效:范围外的条目**与分类路径**都不得出现在任何端点的响应里。 +// +// ★ 判据分两类断言,因为两个端点的泄露形态不同(第一版只查条目名, +// +// 结果「/categories 不过滤」这个变异完全逃过了 —— 分类端点返回的是 +// 路径不是条目名,只查 priv1 永远查不到): +// - 条目名/正文(priv1、身份证):出现在 /tree、/search 的泄露 +// - 分类路径(private):出现在 /categories、/counts 的泄露 +func TestExposeScopeHidesOutOfScopeItems(t *testing.T) { + s := newScopedServer(t, "public") + + for _, path := range []string{ + "/tree", + "/categories", + "/counts", + // ★ 关键:范围外的条目即使能被搜到也不得返回。 + // 查询词用"绝密"是为了让 fake 命中那条私密条目; + // 断言针对的是**结果条目**(priv1 / 其正文),不针对 query 回显 —— + // 响应里回显调用方自己发来的 q 是正常行为,不是泄露。 + "/search?q=" + "绝密", + } { + code, body := get(t, s, path) + if code != http.StatusOK { + t.Fatalf("%s: 期望 200,实际 %d", path, code) + } + if strings.Contains(body, "priv1") { + t.Errorf("%s 泄露了范围外条目 priv1:%s", path, body) + } + if strings.Contains(body, "身份证") { + t.Errorf("%s 泄露了范围外条目的正文:%s", path, body) + } + // 分类路径泄露:private 这个词既可能是分类名也可能是条目内容里 + // 的普通词,所以只在两个"按分类组织"的端点上断言。 + if path == "/categories" || path == "/counts" { + if strings.Contains(body, "private") { + t.Errorf("%s 泄露了范围外分类路径 private:%s", path, body) + } + } + } +} + +// 范围内的条目必须仍然可见(防止"过滤过头"把树清空 —— 那是另一种假绿)。 +func TestExposeScopeKeepsInScopeItems(t *testing.T) { + s := newScopedServer(t, "public") + code, body := get(t, s, "/tree") + if code != http.StatusOK { + t.Fatalf("期望 200,实际 %d", code) + } + for _, want := range []string{"pub1", "pub2"} { + if !strings.Contains(body, want) { + t.Errorf("范围内条目 %s 消失了(过滤过头):%s", want, body) + } + } + // 子分类也必须在(树状结构不能被压平) + if !strings.Contains(body, "tech") { + t.Errorf("子树 tech 丢失,树状结构被压平:%s", body) + } +} + +// 范围配置必须真的能限定 /search 的召回,而不是只在输出端删字段。 +// +// 这一条钉住"过滤发生在哪一层":若只在响应里删掉范围外条目, +// 而 SearchIn 本身把全文返回了,limit 参数会因范围外条目占位而 +// 让范围内条目被挤掉 —— 结果是"看起来过滤了,其实漏了"。 +func TestExposeScopeSearchKeepsInScopeFillsLimit(t *testing.T) { + s := newScopedServer(t, "public") + // limit=1 时若 priv1 占掉名额,pub1 就拿不到 + _, body := get(t, s, "/search?q="+"&limit=1") + if strings.Contains(body, "priv1") { + t.Errorf("范围内检索被范围外条目挤占:%s", body) + } +} + +// 留空 = 全部可见(保持既有行为;范围是"限制"不是"必填")。 +func TestEmptyExposeScopeMeansAll(t *testing.T) { + s := newScopedServer(t, "") + code, body := get(t, s, "/tree") + if code != http.StatusOK { + t.Fatalf("期望 200,实际 %d", code) + } + if !strings.Contains(body, "priv1") { + t.Errorf("范围留空时应全部可见(向后兼容),实际看不到 priv1:%s", body) + } +} + +// 范围解析:逗号/空格/换行分隔都能吃;前缀匹配语义正确。 +func TestExposeScopeParsing(t *testing.T) { + cases := []struct { + in string + want []string + match []struct { + cat string + in bool + } + }{ + {"", nil, []struct { + cat string + in bool + }{{cat: "private", in: true}, {cat: "public", in: true}}}, + {"public", []string{"public"}, []struct { + cat string + in bool + }{{cat: "public", in: true}, {cat: "public/tech", in: true}, {cat: "private", in: false}}}, + {" public , private ", []string{"public", "private"}, []struct { + cat string + in bool + }{{cat: "public", in: true}, {cat: "private", in: true}}}, + {"public\nprivate", []string{"public", "private"}, []struct { + cat string + in bool + }{{cat: "private", in: true}}}, + } + for _, c := range cases { + sc := newScope(c.in) + if len(sc.paths) != len(c.want) { + t.Errorf("parse(%q) 得到 %v,期望 %v", c.in, sc.paths, c.want) + } + for _, m := range c.match { + if got := sc.allows(m.cat); got != m.in { + t.Errorf("parse(%q).allows(%q) = %v,期望 %v", c.in, m.cat, got, m.in) + } + } + } +} + +// 边界:public 不应匹配 publication(前缀必须是路径分段级)。 +func TestExposeScopePrefixIsSegmentWise(t *testing.T) { + sc := newScope("public") + if sc.allows("publication") { + t.Error("publication 被 public 范围包含 —— 前缀必须按路径分段比较," + + "否则 publication 这类目录会意外暴露") + } +} + +// 配置项必须在插件里注册(否则前端设置页无法编辑,功能等于隐藏)。 +func TestExposeScopeConfigIsRegistered(t *testing.T) { + // 直接断言源码里存在注册调用(读同包源码,键名漂移会被立刻发现) + b, err := readSelf(t, "plugin.go") + if err != nil { + t.Fatal(err) + } + src := string(b) + if !strings.Contains(src, `Key: "expose_categories"`) { + t.Error("未注册 expose_categories 配置项 —— 设置页无法编辑,功能等于隐藏") + } +} + +// readSelf 读同包源码文件。 +func readSelf(t *testing.T, name string) ([]byte, error) { + return os.ReadFile(name) +} + +// 响应可解析(粗判 JSON 合法,避免把 HTML 错误页当成功)。 +func TestScopedResponsesAreJSON(t *testing.T) { + s := newScopedServer(t, "public") + for _, path := range []string{"/tree", "/categories", "/counts", "/search?q=a"} { + _, body := get(t, s, path) + var v interface{} + if err := json.Unmarshal([]byte(body), &v); err != nil { + t.Errorf("%s 返回的不是合法 JSON:%v(body=%.120s)", path, err, body) + } + } +} diff --git a/internal/plugins/kbtree/plugin.go b/internal/plugins/kbtree/plugin.go index 20d93dd..ba30c3b 100644 --- a/internal/plugins/kbtree/plugin.go +++ b/internal/plugins/kbtree/plugin.go @@ -49,6 +49,10 @@ type Plugin struct { sdkRef *sdk.PluginSDK addr string token string + // expose 是暴露范围(nil/空 = 全部可见)。见 scope.go。 + expose *scope + // kn 是知识库句柄的测试注入口(生产从 sdkRef 取)。 + kn sdk.KnowledgeAPI server *http.Server mux *http.ServeMux // 启动时未显式配置 token 则自动生成(与 remotedevice 同策略) @@ -85,6 +89,16 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { DisplayName: "访问令牌", Category: "kbtree", Description: "外部 agent 访问本服务所需的令牌;留空则启动时随机生成(仅本次运行有效)", }) + set.RegisterDef(sdk.ConfigDef{ + Key: "expose_categories", Default: "", Type: "string", + DisplayName: "暴露范围", Category: "kbtree", + Description: "逗号分隔的分类路径,只暴露这些分类及其子树(如 \"public,tech/go\")。留空=全部可见。前缀按路径分段匹配:public 不会匹配 publication。根下无分类的条目在范围非空时不可见。", + }) + if v, _ := set.Get("expose_categories"); v != nil { + if str, ok := v.(string); ok { + p.expose = newScope(str) + } + } if v, _ := set.Get("listen_addr"); v != nil { if a, ok := v.(string); ok && strings.TrimSpace(a) != "" { p.addr = strings.TrimSpace(a) @@ -218,6 +232,9 @@ func (p *Plugin) handleTree(w http.ResponseWriter, r *http.Request) { }) return } + // 范围裁剪在服务端做:裁剪后响应里根本不含范围外条目, + // 客户端无从察觉它们存在。 + p.expose.filterTree(view) writeJSON(w, http.StatusOK, map[string]interface{}{"tree": view}) } @@ -227,7 +244,7 @@ func (p *Plugin) handleCategories(w http.ResponseWriter, r *http.Request) { if kn == nil { return } - names := p.safeCategories(kn) + names := p.expose.filterNames(p.safeCategories(kn)) if names == nil { names = []string{} } @@ -248,17 +265,9 @@ func (p *Plugin) handleCounts(w http.ResponseWriter, r *http.Request) { if counts == nil { counts = []sdk.KnowledgeCategoryCount{} } - // 附总量:只数叶子分类,避免中间层重复计数 - total := 0 - byCat := make(map[string]int, len(counts)) - for _, c := range counts { - byCat[c.Category] = c.Count - } - for c := range byCat { - if !isParentCategory(counts, c) { - total += byCat[c] - } - } + // 附总量:只数叶子分类,避免中间层重复计数(范围过滤后按过滤结果重算, + // 否则 total 会把范围外的条目数也报出去 —— 数量本身也是信息泄露) + counts, total := p.expose.filterCounts(counts) writeJSON(w, http.StatusOK, map[string]interface{}{"counts": counts, "total": total}) } @@ -284,11 +293,19 @@ func (p *Plugin) handleSearch(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } + // ★ 范围过滤必须在这里(服务端)。只在前端/客户端过滤等于没过滤: + // 范围外条目全文已经随响应发出去了。 + // 同时它也修正了 limit 语义 —— 范围外条目不占名额,范围内的 + // 条目不会因为 limit 被范围外条目挤掉而漏掉。 items := make([]map[string]interface{}, 0, len(results)) for _, k := range results { + if !p.expose.allows(k.Category) { + continue + } items = append(items, map[string]interface{}{ - "name": k.Name, - "content": k.Content, + "name": k.Name, + "category": k.Category, + "content": k.Content, }) } writeJSON(w, http.StatusOK, map[string]interface{}{ @@ -301,6 +318,10 @@ func (p *Plugin) handleSearch(w http.ResponseWriter, r *http.Request) { // knowledge 取知识库;不可用时写 503 并返回 nil。 func (p *Plugin) knowledge(w http.ResponseWriter) sdk.KnowledgeAPI { + // kn 是测试注入口,优先于 sdkRef(它在 sdkRef 之前就已经有值了) + if p.kn != nil { + return p.kn + } if p.sdkRef == nil { writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "plugin not started"}) return nil diff --git a/internal/plugins/kbtree/scope.go b/internal/plugins/kbtree/scope.go new file mode 100644 index 0000000..c0324fe --- /dev/null +++ b/internal/plugins/kbtree/scope.go @@ -0,0 +1,140 @@ +package kbtree + +import ( + "strings" + + sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" +) + +// scope 是知识库对外暴露范围。 +// +// 存在的理由:kbtree 是**唯一**把知识库开放给外部进程的通道(HomeAgent +// 自己的 agent 走进程内直调,不经此)。没有范围限制时,任何拿到 token 的 +// agent 都能 /tree 列出全部条目、/search 取回任意条目全文 —— 而本机库里 +// 混着个人内容(教材摘录、课表、身份合并规则),不该 broadly 可读。 +// +// 语义: +// - paths 为空 ⇒ 不限范围(全部可见)。范围是"限制"不是"必填", +// 留空时保持既有行为,避免升级即失效。 +// - 前缀匹配按**路径分段**比较:"public" 命中 "public" 与 "public/tech", +// 但**不**命中 "publication"(否则 publication 这类目录会意外暴露)。 +// - 范围外的条目在**服务端**就被剔除:客户端过滤等于没过滤,范围外内容 +// 已经随响应发出去了。 +type scope struct { + paths []string +} + +// newScope 解析范围配置:逗号 / 空格 / 换行分隔,逐项去空白与首尾斜杠。 +// +// 为何容忍多种分隔符:这是给人在设置页手填的字段,`a, b` 与 `a b` 与 +// 换行粘贴都常见;只认逗号会让"看起来填了却没生效"这种错配很难自查。 +func newScope(raw string) *scope { + fields := strings.FieldsFunc(raw, func(r rune) bool { + return r == ',' || r == ';' || r == '\n' || r == '\r' || + r == ' ' || r == '\t' || r == '/' || r == '|' + }) + paths := make([]string, 0, len(fields)) + for _, f := range fields { + f = strings.Trim(strings.TrimSpace(f), "/") + if f != "" { + paths = append(paths, f) + } + } + return &scope{paths: paths} +} + +// allows 报告某分类(或根 "")是否在暴露范围内。 +// +// 条目级的判定:条目的 Category 为 "" 表示直接挂在根下(无分类), +// 它只在**范围留空**时可见 —— 根下条目没有分类可匹配,若允许它按 +// 任意前缀可见,那范围就形同虚设(根下条目永远逃逸)。 +func (s *scope) allows(category string) bool { + if s == nil || len(s.paths) == 0 { + return true + } + cat := strings.Trim(strings.TrimSpace(category), "/") + if cat == "" { + return false + } + for _, p := range s.paths { + if cat == p || strings.HasPrefix(cat, p+"/") { + return true + } + } + return false +} + +// filterTree 就地裁剪树视图,剔除范围外的分支。 +// +// 就地改而不是重建:TreeView 是内核生成的,字段多(含 Media 切片), +// 重建容易漏字段导致"裁剪后条目信息变空"。 +func (s *scope) filterTree(t *sdk.KnowledgeTreeView) { + if t == nil || s == nil || len(s.paths) == 0 { + return + } + // 根节点恒可见(否则调用方连"有什么可看的"都不知道) + kept := t.Children[:0] + for i := range t.Children { + c := &t.Children[i] + if !s.allows(c.Path) { + continue + } + kept = append(kept, *c) + } + t.Children = kept + // 根下直接挂载的条目:范围非空时不可见(allows("") == false) + // 根下直接挂载的条目:范围非空时不可见(allows("") == false)。 + // 只在**本节点自己**这么做,不能对子树也做 —— 子树节点自己挂的条目 + // 属于该分类,在范围内,必须保留(我第一版无差别清空, + // 结果整棵树只剩空壳节点:item_count 全 0,条目名一个都不剩)。 + if s.allows(t.Path) { + // 保留本节点的条目 + } else { + t.Items = nil + t.ItemCount = 0 + } + t.TotalCount = t.ItemCount + for i := range t.Children { + s.filterTree(&t.Children[i]) + t.TotalCount += t.Children[i].TotalCount + } +} + +// filterNames 过滤分类路径列表。 +func (s *scope) filterNames(names []string) []string { + if s == nil || len(s.paths) == 0 { + return names + } + out := make([]string, 0, len(names)) + for _, n := range names { + if s.allows(n) { + out = append(out, n) + } + } + return out +} + +// filterCounts 过滤分类计数,并按范围重算 total。 +func (s *scope) filterCounts(counts []sdk.KnowledgeCategoryCount) ([]sdk.KnowledgeCategoryCount, int) { + if s == nil || len(s.paths) == 0 { + return counts, totalLeafCount(counts) + } + out := make([]sdk.KnowledgeCategoryCount, 0, len(counts)) + for _, c := range counts { + if s.allows(c.Category) { + out = append(out, c) + } + } + return out, totalLeafCount(out) +} + +// totalLeafCount 只数叶子分类,避免中间层重复计数(与未过滤时的口径一致)。 +func totalLeafCount(counts []sdk.KnowledgeCategoryCount) int { + total := 0 + for _, c := range counts { + if !isParentCategory(counts, c.Category) { + total += c.Count + } + } + return total +} diff --git a/internal/sdk/knowledge_impl.go b/internal/sdk/knowledge_impl.go index bf3bd32..f105668 100644 --- a/internal/sdk/knowledge_impl.go +++ b/internal/sdk/knowledge_impl.go @@ -13,7 +13,7 @@ func (k *knowledgeImpl) Search(query string, topK int) ([]*Knowledge, error) { got := k.ks.Search(query, topK) out := make([]*Knowledge, len(got)) for i, item := range got { - out[i] = &Knowledge{Name: item.Name, Content: item.Content} + out[i] = &Knowledge{Name: item.Name, Content: item.Content, Category: item.Category} } return out, nil } @@ -32,7 +32,7 @@ func (k *knowledgeImpl) SearchIn(query, category string, topK int) ([]*Knowledge got := k.ks.SearchIn(query, category, topK) out := make([]*Knowledge, len(got)) for i, item := range got { - out[i] = &Knowledge{Name: item.Name, Content: item.Content} + out[i] = &Knowledge{Name: item.Name, Content: item.Content, Category: item.Category} } return out, nil } diff --git a/third_party/homeagent-sdk/sdk/knowledge.go b/third_party/homeagent-sdk/sdk/knowledge.go index 4c9d5d7..aaf9f11 100644 --- a/third_party/homeagent-sdk/sdk/knowledge.go +++ b/third_party/homeagent-sdk/sdk/knowledge.go @@ -9,6 +9,14 @@ type KnowledgeAPI interface { // Knowledge represents a knowledge entry. type Knowledge struct { - Name string `json:"name"` - Content string `json:"content"` + Name string `json:"name"` + // Category 是该条目的父分类路径(如 "tech/go"),根下条目为空。 + // + // 为何加这个字段:对外服务(kbtree)要做**暴露范围过滤**就必须知道 + // 每条结果属于哪个分类 —— 过滤只能发生在服务端(客户端过滤等于 + // 没过滤,范围外内容已经随响应发出去了)。 + // 之前这里只有 Name/Content,内核明明返回了 Category 却在 + // knowledge_impl.SearchIn 的拷贝里丢掉,导致外部无法按分类判定。 + Category string `json:"category,omitempty"` + Content string `json:"content"` }