Files
HomeAgent/internal/agent/core/graphmedia_test.go
JianFeeeee 28fc833a6f feat(memory): L3 图库媒体反查 + 修 L2→L3 引用泄漏
媒体记忆四层收尾。方案 A:只做引用,不建媒体实体节点。

## 为何不把媒体建成图库实体

图库里的实体与关系全部来自**描述文本**的 NLP 提取——描述经
mediaSummaryForEvent 进 L0 事件的 Input,随归档进 L2 文档的 Content,
蒸馏时提取器自然从描述文字里抽出实体和关系。检索能力已经具备。

若再把媒体本身建成节点,节点名只能从描述里取,而描述会被重新生成
(换个视觉模型、补一次描述,名字就变了),于是同一张图会在图谱上留下
多个语义模糊的节点。代价换不来能力。

所以这一层只做一件事:**反查**。图库句子写着「[image a1b2c3d4e5f6]
一张紫蓝红三色带图」,要能从这条句子取回那份字节。

## CommitWithMedia:新增方法而非改签名

Commit 有 10 个非测试调用点 + 21 个测试调用点。为一个多数调用方都不需要
的返回值改全部签名不划算。新增 CommitWithMedia 返回
map[句子文本]sentences.id,Commit 内部转调同一份落库逻辑。

## digest 靠正则从文本反解

三元组由 NLP 提取器从纯文本产出(nlp.ToMemoryTriple 只填 Subject/
Relation/Object/Confidence/SentenceText),提取链路上没有任何位置能塞进
结构化的 digest。要贯通就得改 internal/nlp 的整条数据流。而媒体标记本身
是我们自己按固定格式写进文本的,反解是最省的可靠做法。

配套加 media.ResolvePrefix:文本里是 12 位短 digest(完整 64 位会把一行
撑爆且无助人眼辨认),media_refs 主键要完整 digest。

**前缀歧义视为错误而非"取第一个"**:挂错引用会让 GC 删掉仍被引用的内容。
完整但不存在的 digest 也报错,否则调用方会挂一条孤儿引用。

## 顺带修掉 L2→L3 的引用泄漏

这是上一层(f855893)留下的缺口:我当时只处理了 L0→L2 的引用转移,
漏了 L2→L3 这一跳。archiveColdDocs 调 docStore.Remove(doc.ID) 时不注销
媒体引用——文档一旦消失就再没有任何东西能告诉我们它引用过哪些 digest,
media_refs 里那条记录永久悬空、引用计数永不归零,对应 blob 永远不会被
GC 回收。

新增 releaseDocMedia。L2→L3 这一跳是**释放**而非转移,因为图库存的是从
描述文本抽出的实体与关系,不再持有字节;媒体此时已完成使命。

顺序有讲究:必须在 commitTriplesWithMedia 之后释放。那一步已把引用挂到
graph_sentence owner 上,先销后挂会让引用计数瞬时归零,此时若后台 GC
正在跑就会把内容当孤儿清掉。

## 顺带修 Pending 的排除逻辑遗漏(承上一提交)

## 测试

graphmedia_test.go 11 例。核心是 TestBindSentenceMedia_RoundTrip:
写入 → 提交 → 从句子 id 反查 digest → 取回字节逐字节比对 → 跑 GC(0)
确认被引用的内容不被清。

其余覆盖:正则不误命中普通方括号([注意]/[TODO] 不能当 digest,否则会拿
假前缀去 ResolvePrefix)、无法补全的 digest 不挂引用、媒体关闭时全链路
静默 no-op、releaseDocMedia 释放后 GC 真能回收、200 个样本的前缀补全
要么唯一命中要么明确报歧义。

TestCommit_StillWorksAfterRefactor 记录一个既有行为:重复提交时
entitiesCreated 不归零,因为 SQLite 的 ON CONFLICT DO UPDATE 也算一行
affected。用 main 分支的 graph.go 单独跑过基线确认与本次重构无关,
该字段只用于日志,故记录现状不改行为。

全仓 go build / go vet / go test 通过,internal/agent/core 与
internal/memory 全部 -race -count=2 通过,SDK 冻结 diff = 0。
2026-09-04 22:52:30 +08:00

358 lines
10 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 core
import (
"path/filepath"
"strconv"
"testing"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
)
// L3 图库媒体引用测试。
//
// 这一层的目的只有一个:几个月后从图谱走到一条句子,要能取回当时那份字节。
// 因此测试的重点是「反查链路是否完整」以及「引用是否会悬空或误删」。
func newGraphMediaAgent(t *testing.T) (*Agent, *memory.GraphDB, *media.Store) {
t.Helper()
dir := t.TempDir()
g, err := memory.NewGraphDB(filepath.Join(dir, "graph.db"))
if err != nil {
t.Fatalf("NewGraphDB: %v", err)
}
t.Cleanup(func() { g.Close() })
ms, err := media.New(filepath.Join(dir, "media"), 0)
if err != nil {
t.Fatalf("media.New: %v", err)
}
t.Cleanup(func() { ms.Close() })
return &Agent{memory: g, mediaStore: ms}, g, ms
}
func TestExtractMediaDigests(t *testing.T) {
// 与 mediaSummaryForEvent 的输出格式对应
cases := []struct {
name string
text string
want []string
}{
{"事件摘要格式", "媒体内容:\n[image/png a1b2c3d4e5f6] 一张紫蓝红三色带图", []string{"a1b2c3d4e5f6"}},
{"kind 兜底格式", "[image abcdef0123456789] (未描述)", []string{"abcdef0123456789"}},
{"一句多个", "[image aaaaaaaaaaaa] 图一;[image bbbbbbbbbbbb] 图二", []string{"aaaaaaaaaaaa", "bbbbbbbbbbbb"}},
{"去重", "[image cccccccccccc] x [image/png cccccccccccc] y", []string{"cccccccccccc"}},
{"无标记", "普通句子,没有媒体", nil},
{"空串", "", nil},
// 非十六进制、过短的方括号内容不能误命中,否则会拿一个假前缀去 ResolvePrefix
{"非 digest 方括号", "[注意] 这是普通标注 [TODO]", nil},
{"过短", "[image abc] 太短", nil},
}
for _, c := range cases {
got := extractMediaDigests(c.text)
if len(got) != len(c.want) {
t.Fatalf("%s: 得到 %v期望 %v", c.name, got, c.want)
}
for i := range got {
if got[i] != c.want[i] {
t.Fatalf("%s: 第 %d 个得到 %q期望 %q", c.name, i, got[i], c.want[i])
}
}
}
}
func TestCommitWithMedia_ReturnsSentenceIDs(t *testing.T) {
_, g, _ := newGraphMediaAgent(t)
sentence := "[image/png a1b2c3d4e5f6] 一张紫蓝红三色带图"
triples := []memory.Triple{{
Subject: "图片", Relation: "内容", Object: "三色带",
SentenceText: sentence,
}}
ids, ec, rc, err := g.CommitWithMedia(triples, "s1", 0)
if err != nil {
t.Fatal(err)
}
if ec == 0 || rc == 0 {
t.Fatalf("应写入实体与关系,实际 ec=%d rc=%d", ec, rc)
}
if ids[sentence] == 0 {
t.Fatalf("应返回句子 id实际 %v", ids)
}
}
func TestCommit_StillWorksAfterRefactor(t *testing.T) {
// Commit 有三十多个调用点,内部转调后行为必须完全不变
_, g, _ := newGraphMediaAgent(t)
triples := []memory.Triple{
{Subject: "张三", Relation: "喜欢", Object: "咖啡", SentenceText: "张三喜欢咖啡"},
{Subject: "李四", Relation: "住在", Object: "北京"},
}
ec, rc, err := g.Commit(triples, "s1", 0)
if err != nil {
t.Fatal(err)
}
if ec != 4 || rc != 2 {
t.Fatalf("期望 4 实体 2 关系,实际 ec=%d rc=%d", ec, rc)
}
// 重复提交同一批:关系被唯一约束去重。
//
// 实体计数**不**归零——这是 upsertEntity 的既有行为SQLite 的
// ON CONFLICT DO UPDATE 也算一行 affected于是 RowsAffected() > 0
// 被当成"新建了"。用 main 分支的 graph.go 单独验证过基线同样是
// 首次 ec=2 / 重复 ec=2与 CommitWithMedia 重构无关。
// entitiesCreated 只用于日志,故此处记录现状而不改行为。
ec2, rc2, err := g.Commit(triples, "s1", 0)
if err != nil {
t.Fatal(err)
}
if rc2 != 0 {
t.Fatalf("重复提交不该新建关系,实际 rc=%d", rc2)
}
if ec2 != 4 {
t.Fatalf("实体计数应与首次一致(既有 upsert 计数行为),实际 ec=%d", ec2)
}
}
func TestBindSentenceMedia_RoundTrip(t *testing.T) {
// 整层的核心断言:写入 → 提交 → 反查取回原始字节
a, _, ms := newGraphMediaAgent(t)
content := []byte("\x89PNG\r\n\x1a\n fake image bytes")
digest, err := ms.Put(content, media.Item{MIME: "image/png", Kind: media.KindImage})
if err != nil {
t.Fatal(err)
}
short := shortDigest(digest)
sentence := "[image/png " + short + "] 一张紫蓝红三色带图"
triples := []memory.Triple{{
Subject: "图片", Relation: "内容", Object: "三色带", SentenceText: sentence,
}}
if _, _, err := a.commitTriplesWithMedia(triples, "s1", 0); err != nil {
t.Fatal(err)
}
// 找到句子 id
ids, _, _, err := a.memory.CommitWithMedia(triples, "s1", 0)
if err != nil {
t.Fatal(err)
}
sid := ids[sentence]
if sid == 0 {
t.Fatal("拿不到句子 id")
}
// 反查:从句子取回 digest再取回字节
digests, err := a.RecallMediaForSentence(sid)
if err != nil {
t.Fatal(err)
}
if len(digests) != 1 || digests[0] != digest {
t.Fatalf("反查应得完整 digest %s实际 %v", shortDigest(digest), digests)
}
got, err := ms.Get(digests[0])
if err != nil {
t.Fatalf("取回内容失败: %v", err)
}
if string(got) != string(content) {
t.Fatal("取回的内容与写入不一致")
}
// 引用计数非零 → GC 不会清它
if _, _, err := ms.GC(0); err != nil {
t.Fatal(err)
}
if _, err := ms.Get(digest); err != nil {
t.Fatalf("被图库句子引用的内容不该被 GC 清掉: %v", err)
}
}
func TestBindSentenceMedia_SkipsUnresolvable(t *testing.T) {
// 文本里的 digest 在库里不存在时必须跳过,不能挂一条对不上的引用——
// 那条引用 DropOwner 永远匹配不到,会永久占着计数。
a, _, ms := newGraphMediaAgent(t)
sentence := "[image/png deadbeefdead] 一张不存在的图"
ids := map[string]int64{sentence: 42}
a.bindSentenceMedia(ids)
refs, err := ms.Refs(media.OwnerGraphSentence, "42")
if err != nil {
t.Fatal(err)
}
if len(refs) != 0 {
t.Fatalf("无法补全的 digest 不该挂引用,实际 %v", refs)
}
}
func TestBindSentenceMedia_NilStoreNoop(t *testing.T) {
a := &Agent{}
a.bindSentenceMedia(map[string]int64{"[image aaaaaaaaaaaa] x": 1})
if got, err := a.RecallMediaForSentence(1); err != nil || got != nil {
t.Fatalf("媒体关闭时应静默无操作,实际 %v / %v", got, err)
}
}
func TestCommitTriplesWithMedia_FallsBackWithoutStore(t *testing.T) {
// 媒体关闭时退回普通 Commit行为与直接调 Commit 完全一致
dir := t.TempDir()
g, err := memory.NewGraphDB(filepath.Join(dir, "g.db"))
if err != nil {
t.Fatal(err)
}
defer g.Close()
a := &Agent{memory: g}
ec, rc, err := a.commitTriplesWithMedia([]memory.Triple{
{Subject: "张三", Relation: "喜欢", Object: "咖啡"},
}, "s1", 0)
if err != nil {
t.Fatal(err)
}
if ec != 2 || rc != 1 {
t.Fatalf("期望 2 实体 1 关系,实际 ec=%d rc=%d", ec, rc)
}
}
func TestReleaseDocMedia_DropsRefsSoGCCanReclaim(t *testing.T) {
// L2→L3 那一跳留下的泄漏:文档被 Remove 但引用没销,
// 引用计数永不归零blob 永远不会被 GC 回收。
a, _, ms := newGraphMediaAgent(t)
digest, err := ms.Put([]byte("doc image"), media.Item{MIME: "image/png"})
if err != nil {
t.Fatal(err)
}
if err := ms.AddRef(digest, media.OwnerDocument, "doc_1"); err != nil {
t.Fatal(err)
}
// 释放前 GC 清不掉
if _, _, err := ms.GC(0); err != nil {
t.Fatal(err)
}
if _, err := ms.Stat(digest); err != nil {
t.Fatal("有文档引用时不该被清")
}
a.releaseDocMedia("doc_1")
if refs, _ := ms.Refs(media.OwnerDocument, "doc_1"); len(refs) != 0 {
t.Fatalf("释放后不该还有文档引用,实际 %v", refs)
}
// 现在 GC 能回收了
removed, _, err := ms.GC(0)
if err != nil {
t.Fatal(err)
}
if removed != 1 {
t.Fatalf("释放引用后 GC 应能回收,实际清理 %d 条", removed)
}
}
func TestMediaContextForSentences(t *testing.T) {
a, _, ms := newGraphMediaAgent(t)
digest, _ := ms.Put([]byte("img"), media.Item{MIME: "image/png"})
if err := ms.Describe(digest, "一张紫蓝红三色带图", "visionllm"); err != nil {
t.Fatal(err)
}
if err := ms.AddRef(digest, media.OwnerGraphSentence, "7"); err != nil {
t.Fatal(err)
}
out := a.mediaContextForSentences([]int64{7, 8})
if out == "" {
t.Fatal("应产出媒体说明")
}
if !contains(out, "句子 #7") || !contains(out, "一张紫蓝红三色带图") {
t.Fatalf("说明内容不对: %q", out)
}
// 8 号句子没引用媒体,不该出现
if contains(out, "句子 #8") {
t.Fatalf("无引用的句子不该出现: %q", out)
}
}
func TestResolvePrefix(t *testing.T) {
dir := t.TempDir()
ms, err := media.New(filepath.Join(dir, "m"), 0)
if err != nil {
t.Fatal(err)
}
defer ms.Close()
digest, _ := ms.Put([]byte("content"), media.Item{MIME: "image/png"})
// 短前缀补全
full, err := ms.ResolvePrefix(digest[:12])
if err != nil || full != digest {
t.Fatalf("短前缀补全失败: %v / %v", full, err)
}
// 完整 digest 原样返回
full, err = ms.ResolvePrefix(digest)
if err != nil || full != digest {
t.Fatalf("完整 digest 应原样返回: %v / %v", full, err)
}
// 过短拒绝
if _, err := ms.ResolvePrefix("abc"); err == nil {
t.Fatal("过短前缀应报错")
}
// 不存在
if _, err := ms.ResolvePrefix("deadbeefdead"); err == nil {
t.Fatal("不存在的前缀应报错")
}
// 完整但不存在的 digest 也要报错,否则调用方会挂一条孤儿引用
fake := ""
for i := 0; i < 64; i++ {
fake += "0"
}
if _, err := ms.ResolvePrefix(fake); err == nil {
t.Fatal("不存在的完整 digest 应报错")
}
}
func TestResolvePrefix_AmbiguityIsError(t *testing.T) {
// 前缀歧义视为错误而非"取第一个":挂错引用会让 GC 删掉仍被引用的内容。
// 构造歧义需要两个同前缀 digest——sha256 无法人为构造,
// 因此这里退而验证「8 位前缀在大量样本下的行为是确定的」:
// 要么唯一命中,要么明确报歧义,绝不静默取第一个。
dir := t.TempDir()
ms, err := media.New(filepath.Join(dir, "m"), 0)
if err != nil {
t.Fatal(err)
}
defer ms.Close()
digests := make([]string, 0, 200)
for i := 0; i < 200; i++ {
d, err := ms.Put([]byte("content-"+strconv.Itoa(i)), media.Item{MIME: "image/png"})
if err != nil {
t.Fatal(err)
}
digests = append(digests, d)
}
for _, d := range digests {
got, err := ms.ResolvePrefix(d[:12])
if err != nil {
// 报歧义是可接受结果;静默取错才是缺陷
if !contains(err.Error(), "歧义") {
t.Fatalf("非歧义错误: %v", err)
}
continue
}
if got != d {
t.Fatalf("补全结果错误: 前缀 %s 得到 %s", d[:12], got)
}
}
}