From 387b28ce09c9788b264fcb1e5b19edf99a45d37d Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sat, 5 Sep 2026 12:03:30 +0800 Subject: [PATCH] =?UTF-8?q?fix(memory):=20=E5=AA=92=E4=BD=93=E5=BD=92?= =?UTF-8?q?=E6=A1=A3=E4=B8=89=E7=BC=BA=E9=99=B7=E2=80=94=E2=80=94=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E4=B8=A2=E5=A4=B1=E3=80=81L3=20=E5=85=A5=E5=BA=93?= =?UTF-8?q?=E4=B8=8D=E5=8F=AF=E9=9D=A0=E3=80=81L3=20=E6=A3=80=E7=B4=A2?= =?UTF-8?q?=E6=9C=AA=E6=8E=A5=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 自动触发链实测(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。 --- internal/agent/core/distill.go | 82 +++- internal/agent/core/graphmedia.go | 223 +++++++++- internal/agent/core/graphmedia_test.go | 347 ++++++++++++++- internal/agent/core/medialive_test.go | 570 +++++++++++++++++++++++++ internal/agent/core/toolcall.go | 12 +- internal/agent/core/tooldefs.go | 24 +- 6 files changed, 1227 insertions(+), 31 deletions(-) create mode 100644 internal/agent/core/medialive_test.go diff --git a/internal/agent/core/distill.go b/internal/agent/core/distill.go index 09979df..44b892c 100644 --- a/internal/agent/core/distill.go +++ b/internal/agent/core/distill.go @@ -181,28 +181,67 @@ func (a *Agent) archiveColdDocs() { coldDocs := a.docStore.FindColdDocs(72*time.Hour, 2) for _, doc := range coldDocs { triples := docToTriples(doc, a.embedder) - if len(triples) > 0 { - ec, rc, err := a.commitTriplesWithMedia(triples, string(a.id)+"_doc_archival", 0) - if err != nil { - log.Printf("[agent] doc→graph archival error: %v", err) - continue - } - log.Printf("[agent] doc→graph: %s → %d entities, %d relations", doc.ID, ec, rc) - // 先销媒体引用再删文档:文档一旦从 docStore 消失, - // 就再没有任何东西能告诉我们它曾经引用过哪些 digest, - // media_refs 里那条记录就永久悬空、引用计数永不归零, - // 导致对应 blob 永远不会被 GC 回收。 - // - // 且必须在 commitTriplesWithMedia 之后:那一步已经把引用 - // 挂到了 graph_sentence owner 上。先销后挂会让引用计数瞬时 - // 归零,此时若后台 GC 正在跑就会把内容当孤儿清掉。 - a.releaseDocMedia(doc.ID) - a.docStore.Remove(doc.ID) + if len(triples) == 0 { + continue } + ec, rc, mediaBound, err := a.commitTriplesWithMedia(triples, string(a.id)+"_doc_archival", 0) + if err != nil { + log.Printf("[agent] doc→graph archival error: %v", err) + continue + } + + // 归档的实质是「信息从 L2 搬到 L3」。一条实体、一条关系都没写进 + // 图库时,信息并没有搬过去,此时删文档等于直接丢数据。 + // + // 这不是理论情形:Commit 会静默跳过实体名不合法的三元组 + //(validEntityName 要求 2–50 字符),而 LLM 生成的长描述几乎 + // 提不出合规实体名——实测 456 字图片描述得到 0 entities 0 + // relations,随后文档被删、媒体引用被释放、blob 被 GC 清掉, + // 图片与描述彻底消失。保留文档,下一轮再试。 + if ec == 0 && rc == 0 { + log.Printf("[agent] doc→graph: %s 未写入任何实体/关系,保留文档待下轮重试"+ + "(三元组 %d 条全被实体名校验拒绝)", doc.ID, len(triples)) + continue + } + log.Printf("[agent] doc→graph: %s → %d entities, %d relations", doc.ID, ec, rc) + + // 先销媒体引用再删文档:文档一旦从 docStore 消失,就再没有任何 + // 东西能告诉我们它曾经引用过哪些 digest,media_refs 里那条记录 + // 就永久悬空、引用计数永不归零,导致 blob 永远不会被 GC 回收。 + // + // 但只有在引用**确实**转移到 graph_sentence 之后才能释放: + // 图库里没有任何句子承载这些 digest 时释放旧引用,计数归零, + // GC 会把内容当孤儿删掉。宁可留一条悬空引用(内容还在,可由 + // 后续一致性检查清理),也不能丢内容。 + refs, refErr := a.docMediaRefs(doc.ID) + switch { + case refErr != nil: + log.Printf("[media] 查文档 %s 的媒体引用失败,保守不释放: %v", doc.ID, refErr) + case len(refs) == 0: + // 该文档本就没有媒体引用,无需释放。 + case mediaBound == 0: + log.Printf("[media] 文档 %s 有 %d 个媒体引用但图库一个都没绑上,"+ + "保留引用以免 GC 删除内容(句子正文里可能没有可反解的短 digest)", + doc.ID, len(refs)) + default: + a.releaseDocMedia(doc.ID) + } + a.docStore.Remove(doc.ID) } } } +// docMediaRefs 返回文档当前持有的媒体引用(nil store 时为空)。 +// +// 单独取出来是为了让归档路径能在释放前先确认「有没有东西要释放」—— +// 没有引用时不必打日志,有引用但没绑上图库时必须保留。 +func (a *Agent) docMediaRefs(docID string) ([]string, error) { + if a.mediaStore == nil || docID == "" { + return nil, nil + } + return a.mediaStore.Refs(media.OwnerDocument, docID) +} + // releaseDocMedia 注销文档持有的全部媒体引用。 // // L2→L3 这一跳不再转移引用而是直接释放,因为图库存的是从描述 @@ -441,6 +480,15 @@ func docToTriples(doc *document.Doc, embedder nlp.Vectorizer) []memory.Triple { }) } + // 媒体三元组:确定性产出,先于 NLP 提取。 + // + // 媒体入 L3 曾完全依赖提取器碰巧从描述文本里提出合规三元组——实测 + // LLM 的 477 字图片描述只产出「水平 -分割-> 成」这类语法碎片, + // obj 仅 1 字被 validEntityName 拒掉,整条媒体记忆就进不了图库 + //(阶段性表现是"时好时坏",取决于提取器运气)。媒体自身的 + // digest / mime / 描述都是确定的,直接建三元组而不经提取器。 + triples = append(triples, mediaTriplesFromText(doc.Content)...) + // NLP 通用提取 e := nlp.NewExtractor(nil) if embedder != nil { diff --git a/internal/agent/core/graphmedia.go b/internal/agent/core/graphmedia.go index df113d1..f75610f 100644 --- a/internal/agent/core/graphmedia.go +++ b/internal/agent/core/graphmedia.go @@ -33,6 +33,153 @@ import ( // 以及有人手写了完整 digest 的情况。 var mediaDigestPattern = regexp.MustCompile(`\[[^\[\]]*?\b([0-9a-f]{8,64})\]`) +// mediaMarkerPattern 完整拆解一条媒体标记及其后跟的描述, +// 捕获组依次为:标签(mime 或 kind)、短 digest、该行剩余的描述文本。 +// +// 与 mediaSummaryForEvent 的输出格式严格对应: +// +// [image/png a1b2c3d4e5f6] 一张紫蓝红三色带图 +// +// 描述取到行尾而非贪婪到底:一条事件可能挂多个媒体,各占一行。 +var mediaMarkerPattern = regexp.MustCompile(`\[([^\[\]\s]+)\s+([0-9a-f]{8,64})\]([^\n]*)`) + +// mediaMarker 是从文档正文里解析出的一条媒体标记。 +type mediaMarker struct { + label string // mime 或 kind,如 image/png + shortDigest string + description string + raw string // 原始整段,用作三元组的 SentenceText +} + +// parseMediaMarkers 从文本里解析全部媒体标记。 +// +// 为何需要它而不只是 extractMediaDigests:媒体入 L3 曾完全依赖 NLP 提取器 +// 碰巧从描述文本里提出合规三元组——实测 LLM 的 477 字图片描述只产出 +// 「水平 -分割-> 成」这种语法碎片,obj 仅 1 字被 validEntityName 拒掉, +// 于是整条媒体记忆进不了图库。而媒体自身的信息(digest / mime / 描述) +// 是确定的,不该受提取器运气支配。 +func parseMediaMarkers(text string) []mediaMarker { + if text == "" { + return nil + } + ms := mediaMarkerPattern.FindAllStringSubmatch(text, -1) + if len(ms) == 0 { + return nil + } + seen := make(map[string]bool, len(ms)) + var out []mediaMarker + for _, m := range ms { + d := m[2] + if seen[d] { + continue + } + seen[d] = true + out = append(out, mediaMarker{ + label: m[1], + shortDigest: d, + description: strings.TrimSpace(m[3]), + raw: strings.TrimSpace(m[0]), + }) + } + return out +} + +// mediaEntityName 是媒体在图库里的实体名。 +// +// 形如「图片 a1b2c3d4e5f6」。刻意用 digest 而非描述文本构成名字: +// 描述会被重新生成(换视觉模型、补描述),若名字取自描述,同一张图 +// 就会在图谱上留下多个节点。digest 不变则名字不变。 +// 长度也天然合规(validEntityName 要求 2–50 字符)。 +func mediaEntityName(label, shortDigest string) string { + kind := "媒体" + switch { + case strings.HasPrefix(label, "image"): + kind = "图片" + case strings.HasPrefix(label, "audio"): + kind = "音频" + case strings.HasPrefix(label, "video"): + kind = "视频" + } + return kind + " " + shortDigest +} + +// mediaTriplesFromText 为文本里的每条媒体标记产出确定的三元组。 +// +// 这是媒体进 L3 的可靠路径:不经过 NLP 提取器,因此不受它对描述性文本 +// 提取能力的影响。每条媒体至少产出一条「<媒体实体> -内容-> <描述摘要>」, +// 且 SentenceText 用原始标记段,保证 bindSentenceMedia 的正则必然能 +// 反解到 digest——绑定从概率事件变成确定行为。 +// +// 描述摘要截到 40 字:validEntityName 上限 50 字符,留出余量; +// 图谱节点名过长会让可视化和实体合并都难以处理,完整描述留在 +// SentenceText 与 media 表里。 +func mediaTriplesFromText(text string) []memory.Triple { + markers := parseMediaMarkers(text) + if len(markers) == 0 { + return nil + } + var out []memory.Triple + for _, m := range markers { + name := mediaEntityName(m.label, m.shortDigest) + + // 类型三元组恒可产出,不依赖描述是否存在 + out = append(out, memory.Triple{ + Subject: name, + SubjectType: "Media", + Relation: "类型", + Object: m.label, + ObjectType: "MimeType", + Confidence: 1.0, + SentenceText: m.raw, + }) + + desc := summarizeForEntity(m.description, 40) + if desc == "" { + continue + } + out = append(out, memory.Triple{ + Subject: name, + SubjectType: "Media", + Relation: "内容", + Object: desc, + ObjectType: "Description", + Confidence: 1.0, + SentenceText: m.raw, + }) + } + return out +} + +// summarizeForEntity 把描述压成可作实体名的短串。 +// +// 取首个句子边界之前的内容,再按 rune 截断——直接按字节截会切坏 UTF-8, +// 图库里就会出现乱码实体名。空白与 Markdown 强调符号一并清掉, +// 否则「**整体构成**」这类标记会进实体名。 +func summarizeForEntity(s string, maxRunes int) string { + s = strings.TrimSpace(s) + if s == "" { + return "" + } + s = strings.NewReplacer("**", "", "*", "", "\n", " ", "\t", " ").Replace(s) + for _, sep := range []string{"。", ";", ",", ". ", "; "} { + if i := strings.Index(s, sep); i > 0 { + s = s[:i] + break + } + } + s = strings.TrimSpace(s) + r := []rune(s) + if len(r) > maxRunes { + r = r[:maxRunes] + } + out := strings.TrimSpace(string(r)) + // 太短的残片(如单字)过不了 validEntityName,直接放弃比写进去更好 + if len([]rune(out)) < 2 { + return "" + } + return out +} + // extractMediaDigests 从文本里找出所有媒体标记的 digest。 // // 为何靠正则从文本反解,而不是让三元组结构携带 digest:三元组是 NLP @@ -66,9 +213,15 @@ func extractMediaDigests(text string) []string { // sentenceIDs 来自 GraphDB.CommitWithMedia:句子文本 → sentences.id。 // 只处理本次真正写入了 sentences 表的句子,避免给历史句子重复挂引用 // (AddRef 幂等,重复挂不会涨计数,但白跑 SQL)。 -func (a *Agent) bindSentenceMedia(sentenceIDs map[string]int64) { +// +// 返回实际绑定成功的引用数,这是调用方的安全依据:归档路径靠它判定 +// 「引用真的转移到图库了吗」,不能用「Commit 没报错」代替——Commit 会 +// 静默跳过实体名不合法(validEntityName 要求 2–50 字符)的三元组, +// 于是「无错但一条也没写进去」是真实会发生的:LLM 生成的长描述提不出 +// 合规实体名,实测 456 字描述得到 0 entities 0 relations。 +func (a *Agent) bindSentenceMedia(sentenceIDs map[string]int64) int { if a.mediaStore == nil || len(sentenceIDs) == 0 { - return + return 0 } bound := 0 @@ -99,26 +252,29 @@ func (a *Agent) bindSentenceMedia(sentenceIDs map[string]int64) { if bound > 0 { log.Printf("[media] L3 图库绑定 %d 个媒体引用", bound) } + return bound } // commitTriplesWithMedia 提交三元组并绑定句子里的媒体引用。 // // 包一层是为了让所有「三元组入库」的调用点用同一条路径拿到媒体绑定, // 而不必各自记得多调一次 bindSentenceMedia。 -func (a *Agent) commitTriplesWithMedia(triples []memory.Triple, sessionID string, turnID int) (int, int, error) { +// mediaBound 是本次实际挂到 graph_sentence owner 上的引用数;归档路径靠它 +// 判定能否安全释放旧引用。媒体存储关闭时恒为 0(此时也没有引用需要释放)。 +func (a *Agent) commitTriplesWithMedia(triples []memory.Triple, sessionID string, turnID int) (entities, relations, mediaBound int, err error) { if a.memory == nil { - return 0, 0, fmt.Errorf("graph memory 未启用") + return 0, 0, 0, fmt.Errorf("graph memory 未启用") } // 媒体存储关闭时退回普通 Commit,省掉 sentenceIDs 的 map 分配。 if a.mediaStore == nil { - return a.memory.Commit(triples, sessionID, turnID) + ec, rc, cErr := a.memory.Commit(triples, sessionID, turnID) + return ec, rc, 0, cErr } sentenceIDs, ec, rc, err := a.memory.CommitWithMedia(triples, sessionID, turnID) if err != nil { - return ec, rc, err + return ec, rc, 0, err } - a.bindSentenceMedia(sentenceIDs) - return ec, rc, nil + return ec, rc, a.bindSentenceMedia(sentenceIDs), nil } // RecallMediaForSentence 反查某条图库句子引用的媒体。 @@ -133,6 +289,57 @@ func (a *Agent) RecallMediaForSentence(sentenceID int64) ([]string, error) { return a.mediaStore.Refs(media.OwnerGraphSentence, strconv.FormatInt(sentenceID, 10)) } +// sentenceIDsFromRelations 收集一批关系引用的句子 id(去重、去零)。 +// +// 关系行本身不持有媒体,媒体挂在句子上(graph_sentence owner)。 +// 因此"这次召回涉及哪些媒体"必须经由关系 → 句子 → media_refs 这条路。 +func sentenceIDsFromRelations(relations []memory.Relation) []int64 { + if len(relations) == 0 { + return nil + } + seen := make(map[int64]bool, len(relations)) + var out []int64 + for _, r := range relations { + if r.SentenceID == 0 || seen[r.SentenceID] { + continue + } + seen[r.SentenceID] = true + out = append(out, r.SentenceID) + } + return out +} + +// mediaContextForRelations 是 mediaContextForSentences 的关系入口。 +// +// 单独包一层是因为两个调用点(自动注入的 buildMemoryContext 与显式的 +// memory_recall 工具)拿到的都是关系列表,不该各自重复"关系→句子"这步。 +func (a *Agent) mediaContextForRelations(relations []memory.Relation) string { + return a.mediaContextForSentences(sentenceIDsFromRelations(relations)) +} + +// mediaContextForInjectedEntities 为自动注入路径产出媒体说明。 +// +// 单独一条路径是因为 Indexer.BuildContext 刻意不返回关系 +// (Relations 恒为 nil,只给实体索引以省 token,细节留给 memory_recall)。 +// 于是自动注入拿不到 sentence_id,必须用命中的实体名再查一次关系。 +// +// 这次额外查询只为取 sentence_id,深度固定 1:媒体是"这条记忆当时带的图", +// 不需要顺着关系network 扩散——扩散只会带出无关媒体并挤占 token。 +func (a *Agent) mediaContextForInjectedEntities(injected *memory.InjectedContext) string { + if a.mediaStore == nil || a.memory == nil || injected == nil || len(injected.Entities) == 0 { + return "" + } + names := make([]string, 0, len(injected.Entities)) + for _, e := range injected.Entities { + names = append(names, e.Name) + } + res, err := a.memory.Recall(nil, names, 1, "") + if err != nil || res == nil { + return "" + } + return a.mediaContextForRelations(res.Relations) +} + // mediaContextForSentences 给一组句子附上媒体说明,供召回时拼进提示词。 // // 输出形如「句子 #12 关联媒体:[image/png a1b2c3d4e5f6] 一张紫蓝红三色带图」。 diff --git a/internal/agent/core/graphmedia_test.go b/internal/agent/core/graphmedia_test.go index 553105a..42f0f08 100644 --- a/internal/agent/core/graphmedia_test.go +++ b/internal/agent/core/graphmedia_test.go @@ -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]) + } +} diff --git a/internal/agent/core/medialive_test.go b/internal/agent/core/medialive_test.go new file mode 100644 index 0000000..73031a5 --- /dev/null +++ b/internal/agent/core/medialive_test.go @@ -0,0 +1,570 @@ +//go:build medialive + +// 媒体记忆自动触发链的集成测试。 +// +// 与其他媒体测试的区别:**不手工调用任何一步**。这里只做两件事—— +// 往 IOManager 注入一个 image 事件,然后等。之后全部由生产代码自己走: +// +// processMediaInput → captureBlockMedia(入 CAS) +// → Prune → transferMediaRefs(L0→L2 引用转移) +// → describePendingMedia(真实视觉模型生成描述) +// → archiveColdDocs → commitTriplesWithMedia → bindSentenceMedia(L2→L3) +// → 第二轮提问,验证 agent 真能召回 +// +// 为什么必须这样测:单测能证明每个函数正确,却证明不了它**被接上了**。 +// 本文件的直接动机是一个真实缺陷——core.New() 漏了 rc.SetMediaStore(cfg.MediaStore), +// 于是 L0→L2 引用转移在生产里永远静默 return,而手工注入 store 的单测全绿。 +// +// 需要真实 LLM,因此加 medialive build tag,默认 go test 不跑: +// +// MEDIALIVE_BASE_URL=http://127.0.0.1:8081/v1 \ +// MEDIALIVE_API_KEY=sk-xxx \ +// MEDIALIVE_MODEL=claude-opus-5 \ +// MEDIALIVE_ADAPTER=openai \ +// go test -tags medialive ./internal/agent/core/ -run TestMediaLive -v -timeout 20m +// +// 源、模型、密钥全部由调用方显式指定,测试自己不猜任何默认值—— +// 猜一个默认端点会让测试在别人机器上打到意料之外的服务。 +package core + +import ( + "bytes" + "compress/zlib" + "encoding/binary" + "fmt" + "hash/crc32" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api" + agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io" + luaVM "gitcode.com/JianFeeeee/HomeAgent/internal/lua" + "gitcode.com/JianFeeeee/HomeAgent/internal/memory" + "gitcode.com/JianFeeeee/HomeAgent/internal/memory/document" + "gitcode.com/JianFeeeee/HomeAgent/internal/memory/media" + "gitcode.com/JianFeeeee/HomeAgent/pkg/types" +) + +// liveCfg 是调用方通过环境变量显式提供的 LLM 源配置。 +type liveCfg struct { + baseURL string + apiKey string + model string + adapter string +} + +// requireLiveCfg 读取环境变量;缺任何一项就 Skip 而非猜默认值。 +// +// 刻意不提供 fallback:一个猜出来的 base_url 可能打到调用者机器上 +// 完全不相干的服务,而测试会把那次调用的失败报成"媒体记忆有问题"。 +func requireLiveCfg(t *testing.T) liveCfg { + t.Helper() + c := liveCfg{ + baseURL: os.Getenv("MEDIALIVE_BASE_URL"), + apiKey: os.Getenv("MEDIALIVE_API_KEY"), + model: os.Getenv("MEDIALIVE_MODEL"), + adapter: os.Getenv("MEDIALIVE_ADAPTER"), + } + var missing []string + if c.baseURL == "" { + missing = append(missing, "MEDIALIVE_BASE_URL") + } + if c.apiKey == "" { + missing = append(missing, "MEDIALIVE_API_KEY") + } + if c.model == "" { + missing = append(missing, "MEDIALIVE_MODEL") + } + if c.adapter == "" { + missing = append(missing, "MEDIALIVE_ADAPTER") + } + if len(missing) > 0 { + t.Skipf("缺少环境变量 %s——本测试要求调用方显式指定源/模型/密钥,不使用任何默认值", + strings.Join(missing, ", ")) + } + return c +} + +// livePNG 造一张横向三色带真 PNG(手工拼 IHDR/IDAT/IEND)。 +// +// 用可辨认的纯色而非随机字节:断言要能检查"模型是否真的看到了内容", +// 随机噪声无法产生可验证的描述。 +func livePNG(t *testing.T, w, h int, colors [][3]byte) []byte { + t.Helper() + chunk := func(typ string, data []byte) []byte { + var b bytes.Buffer + if err := binary.Write(&b, binary.BigEndian, uint32(len(data))); err != nil { + t.Fatal(err) + } + body := append([]byte(typ), data...) + b.Write(body) + if err := binary.Write(&b, binary.BigEndian, crc32.ChecksumIEEE(body)); err != nil { + t.Fatal(err) + } + return b.Bytes() + } + var raw bytes.Buffer + for y := 0; y < h; y++ { + raw.WriteByte(0) // filter type: none + c := colors[y*len(colors)/h] + for x := 0; x < w; x++ { + raw.Write(c[:]) + } + } + var comp bytes.Buffer + zw := zlib.NewWriter(&comp) + if _, err := zw.Write(raw.Bytes()); err != nil { + t.Fatal(err) + } + zw.Close() + + var ihdr bytes.Buffer + binary.Write(&ihdr, binary.BigEndian, uint32(w)) + binary.Write(&ihdr, binary.BigEndian, uint32(h)) + ihdr.Write([]byte{8, 2, 0, 0, 0}) // 8-bit truecolor + + var out bytes.Buffer + out.Write([]byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}) + out.Write(chunk("IHDR", ihdr.Bytes())) + out.Write(chunk("IDAT", comp.Bytes())) + out.Write(chunk("IEND", nil)) + return out.Bytes() +} + +// liveEnv 是一套完整但完全独立的 agent 运行环境。 +type liveEnv struct { + agent *Agent + io *agentIO.IOManager + mediaSt *media.Store + docStore *document.Store + graph *memory.GraphDB + dir string +} + +// newLiveEnv 构造真 Agent:真 provider、真 CAS、真图库、真文档库。 +// +// 不注册任何插件:本测试关心记忆链路,插件会引入无关的外部副作用 +// (网络轮询、写文件),而且生产插件目录里的进程不该被测试碰到。 +func newLiveEnv(t *testing.T, c liveCfg) *liveEnv { + t.Helper() + dir := t.TempDir() + + vm := luaVM.NewVM(filepath.Join(dir, "adapters")) + if err := vm.Start(); err != nil { + t.Fatalf("lua vm: %v", err) + } + t.Cleanup(vm.Stop) + + // Vision: true —— 能力是声明的,不是探测的。网关可能静默剥离 + // image_url 后仍返回 200,从响应无法推断它到底看见了没有。 + prov := agentAPI.NewLuaAdaptedProvider(agentAPI.BaseConfig{ + Model: c.model, BaseURL: c.baseURL, APIKey: c.apiKey, + MaxTokens: 1200, Temperature: 0.3, Vision: true, + }, vm, "medialive", c.adapter) + + pm := agentAPI.NewProviderManager() + pm.Register("medialive", prov) + if err := pm.SetDefault("medialive"); err != nil { + t.Fatalf("set default provider: %v", err) + } + + ms, err := media.New(filepath.Join(dir, "media"), 256<<20) + 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) + } + t.Cleanup(func() { graph.Close() }) + + docStore := document.NewStore(filepath.Join(dir, "docs")) + if err := docStore.Start(); err != nil { + t.Fatalf("doc store: %v", err) + } + t.Cleanup(docStore.Stop) + + io := agentIO.NewIOManager() + + a := New(AgentConfig{ + ID: types.AgentID("medialive"), + SystemPrompt: "你是一个有长期记忆的助手。回答简洁准确。", + Provider: prov, + ProviderManager: pm, + IO: io, + Memory: graph, + DocStore: docStore, + MediaStore: ms, + MediaGCInterval: 0, // 本测试自己控制 GC 时机 + MediaDescribe: true, // 描述循环由测试直接调 describePendingMedia + StageHost: NewStageHost(), + MaxContextSize: 3, // 故意压低:第二轮就能触发 Prune 归档 + InputProcessing: types.InputProcessingConfig{}, + }) + + // 排空 outputCh:容量 256,但长跑不消费会堵住 emitResponse。 + go func() { + for { + select { + case <-io.OutputChan(): + case <-a.ctx.Done(): + return + } + } + }() + + return &liveEnv{agent: a, io: io, mediaSt: ms, docStore: docStore, graph: graph, dir: dir} +} + +// TestMediaLive_AutoTriggerChain 全自动触发链:只注入事件,不手工调任何一步。 +func TestMediaLive_AutoTriggerChain(t *testing.T) { + c := requireLiveCfg(t) + env := newLiveEnv(t, c) + a := env.agent + defer a.Stop() + + img := livePNG(t, 96, 96, [][3]byte{{128, 0, 255}, {0, 64, 255}, {255, 0, 0}}) + t.Logf("测试图片: %d 字节(紫/蓝/红三色带)", len(img)) + + // ── 阶段 1:注入 image 事件,验证 CAS 自动落盘 ── + // + // 直接调 handleInput 而不启 eventLoop:eventLoop 是纯转发(select → + // handleInput),走同一条代码路径,但同步调用让断言不必猜时序。 + evt := &agentIO.InputEvent{ + RequestID: "live-1", + Source: "test_channel", + Type: "image", + OutputChannel: "test_channel", + Payload: map[string]interface{}{ + "data": mediaB64(img), + "mime": "image/png", + "alt": "一张测试图片", + }, + } + + t0 := time.Now() + a.handleInput(evt) + t.Logf("第一轮(含真实 LLM 往返)耗时 %.1fs", time.Since(t0).Seconds()) + + // 用 Pending 而非 Search 查刚落盘的项:Search 的 WHERE 里带 + // `COALESCE(description,'') != ''`,只返回**已描述**的媒体, + // 此刻描述还没生成(阶段3 才做),Search 必然返回 0 条。 + items, err := env.mediaSt.Pending(10) + if err != nil { + t.Fatalf("pending: %v", err) + } + if len(items) != 1 { + t.Fatalf("CAS 应自动收到 1 张图,实际 %d 张(captureBlockMedia 未被触发?)", len(items)) + } + digest := items[0].Digest + t.Logf("✓ 阶段1 CAS 自动落盘: digest=%s size=%d tool=%s", + digest[:12], items[0].Size, items[0].Tool) + + stored, err := env.mediaSt.Get(digest) + if err != nil || !bytes.Equal(stored, img) { + t.Fatalf("落盘内容与原图不一致 (err=%v)", err) + } + + // ── 阶段 2:引用自动挂到 ContextEvent 上 ── + // + // 这一步验证 bindEventMedia:事件必须拿到 ID 且 media_refs 里 + // 有对应 context owner 记录。两者只写一个的后果是 GC 误删或永不清理。 + var evtID string + var summaryOK bool + for _, e := range a.context.Recent(0) { + if len(e.Media) > 0 { + evtID = e.ID + summaryOK = strings.Contains(e.Input, digest[:12]) + break + } + } + if evtID == "" { + t.Fatal("没有任何 ContextEvent 挂上媒体(bindEventMedia 未被触发)") + } + ctxRefs, err := env.mediaSt.Refs(media.OwnerContext, evtID) + if err != nil || len(ctxRefs) != 1 || ctxRefs[0] != digest { + t.Fatalf("context owner 引用缺失: refs=%v err=%v", ctxRefs, err) + } + if !summaryOK { + t.Error("事件 Input 里没有媒体摘要标记(mediaSummaryForEvent 未生效)——" + + "L2/L3 靠正文里的短 digest 反查,缺了它整条召回链断掉") + } + t.Logf("✓ 阶段2 引用自动绑定: event=%s owner=context 摘要内嵌=%v", evtID, summaryOK) + + // ── 阶段 3:描述由后台循环自动生成(真实视觉模型)── + pending, err := env.mediaSt.Pending(5) + if err != nil { + t.Fatal(err) + } + if len(pending) != 1 { + t.Fatalf("应有 1 条待描述,实际 %d 条", len(pending)) + } + + t1 := time.Now() + a.describePendingMedia() + t.Logf("描述生成耗时 %.1fs", time.Since(t1).Seconds()) + + it, err := env.mediaSt.Stat(digest) + if err != nil { + t.Fatal(err) + } + if it.Description == "" { + t.Fatal("描述为空——describePendingMedia 未能通过视觉源生成描述") + } + sawColors := strings.Contains(it.Description, "紫") && + strings.Contains(it.Description, "蓝") && + strings.Contains(it.Description, "红") + t.Logf("✓ 阶段3 描述自动生成 (%d 字, 源=%s): %s", + len([]rune(it.Description)), it.DescribedBy, truncRunes(it.Description, 90)) + if !sawColors { + t.Errorf("描述未含紫/蓝/红三色,视觉模型可能没真正看到图片: %s", + truncRunes(it.Description, 200)) + } + if left, _ := env.mediaSt.Pending(5); len(left) != 0 { + t.Errorf("描述完成后仍在待描述队列(%d 条)——会被反复重描述", len(left)) + } + // 有描述之后 Search 才应能命中(它按 description 做 LIKE) + if found, err := env.mediaSt.Search("紫", media.KindImage, 5); err != nil { + t.Errorf("search: %v", err) + } else if len(found) == 0 { + t.Error("描述已生成但 Search(\"紫\") 命中 0 条——媒体库关键词入口失效") + } else { + t.Logf("✓ 阶段3 Search(\"紫\") 命中 %d 条", len(found)) + } + + // ── 阶段 4:Prune 自动把引用从 L0 转移到 L2 ── + // + // MaxContextSize=3,多注入几轮文本把带图事件挤出活跃上下文。 + // 这一步专门守 core.New() 里 rc.SetMediaStore 的接线:漏了它 + // transferMediaRefs 直接 return,引用永久悬空在 context owner 上。 + // 填充数量必须 > Prune 内部固定的 10 条保护窗口。 + // + // Prune 无条件保护最后 10 条事件(protected := events[len-10:]), + // 只在更早的部分里挑归档对象。填 4 条时总数才 5,全落进保护窗口、 + // candidates 为空、直接返回 0——这不是缺陷,是"最近的对话不该被归档" + // 的设计。带图事件必须被推到第 11 条之前才可能被归档。 + const fillerCount = 14 + for i := 0; i < fillerCount; i++ { + a.context.Append(ContextEvent{ + Timestamp: time.Now(), + Source: "filler", + Input: fmt.Sprintf("无关的填充对话 %d,用来把带图事件挤出活跃窗口", i), + Response: "好的。", + }) + } + archived := a.context.Prune("当前输入", a.maxContextSize-1, env.docStore) + t.Logf("Prune 归档 %d 条事件", archived) + if archived == 0 { + t.Fatal("Prune 未归档任何事件,无法验证引用转移") + } + + docRefsFound := "" + for _, d := range env.docStore.RecentDocs(20) { + refs, err := env.mediaSt.Refs(media.OwnerDocument, d.ID) + if err == nil && len(refs) > 0 && refs[0] == digest { + docRefsFound = d.ID + break + } + } + if docRefsFound == "" { + t.Fatal("引用未转移到 document owner——" + + "core.New() 是否漏了 rc.SetMediaStore(cfg.MediaStore)?" + + "(该缺陷曾真实存在:手工注入 store 的单测全绿,生产里永远静默 return)") + } + if left, _ := env.mediaSt.Refs(media.OwnerContext, evtID); len(left) != 0 { + t.Errorf("旧的 context 引用未注销(%d 条),引用计数永不归零 → blob 永不回收", len(left)) + } + t.Logf("✓ 阶段4 引用自动转移: context/%s → document/%s", evtID, docRefsFound) + + // 转移全程内容必须可读:先挂后销的顺序若反了, + // 计数会瞬时归零,并发 GC 会把仍被引用的内容当孤儿删掉。 + if _, err := env.mediaSt.Get(digest); err != nil { + t.Fatalf("转移后内容不可读: %v", err) + } + + // ── 阶段 5:archiveColdDocs 自动把媒体带进 L3 图库 ── + // + // FindColdDocs(72h, 2) 要求文档足够"冷",测试里新建的文档不满足, + // 因此把 LastAccess 往前推——这是为了触发生产代码路径, + // 而不是替代它(Commit/bindSentenceMedia/releaseDocMedia 全部由它自己调)。 + for _, d := range env.docStore.RecentDocs(20) { + if d.ID == docRefsFound { + d.LastAccess = time.Now().Add(-100 * time.Hour) + d.AccessCount = 0 + } + } + a.archiveColdDocs() + + sentRefs := 0 + var boundSentence int64 + rows, err := env.graph.Recall(nil, nil, 1, "") + if err != nil { + t.Fatalf("graph recall: %v", err) + } + t.Logf("图库实体数 %d", len(rows.Entities)) + // 句子 id 是自增整数,扫前若干个足够覆盖本测试写入的量 + for sid := int64(1); sid <= 40; sid++ { + refs, err := env.mediaSt.Refs(media.OwnerGraphSentence, strconv.FormatInt(sid, 10)) + if err == nil && len(refs) > 0 { + sentRefs += len(refs) + if boundSentence == 0 { + boundSentence = sid + } + } + } + if sentRefs == 0 { + t.Error("L2→L3 未绑定任何 graph_sentence 引用——" + + "bindSentenceMedia 未被 commitTriplesWithMedia 触发," + + "或句子正文里没有可反解的短 digest") + } else { + t.Logf("✓ 阶段5 L3 自动绑定: %d 个句子引用,首个 sentences.id=%d", sentRefs, boundSentence) + + got, err := env.agent.RecallMediaForSentence(boundSentence) + if err != nil || len(got) == 0 || got[0] != digest { + t.Errorf("从句子反查 digest 失败: got=%v err=%v", got, err) + } else if raw, err := env.mediaSt.Get(got[0]); err != nil || !bytes.Equal(raw, img) { + t.Errorf("从句子取回的字节与原图不一致 (err=%v)", err) + } else { + t.Logf("✓ 阶段5 反查取回 %d 字节,与原图逐字节一致", len(raw)) + } + } + + // ── 阶段 6:GC 不能删掉仍被记忆引用的内容 ── + removed, freed, err := env.mediaSt.GC(0) // minAge=0,最激进 + if err != nil { + t.Fatal(err) + } + if _, err := env.mediaSt.Stat(digest); err != nil { + t.Fatalf("被记忆引用的内容被 GC 删除了(清 %d 条/%d 字节)——"+ + "引用计数或 owner 语义有误", removed, freed) + } + t.Logf("✓ 阶段6 GC(minAge=0) 清 %d 条,被引用内容仍在", removed) + + // ── 阶段 7:E2E — 第二轮提问,验证 agent 真能召回 ── + // + // 不再提供图片,只问"还记得吗"。能答出三色说明记忆链路端到端可用。 + // L2 文档此刻已被 archiveColdDocs 删除(归档的语义就是搬完删源), + // 所以这一轮只能靠 L3 图库召回——而自动注入路径依赖 indexer。 + // 生产由 main.go 注入并周期 Sync;测试里手工建一个并同步一次。 + a.indexer = memory.NewIndexer(env.graph) + if err := a.indexer.Sync(); err != nil { + t.Fatalf("indexer sync: %v", err) + } + if mc := a.buildMemoryContext("图片 颜色", 0); mc != "" { + t.Logf("注入的记忆上下文: %s", truncRunes(mc, 200)) + if strings.Contains(mc, "【关联媒体】") { + t.Logf("✓ 记忆上下文含媒体段") + } else { + t.Error("记忆上下文缺少媒体段——L3 媒体检索接线未生效") + } + } else { + t.Error("图库召回为空,agent 无从得知历史媒体") + } + + ask := &agentIO.InputEvent{ + RequestID: "live-2", + Source: "test_channel", + Type: "text", + OutputChannel: "test_channel", + Payload: map[string]interface{}{ + "content": "你还记得我之前发给你的那张图片吗?它是什么样子的?请说出具体颜色。", + }, + } + respCh := make(chan *agentIO.OutputEvent, 4) + ask.ResponseCh = respCh + + t2 := time.Now() + a.handleInput(ask) + t.Logf("第二轮耗时 %.1fs", time.Since(t2).Seconds()) + + var answer string + select { + case out := <-respCh: + answer, _ = out.Payload["content"].(string) + case <-time.After(5 * time.Second): + t.Fatal("第二轮没有收到回复") + } + t.Logf("agent 回答: %s", truncRunes(answer, 220)) + + recalled := strings.Contains(answer, "紫") && + strings.Contains(answer, "蓝") && + strings.Contains(answer, "红") + if !recalled { + t.Errorf("agent 未能召回三色。这可能是记忆注入链路问题,"+ + "也可能是本轮上下文里已无相关记忆(描述在 L2/L3 但未被检索命中)。回答: %s", + truncRunes(answer, 300)) + } else { + t.Logf("✓ 阶段7 E2E 召回成功:不给图,agent 答出紫/蓝/红") + } + + st := env.mediaSt.Stats() + t.Logf("收尾: %v 条 / %v 字节 / 已描述 %v / 无引用 %v", + st["count"], st["total_bytes"], st["described"], st["unreferenced"]) +} + +// TestMediaLive_NegativeControl 阴性对照:没有媒体记忆时不该"记得"。 +// +// 没有这条对照,阶段7 的"答出紫蓝红"可能只是模型在猜常见配色, +// 无法区分真召回与先验偏好。 +func TestMediaLive_NegativeControl(t *testing.T) { + c := requireLiveCfg(t) + env := newLiveEnv(t, c) + a := env.agent + defer a.Stop() + + ask := &agentIO.InputEvent{ + RequestID: "neg-1", + Source: "test_channel", + Type: "text", + OutputChannel: "test_channel", + Payload: map[string]interface{}{ + "content": "你还记得我之前发给你的那张图片吗?它是什么样子的?请说出具体颜色。", + }, + } + respCh := make(chan *agentIO.OutputEvent, 4) + ask.ResponseCh = respCh + + a.handleInput(ask) + + var answer string + select { + case out := <-respCh: + answer, _ = out.Payload["content"].(string) + case <-time.After(5 * time.Second): + t.Fatal("阴性对照没有收到回复") + } + t.Logf("无记忆时的回答: %s", truncRunes(answer, 200)) + + // 上游不可用时这条对照没有意义:它只能证明"没答出颜色", + // 而原因是调用失败而非缺少记忆。据此判 PASS 属于假阳性。 + if strings.HasPrefix(answer, "处理错误:") { + t.Skipf("上游 LLM 调用失败,阴性对照无法判定: %s", truncRunes(answer, 160)) + } + + guessed := strings.Contains(answer, "紫") && + strings.Contains(answer, "蓝") && + strings.Contains(answer, "红") + if guessed { + t.Errorf("无任何媒体记忆却猜中紫/蓝/红——"+ + "说明阳性用例的通过可能只是先验偏好而非真召回: %s", truncRunes(answer, 300)) + } +} + +// mediaB64 返回不带 data URL 前缀的 base64(processMediaInput 自己拼前缀)。 +func mediaB64(b []byte) string { + return media.DataURL("image/png", b)[len("data:image/png;base64,"):] +} + +func truncRunes(s string, n int) string { + r := []rune(strings.ReplaceAll(s, "\n", " ")) + if len(r) <= n { + return string(r) + } + return string(r[:n]) + "…" +} diff --git a/internal/agent/core/toolcall.go b/internal/agent/core/toolcall.go index c6d02ce..1c722b2 100644 --- a/internal/agent/core/toolcall.go +++ b/internal/agent/core/toolcall.go @@ -150,6 +150,14 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall) string { } parts = append(parts, fmt.Sprintf("- %s →(%s)→ %s", r.SourceName, r.RelationType, r.TargetName)) } + // 命中的关系若挂着媒体,把媒体说明附在结果末尾。 + // + // 关系行只有实体名和关系类型,看不出"这条记忆当时还带了一张图"。 + // 媒体挂在句子上(graph_sentence owner),需经关系→句子→media_refs + // 反查。不附上的后果:agent 显式查了图记忆,却仍然不知道有图。 + if mc := a.mediaContextForRelations(result.Relations); mc != "" { + parts = append(parts, "", "关联媒体:", mc) + } return strings.Join(parts, "\n") case "memory_block_merge": @@ -189,7 +197,9 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall) string { if len(triples) == 0 { return "没有有效的三元组" } - ec, rc, err := a.commitTriplesWithMedia(triples, string(a.id), 0) + // remember 工具是用户/模型显式写入,不涉及归档删除, + // 因此不需要 mediaBound——没有旧引用要释放。 + ec, rc, _, err := a.commitTriplesWithMedia(triples, string(a.id), 0) if err != nil { return fmt.Sprintf("记忆写入失败: %v", err) } diff --git a/internal/agent/core/tooldefs.go b/internal/agent/core/tooldefs.go index d657cfc..e52b673 100644 --- a/internal/agent/core/tooldefs.go +++ b/internal/agent/core/tooldefs.go @@ -13,6 +13,24 @@ func (a *Agent) buildMemoryContext(input string, maxTokens int) string { } injected := a.indexer.BuildContext(input) s := a.indexer.FormatContext(injected) + + // 图库召回命中的实体若关联着带媒体的句子,把媒体说明一并注入。 + // + // 不做这一步的后果:媒体描述进了 L3,agent 却拿不出来。图库句子里 + // 写着 [image/png a1b2c3d4e5f6] 这样的短标记,但没有任何东西告诉 + // 模型那份内容是否还在、能否重新查看——描述永存而 blob 可能已被 + // 容量 GC 淘汰,两者状态不同,必须显式告知。 + // + // 注意不能直接用 injected.Relations:BuildContext 刻意把它置为 nil + //(自动注入只给实体索引以省 token,细节留给 memory_recall)。 + // 因此这里用命中的实体名再查一次关系,只为拿到 sentence_id。 + if mc := a.mediaContextForInjectedEntities(injected); mc != "" { + if s != "" { + s += "\n" + } + s += "【关联媒体】\n" + mc + } + if maxTokens > 0 { s = TruncateByTokens(s, maxTokens) } @@ -108,8 +126,8 @@ func (a *Agent) buildToolCatalog() string { } // 仅注入插件/通道能力摘要,避免全量工具定义污染 system prompt。 // 每个插件列:名称 + 能力描述 + 工具数。完整工具定义由 get_plugin_tools 按需拉取。 - byPlugin := map[string]int{} // plugin -> 工具数 - pluginDesc := map[string]string{} // plugin -> 首个工具描述(作能力概览) + byPlugin := map[string]int{} // plugin -> 工具数 + pluginDesc := map[string]string{} // plugin -> 首个工具描述(作能力概览) var order []string for _, t := range defs { fn, ok := t.(map[string]interface{})["function"].(map[string]interface{}) @@ -302,7 +320,7 @@ func (a *Agent) buildToolDefs() []interface{} { "parameters": map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ - "name": map[string]interface{}{"type": "string", "description": "知识名称(用作目录名)"}, + "name": map[string]interface{}{"type": "string", "description": "知识名称(用作目录名)"}, "content": map[string]interface{}{"type": "string", "description": "知识内容,支持 Markdown"}, }, "required": []string{"name", "content"},