From 28fc833a6f6fae6b17d21b3e8430201e376eb579 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Fri, 4 Sep 2026 22:52:30 +0800 Subject: [PATCH] =?UTF-8?q?feat(memory):=20L3=20=E5=9B=BE=E5=BA=93?= =?UTF-8?q?=E5=AA=92=E4=BD=93=E5=8F=8D=E6=9F=A5=20+=20=E4=BF=AE=20L2?= =?UTF-8?q?=E2=86=92L3=20=E5=BC=95=E7=94=A8=E6=B3=84=E6=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 媒体记忆四层收尾。方案 A:只做引用,不建媒体实体节点。 ## 为何不把媒体建成图库实体 图库里的实体与关系全部来自**描述文本**的 NLP 提取——描述经 mediaSummaryForEvent 进 L0 事件的 Input,随归档进 L2 文档的 Content, 蒸馏时提取器自然从描述文字里抽出实体和关系。检索能力已经具备。 若再把媒体本身建成节点,节点名只能从描述里取,而描述会被重新生成 (换个视觉模型、补一次描述,名字就变了),于是同一张图会在图谱上留下 多个语义模糊的节点。代价换不来能力。 所以这一层只做一件事:**反查**。图库句子写着「[image a1b2c3d4e5f6] 一张紫蓝红三色带图」,要能从这条句子取回那份字节。 ## CommitWithMedia:新增方法而非改签名 Commit 有 10 个非测试调用点 + 21 个测试调用点。为一个多数调用方都不需要 的返回值改全部签名不划算。新增 CommitWithMedia 返回 map[句子文本]sentences.id,Commit 内部转调同一份落库逻辑。 ## digest 靠正则从文本反解 三元组由 NLP 提取器从纯文本产出(nlp.ToMemoryTriple 只填 Subject/ Relation/Object/Confidence/SentenceText),提取链路上没有任何位置能塞进 结构化的 digest。要贯通就得改 internal/nlp 的整条数据流。而媒体标记本身 是我们自己按固定格式写进文本的,反解是最省的可靠做法。 配套加 media.ResolvePrefix:文本里是 12 位短 digest(完整 64 位会把一行 撑爆且无助人眼辨认),media_refs 主键要完整 digest。 **前缀歧义视为错误而非"取第一个"**:挂错引用会让 GC 删掉仍被引用的内容。 完整但不存在的 digest 也报错,否则调用方会挂一条孤儿引用。 ## 顺带修掉 L2→L3 的引用泄漏 这是上一层(f855893)留下的缺口:我当时只处理了 L0→L2 的引用转移, 漏了 L2→L3 这一跳。archiveColdDocs 调 docStore.Remove(doc.ID) 时不注销 媒体引用——文档一旦消失就再没有任何东西能告诉我们它引用过哪些 digest, media_refs 里那条记录永久悬空、引用计数永不归零,对应 blob 永远不会被 GC 回收。 新增 releaseDocMedia。L2→L3 这一跳是**释放**而非转移,因为图库存的是从 描述文本抽出的实体与关系,不再持有字节;媒体此时已完成使命。 顺序有讲究:必须在 commitTriplesWithMedia 之后释放。那一步已把引用挂到 graph_sentence owner 上,先销后挂会让引用计数瞬时归零,此时若后台 GC 正在跑就会把内容当孤儿清掉。 ## 顺带修 Pending 的排除逻辑遗漏(承上一提交) ## 测试 graphmedia_test.go 11 例。核心是 TestBindSentenceMedia_RoundTrip: 写入 → 提交 → 从句子 id 反查 digest → 取回字节逐字节比对 → 跑 GC(0) 确认被引用的内容不被清。 其余覆盖:正则不误命中普通方括号([注意]/[TODO] 不能当 digest,否则会拿 假前缀去 ResolvePrefix)、无法补全的 digest 不挂引用、媒体关闭时全链路 静默 no-op、releaseDocMedia 释放后 GC 真能回收、200 个样本的前缀补全 要么唯一命中要么明确报歧义。 TestCommit_StillWorksAfterRefactor 记录一个既有行为:重复提交时 entitiesCreated 不归零,因为 SQLite 的 ON CONFLICT DO UPDATE 也算一行 affected。用 main 分支的 graph.go 单独跑过基线确认与本次重构无关, 该字段只用于日志,故记录现状不改行为。 全仓 go build / go vet / go test 通过,internal/agent/core 与 internal/memory 全部 -race -count=2 通过,SDK 冻结 diff = 0。 --- internal/agent/core/distill.go | 31 ++- internal/agent/core/graphmedia.go | 175 ++++++++++++ internal/agent/core/graphmedia_test.go | 357 +++++++++++++++++++++++++ internal/agent/core/toolcall.go | 10 +- internal/memory/graph.go | 62 +++-- internal/memory/media/media.go | 56 ++++ 6 files changed, 667 insertions(+), 24 deletions(-) create mode 100644 internal/agent/core/graphmedia.go create mode 100644 internal/agent/core/graphmedia_test.go diff --git a/internal/agent/core/distill.go b/internal/agent/core/distill.go index b7da8b9..09979df 100644 --- a/internal/agent/core/distill.go +++ b/internal/agent/core/distill.go @@ -10,6 +10,7 @@ import ( agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io" "gitcode.com/JianFeeeee/HomeAgent/internal/memory" "gitcode.com/JianFeeeee/HomeAgent/internal/memory/document" + "gitcode.com/JianFeeeee/HomeAgent/internal/memory/media" "gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector" "gitcode.com/JianFeeeee/HomeAgent/internal/nlp" ) @@ -181,18 +182,46 @@ func (a *Agent) archiveColdDocs() { for _, doc := range coldDocs { triples := docToTriples(doc, a.embedder) if len(triples) > 0 { - ec, rc, err := a.memory.Commit(triples, string(a.id)+"_doc_archival", 0) + ec, rc, err := a.commitTriplesWithMedia(triples, string(a.id)+"_doc_archival", 0) if err != nil { log.Printf("[agent] doc→graph archival error: %v", err) continue } log.Printf("[agent] doc→graph: %s → %d entities, %d relations", doc.ID, ec, rc) + // 先销媒体引用再删文档:文档一旦从 docStore 消失, + // 就再没有任何东西能告诉我们它曾经引用过哪些 digest, + // media_refs 里那条记录就永久悬空、引用计数永不归零, + // 导致对应 blob 永远不会被 GC 回收。 + // + // 且必须在 commitTriplesWithMedia 之后:那一步已经把引用 + // 挂到了 graph_sentence owner 上。先销后挂会让引用计数瞬时 + // 归零,此时若后台 GC 正在跑就会把内容当孤儿清掉。 + a.releaseDocMedia(doc.ID) a.docStore.Remove(doc.ID) } } } } +// releaseDocMedia 注销文档持有的全部媒体引用。 +// +// L2→L3 这一跳不再转移引用而是直接释放,因为图库存的是从描述 +// 文本里抽出的实体与关系,不再持有字节。媒体本身此时已完成使命: +// 描述已经进了图库,blob 可以交给容量 GC 决定去留。 +func (a *Agent) releaseDocMedia(docID string) { + if a.mediaStore == nil || docID == "" { + return + } + n, err := a.mediaStore.DropOwner(media.OwnerDocument, docID) + if err != nil { + log.Printf("[media] 文档归档释放引用失败 (doc %s): %v", docID, err) + return + } + if n > 0 { + log.Printf("[media] 文档 %s 入图库,释放 %d 个媒体引用(描述已留在图库)", docID, n) + } +} + // ────────────────────────────────────────────── // 实体合并检测:GraphDB → LLM 裁决 // ────────────────────────────────────────────── diff --git a/internal/agent/core/graphmedia.go b/internal/agent/core/graphmedia.go new file mode 100644 index 0000000..df113d1 --- /dev/null +++ b/internal/agent/core/graphmedia.go @@ -0,0 +1,175 @@ +package core + +import ( + "fmt" + "log" + "regexp" + "strconv" + "strings" + + "gitcode.com/JianFeeeee/HomeAgent/internal/memory" + "gitcode.com/JianFeeeee/HomeAgent/internal/memory/media" +) + +// L3 图库的媒体引用绑定。 +// +// 设计定位(方案 A:只做引用,不建媒体实体节点): +// 图库里的实体与关系全部来自**描述文本**的 NLP 提取——媒体描述经 +// mediaSummaryForEvent 进了 L0 事件的 Input,随归档进 L2 文档的 Content, +// 蒸馏时提取器自然会从描述文字里抽出实体和关系。 +// +// 为何不把媒体本身建成实体节点:节点名只能从描述里取,而描述会被重新生成 +// (换个视觉模型、补一次描述,名字就变了),于是同一张图会在图谱上留下 +// 多个语义模糊的节点。检索能力靠描述文本已经具备,多这类节点只是噪声。 +// +// 那么图库侧还需要什么:**反查**。图库里的句子写着「[image a1b2c3d4e5f6] +// 一张紫蓝红三色带图」,要能从这条句子找回那份字节。这就是 +// media_refs 的 graph_sentence owner 的用途,也是这一层唯一要做的事。 + +// mediaDigestPattern 匹配事件摘要里的媒体标记 [ <短digest>]。 +// +// 与 mediaSummaryForEvent 的输出格式对应。短 digest 是 12 位十六进制 +// (shortDigest 的截断长度),这里放宽到 8-64 位以容忍将来调整截断长度, +// 以及有人手写了完整 digest 的情况。 +var mediaDigestPattern = regexp.MustCompile(`\[[^\[\]]*?\b([0-9a-f]{8,64})\]`) + +// extractMediaDigests 从文本里找出所有媒体标记的 digest。 +// +// 为何靠正则从文本反解,而不是让三元组结构携带 digest:三元组是 NLP +// 提取器从纯文本产出的(nlp.ToMemoryTriple 只填 Subject/Relation/Object/ +// Confidence/SentenceText),提取链路上没有任何位置能塞进结构化的 digest。 +// 若要贯通就得改 internal/nlp 的整条数据流——而媒体标记本身就是我们 +// 自己按固定格式写进文本的,反解是这里最省的可靠做法。 +func extractMediaDigests(text string) []string { + if text == "" { + return nil + } + matches := mediaDigestPattern.FindAllStringSubmatch(text, -1) + if len(matches) == 0 { + return nil + } + seen := make(map[string]bool, len(matches)) + var out []string + for _, m := range matches { + d := m[1] + if seen[d] { + continue + } + seen[d] = true + out = append(out, d) + } + return out +} + +// bindSentenceMedia 把句子文本里提到的媒体挂到对应的 sentences.id 上。 +// +// sentenceIDs 来自 GraphDB.CommitWithMedia:句子文本 → sentences.id。 +// 只处理本次真正写入了 sentences 表的句子,避免给历史句子重复挂引用 +// (AddRef 幂等,重复挂不会涨计数,但白跑 SQL)。 +func (a *Agent) bindSentenceMedia(sentenceIDs map[string]int64) { + if a.mediaStore == nil || len(sentenceIDs) == 0 { + return + } + + bound := 0 + for text, sid := range sentenceIDs { + if sid == 0 { + continue + } + digests := extractMediaDigests(text) + if len(digests) == 0 { + continue + } + ownerID := strconv.FormatInt(sid, 10) + for _, short := range digests { + // 文本里是短 digest,media_refs 的主键要完整 digest。 + // 补全失败(内容已被 GC 清掉、或前缀有歧义)就跳过—— + // 挂一条对不上的引用比不挂更糟:DropOwner 永远匹配不到它。 + full, err := a.mediaStore.ResolvePrefix(short) + if err != nil { + continue + } + if err := a.mediaStore.AddRef(full, media.OwnerGraphSentence, ownerID); err != nil { + log.Printf("[media] 句子引用绑定失败 (%s → sentence %s): %v", short, ownerID, err) + continue + } + bound++ + } + } + if bound > 0 { + log.Printf("[media] L3 图库绑定 %d 个媒体引用", bound) + } +} + +// commitTriplesWithMedia 提交三元组并绑定句子里的媒体引用。 +// +// 包一层是为了让所有「三元组入库」的调用点用同一条路径拿到媒体绑定, +// 而不必各自记得多调一次 bindSentenceMedia。 +func (a *Agent) commitTriplesWithMedia(triples []memory.Triple, sessionID string, turnID int) (int, int, error) { + if a.memory == nil { + return 0, 0, fmt.Errorf("graph memory 未启用") + } + // 媒体存储关闭时退回普通 Commit,省掉 sentenceIDs 的 map 分配。 + if a.mediaStore == nil { + return a.memory.Commit(triples, sessionID, turnID) + } + sentenceIDs, ec, rc, err := a.memory.CommitWithMedia(triples, sessionID, turnID) + if err != nil { + return ec, rc, err + } + a.bindSentenceMedia(sentenceIDs) + return ec, rc, nil +} + +// RecallMediaForSentence 反查某条图库句子引用的媒体。 +// +// 这是整层的目的:几个月后从图谱走到一条句子,要能取回当时那份字节 +// (若尚未被容量 GC 淘汰)。返回的是完整 digest,调用方用 +// mediaStore.Get 取内容、Stat 取描述与元数据。 +func (a *Agent) RecallMediaForSentence(sentenceID int64) ([]string, error) { + if a.mediaStore == nil { + return nil, nil + } + return a.mediaStore.Refs(media.OwnerGraphSentence, strconv.FormatInt(sentenceID, 10)) +} + +// mediaContextForSentences 给一组句子附上媒体说明,供召回时拼进提示词。 +// +// 输出形如「句子 #12 关联媒体:[image/png a1b2c3d4e5f6] 一张紫蓝红三色带图」。 +// 描述文本本就在句子里,这里补的是「内容是否还在、能否重新看图」这个信息—— +// 描述永存而字节可能已被淘汰,两者状态不同。 +func (a *Agent) mediaContextForSentences(sentenceIDs []int64) string { + if a.mediaStore == nil || len(sentenceIDs) == 0 { + return "" + } + var lines []string + for _, sid := range sentenceIDs { + digests, err := a.mediaStore.Refs(media.OwnerGraphSentence, strconv.FormatInt(sid, 10)) + if err != nil || len(digests) == 0 { + continue + } + var parts []string + for _, d := range digests { + it, err := a.mediaStore.Stat(d) + if err != nil || it == nil { + continue + } + label := string(it.Kind) + if it.MIME != "" { + label = it.MIME + } + desc := it.Description + if desc == "" { + desc = "(未描述)" + } + parts = append(parts, fmt.Sprintf("[%s %s] %s", label, shortDigest(d), desc)) + } + if len(parts) > 0 { + lines = append(lines, fmt.Sprintf("句子 #%d 关联媒体:%s", sid, strings.Join(parts, ";"))) + } + } + if len(lines) == 0 { + return "" + } + return strings.Join(lines, "\n") +} diff --git a/internal/agent/core/graphmedia_test.go b/internal/agent/core/graphmedia_test.go new file mode 100644 index 0000000..553105a --- /dev/null +++ b/internal/agent/core/graphmedia_test.go @@ -0,0 +1,357 @@ +package core + +import ( + "path/filepath" + "strconv" + "testing" + + "gitcode.com/JianFeeeee/HomeAgent/internal/memory" + "gitcode.com/JianFeeeee/HomeAgent/internal/memory/media" +) + +// L3 图库媒体引用测试。 +// +// 这一层的目的只有一个:几个月后从图谱走到一条句子,要能取回当时那份字节。 +// 因此测试的重点是「反查链路是否完整」以及「引用是否会悬空或误删」。 + +func newGraphMediaAgent(t *testing.T) (*Agent, *memory.GraphDB, *media.Store) { + t.Helper() + dir := t.TempDir() + + g, err := memory.NewGraphDB(filepath.Join(dir, "graph.db")) + if err != nil { + t.Fatalf("NewGraphDB: %v", err) + } + t.Cleanup(func() { g.Close() }) + + ms, err := media.New(filepath.Join(dir, "media"), 0) + if err != nil { + t.Fatalf("media.New: %v", err) + } + t.Cleanup(func() { ms.Close() }) + + return &Agent{memory: g, mediaStore: ms}, g, ms +} + +func TestExtractMediaDigests(t *testing.T) { + // 与 mediaSummaryForEvent 的输出格式对应 + cases := []struct { + name string + text string + want []string + }{ + {"事件摘要格式", "媒体内容:\n[image/png a1b2c3d4e5f6] 一张紫蓝红三色带图", []string{"a1b2c3d4e5f6"}}, + {"kind 兜底格式", "[image abcdef0123456789] (未描述)", []string{"abcdef0123456789"}}, + {"一句多个", "[image aaaaaaaaaaaa] 图一;[image bbbbbbbbbbbb] 图二", []string{"aaaaaaaaaaaa", "bbbbbbbbbbbb"}}, + {"去重", "[image cccccccccccc] x [image/png cccccccccccc] y", []string{"cccccccccccc"}}, + {"无标记", "普通句子,没有媒体", nil}, + {"空串", "", nil}, + // 非十六进制、过短的方括号内容不能误命中,否则会拿一个假前缀去 ResolvePrefix + {"非 digest 方括号", "[注意] 这是普通标注 [TODO]", nil}, + {"过短", "[image abc] 太短", nil}, + } + + for _, c := range cases { + got := extractMediaDigests(c.text) + if len(got) != len(c.want) { + t.Fatalf("%s: 得到 %v,期望 %v", c.name, got, c.want) + } + for i := range got { + if got[i] != c.want[i] { + t.Fatalf("%s: 第 %d 个得到 %q,期望 %q", c.name, i, got[i], c.want[i]) + } + } + } +} + +func TestCommitWithMedia_ReturnsSentenceIDs(t *testing.T) { + _, g, _ := newGraphMediaAgent(t) + + sentence := "[image/png a1b2c3d4e5f6] 一张紫蓝红三色带图" + triples := []memory.Triple{{ + Subject: "图片", Relation: "内容", Object: "三色带", + SentenceText: sentence, + }} + + ids, ec, rc, err := g.CommitWithMedia(triples, "s1", 0) + if err != nil { + t.Fatal(err) + } + if ec == 0 || rc == 0 { + t.Fatalf("应写入实体与关系,实际 ec=%d rc=%d", ec, rc) + } + if ids[sentence] == 0 { + t.Fatalf("应返回句子 id,实际 %v", ids) + } +} + +func TestCommit_StillWorksAfterRefactor(t *testing.T) { + // Commit 有三十多个调用点,内部转调后行为必须完全不变 + _, g, _ := newGraphMediaAgent(t) + + triples := []memory.Triple{ + {Subject: "张三", Relation: "喜欢", Object: "咖啡", SentenceText: "张三喜欢咖啡"}, + {Subject: "李四", Relation: "住在", Object: "北京"}, + } + ec, rc, err := g.Commit(triples, "s1", 0) + if err != nil { + t.Fatal(err) + } + if ec != 4 || rc != 2 { + t.Fatalf("期望 4 实体 2 关系,实际 ec=%d rc=%d", ec, rc) + } + + // 重复提交同一批:关系被唯一约束去重。 + // + // 实体计数**不**归零——这是 upsertEntity 的既有行为:SQLite 的 + // ON CONFLICT DO UPDATE 也算一行 affected,于是 RowsAffected() > 0 + // 被当成"新建了"。用 main 分支的 graph.go 单独验证过基线同样是 + // 首次 ec=2 / 重复 ec=2,与 CommitWithMedia 重构无关。 + // entitiesCreated 只用于日志,故此处记录现状而不改行为。 + ec2, rc2, err := g.Commit(triples, "s1", 0) + if err != nil { + t.Fatal(err) + } + if rc2 != 0 { + t.Fatalf("重复提交不该新建关系,实际 rc=%d", rc2) + } + if ec2 != 4 { + t.Fatalf("实体计数应与首次一致(既有 upsert 计数行为),实际 ec=%d", ec2) + } +} + +func TestBindSentenceMedia_RoundTrip(t *testing.T) { + // 整层的核心断言:写入 → 提交 → 反查取回原始字节 + a, _, ms := newGraphMediaAgent(t) + + content := []byte("\x89PNG\r\n\x1a\n fake image bytes") + digest, err := ms.Put(content, media.Item{MIME: "image/png", Kind: media.KindImage}) + if err != nil { + t.Fatal(err) + } + short := shortDigest(digest) + + sentence := "[image/png " + short + "] 一张紫蓝红三色带图" + triples := []memory.Triple{{ + Subject: "图片", Relation: "内容", Object: "三色带", SentenceText: sentence, + }} + + if _, _, err := a.commitTriplesWithMedia(triples, "s1", 0); err != nil { + t.Fatal(err) + } + + // 找到句子 id + ids, _, _, err := a.memory.CommitWithMedia(triples, "s1", 0) + if err != nil { + t.Fatal(err) + } + sid := ids[sentence] + if sid == 0 { + t.Fatal("拿不到句子 id") + } + + // 反查:从句子取回 digest,再取回字节 + digests, err := a.RecallMediaForSentence(sid) + if err != nil { + t.Fatal(err) + } + if len(digests) != 1 || digests[0] != digest { + t.Fatalf("反查应得完整 digest %s,实际 %v", shortDigest(digest), digests) + } + got, err := ms.Get(digests[0]) + if err != nil { + t.Fatalf("取回内容失败: %v", err) + } + if string(got) != string(content) { + t.Fatal("取回的内容与写入不一致") + } + + // 引用计数非零 → GC 不会清它 + if _, _, err := ms.GC(0); err != nil { + t.Fatal(err) + } + if _, err := ms.Get(digest); err != nil { + t.Fatalf("被图库句子引用的内容不该被 GC 清掉: %v", err) + } +} + +func TestBindSentenceMedia_SkipsUnresolvable(t *testing.T) { + // 文本里的 digest 在库里不存在时必须跳过,不能挂一条对不上的引用—— + // 那条引用 DropOwner 永远匹配不到,会永久占着计数。 + a, _, ms := newGraphMediaAgent(t) + + sentence := "[image/png deadbeefdead] 一张不存在的图" + ids := map[string]int64{sentence: 42} + a.bindSentenceMedia(ids) + + refs, err := ms.Refs(media.OwnerGraphSentence, "42") + if err != nil { + t.Fatal(err) + } + if len(refs) != 0 { + t.Fatalf("无法补全的 digest 不该挂引用,实际 %v", refs) + } +} + +func TestBindSentenceMedia_NilStoreNoop(t *testing.T) { + a := &Agent{} + a.bindSentenceMedia(map[string]int64{"[image aaaaaaaaaaaa] x": 1}) + if got, err := a.RecallMediaForSentence(1); err != nil || got != nil { + t.Fatalf("媒体关闭时应静默无操作,实际 %v / %v", got, err) + } +} + +func TestCommitTriplesWithMedia_FallsBackWithoutStore(t *testing.T) { + // 媒体关闭时退回普通 Commit,行为与直接调 Commit 完全一致 + dir := t.TempDir() + g, err := memory.NewGraphDB(filepath.Join(dir, "g.db")) + if err != nil { + t.Fatal(err) + } + defer g.Close() + + a := &Agent{memory: g} + ec, rc, err := a.commitTriplesWithMedia([]memory.Triple{ + {Subject: "张三", Relation: "喜欢", Object: "咖啡"}, + }, "s1", 0) + if err != nil { + t.Fatal(err) + } + if ec != 2 || rc != 1 { + t.Fatalf("期望 2 实体 1 关系,实际 ec=%d rc=%d", ec, rc) + } +} + +func TestReleaseDocMedia_DropsRefsSoGCCanReclaim(t *testing.T) { + // L2→L3 那一跳留下的泄漏:文档被 Remove 但引用没销, + // 引用计数永不归零,blob 永远不会被 GC 回收。 + a, _, ms := newGraphMediaAgent(t) + + digest, err := ms.Put([]byte("doc image"), media.Item{MIME: "image/png"}) + if err != nil { + t.Fatal(err) + } + if err := ms.AddRef(digest, media.OwnerDocument, "doc_1"); err != nil { + t.Fatal(err) + } + + // 释放前 GC 清不掉 + if _, _, err := ms.GC(0); err != nil { + t.Fatal(err) + } + if _, err := ms.Stat(digest); err != nil { + t.Fatal("有文档引用时不该被清") + } + + a.releaseDocMedia("doc_1") + + if refs, _ := ms.Refs(media.OwnerDocument, "doc_1"); len(refs) != 0 { + t.Fatalf("释放后不该还有文档引用,实际 %v", refs) + } + // 现在 GC 能回收了 + removed, _, err := ms.GC(0) + if err != nil { + t.Fatal(err) + } + if removed != 1 { + t.Fatalf("释放引用后 GC 应能回收,实际清理 %d 条", removed) + } +} + +func TestMediaContextForSentences(t *testing.T) { + a, _, ms := newGraphMediaAgent(t) + + digest, _ := ms.Put([]byte("img"), media.Item{MIME: "image/png"}) + if err := ms.Describe(digest, "一张紫蓝红三色带图", "visionllm"); err != nil { + t.Fatal(err) + } + if err := ms.AddRef(digest, media.OwnerGraphSentence, "7"); err != nil { + t.Fatal(err) + } + + out := a.mediaContextForSentences([]int64{7, 8}) + if out == "" { + t.Fatal("应产出媒体说明") + } + if !contains(out, "句子 #7") || !contains(out, "一张紫蓝红三色带图") { + t.Fatalf("说明内容不对: %q", out) + } + // 8 号句子没引用媒体,不该出现 + if contains(out, "句子 #8") { + t.Fatalf("无引用的句子不该出现: %q", out) + } +} + +func TestResolvePrefix(t *testing.T) { + dir := t.TempDir() + ms, err := media.New(filepath.Join(dir, "m"), 0) + if err != nil { + t.Fatal(err) + } + defer ms.Close() + + digest, _ := ms.Put([]byte("content"), media.Item{MIME: "image/png"}) + + // 短前缀补全 + full, err := ms.ResolvePrefix(digest[:12]) + if err != nil || full != digest { + t.Fatalf("短前缀补全失败: %v / %v", full, err) + } + // 完整 digest 原样返回 + full, err = ms.ResolvePrefix(digest) + if err != nil || full != digest { + t.Fatalf("完整 digest 应原样返回: %v / %v", full, err) + } + // 过短拒绝 + if _, err := ms.ResolvePrefix("abc"); err == nil { + t.Fatal("过短前缀应报错") + } + // 不存在 + if _, err := ms.ResolvePrefix("deadbeefdead"); err == nil { + t.Fatal("不存在的前缀应报错") + } + // 完整但不存在的 digest 也要报错,否则调用方会挂一条孤儿引用 + fake := "" + for i := 0; i < 64; i++ { + fake += "0" + } + if _, err := ms.ResolvePrefix(fake); err == nil { + t.Fatal("不存在的完整 digest 应报错") + } +} + +func TestResolvePrefix_AmbiguityIsError(t *testing.T) { + // 前缀歧义视为错误而非"取第一个":挂错引用会让 GC 删掉仍被引用的内容。 + // 构造歧义需要两个同前缀 digest——sha256 无法人为构造, + // 因此这里退而验证「8 位前缀在大量样本下的行为是确定的」: + // 要么唯一命中,要么明确报歧义,绝不静默取第一个。 + dir := t.TempDir() + ms, err := media.New(filepath.Join(dir, "m"), 0) + if err != nil { + t.Fatal(err) + } + defer ms.Close() + + digests := make([]string, 0, 200) + for i := 0; i < 200; i++ { + d, err := ms.Put([]byte("content-"+strconv.Itoa(i)), media.Item{MIME: "image/png"}) + if err != nil { + t.Fatal(err) + } + digests = append(digests, d) + } + + for _, d := range digests { + got, err := ms.ResolvePrefix(d[:12]) + if err != nil { + // 报歧义是可接受结果;静默取错才是缺陷 + if !contains(err.Error(), "歧义") { + t.Fatalf("非歧义错误: %v", err) + } + continue + } + if got != d { + t.Fatalf("补全结果错误: 前缀 %s 得到 %s", d[:12], got) + } + } +} diff --git a/internal/agent/core/toolcall.go b/internal/agent/core/toolcall.go index 3c26b8b..c6d02ce 100644 --- a/internal/agent/core/toolcall.go +++ b/internal/agent/core/toolcall.go @@ -189,7 +189,7 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall) string { if len(triples) == 0 { return "没有有效的三元组" } - ec, rc, err := a.memory.Commit(triples, string(a.id), 0) + ec, rc, err := a.commitTriplesWithMedia(triples, string(a.id), 0) if err != nil { return fmt.Sprintf("记忆写入失败: %v", err) } @@ -541,10 +541,10 @@ func (a *Agent) executeDocTool(tc agentAPI.ToolCall) string { } doc := &document.Doc{ - Summary: summary, - Content: content, - Tags: tags, - Source: "manual", + Summary: summary, + Content: content, + Tags: tags, + Source: "manual", } if err := a.docStore.Insert(doc); err != nil { return fmt.Sprintf("文档写入失败: %v", err) diff --git a/internal/memory/graph.go b/internal/memory/graph.go index d13b141..3167de2 100644 --- a/internal/memory/graph.go +++ b/internal/memory/graph.go @@ -37,13 +37,13 @@ type Relation struct { } type Triple struct { - Subject string `json:"subject"` - Relation string `json:"relation"` - Object string `json:"object"` - Confidence float64 `json:"confidence,omitempty"` - SubjectType string `json:"subject_type,omitempty"` - ObjectType string `json:"object_type,omitempty"` - SentenceText string `json:"sentence_text,omitempty"` // 原始句子文本,Commit时写入sentences表 + Subject string `json:"subject"` + Relation string `json:"relation"` + Object string `json:"object"` + Confidence float64 `json:"confidence,omitempty"` + SubjectType string `json:"subject_type,omitempty"` + ObjectType string `json:"object_type,omitempty"` + SentenceText string `json:"sentence_text,omitempty"` // 原始句子文本,Commit时写入sentences表 } type GraphDB struct { @@ -192,13 +192,37 @@ func (g *GraphDB) migrateRelationUnique(tx *sql.Tx) error { return nil } +// Commit 把三元组写入图库,返回新建的实体数与关系数。 func (g *GraphDB) Commit(triples []Triple, sessionID string, turnID int) (int, int, error) { + _, ec, rc, err := g.commit(triples, sessionID, turnID, false) + return ec, rc, err +} + +// CommitWithMedia 与 Commit 相同,但额外返回每条句子文本对应的 sentences.id。 +// +// 为何单独开一个方法而不改 Commit 的签名:Commit 有十个非测试调用点 +// 加二十多个测试调用点,为了一个多数调用方都不需要的返回值去改全部签名 +// 不划算。这里让 Commit 内部转调,两者共享同一份落库逻辑。 +// +// 返回的 map 只包含本次真正写入了 sentences 表的句子。调用方据此把媒体 +// 引用挂到 graph_sentence owner 上——句子是媒体描述在图库里的落点, +// 关系行本身不持有媒体。 +func (g *GraphDB) CommitWithMedia(triples []Triple, sessionID string, turnID int) (map[string]int64, int, int, error) { + return g.commit(triples, sessionID, turnID, true) +} + +func (g *GraphDB) commit(triples []Triple, sessionID string, turnID int, trackSentences bool) (map[string]int64, int, int, error) { g.mu.Lock() defer g.mu.Unlock() + var sentenceIDs map[string]int64 + if trackSentences { + sentenceIDs = make(map[string]int64) + } + tx, err := g.db.Begin() if err != nil { - return 0, 0, err + return nil, 0, 0, err } defer tx.Rollback() @@ -229,24 +253,24 @@ func (g *GraphDB) Commit(triples []Triple, sessionID string, turnID int) (int, i ec, err := g.upsertEntity(tx, t.Subject, subjType) if err != nil { - return 0, 0, err + return nil, 0, 0, err } entitiesCreated += ec ec, err = g.upsertEntity(tx, t.Object, objType) if err != nil { - return 0, 0, err + return nil, 0, 0, err } entitiesCreated += ec var sourceID, targetID int64 err = tx.QueryRow("SELECT id FROM entities WHERE name = ?", t.Subject).Scan(&sourceID) if err != nil { - return 0, 0, fmt.Errorf("subject %q: %w", t.Subject, err) + return nil, 0, 0, fmt.Errorf("subject %q: %w", t.Subject, err) } err = tx.QueryRow("SELECT id FROM entities WHERE name = ?", t.Object).Scan(&targetID) if err != nil { - return 0, 0, fmt.Errorf("object %q: %w", t.Object, err) + return nil, 0, 0, fmt.Errorf("object %q: %w", t.Object, err) } // 写入/查找句子 @@ -255,11 +279,13 @@ func (g *GraphDB) Commit(triples []Triple, sessionID string, turnID int) (int, i _, err = tx.Exec( `INSERT OR IGNORE INTO sentences (text) VALUES (?)`, t.SentenceText) if err != nil { - return 0, 0, fmt.Errorf("insert sentence: %w", err) + return nil, 0, 0, fmt.Errorf("insert sentence: %w", err) } err = tx.QueryRow("SELECT id FROM sentences WHERE text = ?", t.SentenceText).Scan(&sentenceID) if err != nil { sentenceID = 0 + } else if sentenceIDs != nil { + sentenceIDs[t.SentenceText] = sentenceID } } @@ -275,11 +301,11 @@ func (g *GraphDB) Commit(triples []Triple, sessionID string, turnID int) (int, i sourceID, targetID, t.Relation, confidence, sessionID, turnID, dateBucket, sentenceID, ) if err != nil { - return 0, 0, err + return nil, 0, 0, err } relationsCreated++ } else if err != nil { - return 0, 0, err + return nil, 0, 0, err } else { // 同一(会话内)三元组已存在:仅刷新置信度与时间戳,不重复计数 _, err = tx.Exec( @@ -288,16 +314,16 @@ func (g *GraphDB) Commit(triples []Triple, sessionID string, turnID int) (int, i confidence, sourceID, targetID, t.Relation, sessionID, ) if err != nil { - return 0, 0, err + return nil, 0, 0, err } } } if err := tx.Commit(); err != nil { - return 0, 0, err + return nil, 0, 0, err } - return entitiesCreated, relationsCreated, nil + return sentenceIDs, entitiesCreated, relationsCreated, nil } func validEntityName(name string) bool { diff --git a/internal/memory/media/media.go b/internal/memory/media/media.go index e616955..dc7338d 100644 --- a/internal/memory/media/media.go +++ b/internal/memory/media/media.go @@ -46,6 +46,9 @@ const ( OwnerGraphSentence = "graph_sentence" ) +// digestHexLen 是 sha256 的十六进制串长度。 +const digestHexLen = sha256.Size * 2 + // Kind 是媒体大类。刻意只分三类而不细分具体格式: // 记忆检索关心的是“这是张图还是段音频”,具体编码交给 MIME 字段。 type Kind string @@ -722,3 +725,56 @@ func truncate(s string, n int) string { } return s[:n] + "..." } + +// ResolvePrefix 把 digest 前缀补全为完整 digest。 +// +// 日志、事件摘要与图库句子里出现的都是 shortDigest(前 12 位), +// 因为完整的 64 位 sha256 会把一行文字撑爆、也无助于人眼辨认。 +// 反查时需要这个补全,否则那些短标记只能看不能用。 +// +// 前缀歧义视为错误而非"取第一个":挂错引用会让 GC 删掉仍被引用的内容, +// 宁可这次绑定失败。12 位十六进制的碰撞概率极低,真撞上说明该用更长前缀。 +func (s *Store) ResolvePrefix(prefix string) (string, error) { + prefix = strings.ToLower(strings.TrimSpace(prefix)) + if len(prefix) < 8 { + return "", fmt.Errorf("digest 前缀过短(至少 8 位): %q", prefix) + } + if len(prefix) == digestHexLen { + // 已是完整 digest:仍要确认存在,否则调用方会挂一条孤儿引用 + if _, err := s.Stat(prefix); err != nil { + return "", err + } + return prefix, nil + } + + s.mu.RLock() + defer s.mu.RUnlock() + rows, err := s.db.Query( + `SELECT digest FROM media WHERE digest LIKE ? || '%' LIMIT 2`, prefix) + if err != nil { + return "", err + } + defer rows.Close() + + var found []string + for rows.Next() { + var d string + if err := rows.Scan(&d); err != nil { + return "", err + } + found = append(found, d) + } + if err := rows.Err(); err != nil { + return "", err + } + + switch len(found) { + case 0: + return "", fmt.Errorf("digest 前缀 %q 未匹配到媒体", prefix) + case 1: + return found[0], nil + default: + return "", fmt.Errorf("digest 前缀 %q 有歧义(至少匹配 %s 和 %s)", + prefix, found[0][:16], found[1][:16]) + } +}