fix(memory): 媒体归档三缺陷——数据丢失、L3 入库不可靠、L3 检索未接线

自动触发链实测(medialive)连续暴露的三个缺陷,全部是「手工调 API 的
单测无法发现」的类型。附带该实测本身。

## 缺陷一:三元组全被拒时仍释放引用并删文档(数据丢失)

archiveColdDocs 只检查 len(triples) > 0 就释放媒体引用、删除文档。
但 Commit 会静默跳过实体名不合法的三元组(validEntityName 要求
2–50 字符),于是「无错但一条也没写进去」真实发生:

  [agent] doc→graph: doc_xxx → 0 entities, 0 relations
  [media] 文档 doc_xxx 入图库,释放 1 个媒体引用(描述已留在图库)
  被记忆引用的内容被 GC 删除了(清 1 条/318 字节)

图库里没有任何句子承载引用,文档也被删,blob 被 GC 回收 → 图片与描述
彻底消失。我在上一层写的注释「Commit 之后引用已挂到 graph_sentence」
是错的:ec=0 rc=0 时它什么也没挂。

修法(用户选定 B+A):
  B. ec==0 && rc==0 时保留文档、跳过归档——归档的实质是「信息从 L2
     搬到 L3」,搬不过去就不该删源,下轮再试。
  A. bindSentenceMedia 返回实际绑定数,commitTriplesWithMedia 透出为
     mediaBound;释放前四路判断(查引用出错→保守不释放/本无引用→无需
     释放/mediaBound==0→保留并记录原因/否则释放)。宁可留一条悬空
     引用(内容还在,可由后续一致性检查清理)也不能丢内容。

反向验证:旧行为下新测试确实 FAIL,报「引用被释放了」+「GC 删掉了本该
保留的内容」;恢复修复后 PASS。

## 缺陷二:媒体入 L3 依赖 NLP 提取器运气(可靠性)

媒体能否进图库,取决于提取器碰巧从描述文本里提出合规三元组。实测 LLM
的 477 字图片描述只产出「水平 -分割-> 成」,obj 仅 1 字被拒 → 整条媒体
记忆进不了图库。表现为「阶段 5 时好时坏」,取决于描述文本。

但媒体自身的 digest / mime / 描述都是确定的,不该受提取器支配。

新增 parseMediaMarkers + mediaTriplesFromText:从文档正文的媒体标记
直接产出确定三元组,先于 NLP 提取。同一份真实文档由 0 entities 0
relations 变为 ec=4 rc=2 且拿到句子 id。

三个设计点:
  - 实体名用「图片 <短digest>」而非描述:描述会被重新生成(换视觉模型、
    补描述),若名字取自描述,同一张图会在图谱上留下多个节点。digest
    不变则名字不变,长度也天然合规。
  - SentenceText 用原始标记段,保证 bindSentenceMedia 的正则必然能反解
    到 digest——绑定从概率事件变成确定行为。
  - 描述为空时仍产出「类型」三元组:描述是后台异步补的,媒体节点不该
    因为还没描述就不存在于图谱。
  - summarizeForEntity 按 rune 截断而非字节:按字节切会破坏 UTF-8,
    图库里会留下乱码实体名。同时清 Markdown 强调符。

这是过渡方案,用户已定:下个 feature 换多模态嵌入后不再依赖
「描述文本 → 提取三元组 → 图谱节点」这条链路。

## 缺陷三:L3 媒体检索没有任何调用方(接线缺失)

第四层实现的 RecallMediaForSentence / mediaContextForSentences 从未被
调用——媒体能存进 L3、能反查,但 agent 拿不出来。实测第二轮 agent 显式
调了 doc_query,回答「没有找到那张图片的任何记录」。

接两个入口:
  - buildMemoryContext(自动注入,每次 LLM 调用都走)
  - memory_recall 工具结果末尾(显式查询)

关系行只有实体名和关系类型,看不出「这条记忆当时还带了一张图」,
媒体挂在句子上,必须经 关系→句子→media_refs 反查。

一处折返:最初直接用 injected.Relations 取 sentence_id,测试失败。
Indexer.BuildContext 刻意把 Relations 置 nil(自动注入只给实体索引以省
token,细节留给 memory_recall)。改为用命中的实体名再查一次关系,
深度固定 1——媒体是「这条记忆当时带的图」,顺关系网扩散只会带出无关
媒体并挤占 token。

## medialive 自动触发链实测

internal/agent/core/medialive_test.go,medialive build tag,默认
go test 不收录。源/模型/密钥全部由调用方经环境变量显式指定,缺任何一项
Skip 并列出缺哪个——刻意不提供 fallback,猜一个 base_url 可能打到调用者
机器上不相干的服务,而失败会被误报成「媒体记忆有问题」。

  MEDIALIVE_BASE_URL=... MEDIALIVE_API_KEY=... \
  MEDIALIVE_MODEL=... MEDIALIVE_ADAPTER=... \
  go test -tags medialive ./internal/agent/core/ -run TestMediaLive -v

只注入一个 image 事件,之后七个阶段全由生产代码自己触发:CAS 落盘 →
引用绑定 → 描述生成 → L0→L2 转移 → L2→L3 绑定 → GC 保护 → 第二轮召回。
另有阴性对照:不给记忆时不该「记得」,否则阳性用例的通过可能只是模型
猜常见配色。上游不可用时 Skip 而非假 PASS。

真实 claude-opus-5 实测通过:第二轮不给图,agent 答出
「上:紫罗兰色 #8800DD / 中:蓝色 #0055EE / 下:纯红 #EE0000」。

## 测试

graphmedia_test.go 新增 8 例:数据丢失回归(反向验证过)、mediaBound
计数、媒体标记解析、实体名生成、描述截断、确定性三元组必然可入库、
关系→句子映射、L3 检索接线(自动注入与显式查询两路)。

全仓 go build / go vet / go test 通过,internal/agent/core 与
internal/memory 全绿,SDK 冻结 diff = 0。
This commit is contained in:
JianFeeeee
2026-09-05 12:03:30 +08:00
parent bce1a043f2
commit 1fbef8b53e
6 changed files with 1227 additions and 31 deletions

View File

@ -3,9 +3,12 @@ package core
import (
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
)
@ -136,7 +139,7 @@ func TestBindSentenceMedia_RoundTrip(t *testing.T) {
Subject: "图片", Relation: "内容", Object: "三色带", SentenceText: sentence,
}}
if _, _, err := a.commitTriplesWithMedia(triples, "s1", 0); err != nil {
if _, _, _, err := a.commitTriplesWithMedia(triples, "s1", 0); err != nil {
t.Fatal(err)
}
@ -211,7 +214,7 @@ func TestCommitTriplesWithMedia_FallsBackWithoutStore(t *testing.T) {
defer g.Close()
a := &Agent{memory: g}
ec, rc, err := a.commitTriplesWithMedia([]memory.Triple{
ec, rc, _, err := a.commitTriplesWithMedia([]memory.Triple{
{Subject: "张三", Relation: "喜欢", Object: "咖啡"},
}, "s1", 0)
if err != nil {
@ -355,3 +358,343 @@ func TestResolvePrefix_AmbiguityIsError(t *testing.T) {
}
}
}
func TestArchiveColdDocs_KeepsDocWhenGraphWriteEmpty(t *testing.T) {
// 数据丢失回归:三元组全被实体名校验拒绝时(Commit 无错但 0 entities
// 0 relations),文档不能删、媒体引用不能释放。
//
// 该缺陷曾真实发生:LLM 生成的 456 字图片描述提不出合规实体名
//(validEntityName 要求 2–50 字符),archiveColdDocs 只检查
// len(triples) > 0 就释放引用并删文档 → GC 清掉 blob → 图片与描述全丢。
a, _, ms := newGraphMediaAgent(t)
dir := t.TempDir()
ds := document.NewStore(filepath.Join(dir, "docs"))
if err := ds.Start(); err != nil {
t.Fatal(err)
}
defer ds.Stop()
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 而不是指望 NLP 提取器:docToTriples 在
// Source != "context_archived" 时会写一条 {文档 -来源-> Source},
// Source 超过 validEntityName 的 50 字符上限 → Commit 静默跳过
// → len(triples)==1 但 ec=0 rc=0。这正是生产上 456 字 LLM 描述
// 造成的同一状态,但构造是确定的,不依赖提取器的具体行为
//(提取器行为随版本变化,测试不该押在它身上)。
longSource := strings.Repeat("超长来源名", 20) // 100 字,远超 50 字符上限
// Summary 也必须超长:docToTriples 会为合理 summary 写一条
// {文档 -主题-> summary},那条能通过校验,ec/rc 就不为 0 了。
// 这里要的是「三元组全部被拒」这一个状态。
longSummary := strings.Repeat("超长摘要文本", 20) // >80 字,触发长度门槛被跳过
doc := &document.Doc{
ID: "doc_keep",
Summary: longSummary,
Content: "[image/png " + shortDigest(digest) + "] 一张图片的描述",
Source: longSource,
CreatedAt: time.Now().Add(-200 * time.Hour),
LastAccess: time.Now().Add(-200 * time.Hour),
AccessCount: 0,
}
if err := ds.Insert(doc); err != nil {
t.Fatal(err)
}
// Insert 会把 LastAccess 覆写成 now、AccessCount 置 1,
// 于是 FindColdDocs(72h, 2) 一篇都找不到。插入后再改回来,
// 让文档真正满足"冷"的条件——这是触发归档路径的前提。
for _, d := range ds.RecentDocs(10) {
if d.ID == doc.ID {
d.LastAccess = time.Now().Add(-200 * time.Hour)
d.AccessCount = 0
}
}
if err := ms.AddRef(digest, media.OwnerDocument, doc.ID); err != nil {
t.Fatal(err)
}
a.archiveColdDocs()
// 关键断言三连:内容在、引用在、文档在
if _, err := ms.Get(digest); err != nil {
t.Fatalf("图库未写入任何实体/关系,内容却丢了: %v", err)
}
refs, err := ms.Refs(media.OwnerDocument, doc.ID)
if err != nil {
t.Fatal(err)
}
if len(refs) == 0 {
t.Error("引用被释放了——图库没有句子承载它,释放后 GC 会删掉内容")
}
if removed, _, err := ms.GC(0); err != nil {
t.Fatal(err)
} else if _, err := ms.Stat(digest); err != nil {
t.Fatalf("GC(清 %d 条) 删掉了本该保留的内容", removed)
}
}
func TestCommitTriplesWithMedia_ReportsBoundCount(t *testing.T) {
// mediaBound 必须反映真实绑定数:归档路径靠它决定能否释放旧引用。
a, _, ms := newGraphMediaAgent(t)
digest, err := ms.Put([]byte("img"), media.Item{MIME: "image/png"})
if err != nil {
t.Fatal(err)
}
short := shortDigest(digest)
// 句子含可反解的短 digest → 应绑定 1 个
_, _, bound, err := a.commitTriplesWithMedia([]memory.Triple{{
Subject: "图片", Relation: "内容", Object: "三色带",
SentenceText: "[image/png " + short + "] 一张三色带图",
}}, "s1", 0)
if err != nil {
t.Fatal(err)
}
if bound != 1 {
t.Fatalf("应绑定 1 个媒体引用,实际 %d", bound)
}
// 句子无 digest → 绑定 0 个
_, _, bound2, err := a.commitTriplesWithMedia([]memory.Triple{{
Subject: "张三", Relation: "喜欢", Object: "咖啡",
SentenceText: "张三喜欢咖啡",
}}, "s2", 0)
if err != nil {
t.Fatal(err)
}
if bound2 != 0 {
t.Fatalf("无媒体标记的句子不该绑定引用,实际 %d", bound2)
}
}
func TestSentenceIDsFromRelations(t *testing.T) {
// 关系行不持有媒体,媒体挂在句子上。这个函数负责"关系→句子"这一跳,
// 去重与去零都不能少:sentence_id=0 表示该关系没有关联句子,
// 拿 0 去查 media_refs 会命中一个不存在的 owner。
rels := []memory.Relation{
{ID: 1, SentenceID: 5},
{ID: 2, SentenceID: 0}, // 无句子
{ID: 3, SentenceID: 5}, // 重复
{ID: 4, SentenceID: 7},
}
got := sentenceIDsFromRelations(rels)
if len(got) != 2 {
t.Fatalf("应得 2 个去重后的句子 id,实际 %v", got)
}
if got[0] != 5 || got[1] != 7 {
t.Fatalf("句子 id 或顺序不对: %v", got)
}
if n := sentenceIDsFromRelations(nil); n != nil {
t.Fatalf("空输入应返回 nil,实际 %v", n)
}
}
func TestMediaContextForRelations_SurfacesMediaToAgent(t *testing.T) {
// L3 检索接线回归:媒体描述进了图库,agent 必须拿得出来。
//
// 第四层做完了"存和反查的能力"(RecallMediaForSentence /
// mediaContextForSentences),但那两个函数一度没有任何调用方——
// 媒体能进 L3,进去之后 agent 检索不到。这个测试守住那条接线。
a, _, ms := newGraphMediaAgent(t)
digest, err := ms.Put([]byte("img bytes"), media.Item{MIME: "image/png"})
if err != nil {
t.Fatal(err)
}
if err := ms.Describe(digest, "一张紫蓝红三色带图", "visionllm"); err != nil {
t.Fatal(err)
}
if err := ms.AddRef(digest, media.OwnerGraphSentence, "5"); err != nil {
t.Fatal(err)
}
// 命中的关系挂着 5 号句子 → 应产出媒体说明
out := a.mediaContextForRelations([]memory.Relation{{ID: 1, SentenceID: 5}})
if out == "" {
t.Fatal("关系挂着有媒体的句子,却没产出媒体说明——L3 检索接线断了")
}
if !contains(out, "一张紫蓝红三色带图") {
t.Errorf("媒体说明里应含描述文本: %q", out)
}
if !contains(out, shortDigest(digest)) {
t.Errorf("媒体说明里应含短 digest 供反查: %q", out)
}
// 没挂媒体的关系不该产出噪声
if out := a.mediaContextForRelations([]memory.Relation{{ID: 2, SentenceID: 99}}); out != "" {
t.Errorf("无媒体的句子不该产出说明: %q", out)
}
if out := a.mediaContextForRelations(nil); out != "" {
t.Errorf("空关系不该产出说明: %q", out)
}
}
func TestBuildMemoryContext_IncludesMediaSection(t *testing.T) {
// buildMemoryContext 是自动注入路径(每次 LLM 调用都走)。
// 媒体说明必须出现在这里,否则 agent 只有显式调 memory_recall 才知道有图。
a, graph, ms := newGraphMediaAgent(t)
digest, err := ms.Put([]byte("auto inject"), media.Item{MIME: "image/png"})
if err != nil {
t.Fatal(err)
}
if err := ms.Describe(digest, "自动注入用的测试图", "visionllm"); err != nil {
t.Fatal(err)
}
sentence := "用户发来的图片 [image/png " + shortDigest(digest) + "] 自动注入用的测试图"
sids, _, _, err := graph.CommitWithMedia([]memory.Triple{{
Subject: "测试图片", Relation: "包含", Object: "三色带", SentenceText: sentence,
}}, "auto", 0)
if err != nil {
t.Fatal(err)
}
sid := sids[sentence]
if sid == 0 {
t.Fatal("拿不到句子 id")
}
if err := ms.AddRef(digest, media.OwnerGraphSentence, strconv.FormatInt(sid, 10)); err != nil {
t.Fatal(err)
}
a.indexer = memory.NewIndexer(graph)
if err := a.indexer.Sync(); err != nil {
t.Fatalf("indexer sync: %v", err)
}
out := a.buildMemoryContext("测试图片", 0)
if out == "" {
t.Skip("图库召回未命中(indexer 检索策略所致),无法验证媒体段注入")
}
if !contains(out, "【关联媒体】") {
t.Errorf("自动注入的记忆上下文缺少媒体段: %q", out)
}
if !contains(out, "自动注入用的测试图") {
t.Errorf("媒体段里应含描述文本: %q", out)
}
}
func TestParseMediaMarkers(t *testing.T) {
// 与 mediaSummaryForEvent 的输出格式严格对应
text := "用户发来图片\n媒体内容:\n" +
"[image/png a1b2c3d4e5f6] 一张紫蓝红三色带图\n" +
"[audio/wav bbbbccccdddd] 一段三秒的钢琴声\n" +
"[image/png a1b2c3d4e5f6] 重复的同一张图"
ms := parseMediaMarkers(text)
if len(ms) != 2 {
t.Fatalf("应解析出 2 条去重后的标记,实际 %d: %+v", len(ms), ms)
}
if ms[0].label != "image/png" || ms[0].shortDigest != "a1b2c3d4e5f6" {
t.Errorf("第一条解析错误: %+v", ms[0])
}
if ms[0].description != "一张紫蓝红三色带图" {
t.Errorf("描述应取到行尾且不跨行: %q", ms[0].description)
}
if ms[1].label != "audio/wav" {
t.Errorf("第二条 label 错误: %+v", ms[1])
}
// raw 用作 SentenceText,必须含 digest 才能被 bindSentenceMedia 反解
if !contains(ms[0].raw, "a1b2c3d4e5f6") {
t.Errorf("raw 必须含 digest: %q", ms[0].raw)
}
if n := parseMediaMarkers("没有任何标记的普通文本"); n != nil {
t.Errorf("无标记应返回 nil,实际 %+v", n)
}
}
func TestMediaEntityName(t *testing.T) {
// 实体名必须由 digest 而非描述构成:描述会被重新生成,
// 若名字取自描述,同一张图会在图谱上留下多个节点。
cases := []struct{ label, digest, want string }{
{"image/png", "a1b2c3d4e5f6", "图片 a1b2c3d4e5f6"},
{"audio/wav", "bbbbccccdddd", "音频 bbbbccccdddd"},
{"video/mp4", "ccccddddeeee", "视频 ccccddddeeee"},
{"application/octet-stream", "ddddeeeeffff", "媒体 ddddeeeeffff"},
}
for _, c := range cases {
got := mediaEntityName(c.label, c.digest)
if got != c.want {
t.Errorf("mediaEntityName(%q,%q) = %q,期望 %q", c.label, c.digest, got, c.want)
}
// 必须过 validEntityName 的 2–50 字符门槛,否则 Commit 会静默跳过
if n := len([]rune(got)); n < 2 || n > 50 {
t.Errorf("实体名长度 %d 不在 2–50 之间: %q", n, got)
}
}
}
func TestSummarizeForEntity(t *testing.T) {
cases := []struct{ in, want string }{
{"一张紫蓝红三色带图。还有更多内容。", "一张紫蓝红三色带图"},
{"**整体构成**:正方形画布", "整体构成:正方形画布"}, // Markdown 强调符被清掉
{"", ""},
{"短", ""}, // 单字过不了 validEntityName,宁可不写
// 无句子边界时按 rune 截到 40(不是按字节,否则切坏 UTF-8 会在图库里留乱码)
{"没有句子边界的一长串文字需要按 rune 截断以免切坏 UTF-8 编码导致图库里出现乱码实体名字符",
"没有句子边界的一长串文字需要按 rune 截断以免切坏 UTF-8 编码导致图库"},
}
for _, c := range cases {
got := summarizeForEntity(c.in, 40)
if got != c.want {
t.Errorf("summarizeForEntity(%q) = %q,期望 %q", c.in, got, c.want)
}
}
}
func TestMediaTriplesFromText_DeterministicRegardlessOfNLP(t *testing.T) {
// 核心回归:媒体入 L3 不再依赖 NLP 提取器的运气。
//
// 实测 LLM 的 477 字图片描述经提取器只产出「水平 -分割-> 成」,
// obj 仅 1 字被 validEntityName 拒掉 → ec=0 rc=0 → 媒体记忆进不了图库,
// 且时好时坏取决于描述文本。这里验证确定性路径。
longDesc := "这张图片是一张纯色块构成的抽象图像,不包含任何文字、人物、物体或可识别的场景。" +
"整体构成:一个小尺寸的正方形图像,被水平分割成三条颜色条带。"
text := "媒体内容:\n[image/png 89e293b42546] " + longDesc
triples := mediaTriplesFromText(text)
if len(triples) < 2 {
t.Fatalf("应至少产出类型+内容两条三元组,实际 %d", len(triples))
}
// 每条都必须能通过 validEntityName(经 Commit 实证)
g, err := memory.NewGraphDB(filepath.Join(t.TempDir(), "g.db"))
if err != nil {
t.Fatal(err)
}
defer g.Close()
sids, ec, rc, err := g.CommitWithMedia(triples, "det", 0)
if err != nil {
t.Fatal(err)
}
if ec == 0 || rc == 0 {
t.Fatalf("确定性三元组应能写入图库,实际 ec=%d rc=%d", ec, rc)
}
if len(sids) == 0 {
t.Fatal("应返回句子 id 供 bindSentenceMedia 绑定")
}
// SentenceText 必须含 digest,否则绑定还是断的
for st := range sids {
if !contains(st, "89e293b42546") {
t.Errorf("句子必须含短 digest 供反解: %q", st)
}
}
// 描述为空时仍应产出类型三元组——媒体节点不能因为没描述就不存在
bare := mediaTriplesFromText("[image/png 89e293b42546]")
if len(bare) != 1 {
t.Fatalf("无描述时应只有类型三元组,实际 %d 条", len(bare))
}
if bare[0].Relation != "类型" {
t.Errorf("无描述时那条应是类型三元组: %+v", bare[0])
}
}