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

@ -335,6 +335,22 @@ func New(cfg AgentConfig) *Agent {
cfg.DocStore.SetDenseSpace(cfg.MultimodalSpace)
cfg.DocStore.BuildDenseIndex(cfg.MultimodalSpace)
}
// 知识库接入同一多模态空间:媒体作为一等节点参与稠密召回,
// 于是「按图搜知识」「按文搜含图知识」成立。
//
// 稀疏两路(词向量 + TF-IDF)**保持启用**且仍是主召回路径:多模态
// 空间未配置时知识库行为与此前逐字一致(退化为 0.5/0.5 两路融合)。
if cfg.Knowledge != nil {
// 顺序要紧:先接线(含 MediaStore),再重建。ReindexDense 会
// 尝试从 .dense.json 缓存恢复,恢复不了才重算,最后把结果落盘。
// 若先重建后接线,首次启动算出的向量会被丢掉而不落盘。
cfg.Knowledge.SetDenseSpace(cfg.MultimodalSpace)
cfg.Knowledge.SetMediaGetter(cfg.MediaStore)
built, skipped := cfg.Knowledge.ReindexDense()
if built > 0 || skipped > 0 {
log.Printf("[knowledge] 多模态稠密索引: 新建 %d 跳过 %d(其余命中缓存)", built, skipped)
}
}
}
a := &Agent{

View File

@ -8,6 +8,7 @@ import (
"time"
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
"gitcode.com/JianFeeeee/HomeAgent/internal/knowledge"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/text"
@ -536,6 +537,30 @@ func (a *Agent) executeSocialTool(tc agentAPI.ToolCall) string {
}
}
// knowledgeMediaRefs 把模型给的 digest 列表解析成知识条目的媒体引用。
//
// 复用 doc_commit 的既有约定:digest 可传前缀(ResolvePrefix),解析不了
// 的跳过而不是报错——模型偶尔会把 digest 记错,不该让整次写入失败。
// MIME 从媒体存储回读,嵌入时需要(EmbedImageDense 靠它判定模态)。
func (a *Agent) knowledgeMediaRefs(digests []string) []knowledge.KnowledgeMediaRef {
if a.mediaStore == nil || len(digests) == 0 {
return nil
}
var out []knowledge.KnowledgeMediaRef
for _, d := range a.resolveMediaDigests(digests) {
it, err := a.mediaStore.Stat(d)
if err != nil {
continue
}
out = append(out, knowledge.KnowledgeMediaRef{
Digest: it.Digest,
MIME: it.MIME,
Kind: string(it.Kind),
})
}
return out
}
func (a *Agent) executeKnowledgeTool(tc agentAPI.ToolCall) string {
if a.knowledge == nil {
return "知识库不可用"
@ -550,7 +575,10 @@ func (a *Agent) executeKnowledgeTool(tc agentAPI.ToolCall) string {
if query == "" {
return "请输入查询关键词"
}
results := a.knowledge.Search(query, topK)
// 可选分类限定:把召回限制在某棵分类子树内(前缀匹配,见
// knowledge.Store.SearchIn)。不传 = 全库。
category, _ := tc.Arguments["category"].(string)
results := a.knowledge.SearchIn(query, category, topK)
if len(results) == 0 {
return "未找到相关知识"
}
@ -559,11 +587,9 @@ func (a *Agent) executeKnowledgeTool(tc agentAPI.ToolCall) string {
if i >= topK {
break
}
label := k.Name
if k.Category != "" {
label = k.Category + "/" + k.Name
}
parts = append(parts, fmt.Sprintf("[%s]\n%s", label, truncateStr(k.Content, 200)))
// Name 已是含分类的规范名("tech/go/并发"),分类前缀就在里面。
// 曾经这里再拼一次 Category,输出成 "tech/go/tech/go/并发"(实测)。
parts = append(parts, fmt.Sprintf("[%s]\n%s", k.Name, truncateStr(k.Content, 200)))
}
return strings.Join(parts, "\n---\n")
@ -573,9 +599,16 @@ func (a *Agent) executeKnowledgeTool(tc agentAPI.ToolCall) string {
if name == "" || content == "" {
return "name 和 content 不能为空"
}
if err := a.knowledge.Add(name, content); err != nil {
// 模型可显式关联已入库的媒体(与 doc_commit 的 media_digests 同形)。
// 这些媒体成为知识条目的一等节点:其向量会与正文向量融合,
// 使该条目能按图本身被召回,而不依赖任何生成的描述文本。
media := a.knowledgeMediaRefs(getStringSlice(tc.Arguments, "media_digests"))
if err := a.knowledge.AddWithMedia(name, content, media); err != nil {
return fmt.Sprintf("知识创建失败: %v", err)
}
if len(media) > 0 {
return fmt.Sprintf("知识「%s」已创建并向量化索引(%d 字符,%d 个媒体参与跨模态召回)", name, len(content), len(media))
}
return fmt.Sprintf("知识「%s」已创建并向量化索引(%d 字符)", name, len(content))
case "knowledge_list":

View File

@ -373,17 +373,23 @@ func (a *Agent) buildToolDefs() []interface{} {
}
if a.knowledge != nil {
tools = append(tools, toolDef("knowledge_search", "搜索知识库。输入查询关键词,返回相关知识内容。", map[string]interface{}{
"query": map[string]interface{}{"type": "string", "description": "查询关键词"},
"top_k": map[string]interface{}{"type": "integer", "description": "返回数量", "default": 5},
tools = append(tools, toolDef("knowledge_search", "搜索知识库。输入查询关键词,返回相关知识内容。可用 category 把搜索限定在某个分类子树内。", map[string]interface{}{
"query": map[string]interface{}{"type": "string", "description": "查询关键词"},
"top_k": map[string]interface{}{"type": "integer", "description": "返回数量", "default": 5},
"category": map[string]interface{}{"type": "string", "description": "可选:限定在某个分类内(前缀匹配子树,如 tech 会搜 tech/go、tech/rust)。留空则搜全库"},
}, "query"))
tools = append(tools, toolDef("knowledge_list", "列出知识库中所有知识分类。", map[string]interface{}{}))
}
if a.knowledge != nil {
tools = append(tools, toolDef("knowledge_create", "创建新知识。将知识写入知识库(knowledge/目录),自动向量化索引。", map[string]interface{}{
tools = append(tools, toolDef("knowledge_create", "创建新知识。将知识写入知识库(knowledge/目录),自动向量化索引。可关联已入库媒体(附图/音视频)使该知识能被图本身检索到。", map[string]interface{}{
"name": map[string]interface{}{"type": "string", "description": "知识名称(用作目录名)"},
"content": map[string]interface{}{"type": "string", "description": "知识内容,支持 Markdown"},
"media_digests": map[string]interface{}{
"type": "array",
"items": map[string]interface{}{"type": "string"},
"description": "可选:关联的媒体 digest(可传前缀)。媒体作为一等节点参与跨模态检索——知识能按图本身被搜到,而不依赖生成的描述文本",
},
}, "name", "content"))
tools = append(tools, toolDef("knowledge_delete", "删除知识库中的指定知识条目。", map[string]interface{}{
"name": map[string]interface{}{"type": "string", "description": "要删除的知识名称"},