mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-21 09:28:14 +00:00
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 的引用泄漏 这是上一层(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。
This commit is contained in:
@ -37,13 +37,13 @@ type Relation struct {
|
||||
}
|
||||
|
||||
type Triple struct {
|
||||
Subject string `json:"subject"`
|
||||
Relation string `json:"relation"`
|
||||
Object string `json:"object"`
|
||||
Confidence float64 `json:"confidence,omitempty"`
|
||||
SubjectType string `json:"subject_type,omitempty"`
|
||||
ObjectType string `json:"object_type,omitempty"`
|
||||
SentenceText string `json:"sentence_text,omitempty"` // 原始句子文本,Commit时写入sentences表
|
||||
Subject string `json:"subject"`
|
||||
Relation string `json:"relation"`
|
||||
Object string `json:"object"`
|
||||
Confidence float64 `json:"confidence,omitempty"`
|
||||
SubjectType string `json:"subject_type,omitempty"`
|
||||
ObjectType string `json:"object_type,omitempty"`
|
||||
SentenceText string `json:"sentence_text,omitempty"` // 原始句子文本,Commit时写入sentences表
|
||||
}
|
||||
|
||||
type GraphDB struct {
|
||||
@ -192,13 +192,37 @@ func (g *GraphDB) migrateRelationUnique(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Commit 把三元组写入图库,返回新建的实体数与关系数。
|
||||
func (g *GraphDB) Commit(triples []Triple, sessionID string, turnID int) (int, int, error) {
|
||||
_, ec, rc, err := g.commit(triples, sessionID, turnID, false)
|
||||
return ec, rc, err
|
||||
}
|
||||
|
||||
// CommitWithMedia 与 Commit 相同,但额外返回每条句子文本对应的 sentences.id。
|
||||
//
|
||||
// 为何单独开一个方法而不改 Commit 的签名:Commit 有十个非测试调用点
|
||||
// 加二十多个测试调用点,为了一个多数调用方都不需要的返回值去改全部签名
|
||||
// 不划算。这里让 Commit 内部转调,两者共享同一份落库逻辑。
|
||||
//
|
||||
// 返回的 map 只包含本次真正写入了 sentences 表的句子。调用方据此把媒体
|
||||
// 引用挂到 graph_sentence owner 上——句子是媒体描述在图库里的落点,
|
||||
// 关系行本身不持有媒体。
|
||||
func (g *GraphDB) CommitWithMedia(triples []Triple, sessionID string, turnID int) (map[string]int64, int, int, error) {
|
||||
return g.commit(triples, sessionID, turnID, true)
|
||||
}
|
||||
|
||||
func (g *GraphDB) commit(triples []Triple, sessionID string, turnID int, trackSentences bool) (map[string]int64, int, int, error) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
var sentenceIDs map[string]int64
|
||||
if trackSentences {
|
||||
sentenceIDs = make(map[string]int64)
|
||||
}
|
||||
|
||||
tx, err := g.db.Begin()
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
return nil, 0, 0, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
@ -229,24 +253,24 @@ func (g *GraphDB) Commit(triples []Triple, sessionID string, turnID int) (int, i
|
||||
|
||||
ec, err := g.upsertEntity(tx, t.Subject, subjType)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
return nil, 0, 0, err
|
||||
}
|
||||
entitiesCreated += ec
|
||||
|
||||
ec, err = g.upsertEntity(tx, t.Object, objType)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
return nil, 0, 0, err
|
||||
}
|
||||
entitiesCreated += ec
|
||||
|
||||
var sourceID, targetID int64
|
||||
err = tx.QueryRow("SELECT id FROM entities WHERE name = ?", t.Subject).Scan(&sourceID)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("subject %q: %w", t.Subject, err)
|
||||
return nil, 0, 0, fmt.Errorf("subject %q: %w", t.Subject, err)
|
||||
}
|
||||
err = tx.QueryRow("SELECT id FROM entities WHERE name = ?", t.Object).Scan(&targetID)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("object %q: %w", t.Object, err)
|
||||
return nil, 0, 0, fmt.Errorf("object %q: %w", t.Object, err)
|
||||
}
|
||||
|
||||
// 写入/查找句子
|
||||
@ -255,11 +279,13 @@ func (g *GraphDB) Commit(triples []Triple, sessionID string, turnID int) (int, i
|
||||
_, err = tx.Exec(
|
||||
`INSERT OR IGNORE INTO sentences (text) VALUES (?)`, t.SentenceText)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("insert sentence: %w", err)
|
||||
return nil, 0, 0, fmt.Errorf("insert sentence: %w", err)
|
||||
}
|
||||
err = tx.QueryRow("SELECT id FROM sentences WHERE text = ?", t.SentenceText).Scan(&sentenceID)
|
||||
if err != nil {
|
||||
sentenceID = 0
|
||||
} else if sentenceIDs != nil {
|
||||
sentenceIDs[t.SentenceText] = sentenceID
|
||||
}
|
||||
}
|
||||
|
||||
@ -275,11 +301,11 @@ func (g *GraphDB) Commit(triples []Triple, sessionID string, turnID int) (int, i
|
||||
sourceID, targetID, t.Relation, confidence, sessionID, turnID, dateBucket, sentenceID,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
return nil, 0, 0, err
|
||||
}
|
||||
relationsCreated++
|
||||
} else if err != nil {
|
||||
return 0, 0, err
|
||||
return nil, 0, 0, err
|
||||
} else {
|
||||
// 同一(会话内)三元组已存在:仅刷新置信度与时间戳,不重复计数
|
||||
_, err = tx.Exec(
|
||||
@ -288,16 +314,16 @@ func (g *GraphDB) Commit(triples []Triple, sessionID string, turnID int) (int, i
|
||||
confidence, sourceID, targetID, t.Relation, sessionID,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
return nil, 0, 0, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return 0, 0, err
|
||||
return nil, 0, 0, err
|
||||
}
|
||||
|
||||
return entitiesCreated, relationsCreated, nil
|
||||
return sentenceIDs, entitiesCreated, relationsCreated, nil
|
||||
}
|
||||
|
||||
func validEntityName(name string) bool {
|
||||
|
||||
@ -46,6 +46,9 @@ const (
|
||||
OwnerGraphSentence = "graph_sentence"
|
||||
)
|
||||
|
||||
// digestHexLen 是 sha256 的十六进制串长度。
|
||||
const digestHexLen = sha256.Size * 2
|
||||
|
||||
// Kind 是媒体大类。刻意只分三类而不细分具体格式:
|
||||
// 记忆检索关心的是“这是张图还是段音频”,具体编码交给 MIME 字段。
|
||||
type Kind string
|
||||
@ -722,3 +725,56 @@ func truncate(s string, n int) string {
|
||||
}
|
||||
return s[:n] + "..."
|
||||
}
|
||||
|
||||
// ResolvePrefix 把 digest 前缀补全为完整 digest。
|
||||
//
|
||||
// 日志、事件摘要与图库句子里出现的都是 shortDigest(前 12 位),
|
||||
// 因为完整的 64 位 sha256 会把一行文字撑爆、也无助于人眼辨认。
|
||||
// 反查时需要这个补全,否则那些短标记只能看不能用。
|
||||
//
|
||||
// 前缀歧义视为错误而非"取第一个":挂错引用会让 GC 删掉仍被引用的内容,
|
||||
// 宁可这次绑定失败。12 位十六进制的碰撞概率极低,真撞上说明该用更长前缀。
|
||||
func (s *Store) ResolvePrefix(prefix string) (string, error) {
|
||||
prefix = strings.ToLower(strings.TrimSpace(prefix))
|
||||
if len(prefix) < 8 {
|
||||
return "", fmt.Errorf("digest 前缀过短(至少 8 位): %q", prefix)
|
||||
}
|
||||
if len(prefix) == digestHexLen {
|
||||
// 已是完整 digest:仍要确认存在,否则调用方会挂一条孤儿引用
|
||||
if _, err := s.Stat(prefix); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return prefix, nil
|
||||
}
|
||||
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
rows, err := s.db.Query(
|
||||
`SELECT digest FROM media WHERE digest LIKE ? || '%' LIMIT 2`, prefix)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var found []string
|
||||
for rows.Next() {
|
||||
var d string
|
||||
if err := rows.Scan(&d); err != nil {
|
||||
return "", err
|
||||
}
|
||||
found = append(found, d)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
switch len(found) {
|
||||
case 0:
|
||||
return "", fmt.Errorf("digest 前缀 %q 未匹配到媒体", prefix)
|
||||
case 1:
|
||||
return found[0], nil
|
||||
default:
|
||||
return "", fmt.Errorf("digest 前缀 %q 有歧义(至少匹配 %s 和 %s)",
|
||||
prefix, found[0][:16], found[1][:16])
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user