Files
HomeAgent/internal/sdk/memory_impl_test.go
JianFeeeee 33e1a0bfaf 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 特性线。
2026-10-12 14:26:13 +08:00

403 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package sdk
import (
"path/filepath"
"strings"
"testing"
"github.com/JianFeeeee/HomeAgent/internal/memory"
doc "github.com/JianFeeeee/HomeAgent/internal/memory/document"
"github.com/JianFeeeee/HomeAgent/internal/memory/text"
)
// 插件边界的媒体透传测试(块 union 化之后)。
//
// 断言的都是不变量,而不是"函数被调过":
// 1. 插件填的字段一个都不许丢(旧实现静默裁掉 Confidence/类型/SentenceText);
// 2. 媒体字节进块(Data),不再是独立 digest;
// 3. 媒体块挂到正确的 owner 上,删除时随之释放;
// 4. 块身份是内容派生(blk_media_<sha256(mime+data)>),同内容同块。
func newTestStores(t *testing.T) (*memory.GraphDB, *doc.Store, *text.Memory) {
t.Helper()
dir := t.TempDir()
g, err := memory.NewGraphDB(filepath.Join(dir, "graph.db"))
if err != nil {
t.Fatalf("NewGraphDB: %v", err)
}
t.Cleanup(func() { g.Close() })
ds := doc.NewStore(filepath.Join(dir, "documents"), memory.TokenizeWords)
if err := ds.Start(); err != nil {
t.Fatalf("doc store start: %v", err)
}
t.Cleanup(func() { ds.Stop() })
tm := text.New(filepath.Join(dir, "text"))
if err := tm.Start(); err != nil {
t.Fatalf("text memory start: %v", err)
}
t.Cleanup(func() { tm.Stop() })
return g, ds, tm
}
// 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)),
}
}
// ---------- 图记忆 ----------
// 旧实现只搬 Subject/Relation/Object,其余字段静默丢弃:
// 插件标注的类型全部落成默认 Concept,置信度全成 1.0,SentenceText 直接消失。
func TestGraphCommit_CarriesAllFields(t *testing.T) {
g, _, _ := newTestStores(t)
m := NewGraphMemory(g)
err := m.Commit([]Triple{{
Subject: "张三",
Relation: "养",
Object: "橘猫",
Confidence: 0.75,
SubjectType: "Person",
ObjectType: "Animal",
SentenceText: "张三养了一只橘猫。",
}})
if err != nil {
t.Fatalf("Commit: %v", err)
}
res, err := g.Recall([]string{"张三"}, nil, 2, "")
if err != nil {
t.Fatalf("Recall: %v", err)
}
if len(res.Relations) == 0 {
t.Fatal("召回不到关系")
}
var subjType, objType string
for _, e := range res.Entities {
switch e.Name {
case "张三":
subjType = e.Type
case "橘猫":
objType = e.Type
}
}
if subjType != "Person" || objType != "Animal" {
t.Errorf("实体类型 = (%q,%q),期望 (Person,Animal)", subjType, objType)
}
}
// 插件只给块 ID 前缀,句子由内核合成;块必须挂到该句子(sentence --contains--> block)。
func TestGraphCommit_BindsMediaFromDigests(t *testing.T) {
g, _, _ := newTestStores(t)
payload := []byte("png-bytes")
b := mediaBlock("image/png", payload)
// 先把媒体块落库(字节随块同生灭)。
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{b.ID[:12]}, // 插件手里通常只有短前缀
}}); err != nil {
t.Fatalf("Commit: %v", err)
}
res, err := g.Recall([]string{"配色图"}, nil, 2, "")
if err != nil {
t.Fatalf("Recall: %v", err)
}
if len(res.Relations) == 0 || res.Relations[0].SentenceText == "" {
t.Fatalf("没有原句落点 —— 媒体块无从挂接(relations=%+v)", res.Relations)
}
sid := res.Relations[0].SentenceText
// 句子文本保持原样:不再往正文里贴媒体标记。
if strings.Contains(res.Relations[0].SentenceText, b.ID[:12]) {
t.Errorf("句子文本不该被媒体标记污染: %q", res.Relations[0].SentenceText)
}
blocks, err := g.BlocksForNode("block", memory.SentenceBlockID(sid))
if err != nil {
t.Fatalf("BlocksForNode: %v", err)
}
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)
}
}
// 同一个媒体块 ID 在同一三元组里重复出现(短/完整混写)时只挂一个块。
func TestGraphCommit_DedupesRepeatedDigest(t *testing.T) {
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 := NewGraphMemoryWithPlugin("tester", g)
if err := m.Commit([]Triple{{
Subject: "重复图",
Relation: "标记",
Object: "一次",
SentenceText: "同一张图说了两遍。",
MediaDigests: []string{b.ID[:12], b.ID},
}}); err != nil {
t.Fatalf("Commit: %v", err)
}
res, _ := g.Recall([]string{"重复图"}, nil, 2, "")
if len(res.Relations) == 0 {
t.Fatal("召回不到关系")
}
blocks, err := g.BlocksForNode("block",
memory.SentenceBlockID(res.Relations[0].SentenceText))
if err != nil {
t.Fatal(err)
}
if len(blocks) != 1 {
t.Fatalf("同一内容应只产生 1 个块,实际 %d: %+v", len(blocks), blocks)
}
}
// 块身份是内容派生:同内容必然同块 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{"deadbeefcafe"},
}}); err != nil {
t.Fatalf("引用不存在块时 Commit 不该失败: %v", err)
}
res, _ := g.Recall([]string{"未知块"}, nil, 2, "")
if len(res.Relations) == 0 {
t.Fatal("召回不到关系")
}
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)
}
}
// Recall 必须把置信度带回插件:拿不到它,插件只能把所有召回结果等同看待。
func TestGraphRecall_CarriesConfidence(t *testing.T) {
g, _, _ := newTestStores(t)
m := NewGraphMemoryWithPlugin("tester", g)
// 实体名至少两个字符:validEntityName 会静默跳过单字实体,
// 那样 Commit 返回 nil 但什么都没写,测试会退化成假阳性。
if err := m.Commit([]Triple{{
Subject: "甲方", Relation: "疑似", Object: "乙方", Confidence: 0.3,
}}); err != nil {
t.Fatalf("Commit: %v", err)
}
_, rels, err := m.Recall([]string{"甲方"}, 2)
if err != nil {
t.Fatalf("Recall: %v", err)
}
if len(rels) == 0 {
t.Fatal("召回为空")
}
if rels[0].Confidence != 0.3 {
t.Errorf("Confidence = %v,期望 0.3", rels[0].Confidence)
}
}
// ---------- 文档记忆(知识库) ----------
// 附件带 Data → 字节直接进块,成为文档直接持有的一等块。
func TestDocInsertWithMedia_StoresAndBinds(t *testing.T) {
_, ds, _ := newTestStores(t)
dm := NewDocMemoryWithPlugin("tester", ds)
d := &Doc{Title: "带图笔记", Content: "这是正文。"}
err := dm.InsertWithMedia(d, []MediaAttachment{{
MIME: "image/png",
Data: []byte("attachment-bytes"),
Name: "chart.png",
}})
if err != nil {
t.Fatalf("InsertWithMedia: %v", err)
}
if d.ID == "" {
t.Fatal("ID 未回填 —— 插件拿不到刚写入文档的 id")
}
// 正文保持原样:不再往 Content 里拼任何媒体标记。
if strings.Contains(d.Content, "image/png") {
t.Errorf("正文不该被媒体标记污染: %q", d.Content)
}
blocks := ds.Blocks()
if len(blocks) != 1 {
t.Fatalf("文档记忆块 = %+v,期望 1 条", blocks)
}
// 字节就在块里,直接读回。
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)
}
}
// 同一个附件字节出现两次 → 只产生一个块(内容去重)。
func TestDocInsertWithMedia_DedupesIdenticalBytes(t *testing.T) {
_, ds, _ := newTestStores(t)
dm := NewDocMemoryWithPlugin("tester", ds)
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 blocks := ds.Blocks(); len(blocks) != 1 {
t.Errorf("相同字节应只产生 1 个块,实际 %d", len(blocks))
}
}
// Query 必须回媒体元数据但**不回字节**:一次检索可能命中几十份媒体,
// 全塞回插件会把跨进程消息撑爆。
func TestDocQuery_FillsMediaMetadataWithoutBytes(t *testing.T) {
_, ds, _ := newTestStores(t)
dm := NewDocMemoryWithPlugin("tester", ds)
d := &Doc{Title: "紫蓝红三色带", Content: "配色说明"}
if err := dm.InsertWithMedia(d, []MediaAttachment{{
MIME: "image/png", Data: []byte("query-bytes"),
}}); err != nil {
t.Fatalf("InsertWithMedia: %v", err)
}
got := dm.Query("紫蓝红三色带 配色说明", 3)
if len(got) == 0 {
t.Fatal("检索不到刚写入的文档")
}
var hit *Doc
for _, g := range got {
if g.ID == d.ID {
hit = g
}
}
if hit == nil {
t.Fatalf("检索结果里没有目标文档: %+v", got)
}
if len(hit.MediaDigests) != 1 {
t.Errorf("MediaDigests = %v,期望 1 条", hit.MediaDigests)
}
if len(hit.Attachments) != 1 {
t.Fatalf("Attachments = %v,期望 1 条", hit.Attachments)
}
att := hit.Attachments[0]
if att.MIME != "image/png" || att.Digest != hit.MediaDigests[0] {
t.Errorf("附件元数据 = %+v,期望 mime=image/png 且 digest 与 MediaDigests 一致", att)
}
if len(att.Data) != 0 {
t.Errorf("Attachments 不该带字节(%d 字节)—— 需要时按块 ID 单取", len(att.Data))
}
}
// 文档被删除时它持有的一等记忆块随之消失,块里的字节也随之消失。
func TestDocRemove_DropsBlocks(t *testing.T) {
_, ds, _ := newTestStores(t)
dm := NewDocMemoryWithPlugin("tester", ds)
d := &Doc{Title: "待删除", Content: "正文"}
if err := dm.InsertWithMedia(d, []MediaAttachment{{
MIME: "image/png", Data: []byte("to-be-freed"),
}}); err != nil {
t.Fatalf("InsertWithMedia: %v", err)
}
if blocks := ds.Blocks(); len(blocks) != 1 {
t.Fatalf("前置条件不成立,块 = %+v", blocks)
}
dm.Remove(d.ID)
if blocks := ds.Blocks(); len(blocks) != 0 {
t.Errorf("删除文档后仍持有 %+v", blocks)
}
}
// ---------- 文本记忆 ----------
// 文本记忆是追加写 JSONL 的字符串日志,没有块容器。
// 它不会愄造文本标记来承载媒体:附件被明确忽略并记录日志,
// 需要保存媒体请用文档/图记忆。
func TestTextMemory_AttachmentsIgnoredNotFaked(t *testing.T) {
_, _, tm := newTestStores(t)
m := NewTextMemoryWithPlugin("tester", tm)
if err := m.Append(TextEvent{
Role: "user",
Content: "看这张图",
Attachments: []MediaAttachment{{
MIME: "image/png", Data: []byte("text-mem-bytes"),
}},
}); err != nil {
t.Fatalf("Append: %v", err)
}
got, err := m.RecentEvents(5)
if err != nil {
t.Fatalf("RecentEvents: %v", err)
}
if len(got) == 0 {
t.Fatal("读不到刚追加的事件")
}
last := got[len(got)-1]
if last.Content != "看这张图" {
t.Errorf("正文应保持原样,实际 %q", last.Content)
}
if len(last.Attachments) != 0 {
t.Errorf("文本层不该凭空造出附件(没有块存储可挂): %+v", last.Attachments)
}
}