mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-21 17:38:10 +00:00
自动触发链实测(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。
383 lines
14 KiB
Go
383 lines
14 KiB
Go
package core
|
||
|
||
import (
|
||
"fmt"
|
||
"log"
|
||
"regexp"
|
||
"strconv"
|
||
"strings"
|
||
|
||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
|
||
)
|
||
|
||
// L3 图库的媒体引用绑定。
|
||
//
|
||
// 设计定位(方案 A:只做引用,不建媒体实体节点):
|
||
// 图库里的实体与关系全部来自**描述文本**的 NLP 提取——媒体描述经
|
||
// mediaSummaryForEvent 进了 L0 事件的 Input,随归档进 L2 文档的 Content,
|
||
// 蒸馏时提取器自然会从描述文字里抽出实体和关系。
|
||
//
|
||
// 为何不把媒体本身建成实体节点:节点名只能从描述里取,而描述会被重新生成
|
||
// (换个视觉模型、补一次描述,名字就变了),于是同一张图会在图谱上留下
|
||
// 多个语义模糊的节点。检索能力靠描述文本已经具备,多这类节点只是噪声。
|
||
//
|
||
// 那么图库侧还需要什么:**反查**。图库里的句子写着「[image a1b2c3d4e5f6]
|
||
// 一张紫蓝红三色带图」,要能从这条句子找回那份字节。这就是
|
||
// media_refs 的 graph_sentence owner 的用途,也是这一层唯一要做的事。
|
||
|
||
// mediaDigestPattern 匹配事件摘要里的媒体标记 [<mime或kind> <短digest>]。
|
||
//
|
||
// 与 mediaSummaryForEvent 的输出格式对应。短 digest 是 12 位十六进制
|
||
// (shortDigest 的截断长度),这里放宽到 8-64 位以容忍将来调整截断长度,
|
||
// 以及有人手写了完整 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
|
||
// 提取器从纯文本产出的(nlp.ToMemoryTriple 只填 Subject/Relation/Object/
|
||
// Confidence/SentenceText),提取链路上没有任何位置能塞进结构化的 digest。
|
||
// 若要贯通就得改 internal/nlp 的整条数据流——而媒体标记本身就是我们
|
||
// 自己按固定格式写进文本的,反解是这里最省的可靠做法。
|
||
func extractMediaDigests(text string) []string {
|
||
if text == "" {
|
||
return nil
|
||
}
|
||
matches := mediaDigestPattern.FindAllStringSubmatch(text, -1)
|
||
if len(matches) == 0 {
|
||
return nil
|
||
}
|
||
seen := make(map[string]bool, len(matches))
|
||
var out []string
|
||
for _, m := range matches {
|
||
d := m[1]
|
||
if seen[d] {
|
||
continue
|
||
}
|
||
seen[d] = true
|
||
out = append(out, d)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// bindSentenceMedia 把句子文本里提到的媒体挂到对应的 sentences.id 上。
|
||
//
|
||
// sentenceIDs 来自 GraphDB.CommitWithMedia:句子文本 → sentences.id。
|
||
// 只处理本次真正写入了 sentences 表的句子,避免给历史句子重复挂引用
|
||
// (AddRef 幂等,重复挂不会涨计数,但白跑 SQL)。
|
||
//
|
||
// 返回实际绑定成功的引用数,这是调用方的安全依据:归档路径靠它判定
|
||
// 「引用真的转移到图库了吗」,不能用「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 0
|
||
}
|
||
|
||
bound := 0
|
||
for text, sid := range sentenceIDs {
|
||
if sid == 0 {
|
||
continue
|
||
}
|
||
digests := extractMediaDigests(text)
|
||
if len(digests) == 0 {
|
||
continue
|
||
}
|
||
ownerID := strconv.FormatInt(sid, 10)
|
||
for _, short := range digests {
|
||
// 文本里是短 digest,media_refs 的主键要完整 digest。
|
||
// 补全失败(内容已被 GC 清掉、或前缀有歧义)就跳过——
|
||
// 挂一条对不上的引用比不挂更糟:DropOwner 永远匹配不到它。
|
||
full, err := a.mediaStore.ResolvePrefix(short)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
if err := a.mediaStore.AddRef(full, media.OwnerGraphSentence, ownerID); err != nil {
|
||
log.Printf("[media] 句子引用绑定失败 (%s → sentence %s): %v", short, ownerID, err)
|
||
continue
|
||
}
|
||
bound++
|
||
}
|
||
}
|
||
if bound > 0 {
|
||
log.Printf("[media] L3 图库绑定 %d 个媒体引用", bound)
|
||
}
|
||
return bound
|
||
}
|
||
|
||
// commitTriplesWithMedia 提交三元组并绑定句子里的媒体引用。
|
||
//
|
||
// 包一层是为了让所有「三元组入库」的调用点用同一条路径拿到媒体绑定,
|
||
// 而不必各自记得多调一次 bindSentenceMedia。
|
||
// 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, 0, fmt.Errorf("graph memory 未启用")
|
||
}
|
||
// 媒体存储关闭时退回普通 Commit,省掉 sentenceIDs 的 map 分配。
|
||
if a.mediaStore == nil {
|
||
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, 0, err
|
||
}
|
||
return ec, rc, a.bindSentenceMedia(sentenceIDs), nil
|
||
}
|
||
|
||
// RecallMediaForSentence 反查某条图库句子引用的媒体。
|
||
//
|
||
// 这是整层的目的:几个月后从图谱走到一条句子,要能取回当时那份字节
|
||
// (若尚未被容量 GC 淘汰)。返回的是完整 digest,调用方用
|
||
// mediaStore.Get 取内容、Stat 取描述与元数据。
|
||
func (a *Agent) RecallMediaForSentence(sentenceID int64) ([]string, error) {
|
||
if a.mediaStore == nil {
|
||
return nil, nil
|
||
}
|
||
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] 一张紫蓝红三色带图」。
|
||
// 描述文本本就在句子里,这里补的是「内容是否还在、能否重新看图」这个信息——
|
||
// 描述永存而字节可能已被淘汰,两者状态不同。
|
||
func (a *Agent) mediaContextForSentences(sentenceIDs []int64) string {
|
||
if a.mediaStore == nil || len(sentenceIDs) == 0 {
|
||
return ""
|
||
}
|
||
var lines []string
|
||
for _, sid := range sentenceIDs {
|
||
digests, err := a.mediaStore.Refs(media.OwnerGraphSentence, strconv.FormatInt(sid, 10))
|
||
if err != nil || len(digests) == 0 {
|
||
continue
|
||
}
|
||
var parts []string
|
||
for _, d := range digests {
|
||
it, err := a.mediaStore.Stat(d)
|
||
if err != nil || it == nil {
|
||
continue
|
||
}
|
||
label := string(it.Kind)
|
||
if it.MIME != "" {
|
||
label = it.MIME
|
||
}
|
||
desc := it.Description
|
||
if desc == "" {
|
||
desc = "(未描述)"
|
||
}
|
||
parts = append(parts, fmt.Sprintf("[%s %s] %s", label, shortDigest(d), desc))
|
||
}
|
||
if len(parts) > 0 {
|
||
lines = append(lines, fmt.Sprintf("句子 #%d 关联媒体:%s", sid, strings.Join(parts, ";")))
|
||
}
|
||
}
|
||
if len(lines) == 0 {
|
||
return ""
|
||
}
|
||
return strings.Join(lines, "\n")
|
||
}
|