Files
HomeAgent/internal/sdk/memory_impl_test.go
JianFeeeee 9f7d91c219 refactor(memory)!: 删除 CAS 包与第二套召回(块 union 化第四阶段:收尾)
块 union 化前三阶段已让块自带负载、回填存量字节。本提交拆掉 CAS 本身:
`internal/memory/media/`(2100 行)+ `QueryMediaScored`(第二套召回)
+ `media_refs`/`ref_count` 引用账本 + `internal/sdk/media.go`(MediaAPI)
+ `SDKConfig.Media` / `SetMediaStore` 接线。删完 CAS 才真正不存在。

## 为何可删(不是"先删了再说")

- **MediaAPI 在生产早已是死的**:`SDKConfig.Media` 自 86572d13 起从未被设置,
  `sdk.Media()` 恒为 nil,插件 digest 路径一直走"内容寻址库不可用"分支。
  删的是死代码,不是活路径。
- **第二套召回确无调用方**:`QueryMediaScored` 仅被 media 包自身的测试引用,
  跨包引用为 0(块召回走 `RecallBlocks`)。
- **heldMediaDigests 补丁**(crossmodal 靠它防召回"无处可归的媒体")
  已在 86572d13 删除 —— 两套存储才需要的那种补丁,随存储一起没了。

## 替代路径:块引用取代 digest

插件取媒体从 `sdk.Media().Get(digest)` 改为 `sdk.Memory().MediaBytes(ref)`:

- **新增 `MemoryAPI.MediaBytes(ref) (data, mime, ok, err)`**(内置接口,非 pubsdk):
  按块引用(完整块 ID 或唯一前缀)取字节;`ok=false` 而非 error ——
  "这个引用没有媒体"是调用方能处理的常态。
- **seemedia / multimodal**:`digest` 参数语义改为**媒体块引用**,工具描述同步。
  path/url 两条来源不变(生产实际只用这两条)。
- **webui 知识上传**:媒体字节不再 Put 进 CAS 再拿 digest,而是作为
  `KnowledgeMediaRef.Data` 随引用携带,由 knowledge.Write 落进条目目录
  (与 content.md 同生共灭,Remove 的 RemoveAll 天然清掉)。
- **回填命令去依赖**:cmd/homed-media-backfill 不再 import 待删的 media 包,
  改为直接读**冻结的历史布局** blobs/<d[:2]>/<d[2:]> 并自校验 sha256。
  依赖待删代码的迁移工具会在删包那刻失效 —— 迁移必须比被迁移的东西活得久。

## 判据(全部做过变异测试)

- `TestMemoryBytes_ByBlockRef`:完整 ID / 唯一前缀可取回且逐字节一致;
  不存在→ok=false;过短前缀(歧义)→ok=false;无负载块→ok=false;空引用不 panic。
  变异(不校验 ok,把不存在当有)当场被 3 条断言抓住。
- `TestE2EMediaBytesAgainstBackfilledDB`(-tags e2e,读真回填库):
  10/10 块经 MediaBytes 取回的字节与块 data 的 sha256 完全一致。
- `TestKnowledgeUploadedMediaRetrievable` 重写为**条目目录自带字节**判据
  (不再是"向存储按 digest 读回");`TestKnowledgeUploadMediaNeedsNoStore`
  改为"上传无外部存储依赖也必须成功"(旧的 503 语义已随 CAS 消失)。

## 静态确认
`QueryMediaScored` / `internal/memory/media` import / `MediaAPI` /
`sdk.Media()` / `SetMediaStore` / `heldMediaDigests` 全仓均为 0 处
(仅剩 medialoop.go 一句历史注释)。

回归:memory / sdk / agent-core / knowledge / plugin / webui / seemedia /
multimodal 八包全绿;全仓生产编译 GREEN。
2026-10-12 16:52:43 +08:00

470 lines
15 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)
}
}
// ---------- MediaBytes:CAS 淘汰后插件取媒体的唯一路径 ----------
// 判据(不变量,不是"函数被调过"):
// 1. 按完整块 ID 取回字节与 MIME,逐字节一致
// 2. 按唯一前缀也能取回(跨进程/跨层只带短引用是常态)
// 3. 引用不存在 → ok=false 而非 error("没这个媒体"是调用方能处理的常态)
// 4. 过短前缀(歧义风险)→ ok=false;宁可不解析也不挂错媒体
// 5. 文本块(无负载)→ ok=false,不返回空切片冒充"有字节"
func TestMemoryBytes_ByBlockRef(t *testing.T) {
g, _, _ := newTestStores(t)
payload := []byte("PNGDATA-0123456789")
b := mediaBlock("image/png", payload)
if err := g.PutMemoryBlocks([]memory.MemoryBlock{b}); err != nil {
t.Fatalf("PutMemoryBlocks: %v", err)
}
m := NewGraphMemoryWithPlugin("test", g)
// 1. 完整块 ID
data, mime, ok, err := m.MediaBytes(b.ID)
if err != nil {
t.Fatalf("MediaBytes(全 ID): %v", err)
}
if !ok {
t.Fatal("完整块 ID 应能取回字节")
}
if string(data) != string(payload) {
t.Errorf("字节 = %q,期望 %q", data, payload)
}
if mime != "image/png" {
t.Errorf("MIME = %q,期望 image/png", mime)
}
// 2. 唯一前缀(blk_media_ + 前 4 位 hex 已 ≥12 字符)
prefix := b.ID[:len("blk_media_")+4]
data2, _, ok2, err := m.MediaBytes(prefix)
if err != nil || !ok2 {
t.Fatalf("唯一前缀应能取回字节: ok=%v err=%v", ok2, err)
}
if string(data2) != string(payload) {
t.Errorf("前缀取回字节不一致: %q", data2)
}
// 3. 不存在的引用 → ok=false, err=nil
if _, _, ok3, err := m.MediaBytes("blk_media_deadbeef0000"); err != nil || ok3 {
t.Errorf("不存在的引用应 ok=false err=nil,实为 ok=%v err=%v", ok3, err)
}
// 4. 过短前缀 → ok=false
if _, _, ok4, err := m.MediaBytes("blk_"); err != nil || ok4 {
t.Errorf("过短前缀应不解析,实为 ok=%v err=%v", ok4, err)
}
// 5. 文本块(无负载)→ ok=false
textBlock := memory.MemoryBlock{ID: "blk_src_abc123def456", Modality: memory.BlockText, Text: "纯文本"}
if err := g.PutMemoryBlocks([]memory.MemoryBlock{textBlock}); err != nil {
t.Fatal(err)
}
if _, _, ok5, err := m.MediaBytes(textBlock.ID); err != nil || ok5 {
t.Errorf("无负载的块应 ok=false,实为 ok=%v err=%v", ok5, err)
}
// 6. 空引用 → ok=false,不 panic
if _, _, ok6, _ := m.MediaBytes(" "); ok6 {
t.Error("空引用应 ok=false")
}
}