feat(memory): L3 图库媒体反查 + 修 L2→L3 引用泄漏

媒体记忆四层收尾。方案 A:只做引用,不建媒体实体节点。

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

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

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

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

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

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

## digest 靠正则从文本反解

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

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

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

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

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

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

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

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

## 测试

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

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

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

全仓 go build / go vet / go test 通过,internal/agent/core 与
internal/memory 全部 -race -count=2 通过,SDK 冻结 diff = 0。
This commit is contained in:
JianFeeeee
2026-09-04 22:52:30 +08:00
parent 7ac40811e7
commit 2533ccd8e0
6 changed files with 667 additions and 24 deletions

View File

@ -0,0 +1,175 @@
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})\]`)
// 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)。
func (a *Agent) bindSentenceMedia(sentenceIDs map[string]int64) {
if a.mediaStore == nil || len(sentenceIDs) == 0 {
return
}
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)
}
}
// commitTriplesWithMedia 提交三元组并绑定句子里的媒体引用。
//
// 包一层是为了让所有「三元组入库」的调用点用同一条路径拿到媒体绑定,
// 而不必各自记得多调一次 bindSentenceMedia。
func (a *Agent) commitTriplesWithMedia(triples []memory.Triple, sessionID string, turnID int) (int, int, error) {
if a.memory == nil {
return 0, 0, fmt.Errorf("graph memory 未启用")
}
// 媒体存储关闭时退回普通 Commit,省掉 sentenceIDs 的 map 分配。
if a.mediaStore == nil {
return a.memory.Commit(triples, sessionID, turnID)
}
sentenceIDs, ec, rc, err := a.memory.CommitWithMedia(triples, sessionID, turnID)
if err != nil {
return ec, rc, err
}
a.bindSentenceMedia(sentenceIDs)
return ec, rc, 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))
}
// 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")
}