feat(knowledge): 分类过滤与多模态打通到工具/插件边界

- internal/sdk:KnowledgeAPI 增加 SearchIn/AddWithMedia/AttachMedia/
  ReindexDense/DenseStats;新增 MediaAPI(Put/Stat/Get)与 PluginSDK.Media(),
  registry 装配。**公开 SDK 契约一字未动** —— third_party/homeagent-sdk/sdk/
  的 diff 恒为 0(发布纪律的硬约束),走 internal/sdk 这条明确不受冻结约束
  的内部扩展路径。
- proc:新增 knowledge.addWithMedia(正文与媒体清单都走共享内存,与
  doc.insertWithMedia 同形);knowledge.search 支持 category。
- corehandler 用**局部接口 + 类型断言**取扩展能力,而非直接引 internal/sdk:
  后者已依赖 internal/plugin 的类型,直接引会成环(CoreSDK 注释已警示)。
  断言失败明确报"能力不可用",不静默退化成"媒体已写入"。
- agent 工具面:knowledge_search 增加 category;knowledge_create 增加
  media_digests(复用 doc_commit 的 digest 前缀解析 + Stat 回读 MIME)。
  顺带修 knowledge_search 的分类前缀重复(Name 已含分类,旧代码又拼一次,
  实测输出 tech/go/tech/go/并发)。
- agent 启动接线多模态空间,顺序为先接线再 ReindexDense(否则首次启动
  算出的向量因 MediaStore 未就绪而不落盘)。
This commit is contained in:
JianFeeeee
2026-09-26 11:36:55 +08:00
parent 9f2ec31cb0
commit 144564f5c2
13 changed files with 404 additions and 24 deletions

View File

@ -126,9 +126,10 @@ var methodCapability = map[string]Capability{
MethodDocInsertMedia: CapDocMemory,
// ---- 知识库 ----
MethodKnowledgeSearch: CapKnowledge,
MethodKnowledgeAdd: CapKnowledge,
MethodKnowledgeList: CapKnowledge,
MethodKnowledgeSearch: CapKnowledge,
MethodKnowledgeAdd: CapKnowledge,
MethodKnowledgeAddMedia: CapKnowledge,
MethodKnowledgeList: CapKnowledge,
// ---- 文本记忆 ----
MethodTextMemoryAppend: CapTextMemory,

View File

@ -166,7 +166,7 @@ func (h *coreHandler) Handle(method string, params json.RawMessage) (interface{}
MethodDocStats:
return h.handleDocMemory(method, params)
case MethodKnowledgeSearch, MethodKnowledgeAdd, MethodKnowledgeList:
case MethodKnowledgeSearch, MethodKnowledgeAdd, MethodKnowledgeAddMedia, MethodKnowledgeList:
return h.handleKnowledge(method, params)
case MethodTextMemoryAppend:

View File

@ -3,6 +3,7 @@ package proc
import (
"encoding/json"
"fmt"
"gitcode.com/JianFeeeee/HomeAgent/internal/knowledge"
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
@ -209,6 +210,16 @@ func (h *coreHandler) handleDocMemory(method string, params json.RawMessage) (in
return nil, fmt.Errorf("未知 method: %s", method)
}
// knowledgeMediaAdder 是 knowledge.addWithMedia 需要的扩展能力。
//
// 定义为局部接口而非直接依赖 internal/sdk.KnowledgeAPI:那样会让
// internal/plugin/proc → internal/sdk,而后者已依赖 internal/plugin 的类型,
// 形成循环(CoreSDK 的注释已说明这一点)。断言失败时返回"能力不可用",
// 而不是静默退化成不写媒体——后者会让调用方以为媒体已入库。
type knowledgeMediaAdder interface {
AddWithMedia(name, content string, media []knowledge.KnowledgeMediaRef) error
}
// handleKnowledge 处理知识库:search / add / list。
//
// 本函数体是 corehandler.go 里 Handle 那一个大 switch 的**整块平移**:
@ -222,12 +233,30 @@ func (h *coreHandler) handleKnowledge(method string, params json.RawMessage) (in
return nil, errUnavailable("knowledge")
}
var p struct {
Query string `json:"query"`
TopK int `json:"top_k"`
Query string `json:"query"`
TopK int `json:"top_k"`
Category string `json:"category,omitempty"`
}
if err := unmarshal(params, &p); err != nil {
return nil, err
}
// 分类限定是内核侧扩展能力(见 internal/sdk/knowledge.go),
// 用局部接口断言取用;能力缺失时退回全库搜索而不是报错——
// 那只是"少了个筛选条件",不是调用失败。
if p.Category != "" {
if scoped, ok := kn.(interface {
SearchIn(query, category string, topK int) ([]*pubsdk.Knowledge, error)
}); ok {
results, err := scoped.SearchIn(p.Query, p.Category, p.TopK)
if err != nil {
return nil, err
}
if results == nil {
results = []*pubsdk.Knowledge{}
}
return map[string]interface{}{"results": results}, nil
}
}
results, err := kn.Search(p.Query, p.TopK)
if err != nil {
return nil, err
@ -257,6 +286,41 @@ func (h *coreHandler) handleKnowledge(method string, params json.RawMessage) (in
}
return nil, kn.Add(p.Name, p.Content)
case MethodKnowledgeAddMedia:
kn := h.sdk.Knowledge()
if kn == nil {
return nil, errUnavailable("knowledge")
}
var p struct {
Name string `json:"name"`
Content string `json:"content,omitempty"`
Media []knowledge.KnowledgeMediaRef `json:"media,omitempty"`
ContentRef SharedRef `json:"content_ref,omitempty"`
MediaRef SharedRef `json:"media_ref,omitempty"`
}
if err := unmarshal(params, &p); err != nil {
return nil, err
}
// 正文与媒体清单都可能很大,优先走共享内存(与 knowledge.add 同形)。
if err := h.resolveJSONRef(p.ContentRef, &p.Content); err != nil {
return nil, err
}
if err := h.resolveJSONRef(p.MediaRef, &p.Media); err != nil {
return nil, err
}
// 多模态是内核侧扩展能力(公开 SDK 契约不含它,见 internal/sdk/knowledge.go)。
// 这里用局部接口 + 类型断言取用,而不把 internal/sdk 拉进本包:
// 后者已依赖 internal/plugin,直接引会成环(见 corehandler.go 顶部注释)。
adder, ok := kn.(knowledgeMediaAdder)
if !ok {
return nil, errUnavailable("knowledge multimodal")
}
if err := adder.AddWithMedia(p.Name, p.Content, p.Media); err != nil {
return nil, err
}
// 回传媒体清单:插件后续要按 digest 引用同一份媒体。
return map[string]interface{}{"media": p.Media}, nil
case MethodKnowledgeList:
kn := h.sdk.Knowledge()
if kn == nil {

View File

@ -3,6 +3,7 @@ package proc
import (
"encoding/json"
"fmt"
"gitcode.com/JianFeeeee/HomeAgent/internal/knowledge"
"strings"
"sync"
"testing"
@ -34,8 +35,17 @@ type fakeCoreSDK struct {
// toolBlocks 累积 SetToolBlocks 收到的块(多模态注入通道)。
toolBlocks []pubsdk.ContentBlock
// 文档/知识:验证大正文经 doc_ref / content_ref 走共享内存。
docMem *fakeDocMemory
knowledge *fakeKnowledge
docMem *fakeDocMemory
// 用接口而非具体类型:需要能塞入"只实现公开 KnowledgeAPI、
// 不具备内核多模态扩展"的替身,以验证能力缺失时的报错路径。
knowledge knowledgeAPITest
}
// knowledgeAPITest 是公开 SDK 的知识库契约(不含内核扩展方法)。
type knowledgeAPITest interface {
Search(query string, topK int) ([]*pubsdk.Knowledge, error)
Add(name, content string) error
List() ([]string, error)
}
func newFakeCore() *fakeCoreSDK {
@ -148,9 +158,18 @@ func (f *fakeDocMemory) Stats() map[string]interface{} { return nil }
// fakeKnowledge 只实现测试需要的部分,记录 Add 收到的正文。
type fakeKnowledge struct {
mu sync.Mutex
name string
body string
mu sync.Mutex
name string
body string
media []knowledge.KnowledgeMediaRef
}
// AddWithMedia 模拟内核的扩展能力(corehandler 用局部接口断言它)。
func (f *fakeKnowledge) AddWithMedia(name, content string, media []knowledge.KnowledgeMediaRef) error {
f.mu.Lock()
f.name, f.body, f.media = name, content, media
f.mu.Unlock()
return nil
}
func (f *fakeKnowledge) Search(string, int) ([]*pubsdk.Knowledge, error) { return nil, nil }
@ -901,3 +920,77 @@ func TestPlugin_ToolInvokeArgsResultViaArena(t *testing.T) {
}
})
}
// §13.14:knowledge.addWithMedia 走共享内存,且媒体清单能穿透到内核扩展能力。
//
// 媒体参数可能很长(一篇知识挂几十张图),与正文同形走 content_ref/media_ref。
func TestCoreHandler_KnowledgeAddWithMedia(t *testing.T) {
host, err := NewHost()
if err != nil {
t.Fatalf("NewHost: %v", err)
}
defer host.Close()
core := newFakeCore()
kn := &fakeKnowledge{}
core.knowledge = kn
h := &coreHandler{sdk: core, name: "x", host: host, locks: &lockRegistry{}}
content := strings.Repeat("带图知识", 2000)
blob, _ := json.Marshal(content)
cRef := arenaPutForTest(t, host, blob)
defer func() { _ = host.Arena().Free(OwnerHost, cRef) }()
media := []knowledge.KnowledgeMediaRef{{Digest: "d1", MIME: "image/png", Kind: "image"}}
mBlob, _ := json.Marshal(media)
mRef := arenaPutForTest(t, host, mBlob)
defer func() { _ = host.Arena().Free(OwnerHost, mRef) }()
params, _ := json.Marshal(map[string]interface{}{
"name": "n", "content_ref": cRef, "media_ref": mRef,
})
res, err := h.Handle(MethodKnowledgeAddMedia, params)
if err != nil {
t.Fatalf("knowledge.addWithMedia 应成功: %v", err)
}
kn.mu.Lock()
got, gotName := kn.body, kn.name
gotMedia := kn.media
kn.mu.Unlock()
if gotName != "n" {
t.Fatalf("name 传错: %q", gotName)
}
if got != content {
t.Fatalf("经共享内存送达的正文不一致(got len=%d want len=%d)", len(got), len(content))
}
if len(gotMedia) != 1 || gotMedia[0].Digest != "d1" {
t.Fatalf("媒体清单未穿透到内核: %+v", gotMedia)
}
// 回传媒体清单(插件据此后续引用同一份媒体)
m, _ := res.(map[string]interface{})
if m == nil || m["media"] == nil {
t.Errorf("应回传 media 清单,实为 %#v", res)
}
}
// 内核不支持多模态扩展时必须**明确报错**,不能静默退化成"媒体已写入"。
func TestCoreHandler_KnowledgeAddMediaCapabilityMissing(t *testing.T) {
core := newFakeCore()
// 用一个只实现公开 KnowledgeAPI 的假实现(无 AddWithMedia)
core.knowledge = &pubOnlyKnowledge{}
h := &coreHandler{sdk: core, name: "x", locks: &lockRegistry{}}
params := json.RawMessage(`{"name":"n","content":"正文","media":[{"digest":"d1","mime":"image/png"}]}`)
if _, err := h.Handle(MethodKnowledgeAddMedia, params); err == nil {
t.Error("内核缺少多模态能力时应明确报错,而不是静默丢弃媒体")
}
}
// pubOnlyKnowledge 只实现公开 SDK 的 KnowledgeAPI(**刻意不含**内核扩展的
// AddWithMedia)。不能内嵌 fakeKnowledge——那会把 AddWithMedia 一起带进来,
// 断言就会成功,用例测不到「能力缺失」这条路径。
type pubOnlyKnowledge struct{}
func (f *pubOnlyKnowledge) Search(string, int) ([]*pubsdk.Knowledge, error) { return nil, nil }
func (f *pubOnlyKnowledge) Add(name, content string) error { return nil }
func (f *pubOnlyKnowledge) List() ([]string, error) { return nil, nil }

View File

@ -128,6 +128,10 @@ const (
MethodKnowledgeSearch = "knowledge.search" // 15
MethodKnowledgeAdd = "knowledge.add" // 35
MethodKnowledgeList = "knowledge.list" // 36
// MethodKnowledgeAddMedia 写入知识并关联媒体(与 doc.insertWithMedia
// 同形)。媒体成为一等节点参与跨模态召回;未接入多模态空间时与
// knowledge.add 等价。
MethodKnowledgeAddMedia = "knowledge.addWithMedia"
// 文本记忆(原 case 41)
MethodTextMemoryAppend = "textmemory.append" // 41

View File

@ -352,6 +352,7 @@ func (r *Registry) buildSDK(name string) *sdk.PluginSDK {
TextMemory: sdk.NewTextMemoryWithMedia(name, r.textMem, r.mediaStore),
DocMemory: sdk.NewDocMemoryWithMedia(name, r.docStore, r.mediaStore),
Knowledge: sdk.NewKnowledge(r.ks),
Media: sdk.NewMedia(r.mediaStore),
LLM: sdk.NewLLM(r.mgr, r.cfgReg, r.lua, r.baseKey),
Settings: sett,
RegTool: regTool,