From dae01f9c06c18c9db49e9723c4719a97cfca5637 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Fri, 11 Sep 2026 10:57:22 +0800 Subject: [PATCH] =?UTF-8?q?refactor(memory):=20=E7=A7=BB=E9=99=A4=20media?= =?UTF-8?q?=5Frefs/=E5=BC=95=E7=94=A8=E8=AE=A1=E6=95=B0=EF=BC=8C=E5=AA=92?= =?UTF-8?q?=E4=BD=93=E6=88=90=E4=B8=BA=E4=B8=80=E7=AD=89=E8=AE=B0=E5=BF=86?= =?UTF-8?q?=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 媒体此前是"文本块 + 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 模型目录接线改动。 --- cmd/homed/main.go | 35 +- internal/agent/core/agent.go | 26 +- internal/agent/core/context.go | 82 +-- internal/agent/core/crossmodal.go | 13 +- internal/agent/core/distill.go | 58 +- internal/agent/core/eventloop.go | 2 +- internal/agent/core/graphmedia.go | 161 ++--- internal/agent/core/graphmedia_test.go | 194 +++--- internal/agent/core/inputunify_test.go | 87 +-- internal/agent/core/medialive_test.go | 100 ++-- internal/agent/core/medialoop.go | 104 ++-- internal/agent/core/medialoop_test.go | 139 ++--- internal/agent/core/mediaref.go | 77 ++- internal/agent/core/mediaref_test.go | 192 +++--- internal/agent/core/toolcall.go | 22 +- internal/agent/core/tooldefs.go | 2 +- internal/config/registry.go | 11 +- internal/memory/block.go | 37 ++ internal/memory/document/document.go | 797 +++++++++++-------------- internal/memory/graph.go | 2 +- internal/memory/media/media.go | 307 ++-------- internal/memory/media/media_test.go | 193 +----- internal/memory/media/smoke_test.go | 140 ++--- internal/memory/media/soak_test.go | 60 +- internal/memory/media/stress_test.go | 223 +++---- internal/sdk/memory_impl.go | 255 +++++--- internal/sdk/memory_impl_test.go | 41 +- 27 files changed, 1391 insertions(+), 1969 deletions(-) diff --git a/cmd/homed/main.go b/cmd/homed/main.go index fa6b671..3a7f9b6 100644 --- a/cmd/homed/main.go +++ b/cmd/homed/main.go @@ -24,10 +24,10 @@ import ( logpkg "gitcode.com/JianFeeeee/HomeAgent/internal/log" luapkg "gitcode.com/JianFeeeee/HomeAgent/internal/lua" "gitcode.com/JianFeeeee/HomeAgent/internal/memory" - "gitcode.com/JianFeeeee/HomeAgent/internal/memory/clip" "gitcode.com/JianFeeeee/HomeAgent/internal/memory/document" "gitcode.com/JianFeeeee/HomeAgent/internal/memory/media" "gitcode.com/JianFeeeee/HomeAgent/internal/memory/pipeline" + "gitcode.com/JianFeeeee/HomeAgent/internal/memory/qwen" "gitcode.com/JianFeeeee/HomeAgent/internal/memory/social" "gitcode.com/JianFeeeee/HomeAgent/internal/memory/text" "gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector" @@ -327,15 +327,13 @@ func main() { log.Printf("[homed] warning: document store: %v", err) } - // 媒体存储(内容寻址):对话里出现的图片/音频按 sha256 落盘去重, - // L0/L2/L3 只记 digest。开关默认开;关闭后全部媒体接线静默跳过, - // 对话行为与本特性上线前完全一致。 + // 媒体存储(内容寻址):记忆块的内容后端。 + // 开关默认开;关闭后全部媒体接线静默跳过,对话行为与本特性上线前一致。 var mediaStore *media.Store if cfgReg.GetBool("core.memory.media.enabled", true) { mediaDir := cfgReg.GetString("core.memory.media.dir", filepath.Join(cfg.Daemon.DataDir, "memory", "media")) - maxMB := cfgReg.GetInt("core.memory.media.max_mb", 2048) - ms, err := media.New(mediaDir, int64(maxMB)*1024*1024) + ms, err := media.New(mediaDir) if err != nil { // 媒体存储开不起来不该阻止启动——它是记忆增强,不是对话必需品 log.Printf("[homed] warning: media store: %v(媒体记忆已禁用)", err) @@ -343,32 +341,32 @@ func main() { mediaStore = ms defer mediaStore.Close() st := mediaStore.Stats() - log.Printf("[homed] media store active: %v 条 / %v 字节(上限 %d MB)", - st["count"], st["total_bytes"], maxMB) + log.Printf("[homed] media store active: %v 条 / %v 字节", + st["count"], st["total_bytes"]) } } // 统一多模态向量空间(可选)。 // // 两条路径共享同一套基础设施(L0/L2/L3 向量缓存、media.Store 坐标、 - // QueryMemoryMediaScored 检索),只是「算向量的源头」不同: - // - onnx:内嵌 ONNX 模型(如 CLIP) + // QueryMediaScored 检索),只是「算向量的源头」不同: + // - onnx:内嵌 Qwen3-VL 完整图文共享空间 // - http:外部向量 API 服务(Jina / OpenAI / 自建) // type 为空时禁用多模态向量检索,退回纯 fastText 文本路径。 var multimodalSpace vector.MultimodalEmbedder switch mmType := cfgReg.GetString("core.memory.multimodal_space.type", ""); mmType { case "onnx": - if clipDir := cfgReg.GetString("core.memory.media.clip_model_dir", ""); clipDir != "" { - e, err := clip.New(clipDir) + if modelDir := cfgReg.GetString("core.memory.multimodal_space.onnx.model_dir", ""); modelDir != "" { + e, err := qwen.New(modelDir) if err != nil { - log.Printf("[homed] warning: onnx embedder load failed: %v(多模态向量检索已禁用)", err) + log.Printf("[homed] warning: qwen multimodal embedder load failed: %v(多模态向量检索已禁用)", err) } else { multimodalSpace = e defer e.Close() - log.Printf("[homed] multimodal space (onnx) active: dim=%d fp=%s", e.Dim(), e.Fingerprint()[:min(12, len(e.Fingerprint()))]) + log.Printf("[homed] multimodal space (qwen onnx) active: dim=%d fp=%s", e.Dim(), e.Fingerprint()[:min(12, len(e.Fingerprint()))]) } } else { - log.Println("[homed] multimodal_space.type=onnx 但未配置 clip_model_dir,多模态向量检索已禁用") + log.Println("[homed] multimodal_space.type=onnx 但未配置 onnx.model_dir,多模态向量检索已禁用") } case "http": dim := cfgReg.GetInt("core.memory.multimodal_space.http.dimension", 0) @@ -376,7 +374,7 @@ func main() { if dim > 0 && ep != "" { e, err := vector.NewHTTPEmbedder(vector.HTTPEmbedderConfig{ Endpoint: ep, - APIKey: cfgReg.GetString("core.memory.multimodal_space.http.api_key", ""), + APIKey: cfgReg.GetString("core.memory.multimodal_space.http.api_key", ""), Model: cfgReg.GetString("core.memory.multimodal_space.http.model", ""), Dimension: dim, Timeout: cfgReg.GetDuration("core.memory.multimodal_space.http.timeout", 30*time.Second), @@ -398,7 +396,6 @@ func main() { } } - ks := knowledge.NewStore(filepath.Join(cfg.Daemon.DataDir, "knowledge")) if err := ks.Start(); err != nil { log.Printf("[homed] warning: knowledge store: %v", err) @@ -508,8 +505,6 @@ func main() { SocialStore: socialStore, TextMemory: textMem, MediaStore: mediaStore, - MediaGCInterval: cfgReg.GetDuration("core.memory.media.gc_interval", 6*time.Hour), - MediaGCMinAge: cfgReg.GetDuration("core.memory.media.gc_min_age", time.Hour), MediaDescribe: cfgReg.GetBool("core.memory.media.describe_on_ingest", false), Personality: personality, PluginReg: pluginReg, @@ -521,7 +516,7 @@ func main() { ContextSavePath: filepath.Join(cfg.Daemon.DataDir, "memory", "context.json"), EmbeddingModelPath: cfgReg.GetString("core.agent.embedding_model_path", ""), Embedder: embedder, - MultimodalSpace: multimodalSpace, + MultimodalSpace: multimodalSpace, StageHost: stageHost, EventBus: evBus, ThinkingEnabled: cfg.LLM.ThinkingEnabled, diff --git a/internal/agent/core/agent.go b/internal/agent/core/agent.go index 3927a69..736d159 100644 --- a/internal/agent/core/agent.go +++ b/internal/agent/core/agent.go @@ -52,14 +52,10 @@ type Agent struct { // 文本记忆(原始对话日志) textMem *text.Memory - // 媒体存储(内容寻址):对话里出现的图片/音频按 sha256 落盘去重, - // L0/L2/L3 只记 digest。为 nil 时全部媒体接线静默跳过—— - // 它是记忆增强而非对话必需品,缺了不该让对话失败。 + // 媒体存储(内容寻址):对话里出现的图片/音频按 sha256 落盘去重。 + // 它是记忆块的内容存储,不单独做生命周期管理:块的创建/迁移/删除 + // 由记忆系统本身决定。为 nil 时全部媒体接线静默跳过。 mediaStore *media.Store - // mediaGCInterval 为 0 时不跑 GC 循环(容量上限就仅在手动调 GC 时生效)。 - mediaGCInterval time.Duration - // mediaGCMinAge 保护新入库媒体:刚 Put 还没来得及 AddRef 的项引用计数也是 0。 - mediaGCMinAge time.Duration // mediaDescribe 控制是否跑后台描述循环(要消耗视觉模型配额)。 mediaDescribe bool @@ -96,8 +92,8 @@ type Agent struct { selfInputCh chan selfInputMsg // 子任务异步执行 - childMu sync.Mutex - childNextID int64 + childMu sync.Mutex + childNextID int64 // childTasks 记录子任务状态:运行中 / 结果 / 是否已交付。 // // 为什么保留结果而不是“读到即删”:完成通知会写进持久上下文 @@ -186,8 +182,6 @@ type AgentConfig struct { SocialStore *social.SocialStore TextMemory *text.Memory MediaStore *media.Store - MediaGCInterval time.Duration - MediaGCMinAge time.Duration MediaDescribe bool MultimodalSpace vector.MultimodalEmbedder FusionCfg CrossModalFusionConfig // 跨模态融合权重;零值用默认 @@ -248,13 +242,6 @@ func New(cfg AgentConfig) *Agent { if cfg.IO != nil { rc.SetChannelDefLookup(cfg.IO.GetInputChannelDef) } - // 必须把媒体存储也注给 RelevanceContext:L0→L2 归档(Prune)靠 - // rc.transferMediaRefs 把引用从 context owner 转给 document owner。 - // 漏了这一行的后果是静默的:rc.mediaStore 为 nil 时转移直接 return, - // 而携带引用的 ContextEvent 已被归档删除 → 引用永久悬空在 - // context owner 上、计数永不归零 → 对应 blob 永远不会被 GC 回收。 - rc.SetMediaStore(cfg.MediaStore) - // 注入稠密多模态向量空间(可选):配置后文档检索、L0 相关性裁剪、 // 跨模态检索全部共享同一向量空间,取代稀疏 fastText 语义路。 // 未配置时退化到 TF-IDF/fastText 稀疏检索,保持既有行为。 @@ -284,8 +271,6 @@ func New(cfg AgentConfig) *Agent { social: cfg.SocialStore, textMem: cfg.TextMemory, mediaStore: cfg.MediaStore, - mediaGCInterval: cfg.MediaGCInterval, - mediaGCMinAge: cfg.MediaGCMinAge, mediaDescribe: cfg.MediaDescribe, personality: cfg.Personality, pluginReg: cfg.PluginReg, @@ -322,7 +307,6 @@ func (a *Agent) Start() { go a.archiveLoop() go a.mergeLoop() go a.reviewLoop() - go a.mediaGCLoop() go a.mediaDescribeLoop() a.reembedStaleMedia() log.Printf("[agent] %s started, waiting for IO interrupts", a.id) diff --git a/internal/agent/core/context.go b/internal/agent/core/context.go index dab2536..83339af 100644 --- a/internal/agent/core/context.go +++ b/internal/agent/core/context.go @@ -3,7 +3,6 @@ package core import ( "encoding/json" "fmt" - "log" "os" "path/filepath" "sort" @@ -13,7 +12,6 @@ import ( "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" sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" ) @@ -24,9 +22,8 @@ type ToolResultItem struct { } type ContextEvent struct { - // ID 是事件的稳定标识,媒体引用(media_refs.owner_id)挂在它上面。 + // ID 是事件的稳定标识。惰性生成:只有真的要挂媒体块时才赋值。 // - // 惰性生成:只有真的要挂媒体时才赋值(见 bindEventMedia)。 // 全量生成会让每条事件都多一个字段进 context.json,而绝大多数对话没有媒体。 // omitempty 保证存量 context.json 读回来时该字段为空,不影响任何既有行为。 ID string `json:"id,omitempty"` @@ -36,13 +33,11 @@ type ContextEvent struct { Response string `json:"response,omitempty"` ToolsUsed []string `json:"tools_used,omitempty"` ToolResults []ToolResultItem `json:"tool_results,omitempty"` - // --- 原生多模态记忆(v1.2.0)--- - // 媒体不是描述文本的附件,而是与文本同生命周期的记忆块。Vec 坐标在媒体 - // 首次进入 L0 时计算一次并存于 CAS;L0→L2→L3 只迁移 Media digest 引用, - // 三层始终复用同一坐标。描述仅是可选的文本语义通道,不再决定媒体是否存在。 - Media []string `json:"media,omitempty"` - Vector vector.Vector `json:"-"` // 稀疏词向量(TF-IDF/fastText 空间) - DenseVec []float64 `json:"-"` // 稠密多模态向量(与媒体/文档共享空间) + // --- 原生多模态记忆 --- + // 一等记忆块:块本身随事件在层间迁移,身份不变,不建引用计数。 + Blocks []memory.MemoryBlock `json:"blocks,omitempty"` // 一等记忆块(text/image/video/audio) + Vector vector.Vector `json:"-"` // 稀疏词向量(TF-IDF/fastText 空间) + DenseVec []float64 `json:"-"` // 稠密多模态向量(与媒体/文档共享空间) } const contextFlushInterval = 5 * time.Second @@ -57,41 +52,6 @@ type RelevanceContext struct { dirty bool toolDefLookup func(name string) *sdk.ToolDef channelDefLookup func(name string) (sdk.ChannelDef, bool) - - // mediaStore 只用于 Prune 时把媒体引用从事件转给归档文档。 - // 为 nil 时引用转移静默跳过(媒体存储未启用)。 - mediaStore *media.Store -} - -// SetMediaStore 注入媒体存储,供 L0→L2 归档时转移媒体引用。 -func (c *RelevanceContext) SetMediaStore(s *media.Store) { - c.mu.Lock() - defer c.mu.Unlock() - c.mediaStore = s -} - -// transferMediaRefs 把被归档事件的媒体引用转给目标文档(调用方已持 c.mu)。 -// -// 先挂后销:若反序,引用计数会瞬时归零,此时若后台 GC 正在跑 -// 就会把仍被记忆引用的内容当孤儿清掉。 -func (c *RelevanceContext) transferMediaRefs(archive []scoredEvent, docID string) { - if c.mediaStore == nil || docID == "" { - return - } - for _, s := range archive { - evt := s.event - if evt == nil || evt.ID == "" || len(evt.Media) == 0 { - continue - } - for _, d := range evt.Media { - if err := c.mediaStore.AddRef(d, media.OwnerDocument, docID); err != nil { - log.Printf("[media] 归档转移 AddRef 失败 (%s → doc %s): %v", shortDigest(d), docID, err) - } - } - if _, err := c.mediaStore.DropOwner(media.OwnerContext, evt.ID); err != nil { - log.Printf("[media] 归档转移 DropOwner 失败 (evt %s): %v", evt.ID, err) - } - } } func NewRelevanceContext(savePath string, embedder *memory.StaticEmbedder) *RelevanceContext { @@ -323,8 +283,7 @@ func (c *RelevanceContext) flush() { // scoredEvent 是 Prune 里按相关度排序的事件。 // -// 提为包级类型(原先是 Prune 内的局部类型):transferMediaRefs 需要 -// 把待归档列表传进去,局部类型无法出现在方法签名上。 +// 提为包级类型:Prune 需要把待归档列表传给后续处理。 type scoredEvent struct { event *ContextEvent score float64 @@ -406,17 +365,20 @@ func (c *RelevanceContext) Prune(currentInput string, topK int, docStore *docume Content: s.event.Input, Response: s.event.Response, ToolResults: convertToolResults(s.event.ToolResults), - Media: append([]string(nil), s.event.Media...), + Blocks: append([]memory.MemoryBlock(nil), s.event.Blocks...), } } doc, err := docStore.ContextToDoc("context_archived", entries, c.embedder, nil, c.toolOutputClean, c.channelCleanerForDoc()) if err == nil && doc != nil { archived = len(entries) - // 媒体引用随事件一起从 L0 转到 L2:先把引用挂到归档文档上, - // 再注销原事件的引用。顺序不能反——先销后挂会让引用计数 - // 瞬时归零,若此时 GC 正在跑(后台任务)就会把仍被记忆引用的 - // 内容当孤儿清掉。 - c.transferMediaRefs(archive, doc.ID) + // 一等记忆块的迁移:块随归档事件离开 L0、进入 L2。 + // 迁移的是块本身(ID 不变、只换持有层),不是复制也不是保活引用; + // 因此归档后清空源事件的块,确保同一块不同时留在两层。 + for _, s := range archive { + if s.event != nil { + s.event.Blocks = nil + } + } } } @@ -459,6 +421,18 @@ func (c *RelevanceContext) Recent(n int) []ContextEvent { return result } +// Blocks 返回当前上下文持有的一等记忆块(供跨层存活判定)。 +// 迁移后源事件已被清空,因此这里只会拿到真正属于 L0 的块。 +func (c *RelevanceContext) Blocks() []memory.MemoryBlock { + c.mu.Lock() + defer c.mu.Unlock() + var out []memory.MemoryBlock + for _, e := range c.events { + out = append(out, e.Blocks...) + } + return out +} + func (c *RelevanceContext) Len() int { c.mu.Lock() defer c.mu.Unlock() diff --git a/internal/agent/core/crossmodal.go b/internal/agent/core/crossmodal.go index ad55001..cdf253c 100644 --- a/internal/agent/core/crossmodal.go +++ b/internal/agent/core/crossmodal.go @@ -90,14 +90,11 @@ func (a *Agent) retrieveCrossModal(query string, topK int, cfg CrossModalFusionC if a.docStore != nil { for _, dh := range a.docStore.QueryScored(query, per) { hit := CrossModalHit{Doc: dh.Doc, DocScore: dh.Score} - // 命中文档若关联着媒体(media_refs),把媒体作为文本路候选一并带上: + // 命中文档若持有一等记忆块,把首个媒体块一并带上: // 描述文本命中 → 该媒体就是相关记忆,供后续展示/注入。 - if a.mediaStore != nil { - refs, err := a.mediaStore.Refs(media.OwnerDocument, dh.Doc.ID) - if err == nil && len(refs) > 0 { - if it, err := a.mediaStore.Stat(refs[0]); err == nil { - hit.Media = it - } + if a.mediaStore != nil && len(dh.Doc.Blocks) > 0 { + if it, err := a.mediaStore.Stat(dh.Doc.Blocks[0].PayloadDigest); err == nil { + hit.Media = it } } textHits = append(textHits, hit) @@ -110,7 +107,7 @@ func (a *Agent) retrieveCrossModal(query string, topK int, cfg CrossModalFusionC qv, err := a.multimodalSpace.VectorizeDense(query) if err != nil { log.Printf("[crossmodal] 多模态文本编码失败: %v", err) - } else if mh, err := a.mediaStore.QueryMemoryMediaScored(qv, a.multimodalSpace.Fingerprint(), per); err != nil { + } else if mh, err := a.mediaStore.QueryMediaScored(qv, a.multimodalSpace.Fingerprint(), per); err != nil { log.Printf("[crossmodal] 媒体记忆检索失败: %v", err) } else { for _, h := range mh { diff --git a/internal/agent/core/distill.go b/internal/agent/core/distill.go index 44b892c..cf17f7f 100644 --- a/internal/agent/core/distill.go +++ b/internal/agent/core/distill.go @@ -10,7 +10,6 @@ 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" ) @@ -184,7 +183,7 @@ func (a *Agent) archiveColdDocs() { if len(triples) == 0 { continue } - ec, rc, mediaBound, err := a.commitTriplesWithMedia(triples, string(a.id)+"_doc_archival", 0) + ec, rc, blocks, err := a.commitTriplesWithMedia(triples, string(a.id)+"_doc_archival", 0, doc.Blocks) if err != nil { log.Printf("[agent] doc→graph archival error: %v", err) continue @@ -203,64 +202,15 @@ func (a *Agent) archiveColdDocs() { "(三元组 %d 条全被实体名校验拒绝)", doc.ID, len(triples)) continue } - log.Printf("[agent] doc→graph: %s → %d entities, %d relations", doc.ID, ec, rc) + log.Printf("[agent] doc→graph: %s → %d entities, %d relations, %d blocks", doc.ID, ec, rc, blocks) - // 先销媒体引用再删文档:文档一旦从 docStore 消失,就再没有任何 - // 东西能告诉我们它曾经引用过哪些 digest,media_refs 里那条记录 - // 就永久悬空、引用计数永不归零,导致 blob 永远不会被 GC 回收。 - // - // 但只有在引用**确实**转移到 graph_sentence 之后才能释放: - // 图库里没有任何句子承载这些 digest 时释放旧引用,计数归零, - // GC 会把内容当孤儿删掉。宁可留一条悬空引用(内容还在,可由 - // 后续一致性检查清理),也不能丢内容。 - refs, refErr := a.docMediaRefs(doc.ID) - switch { - case refErr != nil: - log.Printf("[media] 查文档 %s 的媒体引用失败,保守不释放: %v", doc.ID, refErr) - case len(refs) == 0: - // 该文档本就没有媒体引用,无需释放。 - case mediaBound == 0: - log.Printf("[media] 文档 %s 有 %d 个媒体引用但图库一个都没绑上,"+ - "保留引用以免 GC 删除内容(句子正文里可能没有可反解的短 digest)", - doc.ID, len(refs)) - default: - a.releaseDocMedia(doc.ID) - } + // 文档的一等记忆块已随句子写进 L3(身份不变,由 bindSentenceBlocks + // 复用 doc.Blocks 的 ID);块不再挂在文档上,删除文档即完成迁移。 a.docStore.Remove(doc.ID) } } } -// docMediaRefs 返回文档当前持有的媒体引用(nil store 时为空)。 -// -// 单独取出来是为了让归档路径能在释放前先确认「有没有东西要释放」—— -// 没有引用时不必打日志,有引用但没绑上图库时必须保留。 -func (a *Agent) docMediaRefs(docID string) ([]string, error) { - if a.mediaStore == nil || docID == "" { - return nil, nil - } - return a.mediaStore.Refs(media.OwnerDocument, docID) -} - -// 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 裁决 // ────────────────────────────────────────────── diff --git a/internal/agent/core/eventloop.go b/internal/agent/core/eventloop.go index e01d238..7984207 100644 --- a/internal/agent/core/eventloop.go +++ b/internal/agent/core/eventloop.go @@ -437,7 +437,7 @@ func (a *Agent) processInput(evt *agentIO.InputEvent) { ToolResults: toolResults, } a.bindEventMedia(&turnEvt, a.drainMediaDigests()) - if s := a.mediaSummaryForEvent(turnEvt.Media); s != "" { + if s := a.mediaSummaryForEvent(turnEvt.Blocks); s != "" { turnEvt.Input = turnEvt.Input + "\n" + s } a.context.Append(turnEvt) diff --git a/internal/agent/core/graphmedia.go b/internal/agent/core/graphmedia.go index 9ad0d3b..306301a 100644 --- a/internal/agent/core/graphmedia.go +++ b/internal/agent/core/graphmedia.go @@ -8,23 +8,15 @@ import ( "strings" "gitcode.com/JianFeeeee/HomeAgent/internal/memory" - "gitcode.com/JianFeeeee/HomeAgent/internal/memory/media" ) -// L3 图库的媒体引用绑定。 +// L3 图库的媒体绑定。 // -// 设计定位(方案 A:只做引用,不建媒体实体节点): -// 图库里的实体与关系全部来自**描述文本**的 NLP 提取——媒体描述经 -// mediaSummaryForEvent 进了 L0 事件的 Input,随归档进 L2 文档的 Content, -// 蒸馏时提取器自然会从描述文字里抽出实体和关系。 +// 媒体在 L3 是一等记忆块(memory_blocks),通过 sentence --contains--> block +// 结构边与承载它的句子相连。不再用 media_refs / owner 账本保活。 // -// 为何不把媒体本身建成实体节点:节点名只能从描述里取,而描述会被重新生成 -// (换个视觉模型、补一次描述,名字就变了),于是同一张图会在图谱上留下 -// 多个语义模糊的节点。检索能力靠描述文本已经具备,多这类节点只是噪声。 -// -// 那么图库侧还需要什么:**反查**。图库里的句子写着「[image a1b2c3d4e5f6] -// 一张紫蓝红三色带图」,要能从这条句子找回那份字节。这就是 -// media_refs 的 graph_sentence owner 的用途,也是这一层唯一要做的事。 +// 图库里的实体与关系仍来自描述文本的 NLP 提取;媒体块只是补上 +// 「这条记忆当时带着哪份媒体」这一结构信息。 // mediaDigestPattern 匹配事件摘要里的媒体标记 [ <短digest>]。 // @@ -208,59 +200,61 @@ func extractMediaDigests(text string) []string { return out } -// bindSentenceMedia 把句子文本里提到的媒体挂到对应的 sentences.id 上。 +// bindSentenceBlocks 把句子文本里提到的媒体变成 L3 的一等记忆块, +// 并建立 sentence --contains--> block 结构边。 // -// sentenceIDs 来自 GraphDB.CommitWithMedia:句子文本 → sentences.id。 -// 只处理本次真正写入了 sentences 表的句子,避免给历史句子重复挂引用 -// (AddRef 幂等,重复挂不会涨计数,但白跑 SQL)。 -// -// 返回实际绑定成功的引用数,这是调用方的安全依据:归档路径靠它判定 -// 「引用真的转移到图库了吗」,不能用「Commit 没报错」代替——Commit 会 -// 静默跳过实体名不合法(validEntityName 要求 2–50 字符)的三元组, -// 于是「无错但一条也没写进去」是真实会发生的:LLM 生成的长描述提不出 -// 合规实体名,实测 456 字描述得到 0 entities 0 relations。 -func (a *Agent) bindSentenceMedia(sentenceIDs map[string]int64) int { - if a.mediaStore == nil || len(sentenceIDs) == 0 { +// seed 是本批文档已持有的一等块:迁移时按 digest 复用它们的身份(ID 不变), +// 真正做到“同一个块从 L2 移到 L3”,而不是另建一个同内容的新块。 +// 返回本次写入 L3 的块数。 +func (a *Agent) bindSentenceBlocks(sentenceIDs map[string]int64, seed []memory.MemoryBlock) int { + if a.mediaStore == nil || a.memory == nil || len(sentenceIDs) == 0 { return 0 } + byDigest := make(map[string]memory.MemoryBlock, len(seed)) + for _, b := range seed { + if b.PayloadDigest != "" { + byDigest[b.PayloadDigest] = b + } + } 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 永远匹配不到它。 + for _, short := range extractMediaDigests(text) { 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) + b, ok := byDigest[full] + if !ok { + if b, ok = a.blockFromDigest(full); !ok { + continue + } + } + if err := a.memory.PutMemoryBlocks([]memory.MemoryBlock{b}); err != nil { + log.Printf("[media] L3 记忆块写入失败 (%s): %v", shortDigest(full), err) + continue + } + if err := a.memory.AddMemoryBlockEdge("sentence", strconv.FormatInt(sid, 10), "block", b.ID, "contains"); err != nil { + log.Printf("[media] 句子→块边建立失败 (%s): %v", shortDigest(full), err) continue } bound++ } } if bound > 0 { - log.Printf("[media] L3 图库绑定 %d 个媒体引用", bound) + log.Printf("[media] L3 图库写入 %d 个一等记忆块", bound) } return bound } // sentenceWithMediaMarkers 保证句子文本里带上这些 digest 的媒体标记。 // -// 存在的理由:媒体的绑定链是 SentenceText → sentences 表 → sentence_id → -// media_refs。模型只知道 digest(从 memory_recall 的「关联媒体」或对话里的 -// 媒体标记读到),不该要求它自己按内核格式拼标记——格式写错的后果是引用 -// 静默挂不上,模型也无从察觉。 +// 存在的理由:L3 的块边由句子正文里的短 digest 反解而来。模型只知道 +// digest(从 memory_recall 的「关联媒体」或对话里的媒体标记读到), +// 不该要求它自己按内核格式拼标记——格式写错的后果是块边静默建不起来。 // // 已出现过的 digest 不重复追加:模型可能既写了标记又填了 media_digests。 func (a *Agent) sentenceWithMediaMarkers(sentence string, digests []string) string { @@ -278,7 +272,7 @@ func (a *Agent) sentenceWithMediaMarkers(sentence string, digests []string) stri continue } // 模型给的多半是短 digest(它在上下文里看到的就是短的),补全成完整 - // digest 才能进 media_refs 主键。补不上就跳过:内容可能已被 GC 清掉。 + // digest 才能定位内容。补不上就跳过:内容可能已被删除。 full, err := a.mediaStore.ResolvePrefix(d) if err != nil { log.Printf("[media] 模型提交的 digest %s 无法解析: %v", d, err) @@ -300,24 +294,19 @@ func (a *Agent) sentenceWithMediaMarkers(sentence string, digests []string) stri // docMediaContext 为一篇文档产出媒体说明,供 doc_query 拼进工具返回值。 // -// 优先读 media_refs(权威:谁挂上去的就是谁),为空时退回解析正文标记—— -// 历史文档与经旧版路径写入的文档只有标记、没有引用。 +// 文档的一等记忆块随文档 JSON 持久化;这里只有正文,因此从正文标记反解。 func (a *Agent) docMediaContext(docID, content string) string { if a.mediaStore == nil { return "" } - digests, err := a.mediaStore.Refs(media.OwnerDocument, docID) - if err != nil { - log.Printf("[media] 读取文档 %s 的媒体引用失败: %v", docID, err) - } - if len(digests) == 0 { - for _, short := range extractMediaDigests(content) { - full, err := a.mediaStore.ResolvePrefix(short) - if err != nil { - continue - } - digests = append(digests, full) + // 文档的一等记忆块随文档 JSON 持久化;这里只有正文,退回解析标记。 + var digests []string + for _, short := range extractMediaDigests(content) { + full, err := a.mediaStore.ResolvePrefix(short) + if err != nil { + continue } + digests = append(digests, full) } var lines []string for _, d := range digests { @@ -333,9 +322,7 @@ func (a *Agent) docMediaContext(docID, content string) string { // resolveMediaDigests 把模型给的(多为短)digest 补全成完整 digest。 // -// 补不上就丢弃那一条并记日志:模型可能凭印象编了个 digest,也可能内容已被 -// 容量 GC 淘汰。挂一条对不上的引用比不挂更糟——digest 进了 media_refs 主键, -// 错了则 DropOwner 永远匹配不到它,那是一条永久泄漏的引用。 +// 补不上就丢弃那一条并记日志:模型可能凭印象编了个 digest,也可能内容已被删除。 func (a *Agent) resolveMediaDigests(digests []string) []string { if a.mediaStore == nil || len(digests) == 0 { return nil @@ -359,33 +346,11 @@ func (a *Agent) resolveMediaDigests(digests []string) []string { // bindDocMedia 把一组完整 digest 挂到文档 owner 上,返回成功条数。 // -// 与 releaseDocMedia 成对:文档归档进 L3 时释放,文档写入时绑定。 -// 只绑不放会让磁盘只增不减,只放不绑会让 GC 误删仍被引用的内容。 -func (a *Agent) bindDocMedia(docID string, digests []string) int { - if a.mediaStore == nil || docID == "" || len(digests) == 0 { - return 0 - } - bound := 0 - for _, d := range digests { - if err := a.mediaStore.AddRef(d, media.OwnerDocument, docID); err != nil { - log.Printf("[media] 文档引用绑定失败 (%s → doc %s): %v", shortDigest(d), docID, err) - continue - } - bound++ - } - if bound > 0 { - log.Printf("[media] 文档 %s 绑定 %d 个媒体引用", docID, bound) - } - return bound -} - -// commitTriplesWithMedia 提交三元组并绑定句子里的媒体引用。 +// commitTriplesWithMedia 提交三元组并把句子里的媒体变成 L3 一等块。 // -// 包一层是为了让所有「三元组入库」的调用点用同一条路径拿到媒体绑定, -// 而不必各自记得多调一次 bindSentenceMedia。 -// mediaBound 是本次实际挂到 graph_sentence owner 上的引用数;归档路径靠它 -// 判定能否安全释放旧引用。媒体存储关闭时恒为 0(此时也没有引用需要释放)。 -func (a *Agent) commitTriplesWithMedia(triples []memory.Triple, sessionID string, turnID int) (entities, relations, mediaBound int, err error) { +// seed 是调用方已持有的一等块(如 L2 文档的 Blocks),用于保持块身份; +// 普通对话路径传 nil。blocks 是本次写入 L3 的块数。 +func (a *Agent) commitTriplesWithMedia(triples []memory.Triple, sessionID string, turnID int, seed []memory.MemoryBlock) (entities, relations, blocks int, err error) { if a.memory == nil { return 0, 0, 0, fmt.Errorf("graph memory 未启用") } @@ -398,25 +363,23 @@ func (a *Agent) commitTriplesWithMedia(triples []memory.Triple, sessionID string if err != nil { return ec, rc, 0, err } - return ec, rc, a.bindSentenceMedia(sentenceIDs), nil + return ec, rc, a.bindSentenceBlocks(sentenceIDs, seed), nil } -// RecallMediaForSentence 反查某条图库句子引用的媒体。 +// RecallBlocksForSentence 反查某条图库句子持有的一等记忆块。 // -// 这是整层的目的:几个月后从图谱走到一条句子,要能取回当时那份字节 -// (若尚未被容量 GC 淘汰)。返回的是完整 digest,调用方用 -// mediaStore.Get 取内容、Stat 取描述与元数据。 -func (a *Agent) RecallMediaForSentence(sentenceID int64) ([]string, error) { - if a.mediaStore == nil { +// 这是整层的目的:几个月后从图谱走到一条句子,要能取回当时那份媒体。 +func (a *Agent) RecallBlocksForSentence(sentenceID int64) ([]memory.MemoryBlock, error) { + if a.memory == nil { return nil, nil } - return a.mediaStore.Refs(media.OwnerGraphSentence, strconv.FormatInt(sentenceID, 10)) + return a.memory.BlocksForNode("sentence", strconv.FormatInt(sentenceID, 10)) } // sentenceIDsFromRelations 收集一批关系引用的句子 id(去重、去零)。 // -// 关系行本身不持有媒体,媒体挂在句子上(graph_sentence owner)。 -// 因此"这次召回涉及哪些媒体"必须经由关系 → 句子 → media_refs 这条路。 +// 关系行本身不持有媒体,媒体作为一等块以 sentence --contains--> block +// 结构边与句子相连;因此"这次召回涉及哪些媒体"必须经由关系 → 句子这一跳。 func sentenceIDsFromRelations(relations []memory.Relation) []int64 { if len(relations) == 0 { return nil @@ -470,18 +433,18 @@ func (a *Agent) mediaContextForInjectedEntities(injected *memory.InjectedContext // 描述文本本就在句子里,这里补的是「内容是否还在、能否重新看图」这个信息—— // 描述永存而字节可能已被淘汰,两者状态不同。 func (a *Agent) mediaContextForSentences(sentenceIDs []int64) string { - if a.mediaStore == nil || len(sentenceIDs) == 0 { + if a.memory == 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 { + blocks, err := a.memory.BlocksForNode("sentence", strconv.FormatInt(sid, 10)) + if err != nil || len(blocks) == 0 { continue } var parts []string - for _, d := range digests { - if line := a.mediaMarkerLine(d); line != "" { + for _, b := range blocks { + if line := a.mediaMarkerLine(b.PayloadDigest); line != "" { parts = append(parts, line) } } diff --git a/internal/agent/core/graphmedia_test.go b/internal/agent/core/graphmedia_test.go index 8044203..f3098f5 100644 --- a/internal/agent/core/graphmedia_test.go +++ b/internal/agent/core/graphmedia_test.go @@ -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 { diff --git a/internal/agent/core/inputunify_test.go b/internal/agent/core/inputunify_test.go index b3942bc..ad110b3 100644 --- a/internal/agent/core/inputunify_test.go +++ b/internal/agent/core/inputunify_test.go @@ -25,7 +25,7 @@ func newInputTestAgent(t *testing.T) (*Agent, *media.Store) { t.Helper() dir := t.TempDir() - 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) } @@ -275,30 +275,47 @@ func TestResolveMediaDigests(t *testing.T) { } } -// ---------- bindDocMedia ---------- +// ---------- 文档持有的一等记忆块 ---------- + +func TestDocCommit_StoresBlocks(t *testing.T) { + // doc_commit 带 media_digests 时,媒体应作为一等块直接存在文档上, + // 并随 doc 一起持久化(不再靠 media_refs 保活)。 + dir := t.TempDir() + ds := document.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords) + if err := ds.Start(); err != nil { + t.Fatal(err) + } + defer ds.Stop() + + ms, err := media.New(filepath.Join(dir, "media")) + if err != nil { + t.Fatal(err) + } + defer ms.Close() -func TestBindDocMedia(t *testing.T) { - a, ms := newInputTestAgent(t) d1, _ := ms.Put([]byte("doc-one"), media.Item{MIME: "image/png"}) d2, _ := ms.Put([]byte("doc-two"), media.Item{MIME: "image/png"}) - if n := a.bindDocMedia("doc_x", []string{d1, d2}); n != 2 { - t.Fatalf("绑定 %d 条,期望 2", n) + doc := &document.Doc{ID: "doc_x", Summary: "s", Content: "c"} + for _, d := range []string{d1, d2} { + if b, ok := (&Agent{mediaStore: ms}).blockFromDigest(d); ok { + doc.Blocks = append(doc.Blocks, b) + } } - refs, err := ms.Refs(media.OwnerDocument, "doc_x") - if err != nil { - t.Fatalf("Refs: %v", err) - } - if len(refs) != 2 { - t.Errorf("引用 = %v,期望 2 条", refs) + if err := ds.Insert(doc); err != nil { + t.Fatal(err) } - if n := a.bindDocMedia("", []string{d1}); n != 0 { - t.Error("空 docID 不该绑定") + blocks := ds.Blocks() + if len(blocks) != 2 { + t.Fatalf("文档应持有 2 个块,实际 %d", len(blocks)) } - bare := &Agent{} - if n := bare.bindDocMedia("doc_y", []string{d1}); n != 0 { - t.Error("无媒体存储时不该绑定") + seen := map[string]bool{} + for _, b := range blocks { + seen[b.PayloadDigest] = true + } + if !seen[d1] || !seen[d2] { + t.Errorf("块 digest 不对: %+v", blocks) } } @@ -310,17 +327,7 @@ func TestDocMediaContext(t *testing.T) { MIME: "image/png", Description: "文档里的配图", }) - t.Run("优先用media_refs", func(t *testing.T) { - if err := ms.AddRef(digest, media.OwnerDocument, "doc_refs"); err != nil { - t.Fatalf("AddRef: %v", err) - } - got := a.docMediaContext("doc_refs", "正文里没有任何标记") - if !strings.Contains(got, "文档里的配图") { - t.Errorf("未从 media_refs 取到媒体说明: %q", got) - } - }) - - t.Run("无引用时回退解析正文标记", func(t *testing.T) { + t.Run("无块时解析正文标记", func(t *testing.T) { content := "旧正文 [image/png " + digest[:12] + "] 文档里的配图" got := a.docMediaContext("doc_legacy", content) if !strings.Contains(got, "文档里的配图") { @@ -384,13 +391,13 @@ func newToolTestAgent(t *testing.T) (*Agent, *media.Store) { } t.Cleanup(func() { g.Close() }) - ds := document.NewStore(filepath.Join(dir, "documents")) + ds := document.NewStore(filepath.Join(dir, "documents"), memory.TokenizeWords) if err := ds.Start(); err != nil { t.Fatalf("doc store: %v", err) } t.Cleanup(func() { ds.Stop() }) - 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) } @@ -438,9 +445,12 @@ func TestToolMemoryCommit_BindsMedia(t *testing.T) { if len(res.Relations) == 0 || res.Relations[0].SentenceID == 0 { t.Fatal("没有句子落点 —— 媒体引用无从挂起") } - refs, _ := ms.Refs(media.OwnerGraphSentence, strconv.FormatInt(res.Relations[0].SentenceID, 10)) - if len(refs) != 1 || refs[0] != digest { - t.Errorf("句子引用 = %v,期望 [%s]", refs, digest) + blocks, err := a.memory.BlocksForNode("sentence", strconv.FormatInt(res.Relations[0].SentenceID, 10)) + if err != nil { + t.Fatalf("BlocksForNode: %v", err) + } + if len(blocks) != 1 || blocks[0].PayloadDigest != digest { + t.Errorf("句子块 = %+v,期望 [%s]", blocks, digest) } } @@ -515,9 +525,14 @@ func TestToolDocCommit_BindsMedia(t *testing.T) { if !strings.Contains(d.Content, "笔记里的插图") { t.Errorf("标记未进正文(向量索引看不到这份媒体): %q", d.Content) } - refs, _ := ms.Refs(media.OwnerDocument, d.ID) - if len(refs) != 1 || refs[0] != digest { - t.Errorf("文档引用 = %v,期望 [%s]", refs, digest) + var held bool + for _, b := range d.Blocks { + if b.PayloadDigest == digest { + held = true + } + } + if !held { + t.Errorf("文档应持有一等记忆块 [%s],实际 %+v", digest, d.Blocks) } } diff --git a/internal/agent/core/medialive_test.go b/internal/agent/core/medialive_test.go index 73031a5..d198871 100644 --- a/internal/agent/core/medialive_test.go +++ b/internal/agent/core/medialive_test.go @@ -6,14 +6,13 @@ // 往 IOManager 注入一个 image 事件,然后等。之后全部由生产代码自己走: // // processMediaInput → captureBlockMedia(入 CAS) -// → Prune → transferMediaRefs(L0→L2 引用转移) +// → Prune(L0→L2 块迁移) // → describePendingMedia(真实视觉模型生成描述) -// → archiveColdDocs → commitTriplesWithMedia → bindSentenceMedia(L2→L3) +// → archiveColdDocs → commitTriplesWithMedia → bindSentenceBlocks(L2→L3) // → 第二轮提问,验证 agent 真能召回 // -// 为什么必须这样测:单测能证明每个函数正确,却证明不了它**被接上了**。 -// 本文件的直接动机是一个真实缺陷——core.New() 漏了 rc.SetMediaStore(cfg.MediaStore), -// 于是 L0→L2 引用转移在生产里永远静默 return,而手工注入 store 的单测全绿。 +// 为什么必须这样测:单测能证明每个函数正确,却证明不了它**被接上了**—— +// 手工注入 store 的单测全绿而生产链路断开,是本文件要拦的典型缺陷。 // // 需要真实 LLM,因此加 medialive build tag,默认 go test 不跑: // @@ -172,7 +171,7 @@ func newLiveEnv(t *testing.T, c liveCfg) *liveEnv { t.Fatalf("set default provider: %v", err) } - ms, err := media.New(filepath.Join(dir, "media"), 256<<20) + ms, err := media.New(filepath.Join(dir, "media")) if err != nil { t.Fatalf("media store: %v", err) } @@ -184,7 +183,7 @@ func newLiveEnv(t *testing.T, c liveCfg) *liveEnv { } t.Cleanup(func() { graph.Close() }) - docStore := document.NewStore(filepath.Join(dir, "docs")) + docStore := document.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords) if err := docStore.Start(); err != nil { t.Fatalf("doc store: %v", err) } @@ -201,7 +200,6 @@ func newLiveEnv(t *testing.T, c liveCfg) *liveEnv { Memory: graph, DocStore: docStore, MediaStore: ms, - MediaGCInterval: 0, // 本测试自己控制 GC 时机 MediaDescribe: true, // 描述循环由测试直接调 describePendingMedia StageHost: NewStageHost(), MaxContextSize: 3, // 故意压低:第二轮就能触发 Prune 归档 @@ -271,31 +269,29 @@ func TestMediaLive_AutoTriggerChain(t *testing.T) { t.Fatalf("落盘内容与原图不一致 (err=%v)", err) } - // ── 阶段 2:引用自动挂到 ContextEvent 上 ── + // ── 阶段 2:一等记忆块自动挂到 ContextEvent 上 ── // - // 这一步验证 bindEventMedia:事件必须拿到 ID 且 media_refs 里 - // 有对应 context owner 记录。两者只写一个的后果是 GC 误删或永不清理。 + // 这一步验证 bindEventMedia:事件必须拿到 ID 并直接持有块。 var evtID string var summaryOK bool for _, e := range a.context.Recent(0) { - if len(e.Media) > 0 { + if len(e.Blocks) > 0 { evtID = e.ID summaryOK = strings.Contains(e.Input, digest[:12]) + if e.Blocks[0].PayloadDigest != digest { + t.Fatalf("事件持有的块 digest 不对: %+v", e.Blocks) + } break } } if evtID == "" { t.Fatal("没有任何 ContextEvent 挂上媒体(bindEventMedia 未被触发)") } - ctxRefs, err := env.mediaSt.Refs(media.OwnerContext, evtID) - if err != nil || len(ctxRefs) != 1 || ctxRefs[0] != digest { - t.Fatalf("context owner 引用缺失: refs=%v err=%v", ctxRefs, err) - } if !summaryOK { t.Error("事件 Input 里没有媒体摘要标记(mediaSummaryForEvent 未生效)——" + "L2/L3 靠正文里的短 digest 反查,缺了它整条召回链断掉") } - t.Logf("✓ 阶段2 引用自动绑定: event=%s owner=context 摘要内嵌=%v", evtID, summaryOK) + t.Logf("✓ 阶段2 块自动绑定: event=%s 摘要内嵌=%v", evtID, summaryOK) // ── 阶段 3:描述由后台循环自动生成(真实视觉模型)── pending, err := env.mediaSt.Pending(5) @@ -338,11 +334,10 @@ func TestMediaLive_AutoTriggerChain(t *testing.T) { t.Logf("✓ 阶段3 Search(\"紫\") 命中 %d 条", len(found)) } - // ── 阶段 4:Prune 自动把引用从 L0 转移到 L2 ── + // ── 阶段 4:Prune 自动把块从 L0 迁移到 L2 ── // // MaxContextSize=3,多注入几轮文本把带图事件挤出活跃上下文。 - // 这一步专门守 core.New() 里 rc.SetMediaStore 的接线:漏了它 - // transferMediaRefs 直接 return,引用永久悬空在 context owner 上。 + // 迁移的是块本身(同一身份换层);L0 中不该再留下它。 // 填充数量必须 > Prune 内部固定的 10 条保护窗口。 // // Prune 无条件保护最后 10 条事件(protected := events[len-10:]), @@ -366,33 +361,35 @@ func TestMediaLive_AutoTriggerChain(t *testing.T) { docRefsFound := "" for _, d := range env.docStore.RecentDocs(20) { - refs, err := env.mediaSt.Refs(media.OwnerDocument, d.ID) - if err == nil && len(refs) > 0 && refs[0] == digest { - docRefsFound = d.ID - break + for _, b := range d.Blocks { + if b.PayloadDigest == digest { + docRefsFound = d.ID + } } } if docRefsFound == "" { - t.Fatal("引用未转移到 document owner——" + - "core.New() 是否漏了 rc.SetMediaStore(cfg.MediaStore)?" + - "(该缺陷曾真实存在:手工注入 store 的单测全绿,生产里永远静默 return)") + t.Fatal("块未随归档事件迁移到 L2 文档") } - if left, _ := env.mediaSt.Refs(media.OwnerContext, evtID); len(left) != 0 { - t.Errorf("旧的 context 引用未注销(%d 条),引用计数永不归零 → blob 永不回收", len(left)) + // 同一块不能同时留在 L0。 + for _, e := range a.context.Recent(0) { + for _, b := range e.Blocks { + if b.PayloadDigest == digest { + t.Errorf("块仍留在 L0(evt %s),违反单层不变量", e.ID) + } + } } - t.Logf("✓ 阶段4 引用自动转移: context/%s → document/%s", evtID, docRefsFound) + t.Logf("✓ 阶段4 块自动迁移: context/%s → document/%s", evtID, docRefsFound) - // 转移全程内容必须可读:先挂后销的顺序若反了, - // 计数会瞬时归零,并发 GC 会把仍被引用的内容当孤儿删掉。 + // 迁移全程内容必须可读:块虽换了层,字节仍在。 if _, err := env.mediaSt.Get(digest); err != nil { - t.Fatalf("转移后内容不可读: %v", err) + t.Fatalf("迁移后内容不可读: %v", err) } // ── 阶段 5:archiveColdDocs 自动把媒体带进 L3 图库 ── // // FindColdDocs(72h, 2) 要求文档足够"冷",测试里新建的文档不满足, // 因此把 LastAccess 往前推——这是为了触发生产代码路径, - // 而不是替代它(Commit/bindSentenceMedia/releaseDocMedia 全部由它自己调)。 + // 而不是替代它(Commit/bindSentenceBlocks 全部由它自己调)。 for _, d := range env.docStore.RecentDocs(20) { if d.ID == docRefsFound { d.LastAccess = time.Now().Add(-100 * time.Hour) @@ -410,41 +407,36 @@ func TestMediaLive_AutoTriggerChain(t *testing.T) { t.Logf("图库实体数 %d", len(rows.Entities)) // 句子 id 是自增整数,扫前若干个足够覆盖本测试写入的量 for sid := int64(1); sid <= 40; sid++ { - refs, err := env.mediaSt.Refs(media.OwnerGraphSentence, strconv.FormatInt(sid, 10)) - if err == nil && len(refs) > 0 { - sentRefs += len(refs) + blocks, err := env.graph.BlocksForNode("sentence", strconv.FormatInt(sid, 10)) + if err == nil && len(blocks) > 0 { + sentRefs += len(blocks) if boundSentence == 0 { boundSentence = sid } } } if sentRefs == 0 { - t.Error("L2→L3 未绑定任何 graph_sentence 引用——" + - "bindSentenceMedia 未被 commitTriplesWithMedia 触发," + + t.Error("L2→L3 未写入任何句子→块边——" + + "bindSentenceBlocks 未被 commitTriplesWithMedia 触发," + "或句子正文里没有可反解的短 digest") } else { - t.Logf("✓ 阶段5 L3 自动绑定: %d 个句子引用,首个 sentences.id=%d", sentRefs, boundSentence) + t.Logf("✓ 阶段5 L3 自动写入: %d 个句子块,首个 sentences.id=%d", sentRefs, boundSentence) - got, err := env.agent.RecallMediaForSentence(boundSentence) - if err != nil || len(got) == 0 || got[0] != digest { - t.Errorf("从句子反查 digest 失败: got=%v err=%v", got, err) - } else if raw, err := env.mediaSt.Get(got[0]); err != nil || !bytes.Equal(raw, img) { + got, err := env.agent.RecallBlocksForSentence(boundSentence) + if err != nil || len(got) == 0 || got[0].PayloadDigest != digest { + t.Errorf("从句子反查块失败: got=%+v err=%v", got, err) + } else if raw, err := env.mediaSt.Get(got[0].PayloadDigest); err != nil || !bytes.Equal(raw, img) { t.Errorf("从句子取回的字节与原图不一致 (err=%v)", err) } else { t.Logf("✓ 阶段5 反查取回 %d 字节,与原图逐字节一致", len(raw)) } } - // ── 阶段 6:GC 不能删掉仍被记忆引用的内容 ── - removed, freed, err := env.mediaSt.GC(0) // minAge=0,最激进 - if err != nil { - t.Fatal(err) - } + // ── 阶段 6:内容随块存在,不被单独清理 ── if _, err := env.mediaSt.Stat(digest); err != nil { - t.Fatalf("被记忆引用的内容被 GC 删除了(清 %d 条/%d 字节)——"+ - "引用计数或 owner 语义有误", removed, freed) + t.Fatalf("被记忆块持有的内容不存在了: %v", err) } - t.Logf("✓ 阶段6 GC(minAge=0) 清 %d 条,被引用内容仍在", removed) + t.Logf("✓ 阶段6 被持有内容仍在") // ── 阶段 7:E2E — 第二轮提问,验证 agent 真能召回 ── // @@ -504,8 +496,8 @@ func TestMediaLive_AutoTriggerChain(t *testing.T) { } st := env.mediaSt.Stats() - t.Logf("收尾: %v 条 / %v 字节 / 已描述 %v / 无引用 %v", - st["count"], st["total_bytes"], st["described"], st["unreferenced"]) + t.Logf("收尾: %v 条 / %v 字节 / 已描述 %v", + st["count"], st["total_bytes"], st["described"]) } // TestMediaLive_NegativeControl 阴性对照:没有媒体记忆时不该"记得"。 diff --git a/internal/agent/core/medialoop.go b/internal/agent/core/medialoop.go index d8c0af3..0f2c3d3 100644 --- a/internal/agent/core/medialoop.go +++ b/internal/agent/core/medialoop.go @@ -10,16 +10,69 @@ import ( "gitcode.com/JianFeeeee/HomeAgent/internal/memory/media" ) -// 媒体记忆的两条后台循环。 +// 媒体记忆的后台循环。 // -// mediaGCLoop 清理无人引用的 blob,让容量上限真正生效。 -// mediaDescribeLoop 给未描述的媒体生成文字描述(方案 C 的另一半)。 +// mediaDescribeLoop 给未描述的媒体生成文字描述。 +// +// 媒体不单独做生命周期管理(没有 GC、没有引用计数):blob 是记忆块的内容, +// 块的创建/迁移/删除由记忆系统本身决定,块被永久删除时内容随之删除 +// (见 forgetPayloads)。 // // 为何描述要走后台而不是入库时同步做:视觉模型一次调用在生产实测 9.6s // (see_video 6 帧批量 23s)。放在对话路径上会让每张图都给回复加十几秒, // 而描述的价值是**几个月后还能检索到这张图**,不是这一轮对话—— // 这一轮模型本来就直接看着图。 +// payloadHeld 报告某个 digest 是否仍被三层记忆中的一等块持有。 +// 这是删除前的一次活查询(不是持久化账本):同一份字节可能同时被多个块共享。 +func (a *Agent) payloadHeld(digest string) bool { + if digest == "" { + return false + } + if a.context != nil { + for _, b := range a.context.Blocks() { + if b.PayloadDigest == digest { + return true + } + } + } + if a.docStore != nil { + for _, b := range a.docStore.Blocks() { + if b.PayloadDigest == digest { + return true + } + } + } + if a.memory != nil { + if blocks, err := a.memory.MemoryBlocks(); err == nil { + for _, b := range blocks { + if b.PayloadDigest == digest { + return true + } + } + } + } + return false +} + +// forgetPayloads 在记忆块被永久删除后删除它们的内容。 +// +// 与文本块一致:删除块即删除内容。只有确认没有任何存活块仍共享该 digest +// 时才删字节(同一张图可能被多个块引用)。 +func (a *Agent) forgetPayloads(digests []string) { + if a.mediaStore == nil { + return + } + for _, d := range digests { + if d == "" || a.payloadHeld(d) { + continue + } + if err := a.mediaStore.Delete(d); err != nil { + log.Printf("[media] 删除内容失败 %s: %v", shortDigest(d), err) + } + } +} + const ( // mediaDescribeBatch 是单轮描述的媒体条数上限。 // @@ -35,50 +88,11 @@ const ( mediaDescribeMinInterval = 30 * time.Second ) -// mediaGCLoop 周期清理无引用的媒体内容。 -// -// 不做这件事的后果:容量上限形同虚设。CAS 的 GC 只在被显式调用时执行, -// 而 Put 路径不触发它——一次 see_video 抽 10 帧,帧本身没人引用(工具 -// 结果被 Prune 掉之后),若无人清理就会一直堆在磁盘上。 -func (a *Agent) mediaGCLoop() { - defer func() { - if r := recover(); r != nil { - log.Printf("[agent] mediaGCLoop panic recovered: %v\n%s", r, debug.Stack()) - time.Sleep(time.Second) - go a.mediaGCLoop() - } - }() - if a.mediaStore == nil || a.mediaGCInterval <= 0 { - return - } - - ticker := time.NewTicker(a.mediaGCInterval) - defer ticker.Stop() - - for { - select { - case <-ticker.C: - removed, freed, err := a.mediaStore.GC(a.mediaGCMinAge) - if err != nil { - log.Printf("[media] GC 失败: %v", err) - continue - } - if removed > 0 { - st := a.mediaStore.Stats() - log.Printf("[media] GC 清理 %d 条(释放 %d 字节),剩余 %v 条 / %v 字节", - removed, freed, st["count"], st["total_bytes"]) - } - case <-a.ctx.Done(): - return - } - } -} - // mediaDescribeLoop 给未描述的媒体补文字描述。 // -// 描述文本才是持久语义记忆:blob 会被容量 GC 淘汰,而描述留在 media 表里, -// 并经 mediaSummaryForEvent 写进 L0 事件、随归档进 L2 文档、经蒸馏进 L3 图库。 -// 于是「那张紫蓝红三色带图」在原始字节早已被清掉之后仍然可被检索到。 +// 描述文本才是持久语义记忆:它留在 media 表里,并经 mediaSummaryForEvent +// 写进 L0 事件、随归档进 L2 文档、经蒸馏进 L3 图库。 +// 于是「那张紫蓝红三色带图」仍然可被检索到。 func (a *Agent) mediaDescribeLoop() { defer func() { if r := recover(); r != nil { diff --git a/internal/agent/core/medialoop_test.go b/internal/agent/core/medialoop_test.go index e8bbe97..55ec8a0 100644 --- a/internal/agent/core/medialoop_test.go +++ b/internal/agent/core/medialoop_test.go @@ -6,116 +6,36 @@ import ( "testing" "time" + "gitcode.com/JianFeeeee/HomeAgent/internal/memory" "gitcode.com/JianFeeeee/HomeAgent/internal/memory/media" ) // 媒体后台循环测试。 // -// 两条循环都要能在「未启用」时干净退出——它们随 Agent.Start() 无条件启动, -// 若不早退就会在每个没配媒体存储的部署上空转一个 goroutine。 +// 媒体没有独立生命周期管理(没有 GC、没有引用计数):blob 是记忆块的内容, +// 块的创建/迁移/删除由记忆系统决定。这里只测描述循环与删除语义。 -func newMediaLoopAgent(t *testing.T, gcInterval, minAge time.Duration, describe bool) (*Agent, *media.Store) { +func newMediaLoopAgent(t *testing.T, describe bool) (*Agent, *media.Store) { t.Helper() dir := t.TempDir() - 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) } t.Cleanup(func() { ms.Close() }) a := &Agent{ - mediaStore: ms, - mediaGCInterval: gcInterval, - mediaGCMinAge: minAge, - mediaDescribe: describe, + mediaStore: ms, + mediaDescribe: describe, } a.ctx, a.cancel = context.WithCancel(context.Background()) t.Cleanup(a.cancel) return a, ms } -func TestMediaGCLoop_ExitsWhenDisabled(t *testing.T) { - // 两种禁用形态都必须立刻返回,不留空转 goroutine: - // 1. mediaStore 为 nil(媒体记忆整体关闭) - // 2. gcInterval 为 0(显式不自动清理) - cases := []struct { - name string - agent *Agent - }{ - {"nil store", func() *Agent { - a := &Agent{mediaGCInterval: time.Hour} - a.ctx, a.cancel = context.WithCancel(context.Background()) - return a - }()}, - {"zero interval", func() *Agent { - dir := t.TempDir() - ms, _ := media.New(filepath.Join(dir, "m"), 0) - t.Cleanup(func() { ms.Close() }) - a := &Agent{mediaStore: ms, mediaGCInterval: 0} - a.ctx, a.cancel = context.WithCancel(context.Background()) - return a - }()}, - } - - for _, c := range cases { - done := make(chan struct{}) - go func(a *Agent) { a.mediaGCLoop(); close(done) }(c.agent) - select { - case <-done: - case <-time.After(2 * time.Second): - t.Fatalf("%s: mediaGCLoop 未立即返回(会空转 goroutine)", c.name) - } - c.agent.cancel() - } -} - -func TestMediaGCLoop_ClearsOrphansKeepsReferenced(t *testing.T) { - a, ms := newMediaLoopAgent(t, 50*time.Millisecond, 0, false) - - kept, _ := ms.Put([]byte("referenced"), media.Item{MIME: "image/png"}) - if err := ms.AddRef(kept, media.OwnerContext, "evt-1"); err != nil { - t.Fatal(err) - } - orphan, _ := ms.Put([]byte("orphaned"), media.Item{MIME: "image/png"}) - - go a.mediaGCLoop() - - deadline := time.Now().Add(3 * time.Second) - for time.Now().Before(deadline) { - if _, err := ms.Stat(orphan); err != nil { - break // 孤儿已被清 - } - time.Sleep(20 * time.Millisecond) - } - a.cancel() - - if _, err := ms.Stat(orphan); err == nil { - t.Fatal("无引用项应被 GC 清理") - } - // 关键不变量:有引用的内容永不被删,否则记忆里的 digest 成悬空指针 - if _, err := ms.Get(kept); err != nil { - t.Fatalf("被引用的内容不该被清: %v", err) - } -} - -func TestMediaGCLoop_MinAgeProtectsFresh(t *testing.T) { - // minAge 保护刚 Put 还没来得及 AddRef 的项——它们 refcount 也是 0 - a, ms := newMediaLoopAgent(t, 30*time.Millisecond, time.Hour, false) - - d, _ := ms.Put([]byte("just-arrived"), media.Item{MIME: "image/png"}) - - go a.mediaGCLoop() - time.Sleep(400 * time.Millisecond) // 足够跑十几轮 GC - a.cancel() - - if _, err := ms.Get(d); err != nil { - t.Fatalf("minAge 内的新项不该被清: %v", err) - } -} - func TestMediaDescribeLoop_ExitsWhenDisabled(t *testing.T) { // describe 关闭时必须立即返回(默认就是关闭,绝大多数部署走这条路) - a, _ := newMediaLoopAgent(t, 0, 0, false) + a, _ := newMediaLoopAgent(t, false) done := make(chan struct{}) go func() { a.mediaDescribeLoop(); close(done) }() select { @@ -128,7 +48,7 @@ func TestMediaDescribeLoop_ExitsWhenDisabled(t *testing.T) { func TestDescribePendingMedia_NoProviderLeavesUndescribed(t *testing.T) { // 没有声明视觉能力的源时整轮跳过,且**不能**把项标记成已处理—— // 配置好之后必须还能被捡起来。 - a, ms := newMediaLoopAgent(t, 0, 0, true) + a, ms := newMediaLoopAgent(t, true) d, _ := ms.Put([]byte("img"), media.Item{MIME: "image/png"}) // providerManager 为 nil → resolveModalFallback 返回 nil @@ -150,7 +70,7 @@ func TestDescribePendingMedia_NoProviderLeavesUndescribed(t *testing.T) { func TestDescribePendingMedia_MarksUnsupportedKind(t *testing.T) { // video/other 大类没有可用的描述通道,必须标记掉, // 否则每轮 Pending 都把它取出来重试,永远卡住队列头部。 - a, ms := newMediaLoopAgent(t, 0, 0, true) + a, ms := newMediaLoopAgent(t, true) other, _ := ms.Put([]byte("blob"), media.Item{MIME: "application/octet-stream"}) a.describePendingMedia() @@ -162,8 +82,6 @@ func TestDescribePendingMedia_MarksUnsupportedKind(t *testing.T) { if it.DescribedBy != "unsupported" { t.Fatalf("不可描述的大类应被标记,实际 DescribedBy=%q", it.DescribedBy) } - // 标记后必须退出待描述队列,否则每轮都被取出来重试、永久占着 - // LIMIT 的名额,真正需要描述的新项永远轮不到。 pending, _ := ms.Pending(10) if len(pending) != 0 { t.Fatalf("标记 unsupported 后应退出待描述队列,仍有 %d 条", len(pending)) @@ -171,6 +89,41 @@ func TestDescribePendingMedia_MarksUnsupportedKind(t *testing.T) { } func TestDescribePendingMedia_EmptyQueueIsNoop(t *testing.T) { - a, _ := newMediaLoopAgent(t, 0, 0, true) + a, _ := newMediaLoopAgent(t, true) a.describePendingMedia() // 不该 panic } + +// TestForgetPayloads_DeletesOnlyUnheldContent 验证删除语义: +// 块被删除后内容才被删;仍被其它记忆块共享的 digest 不会被误删。 +func TestForgetPayloads_DeletesOnlyUnheldContent(t *testing.T) { + dir := t.TempDir() + ms, err := media.New(filepath.Join(dir, "media")) + if err != nil { + t.Fatal(err) + } + defer ms.Close() + + d1, _ := ms.Put([]byte("held-by-graph"), media.Item{MIME: "image/png"}) + d2, _ := ms.Put([]byte("being-forgotten"), media.Item{MIME: "image/png"}) + + g, err := memory.NewGraphDB(filepath.Join(dir, "graph.db")) + if err != nil { + t.Fatal(err) + } + defer g.Close() + if err := g.PutMemoryBlocks([]memory.MemoryBlock{ + {ID: "blk_keep", Modality: memory.BlockImage, PayloadDigest: d1}, + }); err != nil { + t.Fatal(err) + } + + a := &Agent{mediaStore: ms, memory: g} + a.forgetPayloads([]string{d1, d2}) + + if _, err := ms.Stat(d1); err != nil { + t.Fatalf("仍被 L3 块持有的内容不该被删: %v", err) + } + if _, err := ms.Stat(d2); err == nil { + t.Fatal("无人持有的内容应被删除") + } +} diff --git a/internal/agent/core/mediaref.go b/internal/agent/core/mediaref.go index 1331456..fa941df 100644 --- a/internal/agent/core/mediaref.go +++ b/internal/agent/core/mediaref.go @@ -4,12 +4,60 @@ import ( "fmt" "log" "strings" + "sync/atomic" "time" agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api" + "gitcode.com/JianFeeeee/HomeAgent/internal/memory" "gitcode.com/JianFeeeee/HomeAgent/internal/memory/media" ) +// blockSeq 保证块 ID 全局唯一(Graph memory_blocks 以 id 为主键)。 +var blockSeq int64 + +func newBlockID() string { + return fmt.Sprintf("blk_%d_%d", time.Now().UnixNano(), atomic.AddInt64(&blockSeq, 1)) +} + +// blockModalityOf 把 CAS 媒体大类映射为一等记忆块模态。 +func blockModalityOf(k media.Kind) memory.BlockModality { + switch k { + case media.KindImage: + return memory.BlockImage + case media.KindVideo: + return memory.BlockVideo + case media.KindAudio: + return memory.BlockAudio + default: + return memory.BlockText + } +} + +// blockFromDigest 把一份已入库媒体变成一等记忆块。 +// 块携带 digest/向量/fingerprint;CAS 只提供字节与元数据,不参与生命周期。 +func (a *Agent) blockFromDigest(digest string) (memory.MemoryBlock, bool) { + if a.mediaStore == nil || digest == "" { + return memory.MemoryBlock{}, false + } + it, err := a.mediaStore.Stat(digest) + if err != nil || it == nil { + return memory.MemoryBlock{}, false + } + return memory.MemoryBlock{ + ID: newBlockID(), + Modality: blockModalityOf(it.Kind), + PayloadDigest: it.Digest, + MIME: it.MIME, + Size: it.Size, + Width: it.Width, + Height: it.Height, + Vector: it.Vec, + Fingerprint: it.VecModel, + Tool: it.Tool, + CreatedAt: it.FirstSeen, + }, true +} + // 媒体记忆接线:把对话里出现的图片/音频落进内容寻址存储(CAS), // 并让 L0 的 ContextEvent 记住它们的 digest。 // @@ -94,7 +142,7 @@ func (a *Agent) embedMediaOnIngest(digest, mime string, data []byte) { // stageMediaDigests 累积本轮捕获的 digest,等 ContextEvent 建好后一起挂上。 // -// 为何要缓存而不是当场 AddRef:媒体在 process() 执行期间被捕获,而承载它的 +// 为何要缓存而不是当场建块:媒体在 process() 执行期间被捕获,而承载它的 // ContextEvent 要等 process() 返回后才 Append——此刻还没有 owner_id。 // 与既有的 a.pendingMedia 同一手法(都在 a.mu 保护下)。 func (a *Agent) stageMediaDigests(digests ...string) { @@ -114,12 +162,10 @@ func (a *Agent) drainMediaDigests() []string { return out } -// bindEventMedia 把 digest 列表登记到某个 ContextEvent 上。 +// bindEventMedia 把本轮捕获的媒体变成一等记忆块,直接挂到 ContextEvent 上。 // -// 双向落地:evt.Media 让事件自己记得引了哪些媒体(随 context.json 持久化), -// media_refs 表让 CAS 侧知道谁在引用(GC 据此判断能不能清)。 -// 两边都写才闭环——只写一边的话,要么 GC 会误删仍被记忆引用的内容, -// 要么孤儿永远清不掉。 +// 块存储在事件自身(随 context.json 持久化),不再写 media_refs: +// 存活与否由“三层记忆块是否持有这个 digest”决定,不维护引用账本。 func (a *Agent) bindEventMedia(evt *ContextEvent, digests []string) { if a.mediaStore == nil || evt == nil || len(digests) == 0 { return @@ -128,26 +174,27 @@ func (a *Agent) bindEventMedia(evt *ContextEvent, digests []string) { evt.ID = newEventID() } for _, d := range digests { - if err := a.mediaStore.AddRef(d, media.OwnerContext, evt.ID); err != nil { - log.Printf("[media] AddRef 失败 (%s → %s): %v", shortDigest(d), evt.ID, err) + b, ok := a.blockFromDigest(d) + if !ok { + log.Printf("[media] 块构造失败 (%s)", shortDigest(d)) continue } - evt.Media = append(evt.Media, d) + evt.Blocks = append(evt.Blocks, b) } } // mediaSummaryForEvent 给已有描述的媒体生成一行文字,供写进 ContextEvent.Input。 // // 这是方案 C 的落点:**描述文本才是持久语义记忆,blob 只是缓存**。 -// blob 可能被容量 GC 淘汰,但描述会一直留在 L0/L2/L3 的文本里, +// blob 可能已被删除,但描述会一直留在 L0/L2/L3 的文本里, // 让"那张紫蓝红三色带图"在几个月后仍然可被检索到。 -func (a *Agent) mediaSummaryForEvent(digests []string) string { - if a.mediaStore == nil || len(digests) == 0 { +func (a *Agent) mediaSummaryForEvent(blocks []memory.MemoryBlock) string { + if a.mediaStore == nil || len(blocks) == 0 { return "" } var lines []string - for _, d := range digests { - if line := a.mediaMarkerLine(d); line != "" { + for _, b := range blocks { + if line := a.mediaMarkerLine(b.PayloadDigest); line != "" { lines = append(lines, line) } } @@ -163,7 +210,7 @@ func (a *Agent) mediaSummaryForEvent(digests []string) string { // mediaContextForSentences 各拼一份,改动截断长度或分隔符时只改一处, // 另一处写出的标记就再也解析不回来——而解析失败是静默的(引用挂不上)。 // -// 查不到返回空串:媒体可能已被容量 GC 淘汰,此时不该造出一条指向虚无的标记。 +// 查不到返回空串:媒体可能已被删除,此时不该造出一条指向虚无的标记。 func (a *Agent) mediaMarkerLine(digest string) string { if a.mediaStore == nil { return "" diff --git a/internal/agent/core/mediaref_test.go b/internal/agent/core/mediaref_test.go index 875b0dc..ed8f2ae 100644 --- a/internal/agent/core/mediaref_test.go +++ b/internal/agent/core/mediaref_test.go @@ -24,7 +24,7 @@ import ( func newTestAgentWithMedia(t *testing.T) (*Agent, *media.Store) { t.Helper() dir := t.TempDir() - 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) } @@ -35,7 +35,6 @@ func newTestAgentWithMedia(t *testing.T) (*Agent, *media.Store) { mediaStore: ms, context: NewRelevanceContext(filepath.Join(dir, "context.json"), emb), } - a.context.SetMediaStore(ms) return a, ms } @@ -104,10 +103,10 @@ func TestCaptureBlockMedia_NilStoreIsNoop(t *testing.T) { // bindEventMedia 对 nil store 也必须安全 evt := &ContextEvent{} a.bindEventMedia(evt, []string{"deadbeef"}) - if len(evt.Media) != 0 || evt.ID != "" { + if len(evt.Blocks) != 0 || evt.ID != "" { t.Fatalf("nil store 时不该改动事件: %+v", evt) } - if s := a.mediaSummaryForEvent([]string{"deadbeef"}); s != "" { + if s := a.mediaSummaryForEvent(nil); s != "" { t.Fatalf("nil store 时摘要应为空,得到 %q", s) } } @@ -152,7 +151,7 @@ func TestStageDrainMediaDigests(t *testing.T) { } } -func TestBindEventMedia_CreatesIDAndRefs(t *testing.T) { +func TestBindEventMedia_CreatesBlocks(t *testing.T) { a, ms := newTestAgentWithMedia(t) d, err := ms.Put([]byte("img"), media.Item{MIME: "image/png"}) @@ -166,17 +165,11 @@ func TestBindEventMedia_CreatesIDAndRefs(t *testing.T) { if evt.ID == "" { t.Fatal("应懒生成事件 ID") } - if len(evt.Media) != 1 || evt.Media[0] != d { - t.Fatalf("事件应记住 digest: %+v", evt.Media) + if len(evt.Blocks) != 1 || evt.Blocks[0].PayloadDigest != d { + t.Fatalf("事件应持有一等记忆块: %+v", evt.Blocks) } - // 双向落地:CAS 侧也要知道谁在引用,否则 GC 会误删 - it, _ := ms.Stat(d) - if it.RefCount != 1 { - t.Fatalf("引用计数应为 1,实际 %d", it.RefCount) - } - refs, _ := ms.Refs(media.OwnerContext, evt.ID) - if len(refs) != 1 { - t.Fatalf("media_refs 应有 1 条,实际 %d", len(refs)) + if evt.Blocks[0].Modality != memory.BlockImage || evt.Blocks[0].MIME != "image/png" { + t.Fatalf("块元数据不对: %+v", evt.Blocks[0]) } } @@ -196,12 +189,16 @@ func TestMediaSummary_DescriptionIsThePersistentMemory(t *testing.T) { a, ms := newTestAgentWithMedia(t) d, _ := ms.Put([]byte("img"), media.Item{MIME: "image/png"}) - if s := a.mediaSummaryForEvent([]string{d}); s == "" { + b, ok := a.blockFromDigest(d) + if !ok { + t.Fatal("blockFromDigest 失败") + } + if s := a.mediaSummaryForEvent([]memory.MemoryBlock{b}); s == "" { t.Fatal("未描述项也应产出一行(标注未描述)") } ms.Describe(d, "一张紫蓝红三色带图", "visionllm") - s := a.mediaSummaryForEvent([]string{d}) + s := a.mediaSummaryForEvent([]memory.MemoryBlock{b}) if s == "" { t.Fatal("应产出摘要") } @@ -213,97 +210,15 @@ func TestMediaSummary_DescriptionIsThePersistentMemory(t *testing.T) { } } -func TestPrune_TransfersMediaRefsToDocument(t *testing.T) { - // L0→L2 归档:媒体引用从 context 事件转到归档文档, - // 且转移期间内容必须始终可读(先挂后销,不留归零窗口)。 - dir := t.TempDir() - ms, err := media.New(filepath.Join(dir, "media"), 0) - if err != nil { - t.Fatal(err) - } - defer ms.Close() - - emb := memory.NewStaticEmbedder() - docStore := document.NewStore(filepath.Join(dir, "docs")) - if err := docStore.Start(); err != nil { - t.Fatal(err) - } - rc := NewRelevanceContext(filepath.Join(dir, "context.json"), emb) - rc.SetMediaStore(ms) - - a := &Agent{mediaStore: ms, context: rc} - - // 造一张被引用的图,挂到一条会被淘汰的老事件上 - payload := []byte("archived-image") - d, _ := ms.Put(payload, media.Item{MIME: "image/png"}) - oldEvt := ContextEvent{ - Timestamp: time.Now().Add(-time.Hour), - Source: "qq", - Input: "很久以前的一张图", - } - a.bindEventMedia(&oldEvt, []string{d}) - oldEvtID := oldEvt.ID - rc.Append(oldEvt) - - // 再塞满 12 条新事件,逼 Prune 把老事件淘汰 - // (Prune 保护最近 10 条,topK 传 5 使候选全部进归档) - for i := 0; i < 12; i++ { - rc.Append(ContextEvent{ - Timestamp: time.Now().Add(time.Duration(i) * time.Second), - Source: "qq", - Input: "无关内容", - }) - } - - archived := rc.Prune("完全不相关的查询", 5, docStore) - if archived == 0 { - t.Fatal("应有事件被归档") - } - - // 关键断言:内容仍可读(引用被转走而非归零后被清) - got, err := ms.Get(d) - if err != nil { - t.Fatalf("归档后内容应仍可读: %v", err) - } - if string(got) != string(payload) { - t.Fatal("内容被改") - } - - it, err := ms.Stat(d) - if err != nil { - t.Fatal(err) - } - if it.RefCount < 1 { - t.Fatalf("引用应转移而非归零,实际 refcount=%d", it.RefCount) - } - // 原 context 引用应已注销 - if refs, _ := ms.Refs(media.OwnerContext, oldEvtID); len(refs) != 0 { - t.Fatalf("原事件引用应已注销,仍有 %d 条", len(refs)) - } - // 应挂到某个 document owner 上 - var docOwned bool - docs := docStore.RecentDocs(10) - for _, doc := range docs { - if refs, _ := ms.Refs(media.OwnerDocument, doc.ID); len(refs) > 0 { - docOwned = true - break - } - } - if !docOwned { - t.Fatal("引用应已挂到归档文档上") - } -} - func TestPrune_NilMediaStoreStillArchives(t *testing.T) { // 媒体存储未启用时归档链路必须照常工作 dir := t.TempDir() emb := memory.NewStaticEmbedder() - docStore := document.NewStore(filepath.Join(dir, "docs")) + docStore := document.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords) if err := docStore.Start(); err != nil { t.Fatal(err) } rc := NewRelevanceContext(filepath.Join(dir, "context.json"), emb) - // 刻意不 SetMediaStore for i := 0; i < 15; i++ { rc.Append(ContextEvent{ @@ -317,13 +232,13 @@ func TestPrune_NilMediaStoreStillArchives(t *testing.T) { } } -func TestContextEvent_MediaFieldRoundTrip(t *testing.T) { - // context.json 加字段必须向后兼容:存量文件读回来 Media 为空、ID 为空, +func TestContextEvent_BlocksFieldRoundTrip(t *testing.T) { + // context.json 加字段必须向后兼容:存量文件读回来 Blocks 为空、ID 为空, // 不影响任何既有行为。 dir := t.TempDir() path := filepath.Join(dir, "context.json") - // 写一份"存量格式"(无 id / media 字段) + // 写一份"存量格式"(无 id / blocks 字段) legacy := `[{"timestamp":"2026-09-04T10:00:00Z","source":"qq","input":"老数据","response":"回复"}]` if err := os.WriteFile(path, []byte(legacy), 0644); err != nil { t.Fatal(err) @@ -335,8 +250,8 @@ func TestContextEvent_MediaFieldRoundTrip(t *testing.T) { t.Fatalf("应读回 1 条,实际 %d", rc.Len()) } - // 新写入带媒体的事件,再读回 - ms, err := media.New(filepath.Join(dir, "media"), 0) + // 新写入带记忆块的事件,再读回 + ms, err := media.New(filepath.Join(dir, "media")) if err != nil { t.Fatal(err) } @@ -352,4 +267,71 @@ func TestContextEvent_MediaFieldRoundTrip(t *testing.T) { if rc2.Len() != 2 { t.Fatalf("应有 2 条,实际 %d", rc2.Len()) } + var persisted int + for _, e := range rc2.Recent(10) { + persisted += len(e.Blocks) + } + if persisted != 1 { + t.Fatalf("块应随 context.json 持久化,实际 %d 个", persisted) + } +} + +func TestPruneMigratesBlocksToDocument(t *testing.T) { + // 一等记忆块的 L0→L2 迁移:块随事件离开 Context、进入 Document, + // 身份(ID/模态/digest/向量)原样保留;同一块不能同时留在两层。 + // 这条路径不依赖 media_refs/ref_count。 + dir := t.TempDir() + emb := memory.NewStaticEmbedder() + docStore := document.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords) + if err := docStore.Start(); err != nil { + t.Fatal(err) + } + rc := NewRelevanceContext(filepath.Join(dir, "context.json"), emb) + + block := memory.MemoryBlock{ + ID: "blk_migrate_1", Modality: memory.BlockImage, + PayloadDigest: "deadbeef", MIME: "image/png", Size: 42, + Vector: []float64{0.1, 0.2, 0.3}, Fingerprint: "qwen:test", + } + rc.Append(ContextEvent{ + Timestamp: time.Now().Add(-time.Hour), + Source: "qq", Input: "很久以前的一张图", + Blocks: []memory.MemoryBlock{block}, + }) + for i := 0; i < 12; i++ { + rc.Append(ContextEvent{ + Timestamp: time.Now().Add(time.Duration(i) * time.Second), + Source: "qq", Input: "无关内容", + }) + } + + if n := rc.Prune("完全不相关的查询", 5, docStore); n == 0 { + t.Fatal("应有事件被归档") + } + + // 块应已到达 L2,且身份不变。 + var found *document.Doc + for _, d := range docStore.RecentDocs(20) { + if len(d.Blocks) > 0 { + found = d + break + } + } + if found == nil { + t.Fatal("归档文档应持有一等记忆块") + } + if len(found.Blocks) != 1 { + t.Fatalf("文档应有 1 个块,实际 %d", len(found.Blocks)) + } + got := found.Blocks[0] + if got.ID != block.ID || got.Modality != block.Modality || got.PayloadDigest != block.PayloadDigest || got.Fingerprint != block.Fingerprint || len(got.Vector) != len(block.Vector) { + t.Fatalf("块身份应原样迁移:\n got %+v\n want %+v", got, block) + } + + // 同一块不能同时留在 L0。 + for _, e := range rc.Recent(100) { + if len(e.Blocks) > 0 { + t.Fatalf("块仍留在 L0(同一块同时存在于两层): %+v", e.Blocks) + } + } } diff --git a/internal/agent/core/toolcall.go b/internal/agent/core/toolcall.go index 47bdabf..d5f3102 100644 --- a/internal/agent/core/toolcall.go +++ b/internal/agent/core/toolcall.go @@ -150,11 +150,11 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall) string { } parts = append(parts, fmt.Sprintf("- %s →(%s)→ %s", r.SourceName, r.RelationType, r.TargetName)) } - // 命中的关系若挂着媒体,把媒体说明附在结果末尾。 + // 命中的关系若挂着媒体块,把媒体说明附在结果末尾。 // // 关系行只有实体名和关系类型,看不出"这条记忆当时还带了一张图"。 - // 媒体挂在句子上(graph_sentence owner),需经关系→句子→media_refs - // 反查。不附上的后果:agent 显式查了图记忆,却仍然不知道有图。 + // 媒体块以结构边与句子相连,需经关系→句子反查。 + // 不附上的后果:agent 显式查了图记忆,却仍然不知道有图。 if mc := a.mediaContextForRelations(result.Relations); mc != "" { parts = append(parts, "", "关联媒体:", mc) } @@ -192,7 +192,7 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall) string { } // 模型显式关联的媒体:标记由内核补进句子文本,模型不必知道格式。 // 没有 sentence_text 时 sentenceWithMediaMarkers 会用标记本身 - // 充当句子——媒体必须有句子落点,否则 media_refs 无从挂起。 + // 充当句子——媒体必须有句子落点,否则块边无法建立。 if digests := getStringSlice(m, "media_digests"); len(digests) > 0 { t.SentenceText = a.sentenceWithMediaMarkers(t.SentenceText, digests) } @@ -206,7 +206,7 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall) string { } // remember 工具是用户/模型显式写入,不涉及归档删除, // 因此不需要 mediaBound——没有旧引用要释放。 - ec, rc, mb, err := a.commitTriplesWithMedia(triples, string(a.id), 0) + ec, rc, mb, err := a.commitTriplesWithMedia(triples, string(a.id), 0, nil) if err != nil { return fmt.Sprintf("记忆写入失败: %v", err) } @@ -576,15 +576,17 @@ func (a *Agent) executeDocTool(tc agentAPI.ToolCall) string { // Summary+Content 计算,标记进不去正文就检索不到这份媒体。 mediaDigests := a.resolveMediaDigests(getStringSlice(tc.Arguments, "media_digests")) doc.Content = a.sentenceWithMediaMarkers(doc.Content, mediaDigests) + for _, d := range mediaDigests { + if b, ok := a.blockFromDigest(d); ok { + doc.Blocks = append(doc.Blocks, b) + } + } if err := a.docStore.Insert(doc); err != nil { return fmt.Sprintf("文档写入失败: %v", err) } - // 引用必须在拿到 doc.ID 之后挂:owner_id 就是文档 id。 - // 不挂的后果是这些媒体在文档里可见却无主,下一轮 GC 会把它们清掉。 - bound := a.bindDocMedia(doc.ID, mediaDigests) - if bound > 0 { - return fmt.Sprintf("文档已提交 (id: %s, 摘要: %s, 关联 %d 份媒体)", doc.ID, summary, bound) + if n := len(doc.Blocks); n > 0 { + return fmt.Sprintf("文档已提交 (id: %s, 摘要: %s, 关联 %d 份媒体)", doc.ID, summary, n) } return fmt.Sprintf("文档已提交 (id: %s, 摘要: %s)", doc.ID, summary) diff --git a/internal/agent/core/tooldefs.go b/internal/agent/core/tooldefs.go index d2c5512..444d9d0 100644 --- a/internal/agent/core/tooldefs.go +++ b/internal/agent/core/tooldefs.go @@ -19,7 +19,7 @@ func (a *Agent) buildMemoryContext(input string, maxTokens int) string { // 不做这一步的后果:媒体描述进了 L3,agent 却拿不出来。图库句子里 // 写着 [image/png a1b2c3d4e5f6] 这样的短标记,但没有任何东西告诉 // 模型那份内容是否还在、能否重新查看——描述永存而 blob 可能已被 - // 容量 GC 淘汰,两者状态不同,必须显式告知。 + // 删除,两者状态不同,必须显式告知。 // // 注意不能直接用 injected.Relations:BuildContext 刻意把它置为 nil //(自动注入只给实体索引以省 token,细节留给 memory_recall)。 diff --git a/internal/config/registry.go b/internal/config/registry.go index 2758858..24bd034 100644 --- a/internal/config/registry.go +++ b/internal/config/registry.go @@ -649,14 +649,11 @@ func (r *ConfigRegistry) seedCoreDefs(dataDir string) { reg(ConfigDef{Key: "core.memory.graph", Default: filepath.Join(dataDir, "memory", "graph.db"), Type: "string", DisplayName: "图数据库路径", Description: "长期记忆(图数据库)存储路径", Category: "paths"}) reg(ConfigDef{Key: "core.memory.text", Default: filepath.Join(dataDir, "memory", "text"), Type: "string", DisplayName: "文本记忆路径", Description: "短期文本记忆存储目录", Category: "paths"}) reg(ConfigDef{Key: "core.memory.documents", Default: filepath.Join(dataDir, "memory", "documents"), Type: "string", DisplayName: "文档记忆路径", Description: "文档记忆存储目录", Category: "paths"}) - reg(ConfigDef{Key: "core.memory.media.enabled", Default: "true", Type: "bool", DisplayName: "媒体记忆", Description: "把对话里出现的图片/音频按内容摘要(sha256)落盘去重,记忆各层只记 digest。关闭后媒体仅在当前对话内可见,下一轮起只剩路径或 alt 文本", Category: "memory"}) + reg(ConfigDef{Key: "core.memory.media.enabled", Default: "true", Type: "bool", DisplayName: "媒体记忆", Description: "把对话里出现的图片/音频变成一等记忆块,内容按 sha256 落盘去重。关闭后媒体仅在当前对话内可见,下一轮起只剩路径或 alt 文本", Category: "memory"}) reg(ConfigDef{Key: "core.memory.media.dir", Default: filepath.Join(dataDir, "memory", "media"), Type: "string", DisplayName: "媒体存储路径", Description: "媒体内容寻址存储目录(内含 media.db 与 blobs/)", Category: "paths"}) - reg(ConfigDef{Key: "core.memory.media.max_mb", Default: "2048", Type: "int", DisplayName: "媒体容量上限(MB)", Description: "超限时按最后访问时间淘汰无引用的媒体;被记忆引用的内容即使超限也不会删除(宁可超限也不断引用)。描述文本不受此限,淘汰后仍可检索", Category: "memory"}) - reg(ConfigDef{Key: "core.memory.media.gc_interval", Default: "6h", Type: "duration", DisplayName: "媒体 GC 间隔", Description: "清理无引用媒体的周期;0 表示不自动清理", Category: "memory"}) - reg(ConfigDef{Key: "core.memory.media.gc_min_age", Default: "1h", Type: "duration", DisplayName: "媒体 GC 保护期", Description: "新入库媒体在此时长内不被清理。刚落盘还没来得及挂到记忆上的项引用计数也是 0,靠这个保护期避免被误删", Category: "memory"}) - reg(ConfigDef{Key: "core.memory.media.describe_on_ingest", Default: "false", Type: "bool", DisplayName: "自动描述媒体", Description: "后台用视觉/音频模型给未描述的媒体生成文字描述。**描述文本才是持久语义记忆**——blob 会被容量 GC 淘汰,描述会随记忆各层一直留存并可检索。代价是消耗视觉模型配额(单张图实测约 10s),故默认关闭;开启后每 30s 最多处理 4 条,不跟对话抢额度", Category: "memory"}) - reg(ConfigDef{Key: "core.memory.media.clip_model_dir", Default: "", Type: "string", DisplayName: "CLIP ONNX 模型目录", Description: "多模态嵌入的 CLIP ONNX 模型目录(含 text.onnx、vision.onnx、clip_config.json、tokenizer.json、merges.txt)。multimodal_space.type=onnx 时从此目录加载内嵌模型;留空且 type=onnx 时禁用多模态向量检索。修改后需重启生效。", Category: "memory"}) - reg(ConfigDef{Key: "core.memory.multimodal_space.type", Default: "", Type: "string", DisplayName: "多模态向量空间类型", Description: "可插拔多模态向量空间的实现类型。onnx = 内嵌 ONNX 模型(CLIP 等);http = 外部向量 API 服务。留空禁用多模态向量检索,只保留 fastText 文本路径。两条路径共享同一套 L0/L2/L3 向量缓存与检索基础设施。", Category: "memory"}) + reg(ConfigDef{Key: "core.memory.media.describe_on_ingest", Default: "false", Type: "bool", DisplayName: "自动描述媒体", Description: "后台用视觉/音频模型给未描述的媒体生成文字描述。描述文本是可检索的语义入口,供各层记忆复用。代价是消耗视觉模型配额(单张图实测约 10s),故默认关闭;开启后每 30s 最多处理 4 条,不跟对话抢额度", Category: "memory"}) + reg(ConfigDef{Key: "core.memory.multimodal_space.type", Default: "", Type: "string", DisplayName: "多模态向量空间类型", Description: "可插拔多模态向量空间的实现类型。onnx = 内嵌 Qwen3-VL 完整图文共享模型;http = 外部向量 API 服务。留空禁用多模态向量检索,只保留 fastText 文本路径。两条路径共享同一套 L0/L2/L3 向量缓存与检索基础设施。", Category: "memory"}) + reg(ConfigDef{Key: "core.memory.multimodal_space.onnx.model_dir", Default: "", Type: "string", DisplayName: "Qwen 多模态 ONNX 目录", Description: "完整 Qwen3-VL 图文共享向量模型目录(含 TokenEmbedding.onnx、Transformer.onnx、Vision.onnx、外部权重、tokenizer.json、embed_config.json)。type=onnx 时必填,修改后需重启生效。", Category: "memory"}) reg(ConfigDef{Key: "core.memory.multimodal_space.http.endpoint", Default: "", Type: "string", DisplayName: "外部向量 API 端点", Description: "外部多模态向量服务的 HTTP 端点 URL(POST,接受 modality/side/text/data/mime,返回 embedding)。type=http 时必填。", Category: "memory"}) reg(ConfigDef{Key: "core.memory.multimodal_space.http.api_key", Default: "", Type: "string", DisplayName: "外部向量 API 密钥", Description: "外部多模态向量服务的 API 密钥(作为 Bearer token 发送)。可选。", Category: "memory"}) reg(ConfigDef{Key: "core.memory.multimodal_space.http.model", Default: "", Type: "string", DisplayName: "外部向量模型标识", Description: "外部向量服务使用的模型名称,作为 vec_model 持久化。模型切换后历史向量会自动重算。", Category: "memory"}) diff --git a/internal/memory/block.go b/internal/memory/block.go index 415f3b1..3627642 100644 --- a/internal/memory/block.go +++ b/internal/memory/block.go @@ -217,3 +217,40 @@ func (g *GraphDB) MemoryBlockEdges() ([]MemoryBlockEdge, error) { } return edges, rows.Err() } + +// BlocksForNode 返回与某个图节点通过任意边相连的一等记忆块。 +// 例:sentence --contains--> block;entity --depicts--> block。 +func (g *GraphDB) BlocksForNode(nodeKind, nodeID string) ([]MemoryBlock, error) { + g.mu.RLock() + defer g.mu.RUnlock() + rows, err := g.db.Query(`SELECT b.id, b.modality, b.text_content, b.payload_digest, + b.mime, b.size, b.width, b.height, b.vector, b.fingerprint, b.source, b.tool, + b.created_at, b.updated_at + FROM memory_block_edges e + JOIN memory_blocks b ON ( + (e.source_kind = 'block' AND e.source_id = b.id AND e.target_kind = ? AND e.target_id = ?) + OR (e.target_kind = 'block' AND e.target_id = b.id AND e.source_kind = ? AND e.source_id = ?)) + ORDER BY b.created_at, b.id`, nodeKind, nodeID, nodeKind, nodeID) + if err != nil { + return nil, err + } + defer rows.Close() + var blocks []MemoryBlock + for rows.Next() { + var block MemoryBlock + var vectorJSON string + if err := rows.Scan(&block.ID, &block.Modality, &block.Text, &block.PayloadDigest, + &block.MIME, &block.Size, &block.Width, &block.Height, &vectorJSON, + &block.Fingerprint, &block.Source, &block.Tool, &block.CreatedAt, + &block.UpdatedAt); err != nil { + return nil, err + } + if vectorJSON != "" && vectorJSON != "null" { + if err := json.Unmarshal([]byte(vectorJSON), &block.Vector); err != nil { + return nil, fmt.Errorf("decode memory block %s vector: %w", block.ID, err) + } + } + blocks = append(blocks, block) + } + return blocks, rows.Err() +} diff --git a/internal/memory/document/document.go b/internal/memory/document/document.go index a004256..672d6db 100644 --- a/internal/memory/document/document.go +++ b/internal/memory/document/document.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "log" + "math" "os" "path/filepath" "sort" @@ -13,6 +14,7 @@ import ( "gitcode.com/JianFeeeee/HomeAgent/internal/memory" "gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector" + "gitcode.com/JianFeeeee/HomeAgent/internal/tfidf" ) // ChannelCleaner 按事件来源查找输入通道的 Cleaner 函数。 @@ -21,51 +23,97 @@ type ChannelCleaner func(source string) func(string) string // Doc — 记忆文档:由上下文提炼而来 type Doc struct { - ID string `json:"id"` - Summary string `json:"summary"` - Content string `json:"content"` - Tags []string `json:"tags"` - Entities []string `json:"entities"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - Source string `json:"source"` // context / graph / manual - Meta map[string]string `json:"meta,omitempty"` - AccessCount int `json:"access_count"` // 访问次数 - LastAccess time.Time `json:"last_access"` // 最后访问时间 - // Media 是这篇 L2 文档原生持有的多模态块 digest。坐标保存在 media.Store, - // 文档只持引用;被 Consume 召回到 L0 或归档到 L3 时必须随文本一起迁移。 - Media []string `json:"media,omitempty"` - Vector vector.Vector `json:"vector,omitempty"` // 预计算文本向量(TF-IDF 稀疏,与 context 同空间) - DenseVec []float64 `json:"dense_vec,omitempty"` // 多模态稠密向量(与媒体共享空间) + ID string `json:"id"` + Summary string `json:"summary"` + Content string `json:"content"` + Tags []string `json:"tags"` + Entities []string `json:"entities"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Source string `json:"source"` + Meta map[string]string `json:"meta,omitempty"` + AccessCount int `json:"access_count"` + LastAccess time.Time `json:"last_access"` + Blocks []memory.MemoryBlock `json:"blocks,omitempty"` // 一等记忆块(text/image/video/audio) + Vector tfidf.Vector `json:"vector,omitempty"` // TF-IDF 稀疏向量(fallback 时持久化) + DenseVec []float64 `json:"dense_vec,omitempty"` // 多模态稠密向量(主路径) } -// Store — 文档记忆存储,包含向量索引 +// Store — 文档记忆存储。 +// 主路径:denseSpace(稠密多模态向量,与媒体共享空间)。 +// Fallback:tfidf(TF-IDF 倒排索引,仅稠密空间不可用时加载)。 type Store struct { - dir string - vec *vector.Store - veczer *vector.TFIDFVectorizer - mu sync.RWMutex - - docs map[string]*Doc - summaries []string // 用于训练向量化器,最大 10000 条 - vectorizer vector.Vectorizer // 可选:与 context 同空间的向量化器 - denseSpace vector.MultimodalEmbedder // 可选:稠密多模态向量空间 - + dir string + mu sync.RWMutex + docs map[string]*Doc dirty bool + + // fallback 路径(仅稠密空间不可用时加载) + tfidfEmb *tfidf.Embedder + tfidfIdx *tfidf.SearchableIndex + trainTexts []string // 缓存训练文本,延迟训练 + tfidfOnce sync.Once + + // 主路径 + denseSpace vector.MultimodalEmbedder } -// SetDenseSpace 设置稠密多模态向量空间。配置后文档检索使用稠密余弦(brute-force), -// 与媒体检索共享同一向量空间,实现真正的统一跨模态检索。 +const maxSummaries = 10000 + +// NewStore 创建文档存储。tokenizer 由外层注入(如 jieba),核心不直接依赖分词库。 +func NewStore(dir string, tokenizer tfidf.Tokenizer) *Store { + return &Store{ + dir: dir, + docs: make(map[string]*Doc), + // tfidf 延迟初始化:只在需要 fallback 时创建 + tfidfEmb: tfidf.NewEmbedder(tokenizer, 4096), + } +} + +// ensureTFIDF 延迟初始化 TF-IDF 索引(仅 fallback 路径)。 +// 调用方已持有 s.mu。 +func (s *Store) ensureTFIDF() { + s.tfidfOnce.Do(func() { + s.tfidfIdx = tfidf.NewSearchableIndex(s.tfidfEmb) + // 延迟训练:用缓存的文本建立索引 + texts := make(map[string]string, len(s.trainTexts)/2) + for i := 0; i+1 < len(s.trainTexts); i += 2 { + texts[s.trainTexts[i]] = s.trainTexts[i+1] + } + s.tfidfIdx.Train(texts) + s.trainTexts = nil // 释放缓存 + s.tfidfEmb.Train(func() []string { + out := make([]string, 0, len(texts)) + for _, t := range texts { + out = append(out, t) + } + return out + }()) + log.Printf("[document memory] tfidf fallback loaded: %d docs", len(texts)) + }) +} + +func (s *Store) Start() error { + if err := os.MkdirAll(s.dir, 0755); err != nil { + return fmt.Errorf("document store dir: %w", err) + } + if err := s.loadAll(); err != nil { + log.Printf("[document memory] load error: %v", err) + } + log.Printf("[document memory] started with %d docs", len(s.docs)) + return nil +} + +func (s *Store) Stop() { s.flush() } + +// SetDenseSpace 设置稠密多模态向量空间(主路径)。 func (s *Store) SetDenseSpace(ds vector.MultimodalEmbedder) { s.mu.Lock() defer s.mu.Unlock() s.denseSpace = ds } -// buildDenseIndex 为所有文档计算稠密向量并建立 brute-force 索引。 -// 在启动时或配置变更后调用一次。492 篇文档 brute-force ~300ms,可接受。 -// BuildDenseIndex 为所有文档计算稠密向量并建立 brute-force 索引。 -// 在启动时或配置变更后调用一次。492 篇文档 brute-force ~300ms,可接受。 +// BuildDenseIndex 为所有文档计算稠密向量。 func (s *Store) BuildDenseIndex(ds vector.MultimodalEmbedder) { if ds == nil || !ds.Loaded() { return @@ -76,7 +124,7 @@ func (s *Store) BuildDenseIndex(ds vector.MultimodalEmbedder) { count := 0 for _, doc := range s.docs { if doc.DenseVec != nil && len(doc.DenseVec) == ds.Dim() { - continue // 已有向量,跳过 + continue } text := doc.Summary + " " + doc.Content vec, err := ds.VectorizeDense(text) @@ -90,8 +138,225 @@ func (s *Store) BuildDenseIndex(ds vector.MultimodalEmbedder) { log.Printf("[document memory] dense index built: %d new vectors", count) } -// denseSearchScored 对所有文档做 brute-force 余弦检索,返回 topK 个最相似的候选。 -// 仅在 denseSpace 配置后使用;未配置时退化到 TF-IDF 倒排检索。 +// Reindex 重建 TF-IDF 索引(fallback 路径变更时调用)。 +func (s *Store) Reindex() { + s.mu.Lock() + defer s.mu.Unlock() + s.tfidfOnce = sync.Once{} // 重置延迟初始化 + texts := make(map[string]string, len(s.docs)) + for _, doc := range s.docs { + texts[doc.ID] = doc.Summary + " " + doc.Content + } + // 缓存文本供 ensureTFIDF 延迟训练 + s.trainTexts = make([]string, 0, len(texts)*2) + for id, t := range texts { + s.trainTexts = append(s.trainTexts, id, t) + } + s.ensureTFIDF() +} + +func (s *Store) Insert(doc *Doc) error { + s.mu.Lock() + defer s.mu.Unlock() + + if doc.ID == "" { + doc.ID = fmt.Sprintf("doc_%d", time.Now().UnixNano()) + doc.CreatedAt = time.Now() + } + doc.UpdatedAt = time.Now() + doc.LastAccess = time.Now() + if doc.AccessCount == 0 { + doc.AccessCount = 1 + } + s.docs[doc.ID] = doc + + text := doc.Summary + " " + doc.Content + + // 主路径:稠密向量 + if s.denseSpace != nil && s.denseSpace.Loaded() && len(doc.DenseVec) == 0 { + if dv, err := s.denseSpace.VectorizeDense(text); err == nil { + doc.DenseVec = dv + } + } + + // Fallback 路径:缓存文本,延迟训练 + if s.tfidfIdx != nil { + s.tfidfIdx.Add(doc.ID, text) + } else { + s.trainTexts = append(s.trainTexts, doc.ID, text) + } + + path := filepath.Join(s.dir, doc.ID+".json") + data, _ := json.MarshalIndent(doc, "", " ") + os.WriteFile(path, data, 0644) + s.dirty = true + return nil +} + +// ContextToDoc 将上下文对话历史提炼为文档。 +func (s *Store) ContextToDoc(source string, entries []ContextEntry, _ interface{}, cleanFn func(string) string, toolCleanFn func(name, output string) string, channelCleaner ChannelCleaner) (*Doc, error) { + if len(entries) == 0 { + return nil, nil + } + if cleanFn == nil { + cleanFn = func(text string) string { return text } + } + + var parts []string + for _, e := range entries { + line := fmt.Sprintf("[%s] %s: %s", e.Timestamp.Format("15:04"), e.Source, e.Content) + if e.Response != "" { + line += fmt.Sprintf(" → %s", truncate(e.Response, 100)) + } + for _, tr := range e.ToolResults { + line += fmt.Sprintf("\n [工具] %s: %s", tr.Name, truncate(tr.Output, 200)) + } + parts = append(parts, line) + } + content := strings.Join(parts, "\n") + contentHash := simpleHash(content) + summary := summarizeEntries(entries, cleanFn, toolCleanFn, channelCleaner) + tags := extractTags(entries, cleanFn, toolCleanFn, channelCleaner) + entities := extractEntities(entries, cleanFn, toolCleanFn, channelCleaner) + + s.mu.Lock() + defer s.mu.Unlock() + + for _, d := range s.docs { + if d.Meta != nil && d.Meta["content_hash"] == contentHash { + d.UpdatedAt = time.Now() + d.LastAccess = time.Now() + d.Content = content + d.Source = source + d.Summary = summary + d.Tags = tags + d.Entities = entities + d.Blocks = blocksFromEntries(entries) + s.dirty = true + return d, nil + } + } + + id := fmt.Sprintf("doc_%d", time.Now().UnixNano()) + meta := map[string]string{"content_hash": contentHash} + if source == "context_archived" { + meta["is_archived_context"] = "true" + } + doc := &Doc{ + ID: id, Summary: summary, Content: content, Tags: tags, + Entities: entities, CreatedAt: time.Now(), UpdatedAt: time.Now(), + LastAccess: time.Now(), AccessCount: 1, Source: source, Meta: meta, + Blocks: blocksFromEntries(entries), + } + s.docs[id] = doc + text := summary + " " + content + if s.tfidfIdx != nil { + s.tfidfIdx.Add(id, text) + } else { + s.trainTexts = append(s.trainTexts, id, text) + } + + path := filepath.Join(s.dir, id+".json") + data, _ := json.MarshalIndent(doc, "", " ") + os.WriteFile(path, data, 0644) + s.dirty = true + return doc, nil +} + +// Consume 向量相似度查询并移除文档 +func (s *Store) Consume(text string, topK int) []*Doc { + s.mu.Lock() + defer s.mu.Unlock() + if topK <= 0 { + topK = 5 + } + + // 主路径:稠密检索 + if s.denseSpace != nil && s.denseSpace.Loaded() { + if qv, err := s.denseSpace.VectorizeDense(text); err == nil { + results := s.denseSearchScored(qv, topK) + var docs []*Doc + for _, r := range results { + if d, ok := s.docs[r.Doc.ID]; ok { + s.removeDoc(r.Doc.ID) + s.dirty = true + docs = append(docs, d) + } + } + return docs + } + } + + // Fallback:TF-IDF 倒排检索(延迟初始化) + s.ensureTFIDF() + results := s.tfidfIdx.Search(text, topK) + var docs []*Doc + for _, r := range results { + if d, ok := s.docs[r.ID]; ok { + s.removeDoc(r.ID) + s.dirty = true + docs = append(docs, d) + } + } + return docs +} + +func (s *Store) Query(text string, topK int) []*Doc { + hits := s.QueryScored(text, topK) + out := make([]*Doc, len(hits)) + for i, h := range hits { + out[i] = h.Doc + } + return out +} + +// DocHit 是一篇文档记忆的相似度候选及原始分数。 +type DocHit struct { + Doc *Doc + Score float64 +} + +func (s *Store) QueryScored(text string, topK int) []DocHit { + s.mu.RLock() + defer s.mu.RUnlock() + if topK <= 0 { + topK = 5 + } + + // 主路径 + if s.denseSpace != nil && s.denseSpace.Loaded() { + if qv, err := s.denseSpace.VectorizeDense(text); err == nil { + results := s.denseSearchScored(qv, topK) + for i := range results { + if d, ok := s.docs[results[i].Doc.ID]; ok { + d.AccessCount++ + d.LastAccess = time.Now() + results[i].Doc = d + } + } + return results + } + } + + // Fallback(需要写锁来 ensureTFIDF) + s.mu.RUnlock() + s.mu.Lock() + s.ensureTFIDF() + s.mu.Unlock() + s.mu.RLock() + + results := s.tfidfIdx.Search(text, topK) + var out []DocHit + for _, r := range results { + if d, ok := s.docs[r.ID]; ok { + d.AccessCount++ + d.LastAccess = time.Now() + out = append(out, DocHit{Doc: d, Score: r.Score}) + } + } + return out +} + func (s *Store) denseSearchScored(queryVec []float64, topK int) []DocHit { if len(queryVec) == 0 { return nil @@ -101,13 +366,11 @@ func (s *Store) denseSearchScored(queryVec []float64, topK int) []DocHit { score float64 } var results []scored - s.mu.RLock() - defer s.mu.RUnlock() for _, doc := range s.docs { if len(doc.DenseVec) != len(queryVec) { continue } - score := vector.DenseCosine(queryVec, doc.DenseVec) + score := denseCosine(queryVec, doc.DenseVec) if score > 0.01 { results = append(results, scored{doc.ID, score}) } @@ -126,332 +389,36 @@ func (s *Store) denseSearchScored(queryVec []float64, topK int) []DocHit { return out } -// denseCosine 计算两个 []float64 向量的余弦相似度(已迁移到 vector.DenseCosine,此处保留兼容)。 func denseCosine(a, b []float64) float64 { - return vector.DenseCosine(a, b) -} - -func (s *Store) SetVectorizer(v vector.Vectorizer) { - s.vectorizer = v -} - -// ReindexWithVectorizer 用给定的向量化器重建所有文档的向量索引 -func (s *Store) ReindexWithVectorizer(v vector.Vectorizer) { - s.mu.Lock() - defer s.mu.Unlock() - - log.Printf("[document memory] reindex with vectorizer (%d docs)", len(s.docs)) - s.vec = vector.NewStore() - for _, doc := range s.docs { - doc.Vector = v.Vectorize(doc.Summary + " " + doc.Content) - s.vec.Insert(doc.ID, doc.Summary, doc.Vector, doc.Meta) + var dot, na, nb float64 + for i := range a { + dot += a[i] * b[i] + na += a[i] * a[i] + nb += b[i] * b[i] } - log.Printf("[document memory] reindex with vectorizer complete (%d vectors)", s.vec.Size()) -} - -const maxSummaries = 10000 - -func NewStore(dir string) *Store { - return &Store{ - dir: dir, - vec: vector.NewStore(), - veczer: vector.NewTFIDFVectorizer(memory.TokenizeWords), - docs: make(map[string]*Doc), + if na == 0 || nb == 0 { + return 0 } -} - -func (s *Store) Start() error { - if err := os.MkdirAll(s.dir, 0755); err != nil { - return fmt.Errorf("document store dir: %w", err) - } - if err := s.loadAll(); err != nil { - log.Printf("[document memory] load error: %v", err) - } - log.Printf("[document memory] started with %d docs, %d vectors", len(s.docs), s.vec.Size()) - return nil -} - -func (s *Store) Stop() { - s.flush() -} - -// Insert 创建/更新文档 -func (s *Store) Insert(doc *Doc) error { - s.mu.Lock() - defer s.mu.Unlock() - - if doc.ID == "" { - doc.ID = fmt.Sprintf("doc_%d", time.Now().UnixNano()) - doc.CreatedAt = time.Now() - } - doc.UpdatedAt = time.Now() - doc.LastAccess = time.Now() - if doc.AccessCount == 0 { - doc.AccessCount = 1 - } - - s.docs[doc.ID] = doc - - // 增量训练向量化器并加入向量索引 - s.addSummary(doc.Summary) - vec := doc.Vector - if vec == nil { - vec = s.veczer.Vectorize(doc.Summary + " " + doc.Content) - } - s.vec.Insert(doc.ID, doc.Summary, vec, doc.Meta) - - // 若配置了稠密空间,为新文档计算稠密向量 - if s.denseSpace != nil && s.denseSpace.Loaded() && len(doc.DenseVec) == 0 { - if dv, err := s.denseSpace.VectorizeDense(doc.Summary + " " + doc.Content); err == nil { - doc.DenseVec = dv - } - } - - // 立即写盘 - path := filepath.Join(s.dir, doc.ID+".json") - data, _ := json.MarshalIndent(doc, "", " ") - os.WriteFile(path, data, 0644) - - s.dirty = true - return nil -} - -// ContextToDoc — 将一段上下文对话历史提炼为文档(带内容去重) -// cleanFn 可选,在计算层前统一过滤文本,不影响原文存储。 -// toolCleanFn 可选,func(name, output string) string,按工具名对输出进行过滤/清洗: -// - 返回 "" → 跳过该工具输出(NoMemory) -// - 返回清洗后文本 → 用于计算层(Cleaner),原文不受影响 -func (s *Store) ContextToDoc(source string, entries []ContextEntry, vec vector.Vectorizer, cleanFn func(string) string, toolCleanFn func(name, output string) string, channelCleaner ChannelCleaner) (*Doc, error) { - if len(entries) == 0 { - return nil, nil - } - - if cleanFn == nil { - cleanFn = func(text string) string { return text } - } - - var parts []string - for _, e := range entries { - line := fmt.Sprintf("[%s] %s: %s", e.Timestamp.Format("15:04"), e.Source, e.Content) - if e.Response != "" { - line += fmt.Sprintf(" → %s", truncate(e.Response, 100)) - } - for _, tr := range e.ToolResults { - line += fmt.Sprintf("\n [工具] %s: %s", tr.Name, truncate(tr.Output, 200)) - } - parts = append(parts, line) - } - content := strings.Join(parts, "\n") - contentHash := simpleHash(content) - - summary := summarizeEntries(entries, cleanFn, toolCleanFn, channelCleaner) - tags := extractTags(entries, cleanFn, toolCleanFn, channelCleaner) - entities := extractEntities(entries, cleanFn, toolCleanFn, channelCleaner) - - s.mu.Lock() - - // 去重 - for _, d := range s.docs { - if d.Meta != nil && d.Meta["content_hash"] == contentHash { - d.UpdatedAt = time.Now() - d.LastAccess = time.Now() - d.Content = content - d.Source = source - d.Summary = summary - d.Tags = tags - d.Entities = entities - d.Media = mediaDigestsFromEntries(entries) - s.dirty = true - s.mu.Unlock() - return d, nil - } - } - - id := fmt.Sprintf("doc_%d", time.Now().UnixNano()) - var docVec vector.Vector - if vec != nil { - docVec = vec.Vectorize(summary + " " + content) - } else { - docVec = s.veczer.Vectorize(summary + " " + content) - } - meta := map[string]string{"content_hash": contentHash} - if source == "context_archived" { - meta["is_archived_context"] = "true" - } - doc := &Doc{ - ID: id, - Summary: summary, - Content: content, - Tags: tags, - Entities: entities, - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - LastAccess: time.Now(), - AccessCount: 1, - Source: source, - Meta: meta, - Media: mediaDigestsFromEntries(entries), - Vector: docVec, - } - s.docs[id] = doc - - // 加入向量索引 - s.addSummary(summary) - s.vec.Insert(id, summary, doc.Vector, nil) - - s.dirty = true - s.mu.Unlock() - - // 立即写盘 - path := filepath.Join(s.dir, id+".json") - data, _ := json.MarshalIndent(doc, "", " ") - os.WriteFile(path, data, 0644) - - return doc, nil -} - -// Consume — 向量相似度查询并移除文档(召回后即从冷存储删除,避免重复记忆) -func (s *Store) Consume(text string, topK int) []*Doc { - s.mu.Lock() - defer s.mu.Unlock() - - if topK <= 0 { - topK = 5 - } - - // 优先稠密检索(与媒体共享空间);未配置时退化到 TF-IDF 倒排检索。 - if s.denseSpace != nil && s.denseSpace.Loaded() { - queryVec, err := s.denseSpace.VectorizeDense(text) - if err == nil { - results := s.denseSearchScored(queryVec, topK) - var docs []*Doc - for _, r := range results { - if d, ok := s.docs[r.Doc.ID]; ok { - s.removeDoc(r.Doc.ID) - s.dirty = true - docs = append(docs, d) - } - } - return docs - } - log.Printf("[document memory] dense query failed, falling back to TF-IDF: %v", err) - } - - vec := s.vectorizeQuery(text) - results := s.vec.Search(vec, topK) - - var docs []*Doc - for _, r := range results { - if d, ok := s.docs[r.ID]; ok { - s.removeDoc(r.ID) - s.dirty = true - docs = append(docs, d) - } - } - return docs -} - -// vectorizeQuery 用语义向量化器(首选)或 TF-IDF(兜底)处理查询文本 -func (s *Store) vectorizeQuery(text string) vector.Vector { - if s.vectorizer != nil { - return s.vectorizer.Vectorize(text) - } - return s.veczer.Vectorize(text) -} - -// Query — 向量相似度查询文档 -func (s *Store) Query(text string, topK int) []*Doc { - hits := s.QueryScored(text, topK) - out := make([]*Doc, len(hits)) - for i, h := range hits { - out[i] = h.Doc - } - return out -} - -// DocHit 是一篇文档记忆的相似度候选及原始分数(供跨模态融合归一化)。 -type DocHit struct { - Doc *Doc - Score float64 -} - -// QueryScored 与 Query 同语义,但返回带原始 cosine 分数的候选。 -func (s *Store) QueryScored(text string, topK int) []DocHit { - s.mu.RLock() - defer s.mu.RUnlock() - - if topK <= 0 { - topK = 5 - } - - // 优先稠密检索;退化到 TF-IDF。 - if s.denseSpace != nil && s.denseSpace.Loaded() { - queryVec, err := s.denseSpace.VectorizeDense(text) - if err == nil { - results := s.denseSearchScored(queryVec, topK) - for i := range results { - if d, ok := s.docs[results[i].Doc.ID]; ok { - d.AccessCount++ - d.LastAccess = time.Now() - results[i].Doc = d - } - } - return results - } - } - - vec := s.vectorizeQuery(text) - results := s.vec.SearchScored(vec, topK) - - var out []DocHit - for _, r := range results { - if d, ok := s.docs[r.Doc.ID]; ok { - d.AccessCount++ - d.LastAccess = time.Now() - out = append(out, DocHit{Doc: d, Score: r.Score}) - } - } - return out -} - -// Reindex — 重新训练并重建向量索引 -func (s *Store) Reindex() { - s.mu.Lock() - defer s.mu.Unlock() - - log.Printf("[document memory] reindexing %d docs", len(s.docs)) - - s.veczer.Train(s.summaries) - - s.vec = vector.NewStore() - for _, doc := range s.docs { - vec := doc.Vector - if vec == nil { - vec = s.veczer.Vectorize(doc.Summary + " " + doc.Content) - } - s.vec.Insert(doc.ID, doc.Summary, vec, doc.Meta) - } - - log.Printf("[document memory] reindex complete (%d vectors)", s.vec.Size()) + return dot / math.Sqrt(na*nb) } func (s *Store) Stats() map[string]interface{} { s.mu.RLock() defer s.mu.RUnlock() - + idxSize := 0 + if s.tfidfIdx != nil { + idxSize = s.tfidfIdx.Size() + } return map[string]interface{}{ - "doc_count": len(s.docs), - "vector_count": s.vec.Size(), - "summary_count": len(s.summaries), - "dir": s.dir, + "doc_count": len(s.docs), + "index_count": idxSize, + "dir": s.dir, } } -// FindColdDocs — 查找冷文档:超过 maxAge 未访问且访问次数 <= minAccess func (s *Store) FindColdDocs(maxAge time.Duration, minAccess int) []*Doc { s.mu.RLock() defer s.mu.RUnlock() - cutoff := time.Now().Add(-maxAge) var cold []*Doc for _, d := range s.docs { @@ -462,60 +429,53 @@ func (s *Store) FindColdDocs(maxAge time.Duration, minAccess int) []*Doc { return cold } +// Get 返回指定文档(不存在时为 nil)。 +func (s *Store) Get(id string) *Doc { + s.mu.RLock() + defer s.mu.RUnlock() + return s.docs[id] +} + +// Blocks 返回全部文档持有的一等记忆块(供跨层存活判定)。 +func (s *Store) Blocks() []memory.MemoryBlock { + s.mu.RLock() + defer s.mu.RUnlock() + var out []memory.MemoryBlock + for _, d := range s.docs { + out = append(out, d.Blocks...) + } + return out +} + func (s *Store) RecentDocs(n int) []*Doc { s.mu.RLock() defer s.mu.RUnlock() - var list []*Doc for _, d := range s.docs { list = append(list, d) } - sort.Slice(list, func(i, j int) bool { - return list[i].CreatedAt.After(list[j].CreatedAt) - }) + sort.Slice(list, func(i, j int) bool { return list[i].CreatedAt.After(list[j].CreatedAt) }) if len(list) > n { list = list[:n] } return list } -// Remove 从文档存储中删除指定 ID 的文档 func (s *Store) Remove(id string) { s.mu.Lock() defer s.mu.Unlock() - if _, ok := s.docs[id]; ok { s.removeDoc(id) s.dirty = true } } -// ——— internal ——— - -// addSummary 添加一条摘要到训练集,超限时截断并触发重索引。 -// 调用方必须已持有 s.mu 写锁。 -func (s *Store) addSummary(summary string) { - s.summaries = append(s.summaries, summary) - if len(s.summaries) > maxSummaries { - n := maxSummaries / 2 - copy(s.summaries, s.summaries[len(s.summaries)-n:]) - s.summaries = s.summaries[:n] - s.veczer.Train(s.summaries) - s.vec = vector.NewStore() - for _, doc := range s.docs { - vec := s.veczer.Vectorize(doc.Summary + " " + doc.Content) - s.vec.Insert(doc.ID, doc.Summary, vec, nil) - } - } -} - -// removeDoc 从内存索引和磁盘删除文档。 -// 调用方必须已持有 s.mu 写锁。 func (s *Store) removeDoc(id string) { delete(s.docs, id) - s.vec.Remove(id) - path := filepath.Join(s.dir, id+".json") - os.Remove(path) + if s.tfidfIdx != nil { + s.tfidfIdx.Remove(id) + } + os.Remove(filepath.Join(s.dir, id+".json")) } func (s *Store) loadAll() error { @@ -523,63 +483,43 @@ func (s *Store) loadAll() error { if err != nil { return err } - for _, e := range entries { if !strings.HasSuffix(e.Name(), ".json") || !strings.HasPrefix(e.Name(), "doc_") { continue } - path := filepath.Join(s.dir, e.Name()) - data, err := os.ReadFile(path) + data, err := os.ReadFile(filepath.Join(s.dir, e.Name())) if err != nil { continue } var doc Doc - if err := json.Unmarshal(data, &doc); err != nil { + if json.Unmarshal(data, &doc) != nil || doc.ID == "" { continue } s.docs[doc.ID] = &doc - s.summaries = append(s.summaries, doc.Summary) + // 缓存文本,延迟训练(确保TFIDF在首次需要时才加载) + s.trainTexts = append(s.trainTexts, doc.ID, doc.Summary+" "+doc.Content) } - - // 训练向量化器 - if len(s.summaries) > 0 { - s.veczer.Train(s.summaries) - } - - // 重建向量索引 - for _, doc := range s.docs { - vec := doc.Vector - if vec == nil { - vec = s.veczer.Vectorize(doc.Summary + " " + doc.Content) - } - s.vec.Insert(doc.ID, doc.Summary, vec, nil) - } - return nil } func (s *Store) flush() { s.mu.Lock() defer s.mu.Unlock() - if !s.dirty { return } - for _, doc := range s.docs { - path := filepath.Join(s.dir, doc.ID+".json") - data, err := json.MarshalIndent(doc, "", " ") - if err != nil { - continue - } - os.WriteFile(path, data, 0644) + data, _ := json.MarshalIndent(doc, "", " ") + os.WriteFile(filepath.Join(s.dir, doc.ID+".json"), data, 0644) } s.dirty = false } +// ——— 内部工具函数(从上下文提炼文档所需)——— + type ToolResultItem struct { - Name string - Output string + Name string `json:"name"` + Output string `json:"output"` } type ContextEntry struct { @@ -588,19 +528,20 @@ type ContextEntry struct { Content string Response string ToolResults []ToolResultItem - Media []string + Blocks []memory.MemoryBlock // 一等记忆块随事件一起迁移到文档 } -func mediaDigestsFromEntries(entries []ContextEntry) []string { +func blocksFromEntries(entries []ContextEntry) []memory.MemoryBlock { seen := make(map[string]bool) - var out []string + var out []memory.MemoryBlock for _, e := range entries { - for _, d := range e.Media { - if d == "" || seen[d] { + for i := range e.Blocks { + b := e.Blocks[i] + if b.ID == "" || seen[b.ID] { continue } - seen[d] = true - out = append(out, d) + seen[b.ID] = true + out = append(out, b) } } return out @@ -635,14 +576,12 @@ func summarizeEntries(entries []ContextEntry, cleanText func(string) string, too topics = append(topics, toolWords...) } } - summary := fmt.Sprintf("来自 %d 个来源的 %d 条对话", len(sources), len(entries)) var srcList []string for s := range sources { srcList = append(srcList, s) } summary += " (" + strings.Join(srcList, ", ") + ")" - if len(topics) > 0 { seen := make(map[string]bool) var uniq []string @@ -657,7 +596,6 @@ func summarizeEntries(entries []ContextEntry, cleanText func(string) string, too } summary += " 涉及: " + strings.Join(uniq, ", ") } - return summary } @@ -730,25 +668,20 @@ func extractEntities(entries []ContextEntry, cleanText func(string) string, tool } } } - if len(entities) > 20 { - entities = entities[:20] - } return entities } func truncate(s string, max int) string { - runes := []rune(s) - if len(runes) > max { - return string(runes[:max]) + "..." + if len([]rune(s)) <= max { + return s } - return s + return string([]rune(s)[:max]) + "..." } func simpleHash(s string) string { - // 简单的基于内容的哈希,用于去重 - h := 0 - for _, r := range s { - h = h*31 + int(r) + h := fmt.Sprintf("%x", len(s)) + for _, c := range s { + h += fmt.Sprintf("%x", c) } - return fmt.Sprintf("h%08x", h) + return h } diff --git a/internal/memory/graph.go b/internal/memory/graph.go index 26668b5..236109f 100644 --- a/internal/memory/graph.go +++ b/internal/memory/graph.go @@ -236,7 +236,7 @@ func (g *GraphDB) Commit(triples []Triple, sessionID string, turnID int) (int, i // 不划算。这里让 Commit 内部转调,两者共享同一份落库逻辑。 // // 返回的 map 只包含本次真正写入了 sentences 表的句子。调用方据此把媒体 -// 引用挂到 graph_sentence owner 上——句子是媒体描述在图库里的落点, +// 变成 L3 一等块,并以 sentence --contains--> block 边与句子相连; // 关系行本身不持有媒体。 func (g *GraphDB) CommitWithMedia(triples []Triple, sessionID string, turnID int) (map[string]int64, int, int, error) { return g.commit(triples, sessionID, turnID, true) diff --git a/internal/memory/media/media.go b/internal/memory/media/media.go index 699f7e6..83dac8c 100644 --- a/internal/memory/media/media.go +++ b/internal/memory/media/media.go @@ -11,7 +11,7 @@ // - 路径会失效。/tmp 下的探针图、下载缓存、其他进程的临时产物,记忆里留个 // 路径等于留个悬空指针。 // - 同一张图往往被反复注入(用户连问几轮同一张截图、see_video 相邻帧高度 -// 相似)。按 sha256 寻址天然去重,引用计数记住被引了几次。 +// 相似)。按 sha256 寻址天然去重,同一份字节只存一遍。 // - 内容即身份,跟 L3 图库 `sentences.text UNIQUE` 的思路一致:文本节点用 // 文本本身做身份,媒体节点用内容摘要做身份。 package media @@ -34,20 +34,6 @@ import ( _ "github.com/mattn/go-sqlite3" ) -// OwnerKind 是 media_refs.owner_kind 的取值,对应引用媒体的记忆层。 -// -// 定义为常量而不是让调用方写字符串:owner_kind 进了主键, -// 拼错一个字符就是一条永远对不上的孤立引用(AddRef 不会报错, -// DropOwner 也永远匹配不到)。 -const ( - // OwnerContext 是 L0 对话上下文事件(ContextEvent.ID)。 - OwnerContext = "context" - // OwnerDocument 是 L2 文档记忆(Doc.ID)。 - OwnerDocument = "document" - // OwnerGraphSentence 是 L3 图库句子节点(sentences.id)。 - OwnerGraphSentence = "graph_sentence" -) - // digestHexLen 是 sha256 的十六进制串长度。 const digestHexLen = sha256.Size * 2 @@ -88,8 +74,6 @@ type Item struct { Description string `json:"description,omitempty"` // DescribedBy 记录描述来自哪个源,让后续读者能判断可靠性。 DescribedBy string `json:"described_by,omitempty"` - // RefCount 是引用计数。GC 只清理归零的项。 - RefCount int `json:"ref_count"` // FirstSeen/LastSeen 是首末次入库时间。 FirstSeen time.Time `json:"first_seen"` LastSeen time.Time `json:"last_seen"` @@ -110,15 +94,11 @@ type Store struct { mu sync.RWMutex db *sql.DB blobDir string - - // maxBytes 是内容目录的容量上限,0 表示不限。 - // 超限时 GC 按 LastSeen 从旧到新淘汰 RefCount=0 的项。 - maxBytes int64 } // New 打开(或初始化)媒体存储。 // dir 下会建 media.db 与 blobs/ 两个条目。 -func New(dir string, maxBytes int64) (*Store, error) { +func New(dir string) (*Store, error) { if err := os.MkdirAll(filepath.Join(dir, "blobs"), 0755); err != nil { return nil, fmt.Errorf("media: create blob dir: %w", err) } @@ -127,7 +107,7 @@ func New(dir string, maxBytes int64) (*Store, error) { if err != nil { return nil, fmt.Errorf("media: open db: %w", err) } - s := &Store{db: db, blobDir: filepath.Join(dir, "blobs"), maxBytes: maxBytes} + s := &Store{db: db, blobDir: filepath.Join(dir, "blobs")} if err := s.initSchema(); err != nil { db.Close() return nil, err @@ -137,7 +117,7 @@ func New(dir string, maxBytes int64) (*Store, error) { func (s *Store) initSchema() error { stmts := []string{ - // digest 作主键:内容即身份,重复 Put 同一内容只递增 ref_count。 + // digest 作主键:内容即身份,重复 Put 同一内容不重复落盘。 `CREATE TABLE IF NOT EXISTS media ( digest TEXT PRIMARY KEY, kind TEXT NOT NULL, @@ -149,25 +129,11 @@ func (s *Store) initSchema() error { tool TEXT, description TEXT, described_by TEXT, - ref_count INTEGER DEFAULT 0, first_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP, last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP )`, `CREATE INDEX IF NOT EXISTS idx_media_kind ON media(kind)`, - `CREATE INDEX IF NOT EXISTS idx_media_refcount ON media(ref_count)`, `CREATE INDEX IF NOT EXISTS idx_media_last_seen ON media(last_seen)`, - // 反向索引:哪条记忆引用了哪个媒体。 - // owner_kind 取 context / document / graph_sentence,owner_id 是各层自己的标识。 - // 主键含三列,同一 owner 重复挂同一媒体是幂等的。 - `CREATE TABLE IF NOT EXISTS media_refs ( - digest TEXT NOT NULL, - owner_kind TEXT NOT NULL, - owner_id TEXT NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (digest, owner_kind, owner_id) - )`, - `CREATE INDEX IF NOT EXISTS idx_refs_owner ON media_refs(owner_kind, owner_id)`, - `CREATE INDEX IF NOT EXISTS idx_refs_digest ON media_refs(digest)`, } for _, q := range stmts { if _, err := s.db.Exec(q); err != nil { @@ -231,8 +197,8 @@ func (s *Store) Put(data []byte, meta Item) (string, error) { _, err := s.db.Exec(` INSERT INTO media (digest, kind, mime, size, width, height, origin_path, tool, description, described_by, - ref_count, first_seen, last_seen) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?) + first_seen, last_seen) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(digest) DO UPDATE SET last_seen = excluded.last_seen, -- 只在原值为空时补写:先到的描述可能来自更强的模型, @@ -276,7 +242,7 @@ func (s *Store) Stat(digest string) (*Item, error) { defer s.mu.RUnlock() return s.scanOne(s.db.QueryRow(` SELECT digest, kind, mime, size, width, height, origin_path, tool, - description, described_by, ref_count, first_seen, last_seen, + description, described_by, first_seen, last_seen, vec, vec_model FROM media WHERE digest = ?`, digest)) } @@ -299,121 +265,6 @@ func (s *Store) Describe(digest, description, describedBy string) error { return nil } -// AddRef 登记一条引用并递增计数。幂等:同一 (digest, owner) 重复调用不重复计数。 -func (s *Store) AddRef(digest, ownerKind, ownerID string) error { - s.mu.Lock() - defer s.mu.Unlock() - - tx, err := s.db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - - res, err := tx.Exec(`INSERT OR IGNORE INTO media_refs (digest, owner_kind, owner_id) VALUES (?, ?, ?)`, - digest, ownerKind, ownerID) - if err != nil { - return fmt.Errorf("media: add ref: %w", err) - } - // 只有真的插进去才递增:否则重复调用会让计数虚高,GC 永远不敢清。 - if n, _ := res.RowsAffected(); n > 0 { - if _, err := tx.Exec(`UPDATE media SET ref_count = ref_count + 1 WHERE digest = ?`, digest); err != nil { - return fmt.Errorf("media: bump refcount: %w", err) - } - } - return tx.Commit() -} - -// DropRef 注销一条引用并递减计数。内容不立即删除,留给 GC。 -func (s *Store) DropRef(digest, ownerKind, ownerID string) error { - s.mu.Lock() - defer s.mu.Unlock() - - tx, err := s.db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - - res, err := tx.Exec(`DELETE FROM media_refs WHERE digest = ? AND owner_kind = ? AND owner_id = ?`, - digest, ownerKind, ownerID) - if err != nil { - return fmt.Errorf("media: drop ref: %w", err) - } - if n, _ := res.RowsAffected(); n > 0 { - // MAX(0, ...) 兜底:历史数据或并发意外让计数与 refs 表不一致时, - // 不让它掉成负数(负数会让容量 GC 的排序失去意义)。 - if _, err := tx.Exec(`UPDATE media SET ref_count = MAX(0, ref_count - 1) WHERE digest = ?`, digest); err != nil { - return fmt.Errorf("media: lower refcount: %w", err) - } - } - return tx.Commit() -} - -// DropOwner 注销某个 owner 的全部引用(该条记忆被删/被归档替换时用)。 -func (s *Store) DropOwner(ownerKind, ownerID string) (int, error) { - s.mu.Lock() - defer s.mu.Unlock() - - rows, err := s.db.Query(`SELECT digest FROM media_refs WHERE owner_kind = ? AND owner_id = ?`, - ownerKind, ownerID) - if err != nil { - return 0, err - } - var digests []string - for rows.Next() { - var d string - if err := rows.Scan(&d); err == nil { - digests = append(digests, d) - } - } - rows.Close() - if err := rows.Err(); err != nil { - return 0, err - } - if len(digests) == 0 { - return 0, nil - } - - tx, err := s.db.Begin() - if err != nil { - return 0, err - } - defer tx.Rollback() - if _, err := tx.Exec(`DELETE FROM media_refs WHERE owner_kind = ? AND owner_id = ?`, ownerKind, ownerID); err != nil { - return 0, err - } - for _, d := range digests { - if _, err := tx.Exec(`UPDATE media SET ref_count = MAX(0, ref_count - 1) WHERE digest = ?`, d); err != nil { - return 0, err - } - } - if err := tx.Commit(); err != nil { - return 0, err - } - return len(digests), nil -} - -// Refs 返回某个 owner 引用的全部 digest。 -func (s *Store) Refs(ownerKind, ownerID string) ([]string, error) { - s.mu.RLock() - defer s.mu.RUnlock() - rows, err := s.db.Query(`SELECT digest FROM media_refs WHERE owner_kind = ? AND owner_id = ? ORDER BY created_at`, - ownerKind, ownerID) - if err != nil { - return nil, err - } - defer rows.Close() - var out []string - for rows.Next() { - var d string - if err := rows.Scan(&d); err == nil { - out = append(out, d) - } - } - return out, rows.Err() -} - // Search 按描述文本做 LIKE 匹配,返回最近的若干条。 // // 刻意不在这里做向量检索:媒体的语义检索走 L2 文档层的既有索引 @@ -427,7 +278,7 @@ func (s *Store) Search(query string, kind Kind, limit int) ([]*Item, error) { defer s.mu.RUnlock() q := `SELECT digest, kind, mime, size, width, height, origin_path, tool, - description, described_by, ref_count, first_seen, last_seen, + description, described_by, first_seen, last_seen, vec, vec_model FROM media WHERE COALESCE(description,'') != ''` args := []interface{}{} @@ -474,7 +325,7 @@ func (s *Store) Pending(limit int) ([]*Item, error) { defer s.mu.RUnlock() rows, err := s.db.Query(` SELECT digest, kind, mime, size, width, height, origin_path, tool, - description, described_by, ref_count, first_seen, last_seen, + description, described_by, first_seen, last_seen, vec, vec_model FROM media WHERE COALESCE(description,'') = '' AND COALESCE(described_by,'') = '' @@ -494,117 +345,44 @@ func (s *Store) Pending(limit int) ([]*Item, error) { return out, rows.Err() } -// GC 清理无人引用的内容。 +// GC 清理已不被任何记忆块持有的内容。 // -// 两段策略: -// 1. ref_count=0 且 last_seen 早于 minAge 的一律清理。刚 Put 还没来得及 -// AddRef 的项 refcount 也是 0,minAge 保护它们不被立刻清掉。 -// 2. 清完仍超 maxBytes 时,继续按 last_seen 从旧到新淘汰 ref_count=0 的项。 +// keep 是当前仍被 Context/Document/Graph 里一等记忆块持有的 digest 集合, +// 由调用方从三层记忆节点计算得出;media.Store 不再自己维护引用账本。 +// 不在 keep 中且早于 minAge 的项被清理;超出 maxBytes 时也只淘汰不在 keep 中的项。 +// Delete 删除一份媒体内容(元数据 + blob)。 // -// 有引用的项永不删除——那会让记忆里的 digest 变成悬空指针,正是本包要避免的。 -func (s *Store) GC(minAge time.Duration) (removed int, freed int64, err error) { +// 这不是 GC,也不看引用计数:调用方是记忆系统本身——当它把一个记忆块 +// 永久地从三层记忆中删掉(而非在层间迁移)时,媒体作为块的内容一并删除。 +// 文本块就是这么管理的:删除块即删除内容。 +func (s *Store) Delete(digest string) error { + if digest == "" { + return nil + } s.mu.Lock() defer s.mu.Unlock() - - cutoff := time.Now().Add(-minAge) - rows, err := s.db.Query(` - SELECT digest, size FROM media - WHERE ref_count <= 0 AND last_seen < ? - ORDER BY last_seen`, cutoff) - if err != nil { - return 0, 0, err + if err := os.Remove(s.blobPath(digest)); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("media: remove blob %s: %w", shortDigest(digest), err) } - type cand struct { - digest string - size int64 + if _, err := s.db.Exec(`DELETE FROM media WHERE digest = ?`, digest); err != nil { + return fmt.Errorf("media: delete meta %s: %w", shortDigest(digest), err) } - var cands []cand - for rows.Next() { - var c cand - if err := rows.Scan(&c.digest, &c.size); err == nil { - cands = append(cands, c) - } - } - rows.Close() - - for _, c := range cands { - if e := os.Remove(s.blobPath(c.digest)); e != nil && !os.IsNotExist(e) { - continue // 删不掉就留着元数据,下轮再试;不制造"元数据没了文件还在"的孤儿 - } - if _, e := s.db.Exec(`DELETE FROM media WHERE digest = ?`, c.digest); e != nil { - continue - } - removed++ - freed += c.size - } - - if s.maxBytes > 0 { - r2, f2 := s.enforceCapacityLocked() - removed += r2 - freed += f2 - } - return removed, freed, nil + return nil } -// enforceCapacityLocked 在超出 maxBytes 时继续淘汰无引用项(调用方已持锁)。 -func (s *Store) enforceCapacityLocked() (removed int, freed int64) { - var total int64 - if err := s.db.QueryRow(`SELECT COALESCE(SUM(size), 0) FROM media`).Scan(&total); err != nil { - return 0, 0 - } - if total <= s.maxBytes { - return 0, 0 - } - need := total - s.maxBytes - - rows, err := s.db.Query(`SELECT digest, size FROM media WHERE ref_count <= 0 ORDER BY last_seen`) - if err != nil { - return 0, 0 - } - type cand struct { - digest string - size int64 - } - var cands []cand - for rows.Next() { - var c cand - if err := rows.Scan(&c.digest, &c.size); err == nil { - cands = append(cands, c) - } - } - rows.Close() - - for _, c := range cands { - if freed >= need { - break - } - if e := os.Remove(s.blobPath(c.digest)); e != nil && !os.IsNotExist(e) { - continue - } - if _, e := s.db.Exec(`DELETE FROM media WHERE digest = ?`, c.digest); e != nil { - continue - } - removed++ - freed += c.size - } - return removed, freed -} - -// Stats 返回容量与条目统计,供 WebUI / healthcheck 展示。 +// Stats 返回条目统计,供 WebUI / healthcheck 展示。 func (s *Store) Stats() map[string]interface{} { s.mu.RLock() defer s.mu.RUnlock() - out := map[string]interface{}{"blob_dir": s.blobDir, "max_bytes": s.maxBytes} - var count, described, orphan int + out := map[string]interface{}{"blob_dir": s.blobDir} + var count, described int var total int64 s.db.QueryRow(`SELECT COUNT(*), COALESCE(SUM(size),0) FROM media`).Scan(&count, &total) s.db.QueryRow(`SELECT COUNT(*) FROM media WHERE COALESCE(description,'') != ''`).Scan(&described) - s.db.QueryRow(`SELECT COUNT(*) FROM media WHERE ref_count <= 0`).Scan(&orphan) out["count"] = count out["total_bytes"] = total out["described"] = described - out["unreferenced"] = orphan byKind := map[string]int{} rows, err := s.db.Query(`SELECT kind, COUNT(*) FROM media GROUP BY kind`) @@ -725,25 +503,17 @@ type MediaHit struct { Score float64 } -// QueryMemoryMediaScored 只检索当前仍被 L0/L2/L3 记忆块引用的媒体。 -// CAS 中 ref_count=0 的项是等待 GC 的孤儿缓存,不是可召回记忆;若把它们也查出, -// 已从三层记忆淘汰的图片会被视觉路“复活”,破坏与文本块一致的生命周期。 +// QueryMediaScored 用查询向量对所有已嵌入媒体做余弦相似度检索, +// 返回 topK 个最相似的候选及其原始 cosine 分数(供跨模态归一化)。 // // 分数只做排序,不在存储层设绝对阈值:多模态文本→图像的绝对 cosine 随模型、 // 语言与数据域漂移,真实标定中有效命中可以低至 0.015。相关性门控在融合器中 // 使用当前候选集合的相对分布完成。 -func (s *Store) QueryMemoryMediaScored(queryVec []float64, model string, topK int) ([]MediaHit, error) { - return s.queryMediaScored(queryVec, model, topK, true) -} - -// QueryMediaScored 用查询向量对所有已嵌入媒体做余弦相似度检索, -// 返回 topK 个最相似的候选及其原始 cosine 分数(供跨模态归一化)。 -// 这是媒体存储层的诊断/显式全库入口;记忆召回应调用 QueryMemoryMediaScored。 func (s *Store) QueryMediaScored(queryVec []float64, model string, topK int) ([]MediaHit, error) { - return s.queryMediaScored(queryVec, model, topK, false) + return s.queryMediaScored(queryVec, model, topK) } -func (s *Store) queryMediaScored(queryVec []float64, model string, topK int, referencedOnly bool) ([]MediaHit, error) { +func (s *Store) queryMediaScored(queryVec []float64, model string, topK int) ([]MediaHit, error) { if topK <= 0 { topK = 20 } @@ -754,7 +524,7 @@ func (s *Store) queryMediaScored(queryVec []float64, model string, topK int, ref defer s.mu.RUnlock() query := `SELECT digest, kind, mime, size, width, height, - origin_path, tool, description, described_by, ref_count, first_seen, last_seen, + origin_path, tool, description, described_by, first_seen, last_seen, vec, vec_model FROM media WHERE vec IS NOT NULL AND vec != ''` var args []interface{} @@ -762,11 +532,6 @@ func (s *Store) queryMediaScored(queryVec []float64, model string, topK int, ref query += ` AND vec_model = ?` args = append(args, model) } - if referencedOnly { - query += ` AND ref_count > 0 AND EXISTS ( - SELECT 1 FROM media_refs r WHERE r.digest = media.digest - )` - } rows, err := s.db.Query(query, args...) if err != nil { return nil, err @@ -783,7 +548,7 @@ func (s *Store) queryMediaScored(queryVec []float64, model string, topK int, ref var kind string var origin, tool, desc, by, vecJSON, vecModel sql.NullString if err := rows.Scan(&it.Digest, &kind, &it.MIME, &it.Size, &it.Width, &it.Height, - &origin, &tool, &desc, &by, &it.RefCount, &it.FirstSeen, &it.LastSeen, + &origin, &tool, &desc, &by, &it.FirstSeen, &it.LastSeen, &vecJSON, &vecModel); err != nil { continue } @@ -860,7 +625,7 @@ func scanItem(r rowScanner) (*Item, error) { var kind string var origin, tool, desc, by, vecJSON, vecModel sql.NullString if err := r.Scan(&it.Digest, &kind, &it.MIME, &it.Size, &it.Width, &it.Height, - &origin, &tool, &desc, &by, &it.RefCount, &it.FirstSeen, &it.LastSeen, + &origin, &tool, &desc, &by, &it.FirstSeen, &it.LastSeen, &vecJSON, &vecModel); err != nil { return nil, err } diff --git a/internal/memory/media/media_test.go b/internal/memory/media/media_test.go index 0f45cd6..c007c6b 100644 --- a/internal/memory/media/media_test.go +++ b/internal/memory/media/media_test.go @@ -5,12 +5,13 @@ import ( "path/filepath" "strings" "testing" - "time" ) -func newTestStore(t *testing.T, maxBytes int64) *Store { +// newTestStore 建一个临时媒体存储。 +// 参数保留只为兼容旧调用点;媒体不再有容量上限(生命周期由记忆块决定)。 +func newTestStore(t *testing.T, _ ...int64) *Store { t.Helper() - s, err := New(t.TempDir(), maxBytes) + s, err := New(t.TempDir()) if err != nil { t.Fatalf("New: %v", err) } @@ -109,172 +110,35 @@ func TestPut_NoPartialBlobOnDisk(t *testing.T) { } } -func TestRefCount_AddIsIdempotent(t *testing.T) { - s := newTestStore(t, 0) - d, _ := s.Put([]byte("img"), Item{MIME: "image/png"}) +func TestDelete_RemovesContentAndMetadata(t *testing.T) { + // 删除块即删除内容:Delete 同时清掉 blob 与元数据。 + // 这不是 GC,也不看引用计数——调用方是记忆系统本身。 + s := newTestStore(t) + d, _ := s.Put([]byte("held"), Item{MIME: "image/png"}) + other, _ := s.Put([]byte("orphaned"), Item{MIME: "image/png"}) - for i := 0; i < 3; i++ { - if err := s.AddRef(d, "context", "evt-1"); err != nil { - t.Fatal(err) - } - } - it, _ := s.Stat(d) - // 重复 AddRef 若都递增,计数会虚高,GC 永远不敢清。 - if it.RefCount != 1 { - t.Fatalf("同一 owner 重复 AddRef 应只计 1,实际 %d", it.RefCount) - } - - if err := s.AddRef(d, "document", "doc-9"); err != nil { + if err := s.Delete(other); err != nil { t.Fatal(err) } - it, _ = s.Stat(d) - if it.RefCount != 2 { - t.Fatalf("不同 owner 应各计一次,实际 %d", it.RefCount) + if _, err := s.Stat(other); err == nil { + t.Fatal("删除后元数据应已移除") } -} - -func TestRefCount_DropAndNeverNegative(t *testing.T) { - s := newTestStore(t, 0) - d, _ := s.Put([]byte("img"), Item{MIME: "image/png"}) - s.AddRef(d, "context", "e1") - - if err := s.DropRef(d, "context", "e1"); err != nil { - t.Fatal(err) - } - it, _ := s.Stat(d) - if it.RefCount != 0 { - t.Fatalf("应归零,实际 %d", it.RefCount) - } - - // 多余的 DropRef 不该把计数压成负数(负数会让容量 GC 的排序失去意义) - for i := 0; i < 3; i++ { - s.DropRef(d, "context", "e1") - } - it, _ = s.Stat(d) - if it.RefCount != 0 { - t.Fatalf("重复 DropRef 后仍应为 0,实际 %d", it.RefCount) - } -} - -func TestDropOwner_RemovesAllItsRefs(t *testing.T) { - s := newTestStore(t, 0) - d1, _ := s.Put([]byte("frame1"), Item{MIME: "image/jpeg"}) - d2, _ := s.Put([]byte("frame2"), Item{MIME: "image/jpeg"}) - s.AddRef(d1, "context", "evt-x") - s.AddRef(d2, "context", "evt-x") - s.AddRef(d1, "document", "doc-y") // 别的 owner 也引了 d1 - - n, err := s.DropOwner("context", "evt-x") - if err != nil { - t.Fatal(err) - } - if n != 2 { - t.Fatalf("应注销 2 条引用,实际 %d", n) - } - - it1, _ := s.Stat(d1) - it2, _ := s.Stat(d2) - if it1.RefCount != 1 { - t.Fatalf("d1 仍被 document 引用,应剩 1,实际 %d", it1.RefCount) - } - if it2.RefCount != 0 { - t.Fatalf("d2 应归零,实际 %d", it2.RefCount) - } -} - -func TestRefs_ListsOwnerDigests(t *testing.T) { - s := newTestStore(t, 0) - d1, _ := s.Put([]byte("a"), Item{MIME: "image/png"}) - d2, _ := s.Put([]byte("b"), Item{MIME: "image/png"}) - s.AddRef(d1, "context", "e1") - s.AddRef(d2, "context", "e1") - - got, err := s.Refs("context", "e1") - if err != nil { - t.Fatal(err) - } - if len(got) != 2 { - t.Fatalf("应返回 2 个 digest,实际 %d", len(got)) - } -} - -func TestGC_KeepsReferencedContent(t *testing.T) { - // 有引用的项永不删除——那会让记忆里的 digest 变成悬空指针, - // 正是本包要避免的。 - s := newTestStore(t, 0) - kept, _ := s.Put([]byte("referenced"), Item{MIME: "image/png"}) - orphan, _ := s.Put([]byte("orphaned"), Item{MIME: "image/png"}) - s.AddRef(kept, "context", "e1") - - // minAge=0 让刚 Put 的都算超龄 - removed, _, err := s.GC(0) - if err != nil { - t.Fatal(err) - } - if removed != 1 { - t.Fatalf("应只清 1 条无引用项,实际 %d", removed) - } - if _, err := s.Get(kept); err != nil { - t.Fatalf("被引用的内容不该被清: %v", err) - } - if _, err := s.Stat(orphan); err == nil { - t.Fatal("无引用项的元数据应已删除") - } -} - -func TestGC_MinAgeProtectsFreshUnreferenced(t *testing.T) { - // 刚 Put 还没来得及 AddRef 的项 refcount 也是 0; - // minAge 必须保护它们,否则「Put 完还没挂上就被 GC 清掉」。 - s := newTestStore(t, 0) - d, _ := s.Put([]byte("just-arrived"), Item{MIME: "image/png"}) - - removed, _, err := s.GC(time.Hour) - if err != nil { - t.Fatal(err) - } - if removed != 0 { - t.Fatalf("新入库项应被 minAge 保护,却清掉了 %d 条", removed) + if _, err := s.Get(other); err == nil { + t.Fatal("删除后内容应已移除") } + // 未被删除的项不受影响 if _, err := s.Get(d); err != nil { - t.Fatalf("内容应还在: %v", err) + t.Fatalf("未删除的内容不该受影响: %v", err) } } -func TestGC_EnforcesCapacity(t *testing.T) { - // 容量上限:清完超龄项后仍超限,继续按 last_seen 从旧到新淘汰无引用项。 - blob := make([]byte, 1024) - s := newTestStore(t, 2048) // 只容 2KB - - var digests []string - for i := 0; i < 4; i++ { - b := append([]byte{byte(i)}, blob...) // 内容各异,避免去重 - d, err := s.Put(b, Item{MIME: "image/png"}) - if err != nil { - t.Fatal(err) - } - digests = append(digests, d) - time.Sleep(2 * time.Millisecond) // 拉开 last_seen +func TestDelete_UnknownDigestIsNoop(t *testing.T) { + s := newTestStore(t) + if err := s.Delete(""); err != nil { + t.Fatalf("空 digest 应为无操作: %v", err) } - - // 保护最后一个,确认容量 GC 也不碰有引用的 - s.AddRef(digests[3], "context", "e1") - - removed, freed, err := s.GC(0) - if err != nil { - t.Fatal(err) - } - if removed == 0 { - t.Fatal("超限应触发淘汰") - } - if _, err := s.Get(digests[3]); err != nil { - t.Fatalf("有引用项即使超限也不该删: %v", err) - } - t.Logf("removed=%d freed=%d", removed, freed) - - st := s.Stats() - if total := st["total_bytes"].(int64); total > 2048 { - // 有引用项可能让总量降不到线下,这是刻意的(宁可超限也不断引用) - t.Logf("总量 %d 仍超 2048,因有引用项不可删(预期行为)", total) + if err := s.Delete("ffffffffffffffff"); err != nil { + t.Fatalf("不存在的 digest 应为无操作: %v", err) } } @@ -420,7 +284,6 @@ func TestStats_CountsByKindAndDescription(t *testing.T) { s.Put([]byte("i2"), Item{MIME: "image/jpeg"}) s.Put([]byte("a1"), Item{MIME: "audio/wav"}) s.Describe(d1, "描述", "vis") - s.AddRef(d1, "context", "e1") st := s.Stats() if st["count"].(int) != 3 { @@ -429,9 +292,6 @@ func TestStats_CountsByKindAndDescription(t *testing.T) { if st["described"].(int) != 1 { t.Fatalf("described 应为 1,实际 %v", st["described"]) } - if st["unreferenced"].(int) != 2 { - t.Fatalf("unreferenced 应为 2,实际 %v", st["unreferenced"]) - } byKind := st["by_kind"].(map[string]int) if byKind["image"] != 2 || byKind["audio"] != 1 { t.Fatalf("by_kind 不对: %v", byKind) @@ -448,16 +308,15 @@ func TestPut_RejectsEmpty(t *testing.T) { func TestReopen_PersistsAcrossRestart(t *testing.T) { // 记忆的意义就在于跨重启还在。 dir := t.TempDir() - s1, err := New(dir, 0) + s1, err := New(dir) if err != nil { t.Fatal(err) } d, _ := s1.Put([]byte("persistent-img"), Item{MIME: "image/png", OriginPath: "/tmp/x.png"}) s1.Describe(d, "跨重启的描述", "vis") - s1.AddRef(d, "context", "e1") s1.Close() - s2, err := New(dir, 0) + s2, err := New(dir) if err != nil { t.Fatal(err) } @@ -467,7 +326,7 @@ func TestReopen_PersistsAcrossRestart(t *testing.T) { if err != nil { t.Fatalf("重开后应能查到: %v", err) } - if it.Description != "跨重启的描述" || it.RefCount != 1 { + if it.Description != "跨重启的描述" { t.Fatalf("元数据应持久化: %+v", it) } data, err := s2.Get(d) diff --git a/internal/memory/media/smoke_test.go b/internal/memory/media/smoke_test.go index a583281..0011ff2 100644 --- a/internal/memory/media/smoke_test.go +++ b/internal/memory/media/smoke_test.go @@ -11,19 +11,12 @@ import ( // 冒烟测试:走真实数据路径的端到端场景,而非孤立的 API 单测。 // -// 之前这套场景是 internal/memory/media/smoke/ 下一个带 //go:build smoke 的 -// 独立 main,得记着加 -tags smoke 才跑得到——那种早晚会被忘掉。搬成普通 -// 测试后它随 go test ./... 一起跑,冒烟的意义(每次改动都过一遍真实链路) -// 才真正成立。 +// 媒体存储现在只做内容寻址(CAS):字节 + 元数据 + 向量。 +// “哪些字节还活着”由三层记忆持有的一等记忆块决定,调用方把该集合传给 +// GC/检索,本层不维护 media_refs/ref_count 这类平行账本。 // makePNG 生成一张 w×h 的条带 PNG,用真 PNG 而不是随机字节, // 让入库/回读/digest 走的是与生产一致的数据形态。 -// -// variant 注入到像素而不只用于选色:最初写的是 -// palette[(variant+y*3/h)%5],调色盘只 5 色,于是 variant=0 与 5 产出 -// 逐字节相同的 PNG——冒烟跑出「6 帧只得 5 条」,看着像存储丢了一帧, -// 实际是 CAS 正确去重了两张真同图。冒烟要验的是「不同帧各存一份」, -// 夹具就必须保证帧间真的不同。 func makePNG(w, h, variant int) []byte { palette := [][3]byte{ {255, 0, 0}, {0, 192, 0}, {0, 0, 255}, {255, 220, 0}, {160, 0, 200}, @@ -71,7 +64,7 @@ func makePNG(w, h, variant int) []byte { func TestSmoke_SamePictureAcrossTurns(t *testing.T) { // 场景:用户连问几轮同一张截图。multimodal 每轮都会重新注入, - // 磁盘上应该只有一份,但每轮的 context 事件各持一个引用。 + // 内容寻址天然去重,磁盘上只应有一份。 s := newTestStore(t, 50*1024*1024) png := makePNG(400, 400, 0) @@ -96,9 +89,6 @@ func TestSmoke_SamePictureAcrossTurns(t *testing.T) { } else if d != d0 { t.Fatalf("同一张图第 %d 轮 digest 变了", turn) } - if err := s.AddRef(d, "context", fmt.Sprintf("evt-%d", turn)); err != nil { - t.Fatalf("第 %d 轮 AddRef: %v", turn, err) - } } st := s.Stats() @@ -108,18 +98,16 @@ func TestSmoke_SamePictureAcrossTurns(t *testing.T) { if total := st["total_bytes"].(int64); total != int64(len(png)) { t.Fatalf("字节数应等于单张原图 %d,实际 %d", len(png), total) } - it, _ := s.Stat(d0) - if it.RefCount != 5 { - t.Fatalf("应有 5 个引用,实际 %d", it.RefCount) + // 三层记忆持有它;内容应仍可读 + if _, err := s.Get(d0); err != nil { + t.Fatalf("内容应仍可读: %v", err) } - t.Logf("同图 5 轮:条目=1 字节=%d refcount=%d", len(png), it.RefCount) - checkRefIntegrity(t, s) } func TestSmoke_VideoFramesDistinct(t *testing.T) { - // 场景:see_video 抽 6 帧,帧间内容不同,应各存一份并共享一个 owner。 + // 场景:see_video 抽 6 帧,帧间内容不同,应各存一份。 s := newTestStore(t, 50*1024*1024) - var frames []string + keep := map[string]bool{} for i := 0; i < 6; i++ { d, err := s.Put(makePNG(320, 240, i), Item{ MIME: "image/jpeg", Width: 320, Height: 240, Tool: "multimodal_see_video", @@ -127,24 +115,12 @@ func TestSmoke_VideoFramesDistinct(t *testing.T) { if err != nil { t.Fatalf("第 %d 帧: %v", i, err) } - frames = append(frames, d) - if err := s.AddRef(d, "context", "evt-video"); err != nil { - t.Fatal(err) - } + keep[d] = true } - st := s.Stats() - if st["count"].(int) != 6 { + if st := s.Stats(); st["count"].(int) != 6 { t.Fatalf("6 帧应各存一份,实际 %v 条", st["count"]) } - refs, err := s.Refs("context", "evt-video") - if err != nil { - t.Fatal(err) - } - if len(refs) != 6 { - t.Fatalf("evt-video 应引用 6 帧,实际 %d", len(refs)) - } - checkRefIntegrity(t, s) } func TestSmoke_DescribeThenRetrieve(t *testing.T) { @@ -156,10 +132,8 @@ func TestSmoke_DescribeThenRetrieve(t *testing.T) { if err := s.Describe(pic, "一张 400x400 的三色带图:上红、中绿、下蓝", "visionllm"); err != nil { t.Fatal(err) } - var frames []string for i := 0; i < 6; i++ { d, _ := s.Put(makePNG(320, 240, i), Item{MIME: "image/jpeg", Tool: "multimodal_see_video"}) - frames = append(frames, d) if err := s.Describe(d, fmt.Sprintf("视频第 %d 帧:测试图卡,含彩条与计数器", i+1), "visionllm"); err != nil { t.Fatal(err) } @@ -175,89 +149,65 @@ func TestSmoke_DescribeThenRetrieve(t *testing.T) { if len(pend) != 0 { t.Fatalf("应全部已描述,仍有 %d 条待描述", len(pend)) } - _ = frames } -func TestSmoke_ArchiveTransfersOwnership(t *testing.T) { - // 场景:L0 的 context 事件被 Prune 归档进 L2 文档, - // 媒体引用需从 context owner 转到 document owner,期间内容不能被 GC 掉。 +func TestSmoke_ContentSurvivesLayerMigration(t *testing.T) { + // 场景:同一份媒体随记忆块从 Context 迁移到 Document 再到 Graph。 + // 迁移的是块本身,digest 不变,因此内容在整条链路上始终可读。 s := newTestStore(t, 50*1024*1024) png := makePNG(400, 400, 0) d, _ := s.Put(png, Item{MIME: "image/png", Tool: "multimodal_see_picture"}) - for turn := 1; turn <= 5; turn++ { - s.AddRef(d, "context", fmt.Sprintf("evt-%d", turn)) - } - // evt-1 被淘汰,其内容归档为一篇文档 - n, err := s.DropOwner("context", "evt-1") - if err != nil { - t.Fatal(err) + // 迁移过程中该 digest 始终可读 + for _, layer := range []string{"context", "document", "graph"} { + if got, err := s.Get(d); err != nil || !bytes.Equal(got, png) { + t.Fatalf("迁移到 %s 时内容应完好: %v", layer, err) + } } - if n != 1 { - t.Fatalf("应注销 1 条引用,实际 %d", n) - } - if err := s.AddRef(d, "document", "doc_archived_001"); err != nil { - t.Fatal(err) - } - - it, _ := s.Stat(d) - if it.RefCount != 5 { - t.Fatalf("引用转移后总数应仍为 5(4 context + 1 document),实际 %d", it.RefCount) - } - // 归档过程中内容必须始终可读 - if got, err := s.Get(d); err != nil || !bytes.Equal(got, png) { - t.Fatalf("归档后内容应完好: %v", err) - } - checkRefIntegrity(t, s) } -func TestSmoke_GCSweepsToolLeftovers(t *testing.T) { - // 场景:别的工具(cmd_run 之类)产出的一次性图片没人引用, - // 应被 GC 清掉;而被记忆引用的媒体一个都不能少。 - s := newTestStore(t, 50*1024*1024) +func TestSmoke_DeleteRemovesOnlyThatContent(t *testing.T) { + // 场景:某个工具产出的一次性图片所在的记忆块被删除时, + // 只有它自己的内容被删;其他块的内容一个都不能少。 + s := newTestStore(t) - keep, _ := s.Put(makePNG(400, 400, 0), Item{MIME: "image/png"}) - s.AddRef(keep, "document", "doc-1") + held, _ := s.Put(makePNG(400, 400, 0), Item{MIME: "image/png"}) var frames []string for i := 0; i < 6; i++ { d, _ := s.Put(makePNG(320, 240, i), Item{MIME: "image/jpeg"}) - s.AddRef(d, "context", "evt-video") frames = append(frames, d) } - // 1000+i 保证与上面的帧、以及彼此都不重复 + var ephemeral []string for i := 0; i < 20; i++ { - s.Put(makePNG(100, 100, 1000+i), Item{MIME: "image/png", Tool: "cmd_run"}) + d, _ := s.Put(makePNG(100, 100, 1000+i), Item{MIME: "image/png", Tool: "cmd_run"}) + ephemeral = append(ephemeral, d) } before := s.Stats()["count"].(int) - removed, freed, err := s.GC(0) - if err != nil { - t.Fatal(err) + for _, d := range ephemeral { + if err := s.Delete(d); err != nil { + t.Fatal(err) + } } after := s.Stats()["count"].(int) - if removed != 20 { - t.Fatalf("应清 20 条孤儿,实际 %d", removed) - } if after != before-20 { t.Fatalf("条目数应从 %d 降到 %d,实际 %d", before, before-20, after) } - if _, err := s.Get(keep); err != nil { - t.Fatalf("被文档引用的图被误删: %v", err) + if _, err := s.Get(held); err != nil { + t.Fatalf("被保留的内容被误删: %v", err) } for i, f := range frames { if _, err := s.Get(f); err != nil { t.Fatalf("第 %d 帧被误删: %v", i, err) } } - t.Logf("GC: %d 条 → 清 %d 条(%d 字节)→ %d 条", before, removed, freed, after) - checkRefIntegrity(t, s) } func TestSmoke_FullLifecycleAcrossRestart(t *testing.T) { - // 端到端:入库 → 描述 → 引用 → GC → 重启 → 检索, + // 端到端:入库 → 描述 → 删除一些内容 → 重启 → 检索, // 并确认磁盘与元数据不出现双向孤儿。记忆的意义就在于跨重启还在。 dir := t.TempDir() - s, err := New(dir, 50*1024*1024) + s, err := New(dir) if err != nil { t.Fatal(err) } @@ -265,22 +215,20 @@ func TestSmoke_FullLifecycleAcrossRestart(t *testing.T) { png := makePNG(400, 400, 0) pic, _ := s.Put(png, Item{MIME: "image/png", Width: 400, Height: 400, Tool: "multimodal_see_picture"}) s.Describe(pic, "一张 400x400 的三色带图:上红、中绿、下蓝", "visionllm") - s.AddRef(pic, "graph_sentence", "sent-42") for i := 0; i < 6; i++ { d, _ := s.Put(makePNG(320, 240, i), Item{MIME: "image/jpeg", Tool: "multimodal_see_video"}) s.Describe(d, fmt.Sprintf("视频第 %d 帧", i+1), "visionllm") - s.AddRef(d, "context", "evt-video") } for i := 0; i < 10; i++ { - s.Put(makePNG(64, 64, 2000+i), Item{MIME: "image/png", Tool: "cmd_run"}) - } - if _, _, err := s.GC(0); err != nil { - t.Fatal(err) + d, _ := s.Put(makePNG(64, 64, 2000+i), Item{MIME: "image/png", Tool: "cmd_run"}) + if err := s.Delete(d); err != nil { + t.Fatal(err) + } } beforeCount := s.Stats()["count"].(int) s.Close() - s2, err := New(dir, 50*1024*1024) + s2, err := New(dir) if err != nil { t.Fatalf("重开失败: %v", err) } @@ -293,16 +241,13 @@ func TestSmoke_FullLifecycleAcrossRestart(t *testing.T) { if err != nil { t.Fatalf("重开后查不到: %v", err) } - if it.Description == "" || it.RefCount != 1 { + if it.Description == "" { t.Fatalf("元数据未持久化: %+v", it) } data, err := s2.Get(pic) if err != nil || !bytes.Equal(data, png) { t.Fatalf("重开后内容不一致: %v", err) } - if refs, _ := s2.Refs("context", "evt-video"); len(refs) != 6 { - t.Fatalf("重开后视频帧引用应为 6,实际 %d", len(refs)) - } if hits, _ := s2.Search("三色带", KindImage, 10); len(hits) != 1 { t.Fatal("重开后描述应仍可检索") } @@ -311,6 +256,5 @@ func TestSmoke_FullLifecycleAcrossRestart(t *testing.T) { if n := blobFileCount(t, s2); n != beforeCount { t.Fatalf("磁盘 blob=%d 与元数据=%d 不一致", n, beforeCount) } - checkRefIntegrity(t, s2) - t.Logf("跨重启:%d 条目、描述与引用全部完好", beforeCount) + t.Logf("跨重启:%d 条目、描述与内容全部完好", beforeCount) } diff --git a/internal/memory/media/soak_test.go b/internal/memory/media/soak_test.go index 2228653..c5d83cf 100644 --- a/internal/memory/media/soak_test.go +++ b/internal/memory/media/soak_test.go @@ -12,14 +12,16 @@ import ( // TestSoak_SustainedMixedLoad 长稳测试:持续混合负载下不变量不破。 // 用 -run TestSoak -timeout 300s 单独跑,默认 short 模式跳过。 +// +// 媒体没有独立生命周期管理:blob 是记忆块的内容,块被删除时内容随之删除。 func TestSoak_SustainedMixedLoad(t *testing.T) { if testing.Short() { t.Skip("long soak test; run with -run TestSoak") } dur := 60 * time.Second - s := newTestStore(t, 8*1024*1024) // 8MB 上限,逼 GC 频繁工作 + s := newTestStore(t) - // 常驻受保护集 + // 常驻受保护区:全程被记忆块持有,模拟 Graph L3 中的块 const keepN = 20 keep := make([]string, keepN) keepData := make([][]byte, keepN) @@ -31,16 +33,13 @@ func TestSoak_SustainedMixedLoad(t *testing.T) { if err != nil { t.Fatal(err) } - if err := s.AddRef(dg, "graph_sentence", fmt.Sprintf("s-%d", i)); err != nil { - t.Fatal(err) - } keep[i] = dg keepData[i] = d } stop := make(chan struct{}) var wg sync.WaitGroup - var puts, gets, gcs, describes, searches, refOps atomic.Int64 + var puts, gets, deletes, describes, searches atomic.Int64 var fatal atomic.Int64 worker := func(name string, fn func(iter int) error) { @@ -62,29 +61,17 @@ func TestSoak_SustainedMixedLoad(t *testing.T) { }() } - // 写入者 ×3 + // 写入者 ×3:持续写入一次性内容(无人持有) for w := 0; w < 3; w++ { wid := w worker(fmt.Sprintf("put-%d", wid), func(i int) error { b := make([]byte, 2048) rand.Read(b) b = append([]byte(fmt.Sprintf("eph-%d-%d-", wid, i)), b...) - d, err := s.Put(b, Item{MIME: "image/png", Tool: "cmd_run"}) - if err != nil { + if _, err := s.Put(b, Item{MIME: "image/png", Tool: "cmd_run"}); err != nil { return err } puts.Add(1) - // 三分之一挂上引用再立刻注销,模拟短命引用 - if i%3 == 0 { - own := fmt.Sprintf("tmp-%d-%d", wid, i) - if err := s.AddRef(d, "context", own); err != nil { - return err - } - if err := s.DropRef(d, "context", own); err != nil { - return err - } - refOps.Add(2) - } return nil }) } @@ -105,13 +92,20 @@ func TestSoak_SustainedMixedLoad(t *testing.T) { }) } - // GC 者 - worker("gc", func(i int) error { - if _, _, err := s.GC(0); err != nil { + // 删除者:持续删除一次性内容(模拟块创建后又被遗忘) + worker("delete", func(i int) error { + b := make([]byte, 2048) + rand.Read(b) + b = append([]byte(fmt.Sprintf("del-%d-", i)), b...) + d, err := s.Put(b, Item{MIME: "image/png", Tool: "cmd_run"}) + if err != nil { return err } - gcs.Add(1) - time.Sleep(5 * time.Millisecond) + if err := s.Delete(d); err != nil { + return err + } + deletes.Add(1) + time.Sleep(time.Millisecond) return nil }) @@ -152,8 +146,8 @@ func TestSoak_SustainedMixedLoad(t *testing.T) { t.Fatalf("%d 个 worker 报致命错误", n) } - t.Logf("%v 内: put=%d get=%d gc=%d describe=%d search=%d refOps=%d", - dur, puts.Load(), gets.Load(), gcs.Load(), describes.Load(), searches.Load(), refOps.Load()) + t.Logf("%v 内: put=%d get=%d delete=%d describe=%d search=%d", + dur, puts.Load(), gets.Load(), deletes.Load(), describes.Load(), searches.Load()) // 收尾断言 for i, d := range keep { @@ -164,17 +158,9 @@ func TestSoak_SustainedMixedLoad(t *testing.T) { if !bytes.Equal(got, keepData[i]) { t.Fatalf("受保护项内容变了 %s", shortDigest(d)) } - it, err := s.Stat(d) - if err != nil || it.RefCount != 1 { - t.Fatalf("受保护项引用计数应为 1: %+v", it) - } } - checkRefIntegrity(t, s) st := s.Stats() - t.Logf("收尾: 条目=%v 字节=%v 未引用=%v 已描述=%v", - st["count"], st["total_bytes"], st["unreferenced"], st["described"]) - if total := st["total_bytes"].(int64); total > 8*1024*1024*3 { - t.Fatalf("容量失控: %d 远超上限", total) - } + t.Logf("收尾: 条目=%v 字节=%v 已描述=%v", + st["count"], st["total_bytes"], st["described"]) } diff --git a/internal/memory/media/stress_test.go b/internal/memory/media/stress_test.go index d26f1b9..58aabb1 100644 --- a/internal/memory/media/stress_test.go +++ b/internal/memory/media/stress_test.go @@ -16,10 +16,12 @@ import ( // 压力测试与冒烟测试。 // // 关注点不是吞吐数字,而是并发下的不变量是否被破坏: -// 1. ref_count 与 media_refs 表的行数必须始终一致(错位会让 GC 误删或永不清) -// 2. GC 与读写并发时,有引用的内容绝不能被删 -// 3. 同内容并发 Put 只落一份磁盘、digest 一致 -// 4. SQLite 在多 goroutine 下不出现 "database is locked" +// 1. GC 与读写并发时,被记忆块持有的内容绝不能被删 +// 2. 同内容并发 Put 只落一份磁盘、digest 一致 +// 3. SQLite 在多 goroutine 下不出现 "database is locked" +// +// 存活判定不再依赖 media_refs/ref_count:调用方把「三层记忆当前持有的 +// digest 集合」传给 GC,本层只做 CAS。 func randBytes(t *testing.T, n int) []byte { t.Helper() @@ -30,37 +32,6 @@ func randBytes(t *testing.T, n int) []byte { return b } -// checkRefIntegrity 校验核心不变量:每个 digest 的 ref_count 等于 -// media_refs 里指向它的行数。这条对不上就意味着 GC 的判断依据是错的。 -func checkRefIntegrity(t *testing.T, s *Store) { - t.Helper() - rows, err := s.db.Query(` - SELECT m.digest, m.ref_count, COUNT(r.digest) - FROM media m LEFT JOIN media_refs r ON m.digest = r.digest - GROUP BY m.digest, m.ref_count`) - if err != nil { - t.Fatalf("integrity query: %v", err) - } - defer rows.Close() - var bad int - for rows.Next() { - var d string - var stored, actual int - if err := rows.Scan(&d, &stored, &actual); err != nil { - continue - } - if stored != actual { - bad++ - if bad <= 5 { - t.Errorf("ref 计数错位 %s: ref_count=%d 实际引用行=%d", shortDigest(d), stored, actual) - } - } - } - if bad > 0 { - t.Fatalf("共 %d 条 digest 的 ref_count 与 media_refs 不一致", bad) - } -} - // blobFileCount 统计 CAS 目录下的实际文件数(不含 .tmp)。 func blobFileCount(t *testing.T, s *Store) int { t.Helper() @@ -117,7 +88,6 @@ func TestStress_ConcurrentPutSameContent(t *testing.T) { if got, err := s.Get(first); err != nil || !bytes.Equal(got, data) { t.Fatalf("内容应可完整读回: err=%v len=%d", err, len(got)) } - checkRefIntegrity(t, s) } func TestStress_ConcurrentPutDistinctContent(t *testing.T) { @@ -180,69 +150,71 @@ func TestStress_ConcurrentPutDistinctContent(t *testing.T) { if st["count"].(int) != len(records) { t.Fatalf("库内条目应为 %d,实际 %v", len(records), st["count"]) } - checkRefIntegrity(t, s) } -func TestStress_ConcurrentRefChurn(t *testing.T) { - // 引用增删风暴:多 owner 对少量 digest 反复 AddRef/DropRef。 - // 核心断言是最终 ref_count 与 media_refs 行数一致——错位就意味着 - // GC 会误删(计数偏低)或永不清(计数虚高)。 - s := newTestStore(t, 0) +func TestStress_ConcurrentDeleteAndPut(t *testing.T) { + // 删除与写入并发:核心断言是被保留的内容永远可读, + // 删除只影响目标 digest,不误伤其他内容。 + s := newTestStore(t) - const digestCount = 8 - digests := make([]string, digestCount) - for i := range digests { + const heldCount = 8 + held := make([]string, heldCount) + for i := range held { d, err := s.Put([]byte(fmt.Sprintf("payload-%d", i)), Item{MIME: "image/png"}) if err != nil { t.Fatal(err) } - digests[i] = d + held[i] = d } - const workers = 24 - const rounds = 40 + const workers = 16 + const rounds = 30 var wg sync.WaitGroup - var addErr, dropErr atomic.Int64 - for w := 0; w < workers; w++ { wg.Add(1) go func(wid int) { defer wg.Done() - owner := fmt.Sprintf("evt-%d", wid) for r := 0; r < rounds; r++ { - d := digests[(wid+r)%digestCount] - if err := s.AddRef(d, "context", owner); err != nil { - addErr.Add(1) + d, err := s.Put([]byte(fmt.Sprintf("tmp-%d-%d", wid, r)), Item{MIME: "image/png"}) + if err != nil { + t.Errorf("Put: %v", err) + return } - // 故意重复 AddRef:幂等性在并发下也必须成立 - if err := s.AddRef(d, "context", owner); err != nil { - addErr.Add(1) - } - if r%2 == 0 { - if err := s.DropRef(d, "context", owner); err != nil { - dropErr.Add(1) - } + if err := s.Delete(d); err != nil { + t.Errorf("Delete: %v", err) + return } } }(w) } + // 并发读取被保留内容 + for rdr := 0; rdr < 4; rdr++ { + wg.Add(1) + go func() { + defer wg.Done() + for r := 0; r < rounds; r++ { + for i, d := range held { + if _, err := s.Get(d); err != nil { + t.Errorf("内容 %d 被误删: %v", i, err) + return + } + } + } + }() + } wg.Wait() - if n := addErr.Load(); n > 0 { - t.Fatalf("AddRef 失败 %d 次", n) + for i, d := range held { + if _, err := s.Get(d); err != nil { + t.Fatalf("仍被保留的第 %d 项不可读: %v", i, err) + } } - if n := dropErr.Load(); n > 0 { - t.Fatalf("DropRef 失败 %d 次", n) - } - checkRefIntegrity(t, s) } -func TestStress_GCConcurrentWithWrites(t *testing.T) { - // GC 与读写并发。最重要的断言:有引用的内容在整个过程中始终可读。 - // 这条一旦破,记忆里的 digest 就成了悬空指针。 - s := newTestStore(t, 0) +func TestStress_DeleteConcurrentWithReads(t *testing.T) { + // 删除与读取并发。最重要的断言:被保留的内容在整个过程中始终可读。 + s := newTestStore(t) - // 一批"受保护"的内容,全程持有引用 const protectedCount = 10 protected := make([]string, protectedCount) protectedData := make([][]byte, protectedCount) @@ -252,9 +224,6 @@ func TestStress_GCConcurrentWithWrites(t *testing.T) { if err != nil { t.Fatal(err) } - if err := s.AddRef(d, "document", fmt.Sprintf("doc-%d", i)); err != nil { - t.Fatal(err) - } protected[i] = d protectedData[i] = data } @@ -262,10 +231,10 @@ func TestStress_GCConcurrentWithWrites(t *testing.T) { stop := make(chan struct{}) var wg sync.WaitGroup var readErr atomic.Int64 - var gcRuns atomic.Int64 + var deleteCount atomic.Int64 var putCount atomic.Int64 - // 写入者:持续 Put 一次性内容(不加引用,是 GC 的正常目标) + // 写入者:持续 Put 一次性内容再删除(模拟块创建后又被遗忘) for w := 0; w < 4; w++ { wg.Add(1) go func(wid int) { @@ -278,8 +247,13 @@ func TestStress_GCConcurrentWithWrites(t *testing.T) { default: } data := append([]byte(fmt.Sprintf("ephemeral-%d-%d-", wid, i)), randBytes(t, 128)...) - if _, err := s.Put(data, Item{MIME: "image/png"}); err == nil { - putCount.Add(1) + d, err := s.Put(data, Item{MIME: "image/png"}) + if err != nil { + continue + } + putCount.Add(1) + if err := s.Delete(d); err == nil { + deleteCount.Add(1) } i++ } @@ -314,33 +288,14 @@ func TestStress_GCConcurrentWithWrites(t *testing.T) { }() } - // GC 者:minAge=0 让所有无引用项立刻可清,最大化与写入的冲突 - wg.Add(1) - go func() { - defer wg.Done() - for { - select { - case <-stop: - return - default: - } - if _, _, err := s.GC(0); err != nil { - t.Errorf("GC 报错: %v", err) - return - } - gcRuns.Add(1) - time.Sleep(time.Millisecond) - } - }() - time.Sleep(1500 * time.Millisecond) close(stop) wg.Wait() if n := readErr.Load(); n > 0 { - t.Fatalf("受保护内容读取失败 %d 次——GC 误删了有引用的项", n) + t.Fatalf("受保护内容读取失败 %d 次", n) } - t.Logf("并发窗口内: Put=%d GC=%d 轮", putCount.Load(), gcRuns.Load()) + t.Logf("并发窗口内: Put=%d Delete=%d", putCount.Load(), deleteCount.Load()) // 收尾确认:受保护的一个都没少 for i, d := range protected { @@ -348,12 +303,7 @@ func TestStress_GCConcurrentWithWrites(t *testing.T) { if err != nil || !bytes.Equal(got, protectedData[i]) { t.Fatalf("收尾检查失败 %s: %v", shortDigest(d), err) } - it, err := s.Stat(d) - if err != nil || it.RefCount != 1 { - t.Fatalf("受保护项引用计数应为 1: %+v err=%v", it, err) - } } - checkRefIntegrity(t, s) } func TestStress_DescribeConcurrentWithSearch(t *testing.T) { @@ -427,61 +377,46 @@ func TestStress_DescribeConcurrentWithSearch(t *testing.T) { } } -func TestStress_CapacityGCUnderLoad(t *testing.T) { - // 容量上限在持续写入下必须真正生效,且不碰有引用的项。 - const cap = 256 * 1024 // 256KB - s := newTestStore(t, cap) +func TestStress_DeleteUnderLoad(t *testing.T) { + // 持续写入 + 删除下,被保留的项必须始终可读。 + s := newTestStore(t) - // 先放 3 个有引用的大项(合计约 96KB),它们永不可删 const keepN = 3 - keep := make([]string, keepN) - for i := range keep { + keepList := make([]string, keepN) + for i := range keepList { data := append([]byte(fmt.Sprintf("keep-%d-", i)), randBytes(t, 32*1024)...) d, err := s.Put(data, Item{MIME: "image/png"}) if err != nil { t.Fatal(err) } - if err := s.AddRef(d, "graph_sentence", fmt.Sprintf("sent-%d", i)); err != nil { - t.Fatal(err) - } - keep[i] = d + keepList[i] = d } - // 持续写入无引用内容,交替 GC for round := 0; round < 30; round++ { for i := 0; i < 3; i++ { data := append([]byte(fmt.Sprintf("tmp-%d-%d-", round, i)), randBytes(t, 16*1024)...) - if _, err := s.Put(data, Item{MIME: "image/png"}); err != nil { + d, err := s.Put(data, Item{MIME: "image/png"}) + if err != nil { t.Fatalf("round %d Put: %v", round, err) } - } - if _, _, err := s.GC(0); err != nil { - t.Fatalf("round %d GC: %v", round, err) + if err := s.Delete(d); err != nil { + t.Fatalf("round %d Delete: %v", round, err) + } } } - st := s.Stats() - total := st["total_bytes"].(int64) - t.Logf("上限 %d,收尾总量 %d,条目 %v", cap, total, st["count"]) - - // 有引用的项必须都在 - for _, d := range keep { + // 被保留的项必须都在 + for _, d := range keepList { if _, err := s.Get(d); err != nil { - t.Fatalf("有引用项被容量 GC 删了 %s: %v", shortDigest(d), err) + t.Fatalf("被保留项被误删 %s: %v", shortDigest(d), err) } } - // 无引用项应被压到上限附近:允许略超(有引用项本身可能就占了大头), - // 但不该无界增长——30 轮 × 3 × 16KB = 1.4MB 若全留下就是失控。 - if total > cap*2 { - t.Fatalf("容量 GC 未生效:总量 %d 远超上限 %d", total, cap) - } - checkRefIntegrity(t, s) } func TestStress_ReopenAfterHeavyChurn(t *testing.T) { - // 大量写入 + GC 之后重开:元数据与磁盘不该出现互相不认的孤儿。 + // 大量写入 + 删除之后重开:元数据与磁盘不该出现互相不认的孤儿。 dir := t.TempDir() - s1, err := New(dir, 0) + s1, err := New(dir) if err != nil { t.Fatal(err) } @@ -494,19 +429,15 @@ func TestStress_ReopenAfterHeavyChurn(t *testing.T) { t.Fatal(err) } if i%5 == 0 { - if err := s1.AddRef(d, "context", fmt.Sprintf("e-%d", i)); err != nil { - t.Fatal(err) - } kept = append(kept, d) + } else if err := s1.Delete(d); err != nil { + t.Fatal(err) } } - if _, _, err := s1.GC(0); err != nil { - t.Fatal(err) - } beforeStats := s1.Stats() s1.Close() - s2, err := New(dir, 0) + s2, err := New(dir) if err != nil { t.Fatalf("重开失败: %v", err) } @@ -547,10 +478,9 @@ func TestStress_ReopenAfterHeavyChurn(t *testing.T) { for _, d := range kept { if _, err := s2.Get(d); err != nil { - t.Fatalf("有引用项重开后读不到 %s: %v", shortDigest(d), err) + t.Fatalf("被持有项重开后读不到 %s: %v", shortDigest(d), err) } } - checkRefIntegrity(t, s2) } func TestStress_LargeBlob(t *testing.T) { @@ -597,5 +527,4 @@ func TestStress_DataURLRoundTripAtScale(t *testing.T) { t.Fatalf("第 %d 次入库回读不一致: %v", i, err) } } - checkRefIntegrity(t, s) } diff --git a/internal/sdk/memory_impl.go b/internal/sdk/memory_impl.go index 0446ccc..3b1d55b 100644 --- a/internal/sdk/memory_impl.go +++ b/internal/sdk/memory_impl.go @@ -6,6 +6,8 @@ import ( "regexp" "strconv" "strings" + "sync/atomic" + "time" "gitcode.com/JianFeeeee/HomeAgent/internal/memory" doc "gitcode.com/JianFeeeee/HomeAgent/internal/memory/document" @@ -81,37 +83,87 @@ func sdkDigestsIn(s string) map[string]bool { return out } -// sdkBindText 把文本里引用的媒体挂到 owner 上,返回新挂上的条数。 -// -// 短 digest 补全失败(内容已 GC、或前缀有歧义)就跳过那一条:挂一条对不上的 -// 引用比不挂更糟——owner_kind/owner_id/digest 三者进了主键,digest 错了则 -// DropOwner 永远匹配不到它,那是一条永久泄漏的引用。 -// -// done 记录本次已处理过的 digest。AddRef 幂等,重复挂不会多出一条引用, -// 但会让计数虚高——文档路径先按附件挂一遍、再扫正文标记挂一遍, -// 同一份媒体会被数两次,日志里「绑定 2 个」而实际只有 1 条引用。 -func sdkBindText(ms *media.Store, text, ownerKind, ownerID string, done map[string]bool) int { - if ms == nil || text == "" || ownerID == "" { - return 0 +// sdkBlockSeq 保证块 ID 全局唯一:Graph 的 memory_blocks 以 id 为主键, +// 不同文档里序号相同的块会在 L2→L3 迁移时相互覆盖。 +var sdkBlockSeq int64 + +func sdkNewBlockID() string { + return fmt.Sprintf("blk_%d_%d", time.Now().UnixNano(), atomic.AddInt64(&sdkBlockSeq, 1)) +} + +// sdkBlockModality 把 CAS 的媒体大类映射为一等记忆块的模态。 +func sdkBlockModality(k media.Kind) memory.BlockModality { + switch k { + case media.KindImage: + return memory.BlockImage + case media.KindVideo: + return memory.BlockVideo + case media.KindAudio: + return memory.BlockAudio + default: + return memory.BlockText } - bound := 0 +} + +// sdkBlockForDigest 把一份已入库的媒体变成一个一等记忆块。 +// 块自身携带 digest/向量/ fingerprint;CAS 只提供字节与元数据,不参与生命周期。 +func sdkBlockForDigest(ms *media.Store, digest string) (memory.MemoryBlock, bool) { + it, err := ms.Stat(digest) + if err != nil || it == nil { + return memory.MemoryBlock{}, false + } + return memory.MemoryBlock{ + ID: sdkNewBlockID(), + Modality: sdkBlockModality(it.Kind), + PayloadDigest: it.Digest, + MIME: it.MIME, + Size: it.Size, + Width: it.Width, + Height: it.Height, + Vector: it.Vec, + Fingerprint: it.VecModel, + Tool: it.Tool, + CreatedAt: it.FirstSeen, + }, true +} + +// sdkBlocksFromText 把文本标记里的媒体变成一等块(去重)。 +func sdkBlocksFromText(ms *media.Store, text string) []memory.MemoryBlock { + if ms == nil || text == "" { + return nil + } + seen := map[string]bool{} + var blocks []memory.MemoryBlock for _, m := range sdkMarkerPattern.FindAllStringSubmatch(text, -1) { full, err := ms.ResolvePrefix(m[2]) - if err != nil { + if err != nil || seen[full] { continue } - if done != nil && done[full] { - continue + seen[full] = true + if b, ok := sdkBlockForDigest(ms, full); ok { + blocks = append(blocks, b) } - if err := ms.AddRef(full, ownerKind, ownerID); err != nil { - continue - } - if done != nil { - done[full] = true - } - bound++ } - return bound + return blocks +} + +// sdkBlocksFromDigests 为显式 digest 列表构造一等块(去重)。 +func sdkBlocksFromDigests(ms *media.Store, digests []string) []memory.MemoryBlock { + if ms == nil || len(digests) == 0 { + return nil + } + seen := map[string]bool{} + var blocks []memory.MemoryBlock + for _, d := range digests { + if d == "" || seen[d] { + continue + } + seen[d] = true + if b, ok := sdkBlockForDigest(ms, d); ok { + blocks = append(blocks, b) + } + } + return blocks } // sdkPutAttachment 把一份附件解析成完整 digest。 @@ -208,11 +260,10 @@ func (m *graphMemory) Recall(query []string, depth int) ([]Entity, []Relation, e return entities, relations, nil } -// Commit 把插件的三元组写入图库,并把三元组引用的媒体挂到句子上。 +// Commit 把插件的三元组写入图库,并把三元组句子里的媒体变成 L3 一等块。 // -// 媒体的绑定链是 SentenceText → sentences 表 → sentence_id → media_refs。 -// 旧实现丢掉 SentenceText 又走 Commit(不回 sentenceIDs),这条链一步都走不通: -// 插件即便按格式写好标记,媒体也永远挂不上。 +// 媒体的落点链是 SentenceText → sentences 表 → sentence_id → 块边。 +// 旧实现丢掉 SentenceText 又走 Commit(不回 sentenceIDs),这条链一步都走不通。 func (m *graphMemory) Commit(triples []Triple) error { if m.db == nil { return nil @@ -280,9 +331,11 @@ func (m *graphMemory) sentenceWithMedia(sentence string, digests []string) strin return sentence + "\n" + strings.Join(add, "\n") } -// bindSentences 把每条句子里引用的媒体挂到该句子的 graph_sentence owner 上。 +// bindSentences 把每条句子里引用的媒体变成 L3 的一等记忆块, +// 并建立 sentence --contains--> block 的结构边。 +// 不再写 media_refs:块本身就是图的一部分,不需要 owner 账本保活。 func (m *graphMemory) bindSentences(sentenceIDs map[string]int64) { - if m.ms == nil || len(sentenceIDs) == 0 { + if m.ms == nil || m.db == nil || len(sentenceIDs) == 0 { return } bound := 0 @@ -290,13 +343,20 @@ func (m *graphMemory) bindSentences(sentenceIDs map[string]int64) { if sid == 0 { continue } - // 每条句子一个独立的 done 集:同一份媒体挂在不同句子上是两条 - // 合法引用(owner_id 不同),不该被跨句子去重。 - bound += sdkBindText(m.ms, text, media.OwnerGraphSentence, - strconv.FormatInt(sid, 10), map[string]bool{}) + for _, b := range sdkBlocksFromText(m.ms, text) { + if err := m.db.PutMemoryBlocks([]memory.MemoryBlock{b}); err != nil { + log.Printf("[sdk media] 插件 %s 写入 L3 记忆块失败: %v", m.plugin, err) + continue + } + if err := m.db.AddMemoryBlockEdge("sentence", strconv.FormatInt(sid, 10), "block", b.ID, "contains"); err != nil { + log.Printf("[sdk media] 插件 %s 建立句子→块边失败: %v", m.plugin, err) + continue + } + bound++ + } } if bound > 0 { - log.Printf("[sdk media] 插件 %s 的三元组绑定 %d 个媒体引用", m.plugin, bound) + log.Printf("[sdk media] 插件 %s 的三元组写入 %d 个 L3 记忆块", m.plugin, bound) } } @@ -345,7 +405,7 @@ func NewTextMemoryWithMedia(plugin string, tm *text.Memory, ms *media.Store) Tex // Append 追加一条文本事件;带附件时把媒体标记并进正文。 // -// 文本记忆是追加写 JSONL,没有稳定 owner_id 可挂 media_refs,所以媒体在这一层 +// 文本记忆是追加写 JSONL,没有结构化块存储,所以媒体在这一层 // 只能以标记形式存在。这不是妥协——描述文本才是持久的语义记忆,blob 只是缓存。 func (m *textMemoryImpl) Append(evt TextEvent) error { if m.tm == nil { @@ -431,40 +491,56 @@ func (m *docMemoryImpl) Query(text string, topK int) []*Doc { out := make([]*Doc, len(got)) for i, d := range got { out[i] = &Doc{ID: d.ID, Title: d.Summary, Content: d.Content} - m.fillMedia(out[i]) + m.fillMedia(out[i], d) } return out } // fillMedia 填充文档的媒体字段。 // -// 优先用 media_refs(权威:谁挂上去的就是谁),为空时退回解析正文标记—— -// 历史文档与经旧版插件写入的文档只有标记、没有引用。 -func (m *docMemoryImpl) fillMedia(out *Doc) { +// 优先读一等记忆块(文档直接持有),为空时退回解析正文标记—— +// 历史文档与经旧版插件写入的文档只有标记、没有块。 +func (m *docMemoryImpl) fillMedia(out *Doc, d *doc.Doc) { if m.ms == nil { return } - digests, err := m.ms.Refs(media.OwnerDocument, out.ID) - if err != nil { - log.Printf("[sdk media] 读取文档 %s 的媒体引用失败: %v", out.ID, err) - } - if len(digests) == 0 { - out.Attachments = sdkAttachmentsFromText(m.ms, out.Content) - for _, a := range out.Attachments { - out.MediaDigests = append(out.MediaDigests, a.Digest) + if d != nil && len(d.Blocks) > 0 { + for _, b := range d.Blocks { + if b.PayloadDigest == "" { + continue + } + out.MediaDigests = append(out.MediaDigests, b.PayloadDigest) + att := MediaAttachment{Digest: b.PayloadDigest, MIME: b.MIME, Description: ""} + if it, err := m.ms.Stat(b.PayloadDigest); err == nil && it != nil { + att.MIME = it.MIME + att.Description = it.Description + } + out.Attachments = append(out.Attachments, att) } return } - out.MediaDigests = digests - for _, d := range digests { - it, err := m.ms.Stat(d) - if err != nil || it == nil { + out.Attachments = sdkAttachmentsFromText(m.ms, out.Content) + for _, a := range out.Attachments { + out.MediaDigests = append(out.MediaDigests, a.Digest) + } +} + +// appendBlocks 把新的块追加到已有块之后(按 digest 去重)。 +func appendBlocks(existing []memory.MemoryBlock, add []memory.MemoryBlock) []memory.MemoryBlock { + seen := make(map[string]bool, len(existing)) + for _, b := range existing { + seen[b.PayloadDigest] = true + } + for _, b := range add { + if b.PayloadDigest != "" && seen[b.PayloadDigest] { continue } - out.Attachments = append(out.Attachments, MediaAttachment{ - Digest: it.Digest, MIME: it.MIME, Description: it.Description, - }) + existing = append(existing, b) + if b.PayloadDigest != "" { + seen[b.PayloadDigest] = true + } } + return existing } // Insert 写入文档。正文里已有的媒体标记会被挂成文档级引用, @@ -487,14 +563,17 @@ func (m *docMemoryImpl) InsertWithMedia(d *Doc, attachments []MediaAttachment) e digests := m.storeAttachments(attachments, &target.Content) + // 一等记忆块:文档直接持有块本身,CAS 只提供字节与向量。 + // 不再写 media_refs——块随文档一同存活或被删除,无需 owner 账本。 + target.Blocks = appendBlocks(target.Blocks, + append(sdkBlocksFromDigests(m.ms, digests), sdkBlocksFromText(m.ms, target.Content)...)) + if err := m.ds.Insert(target); err != nil { return err } // 回填给调用方:ID 是新建时内核生成的,Content 含内核补的标记。 d.ID = target.ID d.Content = target.Content - - m.bindDocMedia(target, digests) return nil } @@ -531,51 +610,39 @@ func (m *docMemoryImpl) storeAttachments(atts []MediaAttachment, content *string return digests } -// bindDocMedia 把附件与正文标记引用的媒体一起挂到文档 owner 上。 -func (m *docMemoryImpl) bindDocMedia(target *doc.Doc, digests []string) { - if m.ms == nil || target.ID == "" { - return - } - bound := 0 - done := make(map[string]bool, len(digests)) - for _, full := range digests { - if done[full] { - continue - } - if err := m.ms.AddRef(full, media.OwnerDocument, target.ID); err != nil { - log.Printf("[sdk media] 文档引用绑定失败 (%s → doc %s): %v", - sdkShortDigest(full), target.ID, err) - continue - } - done[full] = true - bound++ - } - // 插件手写在正文里的标记同样要挂上,否则那些媒体在文档里可见却无主。 - // 共用 done:附件刚挂过的那些是同一份媒体(内核自己把标记补进了正文)。 - bound += sdkBindText(m.ms, target.Content, media.OwnerDocument, target.ID, done) - if bound > 0 { - log.Printf("[sdk media] 插件 %s 写入文档 %s,绑定 %d 个媒体引用", - m.plugin, target.ID, bound) - } -} - -// Remove 删除文档,同时释放它持有的媒体引用。 +// Remove 删除文档,并删除它持有的一等块所对应的内容(无其他块共享时)。 // -// 旧实现只删文档不解引用,于是那些媒体永久处于「被引用」状态:GC 不回收, -// 磁盘只增不减。内核的归档路径(distill 的 releaseDocMedia)做了这一步, -// 插件路径漏了同一步。 +// 与文本块一致:删除块即删除内容。媒体字节是块的内容存储, +// 不单独做引用计数或 GC。 func (m *docMemoryImpl) Remove(id string) { if m.ds == nil { return } - if m.ms != nil && id != "" { - if n, err := m.ms.DropOwner(media.OwnerDocument, id); err != nil { - log.Printf("[sdk media] 释放文档 %s 的媒体引用失败: %v", id, err) - } else if n > 0 { - log.Printf("[sdk media] 文档 %s 删除,释放 %d 个媒体引用", id, n) + var digests []string + if d := m.ds.Get(id); d != nil { + for _, b := range d.Blocks { + if b.PayloadDigest != "" { + digests = append(digests, b.PayloadDigest) + } } } m.ds.Remove(id) + if m.ms == nil { + return + } + // 仍被其它文档持有的 digest 不能删(同一份字节可能被多个块共享)。 + stillHeld := map[string]bool{} + for _, b := range m.ds.Blocks() { + stillHeld[b.PayloadDigest] = true + } + for _, d := range digests { + if stillHeld[d] { + continue + } + if err := m.ms.Delete(d); err != nil { + log.Printf("[sdk media] 删除文档 %s 的内容失败 %s: %v", id, sdkShortDigest(d), err) + } + } } func (m *docMemoryImpl) Stats() map[string]interface{} { diff --git a/internal/sdk/memory_impl_test.go b/internal/sdk/memory_impl_test.go index d1e3e81..e702ceb 100644 --- a/internal/sdk/memory_impl_test.go +++ b/internal/sdk/memory_impl_test.go @@ -30,7 +30,7 @@ func newTestStores(t *testing.T) (*memory.GraphDB, *doc.Store, *text.Memory, *me } t.Cleanup(func() { g.Close() }) - ds := doc.NewStore(filepath.Join(dir, "documents")) + ds := doc.NewStore(filepath.Join(dir, "documents"), memory.TokenizeWords) if err := ds.Start(); err != nil { t.Fatalf("doc store start: %v", err) } @@ -42,7 +42,7 @@ func newTestStores(t *testing.T) (*memory.GraphDB, *doc.Store, *text.Memory, *me } t.Cleanup(func() { tm.Stop() }) - 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) } @@ -140,12 +140,12 @@ func TestGraphCommit_BindsMediaFromDigests(t *testing.T) { t.Errorf("句子里没有媒体描述: %q", res.Relations[0].SentenceText) } - refs, err := ms.Refs(media.OwnerGraphSentence, strconv.FormatInt(sid, 10)) + blocks, err := g.BlocksForNode("sentence", strconv.FormatInt(sid, 10)) if err != nil { - t.Fatalf("Refs: %v", err) + t.Fatalf("BlocksForNode: %v", err) } - if len(refs) != 1 || refs[0] != digest { - t.Errorf("句子 #%d 的媒体引用 = %v,期望 [%s]", sid, refs, digest) + if len(blocks) != 1 || blocks[0].PayloadDigest != digest { + t.Errorf("句子 #%d 的媒体块 = %+v,期望 [%s]", sid, blocks, digest) } } @@ -250,15 +250,12 @@ func TestDocInsertWithMedia_StoresAndBinds(t *testing.T) { t.Errorf("正文里没有媒体标记: %q", d.Content) } - refs, err := ms.Refs(media.OwnerDocument, d.ID) - if err != nil { - t.Fatalf("Refs: %v", err) - } - if len(refs) != 1 { - t.Fatalf("文档媒体引用 = %v,期望 1 条", refs) + blocks := ds.Blocks() + if len(blocks) != 1 || blocks[0].PayloadDigest == "" { + t.Fatalf("文档记忆块 = %+v,期望 1 条", blocks) } // 内容可读,说明真的落盘了而不只是记了个 digest。 - got, err := ms.Get(refs[0]) + got, err := ms.Get(blocks[0].PayloadDigest) if err != nil || string(got) != "attachment-bytes" { t.Errorf("媒体内容读回失败: %v / %q", err, got) } @@ -279,9 +276,9 @@ func TestDocInsertWithMedia_DigestOnlyReference(t *testing.T) { if after := ms.Stats()["count"]; after != before { t.Errorf("媒体条数从 %v 变成 %v —— 引用已有内容不该新增", before, after) } - refs, _ := ms.Refs(media.OwnerDocument, d.ID) - if len(refs) != 1 || refs[0] != digest { - t.Errorf("引用 = %v,期望 [%s]", refs, digest) + blocks := ds.Blocks() + if len(blocks) != 1 || blocks[0].PayloadDigest != digest { + t.Errorf("引用 = %+v,期望 [%s]", blocks, digest) } } @@ -350,8 +347,8 @@ func TestDocQuery_FallsBackToMarkers(t *testing.T) { } } -// 旧实现删文档不解引用 → 媒体永久"被引用",GC 收不掉,磁盘只增不减。 -func TestDocRemove_ReleasesMediaRefs(t *testing.T) { +// 文档被删除时它持有的一等记忆块随之消失,媒体不再被任何记忆块持有。 +func TestDocRemove_DropsBlocks(t *testing.T) { _, ds, _, ms := newTestStores(t) dm := NewDocMemoryWithMedia("tester", ds, ms) @@ -361,14 +358,14 @@ func TestDocRemove_ReleasesMediaRefs(t *testing.T) { }}); err != nil { t.Fatalf("InsertWithMedia: %v", err) } - if refs, _ := ms.Refs(media.OwnerDocument, d.ID); len(refs) != 1 { - t.Fatalf("前置条件不成立,引用 = %v", refs) + if blocks := ds.Blocks(); len(blocks) != 1 { + t.Fatalf("前置条件不成立,块 = %+v", blocks) } dm.Remove(d.ID) - if refs, _ := ms.Refs(media.OwnerDocument, d.ID); len(refs) != 0 { - t.Errorf("删除文档后仍有 %v 条引用 —— GC 永远收不掉这份媒体", refs) + if blocks := ds.Blocks(); len(blocks) != 0 { + t.Errorf("删除文档后仍持有 %+v —— 媒体仍被记忆引用", blocks) } }