mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-04 00:03:59 +00:00
refactor(memory): 移除 media_refs/引用计数,媒体成为一等记忆块
媒体此前是"文本块 + digest 引用 + owner 账本 + 独立 GC":ContextEvent.Media
记 digest,media_refs 表用 owner_kind/owner_id 保活,ref_count 决定 GC 能否清。
这与文本记忆块的管理方式不一致,也是本次一并纠正的核心偏差。
改为与文本块完全一致的生命周期:
1. 一等记忆块直接由所在层持有
- ContextEvent.Blocks / Doc.Blocks / GraphDB memory_blocks
- 块带 modality/digest/MIME/size/vector/fingerprint,文本、图片、视频同构
- Context→Document→Graph 迁移的是块本身(ID 不变),迁移后清空源容器,
同一块不同时存在于两层
2. 删除平行生命周期账本
- media.Store 去掉 media_refs 表、OwnerKind 常量、RefCount 字段、
AddRef/DropRef/DropOwner/Refs、ref_count 列与索引
- 删除 mediaGCLoop、GC(keep,minAge)、容量上限与 media.gc_* / media.max_mb 配置
- 媒体内容在块被永久删除时一并删除(media.Store.Delete + forgetPayloads),
与"删除文本块即删除内容"同一语义
3. L3 原生结构
- memory_blocks / memory_block_edges(contains/depicts/derived_from)
- 边端点必须是真实图节点,不再用 owner 字符串伪装关系
- BlocksForNode 支持 sentence --contains--> block 反查
4. SDK 与检索同步
- 插件附件/标记直接变成块,不再 AddRef
- 跨模态检索改用 QueryMediaScored(CAS 内不再有孤儿缓存需要过滤)
测试全部改写为块语义:删除 refcount/media_refs/GC 断言,新增块迁移、
单层不变量、Delete 语义与并发删除回归。
注:cmd/homed/main.go 同时携带工作区中既有的 CLIP→Qwen 模型目录接线改动。
This commit is contained in:
@ -1,6 +1,7 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
@ -14,8 +15,48 @@ import (
|
||||
|
||||
// L3 图库媒体引用测试。
|
||||
//
|
||||
// 这一层的目的只有一个:几个月后从图谱走到一条句子,要能取回当时那份字节。
|
||||
// 因此测试的重点是「反查链路是否完整」以及「引用是否会悬空或误删」。
|
||||
// 这一层的目的只有一个:几个月后从图谱走到一条句子,要能取回当时那份媒体。
|
||||
// 媒体不再靠 media_refs 挂载,而是作为一等块进入 L3,并以
|
||||
// sentence --contains--> block 的结构边与句子相连。
|
||||
|
||||
// attachBlockToSentence 提交一条句子,把媒体变成 L3 一等块,并以
|
||||
// sentence --contains--> block 相连,返回句子 id 与块。
|
||||
// 必须走真实提交:边要求两端都是真实图节点。
|
||||
func attachBlockToSentence(t *testing.T, g *memory.GraphDB, ms *media.Store, sentenceText, digest string) (int64, memory.MemoryBlock) {
|
||||
t.Helper()
|
||||
ids, _, _, err := g.CommitWithMedia([]memory.Triple{{
|
||||
Subject: "媒体载体", Relation: "包含", Object: "内容", SentenceText: sentenceText,
|
||||
}}, "test", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("CommitWithMedia: %v", err)
|
||||
}
|
||||
sid := ids[sentenceText]
|
||||
if sid == 0 {
|
||||
t.Fatalf("拿不到句子 id: %q", sentenceText)
|
||||
}
|
||||
it, err := ms.Stat(digest)
|
||||
if err != nil || it == nil {
|
||||
t.Fatalf("Stat(%s): %v", shortDigest(digest), err)
|
||||
}
|
||||
b := memory.MemoryBlock{
|
||||
ID: fmt.Sprintf("blk_test_%d_%s", sid, shortDigest(digest)),
|
||||
Modality: memory.BlockImage,
|
||||
PayloadDigest: it.Digest,
|
||||
MIME: it.MIME,
|
||||
Size: it.Size,
|
||||
Width: it.Width,
|
||||
Height: it.Height,
|
||||
Vector: it.Vec,
|
||||
Fingerprint: it.VecModel,
|
||||
}
|
||||
if err := g.PutMemoryBlocks([]memory.MemoryBlock{b}); err != nil {
|
||||
t.Fatalf("PutMemoryBlocks: %v", err)
|
||||
}
|
||||
if err := g.AddMemoryBlockEdge("sentence", strconv.FormatInt(sid, 10), "block", b.ID, "contains"); err != nil {
|
||||
t.Fatalf("AddMemoryBlockEdge: %v", err)
|
||||
}
|
||||
return sid, b
|
||||
}
|
||||
|
||||
func newGraphMediaAgent(t *testing.T) (*Agent, *memory.GraphDB, *media.Store) {
|
||||
t.Helper()
|
||||
@ -27,7 +68,7 @@ func newGraphMediaAgent(t *testing.T) (*Agent, *memory.GraphDB, *media.Store) {
|
||||
}
|
||||
t.Cleanup(func() { g.Close() })
|
||||
|
||||
ms, err := media.New(filepath.Join(dir, "media"), 0)
|
||||
ms, err := media.New(filepath.Join(dir, "media"))
|
||||
if err != nil {
|
||||
t.Fatalf("media.New: %v", err)
|
||||
}
|
||||
@ -139,7 +180,7 @@ func TestBindSentenceMedia_RoundTrip(t *testing.T) {
|
||||
Subject: "图片", Relation: "内容", Object: "三色带", SentenceText: sentence,
|
||||
}}
|
||||
|
||||
if _, _, _, err := a.commitTriplesWithMedia(triples, "s1", 0); err != nil {
|
||||
if _, _, _, err := a.commitTriplesWithMedia(triples, "s1", 0, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@ -153,15 +194,15 @@ func TestBindSentenceMedia_RoundTrip(t *testing.T) {
|
||||
t.Fatal("拿不到句子 id")
|
||||
}
|
||||
|
||||
// 反查:从句子取回 digest,再取回字节
|
||||
digests, err := a.RecallMediaForSentence(sid)
|
||||
// 反查:从句子取回一等块,再取回字节
|
||||
blocks, err := a.RecallBlocksForSentence(sid)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(digests) != 1 || digests[0] != digest {
|
||||
t.Fatalf("反查应得完整 digest %s,实际 %v", shortDigest(digest), digests)
|
||||
if len(blocks) != 1 || blocks[0].PayloadDigest != digest {
|
||||
t.Fatalf("反查应得完整 digest %s,实际 %+v", shortDigest(digest), blocks)
|
||||
}
|
||||
got, err := ms.Get(digests[0])
|
||||
got, err := ms.Get(blocks[0].PayloadDigest)
|
||||
if err != nil {
|
||||
t.Fatalf("取回内容失败: %v", err)
|
||||
}
|
||||
@ -169,37 +210,33 @@ func TestBindSentenceMedia_RoundTrip(t *testing.T) {
|
||||
t.Fatal("取回的内容与写入不一致")
|
||||
}
|
||||
|
||||
// 引用计数非零 → GC 不会清它
|
||||
if _, _, err := ms.GC(0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 块仍被 L3 持有 → 内容应仍可读
|
||||
if _, err := ms.Get(digest); err != nil {
|
||||
t.Fatalf("被图库句子引用的内容不该被 GC 清掉: %v", err)
|
||||
t.Fatalf("被 L3 记忆块持有的内容不该被清除: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBindSentenceMedia_SkipsUnresolvable(t *testing.T) {
|
||||
// 文本里的 digest 在库里不存在时必须跳过,不能挂一条对不上的引用——
|
||||
// 那条引用 DropOwner 永远匹配不到,会永久占着计数。
|
||||
a, _, ms := newGraphMediaAgent(t)
|
||||
// 文本里的 digest 在库里不存在时必须跳过,不能建一条指向虚无的块边。
|
||||
a, g, _ := newGraphMediaAgent(t)
|
||||
|
||||
sentence := "[image/png deadbeefdead] 一张不存在的图"
|
||||
ids := map[string]int64{sentence: 42}
|
||||
a.bindSentenceMedia(ids)
|
||||
a.bindSentenceBlocks(ids, nil)
|
||||
|
||||
refs, err := ms.Refs(media.OwnerGraphSentence, "42")
|
||||
blocks, err := g.BlocksForNode("sentence", "42")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(refs) != 0 {
|
||||
t.Fatalf("无法补全的 digest 不该挂引用,实际 %v", refs)
|
||||
if len(blocks) != 0 {
|
||||
t.Fatalf("无法补全的 digest 不该建块,实际 %+v", blocks)
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
a.bindSentenceBlocks(map[string]int64{"[image aaaaaaaaaaaa] x": 1}, nil)
|
||||
if got, err := a.RecallBlocksForSentence(1); err != nil || got != nil {
|
||||
t.Fatalf("媒体关闭时应静默无操作,实际 %v / %v", got, err)
|
||||
}
|
||||
}
|
||||
@ -216,7 +253,7 @@ func TestCommitTriplesWithMedia_FallsBackWithoutStore(t *testing.T) {
|
||||
a := &Agent{memory: g}
|
||||
ec, rc, _, err := a.commitTriplesWithMedia([]memory.Triple{
|
||||
{Subject: "张三", Relation: "喜欢", Object: "咖啡"},
|
||||
}, "s1", 0)
|
||||
}, "s1", 0, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@ -225,69 +262,76 @@ func TestCommitTriplesWithMedia_FallsBackWithoutStore(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseDocMedia_DropsRefsSoGCCanReclaim(t *testing.T) {
|
||||
// L2→L3 那一跳留下的泄漏:文档被 Remove 但引用没销,
|
||||
// 引用计数永不归零,blob 永远不会被 GC 回收。
|
||||
a, _, ms := newGraphMediaAgent(t)
|
||||
func TestMediaBlocksHeldByDocumentSurviveGC(t *testing.T) {
|
||||
// 文档持有的一等块把内容钉住;文档被删后块随之消失,内容才可回收。
|
||||
a, g, ms := newGraphMediaAgent(t)
|
||||
_ = a
|
||||
|
||||
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 {
|
||||
dir := t.TempDir()
|
||||
ds := document.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords)
|
||||
if err := ds.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ds.Stop()
|
||||
|
||||
// 释放前 GC 清不掉
|
||||
if _, _, err := ms.GC(0); err != nil {
|
||||
it, _ := ms.Stat(digest)
|
||||
doc := &document.Doc{
|
||||
ID: "doc_1", Summary: "带图的文档", Content: "正文",
|
||||
Blocks: []memory.MemoryBlock{{ID: "blk_doc_1", Modality: memory.BlockImage,
|
||||
PayloadDigest: it.Digest, MIME: it.MIME, Size: it.Size}},
|
||||
}
|
||||
if err := ds.Insert(doc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = g
|
||||
|
||||
// 文档仍持有块 → 内容在
|
||||
if _, err := ms.Stat(digest); err != nil {
|
||||
t.Fatal("有文档引用时不该被清")
|
||||
t.Fatal("有文档块持有内容时不该被清")
|
||||
}
|
||||
|
||||
a.releaseDocMedia("doc_1")
|
||||
|
||||
if refs, _ := ms.Refs(media.OwnerDocument, "doc_1"); len(refs) != 0 {
|
||||
t.Fatalf("释放后不该还有文档引用,实际 %v", refs)
|
||||
// 删除文档 → 一并删除其内容(与文本块一致:删块即删内容)
|
||||
ds.Remove(doc.ID)
|
||||
if blocks := ds.Blocks(); len(blocks) != 0 {
|
||||
t.Fatalf("删除文档后不该还有块,实际 %+v", blocks)
|
||||
}
|
||||
// 现在 GC 能回收了
|
||||
removed, _, err := ms.GC(0)
|
||||
if err != nil {
|
||||
if err := ms.Delete(digest); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if removed != 1 {
|
||||
t.Fatalf("释放引用后 GC 应能回收,实际清理 %d 条", removed)
|
||||
if _, err := ms.Stat(digest); err == nil {
|
||||
t.Fatal("删除后内容应已移除")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaContextForSentences(t *testing.T) {
|
||||
a, _, ms := newGraphMediaAgent(t)
|
||||
a, g, 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)
|
||||
}
|
||||
sid, _ := attachBlockToSentence(t, g, ms, "[image/png "+shortDigest(digest)+"] 一张紫蓝红三色带图", digest)
|
||||
|
||||
out := a.mediaContextForSentences([]int64{7, 8})
|
||||
out := a.mediaContextForSentences([]int64{sid, sid + 100})
|
||||
if out == "" {
|
||||
t.Fatal("应产出媒体说明")
|
||||
}
|
||||
if !contains(out, "句子 #7") || !contains(out, "一张紫蓝红三色带图") {
|
||||
if !contains(out, fmt.Sprintf("句子 #%d", sid)) || !contains(out, "一张紫蓝红三色带图") {
|
||||
t.Fatalf("说明内容不对: %q", out)
|
||||
}
|
||||
// 8 号句子没引用媒体,不该出现
|
||||
if contains(out, "句子 #8") {
|
||||
// 无引用的句子不该出现
|
||||
if contains(out, fmt.Sprintf("句子 #%d", sid+100)) {
|
||||
t.Fatalf("无引用的句子不该出现: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePrefix(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ms, err := media.New(filepath.Join(dir, "m"), 0)
|
||||
ms, err := media.New(filepath.Join(dir, "m"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@ -329,7 +373,7 @@ func TestResolvePrefix_AmbiguityIsError(t *testing.T) {
|
||||
// 因此这里退而验证「8 位前缀在大量样本下的行为是确定的」:
|
||||
// 要么唯一命中,要么明确报歧义,绝不静默取第一个。
|
||||
dir := t.TempDir()
|
||||
ms, err := media.New(filepath.Join(dir, "m"), 0)
|
||||
ms, err := media.New(filepath.Join(dir, "m"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@ -369,7 +413,7 @@ func TestArchiveColdDocs_KeepsDocWhenGraphWriteEmpty(t *testing.T) {
|
||||
a, _, ms := newGraphMediaAgent(t)
|
||||
|
||||
dir := t.TempDir()
|
||||
ds := document.NewStore(filepath.Join(dir, "docs"))
|
||||
ds := document.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords)
|
||||
if err := ds.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@ -401,6 +445,8 @@ func TestArchiveColdDocs_KeepsDocWhenGraphWriteEmpty(t *testing.T) {
|
||||
// {文档 -主题-> summary},那条能通过校验,ec/rc 就不为 0 了。
|
||||
// 这里要的是「三元组全部被拒」这一个状态。
|
||||
longSummary := strings.Repeat("超长摘要文本", 20) // >80 字,触发长度门槛被跳过
|
||||
// 文档持有的一等块(模拟“文档有媒体但正文标记已在清洗中丢失”)。
|
||||
it, _ := ms.Stat(digest)
|
||||
doc := &document.Doc{
|
||||
ID: "doc_keep",
|
||||
Summary: longSummary,
|
||||
@ -409,6 +455,8 @@ func TestArchiveColdDocs_KeepsDocWhenGraphWriteEmpty(t *testing.T) {
|
||||
CreatedAt: time.Now().Add(-200 * time.Hour),
|
||||
LastAccess: time.Now().Add(-200 * time.Hour),
|
||||
AccessCount: 0,
|
||||
Blocks: []memory.MemoryBlock{{ID: "blk_keep_1", Modality: memory.BlockImage,
|
||||
PayloadDigest: it.Digest, MIME: it.MIME, Size: it.Size}},
|
||||
}
|
||||
if err := ds.Insert(doc); err != nil {
|
||||
t.Fatal(err)
|
||||
@ -422,27 +470,23 @@ func TestArchiveColdDocs_KeepsDocWhenGraphWriteEmpty(t *testing.T) {
|
||||
d.AccessCount = 0
|
||||
}
|
||||
}
|
||||
if err := ms.AddRef(digest, media.OwnerDocument, doc.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
a.archiveColdDocs()
|
||||
|
||||
// 关键断言三连:内容在、引用在、文档在
|
||||
// 关键断言:内容在、块在、文档在
|
||||
if _, err := ms.Get(digest); err != nil {
|
||||
t.Fatalf("图库未写入任何实体/关系,内容却丢了: %v", err)
|
||||
}
|
||||
refs, err := ms.Refs(media.OwnerDocument, doc.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
held := false
|
||||
for _, d := range ds.RecentDocs(10) {
|
||||
if d.ID == doc.ID && len(d.Blocks) > 0 {
|
||||
held = true
|
||||
}
|
||||
}
|
||||
if len(refs) == 0 {
|
||||
t.Error("引用被释放了——图库没有句子承载它,释放后 GC 会删掉内容")
|
||||
if !held {
|
||||
t.Error("文档或块被释放了——图库没有句子承载它,内容会被删除")
|
||||
}
|
||||
if removed, _, err := ms.GC(0); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if _, err := ms.Stat(digest); err != nil {
|
||||
t.Fatalf("GC(清 %d 条) 删掉了本该保留的内容", removed)
|
||||
if _, err := ms.Stat(digest); err != nil {
|
||||
t.Fatalf("未归档成功时内容不该被删: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@ -460,7 +504,7 @@ func TestCommitTriplesWithMedia_ReportsBoundCount(t *testing.T) {
|
||||
_, _, bound, err := a.commitTriplesWithMedia([]memory.Triple{{
|
||||
Subject: "图片", Relation: "内容", Object: "三色带",
|
||||
SentenceText: "[image/png " + short + "] 一张三色带图",
|
||||
}}, "s1", 0)
|
||||
}}, "s1", 0, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@ -472,7 +516,7 @@ func TestCommitTriplesWithMedia_ReportsBoundCount(t *testing.T) {
|
||||
_, _, bound2, err := a.commitTriplesWithMedia([]memory.Triple{{
|
||||
Subject: "张三", Relation: "喜欢", Object: "咖啡",
|
||||
SentenceText: "张三喜欢咖啡",
|
||||
}}, "s2", 0)
|
||||
}}, "s2", 0, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@ -509,7 +553,7 @@ func TestMediaContextForRelations_SurfacesMediaToAgent(t *testing.T) {
|
||||
// 第四层做完了"存和反查的能力"(RecallMediaForSentence /
|
||||
// mediaContextForSentences),但那两个函数一度没有任何调用方——
|
||||
// 媒体能进 L3,进去之后 agent 检索不到。这个测试守住那条接线。
|
||||
a, _, ms := newGraphMediaAgent(t)
|
||||
a, g, ms := newGraphMediaAgent(t)
|
||||
|
||||
digest, err := ms.Put([]byte("img bytes"), media.Item{MIME: "image/png"})
|
||||
if err != nil {
|
||||
@ -518,12 +562,10 @@ func TestMediaContextForRelations_SurfacesMediaToAgent(t *testing.T) {
|
||||
if err := ms.Describe(digest, "一张紫蓝红三色带图", "visionllm"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ms.AddRef(digest, media.OwnerGraphSentence, "5"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sid, _ := attachBlockToSentence(t, g, ms, "[image/png "+shortDigest(digest)+"] 一张紫蓝红三色带图", digest)
|
||||
|
||||
// 命中的关系挂着 5 号句子 → 应产出媒体说明
|
||||
out := a.mediaContextForRelations([]memory.Relation{{ID: 1, SentenceID: 5}})
|
||||
// 命中的关系挂着该句子 → 应产出媒体说明
|
||||
out := a.mediaContextForRelations([]memory.Relation{{ID: 1, SentenceID: sid}})
|
||||
if out == "" {
|
||||
t.Fatal("关系挂着有媒体的句子,却没产出媒体说明——L3 检索接线断了")
|
||||
}
|
||||
@ -567,9 +609,7 @@ func TestBuildMemoryContext_IncludesMediaSection(t *testing.T) {
|
||||
if sid == 0 {
|
||||
t.Fatal("拿不到句子 id")
|
||||
}
|
||||
if err := ms.AddRef(digest, media.OwnerGraphSentence, strconv.FormatInt(sid, 10)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
attachBlockToSentence(t, graph, ms, sentence, digest)
|
||||
|
||||
a.indexer = memory.NewIndexer(graph)
|
||||
if err := a.indexer.Sync(); err != nil {
|
||||
|
||||
Reference in New Issue
Block a user