mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-22 09:58:06 +00:00
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:
@ -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 {
|
||||
|
||||
@ -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] 一张紫蓝红三色带图」。
|
||||
|
||||
@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
570
internal/agent/core/medialive_test.go
Normal file
570
internal/agent/core/medialive_test.go
Normal file
@ -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]) + "…"
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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"},
|
||||
|
||||
Reference in New Issue
Block a user