mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-25 03:18:08 +00:00
媒体记忆四层收尾。方案 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 的引用泄漏 这是上一层(f855893)留下的缺口:我当时只处理了 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。
176 lines
6.3 KiB
Go
176 lines
6.3 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})\]`)
|
||
|
||
// 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")
|
||
}
|