diff --git a/internal/agent/core/graphmedia_test.go b/internal/agent/core/graphmedia_test.go index 9efcef11..a2b8d5c9 100644 --- a/internal/agent/core/graphmedia_test.go +++ b/internal/agent/core/graphmedia_test.go @@ -3,30 +3,39 @@ package core import ( "fmt" "path/filepath" - "strconv" "strings" "testing" "time" "github.com/JianFeeeee/HomeAgent/internal/memory" "github.com/JianFeeeee/HomeAgent/internal/memory/document" - "github.com/JianFeeeee/HomeAgent/internal/memory/media" ) -// L3 图库媒体绑定测试。 +// L3 图库媒体绑定测试(块 union 化之后)。 // -// 这一层的目的只有一个:几个月后从图谱走到一条句子,要能取回当时那份媒体。 -// 媒体作为一等块进入 L3,以结构边与承载节点相连: +// 这层的目的只有一个:几个月后从图谱走到一条句子,要能取回当时那份媒体。 +// 媒体作为一等块进入 L3(字节在块自己的 Data),以结构边与承载节点相连: // // sentence --contains--> block(对话/三元组产生的记忆) // document --contains--> block(L2 文档归档进 L3) // -// 描述文本、marker 反解、由 marker 反推出的「媒体实体」全部已废弃, -// 因此这些测试也不存在任何按描述检索的断言。 +// 描述文本、marker 反解、CAS digest、由 marker 反推出的「媒体实体」全部已废弃, +// 因此这些测试也不存在任何按描述或 digest 检索的断言。 + +// mediaBlock 构造内容派生 ID 的媒体块。 +func mediaBlock(mime string, payload []byte) memory.MemoryBlock { + return memory.MemoryBlock{ + ID: memory.MediaBlockID(mime, payload), + Modality: memory.BlockImage, + Data: payload, + MIME: mime, + Size: int64(len(payload)), + } +} // attachBlockToSentenceBlock 提交一条句子并把媒体变成 L3 一等块。 // 必须走真实提交:边要求两端都是真实图节点。 -func attachBlockToSentenceBlock(t *testing.T, g *memory.GraphDB, ms *media.Store, sentenceText, digest string) (string, memory.MemoryBlock) { +func attachBlockToSentenceBlock(t *testing.T, g *memory.GraphDB, sentenceText string, payload []byte) (string, memory.MemoryBlock) { t.Helper() ids, _, _, err := g.CommitWithMedia([]memory.Triple{{ Subject: "媒体载体", Relation: "包含", Object: "内容", SentenceText: sentenceText, @@ -34,39 +43,23 @@ func attachBlockToSentenceBlock(t *testing.T, g *memory.GraphDB, ms *media.Store if err != nil { t.Fatalf("CommitWithMedia: %v", err) } - // ★ 媒体的挂载点已从「sentences 表行号」改成「原句块 ID」 - // (CommitWithMedia 返回值由 map[string]int64 改为 map[string]string, - // 因为 sentences 表退场后行号不存在)。 + // ★ 媒体的挂载点是「原句块 ID」(sentences 表行号已退场)。 sid := ids[sentenceText] if sid == "" { t.Fatalf("拿不到原句块 ID: %q", sentenceText) } - it, err := ms.Stat(digest) - if err != nil || it == nil { - t.Fatalf("Stat(%s): %v", shortDigest(digest), err) - } - b := memory.MemoryBlock{ - ID: fmt.Sprintf("blk_test_%s_%s", shortDigest(sid), shortDigest(digest)), - Modality: memory.BlockImage, - PayloadDigest: it.Digest, - MIME: it.MIME, - Size: it.Size, - Width: it.Width, - Height: it.Height, - Vector: it.Vec, - Fingerprint: it.VecModel, - } + b := mediaBlock("image/png", payload) if err := g.PutMemoryBlocks([]memory.MemoryBlock{b}); err != nil { t.Fatalf("PutMemoryBlocks: %v", err) } - // ★ 端点 kind 也从 "sentence" 改成 "block"(原句由块承载) + // 端点 kind 都是 "block"(原句由块承载) if err := g.AddMemoryBlockEdge("block", sid, "block", b.ID, "contains"); err != nil { t.Fatalf("AddMemoryBlockEdge: %v", err) } return sid, b } -func newGraphMediaAgent(t *testing.T) (*Agent, *memory.GraphDB, *media.Store) { +func newGraphMediaAgent(t *testing.T) (*Agent, *memory.GraphDB) { t.Helper() dir := t.TempDir() @@ -76,17 +69,11 @@ func newGraphMediaAgent(t *testing.T) (*Agent, *memory.GraphDB, *media.Store) { } t.Cleanup(func() { g.Close() }) - ms, err := media.New(filepath.Join(dir, "media")) - if err != nil { - t.Fatalf("media.New: %v", err) - } - t.Cleanup(func() { ms.Close() }) - - return &Agent{memory: g, mediaStore: ms}, g, ms + return &Agent{memory: g}, g } func TestCommitWithMedia_ReturnsSentenceIDs(t *testing.T) { - _, g, _ := newGraphMediaAgent(t) + _, g := newGraphMediaAgent(t) sentence := "这张图是紫蓝红三色带。" triples := []memory.Triple{{ @@ -116,7 +103,7 @@ func TestCommitWithMedia_ReturnsSentenceIDs(t *testing.T) { func TestCommit_StillWorksAfterRefactor(t *testing.T) { // Commit 有三十多个调用点,内部转调后行为必须完全不变 - _, g, _ := newGraphMediaAgent(t) + _, g := newGraphMediaAgent(t) triples := []memory.Triple{ {Subject: "张三", Relation: "喜欢", Object: "咖啡", SentenceText: "张三喜欢咖啡"}, @@ -127,13 +114,7 @@ func TestCommit_StillWorksAfterRefactor(t *testing.T) { t.Fatal(err) } // ★ 旧表双写已停(2026-10-04)⇒ ec/rc 恒为 0。 - // - // 它们数的是旧表 entities/relations 的行号,而那两张表不再增长。 - // Commit 的返回签名是 SDK 契约(9 处调用方),改签名代价大 —— - // 所以保留位置并置 0。 - // - // ★ 判据要验的是「Commit 写进去了」,改用**块侧**信号: - // 4 个实体名(张三/李四/编程/北京)+ 2 条关系边。 + // 判据要验的是「Commit 写进去了」,改用**块侧**信号。 if ec != 0 || rc != 0 { t.Fatalf("旧表双写已停,ec/rc 应为 0,实际 ec=%d rc=%d", ec, rc) } @@ -146,23 +127,11 @@ func TestCommit_StillWorksAfterRefactor(t *testing.T) { } fmt.Printf(" Commit 写入块 %d 个(旧表计数已停用 ec=%d rc=%d)\n", len(blocks), ec, rc) - // 重复提交同一批:关系被唯一约束去重。 - // - // 实体计数**不**归零——这是 upsertEntity 的既有行为:SQLite 的 - // ON CONFLICT DO UPDATE 也算一行 affected,于是 RowsAffected() > 0 - // 被当成"新建了"。用 main 分支的 graph.go 单独验证过基线同样是 - // 首次 ec=2 / 重复 ec=2,与 CommitWithMedia 重构无关。 - // entitiesCreated 只用于日志,故此处记录现状而不改行为。 + // 重复提交同一批:关系边允许并存(52e4596 移除 UNIQUE),幂等体现在块侧。 _, _, err = g.Commit(triples, "s1", 0) if err != nil { t.Fatal(err) } - // ★ 幂等的真正判据是「块数不增长」,不是计数返回值 —— - // 后者在旧表停写后恒为 0(2026-10-04)。 - // - // ★ 而且关系边**允许并存多条**(52e4596 移除了 UNIQUE), - // 所以「重复提交新建了关系边」不是缺陷,是设计。 - // 幂等体现在块侧:块 ID 是内容派生的,重写不产生新块。 blocks2, err := g.MemoryBlocks() if err != nil { t.Fatal(err) @@ -175,11 +144,12 @@ func TestCommit_StillWorksAfterRefactor(t *testing.T) { func TestCommitTriplesWithMedia_RoundTrip(t *testing.T) { // 整层的核心断言:写入 → 提交 → 反查取回原始字节 - a, _, ms := newGraphMediaAgent(t) + a, _ := 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 { + b := mediaBlock("image/png", content) + // 媒体块先落库(字节随块),三元组只带轻量块 ID 引用 + if err := a.memory.PutMemoryBlocks([]memory.MemoryBlock{b}); err != nil { t.Fatal(err) } @@ -187,7 +157,7 @@ func TestCommitTriplesWithMedia_RoundTrip(t *testing.T) { triples := []memory.Triple{{ Subject: "图片", Relation: "内容", Object: "三色带", SentenceText: sentence, - MediaDigests: []string{digest[:12]}, // 模型手里通常只有短 digest + MediaDigests: []string{b.ID[:12]}, // 模型手里通常只有短前缀 }} if _, _, bound, err := a.commitTriplesWithMedia(triples, "s1", 0, nil); err != nil { @@ -210,29 +180,25 @@ func TestCommitTriplesWithMedia_RoundTrip(t *testing.T) { if err != nil { t.Fatal(err) } - if len(blocks) != 1 || blocks[0].PayloadDigest != digest { - t.Fatalf("反查应得完整 digest %s,实际 %+v", shortDigest(digest), blocks) + if len(blocks) != 1 || blocks[0].ID != b.ID { + t.Fatalf("反查应得块 %s,实际 %+v", b.ID, blocks) } - got, err := ms.Get(blocks[0].PayloadDigest) + // 召回是 lean 列;字节按块 ID 单取 —— 块即 union + got, err := a.memory.BlockData(b.ID) if err != nil { t.Fatalf("取回内容失败: %v", err) } if string(got) != string(content) { t.Fatal("取回的内容与写入不一致") } - - // 块仍被 L3 持有 → 内容应仍可读 - if _, err := ms.Get(digest); err != nil { - t.Fatalf("被 L3 记忆块持有的内容不该被清除: %v", err) - } } func TestAttachBlocksToSentence_SkipsUnresolvable(t *testing.T) { - // digest 在库里不存在时必须跳过,不能建一条指向虚无的块边。 - a, g, _ := newGraphMediaAgent(t) - // ★ 空块 ID = 无效句柄(原来用 0 行号表示) + // 块引用在库里不存在时必须跳过,不能建一条指向虚无的块边。 + a, g := newGraphMediaAgent(t) + // ★ 空块 ID = 无效句柄 if n := a.attachBlocksToSentenceBlock("", []string{"deadbeefdead"}, nil, ""); n != 0 { - t.Fatalf("无法补全的 digest 不该建块,实际绑定 %d", n) + t.Fatalf("无法补全的块引用不该建块,实际绑定 %d", n) } blocks, err := g.BlocksForNode("sentence", "42") if err != nil { @@ -255,11 +221,12 @@ func TestAttachBlocksToSentence_NilStoreNoop(t *testing.T) { func TestAttachBlocksToSentence_ReusesSeedIdentity(t *testing.T) { // L2→L3 迁移必须保持块身份:同一个块换层,而不是另建一个同内容的新块。 - a, g, ms := newGraphMediaAgent(t) - digest, _ := ms.Put([]byte("seed-img"), media.Item{MIME: "image/png"}) - seedBlock, ok := a.blockFromDigest(digest) - if !ok { - t.Fatal("blockFromDigest 失败") + a, g := newGraphMediaAgent(t) + seedBlock := mediaBlock("image/png", []byte("seed-img")) + // 种子块先入库(内容派生 ID,因此 attachBlocksToSentenceBlock + // 也可以按前缀解析到它 —— 但这里显式走 seed 通道) + if err := g.PutMemoryBlocks([]memory.MemoryBlock{seedBlock}); err != nil { + t.Fatal(err) } ids, _, _, err := g.CommitWithMedia([]memory.Triple{{ @@ -270,8 +237,8 @@ func TestAttachBlocksToSentence_ReusesSeedIdentity(t *testing.T) { } sid := ids["迁移测试句。"] - byDigest := map[string]memory.MemoryBlock{digest: seedBlock} - if n := a.attachBlocksToSentenceBlock(sid, []string{digest}, byDigest, ""); n != 1 { + byDigest := map[string]memory.MemoryBlock{seedBlock.ID: seedBlock} + if n := a.attachBlocksToSentenceBlock(sid, []string{seedBlock.ID}, byDigest, ""); n != 1 { t.Fatalf("应绑定 1 个块,实际 %d", n) } blocks, err := g.BlocksForNode("block", sid) @@ -285,13 +252,9 @@ func TestAttachBlocksToSentence_ReusesSeedIdentity(t *testing.T) { func TestLinkBlocksToDocument_CreatesDocumentNodeEdge(t *testing.T) { // 文档归档进 L3:块原样迁入,document --contains--> block 边建立。 - a, g, ms := newGraphMediaAgent(t) + a, g := newGraphMediaAgent(t) - digest, _ := ms.Put([]byte("doc-img"), media.Item{MIME: "image/png"}) - b, ok := a.blockFromDigest(digest) - if !ok { - t.Fatal("blockFromDigest 失败") - } + b := mediaBlock("image/png", []byte("doc-img")) if n := a.linkBlocksToDocument("doc_42", []memory.MemoryBlock{b}, ""); n != 1 { t.Fatalf("应建立 1 条文档→块边,实际 %d", n) @@ -334,47 +297,60 @@ func TestCommitTriplesWithMedia_FallsBackWithoutStore(t *testing.T) { } func TestMediaContextForSentences(t *testing.T) { - a, g, ms := newGraphMediaAgent(t) + a, g := newGraphMediaAgent(t) - digest, _ := ms.Put([]byte("img"), media.Item{MIME: "image/png"}) - sid, _ := attachBlockToSentenceBlock(t, g, ms, "一张紫蓝红三色带图。", digest) + sid, b := attachBlockToSentenceBlock(t, g, "一张紫蓝红三色带图。", []byte("img")) + // ★ 第二个句子必须是**库里不存在**的独立块 ID,不能用 sid+"-extra": + // shortID 截断到 20 字符,而 blk_src_<12hex> 恰好 20 字符, + // 于是 shortID(sid+"-extra") == shortID(sid),负向断言会误命中 sid 自己那行。 + noRefSID := memory.SentenceBlockID("库里完全不存在的另一句话。") - out := a.mediaContextForSentences([]string{sid, sid + "-extra"}) + out := a.mediaContextForSentences([]string{sid, noRefSID}) if out == "" { t.Fatal("应产出媒体说明") } - // 说明里的句子标识是 shortID(块ID) = 去掉 blk_src_ 前缀的 hex - if !contains(out, fmt.Sprintf("句子 %s", shortID(sid))) || !contains(out, shortDigest(digest)) { + // 说明里的句子标识是 shortID(块ID) + if !contains(out, fmt.Sprintf("句子 %s", shortID(sid))) || !contains(out, shortID(b.ID)) { t.Fatalf("说明内容不对: %q(期望含 shortID=%s)", out, shortID(sid)) } - // 说明只含 MIME 与短 digest,不含任何生成的描述 + // 说明只含 MIME 与短块 ID,不含任何生成的描述 if contains(out, "紫蓝红") { t.Fatalf("说明里不该有描述文本(描述式索引已废弃): %q", out) } // 无引用的句子不该出现 - if contains(out, fmt.Sprintf("句子 %s", shortID(sid+"-extra"))) { + if contains(out, fmt.Sprintf("句子 %s", shortID(noRefSID))) { t.Fatalf("无引用的句子不该出现: %q", out) } } func TestMediaContextForRelations_SurfacesMediaToAgent(t *testing.T) { // L3 检索接线回归:媒体作为一等块进了图库,agent 必须拿得出来。 - a, g, ms := newGraphMediaAgent(t) + a, g := newGraphMediaAgent(t) - digest, err := ms.Put([]byte("img bytes"), media.Item{MIME: "image/png"}) + b := mediaBlock("image/png", []byte("img bytes")) + sentence := "一张紫蓝红三色带图。" + ids, _, _, err := g.CommitWithMedia([]memory.Triple{{ + Subject: "媒体载体", Relation: "包含", Object: "内容", SentenceText: sentence, + }}, "rel", 0) if err != nil { t.Fatal(err) } - _, _ = attachBlockToSentenceBlock(t, g, ms, "一张紫蓝红三色带图。", digest) + sid := ids[sentence] + if err := g.PutMemoryBlocks([]memory.MemoryBlock{b}); err != nil { + t.Fatal(err) + } + if err := g.AddMemoryBlockEdge("block", sid, "block", b.ID, "contains"); err != nil { + t.Fatal(err) + } // 命中的关系挂着该句子 → 应产出媒体说明 - // ★ 挂载点改成 SentenceText 现算块 ID(sentenceIDsFromRelations 的新契约) - out := a.mediaContextForRelations([]memory.Relation{{ID: 1, SentenceText: "一张紫蓝红三色带图。"}}) + // ★ 挂载点是 SentenceText 现算块 ID(sentenceIDsFromRelations 的新契约) + out := a.mediaContextForRelations([]memory.Relation{{ID: 1, SentenceText: sentence}}) if out == "" { t.Fatal("关系挂着有媒体的句子,却没产出媒体说明——L3 检索接线断了") } - if !contains(out, shortDigest(digest)) { - t.Errorf("媒体说明里应含短 digest 供反查: %q", out) + if !contains(out, shortID(b.ID)) { + t.Errorf("媒体说明里应含短块 ID 供反查: %q", out) } // 没挂媒体的关系不该产出噪声 @@ -389,12 +365,9 @@ func TestMediaContextForRelations_SurfacesMediaToAgent(t *testing.T) { func TestBuildMemoryContext_IncludesMediaSection(t *testing.T) { // buildMemoryContext 是自动注入路径(每次 LLM 调用都走)。 // 媒体说明必须出现在这里,否则 agent 只有显式调 memory_recall 才知道有图。 - a, graph, ms := newGraphMediaAgent(t) + a, graph := newGraphMediaAgent(t) - digest, err := ms.Put([]byte("auto inject"), media.Item{MIME: "image/png"}) - if err != nil { - t.Fatal(err) - } + b := mediaBlock("image/png", []byte("auto inject")) sentence := "用户发来的图片。" sids, _, _, err := graph.CommitWithMedia([]memory.Triple{{ @@ -409,7 +382,7 @@ func TestBuildMemoryContext_IncludesMediaSection(t *testing.T) { } if err := graph.PutMemoryBlocks([]memory.MemoryBlock{{ ID: "blk_auto_1", Modality: memory.BlockImage, - PayloadDigest: digest, MIME: "image/png", + Data: b.Data, MIME: "image/png", }}); err != nil { t.Fatal(err) } @@ -429,77 +402,72 @@ func TestBuildMemoryContext_IncludesMediaSection(t *testing.T) { if !contains(out, "【关联媒体】") { t.Errorf("自动注入的记忆上下文缺少媒体段: %q", out) } - if !contains(out, shortDigest(digest)) { - t.Errorf("媒体段里应含短 digest: %q", out) + if !contains(out, shortID("blk_auto_1")) { + t.Errorf("媒体段里应含短块 ID: %q", out) } } func TestResolvePrefix(t *testing.T) { + // BlockByIDPrefix 的前缀解析契约(CAS ResolvePrefix 的接任者)。 dir := t.TempDir() - ms, err := media.New(filepath.Join(dir, "m")) + g, err := memory.NewGraphDB(filepath.Join(dir, "g.db")) if err != nil { t.Fatal(err) } - defer ms.Close() + defer g.Close() - digest, _ := ms.Put([]byte("content"), media.Item{MIME: "image/png"}) + b := mediaBlock("image/png", []byte("content")) + if err := g.PutMemoryBlocks([]memory.MemoryBlock{b}); err != nil { + t.Fatal(err) + } // 短前缀补全 - full, err := ms.ResolvePrefix(digest[:12]) - if err != nil || full != digest { - t.Fatalf("短前缀补全失败: %v / %v", full, err) + got, ok, err := g.BlockByIDPrefix(b.ID[:12]) + if err != nil || !ok || got.ID != b.ID { + t.Fatalf("短前缀补全失败: %v / %v / %v", got, ok, err) } - // 完整 digest 原样返回 - full, err = ms.ResolvePrefix(digest) - if err != nil || full != digest { - t.Fatalf("完整 digest 应原样返回: %v / %v", full, err) + // 完整 ID 原样返回 + got, ok, err = g.BlockByIDPrefix(b.ID) + if err != nil || !ok || got.ID != b.ID { + t.Fatalf("完整 ID 应原样返回: %v / %v / %v", got, ok, err) } - // 过短拒绝 - if _, err := ms.ResolvePrefix("abc"); err == nil { - t.Fatal("过短前缀应报错") + // 过短/不存在 → 不解析(宁可不解析也不猜) + if _, ok, _ := g.BlockByIDPrefix("blk"); ok { + t.Fatal("过短前缀不该命中") } - // 不存在 - if _, err := ms.ResolvePrefix("deadbeefdead"); err == nil { - t.Fatal("不存在的前缀应报错") - } - // 完整但不存在的 digest 也要报错,否则调用方会挂一条孤儿块 - fake := strings.Repeat("0", 64) - if _, err := ms.ResolvePrefix(fake); err == nil { - t.Fatal("不存在的完整 digest 应报错") + if _, ok, _ := g.BlockByIDPrefix("deadbeefdead"); ok { + t.Fatal("不存在的前缀不该命中") } } func TestResolvePrefix_AmbiguityIsError(t *testing.T) { - // 前缀歧义视为错误而非"取第一个":挂错块会让内容被误删。 - // 构造歧义需要两个同前缀 digest——sha256 无法人为构造, + // 前缀歧义视为不命中而非"取第一个":挂错块会让内容被误删。 + // 构造歧义需要两个同前缀块 ID —— 内容派生 ID 无法人为构造同前缀, // 因此这里退而验证「12 位前缀在大量样本下的行为是确定的」: - // 要么唯一命中,要么明确报歧义,绝不静默取第一个。 + // 要么唯一命中,要么明确不命中,绝不静默取第一个。 dir := t.TempDir() - ms, err := media.New(filepath.Join(dir, "m")) + g, err := memory.NewGraphDB(filepath.Join(dir, "g.db")) if err != nil { t.Fatal(err) } - defer ms.Close() + defer g.Close() - digests := make([]string, 0, 200) + ids := 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 { + b := mediaBlock("image/png", []byte("content-"+fmt.Sprint(i)+"-"+strings.Repeat("x", i%7))) + if err := g.PutMemoryBlocks([]memory.MemoryBlock{b}); err != nil { t.Fatal(err) } - digests = append(digests, d) + ids = append(ids, b.ID) } - for _, d := range digests { - got, err := ms.ResolvePrefix(d[:12]) + for _, id := range ids { + got, ok, err := g.BlockByIDPrefix(id[:12]) if err != nil { - if !contains(err.Error(), "歧义") { - t.Fatalf("非歧义错误: %v", err) - } - continue + t.Fatalf("前缀 %s 查询报错: %v", id[:12], err) } - if got != d { - t.Fatalf("补全结果错误: 前缀 %s 得到 %s", d[:12], got) + if ok && got.ID != id { + t.Fatalf("补全结果错误: 前缀 %s 得到 %s(期望 %s)", id[:12], got.ID, id) } } } @@ -507,7 +475,7 @@ func TestResolvePrefix_AmbiguityIsError(t *testing.T) { func TestArchiveColdDocs_KeepsDocWhenGraphWriteEmpty(t *testing.T) { // 数据丢失回归:三元组全被实体名校验拒绝时(Commit 无错但 0 entities // 0 relations),文档不能删、其持有的块不能丢。 - a, _, ms := newGraphMediaAgent(t) + a, _ := newGraphMediaAgent(t) dir := t.TempDir() ds := document.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords) @@ -518,28 +486,16 @@ func TestArchiveColdDocs_KeepsDocWhenGraphWriteEmpty(t *testing.T) { a.docStore = ds a.embedder = memory.NewStaticEmbedder() - content := []byte("image bytes") - digest, err := ms.Put(content, media.Item{MIME: "image/png"}) - if err != nil { - t.Fatal(err) - } - - // 精确构造「三元组非空 + Commit 全部拒绝」这个状态: - // Source/Summary 都超过 validEntityName 的 50 字符上限, - // 于是 docToTriples 产出的两条元数据三元组都被跳过。 - longSource := strings.Repeat("超长来源名", 20) // 100 字 - longSummary := strings.Repeat("超长摘要文本", 20) // >80 字触发长度门槛被跳过 - it, _ := ms.Stat(digest) + b := mediaBlock("image/png", []byte("image bytes")) doc := &document.Doc{ ID: "doc_keep", - Summary: longSummary, + Summary: strings.Repeat("超长摘要文本", 20), // >80 字触发长度门槛被跳过 Content: "一段没有媒体标记的正文", - Source: longSource, + Source: strings.Repeat("超长来源名", 20), // 100 字,超 validEntityName 上限 CreatedAt: time.Now().Add(-200 * time.Hour), LastAccess: time.Now().Add(-200 * time.Hour), AccessCount: 0, - Blocks: []memory.MemoryBlock{{ID: "blk_keep_1", Modality: memory.BlockImage, - PayloadDigest: it.Digest, MIME: it.MIME, Size: it.Size}}, + Blocks: []memory.MemoryBlock{b}, } if err := ds.Insert(doc); err != nil { t.Fatal(err) @@ -555,10 +511,7 @@ func TestArchiveColdDocs_KeepsDocWhenGraphWriteEmpty(t *testing.T) { } a.archiveColdDocs() - // 关键断言:内容在、块在、文档在 - if _, err := ms.Get(digest); err != nil { - t.Fatalf("图库未写入任何实体/关系,内容却丢了: %v", err) - } + // 关键断言:块在、文档在 held := false for _, d := range ds.RecentDocs(10) { if d.ID == doc.ID && len(d.Blocks) > 0 { @@ -573,7 +526,7 @@ func TestArchiveColdDocs_KeepsDocWhenGraphWriteEmpty(t *testing.T) { func TestArchiveColdDocs_MigratesBlocksToGraph(t *testing.T) { // 归档成功时块必须迁进 L3 并以 document --contains--> block 关联, // 然后文档才被删除(迁移而非复制/引用保活)。 - a, g, ms := newGraphMediaAgent(t) + a, g := newGraphMediaAgent(t) dir := t.TempDir() ds := document.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords) @@ -584,15 +537,13 @@ func TestArchiveColdDocs_MigratesBlocksToGraph(t *testing.T) { a.docStore = ds a.embedder = memory.NewStaticEmbedder() - digest, _ := ms.Put([]byte("archived-image"), media.Item{MIME: "image/png"}) - it, _ := ms.Stat(digest) + b := mediaBlock("image/png", []byte("archived-image")) doc := &document.Doc{ ID: "doc_arch", Summary: "带图的冷文档", Content: "张三把三色带图交给了李四。", Source: "manual", - Blocks: []memory.MemoryBlock{{ID: "blk_arch_1", Modality: memory.BlockImage, - PayloadDigest: it.Digest, MIME: it.MIME, Size: it.Size}}, + Blocks: []memory.MemoryBlock{b}, } if err := ds.Insert(doc); err != nil { t.Fatal(err) @@ -612,21 +563,19 @@ func TestArchiveColdDocs_MigratesBlocksToGraph(t *testing.T) { if err != nil { t.Fatal(err) } - if len(blocks) != 1 || blocks[0].ID != "blk_arch_1" { - t.Fatalf("L3 文档节点应持有原块(身份不变),实际 %+v", blocks) - } - if _, err := ms.Get(digest); err != nil { - t.Fatalf("块被 L3 持有,内容应仍可读: %v", err) + if len(blocks) != 1 || blocks[0].ID != b.ID { + t.Fatalf("L3 文档节点应持有原块(身份不变 %s),实际 %+v", b.ID, blocks) } } func TestMigrateLegacyMediaEntities(t *testing.T) { // 旧数据:媒体被伪装成 type=Media 的实体,靠描述文本当索引。 // 迁移必须把它还原成原生块(挂回原句子)并删掉旧实体与描述关系。 - _, g, ms := newGraphMediaAgent(t) + _, g := newGraphMediaAgent(t) - digest, _ := ms.Put([]byte("legacy-img"), media.Item{MIME: "image/png"}) - sentence := "老数据里的三色带图 [image/png " + digest[:12] + "]" + b := mediaBlock("image/png", []byte("legacy-img")) + shortRef := b.ID[len("blk_media_"):][:12] + sentence := "老数据里的三色带图 [image/png " + shortRef + "]" // ★★ 直接写旧表,不用 CommitWithMedia 造(2026-10-04) // // 旧表双写已停 ⇒ Commit 不再写 entities/relations, @@ -635,14 +584,13 @@ func TestMigrateLegacyMediaEntities(t *testing.T) { // // 迁移的输入就是旧表,所以测试也该直接构造旧表。 // (SeedLegacyMediaEntity 明确标注为测试专用。) - mediaName := "图片 " + digest[:12] + mediaName := "图片 " + shortRef mediaEntID, err := g.SeedLegacyMediaEntity(mediaName, "Media") if err != nil { t.Fatal(err) } // ★ 迁移靠旧 relations 找到「该把媒体块挂到哪句上」, - // 所以旧表里必须有 sentence 行 + 关联行(生产上它们还在, - // 停双写只是让它们不再**增长**)。 + // 所以旧表里必须有 sentence 行 + 关联行。 sentRowID, err := g.SeedLegacySentenceRow(sentence) if err != nil { t.Fatal(err) @@ -660,19 +608,12 @@ func TestMigrateLegacyMediaEntities(t *testing.T) { t.Fatal(err) } + // 块 union 化后,resolver 直接把短引用映射回内容派生块。 blocks, entities, err := g.MigrateLegacyMediaEntities(func(short string) (memory.MemoryBlock, bool) { - full, err := ms.ResolvePrefix(short) - if err != nil { + if short != shortRef { return memory.MemoryBlock{}, false } - it, err := ms.Stat(full) - if err != nil { - return memory.MemoryBlock{}, false - } - return memory.MemoryBlock{ - ID: "blk_legacy_" + short, Modality: memory.BlockImage, - PayloadDigest: it.Digest, MIME: it.MIME, Size: it.Size, - }, true + return b, true }) if err != nil { t.Fatal(err) @@ -681,26 +622,7 @@ func TestMigrateLegacyMediaEntities(t *testing.T) { t.Fatalf("应迁移 1 块 / 删 1 实体,实际 %d / %d", blocks, entities) } - // ★ 旧媒体实体与描述关系必须消失。 - // - // ★ 判据口径变了(2026-10-04):原来断言「Recall 结果里没有 Type=='Media'」。 - // 但 media → legacy-entity 块的迁移会给那块打上 semantic_type='Media' - // (迁移保留源实体的 type,这是对的),而 Recall 切块后返回的正是**块** —— - // 于是判据把「块」误判成「残留的旧实体」。 - // - // 判据的**意图**是「旧表 entities 里不该还有它」,所以直接查旧表: - // 与实现无关,也不受迁移是否保留 type 影响。 - res, err := g.Recall([]string{"图片 " + digest[:12]}, nil, 2, "") - if err != nil { - t.Fatal(err) - } - // 块侧可以存在同名同类型的块 —— 那是迁移的**成果**,不是残留。 - // 但必须至少确认召回没把旧实体当块带出来(blockKey 为空 = 旧表行)。 - for _, e := range res.Entities { - if e.Type == "Media" && e.IsLegacyRow() { - t.Fatalf("旧媒体实体仍存在(旧表行): %+v", e) - } - } + // ★ 旧媒体实体必须消失(直接查旧表,与实现无关)。 legacyMedia, err := g.CountLegacyEntitiesByType("Media") if err != nil { t.Fatalf("查旧媒体实体: %v", err) @@ -708,13 +630,17 @@ func TestMigrateLegacyMediaEntities(t *testing.T) { if legacyMedia != 0 { t.Errorf("旧表里仍有 %d 个媒体实体", legacyMedia) } - // 块必须挂回原句子 + // 块必须挂回原句子,且字节随块可取回 got, err := g.BlocksForNode("block", sid) if err != nil { t.Fatal(err) } - if len(got) != 1 || got[0].PayloadDigest != digest { - t.Fatalf("句子应持有原生块,实际 %+v", got) + if len(got) != 1 || got[0].ID != b.ID { + t.Fatalf("句子应持有原生块 %s,实际 %+v", b.ID, got) + } + gotBytes, err := g.BlockData(b.ID) + if err != nil || string(gotBytes) != string(b.Data) { + t.Fatalf("迁移后块字节应可取回: %v / %v", gotBytes, err) } // 幂等:再跑一遍不应重复建块 @@ -730,9 +656,9 @@ func TestMigrateLegacyMediaEntities(t *testing.T) { func TestCleanupOrphanedSentences_KeepsBlockBackedSentences(t *testing.T) { // 旧媒体实体被删除后,承载它的句子可能再无关系引用, // 但它还挂着媒体块——清理孤儿句子时不能把它删掉。 - a, g, ms := newGraphMediaAgent(t) + _, g := newGraphMediaAgent(t) - digest, _ := ms.Put([]byte("orphan-img"), media.Item{MIME: "image/png"}) + b := mediaBlock("image/png", []byte("orphan-img")) sentence := "只靠媒体块存活的句子。" ids, _, _, err := g.CommitWithMedia([]memory.Triple{{ Subject: "媒体载体", Relation: "包含", Object: "内容", SentenceText: sentence, @@ -742,14 +668,10 @@ func TestCleanupOrphanedSentences_KeepsBlockBackedSentences(t *testing.T) { } sid := ids[sentence] - b, ok := a.blockFromDigest(digest) - if !ok { - t.Fatal("blockFromDigest 失败") - } if err := g.PutMemoryBlocks([]memory.MemoryBlock{b}); err != nil { t.Fatal(err) } - // ★ 端点 kind 也从 "sentence" 改成 "block"(原句由块承载) + // 端点 kind 都是 "block"(原句由块承载) if err := g.AddMemoryBlockEdge("block", sid, "block", b.ID, "contains"); err != nil { t.Fatal(err) } @@ -807,13 +729,7 @@ func TestSentenceIDsFromRelations(t *testing.T) { } func TestMediaBlocksHeldByDocumentSurviveDeletion(t *testing.T) { - // 文档持有的一等块把内容钉住;文档被删后块随之消失,内容才可回收。 - _, _, ms := newGraphMediaAgent(t) - - digest, err := ms.Put([]byte("doc image"), media.Item{MIME: "image/png"}) - if err != nil { - t.Fatal(err) - } + // 文档持有的一等块把内容钉住;文档被删后块随之消失,字节随块同生灭。 dir := t.TempDir() ds := document.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords) if err := ds.Start(); err != nil { @@ -821,32 +737,26 @@ func TestMediaBlocksHeldByDocumentSurviveDeletion(t *testing.T) { } defer ds.Stop() - it, _ := ms.Stat(digest) + b := mediaBlock("image/png", []byte("doc image")) doc := &document.Doc{ ID: "doc_1", Summary: "带图的文档", Content: "正文", - Blocks: []memory.MemoryBlock{{ID: "blk_doc_1", Modality: memory.BlockImage, - PayloadDigest: it.Digest, MIME: it.MIME, Size: it.Size}}, + Blocks: []memory.MemoryBlock{b}, } if err := ds.Insert(doc); err != nil { t.Fatal(err) } - // 文档仍持有块 → 内容在 - if _, err := ms.Stat(digest); err != nil { - t.Fatal("有文档块持有内容时不该被清") + // 文档仍持有块 → 字节在块里可读 + blocks := ds.Blocks() + if len(blocks) != 1 || string(blocks[0].Data) != "doc image" { + t.Fatalf("文档应持有带字节的块,实际 %+v", blocks) } - // 删除文档 → 一并删除其内容(与文本块一致:删块即删内容) + // 删除文档 → 块随之消失,字节随之消失(删块即删内容) ds.Remove(doc.ID) if blocks := ds.Blocks(); len(blocks) != 0 { t.Fatalf("删除文档后不该还有块,实际 %+v", blocks) } - if err := ms.Delete(digest); err != nil { - t.Fatal(err) - } - if _, err := ms.Stat(digest); err == nil { - t.Fatal("删除后内容应已移除") - } } // TestFormatRecallRelations_SurfacesSentence 锁死「从图谱回到原文」: diff --git a/internal/agent/core/inputunify_test.go b/internal/agent/core/inputunify_test.go index 72863d33..d975a3ef 100644 --- a/internal/agent/core/inputunify_test.go +++ b/internal/agent/core/inputunify_test.go @@ -9,28 +9,27 @@ import ( agentIO "github.com/JianFeeeee/HomeAgent/internal/agent/io" "github.com/JianFeeeee/HomeAgent/internal/memory" "github.com/JianFeeeee/HomeAgent/internal/memory/document" - "github.com/JianFeeeee/HomeAgent/internal/memory/media" pubsdk "github.com/JianFeeeee/homeagentsdk/sdk" ) // 统一输入主干(processInput / resolveInput / injectedBlocks)与 -// 模型可调用工具的媒体接线测试。 +// 模型可调用工具的媒体接线测试(块 union 化之后)。 // -// 这一层此前的结构性缺陷:text 与 image/audio 各有一个 process 函数, +// 这层此前的结构性缺陷:text 与 image/audio 各有一个 process 函数, // 媒体那条缺了去重、no_memory、通道 Cleaner、中断语义、EventRawInput 五项。 // 归一成一条主干后,这些行为对所有模态一致——下面的断言就是这个不变量。 -func newInputTestAgent(t *testing.T) (*Agent, *media.Store) { +func newInputTestAgent(t *testing.T) *Agent { t.Helper() dir := t.TempDir() - ms, err := media.New(filepath.Join(dir, "media")) + g, err := memory.NewGraphDB(filepath.Join(dir, "graph.db")) if err != nil { - t.Fatalf("media.New: %v", err) + t.Fatalf("NewGraphDB: %v", err) } - t.Cleanup(func() { ms.Close() }) + t.Cleanup(func() { g.Close() }) - return &Agent{mediaStore: ms}, ms + return &Agent{memory: g} } // ---------- injectedBlocks ---------- @@ -107,7 +106,7 @@ func TestInjectedBlocks_AcceptsBothStaticTypes(t *testing.T) { // ---------- resolveInput ---------- func TestResolveInput_UnifiesAllModalities(t *testing.T) { - a, _ := newInputTestAgent(t) + a := newInputTestAgent(t) t.Run("用户上传图片", func(t *testing.T) { in, ok := a.resolveInput(&agentIO.InputEvent{ @@ -191,60 +190,51 @@ func TestResolveInput_UnifiesAllModalities(t *testing.T) { }) } -// ---------- 模型工具侧:memory_digests 结构化传递 ---------- +// ---------- 模型工具侧:media_refs 结构化传递 ---------- -// 模型只知道 digest(从对话或 memory_recall 的「关联媒体」读到)。 -// 它不再需要自己拼任何标记:digest 作为结构化字段随三元组提交。 -func TestResolveMediaDigestsAndNoMarkerText(t *testing.T) { - a, ms := newInputTestAgent(t) - digest, err := ms.Put([]byte("marker-bytes"), media.Item{MIME: "image/png"}) - if err != nil { - t.Fatalf("Put: %v", err) +// 模型只知道块 ID(从对话或 memory_recall 的「关联媒体」读到)。 +// 它不再需要自己拼任何标记:块引用作为结构化字段随三元组提交。 +func TestResolveMediaRefsAndNoMarkerText(t *testing.T) { + a := newInputTestAgent(t) + b := mediaBlock("image/png", []byte("marker-bytes")) + if err := a.memory.PutMemoryBlocks([]memory.MemoryBlock{b}); err != nil { + t.Fatalf("PutMemoryBlocks: %v", err) } - t.Run("短digest补全", func(t *testing.T) { - got := a.resolveMediaDigests([]string{digest[:12]}) - if len(got) != 1 || got[0] != digest { - t.Fatalf("短 digest 应补全为完整 digest,得到 %v", got) + t.Run("短引用补全", func(t *testing.T) { + got := a.resolveMediaRefs([]string{b.ID[:12]}) + if len(got) != 1 || got[0] != b.ID { + t.Fatalf("短引用应补全为完整块 ID,得到 %v", got) } }) - t.Run("无法解析的digest被丢弃", func(t *testing.T) { - if got := a.resolveMediaDigests([]string{"ffffffffffff"}); len(got) != 0 { - t.Errorf("不存在的 digest 不该保留: %v", got) + t.Run("无法解析的引用被丢弃", func(t *testing.T) { + if got := a.resolveMediaRefs([]string{"ffffffffffff"}); len(got) != 0 { + t.Errorf("不存在的块引用不该保留: %v", got) } }) - t.Run("无媒体存储时返回nil", func(t *testing.T) { + t.Run("无图库时返回nil", func(t *testing.T) { bare := &Agent{} - if got := bare.resolveMediaDigests([]string{digest}); got != nil { - t.Errorf("无媒体存储时应返回 nil: %v", got) + if got := bare.resolveMediaRefs([]string{b.ID}); got != nil { + t.Errorf("无图库时应返回 nil: %v", got) } }) } // 句子文本必须保持原样:媒体归属走结构化块边,不往文本里贴 marker。 func TestMemoryCommit_DoesNotPolluteSentenceText(t *testing.T) { - dir := t.TempDir() - g, err := memory.NewGraphDB(filepath.Join(dir, "graph.db")) - if err != nil { + a := newInputTestAgent(t) + b := mediaBlock("image/png", []byte("clean-sentence")) + if err := a.memory.PutMemoryBlocks([]memory.MemoryBlock{b}); err != nil { t.Fatal(err) } - defer g.Close() - ms, err := media.New(filepath.Join(dir, "media")) - if err != nil { - t.Fatal(err) - } - defer ms.Close() - a := &Agent{memory: g, mediaStore: ms} - - digest, _ := ms.Put([]byte("clean-sentence"), media.Item{MIME: "image/png"}) sentence := "用户发来一张图。" triples := []memory.Triple{{ Subject: "用户", Relation: "发来", Object: "图片", SentenceText: sentence, - MediaDigests: a.resolveMediaDigests([]string{digest[:12]}), + MediaDigests: a.resolveMediaRefs([]string{b.ID[:12]}), }} if _, _, _, err := a.commitTriplesWithMedia(triples, "s1", 0, nil); err != nil { t.Fatal(err) @@ -260,49 +250,51 @@ func TestMemoryCommit_DoesNotPolluteSentenceText(t *testing.T) { if res.Relations[0].SentenceText != sentence { t.Errorf("句子文本被污染: %q", res.Relations[0].SentenceText) } - // ★ 挂载点已从「sentences 表行号」改成「原句块 ID」 - // (CommitWithMedia 返回值随之改为 map[string]string) + // ★ 挂载点是「原句块 ID」 blocks, err := a.memory.BlocksForNode("block", memory.SentenceBlockID(res.Relations[0].SentenceText)) if err != nil { t.Fatal(err) } - if len(blocks) != 1 || blocks[0].PayloadDigest != digest { + if len(blocks) != 1 || blocks[0].ID != b.ID { t.Errorf("块应挂到句子,实际 %+v", blocks) } } -// ---------- resolveMediaDigests ---------- +// ---------- resolveMediaRefs ---------- -func TestResolveMediaDigests(t *testing.T) { - a, ms := newInputTestAgent(t) - d1, _ := ms.Put([]byte("one"), media.Item{MIME: "image/png"}) - d2, _ := ms.Put([]byte("two"), media.Item{MIME: "image/png"}) +func TestResolveMediaRefs(t *testing.T) { + a := newInputTestAgent(t) + b1 := mediaBlock("image/png", []byte("one")) + b2 := mediaBlock("image/png", []byte("two")) + if err := a.memory.PutMemoryBlocks([]memory.MemoryBlock{b1, b2}); err != nil { + t.Fatal(err) + } - got := a.resolveMediaDigests([]string{d1[:10], d2, d1, "ffffffffffff"}) + got := a.resolveMediaRefs([]string{b1.ID[:10], b2.ID, b1.ID, "ffffffffffff"}) if len(got) != 2 { t.Fatalf("got = %v,期望 2 条(去重 + 丢弃无法解析的)", got) } - for _, d := range got { - if len(d) != 64 { - t.Errorf("应返回完整 digest,实际 %q", d) + for _, id := range got { + if !strings.HasPrefix(id, "blk_media_") { + t.Errorf("应返回完整块 ID,实际 %q", id) } } - if a.resolveMediaDigests(nil) != nil { + if a.resolveMediaRefs(nil) != nil { t.Error("空输入应返回 nil") } bare := &Agent{} - if bare.resolveMediaDigests([]string{d1}) != nil { - t.Error("无媒体存储时应返回 nil") + if bare.resolveMediaRefs([]string{b1.ID}) != nil { + t.Error("无图库时应返回 nil") } } // ---------- 文档持有的一等记忆块 ---------- func TestDocCommit_StoresBlocks(t *testing.T) { - // doc_commit 带 media_digests 时,媒体应作为一等块直接存在文档上, - // 并随 doc 一起持久化(不再靠 media_refs 保活)。 + // doc_commit 带媒体时,媒体应作为一等块直接存在文档上, + // 并随 doc 一起持久化(块自带字节,不靠外部引用保活)。 dir := t.TempDir() ds := document.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords) if err := ds.Start(); err != nil { @@ -310,21 +302,10 @@ func TestDocCommit_StoresBlocks(t *testing.T) { } defer ds.Stop() - ms, err := media.New(filepath.Join(dir, "media")) - if err != nil { - t.Fatal(err) - } - defer ms.Close() + b1 := mediaBlock("image/png", []byte("doc-one")) + b2 := mediaBlock("image/png", []byte("doc-two")) - d1, _ := ms.Put([]byte("doc-one"), media.Item{MIME: "image/png"}) - d2, _ := ms.Put([]byte("doc-two"), media.Item{MIME: "image/png"}) - - doc := &document.Doc{ID: "doc_x", Summary: "s", Content: "c"} - for _, d := range []string{d1, d2} { - if b, ok := (&Agent{mediaStore: ms}).blockFromDigest(d); ok { - doc.Blocks = append(doc.Blocks, b) - } - } + doc := &document.Doc{ID: "doc_x", Summary: "s", Content: "c", Blocks: []memory.MemoryBlock{b1, b2}} if err := ds.Insert(doc); err != nil { t.Fatal(err) } @@ -335,27 +316,23 @@ func TestDocCommit_StoresBlocks(t *testing.T) { } seen := map[string]bool{} for _, b := range blocks { - seen[b.PayloadDigest] = true + seen[b.ID] = true } - if !seen[d1] || !seen[d2] { - t.Errorf("块 digest 不对: %+v", blocks) + if !seen[b1.ID] || !seen[b2.ID] { + t.Errorf("块身份不对: %+v", blocks) } } // ---------- 文档持有块标签(doc_query 展示用) ---------- func TestBlockLabelsForDoc(t *testing.T) { - a, ms := newInputTestAgent(t) - digest, _ := ms.Put([]byte("ctx-bytes"), media.Item{MIME: "image/png"}) - b, ok := a.blockFromDigest(digest) - if !ok { - t.Fatal("blockFromDigest 失败") - } + a := newInputTestAgent(t) + b := mediaBlock("image/png", []byte("ctx-bytes")) t.Run("从文档持有的一等块渲染", func(t *testing.T) { got := a.blockLabelsForDoc(&document.Doc{ID: "doc_1", Blocks: []memory.MemoryBlock{b}}) - if !strings.Contains(got, shortDigest(digest)) { - t.Errorf("标签应含短 digest: %q", got) + if !strings.Contains(got, shortID(b.ID)) { + t.Errorf("标签应含短块 ID: %q", got) } if !strings.Contains(got, "image/png") { t.Errorf("标签应含 MIME: %q", got) @@ -367,42 +344,28 @@ func TestBlockLabelsForDoc(t *testing.T) { t.Errorf("应返回空串,实际 %q", got) } }) - - t.Run("无媒体存储", func(t *testing.T) { - bare := &Agent{} - if got := bare.blockLabelsForDoc(&document.Doc{ID: "doc_x"}); got != "" { - t.Errorf("无媒体存储时应返回空串,实际 %q", got) - } - }) } -// ---------- mediaLabel ---------- +// ---------- blockLabel ---------- -// 媒体标签的唯一生成处:只含 MIME 与短 digest,不含任何生成的描述。 -func TestMediaLabel(t *testing.T) { - a, ms := newInputTestAgent(t) - _ = a - - digest, _ := ms.Put([]byte("labelled"), media.Item{MIME: "image/png"}) - it, err := ms.Stat(digest) - if err != nil { - t.Fatal(err) - } - got := mediaLabel(it) +// 媒体标签的唯一生成处:只含 MIME 与短块 ID,不含任何生成的描述。 +func TestBlockLabel(t *testing.T) { + b := mediaBlock("image/png", []byte("labelled")) + got := blockLabel(&b) if !strings.Contains(got, "image/png") { t.Errorf("标签应含 MIME: %q", got) } - if !strings.Contains(got, shortDigest(digest)) { - t.Errorf("必须带短 digest 供反查: %q", got) + if !strings.Contains(got, shortID(b.ID)) { + t.Errorf("必须带短块 ID 供反查: %q", got) } - if got := mediaLabel(nil); got != "" { + if got := blockLabel(nil); got != "" { t.Errorf("nil 应返回空串,实际 %q", got) } } // ---------- 模型工具端到端:memory_commit / doc_commit / doc_query ---------- -func newToolTestAgent(t *testing.T) (*Agent, *media.Store) { +func newToolTestAgent(t *testing.T) *Agent { t.Helper() dir := t.TempDir() @@ -418,27 +381,23 @@ func newToolTestAgent(t *testing.T) (*Agent, *media.Store) { } t.Cleanup(func() { ds.Stop() }) - ms, err := media.New(filepath.Join(dir, "media")) - if err != nil { - t.Fatalf("media.New: %v", err) - } - t.Cleanup(func() { ms.Close() }) - emb := memory.NewStaticEmbedder("") a := &Agent{ - id: "tester", - memory: g, - docStore: ds, - mediaStore: ms, - context: NewRelevanceContext("", emb), + id: "tester", + memory: g, + docStore: ds, + context: NewRelevanceContext("", emb), } - return a, ms + return a } -// memory_commit 带 media_digests:三元组入库后必须能从句子反查回那份字节。 +// memory_commit 带 media_refs:三元组入库后必须能从句子反查回那份字节。 func TestToolMemoryCommit_BindsMedia(t *testing.T) { - a, ms := newToolTestAgent(t) - digest, _ := ms.Put([]byte("commit-bytes"), media.Item{MIME: "image/png"}) + a := newToolTestAgent(t) + b := mediaBlock("image/png", []byte("commit-bytes")) + if err := a.memory.PutMemoryBlocks([]memory.MemoryBlock{b}); err != nil { + t.Fatal(err) + } out := a.executeMemoryTool(agentAPI.ToolCall{ Name: "memory_commit", @@ -448,7 +407,7 @@ func TestToolMemoryCommit_BindsMedia(t *testing.T) { "subject": "配色方案", "relation": "参考", "object": "三色带图", - "media_digests": []interface{}{digest[:12]}, + "media_digests": []interface{}{b.ID[:12]}, }, }, }, @@ -462,26 +421,29 @@ func TestToolMemoryCommit_BindsMedia(t *testing.T) { t.Fatalf("Recall: %v", err) } // ★ 断言改用**原句文本**(2026-10-04): - // SentenceID 是旧 sentences 表行号,退场后恒为 0 —— - // 而它是这里唯一与「有没有句子落点」有关的字段,于是恒红。 + // SentenceID 是旧 sentences 表行号,退场后恒为 0。 if len(res.Relations) == 0 || res.Relations[0].SentenceText == "" { t.Fatal("没有句子落点 —— 媒体引用无从挂起") } - // ★ 挂载点已从「sentences 表行号」改成「原句块 ID」 - // (CommitWithMedia 返回值随之改为 map[string]string) + // ★ 挂载点是「原句块 ID」 blocks, err := a.memory.BlocksForNode("block", memory.SentenceBlockID(res.Relations[0].SentenceText)) if err != nil { t.Fatalf("BlocksForNode: %v", err) } - if len(blocks) != 1 || blocks[0].PayloadDigest != digest { - t.Errorf("句子块 = %+v,期望 [%s]", blocks, digest) + if len(blocks) != 1 || blocks[0].ID != b.ID { + t.Errorf("句子块 = %+v,期望 [%s]", blocks, b.ID) + } + // 字节随块可取回(块即 union) + data, err := a.memory.BlockData(b.ID) + if err != nil || string(data) != "commit-bytes" { + t.Errorf("块字节应可取回: %q / %v", data, err) } } -// 不带 media_digests 时行为与本特性上线前一致(不多写句子、不报错)。 +// 不带 media_refs 时行为与本特性上线前一致(不多写句子、不报错)。 func TestToolMemoryCommit_WithoutMedia(t *testing.T) { - a, _ := newToolTestAgent(t) + a := newToolTestAgent(t) out := a.executeMemoryTool(agentAPI.ToolCall{ Name: "memory_commit", Arguments: map[string]interface{}{ @@ -500,7 +462,7 @@ func TestToolMemoryCommit_WithoutMedia(t *testing.T) { // sentence_text 必须透传:丢了它,图谱就回不到原文。 func TestToolMemoryCommit_CarriesSentenceText(t *testing.T) { - a, _ := newToolTestAgent(t) + a := newToolTestAgent(t) a.executeMemoryTool(agentAPI.ToolCall{ Name: "memory_commit", Arguments: map[string]interface{}{ @@ -523,17 +485,20 @@ func TestToolMemoryCommit_CarriesSentenceText(t *testing.T) { } } -// doc_commit 带 media_digests:媒体成为文档直接持有的一等块;正文保持原样。 +// doc_commit 带 media_refs:媒体成为文档直接持有的一等块;正文保持原样。 func TestToolDocCommit_BindsMedia(t *testing.T) { - a, ms := newToolTestAgent(t) - digest, _ := ms.Put([]byte("doc-commit-bytes"), media.Item{MIME: "image/png"}) + a := newToolTestAgent(t) + b := mediaBlock("image/png", []byte("doc-commit-bytes")) + if err := a.memory.PutMemoryBlocks([]memory.MemoryBlock{b}); err != nil { + t.Fatal(err) + } out := a.executeDocTool(agentAPI.ToolCall{ Name: "doc_commit", Arguments: map[string]interface{}{ "content": "这是一篇带图的笔记正文。", "summary": "带图笔记", - "media_digests": []interface{}{digest[:12]}, + "media_digests": []interface{}{b.ID[:12]}, }, }) if !strings.Contains(out, "关联") { @@ -549,27 +514,30 @@ func TestToolDocCommit_BindsMedia(t *testing.T) { t.Errorf("正文不该被媒体标记污染: %q", d.Content) } var held bool - for _, b := range d.Blocks { - if b.PayloadDigest == digest { + for _, blk := range d.Blocks { + if blk.ID == b.ID { held = true } } if !held { - t.Errorf("文档应持有一等记忆块 [%s],实际 %+v", digest, d.Blocks) + t.Errorf("文档应持有一等记忆块 [%s],实际 %+v", b.ID, d.Blocks) } } // doc_query 必须把媒体说明附在返回值里,否则模型检索到带图文档也不知道有图。 func TestToolDocQuery_ShowsMedia(t *testing.T) { - a, ms := newToolTestAgent(t) - digest, _ := ms.Put([]byte("query-bytes"), media.Item{MIME: "image/png"}) + a := newToolTestAgent(t) + b := mediaBlock("image/png", []byte("query-bytes")) + if err := a.memory.PutMemoryBlocks([]memory.MemoryBlock{b}); err != nil { + t.Fatal(err) + } a.executeDocTool(agentAPI.ToolCall{ Name: "doc_commit", Arguments: map[string]interface{}{ "content": "紫蓝红三色带配色说明正文", "summary": "紫蓝红三色带", - "media_digests": []interface{}{digest}, + "media_digests": []interface{}{b.ID}, }, }) @@ -581,7 +549,7 @@ func TestToolDocQuery_ShowsMedia(t *testing.T) { // 正文进的是 cold_storage 事件(工具返回值只给引用编号),媒体说明也在那里。 var found bool for _, e := range a.context.Recent(10) { - if strings.Contains(e.Response, shortDigest(digest)) { + if strings.Contains(e.Response, shortID(b.ID)) { found = true } } @@ -590,10 +558,9 @@ func TestToolDocQuery_ShowsMedia(t *testing.T) { } } -// 无媒体存储时三个工具的行为与本特性上线前完全一致。 -func TestTools_NilMediaStoreDegrades(t *testing.T) { - a, _ := newToolTestAgent(t) - a.mediaStore = nil +// 引用了不存在的块时三个工具的行为与本特性上线前完全一致(不报错、不挂边)。 +func TestTools_UnknownRefDegrades(t *testing.T) { + a := newToolTestAgent(t) out := a.executeMemoryTool(agentAPI.ToolCall{ Name: "memory_commit", @@ -607,17 +574,17 @@ func TestTools_NilMediaStoreDegrades(t *testing.T) { }, }, nil) if strings.Contains(out, "失败") { - t.Errorf("无媒体存储时提交不该失败: %q", out) + t.Errorf("引用未知块时提交不该失败: %q", out) } out = a.executeDocTool(agentAPI.ToolCall{ Name: "doc_commit", Arguments: map[string]interface{}{ - "content": "无媒体存储的文档", + "content": "引用未知块的文档", "media_digests": []interface{}{"aabbccddeeff"}, }, }) if strings.Contains(out, "失败") { - t.Errorf("无媒体存储时文档写入不该失败: %q", out) + t.Errorf("引用未知块时文档写入不该失败: %q", out) } } diff --git a/internal/agent/core/medialive_test.go b/internal/agent/core/medialive_test.go index 4b5bb6dd..3b63246a 100644 --- a/internal/agent/core/medialive_test.go +++ b/internal/agent/core/medialive_test.go @@ -1,13 +1,12 @@ //go:build medialive -// 媒体记忆自动触发链的集成测试。 +// 媒体记忆自动触发链的集成测试(块 union 化之后)。 // // 与其他媒体测试的区别:**不手工调用任何一步**。这里只做两件事—— // 往 IOManager 注入一个 image 事件,然后等。之后全部由生产代码自己走: // -// processMediaInput → captureBlockMedia(入 CAS) +// processMediaInput → captureBlockMedia(字节直接进块) // → Prune(L0→L2 块迁移) -// → describePendingMedia(真实视觉模型生成描述) // → archiveColdDocs → commitTriplesWithMedia → bindSentenceBlocks(L2→L3) // → 第二轮提问,验证 agent 真能召回 // @@ -29,12 +28,12 @@ package core import ( "bytes" "compress/zlib" + "encoding/base64" "encoding/binary" "fmt" "hash/crc32" "os" "path/filepath" - "strconv" "strings" "testing" "time" @@ -44,7 +43,6 @@ import ( luaVM "github.com/JianFeeeee/HomeAgent/internal/lua" "github.com/JianFeeeee/HomeAgent/internal/memory" "github.com/JianFeeeee/HomeAgent/internal/memory/document" - "github.com/JianFeeeee/HomeAgent/internal/memory/media" "github.com/JianFeeeee/HomeAgent/pkg/types" ) @@ -138,13 +136,12 @@ func livePNG(t *testing.T, w, h int, colors [][3]byte) []byte { type liveEnv struct { agent *Agent io *agentIO.IOManager - mediaSt *media.Store docStore *document.Store graph *memory.GraphDB dir string } -// newLiveEnv 构造真 Agent:真 provider、真 CAS、真图库、真文档库。 +// newLiveEnv 构造真 Agent:真 provider、真图库、真文档库。 // // 不注册任何插件:本测试关心记忆链路,插件会引入无关的外部副作用 // (网络轮询、写文件),而且生产插件目录里的进程不该被测试碰到。 @@ -171,12 +168,6 @@ func newLiveEnv(t *testing.T, c liveCfg) *liveEnv { t.Fatalf("set default provider: %v", err) } - ms, err := media.New(filepath.Join(dir, "media")) - if err != nil { - t.Fatalf("media store: %v", err) - } - t.Cleanup(func() { ms.Close() }) - graph, err := memory.NewGraphDB(filepath.Join(dir, "graph.db")) if err != nil { t.Fatalf("graph: %v", err) @@ -199,7 +190,6 @@ func newLiveEnv(t *testing.T, c liveCfg) *liveEnv { IO: io, Memory: graph, DocStore: docStore, - MediaStore: ms, StageHost: NewStageHost(), MaxContextSize: 3, // 故意压低:第二轮就能触发 Prune 归档 InputProcessing: types.InputProcessingConfig{}, @@ -216,7 +206,7 @@ func newLiveEnv(t *testing.T, c liveCfg) *liveEnv { } }() - return &liveEnv{agent: a, io: io, mediaSt: ms, docStore: docStore, graph: graph, dir: dir} + return &liveEnv{agent: a, io: io, docStore: docStore, graph: graph, dir: dir} } // TestMediaLive_AutoTriggerChain 全自动触发链:只注入事件,不手工调任何一步。 @@ -229,7 +219,7 @@ func TestMediaLive_AutoTriggerChain(t *testing.T) { img := livePNG(t, 96, 96, [][3]byte{{128, 0, 255}, {0, 64, 255}, {255, 0, 0}}) t.Logf("测试图片: %d 字节(紫/蓝/红三色带)", len(img)) - // ── 阶段 1:注入 image 事件,验证 CAS 自动落盘 ── + // ── 阶段 1:注入 image 事件,验证块自动捕获 ── // // 直接调 handleInput 而不启 eventLoop:eventLoop 是纯转发(select → // handleInput),走同一条代码路径,但同步调用让断言不必猜时序。 @@ -249,33 +239,21 @@ func TestMediaLive_AutoTriggerChain(t *testing.T) { a.handleInput(evt) t.Logf("第一轮(含真实 LLM 往返)耗时 %.1fs", time.Since(t0).Seconds()) - // 媒体不再有文字描述:CAS 里只有字节、元数据与向量。 - // 这里直接按 digest 定位刚落的图(不再有 Pending 队列)。 - st := env.mediaSt.Stats() - if st["count"].(int) != 1 { - t.Fatalf("CAS 应自动收到 1 张图,实际 %v 张(captureBlockMedia 未被触发?)", st["count"]) - } - var digest string + // 媒体不再有文字描述:字节直接在块的 Data 里,身份内容派生。 + // 这里直接按块字节定位刚捕获的图(不再有 Pending 队列)。 + var blockID string var found bool for _, e := range a.context.Recent(0) { for _, b := range e.Blocks { - digest, found = b.PayloadDigest, true + if bytes.Equal(b.Data, img) { + blockID, found = b.ID, true + } } } if !found { - t.Fatal("无法从上下文块定位刚落盘的图") - } - it0, err := env.mediaSt.Stat(digest) - if err != nil { - t.Fatal(err) - } - t.Logf("✓ 阶段1 CAS 自动落盘: digest=%s size=%d tool=%s", - digest[:12], it0.Size, it0.Tool) - - stored, err := env.mediaSt.Get(digest) - if err != nil || !bytes.Equal(stored, img) { - t.Fatalf("落盘内容与原图不一致 (err=%v)", err) + t.Fatal("无法从上下文块定位刚捕获的图(captureBlockMedia 未被触发?)") } + t.Logf("✓ 阶段1 块自动捕获: id=%s", shortID(blockID)) // ── 阶段 2:一等记忆块自动挂到 ContextEvent 上 ── // @@ -285,11 +263,11 @@ func TestMediaLive_AutoTriggerChain(t *testing.T) { for _, e := range a.context.Recent(0) { if len(e.Blocks) > 0 { evtID = e.ID - if strings.Contains(e.Input, digest[:12]) { + if strings.Contains(e.Input, shortID(blockID)) { t.Error("事件 Input 里被写入了媒体标记——描述式索引链应该已经拆除") } - if e.Blocks[0].PayloadDigest != digest { - t.Fatalf("事件持有的块 digest 不对: %+v", e.Blocks) + if !bytes.Equal(e.Blocks[0].Data, img) { + t.Fatalf("事件持有的块字节不对: %+v", e.Blocks[0]) } break } @@ -300,14 +278,18 @@ func TestMediaLive_AutoTriggerChain(t *testing.T) { t.Logf("✓ 阶段2 块自动绑定: event=%s", evtID) // ── 阶段 3:媒体只按自己的向量被索引,不再生成任何描述 ── - if it, err := env.mediaSt.Stat(digest); err != nil { - t.Fatal(err) - } else if len(it.Vec) == 0 { - // 未配置多模态空间时就没有向量——这是合法的降级状态, - // 但要明确报出来,而不是靠描述文本假装能检索。 - t.Log("未配置多模态空间:本图无向量,之后只能靠块结构召回 digest") - } else { - t.Logf("✓ 阶段3 已写入原生向量: dim=%d", len(it.Vec)) + for _, e := range a.context.Recent(0) { + for _, b := range e.Blocks { + if b.ID == blockID { + if len(b.Vector) == 0 { + // 未配置多模态空间时就没有向量——这是合法的降级状态, + // 但要明确报出来,而不是靠描述文本假装能检索。 + t.Log("未配置多模态空间:本图无向量,之后只能靠块结构召回") + } else { + t.Logf("✓ 阶段3 已写入原生向量: dim=%d", len(b.Vector)) + } + } + } } // ── 阶段 4:Prune 自动把块从 L0 迁移到 L2 ── @@ -338,7 +320,7 @@ func TestMediaLive_AutoTriggerChain(t *testing.T) { docRefsFound := "" for _, d := range env.docStore.RecentDocs(20) { for _, b := range d.Blocks { - if b.PayloadDigest == digest { + if b.ID == blockID { docRefsFound = d.ID } } @@ -349,18 +331,13 @@ func TestMediaLive_AutoTriggerChain(t *testing.T) { // 同一块不能同时留在 L0。 for _, e := range a.context.Recent(0) { for _, b := range e.Blocks { - if b.PayloadDigest == digest { + if b.ID == blockID { t.Errorf("块仍留在 L0(evt %s),违反单层不变量", e.ID) } } } t.Logf("✓ 阶段4 块自动迁移: context/%s → document/%s", evtID, docRefsFound) - // 迁移全程内容必须可读:块虽换了层,字节仍在。 - if _, err := env.mediaSt.Get(digest); err != nil { - t.Fatalf("迁移后内容不可读: %v", err) - } - // ── 阶段 5:archiveColdDocs 自动把块连到 L3 文档节点 ── // // FindColdDocs(72h, 2) 要求文档足够"冷",测试里新建的文档不满足, @@ -377,25 +354,23 @@ func TestMediaLive_AutoTriggerChain(t *testing.T) { // 块可能以 document --contains--> block(文档归档)或 // sentence --contains--> block(对话三元组)两种边存在。 sentRefs := 0 - var boundSentence int64 + var boundSentence string docBound := 0 - rows, err := env.graph.Recall(nil, nil, 1, "") - if err != nil { - t.Fatalf("graph recall: %v", err) - } - t.Logf("图库实体数 %d", len(rows.Entities)) docBlocks, err := env.graph.BlocksForNode("document", docRefsFound) if err != nil { t.Fatal(err) } docBound = len(docBlocks) - // 句子 id 是自增整数,扫前若干个足够覆盖本测试写入的量 - for sid := int64(1); sid <= 40; sid++ { - blocks, err := env.graph.BlocksForNode("sentence", strconv.FormatInt(sid, 10)) - if err == nil && len(blocks) > 0 { - sentRefs += len(blocks) - if boundSentence == 0 { - boundSentence = sid + // 原句块 ID 按 SentenceBlockID 现算(sentences 表退场后行号不存在) + for _, e := range a.context.Recent(0) { + if e.Input != "" { + sid := memory.SentenceBlockID(e.Input) + blocks, err := env.graph.BlocksForNode("block", sid) + if err == nil && len(blocks) > 0 { + sentRefs += len(blocks) + if boundSentence == "" { + boundSentence = sid + } } } } @@ -404,20 +379,20 @@ func TestMediaLive_AutoTriggerChain(t *testing.T) { } else if docBound > 0 { t.Logf("✓ 阶段5 L3 自动写入: 文档 %s 持有 %d 个块", docRefsFound, docBound) got := docBlocks - if got[0].PayloadDigest != digest { - t.Errorf("文档节点持有的块 digest 不对: %+v", got) - } else if raw, err := env.mediaSt.Get(got[0].PayloadDigest); err != nil || !bytes.Equal(raw, img) { + if got[0].ID != blockID { + t.Errorf("文档节点持有的块身份不对: %+v", got) + } else if raw, err := env.graph.BlockData(blockID); err != nil || !bytes.Equal(raw, img) { t.Errorf("从文档块取回的字节与原图不一致 (err=%v)", err) } else { t.Logf("✓ 阶段5 反查取回 %d 字节,与原图逐字节一致", len(raw)) } } else { - t.Logf("✓ 阶段5 L3 自动写入: %d 个句子块,首个 sentences.id=%d", sentRefs, boundSentence) + t.Logf("✓ 阶段5 L3 自动写入: %d 个句子块,首个原句块=%s", sentRefs, shortID(boundSentence)) got, err := env.agent.RecallBlocksForSentence(boundSentence) - if err != nil || len(got) == 0 || got[0].PayloadDigest != digest { + if err != nil || len(got) == 0 || got[0].ID != blockID { t.Errorf("从句子反查块失败: got=%+v err=%v", got, err) - } else if raw, err := env.mediaSt.Get(got[0].PayloadDigest); err != nil || !bytes.Equal(raw, img) { + } else if raw, err := env.graph.BlockData(blockID); err != nil || !bytes.Equal(raw, img) { t.Errorf("从句子取回的字节与原图不一致 (err=%v)", err) } else { t.Logf("✓ 阶段5 反查取回 %d 字节,与原图逐字节一致", len(raw)) @@ -425,7 +400,7 @@ func TestMediaLive_AutoTriggerChain(t *testing.T) { } // ── 阶段 6:内容随块存在,不被单独清理 ── - if _, err := env.mediaSt.Stat(digest); err != nil { + if raw, err := env.graph.BlockData(blockID); err != nil || len(raw) == 0 { t.Fatalf("被记忆块持有的内容不存在了: %v", err) } t.Logf("✓ 阶段6 被持有内容仍在") @@ -477,10 +452,6 @@ func TestMediaLive_AutoTriggerChain(t *testing.T) { t.Skipf("上游 LLM 调用失败,端到端召回无法判定: %s", truncRunes(answer, 160)) } t.Logf("✓ 阶段7 E2E 链路贯通(召回能力取决于是否配置多模态向量空间)") - - st = env.mediaSt.Stats() - t.Logf("收尾: %v 条 / %v 字节 / 类型 %v", - st["count"], st["total_bytes"], st["by_kind"]) } // TestMediaLive_NegativeControl 阴性对照:没有媒体记忆时不该"记得"。 @@ -533,7 +504,7 @@ func TestMediaLive_NegativeControl(t *testing.T) { // mediaB64 返回不带 data URL 前缀的 base64(processMediaInput 自己拼前缀)。 func mediaB64(b []byte) string { - return media.DataURL("image/png", b)[len("data:image/png;base64,"):] + return base64.StdEncoding.EncodeToString(b) } func truncRunes(s string, n int) string { diff --git a/internal/agent/core/medialoop.go b/internal/agent/core/medialoop.go index c07a9f95..0ea7c2cd 100644 --- a/internal/agent/core/medialoop.go +++ b/internal/agent/core/medialoop.go @@ -1,6 +1,7 @@ package core import ( + "fmt" "log" "sync" "sync/atomic" @@ -44,9 +45,10 @@ func (a *Agent) reembedStaleMedia() { } // 只迁图像:多模态空间当前覆盖 text+image,音频/视频不在其内 // (不拿别的模态的向量顶替——那是静默错配)。 + // StaleBlockVectors 只给元数据(lean 列),字节由 reembedOneBlock 按需取。 var targets []memory.MemoryBlock for _, b := range stale { - if b.Modality == memory.BlockImage && len(b.Data) > 0 { + if b.Modality == memory.BlockImage { targets = append(targets, b) } } @@ -102,7 +104,19 @@ func (a *Agent) reembedOneBlock(b memory.MemoryBlock, fp string) error { if mime == "" { mime = "image/png" } - vec, err := a.multimodalSpace.EmbedImageDense(b.Data, mime) + // 负载不在 lean 列里:按块 ID 单取字节(块即 union)。 + data := b.Data + if len(data) == 0 { + var err error + data, err = a.memory.BlockData(b.ID) + if err != nil { + return err + } + } + if len(data) == 0 { + return fmt.Errorf("block %s has no payload", b.ID) + } + vec, err := a.multimodalSpace.EmbedImageDense(data, mime) if err != nil { return err } diff --git a/internal/agent/core/medialoop_test.go b/internal/agent/core/medialoop_test.go index be112f5b..4dae1851 100644 --- a/internal/agent/core/medialoop_test.go +++ b/internal/agent/core/medialoop_test.go @@ -8,80 +8,30 @@ import ( "testing" "github.com/JianFeeeee/HomeAgent/internal/memory" - "github.com/JianFeeeee/HomeAgent/internal/memory/document" - "github.com/JianFeeeee/HomeAgent/internal/memory/media" "github.com/JianFeeeee/HomeAgent/internal/memory/vector" ) -// 媒体与记忆块的生命周期测试。 +// 媒体与记忆块的生命周期测试(块 union 化之后)。 // -// 媒体没有独立生命周期管理(没有 GC、没有引用计数):blob 是记忆块的内容, -// 块的创建/迁移/删除由记忆系统决定。图片也不靠文本描述索引。 +// 媒体没有独立生命周期管理(没有 GC、没有引用计数):字节是记忆块的内容, +// 块的创建/迁移/删除由记忆系统决定。块被删即字节被删——没有独立层要清理。 +// +// 需要特殊对待的只有一项:向量空间更换(换模型/换维度)时, +// 历史图像块的向量要迁移到新空间,否则永远无法被召回。 -func newMediaLoopAgent(t *testing.T) (*Agent, *media.Store) { +func newMediaLoopAgent(t *testing.T) *Agent { t.Helper() dir := t.TempDir() - ms, err := media.New(filepath.Join(dir, "media")) - if err != nil { - t.Fatalf("media.New: %v", err) - } - t.Cleanup(func() { ms.Close() }) - - a := &Agent{mediaStore: ms} - a.ctx, a.cancel = context.WithCancel(context.Background()) - t.Cleanup(a.cancel) - return a, ms -} - -// heldMediaDigests 汇总三层记忆持有的媒体:只有这些才可被召回。 -func TestHeldMediaDigests_CollectsAcrossLayers(t *testing.T) { - a, ms := newMediaLoopAgent(t) - d1, _ := ms.Put([]byte("ctx-layer"), media.Item{MIME: "image/png"}) - d2, _ := ms.Put([]byte("doc-layer"), media.Item{MIME: "image/png"}) - d3, _ := ms.Put([]byte("graph-layer"), media.Item{MIME: "image/png"}) - d4, _ := ms.Put([]byte("orphan"), media.Item{MIME: "image/png"}) - - a.context = NewRelevanceContext("", memory.NewStaticEmbedder("")) - a.context.Append(ContextEvent{Input: "带图的一轮", Blocks: []memory.MemoryBlock{ - {ID: "blk_ctx", Modality: memory.BlockImage, PayloadDigest: d1}, - }}) - - dir := t.TempDir() - bo, ok := a.blockFromDigest(d2) - if !ok { - t.Fatal("blockFromDigest 失败") - } - ds := document.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords) - if err := ds.Start(); err != nil { - t.Fatal(err) - } - defer ds.Stop() - if err := ds.Insert(&document.Doc{ID: "doc_1", Summary: "s", Blocks: []memory.MemoryBlock{bo}}); err != nil { - t.Fatal(err) - } - a.docStore = ds - g, err := memory.NewGraphDB(filepath.Join(dir, "graph.db")) if err != nil { - t.Fatal(err) + t.Fatalf("NewGraphDB: %v", err) } - defer g.Close() - if err := g.PutMemoryBlocks([]memory.MemoryBlock{ - {ID: "blk_g", Modality: memory.BlockImage, PayloadDigest: d3}, - }); err != nil { - t.Fatal(err) - } - a.memory = g + t.Cleanup(func() { g.Close() }) - held := a.heldMediaDigests() - for _, want := range []string{d1, d2, d3} { - if !held[want] { - t.Errorf("层次持有 %s 却不在结果里: %v", shortDigest(want), held) - } - } - if held[d4] { - t.Errorf("无人持有的 %s 不该出现在结果里", shortDigest(d4)) - } + a := &Agent{memory: g} + a.ctx, a.cancel = context.WithCancel(context.Background()) + t.Cleanup(a.cancel) + return a } // fakeSpace 是一个只覆盖图像的假统一空间,用来验证「不在本空间」与 @@ -106,82 +56,49 @@ func (fakeSpace) Close() {} // - 把音频当失败反复重试; // - 更不能拿另一个模型的向量顶替音频(那会污染统一空间且静默)。 func TestReembedStaleMedia_SkipsUnsupportedWithoutFaking(t *testing.T) { - a, ms := newMediaLoopAgent(t) - img, _ := ms.Put([]byte("img-bytes"), media.Item{MIME: "image/png"}) - aud, _ := ms.Put([]byte("aud-bytes"), media.Item{MIME: "audio/wav"}) + a := newMediaLoopAgent(t) + // 图像块与音频块各落一条:字节都进块(union) + img := mediaBlock("image/png", []byte("img-bytes")) + aud := mediaBlock("audio/wav", []byte("aud-bytes")) + if err := a.memory.PutMemoryBlocks([]memory.MemoryBlock{img, aud}); err != nil { + t.Fatal(err) + } a.multimodalSpace = fakeSpace{} a.reembedStaleMedia() - it, err := ms.Stat(img) - if err != nil { - t.Fatal(err) + // 图像块应拿到本空间向量(fp 写回,dim=2) + imgGot, ok, err := a.memory.BlockByIDPrefix(img.ID) + if err != nil || !ok { + t.Fatalf("BlockByIDPrefix(img): %v / %v", ok, err) } - if len(it.Vec) != 2 || it.VecModel != "fake-space" { - t.Fatalf("图像应拿到本空间向量,实际 vec=%v model=%q", it.Vec, it.VecModel) + if len(imgGot.Vector) != 2 || imgGot.Fingerprint != "fake-space" { + t.Fatalf("图像应拿到本空间向量,实际 vec=%v model=%q", imgGot.Vector, imgGot.Fingerprint) } - audIt, err := ms.Stat(aud) - if err != nil { - t.Fatal(err) + // 音频块不得被写入任何向量(不能用别的模型顶替),fp 应保持为空 + audGot, ok, err := a.memory.BlockByIDPrefix(aud.ID) + if err != nil || !ok { + t.Fatalf("BlockByIDPrefix(aud): %v / %v", ok, err) } - if len(audIt.Vec) != 0 || audIt.VecModel != "" { + if len(audGot.Vector) != 0 || audGot.Fingerprint != "" { t.Fatalf("音频不得被写入任何向量(不能用别的模型顶替),实际 vec=%v model=%q", - audIt.Vec, audIt.VecModel) + audGot.Vector, audGot.Fingerprint) } } -// payloadHeld 是删除前的活查询。 -func TestPayloadHeld(t *testing.T) { - a, ms := newMediaLoopAgent(t) - d, _ := ms.Put([]byte("held"), media.Item{MIME: "image/png"}) - if a.payloadHeld(d) { - t.Fatal("尚无块持有时不该报已持有") +// 无多模态空间可用时迁移路径必须整体跳过,不报错也不写脏向量。 +func TestReembedStaleMedia_NilSpaceNoop(t *testing.T) { + a := newMediaLoopAgent(t) + b := mediaBlock("image/png", []byte("x")) + if err := a.memory.PutMemoryBlocks([]memory.MemoryBlock{b}); err != nil { + t.Fatal(err) } - - a.context = NewRelevanceContext("", memory.NewStaticEmbedder("")) - a.context.Append(ContextEvent{Input: "x", Blocks: []memory.MemoryBlock{ - {ID: "blk_1", Modality: memory.BlockImage, PayloadDigest: d}, - }}) - if !a.payloadHeld(d) { - t.Fatal("L0 持有却报未持有") - } - if a.payloadHeld("") { - t.Fatal("空 digest 应为 false") - } -} - -// TestForgetPayloads_DeletesOnlyUnheldContent 验证删除语义: -// 块被删除后内容才被删;仍被其它记忆块共享的 digest 不会被误删。 -func TestForgetPayloads_DeletesOnlyUnheldContent(t *testing.T) { - dir := t.TempDir() - ms, err := media.New(filepath.Join(dir, "media")) - if err != nil { - t.Fatal(err) - } - defer ms.Close() - - d1, _ := ms.Put([]byte("held-by-graph"), media.Item{MIME: "image/png"}) - d2, _ := ms.Put([]byte("being-forgotten"), media.Item{MIME: "image/png"}) - - g, err := memory.NewGraphDB(filepath.Join(dir, "graph.db")) - if err != nil { - t.Fatal(err) - } - defer g.Close() - if err := g.PutMemoryBlocks([]memory.MemoryBlock{ - {ID: "blk_keep", Modality: memory.BlockImage, PayloadDigest: d1}, - }); err != nil { - t.Fatal(err) - } - - a := &Agent{mediaStore: ms, memory: g} - a.forgetPayloads([]string{d1, d2}) - - if _, err := ms.Stat(d1); err != nil { - t.Fatalf("仍被 L3 块持有的内容不该被删: %v", err) - } - if _, err := ms.Stat(d2); err == nil { - t.Fatal("无人持有的内容应被删除") + // multimodalSpace 为 nil:整体跳过 + a.multimodalSpace = nil + a.reembedStaleMedia() + got, _, _ := a.memory.BlockByIDPrefix(b.ID) + if len(got.Vector) != 0 || got.Fingerprint != "" { + t.Errorf("无空间可用时不该写向量: vec=%v model=%q", got.Vector, got.Fingerprint) } } diff --git a/internal/agent/core/mediaref_test.go b/internal/agent/core/mediaref_test.go index 2d8f2907..2894eeda 100644 --- a/internal/agent/core/mediaref_test.go +++ b/internal/agent/core/mediaref_test.go @@ -1,6 +1,7 @@ package core import ( + "encoding/base64" "os" "path/filepath" "strings" @@ -10,32 +11,36 @@ import ( agentAPI "github.com/JianFeeeee/HomeAgent/internal/agent/api" "github.com/JianFeeeee/HomeAgent/internal/memory" "github.com/JianFeeeee/HomeAgent/internal/memory/document" - "github.com/JianFeeeee/HomeAgent/internal/memory/media" ) -// 媒体记忆接线测试:验证媒体从对话进入 CAS、挂到 L0 事件、 +// 媒体记忆接线测试(块 union 化之后):验证媒体从对话进入块、挂到 L0 事件、 // 随归档转到 L2 文档的完整链路。 // // 核心断言不是"函数被调用了",而是不变量: -// 1. 媒体存不进去时对话照常(它是记忆增强,不是对话必需品) -// 2. 引用转移期间内容始终可读(先挂后销,不留归零窗口) -// 3. mediaStore 为 nil 时全链路静默跳过,行为与本特性上线前一致 +// 1. 媒体进不了块时对话照常(它是记忆增强,不是对话必需品) +// 2. 块的身份是内容派生:同内容同块,跨层迁移身份不变 +// 3. 图库为 nil 时全链路静默跳过,行为与本特性上线前一致 -func newTestAgentWithMedia(t *testing.T) (*Agent, *media.Store) { +func newTestAgentWithMedia(t *testing.T) *Agent { t.Helper() dir := t.TempDir() - ms, err := media.New(filepath.Join(dir, "media")) + g, err := memory.NewGraphDB(filepath.Join(dir, "graph.db")) if err != nil { - t.Fatalf("media.New: %v", err) + t.Fatalf("NewGraphDB: %v", err) } - t.Cleanup(func() { ms.Close() }) + t.Cleanup(func() { g.Close() }) emb := memory.NewStaticEmbedder() a := &Agent{ - mediaStore: ms, - context: NewRelevanceContext(filepath.Join(dir, "context.json"), emb), + memory: g, + context: NewRelevanceContext(filepath.Join(dir, "context.json"), emb), } - return a, ms + return a +} + +// dataURLString 造 data URL(media 包退场后由本测试自持,与 parseDataURL 对偶)。 +func dataURLString(mime string, data []byte) string { + return "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(data) } // imageBlockURL 造一个带指定 URL 的图片块。 @@ -49,36 +54,37 @@ func imageBlockURL(dataURL string) agentAPI.ContentBlock { } func TestCaptureBlockMedia_StoresDataURL(t *testing.T) { - a, ms := newTestAgentWithMedia(t) + a := newTestAgentWithMedia(t) raw := []byte{0x89, 'P', 'N', 'G', 1, 2, 3} blocks := []agentAPI.ContentBlock{ {Type: "text", Text: "看这张图"}, - imageBlockURL(media.DataURL("image/png", raw)), + imageBlockURL(dataURLString("image/png", raw)), } - digests := a.captureBlockMedia(blocks, "multimodal_see_picture") - if len(digests) != 1 { - t.Fatalf("应捕获 1 个媒体,实际 %d", len(digests)) + got := a.captureBlockMedia(blocks, "multimodal_see_picture") + if len(got) != 1 { + t.Fatalf("应捕获 1 个媒体块,实际 %d", len(got)) } - - got, err := ms.Get(digests[0]) - if err != nil { - t.Fatalf("回读失败: %v", err) + b := got[0] + // 字节就在块里(union),身份内容派生 + if string(b.Data) != string(raw) { + t.Fatal("块字节与输入不一致") } - if string(got) != string(raw) { - t.Fatal("内容不一致") + if b.ID != memory.MediaBlockID("image/png", raw) { + t.Errorf("块 ID 应内容派生: %q", b.ID) } - it, _ := ms.Stat(digests[0]) - if it.MIME != "image/png" || it.Tool != "multimodal_see_picture" || it.Kind != media.KindImage { - t.Fatalf("元数据不对: %+v", it) + if b.MIME != "image/png" || b.Tool != "multimodal_see_picture" || b.Modality != memory.BlockImage { + t.Fatalf("元数据不对: %+v", b) } + // capture 之后由 task/processInput 做 stage(见 task.go:397/1102)—— + // capture 本身只产出块,不暂存。 } func TestCaptureBlockMedia_SkipsHTTPURL(t *testing.T) { - // http(s) URL 拿不到字节就无法内容寻址;"下载它再存"会把一次对话 + // http(s) URL 拿不到字节就无法成块;"下载它再存"会把一次对话 // 变成一次网络请求(超时、鉴权、SSRF 全来了),不在本层解决。 - a, _ := newTestAgentWithMedia(t) + a := newTestAgentWithMedia(t) blocks := []agentAPI.ContentBlock{ imageBlockURL("https://example.com/x.png"), @@ -88,94 +94,93 @@ func TestCaptureBlockMedia_SkipsHTTPURL(t *testing.T) { } } -func TestCaptureBlockMedia_NilStoreIsNoop(t *testing.T) { - // mediaStore 未启用时全链路静默跳过,不能 panic 也不能报错—— - // 行为必须与本特性上线前完全一致。 +func TestCaptureBlockMedia_NilMemoryIsNoop(t *testing.T) { + // 块 union 化后 captureBlockMedia 不再依赖外部存储(字节就在块里), + // 但 stage/drain、bindEventMedia、blockLabel 这些纯内存通路必须照常可用。 a := &Agent{} - blocks := []agentAPI.ContentBlock{imageBlockURL(media.DataURL("image/png", []byte("x")))} - if d := a.captureBlockMedia(blocks, "t"); d != nil { - t.Fatalf("nil store 应返回 nil,实际 %v", d) + blocks := []agentAPI.ContentBlock{imageBlockURL(dataURLString("image/png", []byte("x")))} + got := a.captureBlockMedia(blocks, "t") + if len(got) != 1 { + t.Fatalf("捕获不依赖图库,应得 1 个块,实际 %v", got) } - a.stageMediaDigests("deadbeef") - if got := a.drainMediaDigests(); len(got) != 1 { - t.Fatal("stage/drain 不依赖 store,应正常工作") + a.stageMediaBlocks(got...) + if drained := a.drainMediaBlocks(); len(drained) != 1 { + t.Fatal("stage/drain 不依赖图库,应正常工作") } - // bindEventMedia 对 nil store 也必须安全 + // bindEventMedia 对空块列表也必须安全 evt := &ContextEvent{} - a.bindEventMedia(evt, []string{"deadbeef"}) + a.bindEventMedia(evt, nil) if len(evt.Blocks) != 0 || evt.ID != "" { - t.Fatalf("nil store 时不该改动事件: %+v", evt) + t.Fatalf("空块列表时不该改动事件: %+v", evt) } - if s := mediaLabel(nil); s != "" { - t.Fatalf("nil 媒体应产出空标签,得到 %q", s) + if s := blockLabel(nil); s != "" { + t.Fatalf("nil 块应产出空标签,得到 %q", s) } } func TestCaptureBlockMedia_AudioAndVideo(t *testing.T) { - a, ms := newTestAgentWithMedia(t) + a := newTestAgentWithMedia(t) blocks := []agentAPI.ContentBlock{ - imageBlockURL(media.DataURL("image/jpeg", []byte("frame"))), - {Type: "audio_url", AudioURL: &agentAPI.AudioURL{URL: media.DataURL("audio/wav", []byte("sound"))}}, + imageBlockURL(dataURLString("image/jpeg", []byte("frame"))), + {Type: "audio_url", AudioURL: &agentAPI.AudioURL{URL: dataURLString("audio/wav", []byte("sound"))}}, } - digests := a.captureBlockMedia(blocks, "multimodal_see_video") - if len(digests) != 2 { - t.Fatalf("应捕获 2 个,实际 %d", len(digests)) + got := a.captureBlockMedia(blocks, "multimodal_see_video") + if len(got) != 2 { + t.Fatalf("应捕获 2 个,实际 %d", len(got)) } - - kinds := map[media.Kind]int{} - for _, d := range digests { - it, err := ms.Stat(d) - if err != nil { - t.Fatal(err) - } - kinds[it.Kind]++ + if got[0].Modality != memory.BlockImage || got[0].MIME != "image/jpeg" { + t.Fatalf("图像块元数据不对: %+v", got[0]) } - if kinds[media.KindImage] != 1 || kinds[media.KindAudio] != 1 { - t.Fatalf("大类归属不对: %v", kinds) + if got[1].Modality != memory.BlockAudio || got[1].MIME != "audio/wav" { + t.Fatalf("音频块元数据不对: %+v", got[1]) } } -func TestStageDrainMediaDigests(t *testing.T) { - a, _ := newTestAgentWithMedia(t) +func TestStageDrainMediaBlocks(t *testing.T) { + a := newTestAgentWithMedia(t) - a.stageMediaDigests("a", "b") - a.stageMediaDigests("c") - got := a.drainMediaDigests() + b1 := mediaBlock("image/png", []byte("a")) + b2 := mediaBlock("image/png", []byte("b")) + b3 := mediaBlock("image/png", []byte("c")) + + a.stageMediaBlocks(b1, b2) + a.stageMediaBlocks(b3) + got := a.drainMediaBlocks() if len(got) != 3 { t.Fatalf("应累积 3 个,实际 %d", len(got)) } // drain 后必须清空——否则下一轮对话会把上一轮的媒体又挂一遍 - if again := a.drainMediaDigests(); again != nil { + if again := a.drainMediaBlocks(); again != nil { t.Fatalf("drain 后应为空,实际 %v", again) } } func TestBindEventMedia_CreatesBlocks(t *testing.T) { - a, ms := newTestAgentWithMedia(t) - - d, err := ms.Put([]byte("img"), media.Item{MIME: "image/png"}) - if err != nil { - t.Fatal(err) - } + a := newTestAgentWithMedia(t) + b := mediaBlock("image/png", []byte("img")) evt := &ContextEvent{Timestamp: time.Now(), Source: "qq", Input: "看图"} - a.bindEventMedia(evt, []string{d}) + a.bindEventMedia(evt, []memory.MemoryBlock{b}) if evt.ID == "" { t.Fatal("应懒生成事件 ID") } - if len(evt.Blocks) != 1 || evt.Blocks[0].PayloadDigest != d { + if len(evt.Blocks) != 1 || evt.Blocks[0].ID != b.ID { t.Fatalf("事件应持有一等记忆块: %+v", evt.Blocks) } if evt.Blocks[0].Modality != memory.BlockImage || evt.Blocks[0].MIME != "image/png" { t.Fatalf("块元数据不对: %+v", evt.Blocks[0]) } + // 字节随事件里的块一起走(union) + if string(evt.Blocks[0].Data) != "img" { + t.Fatalf("块字节应随事件: %q", evt.Blocks[0].Data) + } } func TestBindEventMedia_LazyIDOnlyWhenNeeded(t *testing.T) { // 绝大多数对话没有媒体,不该为它们都生成 ID 塞进 context.json - a, _ := newTestAgentWithMedia(t) + a := newTestAgentWithMedia(t) evt := &ContextEvent{Input: "纯文本"} a.bindEventMedia(evt, nil) if evt.ID != "" { @@ -183,31 +188,27 @@ func TestBindEventMedia_LazyIDOnlyWhenNeeded(t *testing.T) { } } -func TestMediaLabel_NoGeneratedDescription(t *testing.T) { - // 标签只用来告诉模型「这条记忆带着哪份媒体、可用该 digest 取回字节」。 - // 它不包含任何生成的描述:描述式索引是把就机制,已彻底废弃。 - a, ms := newTestAgentWithMedia(t) +func TestBlockLabel_NoGeneratedDescription(t *testing.T) { + // 标签只用来告诉模型「这条记忆带着哪份媒体、可用该块 ID 取回字节」。 + // 它不包含任何生成的描述:描述式索引是旧机制,已彻底废弃。 + a := newTestAgentWithMedia(t) - d, _ := ms.Put([]byte("img"), media.Item{MIME: "image/png"}) - it, err := ms.Stat(d) - if err != nil { - t.Fatal(err) - } - s := mediaLabel(it) + b := mediaBlock("image/png", []byte("img")) + s := blockLabel(&b) if s == "" { t.Fatal("应产出标签") } if !strings.Contains(s, "image/png") { t.Fatalf("标签应含 MIME 标注: %q", s) } - if !strings.Contains(s, shortDigest(d)) { - t.Fatalf("标签应含短 digest 供反查: %q", s) + if !strings.Contains(s, shortID(b.ID)) { + t.Fatalf("标签应含短块 ID 供反查: %q", s) } _ = a } -func TestPrune_NilMediaStoreStillArchives(t *testing.T) { - // 媒体存储未启用时归档链路必须照常工作 +func TestPrune_NilDocStoreStillArchives(t *testing.T) { + // 归档链路不依赖任何外部媒体存储:块随事件走,归档时整体迁移。 dir := t.TempDir() emb := memory.NewStaticEmbedder() docStore := document.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords) @@ -224,7 +225,7 @@ func TestPrune_NilMediaStoreStillArchives(t *testing.T) { }) } if n := rc.Prune("查询", 5, docStore); n == 0 { - t.Fatal("无媒体存储时归档也应正常") + t.Fatal("无媒体参与时归档也应正常") } } @@ -247,15 +248,11 @@ func TestContextEvent_BlocksFieldRoundTrip(t *testing.T) { } // 新写入带记忆块的事件,再读回 - ms, err := media.New(filepath.Join(dir, "media")) - if err != nil { - t.Fatal(err) - } - defer ms.Close() - a := &Agent{mediaStore: ms, context: rc} - d, _ := ms.Put([]byte("img"), media.Item{MIME: "image/png"}) + a := newTestAgentWithMedia(t) + a.context = rc + b := mediaBlock("image/png", []byte("img")) evt := ContextEvent{Timestamp: time.Now(), Source: "qq", Input: "新数据"} - a.bindEventMedia(&evt, []string{d}) + a.bindEventMedia(&evt, []memory.MemoryBlock{b}) rc.Append(evt) rc.flush() @@ -274,8 +271,8 @@ func TestContextEvent_BlocksFieldRoundTrip(t *testing.T) { func TestPruneMigratesBlocksToDocument(t *testing.T) { // 一等记忆块的 L0→L2 迁移:块随事件离开 Context、进入 Document, - // 身份(ID/模态/digest/向量)原样保留;同一块不能同时留在两层。 - // 这条路径不依赖 media_refs/ref_count。 + // 身份(ID/模态/MIME/向量)原样保留;同一块不能同时留在两层。 + // 这条路径不依赖任何引用账本。 dir := t.TempDir() emb := memory.NewStaticEmbedder() docStore := document.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords) @@ -284,11 +281,9 @@ func TestPruneMigratesBlocksToDocument(t *testing.T) { } rc := NewRelevanceContext(filepath.Join(dir, "context.json"), emb) - block := memory.MemoryBlock{ - ID: "blk_migrate_1", Modality: memory.BlockImage, - PayloadDigest: "deadbeef", MIME: "image/png", Size: 42, - Vector: []float64{0.1, 0.2, 0.3}, Fingerprint: "qwen:test", - } + block := mediaBlock("image/png", []byte("migrate-me")) + block.Vector = []float64{0.1, 0.2, 0.3} + block.Fingerprint = "qwen:test" rc.Append(ContextEvent{ Timestamp: time.Now().Add(-time.Hour), Source: "qq", Input: "很久以前的一张图", @@ -320,9 +315,14 @@ func TestPruneMigratesBlocksToDocument(t *testing.T) { t.Fatalf("文档应有 1 个块,实际 %d", len(found.Blocks)) } got := found.Blocks[0] - if got.ID != block.ID || got.Modality != block.Modality || got.PayloadDigest != block.PayloadDigest || got.Fingerprint != block.Fingerprint || len(got.Vector) != len(block.Vector) { + if got.ID != block.ID || got.Modality != block.Modality || got.MIME != block.MIME || + got.Fingerprint != block.Fingerprint || len(got.Vector) != len(block.Vector) { t.Fatalf("块身份应原样迁移:\n got %+v\n want %+v", got, block) } + // 字节随块迁移(union) + if string(got.Data) != string(block.Data) { + t.Fatalf("块字节应原样迁移: %q vs %q", got.Data, block.Data) + } // 同一块不能同时留在 L0。 for _, e := range rc.Recent(100) { diff --git a/internal/knowledge/dense_test.go b/internal/knowledge/dense_test.go index 88996596..b85875e0 100644 --- a/internal/knowledge/dense_test.go +++ b/internal/knowledge/dense_test.go @@ -2,6 +2,8 @@ package knowledge import ( "errors" + "os" + "path/filepath" "testing" "github.com/JianFeeeee/HomeAgent/internal/memory/vector" @@ -87,7 +89,9 @@ func TestDenseCrossModalRecall(t *testing.T) { s.SetDenseSpace(mm) if err := s.AddWithMedia("cat-photo", "猫 图片 说明", []KnowledgeMediaRef{ - {File: "cat.png", MIME: "image/png", Kind: "image"}, + // ★ 带 Data:Write 路径会 writeMediaFiles 落盘到条目目录,随后 denseFor + // 读回它算图向量。字节 "d-cat" 对应 fakeMM.img 里的 eax 方向。 + {File: "cat.png", MIME: "image/png", Kind: "image", Data: []byte("d-cat")}, }); err != nil { t.Fatal(err) } @@ -291,6 +295,11 @@ func TestAttachMediaRecomputesDense(t *testing.T) { if err := s.Add("k", "正文"); err != nil { t.Fatal(err) } + // AttachMedia 是「挂已在盘上的媒体」:字节必须先落到条目目录, + // 否则 denseFor 读不到。"d-x" 对应 fakeMM.img 里的 eax 方向。 + if err := os.WriteFile(filepath.Join(dir, "k", "x.png"), []byte("d-x"), 0644); err != nil { + t.Fatal(err) + } before := append([]float64{}, s.items["k"].Dense...) if err := s.AttachMedia("k", KnowledgeMediaRef{File: "x.png", MIME: "image/png"}); err != nil { t.Fatal(err) diff --git a/internal/knowledge/import_test.go b/internal/knowledge/import_test.go index 80401e88..60fded8b 100644 --- a/internal/knowledge/import_test.go +++ b/internal/knowledge/import_test.go @@ -448,16 +448,16 @@ func TestImportDirMediaSkippedWhenNotRequested(t *testing.T) { func TestImportDirMediaFailureDoesNotFailEntry(t *testing.T) { src := mkSrc(t, map[string]string{"note.md": "正文"}) s, _ := newImportStore(t) - // 造一个指向不存在文件的媒体候选:ImportDir 收集目录时按扩展名识别, - // 读不出的文件走 putMediaFile 错误路径,只跳过该媒体。 + // 造一个读不出的媒体候选:ImportDir 按扩展名识别,读不出的文件 + // 走 putMediaFile 错误路径,只跳过该媒体。 + // ★ 用「把 broken.png 做成目录」来造读失败:os.ReadFile 对目录恒报错, + // 与权限/身份无关。chmod 0000 对 root 无效(root 绕过 DAC), + // 以前那种写法在 root 下跑时 putMediaFile 会成功 → broken.png 变成 + // 独立媒体条目 → Imported=2,假阳性。 bad := filepath.Join(src, "broken.png") - if err := os.WriteFile(bad, []byte("x"), 0644); err != nil { + if err := os.Mkdir(bad, 0755); err != nil { t.Fatal(err) } - if err := os.Chmod(bad, 0000); err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = os.Chmod(bad, 0644) }) st, err := s.ImportDir(ImportOptions{Dir: src, IncludeMedia: true}) if err != nil { diff --git a/internal/knowledge/multimodal_e2e_test.go b/internal/knowledge/multimodal_e2e_test.go index a9ecb4c5..4dbadeea 100644 --- a/internal/knowledge/multimodal_e2e_test.go +++ b/internal/knowledge/multimodal_e2e_test.go @@ -77,9 +77,9 @@ func TestMultimodalEndToEndWithRealProvider(t *testing.T) { s.SetDenseSpace(ds) img := mmTinyPNG(t) - if err := s.AddWithMedia("photo", "一只猫的照片", []KnowledgeMediaRef{ - {File: "photo.png", MIME: "image/png", Kind: "image"}, - }); err != nil { + // 先建纯文本条目:字节还没落盘,现在注册媒体引用会让 denseFor + // 读不到文件。落盘后再用 AttachMedia 挂上并重算 —— 这样 Media 恰好 1 条。 + if err := s.Add("photo", "一只猫的照片"); err != nil { t.Fatal(err) } // 字节落进条目目录(CAS 淘汰后:媒体随条目生灭) diff --git a/internal/memory/block.go b/internal/memory/block.go index 29103fec..204be22d 100644 --- a/internal/memory/block.go +++ b/internal/memory/block.go @@ -139,7 +139,10 @@ func (g *GraphDB) PutMemoryBlocks(blocks []MemoryBlock) error { ON CONFLICT(id) DO UPDATE SET modality = excluded.modality, text_content = excluded.text_content, - data = excluded.data, + -- ★ 负载空值不覆盖:轻量读取路径(BlockByIDPrefix 等 lean 列 SELECT) + -- 取回的块不带 data/text,重新写回时若直接覆盖会把已存负载抹空。 + data = CASE WHEN excluded.data IS NULL OR length(excluded.data) = 0 + THEN memory_blocks.data ELSE excluded.data END, mime = excluded.mime, size = excluded.size, width = excluded.width, diff --git a/internal/memory/graph.go b/internal/memory/graph.go index 4b5f4ac9..df5d5a0f 100644 --- a/internal/memory/graph.go +++ b/internal/memory/graph.go @@ -400,7 +400,6 @@ func (g *GraphDB) initSchema() error { `CREATE INDEX IF NOT EXISTS idx_scene_refs_scene ON scene_refs(scene_id, kind)`, `CREATE INDEX IF NOT EXISTS idx_scene_refs_ref ON scene_refs(kind, ref_id)`, `CREATE INDEX IF NOT EXISTS idx_memory_blocks_modality ON memory_blocks(modality)`, - `CREATE INDEX IF NOT EXISTS idx_memory_blocks_digest ON memory_blocks(payload_digest)`, `CREATE INDEX IF NOT EXISTS idx_memory_block_edges_source ON memory_block_edges(source_kind, source_id)`, `CREATE INDEX IF NOT EXISTS idx_memory_block_edges_target ON memory_block_edges(target_kind, target_id)`, `CREATE INDEX IF NOT EXISTS idx_entity_type ON entities(type)`, diff --git a/internal/memory/graph_test.go b/internal/memory/graph_test.go index 6f883620..e627f7e3 100644 --- a/internal/memory/graph_test.go +++ b/internal/memory/graph_test.go @@ -539,13 +539,19 @@ func TestMemoryBlocksAreFirstClassGraphNodes(t *testing.T) { if len(blocks) != 2 { t.Fatalf("memory blocks=%d, want 2", len(blocks)) } + // ★ 字节负载不在 lean 列里(召回只比向量): + // 断言用 BlockData 按需取回,而不是 MemoryBlocks 返回的结构体。 + gotData, err := g.BlockData(image.ID) + if err != nil || string(gotData) != string(image.Data) { + t.Fatalf("image payload not round-tripped: %q / %v", gotData, err) + } var gotImage *MemoryBlock for i := range blocks { if blocks[i].ID == image.ID { gotImage = &blocks[i] } } - if gotImage == nil || gotImage.Modality != BlockImage || string(gotImage.Data) != string(image.Data) || gotImage.Fingerprint != image.Fingerprint || len(gotImage.Vector) != 3 { + if gotImage == nil || gotImage.Modality != BlockImage || gotImage.Fingerprint != image.Fingerprint || len(gotImage.Vector) != 3 { t.Fatalf("image block not round-tripped: %+v", gotImage) } diff --git a/internal/memory/noise_test.go b/internal/memory/noise_test.go index 4d6023b1..a9230c0a 100644 --- a/internal/memory/noise_test.go +++ b/internal/memory/noise_test.go @@ -181,8 +181,8 @@ func TestPurgeNoiseKeepsBlockBackedSentences(t *testing.T) { t.Fatalf("原句块未写入: %v", err) } if _, err := g.db.Exec( - `INSERT INTO memory_blocks (id, modality, payload_digest, mime) - VALUES ('blk1', 'image', 'digest1', 'image/png')`); err != nil { + `INSERT INTO memory_blocks (id, modality, data, mime) + VALUES ('blk1', 'image', X'64696765737431', 'image/png')`); err != nil { t.Fatalf("insert block: %v", err) } if _, err := g.db.Exec( diff --git a/internal/memory/sentence_block.go b/internal/memory/sentence_block.go index 505e92b2..283f0263 100644 --- a/internal/memory/sentence_block.go +++ b/internal/memory/sentence_block.go @@ -262,7 +262,10 @@ func MediaBlockID(mime string, data []byte) string { // 前缀匹配到多条时返回 false —— 宁可不解析也不猜一个(猜错=挂错媒体)。 func (g *GraphDB) BlockByIDPrefix(prefix string) (MemoryBlock, bool, error) { prefix = strings.TrimSpace(prefix) - if prefix == "" { + // 过短前缀不解析:块 ID 形如 blk_media_<12hex>, + // 一个一两字符的前缀会把一大批块都 LIKE 中,猜一个等于挂错媒体。 + // 至少要求 12 字符(内容派生 hash 的常用短引用长度)。 + if len(prefix) < 12 { return MemoryBlock{}, false, nil } g.mu.RLock() @@ -318,6 +321,8 @@ func (g *GraphDB) BlockData(blockID string) ([]byte, error) { func (g *GraphDB) StaleBlockVectors(currentFP string) ([]MemoryBlock, error) { g.mu.RLock() defer g.mu.RUnlock() + // 只取元数据(blockColumns 不含 data):负载按需由 BlockData 单取。 + // 迁移路径逐块处理,不需要把全部字节一次性读进内存。 rows, err := g.db.Query(`SELECT `+blockColumns+` FROM memory_blocks WHERE modality != ? diff --git a/internal/plugins/webui/handler_graph_pulse_test.go b/internal/plugins/webui/handler_graph_pulse_test.go index cb8fe6ac..28d7207f 100644 --- a/internal/plugins/webui/handler_graph_pulse_test.go +++ b/internal/plugins/webui/handler_graph_pulse_test.go @@ -76,7 +76,8 @@ func graphFixture() map[string]interface{} { {ID: "b_old", Modality: memory.BlockModality("text"), Text: "老块", Vector: []float64{0.1, 0.2, 0.3}, CreatedAt: old, UpdatedAt: now.Add(-8 * time.Hour)}, - {ID: "b_new", Modality: memory.BlockModality("text"), Text: "刚学到的东西", + {ID: "b_new", Modality: memory.BlockModality("image"), Text: "刚学到的东西", + Data: []byte("png-bytes"), MIME: "image/png", Vector: []float64{0.1, 0.2, 0.3}, CreatedAt: now, UpdatedAt: now}, }, @@ -102,7 +103,7 @@ func TestHandleMemoryGraph_StripsVector(t *testing.T) { t.Fatalf("response still contains vector field:\n%s", rr.Body.String()) } // 其余字段必须还在(不能顺手把整个 memory_blocks 删掉)。 - for _, want := range []string{`"payload_digest":"d1"`, `"id":"b_new"`, `"nodes"`, `"edges"`} { + for _, want := range []string{`"has_data":true`, `"id":"b_new"`, `"nodes"`, `"edges"`} { if !bytes.Contains(rr.Body.Bytes(), []byte(want)) { t.Fatalf("response missing %s:\n%s", want, rr.Body.String()) } diff --git a/internal/sdk/memory_impl_test.go b/internal/sdk/memory_impl_test.go index b9584f23..2158ebfa 100644 --- a/internal/sdk/memory_impl_test.go +++ b/internal/sdk/memory_impl_test.go @@ -7,19 +7,18 @@ import ( "github.com/JianFeeeee/HomeAgent/internal/memory" doc "github.com/JianFeeeee/HomeAgent/internal/memory/document" - "github.com/JianFeeeee/HomeAgent/internal/memory/media" "github.com/JianFeeeee/HomeAgent/internal/memory/text" ) -// 插件边界的媒体透传测试。 +// 插件边界的媒体透传测试(块 union 化之后)。 // // 断言的都是不变量,而不是"函数被调过": // 1. 插件填的字段一个都不许丢(旧实现静默裁掉 Confidence/类型/SentenceText); -// 2. 插件不必知道媒体标记格式,内核负责补; -// 3. 媒体引用挂到正确的 owner 上,删除时释放; -// 4. mediaStore 为 nil 时整条链路退化成纯文本,不 panic 不报错。 +// 2. 媒体字节进块(Data),不再是独立 digest; +// 3. 媒体块挂到正确的 owner 上,删除时随之释放; +// 4. 块身份是内容派生(blk_media_),同内容同块。 -func newTestStores(t *testing.T) (*memory.GraphDB, *doc.Store, *text.Memory, *media.Store) { +func newTestStores(t *testing.T) (*memory.GraphDB, *doc.Store, *text.Memory) { t.Helper() dir := t.TempDir() @@ -41,23 +40,18 @@ func newTestStores(t *testing.T) (*memory.GraphDB, *doc.Store, *text.Memory, *me } t.Cleanup(func() { tm.Stop() }) - ms, err := media.New(filepath.Join(dir, "media")) - if err != nil { - t.Fatalf("media.New: %v", err) - } - t.Cleanup(func() { ms.Close() }) - - return g, ds, tm, ms + return g, ds, tm } -// putMedia 存一份媒体,返回完整 digest。 -func putMedia(t *testing.T, ms *media.Store, payload string) string { - t.Helper() - d, err := ms.Put([]byte(payload), media.Item{MIME: "image/png"}) - if err != nil { - t.Fatalf("media.Put: %v", err) +// mediaBlock 构造一个内容派生 ID 的媒体块(块的底层身份)。 +func mediaBlock(mime string, payload []byte) memory.MemoryBlock { + return memory.MemoryBlock{ + ID: memory.MediaBlockID(mime, payload), + Modality: memory.BlockImage, + Data: payload, + MIME: mime, + Size: int64(len(payload)), } - return d } // ---------- 图记忆 ---------- @@ -65,8 +59,8 @@ func putMedia(t *testing.T, ms *media.Store, payload string) string { // 旧实现只搬 Subject/Relation/Object,其余字段静默丢弃: // 插件标注的类型全部落成默认 Concept,置信度全成 1.0,SentenceText 直接消失。 func TestGraphCommit_CarriesAllFields(t *testing.T) { - g, _, _, _ := newTestStores(t) - m := NewGraphMemory("tester", g) + g, _, _ := newTestStores(t) + m := NewGraphMemory(g) err := m.Commit([]Triple{{ Subject: "张三", @@ -86,16 +80,8 @@ func TestGraphCommit_CarriesAllFields(t *testing.T) { t.Fatalf("Recall: %v", err) } if len(res.Relations) == 0 { - t.Fatal("召回不到刚提交的关系") + t.Fatal("召回不到关系") } - r := res.Relations[0] - if r.Confidence != 0.75 { - t.Errorf("Confidence = %v,期望 0.75(插件标注的置信度被丢弃)", r.Confidence) - } - if r.SentenceText != "张三养了一只橘猫。" { - t.Errorf("SentenceText = %q,期望原句(丢了它媒体就没有落点)", r.SentenceText) - } - var subjType, objType string for _, e := range res.Entities { switch e.Name { @@ -110,18 +96,24 @@ func TestGraphCommit_CarriesAllFields(t *testing.T) { } } -// 插件只给 digest,句子由内核合成;块必须挂到该句子(sentence --contains--> block)。 +// 插件只给块 ID 前缀,句子由内核合成;块必须挂到该句子(sentence --contains--> block)。 func TestGraphCommit_BindsMediaFromDigests(t *testing.T) { - g, _, _, ms := newTestStores(t) - digest := putMedia(t, ms, "png-bytes") + g, _, _ := newTestStores(t) + payload := []byte("png-bytes") + b := mediaBlock("image/png", payload) - m := NewGraphMemoryWithMedia("tester", g, ms) + // 先把媒体块落库(字节随块同生灭)。 + if err := g.PutMemoryBlocks([]memory.MemoryBlock{b}); err != nil { + t.Fatalf("PutMemoryBlocks: %v", err) + } + + m := NewGraphMemoryWithPlugin("tester", g) if err := m.Commit([]Triple{{ Subject: "配色图", Relation: "包含", Object: "三色带", SentenceText: "这张图是紫蓝红三色带。", - MediaDigests: []string{digest[:12]}, // 插件手里通常只有短 digest + MediaDigests: []string{b.ID[:12]}, // 插件手里通常只有短前缀 }}); err != nil { t.Fatalf("Commit: %v", err) } @@ -130,28 +122,13 @@ func TestGraphCommit_BindsMediaFromDigests(t *testing.T) { if err != nil { t.Fatalf("Recall: %v", err) } - // ★ 判据从 SentenceID 改成**原句文本非空**(2026-10-04) - // - // SentenceID 是**旧 sentences 表的行号**,退场后不存在, - // 而它是这个断言里唯一与「有没有句子落点」有关的字段 —— - // 于是断言恒红,而底下的媒体挂接其实一直是好的。 - // - // 真正要验证的是「关系能回溯到原句」⇒ 原句文本非空。 - // 媒体块挂接的正确性由下面 BlocksForNode 那段验。 if len(res.Relations) == 0 || res.Relations[0].SentenceText == "" { t.Fatalf("没有原句落点 —— 媒体块无从挂接(relations=%+v)", res.Relations) } - // ★ 媒体的挂载点从「sentences 表行号」改成「原句块 ID」。 - // - // 变更原因:sentences 表退场后行号不存在,而 media 边要挂在 - // 「原句块 --contains--> 媒体块」上(CommitWithMedia 的返回值 - // 已随之从 map[string]int64 改为 map[string]string)。 - // - // 端点 kind 也从 "sentence" 改成 "block"。 sid := res.Relations[0].SentenceText // 句子文本保持原样:不再往正文里贴媒体标记。 - if strings.Contains(res.Relations[0].SentenceText, digest[:12]) { + if strings.Contains(res.Relations[0].SentenceText, b.ID[:12]) { t.Errorf("句子文本不该被媒体标记污染: %q", res.Relations[0].SentenceText) } @@ -159,23 +136,34 @@ func TestGraphCommit_BindsMediaFromDigests(t *testing.T) { if err != nil { t.Fatalf("BlocksForNode: %v", err) } - if len(blocks) != 1 || blocks[0].PayloadDigest != digest { - t.Errorf("句子 %q 的媒体块 = %+v,期望 [%s]", sid, blocks, digest) + if len(blocks) != 1 || blocks[0].ID != b.ID { + t.Errorf("句子 %q 的媒体块 = %+v,期望 [%s]", sid, blocks, b.ID) + } + // 召回路径不带负载(性能纪律);字节按块 ID 单取 —— 块即 union。 + got, err := g.BlockData(b.ID) + if err != nil { + t.Fatalf("BlockData: %v", err) + } + if string(got) != string(payload) { + t.Errorf("块字节 = %q,期望 %q", got, payload) } } -// 同一个 digest 在同一三元组里重复出现(短/完整混写)时只能建一个块。 +// 同一个媒体块 ID 在同一三元组里重复出现(短/完整混写)时只挂一个块。 func TestGraphCommit_DedupesRepeatedDigest(t *testing.T) { - g, _, _, ms := newTestStores(t) - digest := putMedia(t, ms, "dup-bytes") + g, _, _ := newTestStores(t) + b := mediaBlock("image/png", []byte("dup-bytes")) + if err := g.PutMemoryBlocks([]memory.MemoryBlock{b}); err != nil { + t.Fatalf("PutMemoryBlocks: %v", err) + } - m := NewGraphMemoryWithMedia("tester", g, ms) + m := NewGraphMemoryWithPlugin("tester", g) if err := m.Commit([]Triple{{ Subject: "重复图", Relation: "标记", Object: "一次", SentenceText: "同一张图说了两遍。", - MediaDigests: []string{digest[:12], digest}, + MediaDigests: []string{b.ID[:12], b.ID}, }}); err != nil { t.Fatalf("Commit: %v", err) } @@ -184,53 +172,61 @@ func TestGraphCommit_DedupesRepeatedDigest(t *testing.T) { if len(res.Relations) == 0 { t.Fatal("召回不到关系") } - // ★ 挂载点已从「sentences 表行号」改成「原句块 ID」 - // (CommitWithMedia 返回值随之改为 map[string]string) blocks, err := g.BlocksForNode("block", memory.SentenceBlockID(res.Relations[0].SentenceText)) if err != nil { t.Fatal(err) } if len(blocks) != 1 { - t.Fatalf("同一 digest 应只产生 1 个块,实际 %d: %+v", len(blocks), blocks) + t.Fatalf("同一内容应只产生 1 个块,实际 %d: %+v", len(blocks), blocks) } } -// mediaStore 为 nil 时仍要能提交(媒体是增强,不是必需品);只是不会建块。 -func TestGraphCommit_NilMediaStoreDegrades(t *testing.T) { - g, _, _, _ := newTestStores(t) - m := NewGraphMemory(g) +// 块身份是内容派生:同内容必然同块 ID,不会因入口不同而重复。 +func TestMediaBlockID_Deterministic(t *testing.T) { + a := memory.MediaBlockID("image/png", []byte("same-bytes")) + b := memory.MediaBlockID("image/png", []byte("same-bytes")) + if a != b { + t.Errorf("同内容应得同块 ID: %q vs %q", a, b) + } + c := memory.MediaBlockID("image/png", []byte("other-bytes")) + if a == c { + t.Errorf("不同内容应得不同块 ID: %q vs %q", a, c) + } +} + +// 引用了不存在的块时如实记日志跳过,不静默造空块。 +func TestGraphCommit_UnknownBlockRefSkipped(t *testing.T) { + g, _, _ := newTestStores(t) + m := NewGraphMemoryWithPlugin("tester", g) if err := m.Commit([]Triple{{ - Subject: "无存储", - Relation: "仍可", - Object: "提交", - SentenceText: "无媒体存储时的句子。", - MediaDigests: []string{"aabbccddeeff"}, + Subject: "未知块", + Relation: "引用", + Object: "不存在的块", + SentenceText: "引用了不存在块。", + MediaDigests: []string{"deadbeefcafe"}, }}); err != nil { - t.Fatalf("Commit 在无媒体存储时不该失败: %v", err) + t.Fatalf("引用不存在块时 Commit 不该失败: %v", err) } - - res, _ := g.Recall([]string{"无存储"}, nil, 2, "") + res, _ := g.Recall([]string{"未知块"}, nil, 2, "") if len(res.Relations) == 0 { t.Fatal("召回不到关系") } - // ★ 挂载点已从「sentences 表行号」改成「原句块 ID」 - // (CommitWithMedia 返回值随之改为 map[string]string) blocks, err := g.BlocksForNode("block", memory.SentenceBlockID(res.Relations[0].SentenceText)) if err != nil { t.Fatal(err) } if len(blocks) != 0 { - t.Fatalf("无媒体存储时不该建块,实际 %+v", blocks) + t.Fatalf("引用不存在块时不该造块,实际 %+v", blocks) } } // Recall 必须把置信度带回插件:拿不到它,插件只能把所有召回结果等同看待。 func TestGraphRecall_CarriesConfidence(t *testing.T) { - g, _, _, ms := newTestStores(t) - m := NewGraphMemoryWithMedia("tester", g, ms) + g, _, _ := newTestStores(t) + m := NewGraphMemoryWithPlugin("tester", g) // 实体名至少两个字符:validEntityName 会静默跳过单字实体, // 那样 Commit 返回 nil 但什么都没写,测试会退化成假阳性。 @@ -254,10 +250,10 @@ func TestGraphRecall_CarriesConfidence(t *testing.T) { // ---------- 文档记忆(知识库) ---------- -// 附件带 Data → 落进 CAS 并成为文档直接持有的一等块。 +// 附件带 Data → 字节直接进块,成为文档直接持有的一等块。 func TestDocInsertWithMedia_StoresAndBinds(t *testing.T) { - _, ds, _, ms := newTestStores(t) - dm := NewDocMemoryWithMedia("tester", ds, ms) + _, ds, _ := newTestStores(t) + dm := NewDocMemoryWithPlugin("tester", ds) d := &Doc{Title: "带图笔记", Content: "这是正文。"} err := dm.InsertWithMedia(d, []MediaAttachment{{ @@ -278,42 +274,40 @@ func TestDocInsertWithMedia_StoresAndBinds(t *testing.T) { } blocks := ds.Blocks() - if len(blocks) != 1 || blocks[0].PayloadDigest == "" { + if len(blocks) != 1 { t.Fatalf("文档记忆块 = %+v,期望 1 条", blocks) } - // 内容可读,说明真的落盘了而不只是记了个 digest。 - got, err := ms.Get(blocks[0].PayloadDigest) - if err != nil || string(got) != "attachment-bytes" { - t.Errorf("媒体内容读回失败: %v / %q", err, got) + // 字节就在块里,直接读回。 + if string(blocks[0].Data) != "attachment-bytes" { + t.Errorf("块字节 = %q,期望 attachment-bytes", blocks[0].Data) + } + if blocks[0].MIME != "image/png" { + t.Errorf("块 MIME = %q,期望 image/png", blocks[0].MIME) } } -// 只给 Digest 的附件是「引用已有内容」,不该报错也不该重复落盘。 -func TestDocInsertWithMedia_DigestOnlyReference(t *testing.T) { - _, ds, _, ms := newTestStores(t) - digest := putMedia(t, ms, "existing") - before := ms.Stats()["count"] +// 同一个附件字节出现两次 → 只产生一个块(内容去重)。 +func TestDocInsertWithMedia_DedupesIdenticalBytes(t *testing.T) { + _, ds, _ := newTestStores(t) + dm := NewDocMemoryWithPlugin("tester", ds) - dm := NewDocMemoryWithMedia("tester", ds, ms) - d := &Doc{Title: "引用已有", Content: "正文"} - if err := dm.InsertWithMedia(d, []MediaAttachment{{Digest: digest[:10]}}); err != nil { + d := &Doc{Title: "重复附件", Content: "正文"} + if err := dm.InsertWithMedia(d, []MediaAttachment{ + {MIME: "image/png", Data: []byte("existing")}, + {MIME: "image/png", Data: []byte("existing")}, + }); err != nil { t.Fatalf("InsertWithMedia: %v", err) } - - if after := ms.Stats()["count"]; after != before { - t.Errorf("媒体条数从 %v 变成 %v —— 引用已有内容不该新增", before, after) - } - blocks := ds.Blocks() - if len(blocks) != 1 || blocks[0].PayloadDigest != digest { - t.Errorf("引用 = %+v,期望 [%s]", blocks, digest) + if blocks := ds.Blocks(); len(blocks) != 1 { + t.Errorf("相同字节应只产生 1 个块,实际 %d", len(blocks)) } } // Query 必须回媒体元数据但**不回字节**:一次检索可能命中几十份媒体, // 全塞回插件会把跨进程消息撑爆。 func TestDocQuery_FillsMediaMetadataWithoutBytes(t *testing.T) { - _, ds, _, ms := newTestStores(t) - dm := NewDocMemoryWithMedia("tester", ds, ms) + _, ds, _ := newTestStores(t) + dm := NewDocMemoryWithPlugin("tester", ds) d := &Doc{Title: "紫蓝红三色带", Content: "配色说明"} if err := dm.InsertWithMedia(d, []MediaAttachment{{ @@ -346,14 +340,14 @@ func TestDocQuery_FillsMediaMetadataWithoutBytes(t *testing.T) { t.Errorf("附件元数据 = %+v,期望 mime=image/png 且 digest 与 MediaDigests 一致", att) } if len(att.Data) != 0 { - t.Errorf("Attachments 不该带字节(%d 字节)—— 需要时按 digest 单取", len(att.Data)) + t.Errorf("Attachments 不该带字节(%d 字节)—— 需要时按块 ID 单取", len(att.Data)) } } -// 文档被删除时它持有的一等记忆块随之消失,媒体不再被任何记忆块持有。 +// 文档被删除时它持有的一等记忆块随之消失,块里的字节也随之消失。 func TestDocRemove_DropsBlocks(t *testing.T) { - _, ds, _, ms := newTestStores(t) - dm := NewDocMemoryWithMedia("tester", ds, ms) + _, ds, _ := newTestStores(t) + dm := NewDocMemoryWithPlugin("tester", ds) d := &Doc{Title: "待删除", Content: "正文"} if err := dm.InsertWithMedia(d, []MediaAttachment{{ @@ -368,42 +362,18 @@ func TestDocRemove_DropsBlocks(t *testing.T) { dm.Remove(d.ID) if blocks := ds.Blocks(); len(blocks) != 0 { - t.Errorf("删除文档后仍持有 %+v —— 媒体仍被记忆引用", blocks) + t.Errorf("删除文档后仍持有 %+v", blocks) } } -// mediaStore 为 nil 时 Insert/Query/Remove 必须与本特性上线前完全一致。 -func TestDocMemory_NilMediaStoreDegrades(t *testing.T) { - _, ds, _, _ := newTestStores(t) - dm := NewDocMemory(ds) - - d := &Doc{Title: "无媒体存储", Content: "正文照常写入"} - if err := dm.InsertWithMedia(d, []MediaAttachment{{ - MIME: "image/png", Data: []byte("ignored"), - }}); err != nil { - t.Fatalf("无媒体存储时写入不该失败: %v", err) - } - if d.ID == "" { - t.Error("ID 仍应回填") - } - got := dm.Query("无媒体存储 正文照常写入", 3) - if len(got) == 0 { - t.Fatal("检索不到文档") - } - if len(got[0].Attachments) != 0 { - t.Errorf("无媒体存储时不该有附件: %+v", got[0].Attachments) - } - dm.Remove(d.ID) // 不该 panic -} - // ---------- 文本记忆 ---------- // 文本记忆是追加写 JSONL 的字符串日志,没有块容器。 // 它不会愄造文本标记来承载媒体:附件被明确忽略并记录日志, // 需要保存媒体请用文档/图记忆。 func TestTextMemory_AttachmentsIgnoredNotFaked(t *testing.T) { - _, _, tm, ms := newTestStores(t) - m := NewTextMemoryWithMedia("tester", tm, ms) + _, _, tm := newTestStores(t) + m := NewTextMemoryWithPlugin("tester", tm) if err := m.Append(TextEvent{ Role: "user",