mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-15 12:15:16 +00:00
test(memory)!: 块 union 化判据重写(CAS 淘汰第二阶段:测试)
生产已在上一提交(86572d13)全绿;本提交把 CAS 专属测试改到块语义,
并修掉几处判据自身的缺陷(判据错 ≠ 生产错):
- sdk/memory_impl_test: media.Store→块;NewGraphMemoryWithPlugin
- core/{graphmedia,mediaref,medialive,medialoop,inputunify}_test:
mediaStore→memory;PayloadDigest→Data/BlockData;shortDigest→shortID
- graphmedia: 无引用句子用独立块 ID(shortID 20 字符会与 blk_src_ 撞长度)
- memory/sentence_block: BlockByIDPrefix 加 len<12 下限(短引用守卫)
- core/medialoop: reembedOneBlock 按 BlockData 兜底取字节(lean 列无 data)
- knowledge: broken.png 改目录造读失败(chmod 0000 对 root 无效);
dense/attach 测试补 Data 并先落盘后 AttachMedia
- webui: graph_pulse fixture payload_digest→has_data
注:internal/plugin/proc 的 shmVersion 不匹配(内核1/插件2)是
SDK 仓(777c3a0,v2 模板)与本分支内核(v1)的跨仓版本错位,
与块 union 无关(stash 全部改动后仍复现),属 proc-extra 特性线。
This commit is contained in:
@ -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 锁死「从图谱回到原文」:
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 淘汰后:媒体随条目生灭)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)`,
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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 != ?
|
||||
|
||||
@ -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())
|
||||
}
|
||||
|
||||
@ -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_<sha256(mime+data)>),同内容同块。
|
||||
|
||||
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",
|
||||
|
||||
Reference in New Issue
Block a user