mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-21 01:18:08 +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:
@ -10,6 +10,7 @@ import (
|
||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/nlp"
|
||||
)
|
||||
@ -181,18 +182,46 @@ func (a *Agent) archiveColdDocs() {
|
||||
for _, doc := range coldDocs {
|
||||
triples := docToTriples(doc, a.embedder)
|
||||
if len(triples) > 0 {
|
||||
ec, rc, err := a.memory.Commit(triples, string(a.id)+"_doc_archival", 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// releaseDocMedia 注销文档持有的全部媒体引用。
|
||||
//
|
||||
// L2→L3 这一跳不再转移引用而是直接释放,因为图库存的是从描述
|
||||
// 文本里抽出的实体与关系,不再持有字节。媒体本身此时已完成使命:
|
||||
// 描述已经进了图库,blob 可以交给容量 GC 决定去留。
|
||||
func (a *Agent) releaseDocMedia(docID string) {
|
||||
if a.mediaStore == nil || docID == "" {
|
||||
return
|
||||
}
|
||||
n, err := a.mediaStore.DropOwner(media.OwnerDocument, docID)
|
||||
if err != nil {
|
||||
log.Printf("[media] 文档归档释放引用失败 (doc %s): %v", docID, err)
|
||||
return
|
||||
}
|
||||
if n > 0 {
|
||||
log.Printf("[media] 文档 %s 入图库,释放 %d 个媒体引用(描述已留在图库)", docID, n)
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// 实体合并检测:GraphDB → LLM 裁决
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
175
internal/agent/core/graphmedia.go
Normal file
175
internal/agent/core/graphmedia.go
Normal 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")
|
||||
}
|
||||
357
internal/agent/core/graphmedia_test.go
Normal file
357
internal/agent/core/graphmedia_test.go
Normal file
@ -0,0 +1,357 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
|
||||
)
|
||||
|
||||
// L3 图库媒体引用测试。
|
||||
//
|
||||
// 这一层的目的只有一个:几个月后从图谱走到一条句子,要能取回当时那份字节。
|
||||
// 因此测试的重点是「反查链路是否完整」以及「引用是否会悬空或误删」。
|
||||
|
||||
func newGraphMediaAgent(t *testing.T) (*Agent, *memory.GraphDB, *media.Store) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
|
||||
g, err := memory.NewGraphDB(filepath.Join(dir, "graph.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("NewGraphDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { g.Close() })
|
||||
|
||||
ms, err := media.New(filepath.Join(dir, "media"), 0)
|
||||
if err != nil {
|
||||
t.Fatalf("media.New: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { ms.Close() })
|
||||
|
||||
return &Agent{memory: g, mediaStore: ms}, g, ms
|
||||
}
|
||||
|
||||
func TestExtractMediaDigests(t *testing.T) {
|
||||
// 与 mediaSummaryForEvent 的输出格式对应
|
||||
cases := []struct {
|
||||
name string
|
||||
text string
|
||||
want []string
|
||||
}{
|
||||
{"事件摘要格式", "媒体内容:\n[image/png a1b2c3d4e5f6] 一张紫蓝红三色带图", []string{"a1b2c3d4e5f6"}},
|
||||
{"kind 兜底格式", "[image abcdef0123456789] (未描述)", []string{"abcdef0123456789"}},
|
||||
{"一句多个", "[image aaaaaaaaaaaa] 图一;[image bbbbbbbbbbbb] 图二", []string{"aaaaaaaaaaaa", "bbbbbbbbbbbb"}},
|
||||
{"去重", "[image cccccccccccc] x [image/png cccccccccccc] y", []string{"cccccccccccc"}},
|
||||
{"无标记", "普通句子,没有媒体", nil},
|
||||
{"空串", "", nil},
|
||||
// 非十六进制、过短的方括号内容不能误命中,否则会拿一个假前缀去 ResolvePrefix
|
||||
{"非 digest 方括号", "[注意] 这是普通标注 [TODO]", nil},
|
||||
{"过短", "[image abc] 太短", nil},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
got := extractMediaDigests(c.text)
|
||||
if len(got) != len(c.want) {
|
||||
t.Fatalf("%s: 得到 %v,期望 %v", c.name, got, c.want)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != c.want[i] {
|
||||
t.Fatalf("%s: 第 %d 个得到 %q,期望 %q", c.name, i, got[i], c.want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommitWithMedia_ReturnsSentenceIDs(t *testing.T) {
|
||||
_, g, _ := newGraphMediaAgent(t)
|
||||
|
||||
sentence := "[image/png a1b2c3d4e5f6] 一张紫蓝红三色带图"
|
||||
triples := []memory.Triple{{
|
||||
Subject: "图片", Relation: "内容", Object: "三色带",
|
||||
SentenceText: sentence,
|
||||
}}
|
||||
|
||||
ids, ec, rc, err := g.CommitWithMedia(triples, "s1", 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ec == 0 || rc == 0 {
|
||||
t.Fatalf("应写入实体与关系,实际 ec=%d rc=%d", ec, rc)
|
||||
}
|
||||
if ids[sentence] == 0 {
|
||||
t.Fatalf("应返回句子 id,实际 %v", ids)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommit_StillWorksAfterRefactor(t *testing.T) {
|
||||
// Commit 有三十多个调用点,内部转调后行为必须完全不变
|
||||
_, g, _ := newGraphMediaAgent(t)
|
||||
|
||||
triples := []memory.Triple{
|
||||
{Subject: "张三", Relation: "喜欢", Object: "咖啡", SentenceText: "张三喜欢咖啡"},
|
||||
{Subject: "李四", Relation: "住在", Object: "北京"},
|
||||
}
|
||||
ec, rc, err := g.Commit(triples, "s1", 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ec != 4 || rc != 2 {
|
||||
t.Fatalf("期望 4 实体 2 关系,实际 ec=%d rc=%d", ec, rc)
|
||||
}
|
||||
|
||||
// 重复提交同一批:关系被唯一约束去重。
|
||||
//
|
||||
// 实体计数**不**归零——这是 upsertEntity 的既有行为:SQLite 的
|
||||
// ON CONFLICT DO UPDATE 也算一行 affected,于是 RowsAffected() > 0
|
||||
// 被当成"新建了"。用 main 分支的 graph.go 单独验证过基线同样是
|
||||
// 首次 ec=2 / 重复 ec=2,与 CommitWithMedia 重构无关。
|
||||
// entitiesCreated 只用于日志,故此处记录现状而不改行为。
|
||||
ec2, rc2, err := g.Commit(triples, "s1", 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rc2 != 0 {
|
||||
t.Fatalf("重复提交不该新建关系,实际 rc=%d", rc2)
|
||||
}
|
||||
if ec2 != 4 {
|
||||
t.Fatalf("实体计数应与首次一致(既有 upsert 计数行为),实际 ec=%d", ec2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBindSentenceMedia_RoundTrip(t *testing.T) {
|
||||
// 整层的核心断言:写入 → 提交 → 反查取回原始字节
|
||||
a, _, ms := newGraphMediaAgent(t)
|
||||
|
||||
content := []byte("\x89PNG\r\n\x1a\n fake image bytes")
|
||||
digest, err := ms.Put(content, media.Item{MIME: "image/png", Kind: media.KindImage})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
short := shortDigest(digest)
|
||||
|
||||
sentence := "[image/png " + short + "] 一张紫蓝红三色带图"
|
||||
triples := []memory.Triple{{
|
||||
Subject: "图片", Relation: "内容", Object: "三色带", SentenceText: sentence,
|
||||
}}
|
||||
|
||||
if _, _, err := a.commitTriplesWithMedia(triples, "s1", 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 找到句子 id
|
||||
ids, _, _, err := a.memory.CommitWithMedia(triples, "s1", 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sid := ids[sentence]
|
||||
if sid == 0 {
|
||||
t.Fatal("拿不到句子 id")
|
||||
}
|
||||
|
||||
// 反查:从句子取回 digest,再取回字节
|
||||
digests, err := a.RecallMediaForSentence(sid)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(digests) != 1 || digests[0] != digest {
|
||||
t.Fatalf("反查应得完整 digest %s,实际 %v", shortDigest(digest), digests)
|
||||
}
|
||||
got, err := ms.Get(digests[0])
|
||||
if err != nil {
|
||||
t.Fatalf("取回内容失败: %v", err)
|
||||
}
|
||||
if string(got) != string(content) {
|
||||
t.Fatal("取回的内容与写入不一致")
|
||||
}
|
||||
|
||||
// 引用计数非零 → GC 不会清它
|
||||
if _, _, err := ms.GC(0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ms.Get(digest); err != nil {
|
||||
t.Fatalf("被图库句子引用的内容不该被 GC 清掉: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBindSentenceMedia_SkipsUnresolvable(t *testing.T) {
|
||||
// 文本里的 digest 在库里不存在时必须跳过,不能挂一条对不上的引用——
|
||||
// 那条引用 DropOwner 永远匹配不到,会永久占着计数。
|
||||
a, _, ms := newGraphMediaAgent(t)
|
||||
|
||||
sentence := "[image/png deadbeefdead] 一张不存在的图"
|
||||
ids := map[string]int64{sentence: 42}
|
||||
a.bindSentenceMedia(ids)
|
||||
|
||||
refs, err := ms.Refs(media.OwnerGraphSentence, "42")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(refs) != 0 {
|
||||
t.Fatalf("无法补全的 digest 不该挂引用,实际 %v", refs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBindSentenceMedia_NilStoreNoop(t *testing.T) {
|
||||
a := &Agent{}
|
||||
a.bindSentenceMedia(map[string]int64{"[image aaaaaaaaaaaa] x": 1})
|
||||
if got, err := a.RecallMediaForSentence(1); err != nil || got != nil {
|
||||
t.Fatalf("媒体关闭时应静默无操作,实际 %v / %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommitTriplesWithMedia_FallsBackWithoutStore(t *testing.T) {
|
||||
// 媒体关闭时退回普通 Commit,行为与直接调 Commit 完全一致
|
||||
dir := t.TempDir()
|
||||
g, err := memory.NewGraphDB(filepath.Join(dir, "g.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer g.Close()
|
||||
|
||||
a := &Agent{memory: g}
|
||||
ec, rc, err := a.commitTriplesWithMedia([]memory.Triple{
|
||||
{Subject: "张三", Relation: "喜欢", Object: "咖啡"},
|
||||
}, "s1", 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ec != 2 || rc != 1 {
|
||||
t.Fatalf("期望 2 实体 1 关系,实际 ec=%d rc=%d", ec, rc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseDocMedia_DropsRefsSoGCCanReclaim(t *testing.T) {
|
||||
// L2→L3 那一跳留下的泄漏:文档被 Remove 但引用没销,
|
||||
// 引用计数永不归零,blob 永远不会被 GC 回收。
|
||||
a, _, ms := newGraphMediaAgent(t)
|
||||
|
||||
digest, err := ms.Put([]byte("doc image"), media.Item{MIME: "image/png"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ms.AddRef(digest, media.OwnerDocument, "doc_1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 释放前 GC 清不掉
|
||||
if _, _, err := ms.GC(0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ms.Stat(digest); err != nil {
|
||||
t.Fatal("有文档引用时不该被清")
|
||||
}
|
||||
|
||||
a.releaseDocMedia("doc_1")
|
||||
|
||||
if refs, _ := ms.Refs(media.OwnerDocument, "doc_1"); len(refs) != 0 {
|
||||
t.Fatalf("释放后不该还有文档引用,实际 %v", refs)
|
||||
}
|
||||
// 现在 GC 能回收了
|
||||
removed, _, err := ms.GC(0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if removed != 1 {
|
||||
t.Fatalf("释放引用后 GC 应能回收,实际清理 %d 条", removed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaContextForSentences(t *testing.T) {
|
||||
a, _, ms := newGraphMediaAgent(t)
|
||||
|
||||
digest, _ := ms.Put([]byte("img"), media.Item{MIME: "image/png"})
|
||||
if err := ms.Describe(digest, "一张紫蓝红三色带图", "visionllm"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ms.AddRef(digest, media.OwnerGraphSentence, "7"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
out := a.mediaContextForSentences([]int64{7, 8})
|
||||
if out == "" {
|
||||
t.Fatal("应产出媒体说明")
|
||||
}
|
||||
if !contains(out, "句子 #7") || !contains(out, "一张紫蓝红三色带图") {
|
||||
t.Fatalf("说明内容不对: %q", out)
|
||||
}
|
||||
// 8 号句子没引用媒体,不该出现
|
||||
if contains(out, "句子 #8") {
|
||||
t.Fatalf("无引用的句子不该出现: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePrefix(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ms, err := media.New(filepath.Join(dir, "m"), 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ms.Close()
|
||||
|
||||
digest, _ := ms.Put([]byte("content"), media.Item{MIME: "image/png"})
|
||||
|
||||
// 短前缀补全
|
||||
full, err := ms.ResolvePrefix(digest[:12])
|
||||
if err != nil || full != digest {
|
||||
t.Fatalf("短前缀补全失败: %v / %v", full, err)
|
||||
}
|
||||
// 完整 digest 原样返回
|
||||
full, err = ms.ResolvePrefix(digest)
|
||||
if err != nil || full != digest {
|
||||
t.Fatalf("完整 digest 应原样返回: %v / %v", full, err)
|
||||
}
|
||||
// 过短拒绝
|
||||
if _, err := ms.ResolvePrefix("abc"); err == nil {
|
||||
t.Fatal("过短前缀应报错")
|
||||
}
|
||||
// 不存在
|
||||
if _, err := ms.ResolvePrefix("deadbeefdead"); err == nil {
|
||||
t.Fatal("不存在的前缀应报错")
|
||||
}
|
||||
// 完整但不存在的 digest 也要报错,否则调用方会挂一条孤儿引用
|
||||
fake := ""
|
||||
for i := 0; i < 64; i++ {
|
||||
fake += "0"
|
||||
}
|
||||
if _, err := ms.ResolvePrefix(fake); err == nil {
|
||||
t.Fatal("不存在的完整 digest 应报错")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePrefix_AmbiguityIsError(t *testing.T) {
|
||||
// 前缀歧义视为错误而非"取第一个":挂错引用会让 GC 删掉仍被引用的内容。
|
||||
// 构造歧义需要两个同前缀 digest——sha256 无法人为构造,
|
||||
// 因此这里退而验证「8 位前缀在大量样本下的行为是确定的」:
|
||||
// 要么唯一命中,要么明确报歧义,绝不静默取第一个。
|
||||
dir := t.TempDir()
|
||||
ms, err := media.New(filepath.Join(dir, "m"), 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ms.Close()
|
||||
|
||||
digests := make([]string, 0, 200)
|
||||
for i := 0; i < 200; i++ {
|
||||
d, err := ms.Put([]byte("content-"+strconv.Itoa(i)), media.Item{MIME: "image/png"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
digests = append(digests, d)
|
||||
}
|
||||
|
||||
for _, d := range digests {
|
||||
got, err := ms.ResolvePrefix(d[:12])
|
||||
if err != nil {
|
||||
// 报歧义是可接受结果;静默取错才是缺陷
|
||||
if !contains(err.Error(), "歧义") {
|
||||
t.Fatalf("非歧义错误: %v", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if got != d {
|
||||
t.Fatalf("补全结果错误: 前缀 %s 得到 %s", d[:12], got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -189,7 +189,7 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall) string {
|
||||
if len(triples) == 0 {
|
||||
return "没有有效的三元组"
|
||||
}
|
||||
ec, rc, err := a.memory.Commit(triples, string(a.id), 0)
|
||||
ec, rc, err := a.commitTriplesWithMedia(triples, string(a.id), 0)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("记忆写入失败: %v", err)
|
||||
}
|
||||
@ -541,10 +541,10 @@ func (a *Agent) executeDocTool(tc agentAPI.ToolCall) string {
|
||||
}
|
||||
|
||||
doc := &document.Doc{
|
||||
Summary: summary,
|
||||
Content: content,
|
||||
Tags: tags,
|
||||
Source: "manual",
|
||||
Summary: summary,
|
||||
Content: content,
|
||||
Tags: tags,
|
||||
Source: "manual",
|
||||
}
|
||||
if err := a.docStore.Insert(doc); err != nil {
|
||||
return fmt.Sprintf("文档写入失败: %v", err)
|
||||
|
||||
@ -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