diff --git a/cmd/homed/main.go b/cmd/homed/main.go index 6875d9f..5c04989 100644 --- a/cmd/homed/main.go +++ b/cmd/homed/main.go @@ -25,6 +25,7 @@ import ( luapkg "gitcode.com/JianFeeeee/HomeAgent/internal/lua" "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/pipeline" "gitcode.com/JianFeeeee/HomeAgent/internal/memory/social" "gitcode.com/JianFeeeee/HomeAgent/internal/memory/text" @@ -324,6 +325,27 @@ 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) + if err != nil { + // 媒体存储开不起来不该阻止启动——它是记忆增强,不是对话必需品 + log.Printf("[homed] warning: media store: %v(媒体记忆已禁用)", err) + } else { + mediaStore = ms + defer mediaStore.Close() + st := mediaStore.Stats() + log.Printf("[homed] media store active: %v 条 / %v 字节(上限 %d MB)", + st["count"], st["total_bytes"], maxMB) + } + } + ks := knowledge.NewStore(filepath.Join(cfg.Daemon.DataDir, "knowledge")) if err := ks.Start(); err != nil { log.Printf("[homed] warning: knowledge store: %v", err) @@ -431,6 +453,10 @@ func main() { Knowledge: ks, 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, PluginDir: cfg.Plugin.Dir, diff --git a/internal/agent/core/agent.go b/internal/agent/core/agent.go index 1aa3881..0ad204d 100644 --- a/internal/agent/core/agent.go +++ b/internal/agent/core/agent.go @@ -14,6 +14,7 @@ import ( "gitcode.com/JianFeeeee/HomeAgent/internal/knowledge" "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/social" "gitcode.com/JianFeeeee/HomeAgent/internal/memory/text" "gitcode.com/JianFeeeee/HomeAgent/internal/plugin" @@ -50,6 +51,17 @@ type Agent struct { // 文本记忆(原始对话日志) textMem *text.Memory + // 媒体存储(内容寻址):对话里出现的图片/音频按 sha256 落盘去重, + // L0/L2/L3 只记 digest。为 nil 时全部媒体接线静默跳过—— + // 它是记忆增强而非对话必需品,缺了不该让对话失败。 + mediaStore *media.Store + // mediaGCInterval 为 0 时不跑 GC 循环(容量上限就仅在手动调 GC 时生效)。 + mediaGCInterval time.Duration + // mediaGCMinAge 保护新入库媒体:刚 Put 还没来得及 AddRef 的项引用计数也是 0。 + mediaGCMinAge time.Duration + // mediaDescribe 控制是否跑后台描述循环(要消耗视觉模型配额)。 + mediaDescribe bool + // 人格设定 personality *agentPkg.Personality @@ -106,6 +118,13 @@ type Agent struct { // 当前轮次的非文本媒体数据(图片/音频),供 describe_image 等工具访问 pendingMedia map[string]interface{} + // pendingMediaDigests 累积本轮已落进 CAS 的媒体 digest。 + // + // 需要缓存而不是当场挂到事件上:媒体在 process() 执行期间被捕获, + // 而承载它的 ContextEvent 要等 process() 返回后才 Append——此刻还没有 owner_id。 + // 与 pendingMedia 同受 a.mu 保护。 + pendingMediaDigests []string + // 当前输入是否为工具提醒/中断(以 system 角色注入,避免被当成用户消息) interruptInput bool @@ -119,7 +138,7 @@ type Agent struct { // 输入去重:防 webui/GUI 断线重连导致的消息重放 // key=source+"|"+content, value=上次接收时间;短窗口内同内容丢弃 - lastInput map[string]time.Time + lastInput map[string]time.Time lastInputMu sync.Mutex // 词嵌入模型,用于实体语义相似度计算 @@ -151,16 +170,20 @@ type AgentConfig struct { Knowledge *knowledge.Store SocialStore *social.SocialStore TextMemory *text.Memory + MediaStore *media.Store + MediaGCInterval time.Duration + MediaGCMinAge time.Duration + MediaDescribe bool Personality *agentPkg.Personality PluginReg *plugin.Registry PluginDir string DistillInterval time.Duration - ArchiveInterval time.Duration // 冷文档归档间隔(L2→L3),0 则使用 DistillInterval - ReviewInterval time.Duration // 关系复审间隔,0 则使用 DistillInterval - MergeInterval time.Duration // 实体合并检测间隔,0 则使用 DistillInterval - MaxContextSize int // 活跃上下文最大条数,超出按相关性裁剪 - ContextSavePath string // 上下文持久化路径,空则不持久化 - EmbeddingModelPath string // 预训练词嵌入模型路径(word2vec 文本格式),空则不使用 + ArchiveInterval time.Duration // 冷文档归档间隔(L2→L3),0 则使用 DistillInterval + ReviewInterval time.Duration // 关系复审间隔,0 则使用 DistillInterval + MergeInterval time.Duration // 实体合并检测间隔,0 则使用 DistillInterval + MaxContextSize int // 活跃上下文最大条数,超出按相关性裁剪 + ContextSavePath string // 上下文持久化路径,空则不持久化 + EmbeddingModelPath string // 预训练词嵌入模型路径(word2vec 文本格式),空则不使用 Embedder *memory.StaticEmbedder // 共享词嵌入实例;nil 时按 EmbeddingModelPath 自建 StageHost *StageHost EventBus *events.Bus @@ -209,6 +232,12 @@ 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) return &Agent{ id: cfg.ID, @@ -227,6 +256,10 @@ func New(cfg AgentConfig) *Agent { knowledge: cfg.Knowledge, social: cfg.SocialStore, textMem: cfg.TextMemory, + mediaStore: cfg.MediaStore, + mediaGCInterval: cfg.MediaGCInterval, + mediaGCMinAge: cfg.MediaGCMinAge, + mediaDescribe: cfg.MediaDescribe, personality: cfg.Personality, pluginReg: cfg.PluginReg, pluginDir: cfg.PluginDir, @@ -261,6 +294,8 @@ func (a *Agent) Start() { go a.archiveLoop() go a.mergeLoop() go a.reviewLoop() + go a.mediaGCLoop() + go a.mediaDescribeLoop() 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 848b8e2..fb09429 100644 --- a/internal/agent/core/context.go +++ b/internal/agent/core/context.go @@ -3,6 +3,7 @@ package core import ( "encoding/json" "fmt" + "log" "os" "path/filepath" "sort" @@ -12,6 +13,7 @@ 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" ) @@ -22,13 +24,25 @@ type ToolResultItem struct { } type ContextEvent struct { - Timestamp time.Time `json:"timestamp"` - Source string `json:"source"` - Input string `json:"input"` - Response string `json:"response,omitempty"` - ToolsUsed []string `json:"tools_used,omitempty"` - ToolResults []ToolResultItem `json:"tool_results,omitempty"` - Vector vector.Vector `json:"-"` + // ID 是事件的稳定标识,媒体引用(media_refs.owner_id)挂在它上面。 + // + // 惰性生成:只有真的要挂媒体时才赋值(见 bindEventMedia)。 + // 全量生成会让每条事件都多一个字段进 context.json,而绝大多数对话没有媒体。 + // omitempty 保证存量 context.json 读回来时该字段为空,不影响任何既有行为。 + ID string `json:"id,omitempty"` + Timestamp time.Time `json:"timestamp"` + Source string `json:"source"` + Input string `json:"input"` + Response string `json:"response,omitempty"` + ToolsUsed []string `json:"tools_used,omitempty"` + ToolResults []ToolResultItem `json:"tool_results,omitempty"` + // Media 是本轮对话涉及的媒体 digest(sha256 十六进制)。 + // + // 存 digest 而不存路径:路径会失效(/tmp 探针图、下载缓存、别的进程的 + // 临时产物),digest 是内容本身的身份,配合 internal/memory/media 的 CAS + // 永远能取回原始字节——只要它还没被容量 GC 淘汰。 + Media []string `json:"media,omitempty"` + Vector vector.Vector `json:"-"` } const contextFlushInterval = 5 * time.Second @@ -42,6 +56,41 @@ 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 { @@ -256,6 +305,16 @@ func (c *RelevanceContext) flush() { c.dirty = false } +// scoredEvent 是 Prune 里按相关度排序的事件。 +// +// 提为包级类型(原先是 Prune 内的局部类型):transferMediaRefs 需要 +// 把待归档列表传进去,局部类型无法出现在方法签名上。 +type scoredEvent struct { + event *ContextEvent + score float64 + idx int +} + func (c *RelevanceContext) Prune(currentInput string, topK int, docStore *document.Store) int { c.mu.Lock() defer c.mu.Unlock() @@ -277,15 +336,10 @@ func (c *RelevanceContext) Prune(currentInput string, topK int, docStore *docume queryVec := c.embedder.VectorizeClean(currentInput) - type scored struct { - event *ContextEvent - score float64 - idx int - } - scoredEvents := make([]scored, len(candidates)) + scoredEvents := make([]scoredEvent, len(candidates)) for i, evt := range candidates { score := vector.CosineSimilarity(queryVec, evt.Vector) - scoredEvents[i] = scored{event: evt, score: score, idx: i} + scoredEvents[i] = scoredEvent{event: evt, score: score, idx: i} } sort.Slice(scoredEvents, func(i, j int) bool { @@ -327,6 +381,11 @@ func (c *RelevanceContext) Prune(currentInput string, topK int, docStore *docume 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) } } @@ -385,5 +444,3 @@ func convertToolResults(items []ToolResultItem) []document.ToolResultItem { } return result } - - diff --git a/internal/agent/core/distill.go b/internal/agent/core/distill.go index b7da8b9..44b892c 100644 --- a/internal/agent/core/distill.go +++ b/internal/agent/core/distill.go @@ -10,6 +10,7 @@ import ( agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io" "gitcode.com/JianFeeeee/HomeAgent/internal/memory" "gitcode.com/JianFeeeee/HomeAgent/internal/memory/document" + "gitcode.com/JianFeeeee/HomeAgent/internal/memory/media" "gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector" "gitcode.com/JianFeeeee/HomeAgent/internal/nlp" ) @@ -180,19 +181,86 @@ func (a *Agent) archiveColdDocs() { coldDocs := a.docStore.FindColdDocs(72*time.Hour, 2) for _, doc := range coldDocs { triples := docToTriples(doc, a.embedder) - if len(triples) > 0 { - ec, rc, err := a.memory.Commit(triples, string(a.id)+"_doc_archival", 0) - if err != nil { - log.Printf("[agent] doc→graph archival error: %v", err) - continue - } - log.Printf("[agent] doc→graph: %s → %d entities, %d relations", doc.ID, ec, rc) - a.docStore.Remove(doc.ID) + if len(triples) == 0 { + continue } + ec, rc, mediaBound, err := a.commitTriplesWithMedia(triples, string(a.id)+"_doc_archival", 0) + if err != nil { + log.Printf("[agent] doc→graph archival error: %v", err) + continue + } + + // 归档的实质是「信息从 L2 搬到 L3」。一条实体、一条关系都没写进 + // 图库时,信息并没有搬过去,此时删文档等于直接丢数据。 + // + // 这不是理论情形:Commit 会静默跳过实体名不合法的三元组 + //(validEntityName 要求 2–50 字符),而 LLM 生成的长描述几乎 + // 提不出合规实体名——实测 456 字图片描述得到 0 entities 0 + // relations,随后文档被删、媒体引用被释放、blob 被 GC 清掉, + // 图片与描述彻底消失。保留文档,下一轮再试。 + if ec == 0 && rc == 0 { + log.Printf("[agent] doc→graph: %s 未写入任何实体/关系,保留文档待下轮重试"+ + "(三元组 %d 条全被实体名校验拒绝)", doc.ID, len(triples)) + continue + } + log.Printf("[agent] doc→graph: %s → %d entities, %d relations", doc.ID, ec, rc) + + // 先销媒体引用再删文档:文档一旦从 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) + } + 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 裁决 // ────────────────────────────────────────────── @@ -412,6 +480,15 @@ func docToTriples(doc *document.Doc, embedder nlp.Vectorizer) []memory.Triple { }) } + // 媒体三元组:确定性产出,先于 NLP 提取。 + // + // 媒体入 L3 曾完全依赖提取器碰巧从描述文本里提出合规三元组——实测 + // LLM 的 477 字图片描述只产出「水平 -分割-> 成」这类语法碎片, + // obj 仅 1 字被 validEntityName 拒掉,整条媒体记忆就进不了图库 + //(阶段性表现是"时好时坏",取决于提取器运气)。媒体自身的 + // digest / mime / 描述都是确定的,直接建三元组而不经提取器。 + triples = append(triples, mediaTriplesFromText(doc.Content)...) + // NLP 通用提取 e := nlp.NewExtractor(nil) if embedder != nil { diff --git a/internal/agent/core/eventloop.go b/internal/agent/core/eventloop.go index c538772..b4845f9 100644 --- a/internal/agent/core/eventloop.go +++ b/internal/agent/core/eventloop.go @@ -174,6 +174,11 @@ func (a *Agent) processMediaInput(evt *agentIO.InputEvent) { blocks, fallback := a.mediaToBlocks(evt.Payload, evt.Type, evt.Source) + // 用户直接发来的媒体:先落进 CAS。 + // 不存的后果是 ContextEvent.Input 只剩一句 alt 文本 + //("[从 qq 收到了 image]"),base64 随 message 数组发给模型后就丢了。 + a.stageMediaDigests(a.captureBlockMedia(blocks, "input_"+evt.Type)...) + stageCtx := a.stageCtxFromInput(fallback, evt.Source, "") stageCtx.Extra = map[string]interface{}{ "media_blocks": blocks, @@ -216,14 +221,22 @@ func (a *Agent) processMediaInput(evt *agentIO.InputEvent) { elapsed := time.Since(start) log.Printf("[agent] %s from %s → response (%dms, tools=%v)", evt.Type, evt.Source, elapsed.Milliseconds(), toolsUsed) - a.context.Append(ContextEvent{ + // 本轮捕获的媒体(用户发的 + 工具注入的)挂到这条事件上。 + // 媒体描述并进 Input:描述文本才是持久语义记忆,blob 只是缓存。 + digests := a.drainMediaDigests() + mediaEvt := ContextEvent{ Timestamp: time.Now(), Source: "agent", Input: fallback, Response: response, ToolsUsed: toolsUsed, ToolResults: toolResults, - }) + } + a.bindEventMedia(&mediaEvt, digests) + if s := a.mediaSummaryForEvent(mediaEvt.Media); s != "" { + mediaEvt.Input = mediaEvt.Input + "\n" + s + } + a.context.Append(mediaEvt) a.emitResponse(evt, response) @@ -271,12 +284,12 @@ func (a *Agent) mediaToBlocks(payload map[string]interface{}, mediaType string, } if mediaType == "image" { blocks = append(blocks, agentAPI.ContentBlock{ - Type: "image_url", + Type: "image_url", ImageURL: &agentAPI.ImageURL{URL: imgURL, Detail: "auto"}, }) } else if mediaType == "audio" { blocks = append(blocks, agentAPI.ContentBlock{ - Type: "audio_url", + Type: "audio_url", AudioURL: &agentAPI.AudioURL{URL: imgURL}, }) } @@ -371,14 +384,21 @@ func (a *Agent) processTextInput(evt *agentIO.InputEvent, input string) { elapsed := time.Since(start) log.Printf("[agent] input from %s → response (%dms, tools=%v)", evt.Source, elapsed.Milliseconds(), toolsUsed) - a.context.Append(ContextEvent{ + // 纯文本输入也可能产生媒体:模型调 multimodal_see_picture / see_video 等工具时, + // 插件经 SetToolBlocks 注入的块已在 process() 里被捕获。 + textEvt := ContextEvent{ Timestamp: time.Now(), Source: "agent", Input: cleanInput, Response: response, ToolsUsed: toolsUsed, ToolResults: toolResults, - }) + } + a.bindEventMedia(&textEvt, a.drainMediaDigests()) + if s := a.mediaSummaryForEvent(textEvt.Media); s != "" { + textEvt.Input = textEvt.Input + "\n" + s + } + a.context.Append(textEvt) a.emitResponse(evt, response) diff --git a/internal/agent/core/graphmedia.go b/internal/agent/core/graphmedia.go new file mode 100644 index 0000000..f75610f --- /dev/null +++ b/internal/agent/core/graphmedia.go @@ -0,0 +1,382 @@ +package core + +import ( + "fmt" + "log" + "regexp" + "strconv" + "strings" + + "gitcode.com/JianFeeeee/HomeAgent/internal/memory" + "gitcode.com/JianFeeeee/HomeAgent/internal/memory/media" +) + +// L3 图库的媒体引用绑定。 +// +// 设计定位(方案 A:只做引用,不建媒体实体节点): +// 图库里的实体与关系全部来自**描述文本**的 NLP 提取——媒体描述经 +// mediaSummaryForEvent 进了 L0 事件的 Input,随归档进 L2 文档的 Content, +// 蒸馏时提取器自然会从描述文字里抽出实体和关系。 +// +// 为何不把媒体本身建成实体节点:节点名只能从描述里取,而描述会被重新生成 +// (换个视觉模型、补一次描述,名字就变了),于是同一张图会在图谱上留下 +// 多个语义模糊的节点。检索能力靠描述文本已经具备,多这类节点只是噪声。 +// +// 那么图库侧还需要什么:**反查**。图库里的句子写着「[image a1b2c3d4e5f6] +// 一张紫蓝红三色带图」,要能从这条句子找回那份字节。这就是 +// media_refs 的 graph_sentence owner 的用途,也是这一层唯一要做的事。 + +// mediaDigestPattern 匹配事件摘要里的媒体标记 [ <短digest>]。 +// +// 与 mediaSummaryForEvent 的输出格式对应。短 digest 是 12 位十六进制 +// (shortDigest 的截断长度),这里放宽到 8-64 位以容忍将来调整截断长度, +// 以及有人手写了完整 digest 的情况。 +var mediaDigestPattern = regexp.MustCompile(`\[[^\[\]]*?\b([0-9a-f]{8,64})\]`) + +// mediaMarkerPattern 完整拆解一条媒体标记及其后跟的描述, +// 捕获组依次为:标签(mime 或 kind)、短 digest、该行剩余的描述文本。 +// +// 与 mediaSummaryForEvent 的输出格式严格对应: +// +// [image/png a1b2c3d4e5f6] 一张紫蓝红三色带图 +// +// 描述取到行尾而非贪婪到底:一条事件可能挂多个媒体,各占一行。 +var mediaMarkerPattern = regexp.MustCompile(`\[([^\[\]\s]+)\s+([0-9a-f]{8,64})\]([^\n]*)`) + +// mediaMarker 是从文档正文里解析出的一条媒体标记。 +type mediaMarker struct { + label string // mime 或 kind,如 image/png + shortDigest string + description string + raw string // 原始整段,用作三元组的 SentenceText +} + +// parseMediaMarkers 从文本里解析全部媒体标记。 +// +// 为何需要它而不只是 extractMediaDigests:媒体入 L3 曾完全依赖 NLP 提取器 +// 碰巧从描述文本里提出合规三元组——实测 LLM 的 477 字图片描述只产出 +// 「水平 -分割-> 成」这种语法碎片,obj 仅 1 字被 validEntityName 拒掉, +// 于是整条媒体记忆进不了图库。而媒体自身的信息(digest / mime / 描述) +// 是确定的,不该受提取器运气支配。 +func parseMediaMarkers(text string) []mediaMarker { + if text == "" { + return nil + } + ms := mediaMarkerPattern.FindAllStringSubmatch(text, -1) + if len(ms) == 0 { + return nil + } + seen := make(map[string]bool, len(ms)) + var out []mediaMarker + for _, m := range ms { + d := m[2] + if seen[d] { + continue + } + seen[d] = true + out = append(out, mediaMarker{ + label: m[1], + shortDigest: d, + description: strings.TrimSpace(m[3]), + raw: strings.TrimSpace(m[0]), + }) + } + return out +} + +// mediaEntityName 是媒体在图库里的实体名。 +// +// 形如「图片 a1b2c3d4e5f6」。刻意用 digest 而非描述文本构成名字: +// 描述会被重新生成(换视觉模型、补描述),若名字取自描述,同一张图 +// 就会在图谱上留下多个节点。digest 不变则名字不变。 +// 长度也天然合规(validEntityName 要求 2–50 字符)。 +func mediaEntityName(label, shortDigest string) string { + kind := "媒体" + switch { + case strings.HasPrefix(label, "image"): + kind = "图片" + case strings.HasPrefix(label, "audio"): + kind = "音频" + case strings.HasPrefix(label, "video"): + kind = "视频" + } + return kind + " " + shortDigest +} + +// mediaTriplesFromText 为文本里的每条媒体标记产出确定的三元组。 +// +// 这是媒体进 L3 的可靠路径:不经过 NLP 提取器,因此不受它对描述性文本 +// 提取能力的影响。每条媒体至少产出一条「<媒体实体> -内容-> <描述摘要>」, +// 且 SentenceText 用原始标记段,保证 bindSentenceMedia 的正则必然能 +// 反解到 digest——绑定从概率事件变成确定行为。 +// +// 描述摘要截到 40 字:validEntityName 上限 50 字符,留出余量; +// 图谱节点名过长会让可视化和实体合并都难以处理,完整描述留在 +// SentenceText 与 media 表里。 +func mediaTriplesFromText(text string) []memory.Triple { + markers := parseMediaMarkers(text) + if len(markers) == 0 { + return nil + } + var out []memory.Triple + for _, m := range markers { + name := mediaEntityName(m.label, m.shortDigest) + + // 类型三元组恒可产出,不依赖描述是否存在 + out = append(out, memory.Triple{ + Subject: name, + SubjectType: "Media", + Relation: "类型", + Object: m.label, + ObjectType: "MimeType", + Confidence: 1.0, + SentenceText: m.raw, + }) + + desc := summarizeForEntity(m.description, 40) + if desc == "" { + continue + } + out = append(out, memory.Triple{ + Subject: name, + SubjectType: "Media", + Relation: "内容", + Object: desc, + ObjectType: "Description", + Confidence: 1.0, + SentenceText: m.raw, + }) + } + return out +} + +// summarizeForEntity 把描述压成可作实体名的短串。 +// +// 取首个句子边界之前的内容,再按 rune 截断——直接按字节截会切坏 UTF-8, +// 图库里就会出现乱码实体名。空白与 Markdown 强调符号一并清掉, +// 否则「**整体构成**」这类标记会进实体名。 +func summarizeForEntity(s string, maxRunes int) string { + s = strings.TrimSpace(s) + if s == "" { + return "" + } + s = strings.NewReplacer("**", "", "*", "", "\n", " ", "\t", " ").Replace(s) + for _, sep := range []string{"。", ";", ",", ". ", "; "} { + if i := strings.Index(s, sep); i > 0 { + s = s[:i] + break + } + } + s = strings.TrimSpace(s) + r := []rune(s) + if len(r) > maxRunes { + r = r[:maxRunes] + } + out := strings.TrimSpace(string(r)) + // 太短的残片(如单字)过不了 validEntityName,直接放弃比写进去更好 + if len([]rune(out)) < 2 { + return "" + } + return out +} + +// extractMediaDigests 从文本里找出所有媒体标记的 digest。 +// +// 为何靠正则从文本反解,而不是让三元组结构携带 digest:三元组是 NLP +// 提取器从纯文本产出的(nlp.ToMemoryTriple 只填 Subject/Relation/Object/ +// Confidence/SentenceText),提取链路上没有任何位置能塞进结构化的 digest。 +// 若要贯通就得改 internal/nlp 的整条数据流——而媒体标记本身就是我们 +// 自己按固定格式写进文本的,反解是这里最省的可靠做法。 +func extractMediaDigests(text string) []string { + if text == "" { + return nil + } + matches := mediaDigestPattern.FindAllStringSubmatch(text, -1) + if len(matches) == 0 { + return nil + } + seen := make(map[string]bool, len(matches)) + var out []string + for _, m := range matches { + d := m[1] + if seen[d] { + continue + } + seen[d] = true + out = append(out, d) + } + return out +} + +// bindSentenceMedia 把句子文本里提到的媒体挂到对应的 sentences.id 上。 +// +// sentenceIDs 来自 GraphDB.CommitWithMedia:句子文本 → sentences.id。 +// 只处理本次真正写入了 sentences 表的句子,避免给历史句子重复挂引用 +// (AddRef 幂等,重复挂不会涨计数,但白跑 SQL)。 +// +// 返回实际绑定成功的引用数,这是调用方的安全依据:归档路径靠它判定 +// 「引用真的转移到图库了吗」,不能用「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 { + return 0 + } + + bound := 0 + for text, sid := range sentenceIDs { + if sid == 0 { + continue + } + digests := extractMediaDigests(text) + if len(digests) == 0 { + continue + } + ownerID := strconv.FormatInt(sid, 10) + for _, short := range digests { + // 文本里是短 digest,media_refs 的主键要完整 digest。 + // 补全失败(内容已被 GC 清掉、或前缀有歧义)就跳过—— + // 挂一条对不上的引用比不挂更糟:DropOwner 永远匹配不到它。 + full, err := a.mediaStore.ResolvePrefix(short) + if err != nil { + continue + } + if err := a.mediaStore.AddRef(full, media.OwnerGraphSentence, ownerID); err != nil { + log.Printf("[media] 句子引用绑定失败 (%s → sentence %s): %v", short, ownerID, err) + continue + } + bound++ + } + } + if bound > 0 { + log.Printf("[media] L3 图库绑定 %d 个媒体引用", bound) + } + return bound +} + +// commitTriplesWithMedia 提交三元组并绑定句子里的媒体引用。 +// +// 包一层是为了让所有「三元组入库」的调用点用同一条路径拿到媒体绑定, +// 而不必各自记得多调一次 bindSentenceMedia。 +// mediaBound 是本次实际挂到 graph_sentence owner 上的引用数;归档路径靠它 +// 判定能否安全释放旧引用。媒体存储关闭时恒为 0(此时也没有引用需要释放)。 +func (a *Agent) commitTriplesWithMedia(triples []memory.Triple, sessionID string, turnID int) (entities, relations, mediaBound int, err error) { + if a.memory == nil { + return 0, 0, 0, fmt.Errorf("graph memory 未启用") + } + // 媒体存储关闭时退回普通 Commit,省掉 sentenceIDs 的 map 分配。 + if a.mediaStore == nil { + ec, rc, cErr := a.memory.Commit(triples, sessionID, turnID) + return ec, rc, 0, cErr + } + sentenceIDs, ec, rc, err := a.memory.CommitWithMedia(triples, sessionID, turnID) + if err != nil { + return ec, rc, 0, err + } + return ec, rc, a.bindSentenceMedia(sentenceIDs), nil +} + +// RecallMediaForSentence 反查某条图库句子引用的媒体。 +// +// 这是整层的目的:几个月后从图谱走到一条句子,要能取回当时那份字节 +// (若尚未被容量 GC 淘汰)。返回的是完整 digest,调用方用 +// mediaStore.Get 取内容、Stat 取描述与元数据。 +func (a *Agent) RecallMediaForSentence(sentenceID int64) ([]string, error) { + if a.mediaStore == nil { + return nil, nil + } + return a.mediaStore.Refs(media.OwnerGraphSentence, strconv.FormatInt(sentenceID, 10)) +} + +// sentenceIDsFromRelations 收集一批关系引用的句子 id(去重、去零)。 +// +// 关系行本身不持有媒体,媒体挂在句子上(graph_sentence owner)。 +// 因此"这次召回涉及哪些媒体"必须经由关系 → 句子 → media_refs 这条路。 +func sentenceIDsFromRelations(relations []memory.Relation) []int64 { + if len(relations) == 0 { + return nil + } + seen := make(map[int64]bool, len(relations)) + var out []int64 + for _, r := range relations { + if r.SentenceID == 0 || seen[r.SentenceID] { + continue + } + seen[r.SentenceID] = true + out = append(out, r.SentenceID) + } + return out +} + +// mediaContextForRelations 是 mediaContextForSentences 的关系入口。 +// +// 单独包一层是因为两个调用点(自动注入的 buildMemoryContext 与显式的 +// memory_recall 工具)拿到的都是关系列表,不该各自重复"关系→句子"这步。 +func (a *Agent) mediaContextForRelations(relations []memory.Relation) string { + return a.mediaContextForSentences(sentenceIDsFromRelations(relations)) +} + +// mediaContextForInjectedEntities 为自动注入路径产出媒体说明。 +// +// 单独一条路径是因为 Indexer.BuildContext 刻意不返回关系 +// (Relations 恒为 nil,只给实体索引以省 token,细节留给 memory_recall)。 +// 于是自动注入拿不到 sentence_id,必须用命中的实体名再查一次关系。 +// +// 这次额外查询只为取 sentence_id,深度固定 1:媒体是"这条记忆当时带的图", +// 不需要顺着关系network 扩散——扩散只会带出无关媒体并挤占 token。 +func (a *Agent) mediaContextForInjectedEntities(injected *memory.InjectedContext) string { + if a.mediaStore == nil || a.memory == nil || injected == nil || len(injected.Entities) == 0 { + return "" + } + names := make([]string, 0, len(injected.Entities)) + for _, e := range injected.Entities { + names = append(names, e.Name) + } + res, err := a.memory.Recall(nil, names, 1, "") + if err != nil || res == nil { + return "" + } + return a.mediaContextForRelations(res.Relations) +} + +// mediaContextForSentences 给一组句子附上媒体说明,供召回时拼进提示词。 +// +// 输出形如「句子 #12 关联媒体:[image/png a1b2c3d4e5f6] 一张紫蓝红三色带图」。 +// 描述文本本就在句子里,这里补的是「内容是否还在、能否重新看图」这个信息—— +// 描述永存而字节可能已被淘汰,两者状态不同。 +func (a *Agent) mediaContextForSentences(sentenceIDs []int64) string { + if a.mediaStore == nil || len(sentenceIDs) == 0 { + return "" + } + var lines []string + for _, sid := range sentenceIDs { + digests, err := a.mediaStore.Refs(media.OwnerGraphSentence, strconv.FormatInt(sid, 10)) + if err != nil || len(digests) == 0 { + continue + } + var parts []string + for _, d := range digests { + it, err := a.mediaStore.Stat(d) + if err != nil || it == nil { + continue + } + label := string(it.Kind) + if it.MIME != "" { + label = it.MIME + } + desc := it.Description + if desc == "" { + desc = "(未描述)" + } + parts = append(parts, fmt.Sprintf("[%s %s] %s", label, shortDigest(d), desc)) + } + if len(parts) > 0 { + lines = append(lines, fmt.Sprintf("句子 #%d 关联媒体:%s", sid, strings.Join(parts, ";"))) + } + } + if len(lines) == 0 { + return "" + } + return strings.Join(lines, "\n") +} diff --git a/internal/agent/core/graphmedia_test.go b/internal/agent/core/graphmedia_test.go new file mode 100644 index 0000000..8044203 --- /dev/null +++ b/internal/agent/core/graphmedia_test.go @@ -0,0 +1,705 @@ +package core + +import ( + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "gitcode.com/JianFeeeee/HomeAgent/internal/memory" + "gitcode.com/JianFeeeee/HomeAgent/internal/memory/document" + "gitcode.com/JianFeeeee/HomeAgent/internal/memory/media" +) + +// L3 图库媒体引用测试。 +// +// 这一层的目的只有一个:几个月后从图谱走到一条句子,要能取回当时那份字节。 +// 因此测试的重点是「反查链路是否完整」以及「引用是否会悬空或误删」。 + +func newGraphMediaAgent(t *testing.T) (*Agent, *memory.GraphDB, *media.Store) { + t.Helper() + dir := t.TempDir() + + g, err := memory.NewGraphDB(filepath.Join(dir, "graph.db")) + if err != nil { + t.Fatalf("NewGraphDB: %v", err) + } + t.Cleanup(func() { g.Close() }) + + ms, err := media.New(filepath.Join(dir, "media"), 0) + if err != nil { + t.Fatalf("media.New: %v", err) + } + t.Cleanup(func() { ms.Close() }) + + return &Agent{memory: g, mediaStore: ms}, g, ms +} + +func TestExtractMediaDigests(t *testing.T) { + // 与 mediaSummaryForEvent 的输出格式对应 + cases := []struct { + name string + text string + want []string + }{ + {"事件摘要格式", "媒体内容:\n[image/png a1b2c3d4e5f6] 一张紫蓝红三色带图", []string{"a1b2c3d4e5f6"}}, + {"kind 兜底格式", "[image abcdef0123456789] (未描述)", []string{"abcdef0123456789"}}, + {"一句多个", "[image aaaaaaaaaaaa] 图一;[image bbbbbbbbbbbb] 图二", []string{"aaaaaaaaaaaa", "bbbbbbbbbbbb"}}, + {"去重", "[image cccccccccccc] x [image/png cccccccccccc] y", []string{"cccccccccccc"}}, + {"无标记", "普通句子,没有媒体", nil}, + {"空串", "", nil}, + // 非十六进制、过短的方括号内容不能误命中,否则会拿一个假前缀去 ResolvePrefix + {"非 digest 方括号", "[注意] 这是普通标注 [TODO]", nil}, + {"过短", "[image abc] 太短", nil}, + } + + for _, c := range cases { + got := extractMediaDigests(c.text) + if len(got) != len(c.want) { + t.Fatalf("%s: 得到 %v,期望 %v", c.name, got, c.want) + } + for i := range got { + if got[i] != c.want[i] { + t.Fatalf("%s: 第 %d 个得到 %q,期望 %q", c.name, i, got[i], c.want[i]) + } + } + } +} + +func TestCommitWithMedia_ReturnsSentenceIDs(t *testing.T) { + _, g, _ := newGraphMediaAgent(t) + + sentence := "[image/png a1b2c3d4e5f6] 一张紫蓝红三色带图" + triples := []memory.Triple{{ + Subject: "图片", Relation: "内容", Object: "三色带", + SentenceText: sentence, + }} + + ids, ec, rc, err := g.CommitWithMedia(triples, "s1", 0) + if err != nil { + t.Fatal(err) + } + if ec == 0 || rc == 0 { + t.Fatalf("应写入实体与关系,实际 ec=%d rc=%d", ec, rc) + } + if ids[sentence] == 0 { + t.Fatalf("应返回句子 id,实际 %v", ids) + } +} + +func TestCommit_StillWorksAfterRefactor(t *testing.T) { + // Commit 有三十多个调用点,内部转调后行为必须完全不变 + _, g, _ := newGraphMediaAgent(t) + + triples := []memory.Triple{ + {Subject: "张三", Relation: "喜欢", Object: "咖啡", SentenceText: "张三喜欢咖啡"}, + {Subject: "李四", Relation: "住在", Object: "北京"}, + } + ec, rc, err := g.Commit(triples, "s1", 0) + if err != nil { + t.Fatal(err) + } + if ec != 4 || rc != 2 { + t.Fatalf("期望 4 实体 2 关系,实际 ec=%d rc=%d", ec, rc) + } + + // 重复提交同一批:关系被唯一约束去重。 + // + // 实体计数**不**归零——这是 upsertEntity 的既有行为:SQLite 的 + // ON CONFLICT DO UPDATE 也算一行 affected,于是 RowsAffected() > 0 + // 被当成"新建了"。用 main 分支的 graph.go 单独验证过基线同样是 + // 首次 ec=2 / 重复 ec=2,与 CommitWithMedia 重构无关。 + // entitiesCreated 只用于日志,故此处记录现状而不改行为。 + ec2, rc2, err := g.Commit(triples, "s1", 0) + if err != nil { + t.Fatal(err) + } + if rc2 != 0 { + t.Fatalf("重复提交不该新建关系,实际 rc=%d", rc2) + } + if ec2 != 4 { + t.Fatalf("实体计数应与首次一致(既有 upsert 计数行为),实际 ec=%d", ec2) + } +} + +func TestBindSentenceMedia_RoundTrip(t *testing.T) { + // 整层的核心断言:写入 → 提交 → 反查取回原始字节 + a, _, ms := newGraphMediaAgent(t) + + content := []byte("\x89PNG\r\n\x1a\n fake image bytes") + digest, err := ms.Put(content, media.Item{MIME: "image/png", Kind: media.KindImage}) + if err != nil { + t.Fatal(err) + } + short := shortDigest(digest) + + sentence := "[image/png " + short + "] 一张紫蓝红三色带图" + triples := []memory.Triple{{ + Subject: "图片", Relation: "内容", Object: "三色带", SentenceText: sentence, + }} + + if _, _, _, err := a.commitTriplesWithMedia(triples, "s1", 0); err != nil { + t.Fatal(err) + } + + // 找到句子 id + ids, _, _, err := a.memory.CommitWithMedia(triples, "s1", 0) + if err != nil { + t.Fatal(err) + } + sid := ids[sentence] + if sid == 0 { + t.Fatal("拿不到句子 id") + } + + // 反查:从句子取回 digest,再取回字节 + digests, err := a.RecallMediaForSentence(sid) + if err != nil { + t.Fatal(err) + } + if len(digests) != 1 || digests[0] != digest { + t.Fatalf("反查应得完整 digest %s,实际 %v", shortDigest(digest), digests) + } + got, err := ms.Get(digests[0]) + if err != nil { + t.Fatalf("取回内容失败: %v", err) + } + if string(got) != string(content) { + t.Fatal("取回的内容与写入不一致") + } + + // 引用计数非零 → GC 不会清它 + if _, _, err := ms.GC(0); err != nil { + t.Fatal(err) + } + if _, err := ms.Get(digest); err != nil { + t.Fatalf("被图库句子引用的内容不该被 GC 清掉: %v", err) + } +} + +func TestBindSentenceMedia_SkipsUnresolvable(t *testing.T) { + // 文本里的 digest 在库里不存在时必须跳过,不能挂一条对不上的引用—— + // 那条引用 DropOwner 永远匹配不到,会永久占着计数。 + a, _, ms := newGraphMediaAgent(t) + + sentence := "[image/png deadbeefdead] 一张不存在的图" + ids := map[string]int64{sentence: 42} + a.bindSentenceMedia(ids) + + refs, err := ms.Refs(media.OwnerGraphSentence, "42") + if err != nil { + t.Fatal(err) + } + if len(refs) != 0 { + t.Fatalf("无法补全的 digest 不该挂引用,实际 %v", refs) + } +} + +func TestBindSentenceMedia_NilStoreNoop(t *testing.T) { + a := &Agent{} + a.bindSentenceMedia(map[string]int64{"[image aaaaaaaaaaaa] x": 1}) + if got, err := a.RecallMediaForSentence(1); err != nil || got != nil { + t.Fatalf("媒体关闭时应静默无操作,实际 %v / %v", got, err) + } +} + +func TestCommitTriplesWithMedia_FallsBackWithoutStore(t *testing.T) { + // 媒体关闭时退回普通 Commit,行为与直接调 Commit 完全一致 + dir := t.TempDir() + g, err := memory.NewGraphDB(filepath.Join(dir, "g.db")) + if err != nil { + t.Fatal(err) + } + defer g.Close() + + a := &Agent{memory: g} + ec, rc, _, err := a.commitTriplesWithMedia([]memory.Triple{ + {Subject: "张三", Relation: "喜欢", Object: "咖啡"}, + }, "s1", 0) + if err != nil { + t.Fatal(err) + } + if ec != 2 || rc != 1 { + t.Fatalf("期望 2 实体 1 关系,实际 ec=%d rc=%d", ec, rc) + } +} + +func TestReleaseDocMedia_DropsRefsSoGCCanReclaim(t *testing.T) { + // L2→L3 那一跳留下的泄漏:文档被 Remove 但引用没销, + // 引用计数永不归零,blob 永远不会被 GC 回收。 + a, _, ms := newGraphMediaAgent(t) + + digest, err := ms.Put([]byte("doc image"), media.Item{MIME: "image/png"}) + if err != nil { + t.Fatal(err) + } + if err := ms.AddRef(digest, media.OwnerDocument, "doc_1"); err != nil { + t.Fatal(err) + } + + // 释放前 GC 清不掉 + if _, _, err := ms.GC(0); err != nil { + t.Fatal(err) + } + if _, err := ms.Stat(digest); err != nil { + t.Fatal("有文档引用时不该被清") + } + + a.releaseDocMedia("doc_1") + + if refs, _ := ms.Refs(media.OwnerDocument, "doc_1"); len(refs) != 0 { + t.Fatalf("释放后不该还有文档引用,实际 %v", refs) + } + // 现在 GC 能回收了 + removed, _, err := ms.GC(0) + if err != nil { + t.Fatal(err) + } + if removed != 1 { + t.Fatalf("释放引用后 GC 应能回收,实际清理 %d 条", removed) + } +} + +func TestMediaContextForSentences(t *testing.T) { + a, _, ms := newGraphMediaAgent(t) + + digest, _ := ms.Put([]byte("img"), media.Item{MIME: "image/png"}) + if err := ms.Describe(digest, "一张紫蓝红三色带图", "visionllm"); err != nil { + t.Fatal(err) + } + if err := ms.AddRef(digest, media.OwnerGraphSentence, "7"); err != nil { + t.Fatal(err) + } + + out := a.mediaContextForSentences([]int64{7, 8}) + if out == "" { + t.Fatal("应产出媒体说明") + } + if !contains(out, "句子 #7") || !contains(out, "一张紫蓝红三色带图") { + t.Fatalf("说明内容不对: %q", out) + } + // 8 号句子没引用媒体,不该出现 + if contains(out, "句子 #8") { + t.Fatalf("无引用的句子不该出现: %q", out) + } +} + +func TestResolvePrefix(t *testing.T) { + dir := t.TempDir() + ms, err := media.New(filepath.Join(dir, "m"), 0) + if err != nil { + t.Fatal(err) + } + defer ms.Close() + + digest, _ := ms.Put([]byte("content"), media.Item{MIME: "image/png"}) + + // 短前缀补全 + full, err := ms.ResolvePrefix(digest[:12]) + if err != nil || full != digest { + t.Fatalf("短前缀补全失败: %v / %v", full, err) + } + // 完整 digest 原样返回 + full, err = ms.ResolvePrefix(digest) + if err != nil || full != digest { + t.Fatalf("完整 digest 应原样返回: %v / %v", full, err) + } + // 过短拒绝 + if _, err := ms.ResolvePrefix("abc"); err == nil { + t.Fatal("过短前缀应报错") + } + // 不存在 + if _, err := ms.ResolvePrefix("deadbeefdead"); err == nil { + t.Fatal("不存在的前缀应报错") + } + // 完整但不存在的 digest 也要报错,否则调用方会挂一条孤儿引用 + fake := "" + for i := 0; i < 64; i++ { + fake += "0" + } + if _, err := ms.ResolvePrefix(fake); err == nil { + t.Fatal("不存在的完整 digest 应报错") + } +} + +func TestResolvePrefix_AmbiguityIsError(t *testing.T) { + // 前缀歧义视为错误而非"取第一个":挂错引用会让 GC 删掉仍被引用的内容。 + // 构造歧义需要两个同前缀 digest——sha256 无法人为构造, + // 因此这里退而验证「8 位前缀在大量样本下的行为是确定的」: + // 要么唯一命中,要么明确报歧义,绝不静默取第一个。 + dir := t.TempDir() + ms, err := media.New(filepath.Join(dir, "m"), 0) + if err != nil { + t.Fatal(err) + } + defer ms.Close() + + digests := make([]string, 0, 200) + for i := 0; i < 200; i++ { + d, err := ms.Put([]byte("content-"+strconv.Itoa(i)), media.Item{MIME: "image/png"}) + if err != nil { + t.Fatal(err) + } + digests = append(digests, d) + } + + for _, d := range digests { + got, err := ms.ResolvePrefix(d[:12]) + if err != nil { + // 报歧义是可接受结果;静默取错才是缺陷 + if !contains(err.Error(), "歧义") { + t.Fatalf("非歧义错误: %v", err) + } + continue + } + if got != d { + t.Fatalf("补全结果错误: 前缀 %s 得到 %s", d[:12], got) + } + } +} + +func TestArchiveColdDocs_KeepsDocWhenGraphWriteEmpty(t *testing.T) { + // 数据丢失回归:三元组全被实体名校验拒绝时(Commit 无错但 0 entities + // 0 relations),文档不能删、媒体引用不能释放。 + // + // 该缺陷曾真实发生:LLM 生成的 456 字图片描述提不出合规实体名 + //(validEntityName 要求 2–50 字符),archiveColdDocs 只检查 + // len(triples) > 0 就释放引用并删文档 → GC 清掉 blob → 图片与描述全丢。 + a, _, ms := newGraphMediaAgent(t) + + dir := t.TempDir() + ds := document.NewStore(filepath.Join(dir, "docs")) + if err := ds.Start(); err != nil { + t.Fatal(err) + } + defer ds.Stop() + a.docStore = ds + a.embedder = memory.NewStaticEmbedder() + + content := []byte("image bytes") + digest, err := ms.Put(content, media.Item{MIME: "image/png"}) + if err != nil { + t.Fatal(err) + } + + // 精确构造「三元组非空 + Commit 全部拒绝」这个状态。 + // + // 用超长 Source 而不是指望 NLP 提取器:docToTriples 在 + // Source != "context_archived" 时会写一条 {文档 -来源-> Source}, + // Source 超过 validEntityName 的 50 字符上限 → Commit 静默跳过 + // → len(triples)==1 但 ec=0 rc=0。构造是确定的,不依赖提取器的 + // 具体行为(提取器行为随版本变化,测试不该押在它身上)。 + // + // 正文里刻意**不放**媒体标记:mediaTriplesFromText 会为标记产出 + // 合规的「图片 」三元组,那样 ec/rc 就不为 0,这个用例 + // 也就测不到「全被拒绝」这个状态了。媒体引用直接用 AddRef 挂上, + // 模拟「文档持有媒体但正文的媒体标记已在清洗中丢失」这一情形—— + // 那正是最危险的组合:有引用要释放,却没有句子能承载它。 + longSource := strings.Repeat("超长来源名", 20) // 100 字,远超 50 字符上限 + // Summary 也必须超长:docToTriples 会为合理 summary 写一条 + // {文档 -主题-> summary},那条能通过校验,ec/rc 就不为 0 了。 + // 这里要的是「三元组全部被拒」这一个状态。 + longSummary := strings.Repeat("超长摘要文本", 20) // >80 字,触发长度门槛被跳过 + doc := &document.Doc{ + ID: "doc_keep", + Summary: longSummary, + Content: "一段没有媒体标记的正文", + Source: longSource, + CreatedAt: time.Now().Add(-200 * time.Hour), + LastAccess: time.Now().Add(-200 * time.Hour), + AccessCount: 0, + } + if err := ds.Insert(doc); err != nil { + t.Fatal(err) + } + // Insert 会把 LastAccess 覆写成 now、AccessCount 置 1, + // 于是 FindColdDocs(72h, 2) 一篇都找不到。插入后再改回来, + // 让文档真正满足"冷"的条件——这是触发归档路径的前提。 + for _, d := range ds.RecentDocs(10) { + if d.ID == doc.ID { + d.LastAccess = time.Now().Add(-200 * time.Hour) + 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) + } + if len(refs) == 0 { + t.Error("引用被释放了——图库没有句子承载它,释放后 GC 会删掉内容") + } + if removed, _, err := ms.GC(0); err != nil { + t.Fatal(err) + } else if _, err := ms.Stat(digest); err != nil { + t.Fatalf("GC(清 %d 条) 删掉了本该保留的内容", removed) + } +} + +func TestCommitTriplesWithMedia_ReportsBoundCount(t *testing.T) { + // mediaBound 必须反映真实绑定数:归档路径靠它决定能否释放旧引用。 + a, _, ms := newGraphMediaAgent(t) + + digest, err := ms.Put([]byte("img"), media.Item{MIME: "image/png"}) + if err != nil { + t.Fatal(err) + } + short := shortDigest(digest) + + // 句子含可反解的短 digest → 应绑定 1 个 + _, _, bound, err := a.commitTriplesWithMedia([]memory.Triple{{ + Subject: "图片", Relation: "内容", Object: "三色带", + SentenceText: "[image/png " + short + "] 一张三色带图", + }}, "s1", 0) + if err != nil { + t.Fatal(err) + } + if bound != 1 { + t.Fatalf("应绑定 1 个媒体引用,实际 %d", bound) + } + + // 句子无 digest → 绑定 0 个 + _, _, bound2, err := a.commitTriplesWithMedia([]memory.Triple{{ + Subject: "张三", Relation: "喜欢", Object: "咖啡", + SentenceText: "张三喜欢咖啡", + }}, "s2", 0) + if err != nil { + t.Fatal(err) + } + if bound2 != 0 { + t.Fatalf("无媒体标记的句子不该绑定引用,实际 %d", bound2) + } +} + +func TestSentenceIDsFromRelations(t *testing.T) { + // 关系行不持有媒体,媒体挂在句子上。这个函数负责"关系→句子"这一跳, + // 去重与去零都不能少:sentence_id=0 表示该关系没有关联句子, + // 拿 0 去查 media_refs 会命中一个不存在的 owner。 + rels := []memory.Relation{ + {ID: 1, SentenceID: 5}, + {ID: 2, SentenceID: 0}, // 无句子 + {ID: 3, SentenceID: 5}, // 重复 + {ID: 4, SentenceID: 7}, + } + got := sentenceIDsFromRelations(rels) + if len(got) != 2 { + t.Fatalf("应得 2 个去重后的句子 id,实际 %v", got) + } + if got[0] != 5 || got[1] != 7 { + t.Fatalf("句子 id 或顺序不对: %v", got) + } + if n := sentenceIDsFromRelations(nil); n != nil { + t.Fatalf("空输入应返回 nil,实际 %v", n) + } +} + +func TestMediaContextForRelations_SurfacesMediaToAgent(t *testing.T) { + // L3 检索接线回归:媒体描述进了图库,agent 必须拿得出来。 + // + // 第四层做完了"存和反查的能力"(RecallMediaForSentence / + // mediaContextForSentences),但那两个函数一度没有任何调用方—— + // 媒体能进 L3,进去之后 agent 检索不到。这个测试守住那条接线。 + a, _, ms := newGraphMediaAgent(t) + + digest, err := ms.Put([]byte("img bytes"), media.Item{MIME: "image/png"}) + if err != nil { + t.Fatal(err) + } + if err := ms.Describe(digest, "一张紫蓝红三色带图", "visionllm"); err != nil { + t.Fatal(err) + } + if err := ms.AddRef(digest, media.OwnerGraphSentence, "5"); err != nil { + t.Fatal(err) + } + + // 命中的关系挂着 5 号句子 → 应产出媒体说明 + out := a.mediaContextForRelations([]memory.Relation{{ID: 1, SentenceID: 5}}) + if out == "" { + t.Fatal("关系挂着有媒体的句子,却没产出媒体说明——L3 检索接线断了") + } + if !contains(out, "一张紫蓝红三色带图") { + t.Errorf("媒体说明里应含描述文本: %q", out) + } + if !contains(out, shortDigest(digest)) { + t.Errorf("媒体说明里应含短 digest 供反查: %q", out) + } + + // 没挂媒体的关系不该产出噪声 + if out := a.mediaContextForRelations([]memory.Relation{{ID: 2, SentenceID: 99}}); out != "" { + t.Errorf("无媒体的句子不该产出说明: %q", out) + } + if out := a.mediaContextForRelations(nil); out != "" { + t.Errorf("空关系不该产出说明: %q", out) + } +} + +func TestBuildMemoryContext_IncludesMediaSection(t *testing.T) { + // buildMemoryContext 是自动注入路径(每次 LLM 调用都走)。 + // 媒体说明必须出现在这里,否则 agent 只有显式调 memory_recall 才知道有图。 + a, graph, ms := newGraphMediaAgent(t) + + digest, err := ms.Put([]byte("auto inject"), media.Item{MIME: "image/png"}) + if err != nil { + t.Fatal(err) + } + if err := ms.Describe(digest, "自动注入用的测试图", "visionllm"); err != nil { + t.Fatal(err) + } + + sentence := "用户发来的图片 [image/png " + shortDigest(digest) + "] 自动注入用的测试图" + sids, _, _, err := graph.CommitWithMedia([]memory.Triple{{ + Subject: "测试图片", Relation: "包含", Object: "三色带", SentenceText: sentence, + }}, "auto", 0) + if err != nil { + t.Fatal(err) + } + sid := sids[sentence] + if sid == 0 { + t.Fatal("拿不到句子 id") + } + if err := ms.AddRef(digest, media.OwnerGraphSentence, strconv.FormatInt(sid, 10)); err != nil { + t.Fatal(err) + } + + a.indexer = memory.NewIndexer(graph) + if err := a.indexer.Sync(); err != nil { + t.Fatalf("indexer sync: %v", err) + } + + out := a.buildMemoryContext("测试图片", 0) + if out == "" { + t.Skip("图库召回未命中(indexer 检索策略所致),无法验证媒体段注入") + } + if !contains(out, "【关联媒体】") { + t.Errorf("自动注入的记忆上下文缺少媒体段: %q", out) + } + if !contains(out, "自动注入用的测试图") { + t.Errorf("媒体段里应含描述文本: %q", out) + } +} + +func TestParseMediaMarkers(t *testing.T) { + // 与 mediaSummaryForEvent 的输出格式严格对应 + text := "用户发来图片\n媒体内容:\n" + + "[image/png a1b2c3d4e5f6] 一张紫蓝红三色带图\n" + + "[audio/wav bbbbccccdddd] 一段三秒的钢琴声\n" + + "[image/png a1b2c3d4e5f6] 重复的同一张图" + + ms := parseMediaMarkers(text) + if len(ms) != 2 { + t.Fatalf("应解析出 2 条去重后的标记,实际 %d: %+v", len(ms), ms) + } + if ms[0].label != "image/png" || ms[0].shortDigest != "a1b2c3d4e5f6" { + t.Errorf("第一条解析错误: %+v", ms[0]) + } + if ms[0].description != "一张紫蓝红三色带图" { + t.Errorf("描述应取到行尾且不跨行: %q", ms[0].description) + } + if ms[1].label != "audio/wav" { + t.Errorf("第二条 label 错误: %+v", ms[1]) + } + // raw 用作 SentenceText,必须含 digest 才能被 bindSentenceMedia 反解 + if !contains(ms[0].raw, "a1b2c3d4e5f6") { + t.Errorf("raw 必须含 digest: %q", ms[0].raw) + } + if n := parseMediaMarkers("没有任何标记的普通文本"); n != nil { + t.Errorf("无标记应返回 nil,实际 %+v", n) + } +} + +func TestMediaEntityName(t *testing.T) { + // 实体名必须由 digest 而非描述构成:描述会被重新生成, + // 若名字取自描述,同一张图会在图谱上留下多个节点。 + cases := []struct{ label, digest, want string }{ + {"image/png", "a1b2c3d4e5f6", "图片 a1b2c3d4e5f6"}, + {"audio/wav", "bbbbccccdddd", "音频 bbbbccccdddd"}, + {"video/mp4", "ccccddddeeee", "视频 ccccddddeeee"}, + {"application/octet-stream", "ddddeeeeffff", "媒体 ddddeeeeffff"}, + } + for _, c := range cases { + got := mediaEntityName(c.label, c.digest) + if got != c.want { + t.Errorf("mediaEntityName(%q,%q) = %q,期望 %q", c.label, c.digest, got, c.want) + } + // 必须过 validEntityName 的 2–50 字符门槛,否则 Commit 会静默跳过 + if n := len([]rune(got)); n < 2 || n > 50 { + t.Errorf("实体名长度 %d 不在 2–50 之间: %q", n, got) + } + } +} + +func TestSummarizeForEntity(t *testing.T) { + cases := []struct{ in, want string }{ + {"一张紫蓝红三色带图。还有更多内容。", "一张紫蓝红三色带图"}, + {"**整体构成**:正方形画布", "整体构成:正方形画布"}, // Markdown 强调符被清掉 + {"", ""}, + {"短", ""}, // 单字过不了 validEntityName,宁可不写 + // 无句子边界时按 rune 截到 40(不是按字节,否则切坏 UTF-8 会在图库里留乱码) + {"没有句子边界的一长串文字需要按 rune 截断以免切坏 UTF-8 编码导致图库里出现乱码实体名字符", + "没有句子边界的一长串文字需要按 rune 截断以免切坏 UTF-8 编码导致图库"}, + } + for _, c := range cases { + got := summarizeForEntity(c.in, 40) + if got != c.want { + t.Errorf("summarizeForEntity(%q) = %q,期望 %q", c.in, got, c.want) + } + } +} + +func TestMediaTriplesFromText_DeterministicRegardlessOfNLP(t *testing.T) { + // 核心回归:媒体入 L3 不再依赖 NLP 提取器的运气。 + // + // 实测 LLM 的 477 字图片描述经提取器只产出「水平 -分割-> 成」, + // obj 仅 1 字被 validEntityName 拒掉 → ec=0 rc=0 → 媒体记忆进不了图库, + // 且时好时坏取决于描述文本。这里验证确定性路径。 + longDesc := "这张图片是一张纯色块构成的抽象图像,不包含任何文字、人物、物体或可识别的场景。" + + "整体构成:一个小尺寸的正方形图像,被水平分割成三条颜色条带。" + text := "媒体内容:\n[image/png 89e293b42546] " + longDesc + + triples := mediaTriplesFromText(text) + if len(triples) < 2 { + t.Fatalf("应至少产出类型+内容两条三元组,实际 %d", len(triples)) + } + + // 每条都必须能通过 validEntityName(经 Commit 实证) + g, err := memory.NewGraphDB(filepath.Join(t.TempDir(), "g.db")) + if err != nil { + t.Fatal(err) + } + defer g.Close() + sids, ec, rc, err := g.CommitWithMedia(triples, "det", 0) + if err != nil { + t.Fatal(err) + } + if ec == 0 || rc == 0 { + t.Fatalf("确定性三元组应能写入图库,实际 ec=%d rc=%d", ec, rc) + } + if len(sids) == 0 { + t.Fatal("应返回句子 id 供 bindSentenceMedia 绑定") + } + // SentenceText 必须含 digest,否则绑定还是断的 + for st := range sids { + if !contains(st, "89e293b42546") { + t.Errorf("句子必须含短 digest 供反解: %q", st) + } + } + + // 描述为空时仍应产出类型三元组——媒体节点不能因为没描述就不存在 + bare := mediaTriplesFromText("[image/png 89e293b42546]") + if len(bare) != 1 { + t.Fatalf("无描述时应只有类型三元组,实际 %d 条", len(bare)) + } + if bare[0].Relation != "类型" { + t.Errorf("无描述时那条应是类型三元组: %+v", bare[0]) + } +} diff --git a/internal/agent/core/medialive_test.go b/internal/agent/core/medialive_test.go new file mode 100644 index 0000000..73031a5 --- /dev/null +++ b/internal/agent/core/medialive_test.go @@ -0,0 +1,570 @@ +//go:build medialive + +// 媒体记忆自动触发链的集成测试。 +// +// 与其他媒体测试的区别:**不手工调用任何一步**。这里只做两件事—— +// 往 IOManager 注入一个 image 事件,然后等。之后全部由生产代码自己走: +// +// processMediaInput → captureBlockMedia(入 CAS) +// → Prune → transferMediaRefs(L0→L2 引用转移) +// → describePendingMedia(真实视觉模型生成描述) +// → archiveColdDocs → commitTriplesWithMedia → bindSentenceMedia(L2→L3) +// → 第二轮提问,验证 agent 真能召回 +// +// 为什么必须这样测:单测能证明每个函数正确,却证明不了它**被接上了**。 +// 本文件的直接动机是一个真实缺陷——core.New() 漏了 rc.SetMediaStore(cfg.MediaStore), +// 于是 L0→L2 引用转移在生产里永远静默 return,而手工注入 store 的单测全绿。 +// +// 需要真实 LLM,因此加 medialive build tag,默认 go test 不跑: +// +// MEDIALIVE_BASE_URL=http://127.0.0.1:8081/v1 \ +// MEDIALIVE_API_KEY=sk-xxx \ +// MEDIALIVE_MODEL=claude-opus-5 \ +// MEDIALIVE_ADAPTER=openai \ +// go test -tags medialive ./internal/agent/core/ -run TestMediaLive -v -timeout 20m +// +// 源、模型、密钥全部由调用方显式指定,测试自己不猜任何默认值—— +// 猜一个默认端点会让测试在别人机器上打到意料之外的服务。 +package core + +import ( + "bytes" + "compress/zlib" + "encoding/binary" + "fmt" + "hash/crc32" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api" + agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io" + luaVM "gitcode.com/JianFeeeee/HomeAgent/internal/lua" + "gitcode.com/JianFeeeee/HomeAgent/internal/memory" + "gitcode.com/JianFeeeee/HomeAgent/internal/memory/document" + "gitcode.com/JianFeeeee/HomeAgent/internal/memory/media" + "gitcode.com/JianFeeeee/HomeAgent/pkg/types" +) + +// liveCfg 是调用方通过环境变量显式提供的 LLM 源配置。 +type liveCfg struct { + baseURL string + apiKey string + model string + adapter string +} + +// requireLiveCfg 读取环境变量;缺任何一项就 Skip 而非猜默认值。 +// +// 刻意不提供 fallback:一个猜出来的 base_url 可能打到调用者机器上 +// 完全不相干的服务,而测试会把那次调用的失败报成"媒体记忆有问题"。 +func requireLiveCfg(t *testing.T) liveCfg { + t.Helper() + c := liveCfg{ + baseURL: os.Getenv("MEDIALIVE_BASE_URL"), + apiKey: os.Getenv("MEDIALIVE_API_KEY"), + model: os.Getenv("MEDIALIVE_MODEL"), + adapter: os.Getenv("MEDIALIVE_ADAPTER"), + } + var missing []string + if c.baseURL == "" { + missing = append(missing, "MEDIALIVE_BASE_URL") + } + if c.apiKey == "" { + missing = append(missing, "MEDIALIVE_API_KEY") + } + if c.model == "" { + missing = append(missing, "MEDIALIVE_MODEL") + } + if c.adapter == "" { + missing = append(missing, "MEDIALIVE_ADAPTER") + } + if len(missing) > 0 { + t.Skipf("缺少环境变量 %s——本测试要求调用方显式指定源/模型/密钥,不使用任何默认值", + strings.Join(missing, ", ")) + } + return c +} + +// livePNG 造一张横向三色带真 PNG(手工拼 IHDR/IDAT/IEND)。 +// +// 用可辨认的纯色而非随机字节:断言要能检查"模型是否真的看到了内容", +// 随机噪声无法产生可验证的描述。 +func livePNG(t *testing.T, w, h int, colors [][3]byte) []byte { + t.Helper() + chunk := func(typ string, data []byte) []byte { + var b bytes.Buffer + if err := binary.Write(&b, binary.BigEndian, uint32(len(data))); err != nil { + t.Fatal(err) + } + body := append([]byte(typ), data...) + b.Write(body) + if err := binary.Write(&b, binary.BigEndian, crc32.ChecksumIEEE(body)); err != nil { + t.Fatal(err) + } + return b.Bytes() + } + var raw bytes.Buffer + for y := 0; y < h; y++ { + raw.WriteByte(0) // filter type: none + c := colors[y*len(colors)/h] + for x := 0; x < w; x++ { + raw.Write(c[:]) + } + } + var comp bytes.Buffer + zw := zlib.NewWriter(&comp) + if _, err := zw.Write(raw.Bytes()); err != nil { + t.Fatal(err) + } + zw.Close() + + var ihdr bytes.Buffer + binary.Write(&ihdr, binary.BigEndian, uint32(w)) + binary.Write(&ihdr, binary.BigEndian, uint32(h)) + ihdr.Write([]byte{8, 2, 0, 0, 0}) // 8-bit truecolor + + var out bytes.Buffer + out.Write([]byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}) + out.Write(chunk("IHDR", ihdr.Bytes())) + out.Write(chunk("IDAT", comp.Bytes())) + out.Write(chunk("IEND", nil)) + return out.Bytes() +} + +// liveEnv 是一套完整但完全独立的 agent 运行环境。 +type liveEnv struct { + agent *Agent + io *agentIO.IOManager + mediaSt *media.Store + docStore *document.Store + graph *memory.GraphDB + dir string +} + +// newLiveEnv 构造真 Agent:真 provider、真 CAS、真图库、真文档库。 +// +// 不注册任何插件:本测试关心记忆链路,插件会引入无关的外部副作用 +// (网络轮询、写文件),而且生产插件目录里的进程不该被测试碰到。 +func newLiveEnv(t *testing.T, c liveCfg) *liveEnv { + t.Helper() + dir := t.TempDir() + + vm := luaVM.NewVM(filepath.Join(dir, "adapters")) + if err := vm.Start(); err != nil { + t.Fatalf("lua vm: %v", err) + } + t.Cleanup(vm.Stop) + + // Vision: true —— 能力是声明的,不是探测的。网关可能静默剥离 + // image_url 后仍返回 200,从响应无法推断它到底看见了没有。 + prov := agentAPI.NewLuaAdaptedProvider(agentAPI.BaseConfig{ + Model: c.model, BaseURL: c.baseURL, APIKey: c.apiKey, + MaxTokens: 1200, Temperature: 0.3, Vision: true, + }, vm, "medialive", c.adapter) + + pm := agentAPI.NewProviderManager() + pm.Register("medialive", prov) + if err := pm.SetDefault("medialive"); err != nil { + t.Fatalf("set default provider: %v", err) + } + + ms, err := media.New(filepath.Join(dir, "media"), 256<<20) + if err != nil { + t.Fatalf("media store: %v", err) + } + t.Cleanup(func() { ms.Close() }) + + graph, err := memory.NewGraphDB(filepath.Join(dir, "graph.db")) + if err != nil { + t.Fatalf("graph: %v", err) + } + t.Cleanup(func() { graph.Close() }) + + docStore := document.NewStore(filepath.Join(dir, "docs")) + if err := docStore.Start(); err != nil { + t.Fatalf("doc store: %v", err) + } + t.Cleanup(docStore.Stop) + + io := agentIO.NewIOManager() + + a := New(AgentConfig{ + ID: types.AgentID("medialive"), + SystemPrompt: "你是一个有长期记忆的助手。回答简洁准确。", + Provider: prov, + ProviderManager: pm, + IO: io, + Memory: graph, + DocStore: docStore, + MediaStore: ms, + MediaGCInterval: 0, // 本测试自己控制 GC 时机 + MediaDescribe: true, // 描述循环由测试直接调 describePendingMedia + StageHost: NewStageHost(), + MaxContextSize: 3, // 故意压低:第二轮就能触发 Prune 归档 + InputProcessing: types.InputProcessingConfig{}, + }) + + // 排空 outputCh:容量 256,但长跑不消费会堵住 emitResponse。 + go func() { + for { + select { + case <-io.OutputChan(): + case <-a.ctx.Done(): + return + } + } + }() + + return &liveEnv{agent: a, io: io, mediaSt: ms, docStore: docStore, graph: graph, dir: dir} +} + +// TestMediaLive_AutoTriggerChain 全自动触发链:只注入事件,不手工调任何一步。 +func TestMediaLive_AutoTriggerChain(t *testing.T) { + c := requireLiveCfg(t) + env := newLiveEnv(t, c) + a := env.agent + defer a.Stop() + + img := livePNG(t, 96, 96, [][3]byte{{128, 0, 255}, {0, 64, 255}, {255, 0, 0}}) + t.Logf("测试图片: %d 字节(紫/蓝/红三色带)", len(img)) + + // ── 阶段 1:注入 image 事件,验证 CAS 自动落盘 ── + // + // 直接调 handleInput 而不启 eventLoop:eventLoop 是纯转发(select → + // handleInput),走同一条代码路径,但同步调用让断言不必猜时序。 + evt := &agentIO.InputEvent{ + RequestID: "live-1", + Source: "test_channel", + Type: "image", + OutputChannel: "test_channel", + Payload: map[string]interface{}{ + "data": mediaB64(img), + "mime": "image/png", + "alt": "一张测试图片", + }, + } + + t0 := time.Now() + a.handleInput(evt) + t.Logf("第一轮(含真实 LLM 往返)耗时 %.1fs", time.Since(t0).Seconds()) + + // 用 Pending 而非 Search 查刚落盘的项:Search 的 WHERE 里带 + // `COALESCE(description,'') != ''`,只返回**已描述**的媒体, + // 此刻描述还没生成(阶段3 才做),Search 必然返回 0 条。 + items, err := env.mediaSt.Pending(10) + if err != nil { + t.Fatalf("pending: %v", err) + } + if len(items) != 1 { + t.Fatalf("CAS 应自动收到 1 张图,实际 %d 张(captureBlockMedia 未被触发?)", len(items)) + } + digest := items[0].Digest + t.Logf("✓ 阶段1 CAS 自动落盘: digest=%s size=%d tool=%s", + digest[:12], items[0].Size, items[0].Tool) + + stored, err := env.mediaSt.Get(digest) + if err != nil || !bytes.Equal(stored, img) { + t.Fatalf("落盘内容与原图不一致 (err=%v)", err) + } + + // ── 阶段 2:引用自动挂到 ContextEvent 上 ── + // + // 这一步验证 bindEventMedia:事件必须拿到 ID 且 media_refs 里 + // 有对应 context owner 记录。两者只写一个的后果是 GC 误删或永不清理。 + var evtID string + var summaryOK bool + for _, e := range a.context.Recent(0) { + if len(e.Media) > 0 { + evtID = e.ID + summaryOK = strings.Contains(e.Input, digest[:12]) + 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) + + // ── 阶段 3:描述由后台循环自动生成(真实视觉模型)── + pending, err := env.mediaSt.Pending(5) + if err != nil { + t.Fatal(err) + } + if len(pending) != 1 { + t.Fatalf("应有 1 条待描述,实际 %d 条", len(pending)) + } + + t1 := time.Now() + a.describePendingMedia() + t.Logf("描述生成耗时 %.1fs", time.Since(t1).Seconds()) + + it, err := env.mediaSt.Stat(digest) + if err != nil { + t.Fatal(err) + } + if it.Description == "" { + t.Fatal("描述为空——describePendingMedia 未能通过视觉源生成描述") + } + sawColors := strings.Contains(it.Description, "紫") && + strings.Contains(it.Description, "蓝") && + strings.Contains(it.Description, "红") + t.Logf("✓ 阶段3 描述自动生成 (%d 字, 源=%s): %s", + len([]rune(it.Description)), it.DescribedBy, truncRunes(it.Description, 90)) + if !sawColors { + t.Errorf("描述未含紫/蓝/红三色,视觉模型可能没真正看到图片: %s", + truncRunes(it.Description, 200)) + } + if left, _ := env.mediaSt.Pending(5); len(left) != 0 { + t.Errorf("描述完成后仍在待描述队列(%d 条)——会被反复重描述", len(left)) + } + // 有描述之后 Search 才应能命中(它按 description 做 LIKE) + if found, err := env.mediaSt.Search("紫", media.KindImage, 5); err != nil { + t.Errorf("search: %v", err) + } else if len(found) == 0 { + t.Error("描述已生成但 Search(\"紫\") 命中 0 条——媒体库关键词入口失效") + } else { + t.Logf("✓ 阶段3 Search(\"紫\") 命中 %d 条", len(found)) + } + + // ── 阶段 4:Prune 自动把引用从 L0 转移到 L2 ── + // + // MaxContextSize=3,多注入几轮文本把带图事件挤出活跃上下文。 + // 这一步专门守 core.New() 里 rc.SetMediaStore 的接线:漏了它 + // transferMediaRefs 直接 return,引用永久悬空在 context owner 上。 + // 填充数量必须 > Prune 内部固定的 10 条保护窗口。 + // + // Prune 无条件保护最后 10 条事件(protected := events[len-10:]), + // 只在更早的部分里挑归档对象。填 4 条时总数才 5,全落进保护窗口、 + // candidates 为空、直接返回 0——这不是缺陷,是"最近的对话不该被归档" + // 的设计。带图事件必须被推到第 11 条之前才可能被归档。 + const fillerCount = 14 + for i := 0; i < fillerCount; i++ { + a.context.Append(ContextEvent{ + Timestamp: time.Now(), + Source: "filler", + Input: fmt.Sprintf("无关的填充对话 %d,用来把带图事件挤出活跃窗口", i), + Response: "好的。", + }) + } + archived := a.context.Prune("当前输入", a.maxContextSize-1, env.docStore) + t.Logf("Prune 归档 %d 条事件", archived) + if archived == 0 { + t.Fatal("Prune 未归档任何事件,无法验证引用转移") + } + + 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 + } + } + if docRefsFound == "" { + t.Fatal("引用未转移到 document owner——" + + "core.New() 是否漏了 rc.SetMediaStore(cfg.MediaStore)?" + + "(该缺陷曾真实存在:手工注入 store 的单测全绿,生产里永远静默 return)") + } + if left, _ := env.mediaSt.Refs(media.OwnerContext, evtID); len(left) != 0 { + t.Errorf("旧的 context 引用未注销(%d 条),引用计数永不归零 → blob 永不回收", len(left)) + } + t.Logf("✓ 阶段4 引用自动转移: context/%s → document/%s", evtID, docRefsFound) + + // 转移全程内容必须可读:先挂后销的顺序若反了, + // 计数会瞬时归零,并发 GC 会把仍被引用的内容当孤儿删掉。 + if _, err := env.mediaSt.Get(digest); err != nil { + t.Fatalf("转移后内容不可读: %v", err) + } + + // ── 阶段 5:archiveColdDocs 自动把媒体带进 L3 图库 ── + // + // FindColdDocs(72h, 2) 要求文档足够"冷",测试里新建的文档不满足, + // 因此把 LastAccess 往前推——这是为了触发生产代码路径, + // 而不是替代它(Commit/bindSentenceMedia/releaseDocMedia 全部由它自己调)。 + for _, d := range env.docStore.RecentDocs(20) { + if d.ID == docRefsFound { + d.LastAccess = time.Now().Add(-100 * time.Hour) + d.AccessCount = 0 + } + } + a.archiveColdDocs() + + sentRefs := 0 + var boundSentence int64 + rows, err := env.graph.Recall(nil, nil, 1, "") + if err != nil { + t.Fatalf("graph recall: %v", err) + } + 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) + if boundSentence == 0 { + boundSentence = sid + } + } + } + if sentRefs == 0 { + t.Error("L2→L3 未绑定任何 graph_sentence 引用——" + + "bindSentenceMedia 未被 commitTriplesWithMedia 触发," + + "或句子正文里没有可反解的短 digest") + } else { + 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) { + 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) + } + if _, err := env.mediaSt.Stat(digest); err != nil { + t.Fatalf("被记忆引用的内容被 GC 删除了(清 %d 条/%d 字节)——"+ + "引用计数或 owner 语义有误", removed, freed) + } + t.Logf("✓ 阶段6 GC(minAge=0) 清 %d 条,被引用内容仍在", removed) + + // ── 阶段 7:E2E — 第二轮提问,验证 agent 真能召回 ── + // + // 不再提供图片,只问"还记得吗"。能答出三色说明记忆链路端到端可用。 + // L2 文档此刻已被 archiveColdDocs 删除(归档的语义就是搬完删源), + // 所以这一轮只能靠 L3 图库召回——而自动注入路径依赖 indexer。 + // 生产由 main.go 注入并周期 Sync;测试里手工建一个并同步一次。 + a.indexer = memory.NewIndexer(env.graph) + if err := a.indexer.Sync(); err != nil { + t.Fatalf("indexer sync: %v", err) + } + if mc := a.buildMemoryContext("图片 颜色", 0); mc != "" { + t.Logf("注入的记忆上下文: %s", truncRunes(mc, 200)) + if strings.Contains(mc, "【关联媒体】") { + t.Logf("✓ 记忆上下文含媒体段") + } else { + t.Error("记忆上下文缺少媒体段——L3 媒体检索接线未生效") + } + } else { + t.Error("图库召回为空,agent 无从得知历史媒体") + } + + ask := &agentIO.InputEvent{ + RequestID: "live-2", + Source: "test_channel", + Type: "text", + OutputChannel: "test_channel", + Payload: map[string]interface{}{ + "content": "你还记得我之前发给你的那张图片吗?它是什么样子的?请说出具体颜色。", + }, + } + respCh := make(chan *agentIO.OutputEvent, 4) + ask.ResponseCh = respCh + + t2 := time.Now() + a.handleInput(ask) + t.Logf("第二轮耗时 %.1fs", time.Since(t2).Seconds()) + + var answer string + select { + case out := <-respCh: + answer, _ = out.Payload["content"].(string) + case <-time.After(5 * time.Second): + t.Fatal("第二轮没有收到回复") + } + t.Logf("agent 回答: %s", truncRunes(answer, 220)) + + recalled := strings.Contains(answer, "紫") && + strings.Contains(answer, "蓝") && + strings.Contains(answer, "红") + if !recalled { + t.Errorf("agent 未能召回三色。这可能是记忆注入链路问题,"+ + "也可能是本轮上下文里已无相关记忆(描述在 L2/L3 但未被检索命中)。回答: %s", + truncRunes(answer, 300)) + } else { + t.Logf("✓ 阶段7 E2E 召回成功:不给图,agent 答出紫/蓝/红") + } + + st := env.mediaSt.Stats() + t.Logf("收尾: %v 条 / %v 字节 / 已描述 %v / 无引用 %v", + st["count"], st["total_bytes"], st["described"], st["unreferenced"]) +} + +// TestMediaLive_NegativeControl 阴性对照:没有媒体记忆时不该"记得"。 +// +// 没有这条对照,阶段7 的"答出紫蓝红"可能只是模型在猜常见配色, +// 无法区分真召回与先验偏好。 +func TestMediaLive_NegativeControl(t *testing.T) { + c := requireLiveCfg(t) + env := newLiveEnv(t, c) + a := env.agent + defer a.Stop() + + ask := &agentIO.InputEvent{ + RequestID: "neg-1", + Source: "test_channel", + Type: "text", + OutputChannel: "test_channel", + Payload: map[string]interface{}{ + "content": "你还记得我之前发给你的那张图片吗?它是什么样子的?请说出具体颜色。", + }, + } + respCh := make(chan *agentIO.OutputEvent, 4) + ask.ResponseCh = respCh + + a.handleInput(ask) + + var answer string + select { + case out := <-respCh: + answer, _ = out.Payload["content"].(string) + case <-time.After(5 * time.Second): + t.Fatal("阴性对照没有收到回复") + } + t.Logf("无记忆时的回答: %s", truncRunes(answer, 200)) + + // 上游不可用时这条对照没有意义:它只能证明"没答出颜色", + // 而原因是调用失败而非缺少记忆。据此判 PASS 属于假阳性。 + if strings.HasPrefix(answer, "处理错误:") { + t.Skipf("上游 LLM 调用失败,阴性对照无法判定: %s", truncRunes(answer, 160)) + } + + guessed := strings.Contains(answer, "紫") && + strings.Contains(answer, "蓝") && + strings.Contains(answer, "红") + if guessed { + t.Errorf("无任何媒体记忆却猜中紫/蓝/红——"+ + "说明阳性用例的通过可能只是先验偏好而非真召回: %s", truncRunes(answer, 300)) + } +} + +// mediaB64 返回不带 data URL 前缀的 base64(processMediaInput 自己拼前缀)。 +func mediaB64(b []byte) string { + return media.DataURL("image/png", b)[len("data:image/png;base64,"):] +} + +func truncRunes(s string, n int) string { + r := []rune(strings.ReplaceAll(s, "\n", " ")) + if len(r) <= n { + return string(r) + } + return string(r[:n]) + "…" +} diff --git a/internal/agent/core/medialoop.go b/internal/agent/core/medialoop.go new file mode 100644 index 0000000..aa38509 --- /dev/null +++ b/internal/agent/core/medialoop.go @@ -0,0 +1,183 @@ +package core + +import ( + "log" + "runtime/debug" + "time" + + "gitcode.com/JianFeeeee/HomeAgent/internal/memory/media" +) + +// 媒体记忆的两条后台循环。 +// +// mediaGCLoop 清理无人引用的 blob,让容量上限真正生效。 +// mediaDescribeLoop 给未描述的媒体生成文字描述(方案 C 的另一半)。 +// +// 为何描述要走后台而不是入库时同步做:视觉模型一次调用在生产实测 9.6s +// (see_video 6 帧批量 23s)。放在对话路径上会让每张图都给回复加十几秒, +// 而描述的价值是**几个月后还能检索到这张图**,不是这一轮对话—— +// 这一轮模型本来就直接看着图。 + +const ( + // mediaDescribeBatch 是单轮描述的媒体条数上限。 + // + // 取 4:既有回退链的 modalFallbackMaxBlocks 是 6(一次请求最多带 6 个媒体), + // 这里留出余量,且每条单独请求以便逐条落库——批量描述拿回来一整段文字 + // 无法可靠切分回各自的 digest。 + mediaDescribeBatch = 4 + + // mediaDescribeMinInterval 是两轮描述之间的最小间隔。 + // + // 描述是纯后台的锦上添花,不该跟对话抢视觉模型配额。取 30s 让它 + // 慢慢消化积压,而不是一上线就把几百条历史媒体全打过去。 + 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 图库。 +// 于是「那张紫蓝红三色带图」在原始字节早已被清掉之后仍然可被检索到。 +func (a *Agent) mediaDescribeLoop() { + defer func() { + if r := recover(); r != nil { + log.Printf("[agent] mediaDescribeLoop panic recovered: %v\n%s", r, debug.Stack()) + time.Sleep(time.Second) + go a.mediaDescribeLoop() + } + }() + if a.mediaStore == nil || !a.mediaDescribe { + return + } + + ticker := time.NewTicker(mediaDescribeMinInterval) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + a.describePendingMedia() + case <-a.ctx.Done(): + return + } + } +} + +// describePendingMedia 取一批未描述的媒体逐条描述。 +// +// 逐条而非批量:批量拿回来是一整段文字,无法可靠切分回各自的 digest +// (模型未必按序号输出,也可能把两张图合并成一句)。宁可多几次往返 +// 也要保证「描述 ↔ digest」的对应关系是确定的。 +func (a *Agent) describePendingMedia() { + pending, err := a.mediaStore.Pending(mediaDescribeBatch) + if err != nil { + log.Printf("[media] 取待描述项失败: %v", err) + return + } + if len(pending) == 0 { + return + } + + for _, it := range pending { + select { + case <-a.ctx.Done(): + return + default: + } + + kind := "image" + if it.Kind == media.KindAudio { + kind = "audio" + } else if it.Kind != media.KindImage { + // 视频帧以 image 入库;其余大类没有可用的描述通道, + // 标记成"不可描述"以免每轮都被 Pending 取出来重试。 + if err := a.mediaStore.Describe(it.Digest, "", "unsupported"); err != nil { + log.Printf("[media] 标记不可描述失败 %s: %v", shortDigest(it.Digest), err) + } + continue + } + + p, srcName := a.resolveModalFallback(kind) + if p == nil { + // 没有声明该模态能力的源——这一轮整体跳过,不逐条重试。 + // 配置好之后自然会被下一轮捡起来。 + log.Printf("[media] 无可用的 %s 描述源,跳过本轮(%d 条待描述)", kind, len(pending)) + return + } + + data, err := a.mediaStore.Get(it.Digest) + if err != nil { + // blob 已被 GC 清掉但元数据还在(GC 会同删,此处属异常路径): + // 标记一下避免死循环。 + log.Printf("[media] 读内容失败 %s: %v", shortDigest(it.Digest), err) + if e := a.mediaStore.Describe(it.Digest, "", "content-missing"); e != nil { + log.Printf("[media] 标记内容缺失失败 %s: %v", shortDigest(it.Digest), e) + } + continue + } + + mime := it.MIME + if mime == "" { + mime = "image/png" + } + url := media.DataURL(mime, data) + + desc, err := a.chatModalFallbackBatch(p, kind, []string{url}, []string{"high"}) + if err != nil { + // 失败不标记:可能是网络抖动或配额,下一轮该重试。 + log.Printf("[media] 描述失败 %s (源=%s): %v", shortDigest(it.Digest), srcName, err) + continue + } + if desc == "" { + // 空回复通常意味着上游把媒体剥离了——与 modalfallback 里的判断 + // 同一个道理,视作失败而非"没什么可说的"。 + log.Printf("[media] 描述为空 %s (源=%s),视作失败", shortDigest(it.Digest), srcName) + continue + } + + if err := a.mediaStore.Describe(it.Digest, desc, srcName); err != nil { + log.Printf("[media] 写描述失败 %s: %v", shortDigest(it.Digest), err) + continue + } + log.Printf("[media] 已描述 %s (%s, %d 字, 源=%s)", shortDigest(it.Digest), kind, len([]rune(desc)), srcName) + } +} diff --git a/internal/agent/core/medialoop_test.go b/internal/agent/core/medialoop_test.go new file mode 100644 index 0000000..e8bbe97 --- /dev/null +++ b/internal/agent/core/medialoop_test.go @@ -0,0 +1,176 @@ +package core + +import ( + "context" + "path/filepath" + "testing" + "time" + + "gitcode.com/JianFeeeee/HomeAgent/internal/memory/media" +) + +// 媒体后台循环测试。 +// +// 两条循环都要能在「未启用」时干净退出——它们随 Agent.Start() 无条件启动, +// 若不早退就会在每个没配媒体存储的部署上空转一个 goroutine。 + +func newMediaLoopAgent(t *testing.T, gcInterval, minAge time.Duration, describe bool) (*Agent, *media.Store) { + t.Helper() + dir := t.TempDir() + ms, err := media.New(filepath.Join(dir, "media"), 0) + if err != nil { + t.Fatalf("media.New: %v", err) + } + t.Cleanup(func() { ms.Close() }) + + a := &Agent{ + mediaStore: ms, + mediaGCInterval: gcInterval, + mediaGCMinAge: minAge, + 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) + done := make(chan struct{}) + go func() { a.mediaDescribeLoop(); close(done) }() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("describe 关闭时 mediaDescribeLoop 未立即返回") + } +} + +func TestDescribePendingMedia_NoProviderLeavesUndescribed(t *testing.T) { + // 没有声明视觉能力的源时整轮跳过,且**不能**把项标记成已处理—— + // 配置好之后必须还能被捡起来。 + a, ms := newMediaLoopAgent(t, 0, 0, true) + d, _ := ms.Put([]byte("img"), media.Item{MIME: "image/png"}) + + // providerManager 为 nil → resolveModalFallback 返回 nil + a.describePendingMedia() + + it, err := ms.Stat(d) + if err != nil { + t.Fatal(err) + } + if it.Description != "" || it.DescribedBy != "" { + t.Fatalf("无可用源时不该写描述: %+v", it) + } + pending, _ := ms.Pending(10) + if len(pending) != 1 { + t.Fatalf("项应仍在待描述队列里,实际 %d 条", len(pending)) + } +} + +func TestDescribePendingMedia_MarksUnsupportedKind(t *testing.T) { + // video/other 大类没有可用的描述通道,必须标记掉, + // 否则每轮 Pending 都把它取出来重试,永远卡住队列头部。 + a, ms := newMediaLoopAgent(t, 0, 0, true) + + other, _ := ms.Put([]byte("blob"), media.Item{MIME: "application/octet-stream"}) + a.describePendingMedia() + + it, err := ms.Stat(other) + if err != nil { + t.Fatal(err) + } + 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)) + } +} + +func TestDescribePendingMedia_EmptyQueueIsNoop(t *testing.T) { + a, _ := newMediaLoopAgent(t, 0, 0, true) + a.describePendingMedia() // 不该 panic +} diff --git a/internal/agent/core/mediaref.go b/internal/agent/core/mediaref.go new file mode 100644 index 0000000..56a8099 --- /dev/null +++ b/internal/agent/core/mediaref.go @@ -0,0 +1,160 @@ +package core + +import ( + "fmt" + "log" + "strings" + "time" + + agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api" + "gitcode.com/JianFeeeee/HomeAgent/internal/memory/media" +) + +// 媒体记忆接线:把对话里出现的图片/音频落进内容寻址存储(CAS), +// 并让 L0 的 ContextEvent 记住它们的 digest。 +// +// 为何需要这一层:媒体进入对话有两条路,两条都只把**文字**留给记忆—— +// +// 1. 用户直接发图 → processMediaInput → mediaToBlocks +// ContextEvent.Input 只存 alt 文本("[从 qq 收到了 image]"), +// base64 随 message 数组发给模型后就丢了。 +// 2. 插件注入 → SetToolBlocks → process.go 的 mediaMsg +// ToolResultItem.Output 只存那句 "[已将图片注入后续对话] /tmp/x.png"。 +// +// 于是下一轮对话起,模型能看到的只有一句路径或一句 alt。那个文件被删、 +// 被覆盖,或者本来就是 /tmp 下的临时产物,连线索都断了。 +// +// 现在两条路都在同一处收口:从 ContentBlock 的 data URL 取出字节存进 CAS, +// digest 挂到当轮 ContextEvent 上;事件被 Prune 归档进 L2 时引用随之转移。 + +// captureBlockMedia 把 blocks 里的 data URL 媒体落进 CAS,返回 digest 列表。 +// +// 只处理 data URL:http(s) URL 拿不到字节就无法做内容寻址, +// 而"下载它再存"会把一次对话变成一次网络请求(超时、鉴权、SSRF 全来了), +// 不在本层解决。 +func (a *Agent) captureBlockMedia(blocks []agentAPI.ContentBlock, tool string) []string { + if a.mediaStore == nil || len(blocks) == 0 { + return nil + } + + var digests []string + for _, b := range blocks { + var url string + switch { + case b.ImageURL != nil && b.ImageURL.URL != "": + url = b.ImageURL.URL + case b.AudioURL != nil && b.AudioURL.URL != "": + url = b.AudioURL.URL + default: + continue + } + + mime, data, ok := media.ParseDataURL(url) + if !ok { + continue // http(s) URL 或格式不认,跳过 + } + + d, err := a.mediaStore.Put(data, media.Item{ + MIME: mime, + Tool: tool, + }) + if err != nil { + // 媒体存不进去不该让对话失败——它是记忆增强,不是对话必需品 + log.Printf("[media] 落盘失败 (tool=%s mime=%s): %v", tool, mime, err) + continue + } + digests = append(digests, d) + } + return digests +} + +// stageMediaDigests 累积本轮捕获的 digest,等 ContextEvent 建好后一起挂上。 +// +// 为何要缓存而不是当场 AddRef:媒体在 process() 执行期间被捕获,而承载它的 +// ContextEvent 要等 process() 返回后才 Append——此刻还没有 owner_id。 +// 与既有的 a.pendingMedia 同一手法(都在 a.mu 保护下)。 +func (a *Agent) stageMediaDigests(digests ...string) { + if len(digests) == 0 { + return + } + a.pendingMediaDigests = append(a.pendingMediaDigests, digests...) +} + +// drainMediaDigests 取出并清空本轮累积的 digest。 +func (a *Agent) drainMediaDigests() []string { + if len(a.pendingMediaDigests) == 0 { + return nil + } + out := a.pendingMediaDigests + a.pendingMediaDigests = nil + return out +} + +// bindEventMedia 把 digest 列表登记到某个 ContextEvent 上。 +// +// 双向落地:evt.Media 让事件自己记得引了哪些媒体(随 context.json 持久化), +// media_refs 表让 CAS 侧知道谁在引用(GC 据此判断能不能清)。 +// 两边都写才闭环——只写一边的话,要么 GC 会误删仍被记忆引用的内容, +// 要么孤儿永远清不掉。 +func (a *Agent) bindEventMedia(evt *ContextEvent, digests []string) { + if a.mediaStore == nil || evt == nil || len(digests) == 0 { + return + } + if evt.ID == "" { + 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) + continue + } + evt.Media = append(evt.Media, d) + } +} + +// mediaSummaryForEvent 给已有描述的媒体生成一行文字,供写进 ContextEvent.Input。 +// +// 这是方案 C 的落点:**描述文本才是持久语义记忆,blob 只是缓存**。 +// blob 可能被容量 GC 淘汰,但描述会一直留在 L0/L2/L3 的文本里, +// 让"那张紫蓝红三色带图"在几个月后仍然可被检索到。 +func (a *Agent) mediaSummaryForEvent(digests []string) string { + if a.mediaStore == nil || len(digests) == 0 { + return "" + } + var lines []string + for _, d := range digests { + it, err := a.mediaStore.Stat(d) + if err != nil || it == nil { + continue + } + label := string(it.Kind) + if it.MIME != "" { + label = it.MIME + } + if it.Description != "" { + lines = append(lines, fmt.Sprintf("[%s %s] %s", label, shortDigest(d), it.Description)) + } else { + lines = append(lines, fmt.Sprintf("[%s %s] (未描述)", label, shortDigest(d))) + } + } + if len(lines) == 0 { + return "" + } + return "媒体内容:\n" + strings.Join(lines, "\n") +} + +// newEventID 生成 ContextEvent 的稳定标识。 +// +// 沿用 document.Store 的 doc_ 手法(同一份代码库里保持一致, +// 也避免为此引入 uuid 依赖)。纳秒精度足够:同一 Agent 的事件由 +// a.mu 串行化 Append,不存在同纳秒两条。 +func newEventID() string { + return fmt.Sprintf("evt_%d", time.Now().UnixNano()) +} + +func shortDigest(d string) string { + if len(d) > 12 { + return d[:12] + } + return d +} diff --git a/internal/agent/core/mediaref_test.go b/internal/agent/core/mediaref_test.go new file mode 100644 index 0000000..875b0dc --- /dev/null +++ b/internal/agent/core/mediaref_test.go @@ -0,0 +1,355 @@ +package core + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api" + "gitcode.com/JianFeeeee/HomeAgent/internal/memory" + "gitcode.com/JianFeeeee/HomeAgent/internal/memory/document" + "gitcode.com/JianFeeeee/HomeAgent/internal/memory/media" +) + +// 媒体记忆接线测试:验证媒体从对话进入 CAS、挂到 L0 事件、 +// 随归档转到 L2 文档的完整链路。 +// +// 核心断言不是"函数被调用了",而是不变量: +// 1. 媒体存不进去时对话照常(它是记忆增强,不是对话必需品) +// 2. 引用转移期间内容始终可读(先挂后销,不留归零窗口) +// 3. mediaStore 为 nil 时全链路静默跳过,行为与本特性上线前一致 + +func newTestAgentWithMedia(t *testing.T) (*Agent, *media.Store) { + t.Helper() + dir := t.TempDir() + ms, err := media.New(filepath.Join(dir, "media"), 0) + if err != nil { + t.Fatalf("media.New: %v", err) + } + t.Cleanup(func() { ms.Close() }) + + emb := memory.NewStaticEmbedder() + a := &Agent{ + mediaStore: ms, + context: NewRelevanceContext(filepath.Join(dir, "context.json"), emb), + } + a.context.SetMediaStore(ms) + return a, ms +} + +// imageBlockURL 造一个带指定 URL 的图片块。 +// 名字带 URL 后缀是为了不与 modalfallback_test.go 里固定用 testPNG 的 +// imageBlock() 撞名——两者用途不同:那个验回退链,这个验入库。 +func imageBlockURL(dataURL string) agentAPI.ContentBlock { + return agentAPI.ContentBlock{ + Type: "image_url", + ImageURL: &agentAPI.ImageURL{URL: dataURL, Detail: "auto"}, + } +} + +func TestCaptureBlockMedia_StoresDataURL(t *testing.T) { + a, ms := newTestAgentWithMedia(t) + + raw := []byte{0x89, 'P', 'N', 'G', 1, 2, 3} + blocks := []agentAPI.ContentBlock{ + {Type: "text", Text: "看这张图"}, + imageBlockURL(media.DataURL("image/png", raw)), + } + + digests := a.captureBlockMedia(blocks, "multimodal_see_picture") + if len(digests) != 1 { + t.Fatalf("应捕获 1 个媒体,实际 %d", len(digests)) + } + + got, err := ms.Get(digests[0]) + if err != nil { + t.Fatalf("回读失败: %v", err) + } + if string(got) != string(raw) { + t.Fatal("内容不一致") + } + it, _ := ms.Stat(digests[0]) + if it.MIME != "image/png" || it.Tool != "multimodal_see_picture" || it.Kind != media.KindImage { + t.Fatalf("元数据不对: %+v", it) + } +} + +func TestCaptureBlockMedia_SkipsHTTPURL(t *testing.T) { + // http(s) URL 拿不到字节就无法内容寻址;"下载它再存"会把一次对话 + // 变成一次网络请求(超时、鉴权、SSRF 全来了),不在本层解决。 + a, _ := newTestAgentWithMedia(t) + + blocks := []agentAPI.ContentBlock{ + imageBlockURL("https://example.com/x.png"), + } + if d := a.captureBlockMedia(blocks, "t"); len(d) != 0 { + t.Fatalf("http URL 不该被捕获,实际 %d 个", len(d)) + } +} + +func TestCaptureBlockMedia_NilStoreIsNoop(t *testing.T) { + // mediaStore 未启用时全链路静默跳过,不能 panic 也不能报错—— + // 行为必须与本特性上线前完全一致。 + a := &Agent{} + blocks := []agentAPI.ContentBlock{imageBlockURL(media.DataURL("image/png", []byte("x")))} + if d := a.captureBlockMedia(blocks, "t"); d != nil { + t.Fatalf("nil store 应返回 nil,实际 %v", d) + } + a.stageMediaDigests("deadbeef") + if got := a.drainMediaDigests(); len(got) != 1 { + t.Fatal("stage/drain 不依赖 store,应正常工作") + } + // bindEventMedia 对 nil store 也必须安全 + evt := &ContextEvent{} + a.bindEventMedia(evt, []string{"deadbeef"}) + if len(evt.Media) != 0 || evt.ID != "" { + t.Fatalf("nil store 时不该改动事件: %+v", evt) + } + if s := a.mediaSummaryForEvent([]string{"deadbeef"}); s != "" { + t.Fatalf("nil store 时摘要应为空,得到 %q", s) + } +} + +func TestCaptureBlockMedia_AudioAndVideo(t *testing.T) { + a, ms := newTestAgentWithMedia(t) + + blocks := []agentAPI.ContentBlock{ + imageBlockURL(media.DataURL("image/jpeg", []byte("frame"))), + {Type: "audio_url", AudioURL: &agentAPI.AudioURL{URL: media.DataURL("audio/wav", []byte("sound"))}}, + } + digests := a.captureBlockMedia(blocks, "multimodal_see_video") + if len(digests) != 2 { + t.Fatalf("应捕获 2 个,实际 %d", len(digests)) + } + + kinds := map[media.Kind]int{} + for _, d := range digests { + it, err := ms.Stat(d) + if err != nil { + t.Fatal(err) + } + kinds[it.Kind]++ + } + if kinds[media.KindImage] != 1 || kinds[media.KindAudio] != 1 { + t.Fatalf("大类归属不对: %v", kinds) + } +} + +func TestStageDrainMediaDigests(t *testing.T) { + a, _ := newTestAgentWithMedia(t) + + a.stageMediaDigests("a", "b") + a.stageMediaDigests("c") + got := a.drainMediaDigests() + if len(got) != 3 { + t.Fatalf("应累积 3 个,实际 %d", len(got)) + } + // drain 后必须清空——否则下一轮对话会把上一轮的媒体又挂一遍 + if again := a.drainMediaDigests(); again != nil { + t.Fatalf("drain 后应为空,实际 %v", again) + } +} + +func TestBindEventMedia_CreatesIDAndRefs(t *testing.T) { + a, ms := newTestAgentWithMedia(t) + + d, err := ms.Put([]byte("img"), media.Item{MIME: "image/png"}) + if err != nil { + t.Fatal(err) + } + + evt := &ContextEvent{Timestamp: time.Now(), Source: "qq", Input: "看图"} + a.bindEventMedia(evt, []string{d}) + + if evt.ID == "" { + t.Fatal("应懒生成事件 ID") + } + if len(evt.Media) != 1 || evt.Media[0] != d { + t.Fatalf("事件应记住 digest: %+v", evt.Media) + } + // 双向落地: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)) + } +} + +func TestBindEventMedia_LazyIDOnlyWhenNeeded(t *testing.T) { + // 绝大多数对话没有媒体,不该为它们都生成 ID 塞进 context.json + a, _ := newTestAgentWithMedia(t) + evt := &ContextEvent{Input: "纯文本"} + a.bindEventMedia(evt, nil) + if evt.ID != "" { + t.Fatalf("无媒体时不该生成 ID,得到 %q", evt.ID) + } +} + +func TestMediaSummary_DescriptionIsThePersistentMemory(t *testing.T) { + // 方案 C 的核心:描述文本才是持久语义记忆,blob 只是缓存。 + // blob 被容量 GC 淘汰后,描述仍留在 L0/L2/L3 的文本里可被检索。 + a, ms := newTestAgentWithMedia(t) + + d, _ := ms.Put([]byte("img"), media.Item{MIME: "image/png"}) + if s := a.mediaSummaryForEvent([]string{d}); s == "" { + t.Fatal("未描述项也应产出一行(标注未描述)") + } + + ms.Describe(d, "一张紫蓝红三色带图", "visionllm") + s := a.mediaSummaryForEvent([]string{d}) + if s == "" { + t.Fatal("应产出摘要") + } + if !strings.Contains(s, "紫蓝红三色带图") { + t.Fatalf("摘要应含描述文本: %q", s) + } + if !strings.Contains(s, "image/png") { + t.Fatalf("摘要应含 MIME 标注: %q", s) + } +} + +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")) + 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{ + Timestamp: time.Now().Add(time.Duration(i) * time.Second), + Source: "qq", + Input: "内容", + }) + } + if n := rc.Prune("查询", 5, docStore); n == 0 { + t.Fatal("无媒体存储时归档也应正常") + } +} + +func TestContextEvent_MediaFieldRoundTrip(t *testing.T) { + // context.json 加字段必须向后兼容:存量文件读回来 Media 为空、ID 为空, + // 不影响任何既有行为。 + dir := t.TempDir() + path := filepath.Join(dir, "context.json") + + // 写一份"存量格式"(无 id / media 字段) + legacy := `[{"timestamp":"2026-09-04T10:00:00Z","source":"qq","input":"老数据","response":"回复"}]` + if err := os.WriteFile(path, []byte(legacy), 0644); err != nil { + t.Fatal(err) + } + + emb := memory.NewStaticEmbedder() + rc := NewRelevanceContext(path, emb) + if rc.Len() != 1 { + t.Fatalf("应读回 1 条,实际 %d", rc.Len()) + } + + // 新写入带媒体的事件,再读回 + ms, err := media.New(filepath.Join(dir, "media"), 0) + if err != nil { + t.Fatal(err) + } + defer ms.Close() + a := &Agent{mediaStore: ms, context: rc} + d, _ := ms.Put([]byte("img"), media.Item{MIME: "image/png"}) + evt := ContextEvent{Timestamp: time.Now(), Source: "qq", Input: "新数据"} + a.bindEventMedia(&evt, []string{d}) + rc.Append(evt) + rc.flush() + + rc2 := NewRelevanceContext(path, emb) + if rc2.Len() != 2 { + t.Fatalf("应有 2 条,实际 %d", rc2.Len()) + } +} diff --git a/internal/agent/core/process.go b/internal/agent/core/process.go index 1528b4b..39f9333 100644 --- a/internal/agent/core/process.go +++ b/internal/agent/core/process.go @@ -344,6 +344,11 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri } } if len(blocks) > 0 { + // 先落进 CAS:无论下面走直视还是回退转写,媒体本体都该进记忆。 + // 不存的后果是 ToolResultItem.Output 只剩那句 + // "[已将图片注入后续对话] /tmp/x.png",文件一删线索就断了。 + a.stageMediaDigests(a.captureBlockMedia(blocks, tc.Name)...) + if native, fallbackText := a.prepareToolBlocks(blocks); len(native) > 0 { // 能直视:另起一条 user message 承载媒体,并补一句来源说明, // 否则模型会把它当成用户新发的图而不是工具拉回来的。 diff --git a/internal/agent/core/toolcall.go b/internal/agent/core/toolcall.go index 3c26b8b..1c722b2 100644 --- a/internal/agent/core/toolcall.go +++ b/internal/agent/core/toolcall.go @@ -150,6 +150,14 @@ 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 显式查了图记忆,却仍然不知道有图。 + if mc := a.mediaContextForRelations(result.Relations); mc != "" { + parts = append(parts, "", "关联媒体:", mc) + } return strings.Join(parts, "\n") case "memory_block_merge": @@ -189,7 +197,9 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall) string { if len(triples) == 0 { return "没有有效的三元组" } - ec, rc, err := a.memory.Commit(triples, string(a.id), 0) + // remember 工具是用户/模型显式写入,不涉及归档删除, + // 因此不需要 mediaBound——没有旧引用要释放。 + ec, rc, _, err := a.commitTriplesWithMedia(triples, string(a.id), 0) if err != nil { return fmt.Sprintf("记忆写入失败: %v", err) } @@ -541,10 +551,10 @@ func (a *Agent) executeDocTool(tc agentAPI.ToolCall) string { } doc := &document.Doc{ - Summary: summary, - Content: content, - Tags: tags, - Source: "manual", + Summary: summary, + Content: content, + Tags: tags, + Source: "manual", } if err := a.docStore.Insert(doc); err != nil { return fmt.Sprintf("文档写入失败: %v", err) diff --git a/internal/agent/core/tooldefs.go b/internal/agent/core/tooldefs.go index d657cfc..e52b673 100644 --- a/internal/agent/core/tooldefs.go +++ b/internal/agent/core/tooldefs.go @@ -13,6 +13,24 @@ func (a *Agent) buildMemoryContext(input string, maxTokens int) string { } injected := a.indexer.BuildContext(input) s := a.indexer.FormatContext(injected) + + // 图库召回命中的实体若关联着带媒体的句子,把媒体说明一并注入。 + // + // 不做这一步的后果:媒体描述进了 L3,agent 却拿不出来。图库句子里 + // 写着 [image/png a1b2c3d4e5f6] 这样的短标记,但没有任何东西告诉 + // 模型那份内容是否还在、能否重新查看——描述永存而 blob 可能已被 + // 容量 GC 淘汰,两者状态不同,必须显式告知。 + // + // 注意不能直接用 injected.Relations:BuildContext 刻意把它置为 nil + //(自动注入只给实体索引以省 token,细节留给 memory_recall)。 + // 因此这里用命中的实体名再查一次关系,只为拿到 sentence_id。 + if mc := a.mediaContextForInjectedEntities(injected); mc != "" { + if s != "" { + s += "\n" + } + s += "【关联媒体】\n" + mc + } + if maxTokens > 0 { s = TruncateByTokens(s, maxTokens) } @@ -108,8 +126,8 @@ func (a *Agent) buildToolCatalog() string { } // 仅注入插件/通道能力摘要,避免全量工具定义污染 system prompt。 // 每个插件列:名称 + 能力描述 + 工具数。完整工具定义由 get_plugin_tools 按需拉取。 - byPlugin := map[string]int{} // plugin -> 工具数 - pluginDesc := map[string]string{} // plugin -> 首个工具描述(作能力概览) + byPlugin := map[string]int{} // plugin -> 工具数 + pluginDesc := map[string]string{} // plugin -> 首个工具描述(作能力概览) var order []string for _, t := range defs { fn, ok := t.(map[string]interface{})["function"].(map[string]interface{}) @@ -302,7 +320,7 @@ func (a *Agent) buildToolDefs() []interface{} { "parameters": map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ - "name": map[string]interface{}{"type": "string", "description": "知识名称(用作目录名)"}, + "name": map[string]interface{}{"type": "string", "description": "知识名称(用作目录名)"}, "content": map[string]interface{}{"type": "string", "description": "知识内容,支持 Markdown"}, }, "required": []string{"name", "content"}, diff --git a/internal/config/registry.go b/internal/config/registry.go index 75c7c50..4290112 100644 --- a/internal/config/registry.go +++ b/internal/config/registry.go @@ -544,6 +544,7 @@ func (r *ConfigRegistry) seedDBValues(dataDir string) { set("core.memory.graph", filepath.Join(dataDir, "memory", "graph.db")) set("core.memory.text", filepath.Join(dataDir, "memory", "text")) set("core.memory.documents", filepath.Join(dataDir, "memory", "documents")) + set("core.memory.media.dir", filepath.Join(dataDir, "memory", "media")) set("core.knowledge.path", filepath.Join(dataDir, "knowledge")) set("core.log.path", filepath.Join(dataDir, "log")) @@ -648,6 +649,12 @@ 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.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.knowledge.path", Default: filepath.Join(dataDir, "knowledge"), Type: "string", DisplayName: "知识库路径", Description: "知识库存储目录", Category: "paths"}) reg(ConfigDef{Key: "core.log.path", Default: filepath.Join(dataDir, "log"), Type: "string", DisplayName: "日志目录", Description: "日志文件输出目录", Category: "paths"}) diff --git a/internal/memory/graph.go b/internal/memory/graph.go index d13b141..3167de2 100644 --- a/internal/memory/graph.go +++ b/internal/memory/graph.go @@ -37,13 +37,13 @@ type Relation struct { } type Triple struct { - Subject string `json:"subject"` - Relation string `json:"relation"` - Object string `json:"object"` - Confidence float64 `json:"confidence,omitempty"` - SubjectType string `json:"subject_type,omitempty"` - ObjectType string `json:"object_type,omitempty"` - SentenceText string `json:"sentence_text,omitempty"` // 原始句子文本,Commit时写入sentences表 + Subject string `json:"subject"` + Relation string `json:"relation"` + Object string `json:"object"` + Confidence float64 `json:"confidence,omitempty"` + SubjectType string `json:"subject_type,omitempty"` + ObjectType string `json:"object_type,omitempty"` + SentenceText string `json:"sentence_text,omitempty"` // 原始句子文本,Commit时写入sentences表 } type GraphDB struct { @@ -192,13 +192,37 @@ func (g *GraphDB) migrateRelationUnique(tx *sql.Tx) error { return nil } +// Commit 把三元组写入图库,返回新建的实体数与关系数。 func (g *GraphDB) Commit(triples []Triple, sessionID string, turnID int) (int, int, error) { + _, ec, rc, err := g.commit(triples, sessionID, turnID, false) + return ec, rc, err +} + +// CommitWithMedia 与 Commit 相同,但额外返回每条句子文本对应的 sentences.id。 +// +// 为何单独开一个方法而不改 Commit 的签名:Commit 有十个非测试调用点 +// 加二十多个测试调用点,为了一个多数调用方都不需要的返回值去改全部签名 +// 不划算。这里让 Commit 内部转调,两者共享同一份落库逻辑。 +// +// 返回的 map 只包含本次真正写入了 sentences 表的句子。调用方据此把媒体 +// 引用挂到 graph_sentence owner 上——句子是媒体描述在图库里的落点, +// 关系行本身不持有媒体。 +func (g *GraphDB) CommitWithMedia(triples []Triple, sessionID string, turnID int) (map[string]int64, int, int, error) { + return g.commit(triples, sessionID, turnID, true) +} + +func (g *GraphDB) commit(triples []Triple, sessionID string, turnID int, trackSentences bool) (map[string]int64, int, int, error) { g.mu.Lock() defer g.mu.Unlock() + var sentenceIDs map[string]int64 + if trackSentences { + sentenceIDs = make(map[string]int64) + } + tx, err := g.db.Begin() if err != nil { - return 0, 0, err + return nil, 0, 0, err } defer tx.Rollback() @@ -229,24 +253,24 @@ func (g *GraphDB) Commit(triples []Triple, sessionID string, turnID int) (int, i ec, err := g.upsertEntity(tx, t.Subject, subjType) if err != nil { - return 0, 0, err + return nil, 0, 0, err } entitiesCreated += ec ec, err = g.upsertEntity(tx, t.Object, objType) if err != nil { - return 0, 0, err + return nil, 0, 0, err } entitiesCreated += ec var sourceID, targetID int64 err = tx.QueryRow("SELECT id FROM entities WHERE name = ?", t.Subject).Scan(&sourceID) if err != nil { - return 0, 0, fmt.Errorf("subject %q: %w", t.Subject, err) + return nil, 0, 0, fmt.Errorf("subject %q: %w", t.Subject, err) } err = tx.QueryRow("SELECT id FROM entities WHERE name = ?", t.Object).Scan(&targetID) if err != nil { - return 0, 0, fmt.Errorf("object %q: %w", t.Object, err) + return nil, 0, 0, fmt.Errorf("object %q: %w", t.Object, err) } // 写入/查找句子 @@ -255,11 +279,13 @@ func (g *GraphDB) Commit(triples []Triple, sessionID string, turnID int) (int, i _, err = tx.Exec( `INSERT OR IGNORE INTO sentences (text) VALUES (?)`, t.SentenceText) if err != nil { - return 0, 0, fmt.Errorf("insert sentence: %w", err) + return nil, 0, 0, fmt.Errorf("insert sentence: %w", err) } err = tx.QueryRow("SELECT id FROM sentences WHERE text = ?", t.SentenceText).Scan(&sentenceID) if err != nil { sentenceID = 0 + } else if sentenceIDs != nil { + sentenceIDs[t.SentenceText] = sentenceID } } @@ -275,11 +301,11 @@ func (g *GraphDB) Commit(triples []Triple, sessionID string, turnID int) (int, i sourceID, targetID, t.Relation, confidence, sessionID, turnID, dateBucket, sentenceID, ) if err != nil { - return 0, 0, err + return nil, 0, 0, err } relationsCreated++ } else if err != nil { - return 0, 0, err + return nil, 0, 0, err } else { // 同一(会话内)三元组已存在:仅刷新置信度与时间戳,不重复计数 _, err = tx.Exec( @@ -288,16 +314,16 @@ func (g *GraphDB) Commit(triples []Triple, sessionID string, turnID int) (int, i confidence, sourceID, targetID, t.Relation, sessionID, ) if err != nil { - return 0, 0, err + return nil, 0, 0, err } } } if err := tx.Commit(); err != nil { - return 0, 0, err + return nil, 0, 0, err } - return entitiesCreated, relationsCreated, nil + return sentenceIDs, entitiesCreated, relationsCreated, nil } func validEntityName(name string) bool { diff --git a/internal/memory/media/base64.go b/internal/memory/media/base64.go new file mode 100644 index 0000000..247d87b --- /dev/null +++ b/internal/memory/media/base64.go @@ -0,0 +1,14 @@ +package media + +import "encoding/base64" + +// base64 编解码单独抽出来,让 media.go 的 import 块只留业务依赖。 +// 用 StdEncoding:data URL 规范用的是标准表(含 + / =),不是 URL-safe 表。 + +func base64Decode(s string) ([]byte, error) { + return base64.StdEncoding.DecodeString(s) +} + +func base64Encode(b []byte) string { + return base64.StdEncoding.EncodeToString(b) +} diff --git a/internal/memory/media/media.go b/internal/memory/media/media.go new file mode 100644 index 0000000..dc7338d --- /dev/null +++ b/internal/memory/media/media.go @@ -0,0 +1,780 @@ +// Package media 是记忆系统的内容寻址媒体存储(CAS)。 +// +// 为何需要它:此前四层记忆全是纯文本载体——L0 `ContextEvent`、L1 `text.Event`、 +// L2 `document.Doc`、L3 图库的 `sentences.text TEXT UNIQUE`——没有任何一层能 +// 存二进制。multimodal 插件注入的图片在本轮对话内可见(走 message 数组,不经 +// 记忆),下一轮起就只剩 `ToolResultItem.Output` 里那句 +// "[已将图片注入后续对话] /tmp/x.png",即一条路径字符串。那个文件被删或被 +// 覆盖之后连线索都断了。 +// +// 为何是内容寻址而不是存路径: +// - 路径会失效。/tmp 下的探针图、下载缓存、其他进程的临时产物,记忆里留个 +// 路径等于留个悬空指针。 +// - 同一张图往往被反复注入(用户连问几轮同一张截图、see_video 相邻帧高度 +// 相似)。按 sha256 寻址天然去重,引用计数记住被引了几次。 +// - 内容即身份,跟 L3 图库 `sentences.text UNIQUE` 的思路一致:文本节点用 +// 文本本身做身份,媒体节点用内容摘要做身份。 +package media + +import ( + "crypto/sha256" + "database/sql" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "sync" + "time" + + _ "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 + +// Kind 是媒体大类。刻意只分三类而不细分具体格式: +// 记忆检索关心的是“这是张图还是段音频”,具体编码交给 MIME 字段。 +type Kind string + +const ( + KindImage Kind = "image" + KindAudio Kind = "audio" + KindVideo Kind = "video" + KindOther Kind = "other" +) + +// Item 是一条媒体记录。 +// +// Digest 既是主键也是磁盘文件名,所以没有单独的 Path 字段——路径可由 +// Store 根据 Digest 推导,不落库(落了就又是个会失效的引用)。 +type Item struct { + // Digest 是内容 sha256 的十六进制串(64 字符),媒体的唯一身份。 + Digest string `json:"digest"` + // Kind 是大类,供检索时按模态筛选。 + Kind Kind `json:"kind"` + // MIME 是原始 MIME 类型,如 image/png。 + MIME string `json:"mime"` + // Size 是字节数。 + Size int64 `json:"size"` + // Width/Height 是像素尺寸,未知或不适用时为 0。 + Width int `json:"width,omitempty"` + Height int `json:"height,omitempty"` + // OriginPath 是首次入库时的来源路径,仅供人类溯源与调试。 + // **不可用于读取内容**——它随时可能失效,这正是本包存在的理由。 + OriginPath string `json:"origin_path,omitempty"` + // Tool 是注入这条媒体的工具名(如 multimodal_see_picture)。 + Tool string `json:"tool,omitempty"` + // Description 是视觉/音频模型生成的文字描述,供 L2/L3 检索。 + // 空表示未描述(未开启描述、模型不可用或描述失败)。 + 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"` +} + +// Store 管理媒体的元数据(SQLite)与内容(磁盘 CAS 目录)。 +// +// 元数据与内容分离而不是把 blob 塞进 SQLite:单张图动辄几 MB,塞进库会让 +// 每次 VACUUM/备份都拖着几百 MB 走,也让 WAL 迅速膨胀。CAS 目录用两级 +// 前缀分桶(ab/cdef...)避免单目录几万文件。 +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) { + if err := os.MkdirAll(filepath.Join(dir, "blobs"), 0755); err != nil { + return nil, fmt.Errorf("media: create blob dir: %w", err) + } + dbPath := filepath.Join(dir, "media.db") + db, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL&_busy_timeout=5000") + if err != nil { + return nil, fmt.Errorf("media: open db: %w", err) + } + s := &Store{db: db, blobDir: filepath.Join(dir, "blobs"), maxBytes: maxBytes} + if err := s.initSchema(); err != nil { + db.Close() + return nil, err + } + return s, nil +} + +func (s *Store) initSchema() error { + stmts := []string{ + // digest 作主键:内容即身份,重复 Put 同一内容只递增 ref_count。 + `CREATE TABLE IF NOT EXISTS media ( + digest TEXT PRIMARY KEY, + kind TEXT NOT NULL, + mime TEXT NOT NULL, + size INTEGER NOT NULL, + width INTEGER DEFAULT 0, + height INTEGER DEFAULT 0, + origin_path TEXT, + 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 { + return fmt.Errorf("media: schema %q: %w", truncate(q, 60), err) + } + } + return nil +} + +// blobPath 按两级前缀分桶推导内容路径。 +func (s *Store) blobPath(digest string) string { + if len(digest) < 4 { + return filepath.Join(s.blobDir, digest) + } + return filepath.Join(s.blobDir, digest[:2], digest[2:]) +} + +// Put 落盘并登记一段媒体内容,返回其 digest。 +// +// 幂等:同一内容重复 Put 不重复落盘,只更新 last_seen 与可选的新元数据 +// (描述、尺寸等——后来者可能带着前一次没有的信息)。 +func (s *Store) Put(data []byte, meta Item) (string, error) { + if len(data) == 0 { + return "", fmt.Errorf("media: empty content") + } + sum := sha256.Sum256(data) + digest := hex.EncodeToString(sum[:]) + + s.mu.Lock() + defer s.mu.Unlock() + + path := s.blobPath(digest) + if _, err := os.Stat(path); os.IsNotExist(err) { + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return "", fmt.Errorf("media: mkdir: %w", err) + } + // 先写临时文件再 rename:中途崩溃不会留下半个 blob 被后续 + // 当成完整内容读走(digest 校验能发现,但那时已经把坏数据喂给模型了)。 + tmp := path + ".tmp" + if err := os.WriteFile(tmp, data, 0644); err != nil { + return "", fmt.Errorf("media: write blob: %w", err) + } + if err := os.Rename(tmp, path); err != nil { + os.Remove(tmp) + return "", fmt.Errorf("media: commit blob: %w", err) + } + } + + now := time.Now() + if meta.Kind == "" { + meta.Kind = KindFromMIME(meta.MIME) + } + _, 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, ?, ?) + ON CONFLICT(digest) DO UPDATE SET + last_seen = excluded.last_seen, + -- 只在原值为空时补写:先到的描述可能来自更强的模型, + -- 后到的空值不该把它冲掉。 + description = CASE WHEN COALESCE(media.description,'') = '' THEN excluded.description ELSE media.description END, + described_by = CASE WHEN COALESCE(media.described_by,'') = '' THEN excluded.described_by ELSE media.described_by END, + width = CASE WHEN media.width = 0 THEN excluded.width ELSE media.width END, + height = CASE WHEN media.height = 0 THEN excluded.height ELSE media.height END, + tool = CASE WHEN COALESCE(media.tool,'') = '' THEN excluded.tool ELSE media.tool END + `, digest, string(meta.Kind), meta.MIME, int64(len(data)), meta.Width, meta.Height, + meta.OriginPath, meta.Tool, meta.Description, meta.DescribedBy, now, now) + if err != nil { + return "", fmt.Errorf("media: upsert meta: %w", err) + } + return digest, nil +} + +// Get 读取内容并校验 digest。 +// +// 校验不是多余的:CAS 的全部保证建立在"文件名 == 内容摘要"上,磁盘位翻转 +// 或外部误改会让这条保证失效,而把损坏的图喂给模型只会得到无从追溯的幻觉。 +func (s *Store) Get(digest string) ([]byte, error) { + s.mu.RLock() + path := s.blobPath(digest) + s.mu.RUnlock() + + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("media: read %s: %w", shortDigest(digest), err) + } + sum := sha256.Sum256(data) + if got := hex.EncodeToString(sum[:]); got != digest { + return nil, fmt.Errorf("media: digest mismatch for %s (content corrupted)", shortDigest(digest)) + } + return data, nil +} + +// Stat 返回元数据,不读内容。 +func (s *Store) Stat(digest string) (*Item, error) { + s.mu.RLock() + 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 + FROM media WHERE digest = ?`, digest)) +} + +// Describe 写入(或覆盖)文字描述。 +// +// 与 Put 的"只在空时补写"不同:Describe 是显式操作,调用方明确想要这份 +// 描述生效(例如换了更强的视觉模型重新描述)。 +func (s *Store) Describe(digest, description, describedBy string) error { + s.mu.Lock() + defer s.mu.Unlock() + res, err := s.db.Exec(`UPDATE media SET description = ?, described_by = ? WHERE digest = ?`, + description, describedBy, digest) + if err != nil { + return fmt.Errorf("media: describe: %w", err) + } + if n, _ := res.RowsAffected(); n == 0 { + return fmt.Errorf("media: describe: unknown digest %s", shortDigest(digest)) + } + 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 文档层的既有索引 +// (描述文字随记忆条目一起进 Doc.Content,复用那套 TF-IDF/embedding), +// 本方法只是"按关键词直接翻媒体库"的补充入口。 +func (s *Store) Search(query string, kind Kind, limit int) ([]*Item, error) { + if limit <= 0 { + limit = 20 + } + s.mu.RLock() + defer s.mu.RUnlock() + + q := `SELECT digest, kind, mime, size, width, height, origin_path, tool, + description, described_by, ref_count, first_seen, last_seen + FROM media WHERE COALESCE(description,'') != ''` + args := []interface{}{} + if strings.TrimSpace(query) != "" { + q += ` AND description LIKE ?` + args = append(args, "%"+query+"%") + } + if kind != "" { + q += ` AND kind = ?` + args = append(args, string(kind)) + } + q += ` ORDER BY last_seen DESC LIMIT ?` + args = append(args, limit) + + rows, err := s.db.Query(q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var out []*Item + for rows.Next() { + it, err := s.scanRows(rows) + if err != nil { + continue + } + out = append(out, it) + } + return out, rows.Err() +} + +// Pending 返回尚无描述的媒体,供后台描述任务消费。 +// Pending 返回尚无描述的媒体,供后台描述任务消费。 +// +// 不只看 description 为空,还要求 described_by 也为空。 +// 因为“已尝试但无法描述”的项(如 kind=other 的二进制、blob 已丢失) +// 会被标记为 described_by=unsupported/content-missing 而 description 仍为空—— +// 若只看 description,这些项每轮都会被取出来重试,永远卡在队列头部, +// 真正需要描述的新项永远轮不到(LIMIT 只取前 N 条)。 +func (s *Store) Pending(limit int) ([]*Item, error) { + if limit <= 0 { + limit = 10 + } + s.mu.RLock() + 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 + FROM media + WHERE COALESCE(description,'') = '' AND COALESCE(described_by,'') = '' + ORDER BY last_seen DESC LIMIT ?`, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var out []*Item + for rows.Next() { + it, err := s.scanRows(rows) + if err != nil { + continue + } + out = append(out, it) + } + return out, rows.Err() +} + +// GC 清理无人引用的内容。 +// +// 两段策略: +// 1. ref_count=0 且 last_seen 早于 minAge 的一律清理。刚 Put 还没来得及 +// AddRef 的项 refcount 也是 0,minAge 保护它们不被立刻清掉。 +// 2. 清完仍超 maxBytes 时,继续按 last_seen 从旧到新淘汰 ref_count=0 的项。 +// +// 有引用的项永不删除——那会让记忆里的 digest 变成悬空指针,正是本包要避免的。 +func (s *Store) GC(minAge time.Duration) (removed int, freed int64, err error) { + 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 + } + 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 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 +} + +// 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 展示。 +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 + 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`) + if err == nil { + defer rows.Close() + for rows.Next() { + var k string + var n int + if rows.Scan(&k, &n) == nil { + byKind[k] = n + } + } + } + out["by_kind"] = byKind + return out +} + +func (s *Store) Close() error { + s.mu.Lock() + defer s.mu.Unlock() + return s.db.Close() +} + +// ---- 扫描辅助 ---- + +type rowScanner interface { + Scan(dest ...interface{}) error +} + +func (s *Store) scanOne(r rowScanner) (*Item, error) { + it, err := scanItem(r) + if err == sql.ErrNoRows { + return nil, fmt.Errorf("media: unknown digest") + } + return it, err +} + +func (s *Store) scanRows(r rowScanner) (*Item, error) { return scanItem(r) } + +func scanItem(r rowScanner) (*Item, error) { + var it Item + var kind string + var origin, tool, desc, by 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); err != nil { + return nil, err + } + it.Kind = Kind(kind) + it.OriginPath = origin.String + it.Tool = tool.String + it.Description = desc.String + it.DescribedBy = by.String + return &it, nil +} + +// ---- 工具函数 ---- + +// KindFromMIME 把 MIME 归到大类。 +func KindFromMIME(mime string) Kind { + m := strings.ToLower(strings.TrimSpace(mime)) + switch { + case strings.HasPrefix(m, "image/"): + return KindImage + case strings.HasPrefix(m, "audio/"): + return KindAudio + case strings.HasPrefix(m, "video/"): + return KindVideo + default: + return KindOther + } +} + +// ParseDataURL 从 data:;base64, 提取 MIME 与原始字节。 +// +// 与 agent/api 里的 parseAudioDataURL 分开实现:那个只认音频且只回 base64 +// 串(它要把串塞回 OpenAI 的 input_audio 字段),这里要的是解码后的字节。 +func ParseDataURL(url string) (mime string, data []byte, ok bool) { + const prefix = "data:" + if !strings.HasPrefix(url, prefix) { + return "", nil, false + } + rest := url[len(prefix):] + comma := strings.IndexByte(rest, ',') + if comma < 0 { + return "", nil, false + } + head := rest[:comma] + payload := rest[comma+1:] + if !strings.HasSuffix(strings.ToLower(head), ";base64") { + return "", nil, false + } + mime = head[:len(head)-len(";base64")] + if mime == "" || payload == "" { + return "", nil, false + } + decoded, err := base64Decode(payload) + if err != nil { + return "", nil, false + } + return mime, decoded, true +} + +// DataURL 把内容编回 data URL,供重新注入模型对话。 +func DataURL(mime string, data []byte) string { + return "data:" + mime + ";base64," + base64Encode(data) +} + +// CopyFrom 从 reader 读全部内容后 Put,用于大文件不便一次性构造 []byte 的场合。 +func (s *Store) CopyFrom(r io.Reader, meta Item) (string, error) { + data, err := io.ReadAll(r) + if err != nil { + return "", fmt.Errorf("media: read source: %w", err) + } + return s.Put(data, meta) +} + +// MarshalItems 序列化条目列表,供工具返回给模型。 +func MarshalItems(items []*Item) string { + b, err := json.Marshal(items) + if err != nil { + return "[]" + } + return string(b) +} + +func shortDigest(d string) string { + if len(d) > 12 { + return d[:12] + } + return d +} + +func truncate(s string, n int) string { + s = strings.Join(strings.Fields(s), " ") + if len(s) <= n { + return s + } + return s[:n] + "..." +} + +// ResolvePrefix 把 digest 前缀补全为完整 digest。 +// +// 日志、事件摘要与图库句子里出现的都是 shortDigest(前 12 位), +// 因为完整的 64 位 sha256 会把一行文字撑爆、也无助于人眼辨认。 +// 反查时需要这个补全,否则那些短标记只能看不能用。 +// +// 前缀歧义视为错误而非"取第一个":挂错引用会让 GC 删掉仍被引用的内容, +// 宁可这次绑定失败。12 位十六进制的碰撞概率极低,真撞上说明该用更长前缀。 +func (s *Store) ResolvePrefix(prefix string) (string, error) { + prefix = strings.ToLower(strings.TrimSpace(prefix)) + if len(prefix) < 8 { + return "", fmt.Errorf("digest 前缀过短(至少 8 位): %q", prefix) + } + if len(prefix) == digestHexLen { + // 已是完整 digest:仍要确认存在,否则调用方会挂一条孤儿引用 + if _, err := s.Stat(prefix); err != nil { + return "", err + } + return prefix, nil + } + + s.mu.RLock() + defer s.mu.RUnlock() + rows, err := s.db.Query( + `SELECT digest FROM media WHERE digest LIKE ? || '%' LIMIT 2`, prefix) + if err != nil { + return "", err + } + defer rows.Close() + + var found []string + for rows.Next() { + var d string + if err := rows.Scan(&d); err != nil { + return "", err + } + found = append(found, d) + } + if err := rows.Err(); err != nil { + return "", err + } + + switch len(found) { + case 0: + return "", fmt.Errorf("digest 前缀 %q 未匹配到媒体", prefix) + case 1: + return found[0], nil + default: + return "", fmt.Errorf("digest 前缀 %q 有歧义(至少匹配 %s 和 %s)", + prefix, found[0][:16], found[1][:16]) + } +} diff --git a/internal/memory/media/media_test.go b/internal/memory/media/media_test.go new file mode 100644 index 0000000..0f45cd6 --- /dev/null +++ b/internal/memory/media/media_test.go @@ -0,0 +1,513 @@ +package media + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func newTestStore(t *testing.T, maxBytes int64) *Store { + t.Helper() + s, err := New(t.TempDir(), maxBytes) + if err != nil { + t.Fatalf("New: %v", err) + } + t.Cleanup(func() { s.Close() }) + return s +} + +func TestPut_ContentAddressedDedup(t *testing.T) { + s := newTestStore(t, 0) + data := []byte("fake-png-bytes") + + d1, err := s.Put(data, Item{MIME: "image/png", OriginPath: "/tmp/a.png"}) + if err != nil { + t.Fatal(err) + } + d2, err := s.Put(data, Item{MIME: "image/png", OriginPath: "/tmp/b.png"}) + if err != nil { + t.Fatal(err) + } + if d1 != d2 { + t.Fatalf("同一内容应得同一 digest:%s vs %s", d1, d2) + } + + // 去重的意义:同一张图反复注入(连问几轮同一截图、see_video 相邻帧) + // 只占一份磁盘。 + st := s.Stats() + if st["count"].(int) != 1 { + t.Fatalf("同一内容应只有 1 条记录,实际 %v", st["count"]) + } +} + +func TestPut_KindInferredFromMIME(t *testing.T) { + s := newTestStore(t, 0) + cases := map[string]Kind{ + "image/png": KindImage, + "image/jpeg": KindImage, + "audio/wav": KindAudio, + "video/mp4": KindVideo, + "text/plain": KindOther, + } + for mime, want := range cases { + d, err := s.Put([]byte("payload-"+mime), Item{MIME: mime}) + if err != nil { + t.Fatal(err) + } + it, err := s.Stat(d) + if err != nil { + t.Fatal(err) + } + if it.Kind != want { + t.Fatalf("%s 应归为 %s,实际 %s", mime, want, it.Kind) + } + } +} + +func TestGet_DetectsCorruption(t *testing.T) { + // CAS 的全部保证建立在「文件名 == 内容摘要」上。外部误改或位翻转必须 + // 被发现——把损坏的图喂给模型只会得到无从追溯的幻觉。 + s := newTestStore(t, 0) + d, err := s.Put([]byte("original-content"), Item{MIME: "image/png"}) + if err != nil { + t.Fatal(err) + } + if _, err := s.Get(d); err != nil { + t.Fatalf("正常读取应成功: %v", err) + } + + if err := os.WriteFile(s.blobPath(d), []byte("tampered!"), 0644); err != nil { + t.Fatal(err) + } + _, err = s.Get(d) + if err == nil { + t.Fatal("内容被改后应报 digest 不匹配,却读成功了") + } + if !strings.Contains(err.Error(), "digest mismatch") { + t.Fatalf("错误应指明 digest 不匹配,得到: %v", err) + } +} + +func TestPut_NoPartialBlobOnDisk(t *testing.T) { + // 先写 .tmp 再 rename:确认落地后目录里不留临时文件。 + s := newTestStore(t, 0) + d, err := s.Put([]byte("some-bytes"), Item{MIME: "image/png"}) + if err != nil { + t.Fatal(err) + } + dir := filepath.Dir(s.blobPath(d)) + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + for _, e := range entries { + if strings.HasSuffix(e.Name(), ".tmp") { + t.Fatalf("落地后不该留临时文件: %s", e.Name()) + } + } +} + +func TestRefCount_AddIsIdempotent(t *testing.T) { + s := newTestStore(t, 0) + d, _ := s.Put([]byte("img"), 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 { + t.Fatal(err) + } + it, _ = s.Stat(d) + if it.RefCount != 2 { + t.Fatalf("不同 owner 应各计一次,实际 %d", it.RefCount) + } +} + +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(d); err != nil { + 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 + } + + // 保护最后一个,确认容量 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) + } +} + +func TestDescribe_OverwritesExplicitly(t *testing.T) { + s := newTestStore(t, 0) + d, _ := s.Put([]byte("img"), Item{MIME: "image/png"}) + + if err := s.Describe(d, "一只橘猫", "vis-a"); err != nil { + t.Fatal(err) + } + it, _ := s.Stat(d) + if it.Description != "一只橘猫" || it.DescribedBy != "vis-a" { + t.Fatalf("描述未写入: %+v", it) + } + + // Describe 是显式操作,允许覆盖(换更强模型重描述) + if err := s.Describe(d, "一只橘色虎斑猫坐在窗台", "vis-b"); err != nil { + t.Fatal(err) + } + it, _ = s.Stat(d) + if !strings.Contains(it.Description, "虎斑") || it.DescribedBy != "vis-b" { + t.Fatalf("Describe 应覆盖旧描述: %+v", it) + } +} + +func TestDescribe_UnknownDigestErrors(t *testing.T) { + s := newTestStore(t, 0) + err := s.Describe("deadbeef", "x", "y") + if err == nil { + t.Fatal("未知 digest 应报错而非静默成功") + } +} + +func TestPut_DoesNotClobberExistingDescription(t *testing.T) { + // 先到的描述可能来自更强的模型;后到的空值不该把它冲掉。 + s := newTestStore(t, 0) + data := []byte("img") + d, _ := s.Put(data, Item{MIME: "image/png", Description: "详细描述", DescribedBy: "strong-model"}) + + // 第二次 Put 同内容但不带描述 + if _, err := s.Put(data, Item{MIME: "image/png"}); err != nil { + t.Fatal(err) + } + it, _ := s.Stat(d) + if it.Description != "详细描述" || it.DescribedBy != "strong-model" { + t.Fatalf("重复 Put 的空描述不该冲掉已有描述: %+v", it) + } +} + +func TestPut_BackfillsMissingDimensions(t *testing.T) { + // 后来者可能带着前一次没有的信息(尺寸、工具名) + s := newTestStore(t, 0) + data := []byte("img") + d, _ := s.Put(data, Item{MIME: "image/png"}) + + if _, err := s.Put(data, Item{MIME: "image/png", Width: 640, Height: 480, Tool: "multimodal_see_picture"}); err != nil { + t.Fatal(err) + } + it, _ := s.Stat(d) + if it.Width != 640 || it.Height != 480 { + t.Fatalf("尺寸应被补写: %dx%d", it.Width, it.Height) + } + if it.Tool != "multimodal_see_picture" { + t.Fatalf("工具名应被补写: %q", it.Tool) + } +} + +func TestSearch_FiltersByDescriptionAndKind(t *testing.T) { + s := newTestStore(t, 0) + di, _ := s.Put([]byte("chart-img"), Item{MIME: "image/png"}) + da, _ := s.Put([]byte("speech-aud"), Item{MIME: "audio/wav"}) + dn, _ := s.Put([]byte("no-desc"), Item{MIME: "image/png"}) + s.Describe(di, "一张蓝色的柱状图表", "vis") + s.Describe(da, "一段关于图表的讲解录音", "aud") + + all, err := s.Search("图表", "", 10) + if err != nil { + t.Fatal(err) + } + if len(all) != 2 { + t.Fatalf("两条描述都含「图表」,应返回 2,实际 %d", len(all)) + } + + imgs, _ := s.Search("图表", KindImage, 10) + if len(imgs) != 1 || imgs[0].Digest != di { + t.Fatalf("按 image 过滤应只剩图片,实际 %d 条", len(imgs)) + } + + // 无描述的项不该出现在语义检索结果里 + for _, it := range all { + if it.Digest == dn { + t.Fatal("无描述的项不该被 Search 返回") + } + } +} + +func TestPending_ReturnsUndescribed(t *testing.T) { + s := newTestStore(t, 0) + described, _ := s.Put([]byte("has-desc"), Item{MIME: "image/png"}) + undescribed, _ := s.Put([]byte("needs-desc"), Item{MIME: "image/png"}) + s.Describe(described, "已有描述", "vis") + + pending, err := s.Pending(10) + if err != nil { + t.Fatal(err) + } + if len(pending) != 1 || pending[0].Digest != undescribed { + t.Fatalf("应只返回未描述项,实际 %d 条", len(pending)) + } +} + +func TestParseDataURL(t *testing.T) { + // 与 agent/api 的 parseAudioDataURL 不同:这里要解码后的字节。 + raw := []byte{0x89, 'P', 'N', 'G'} + url := DataURL("image/png", raw) + + mime, data, ok := ParseDataURL(url) + if !ok { + t.Fatal("应解析成功") + } + if mime != "image/png" { + t.Fatalf("MIME 应为 image/png,得到 %q", mime) + } + if string(data) != string(raw) { + t.Fatalf("字节应还原,得到 %v", data) + } + + for _, bad := range []string{ + "http://example.com/x.png", // 不是 data URL + "data:image/png,notbase64", // 缺 ;base64 + "data:;base64,", // 空 MIME 与空载荷 + "data:image/png;base64,!!!", // 非法 base64 + } { + if _, _, ok := ParseDataURL(bad); ok { + t.Fatalf("%q 应解析失败", bad) + } + } +} + +func TestStats_CountsByKindAndDescription(t *testing.T) { + s := newTestStore(t, 4096) + d1, _ := s.Put([]byte("i1"), Item{MIME: "image/png"}) + 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 { + t.Fatalf("count 应为 3,实际 %v", st["count"]) + } + 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) + } +} + +func TestPut_RejectsEmpty(t *testing.T) { + s := newTestStore(t, 0) + if _, err := s.Put(nil, Item{MIME: "image/png"}); err == nil { + t.Fatal("空内容应报错") + } +} + +func TestReopen_PersistsAcrossRestart(t *testing.T) { + // 记忆的意义就在于跨重启还在。 + dir := t.TempDir() + s1, err := New(dir, 0) + 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) + if err != nil { + t.Fatal(err) + } + defer s2.Close() + + it, err := s2.Stat(d) + if err != nil { + t.Fatalf("重开后应能查到: %v", err) + } + if it.Description != "跨重启的描述" || it.RefCount != 1 { + t.Fatalf("元数据应持久化: %+v", it) + } + data, err := s2.Get(d) + if err != nil || string(data) != "persistent-img" { + t.Fatalf("内容应持久化: %v / %q", err, data) + } +} + +func TestPending_ExcludesAttemptedButUndescribable(t *testing.T) { + // 「已尝试但无法描述」的项必须退出待描述队列。 + // + // 这些项被标记为 described_by=unsupported/content-missing 而 description + // 仍为空。若 Pending 只看 description,它们每轮都会被取出来重试、 + // 永久占着 LIMIT 的名额,真正需要描述的新项永远轮不到。 + s := newTestStore(t, 0) + + fresh, _ := s.Put([]byte("needs-describe"), Item{MIME: "image/png"}) + unsupported, _ := s.Put([]byte("cannot-describe"), Item{MIME: "application/octet-stream"}) + described, _ := s.Put([]byte("已描述"), Item{MIME: "image/png"}) + + // 标记「尝试过但不支持」:description 空,described_by 非空 + if err := s.Describe(unsupported, "", "unsupported"); err != nil { + t.Fatal(err) + } + if err := s.Describe(described, "一张图", "visionllm"); err != nil { + t.Fatal(err) + } + + pending, err := s.Pending(10) + if err != nil { + t.Fatal(err) + } + if len(pending) != 1 { + var names []string + for _, p := range pending { + names = append(names, shortDigest(p.Digest)) + } + t.Fatalf("应只剩 1 条待描述,实际 %d 条: %v", len(pending), names) + } + if pending[0].Digest != fresh { + t.Fatalf("待描述的应是未处理项,实际 %s", shortDigest(pending[0].Digest)) + } +} diff --git a/internal/memory/media/smoke_test.go b/internal/memory/media/smoke_test.go new file mode 100644 index 0000000..a583281 --- /dev/null +++ b/internal/memory/media/smoke_test.go @@ -0,0 +1,316 @@ +package media + +import ( + "bytes" + "compress/zlib" + "encoding/binary" + "fmt" + "hash/crc32" + "testing" +) + +// 冒烟测试:走真实数据路径的端到端场景,而非孤立的 API 单测。 +// +// 之前这套场景是 internal/memory/media/smoke/ 下一个带 //go:build smoke 的 +// 独立 main,得记着加 -tags smoke 才跑得到——那种早晚会被忘掉。搬成普通 +// 测试后它随 go test ./... 一起跑,冒烟的意义(每次改动都过一遍真实链路) +// 才真正成立。 + +// 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}, + } + var raw bytes.Buffer + for y := 0; y < h; y++ { + raw.WriteByte(0) // 每行的滤波器字节 + c := palette[(variant+y*3/h)%len(palette)] + for x := 0; x < w; x++ { + raw.Write(c[:]) + } + } + // 把 variant 写进首行头几个像素,确保不同 variant 字节必然不同。 + b := raw.Bytes() + if len(b) > 8 { + b[1] = byte(variant) + b[2] = byte(variant >> 8) + } + + var z bytes.Buffer + zw := zlib.NewWriter(&z) + zw.Write(b) + zw.Close() + + chunk := func(typ string, data []byte) []byte { + var out bytes.Buffer + binary.Write(&out, binary.BigEndian, uint32(len(data))) + out.WriteString(typ) + out.Write(data) + binary.Write(&out, binary.BigEndian, crc32.ChecksumIEEE(append([]byte(typ), data...))) + return out.Bytes() + } + var ihdr bytes.Buffer + binary.Write(&ihdr, binary.BigEndian, uint32(w)) + binary.Write(&ihdr, binary.BigEndian, uint32(h)) + ihdr.Write([]byte{8, 2, 0, 0, 0}) // 8bit 深度、truecolor + + var out bytes.Buffer + out.Write([]byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}) + out.Write(chunk("IHDR", ihdr.Bytes())) + out.Write(chunk("IDAT", z.Bytes())) + out.Write(chunk("IEND", nil)) + return out.Bytes() +} + +func TestSmoke_SamePictureAcrossTurns(t *testing.T) { + // 场景:用户连问几轮同一张截图。multimodal 每轮都会重新注入, + // 磁盘上应该只有一份,但每轮的 context 事件各持一个引用。 + s := newTestStore(t, 50*1024*1024) + png := makePNG(400, 400, 0) + + var d0 string + for turn := 1; turn <= 5; turn++ { + // 走 data URL:这是 SetToolBlocks 实际给出的形态 + url := DataURL("image/png", png) + mime, data, ok := ParseDataURL(url) + if !ok { + t.Fatalf("第 %d 轮 data URL 解析失败", turn) + } + d, err := s.Put(data, Item{ + MIME: mime, Width: 400, Height: 400, + OriginPath: fmt.Sprintf("/tmp/probe_%d.png", turn), + Tool: "multimodal_see_picture", + }) + if err != nil { + t.Fatalf("第 %d 轮 Put: %v", turn, err) + } + if d0 == "" { + d0 = d + } 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() + if st["count"].(int) != 1 { + t.Fatalf("5 轮同图应只存 1 份,实际 %v 条", st["count"]) + } + 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) + } + t.Logf("同图 5 轮:条目=1 字节=%d refcount=%d", len(png), it.RefCount) + checkRefIntegrity(t, s) +} + +func TestSmoke_VideoFramesDistinct(t *testing.T) { + // 场景:see_video 抽 6 帧,帧间内容不同,应各存一份并共享一个 owner。 + s := newTestStore(t, 50*1024*1024) + var frames []string + 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", + }) + 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) + } + } + + st := s.Stats() + if 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) { + // 场景 C:视觉模型描述落库后,描述文字成为可检索的语义入口。 + // 这是本方案最关键的一环——blob 可能被淘汰,描述会长期留在记忆里。 + s := newTestStore(t, 50*1024*1024) + + pic, _ := s.Put(makePNG(400, 400, 0), Item{MIME: "image/png", Tool: "multimodal_see_picture"}) + 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) + } + } + + if hits, _ := s.Search("三色带", KindImage, 10); len(hits) != 1 { + t.Fatalf("搜「三色带」应命中 1 条,实际 %d", len(hits)) + } + if hits, _ := s.Search("计数器", KindImage, 10); len(hits) != 6 { + t.Fatalf("搜「计数器」应命中 6 帧,实际 %d", len(hits)) + } + pend, _ := s.Pending(100) + 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 掉。 + 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) + } + 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) + + keep, _ := s.Put(makePNG(400, 400, 0), Item{MIME: "image/png"}) + s.AddRef(keep, "document", "doc-1") + 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 保证与上面的帧、以及彼此都不重复 + for i := 0; i < 20; i++ { + s.Put(makePNG(100, 100, 1000+i), Item{MIME: "image/png", Tool: "cmd_run"}) + } + + before := s.Stats()["count"].(int) + removed, freed, err := s.GC(0) + if 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) + } + 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) + if err != nil { + t.Fatal(err) + } + + 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) + } + beforeCount := s.Stats()["count"].(int) + s.Close() + + s2, err := New(dir, 50*1024*1024) + if err != nil { + t.Fatalf("重开失败: %v", err) + } + defer s2.Close() + + if got := s2.Stats()["count"].(int); got != beforeCount { + t.Fatalf("重开后条目数变了: %d → %d", beforeCount, got) + } + it, err := s2.Stat(pic) + if err != nil { + t.Fatalf("重开后查不到: %v", err) + } + if it.Description == "" || it.RefCount != 1 { + 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("重开后描述应仍可检索") + } + + // 磁盘文件数 == 元数据条数:无「元数据在文件没了」也无「文件在元数据没了」 + if n := blobFileCount(t, s2); n != beforeCount { + t.Fatalf("磁盘 blob=%d 与元数据=%d 不一致", n, beforeCount) + } + checkRefIntegrity(t, s2) + t.Logf("跨重启:%d 条目、描述与引用全部完好", beforeCount) +} diff --git a/internal/memory/media/soak_test.go b/internal/memory/media/soak_test.go new file mode 100644 index 0000000..2228653 --- /dev/null +++ b/internal/memory/media/soak_test.go @@ -0,0 +1,180 @@ +package media + +import ( + "bytes" + "crypto/rand" + "fmt" + "sync" + "sync/atomic" + "testing" + "time" +) + +// TestSoak_SustainedMixedLoad 长稳测试:持续混合负载下不变量不破。 +// 用 -run TestSoak -timeout 300s 单独跑,默认 short 模式跳过。 +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 频繁工作 + + // 常驻受保护集 + const keepN = 20 + keep := make([]string, keepN) + keepData := make([][]byte, keepN) + for i := range keep { + d := make([]byte, 4096) + rand.Read(d) + d = append([]byte(fmt.Sprintf("keep-%d-", i)), d...) + dg, err := s.Put(d, Item{MIME: "image/png"}) + 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 fatal atomic.Int64 + + worker := func(name string, fn func(iter int) error) { + wg.Add(1) + go func() { + defer wg.Done() + for i := 0; ; i++ { + select { + case <-stop: + return + default: + } + if err := fn(i); err != nil { + fatal.Add(1) + t.Errorf("%s 第 %d 次失败: %v", name, i, err) + return + } + } + }() + } + + // 写入者 ×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 { + 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 + }) + } + + // 读取者 ×3:受保护集必须始终完好 + for r := 0; r < 3; r++ { + worker("get", func(i int) error { + idx := i % keepN + got, err := s.Get(keep[idx]) + if err != nil { + return err + } + if !bytes.Equal(got, keepData[idx]) { + return fmt.Errorf("内容被改 %s", shortDigest(keep[idx])) + } + gets.Add(1) + return nil + }) + } + + // GC 者 + worker("gc", func(i int) error { + if _, _, err := s.GC(0); err != nil { + return err + } + gcs.Add(1) + time.Sleep(5 * time.Millisecond) + return nil + }) + + // 描述者 + worker("describe", func(i int) error { + pend, err := s.Pending(5) + if err != nil { + return err + } + for _, it := range pend { + // 忽略 unknown digest:GC 可能在 Pending 与 Describe 之间清掉它, + // 这是正常竞态而非缺陷。 + _ = s.Describe(it.Digest, fmt.Sprintf("描述 %d 含图表与文字", i), "vis") + describes.Add(1) + } + time.Sleep(2 * time.Millisecond) + return nil + }) + + // 检索者 + worker("search", func(i int) error { + if _, err := s.Search("图表", KindImage, 20); err != nil { + return err + } + if _, err := s.Stat(keep[i%keepN]); err != nil { + return err + } + searches.Add(1) + time.Sleep(2 * time.Millisecond) + return nil + }) + + time.Sleep(dur) + close(stop) + wg.Wait() + + if n := fatal.Load(); n > 0 { + 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()) + + // 收尾断言 + for i, d := range keep { + got, err := s.Get(d) + if err != nil { + t.Fatalf("受保护项丢失 %s: %v", shortDigest(d), err) + } + 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) + } +} diff --git a/internal/memory/media/stress_test.go b/internal/memory/media/stress_test.go new file mode 100644 index 0000000..d26f1b9 --- /dev/null +++ b/internal/memory/media/stress_test.go @@ -0,0 +1,601 @@ +package media + +import ( + "bytes" + "crypto/rand" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" + "time" +) + +// 压力测试与冒烟测试。 +// +// 关注点不是吞吐数字,而是并发下的不变量是否被破坏: +// 1. ref_count 与 media_refs 表的行数必须始终一致(错位会让 GC 误删或永不清) +// 2. GC 与读写并发时,有引用的内容绝不能被删 +// 3. 同内容并发 Put 只落一份磁盘、digest 一致 +// 4. SQLite 在多 goroutine 下不出现 "database is locked" + +func randBytes(t *testing.T, n int) []byte { + t.Helper() + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + t.Fatalf("rand: %v", err) + } + 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() + n := 0 + filepath.Walk(s.blobDir, func(path string, info os.FileInfo, err error) error { + if err != nil || info == nil || info.IsDir() { + return nil + } + if strings.HasSuffix(path, ".tmp") { + t.Errorf("残留临时文件: %s", path) + return nil + } + n++ + return nil + }) + return n +} + +func TestStress_ConcurrentPutSameContent(t *testing.T) { + // 同一内容被 N 个 goroutine 同时 Put:digest 必须一致,磁盘只一份。 + // 现实对应:see_video 抽出的相邻帧、用户连发同一张图。 + s := newTestStore(t, 0) + data := randBytes(t, 64*1024) + + const workers = 32 + var wg sync.WaitGroup + digests := make([]string, workers) + errs := make([]error, workers) + for i := 0; i < workers; i++ { + wg.Add(1) + go func(idx int) { + defer wg.Done() + d, err := s.Put(data, Item{MIME: "image/png", OriginPath: fmt.Sprintf("/tmp/%d.png", idx)}) + digests[idx] = d + errs[idx] = err + }(i) + } + wg.Wait() + + for i, err := range errs { + if err != nil { + t.Fatalf("worker %d Put 失败: %v", i, err) + } + } + first := digests[0] + for i, d := range digests { + if d != first { + t.Fatalf("worker %d digest 不一致: %s vs %s", i, d, first) + } + } + if n := blobFileCount(t, s); n != 1 { + t.Fatalf("同一内容应只落一份 blob,实际 %d 个文件", n) + } + 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) { + // 大量不同内容并发入库:不丢条目、不串内容。 + s := newTestStore(t, 0) + const workers = 16 + const perWorker = 25 + + var wg sync.WaitGroup + var failed atomic.Int64 + type rec struct { + digest string + data []byte + } + recCh := make(chan rec, workers*perWorker) + + for w := 0; w < workers; w++ { + wg.Add(1) + go func(wid int) { + defer wg.Done() + for i := 0; i < perWorker; i++ { + data := []byte(fmt.Sprintf("w%d-i%d-", wid, i)) + data = append(data, randBytes(t, 512)...) + d, err := s.Put(data, Item{MIME: "image/png"}) + if err != nil { + failed.Add(1) + continue + } + recCh <- rec{digest: d, data: data} + } + }(w) + } + wg.Wait() + close(recCh) + + if n := failed.Load(); n > 0 { + t.Fatalf("%d 次 Put 失败", n) + } + + var records []rec + for r := range recCh { + records = append(records, r) + } + if len(records) != workers*perWorker { + t.Fatalf("应有 %d 条记录,实际 %d", workers*perWorker, len(records)) + } + + // 逐条回读校验内容没串 + for _, r := range records { + got, err := s.Get(r.digest) + if err != nil { + t.Fatalf("读 %s 失败: %v", shortDigest(r.digest), err) + } + if !bytes.Equal(got, r.data) { + t.Fatalf("内容串了: %s", shortDigest(r.digest)) + } + } + + st := s.Stats() + 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) + + const digestCount = 8 + digests := make([]string, digestCount) + for i := range digests { + d, err := s.Put([]byte(fmt.Sprintf("payload-%d", i)), Item{MIME: "image/png"}) + if err != nil { + t.Fatal(err) + } + digests[i] = d + } + + const workers = 24 + const rounds = 40 + 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) + } + // 故意重复 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) + } + } + } + }(w) + } + wg.Wait() + + if n := addErr.Load(); n > 0 { + t.Fatalf("AddRef 失败 %d 次", n) + } + 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) + + // 一批"受保护"的内容,全程持有引用 + const protectedCount = 10 + protected := make([]string, protectedCount) + protectedData := make([][]byte, protectedCount) + for i := range protected { + data := append([]byte(fmt.Sprintf("protected-%d-", i)), randBytes(t, 256)...) + d, err := s.Put(data, Item{MIME: "image/png"}) + 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 + } + + stop := make(chan struct{}) + var wg sync.WaitGroup + var readErr atomic.Int64 + var gcRuns atomic.Int64 + var putCount atomic.Int64 + + // 写入者:持续 Put 一次性内容(不加引用,是 GC 的正常目标) + for w := 0; w < 4; w++ { + wg.Add(1) + go func(wid int) { + defer wg.Done() + i := 0 + for { + select { + case <-stop: + return + 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) + } + i++ + } + }(w) + } + + // 读取者:反复读受保护内容,任何一次失败都是致命的 + for r := 0; r < 4; r++ { + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-stop: + return + default: + } + for i, d := range protected { + got, err := s.Get(d) + if err != nil { + readErr.Add(1) + t.Errorf("受保护内容读失败 %s: %v", shortDigest(d), err) + return + } + if !bytes.Equal(got, protectedData[i]) { + readErr.Add(1) + t.Errorf("受保护内容被改 %s", shortDigest(d)) + return + } + } + } + }() + } + + // 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.Logf("并发窗口内: Put=%d GC=%d 轮", putCount.Load(), gcRuns.Load()) + + // 收尾确认:受保护的一个都没少 + for i, d := range protected { + got, err := s.Get(d) + 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) { + // 描述写入与检索并发。C 部分的后台描述任务会长期这样跑。 + s := newTestStore(t, 0) + const n = 60 + digests := make([]string, n) + for i := range digests { + d, err := s.Put([]byte(fmt.Sprintf("img-%d", i)), Item{MIME: "image/png"}) + if err != nil { + t.Fatal(err) + } + digests[i] = d + } + + var wg sync.WaitGroup + var descErr, searchErr atomic.Int64 + + // 描述写入者 + for w := 0; w < 4; w++ { + wg.Add(1) + go func(wid int) { + defer wg.Done() + for i := wid; i < n; i += 4 { + desc := fmt.Sprintf("第 %d 张图,含蓝色图表与文字", i) + if err := s.Describe(digests[i], desc, "vis-src"); err != nil { + descErr.Add(1) + } + } + }(w) + } + + // 检索者 + Pending 消费者 + for r := 0; r < 3; r++ { + wg.Add(1) + go func() { + defer wg.Done() + for i := 0; i < 50; i++ { + if _, err := s.Search("图表", KindImage, 20); err != nil { + searchErr.Add(1) + } + if _, err := s.Pending(10); err != nil { + searchErr.Add(1) + } + } + }() + } + wg.Wait() + + if v := descErr.Load(); v > 0 { + t.Fatalf("Describe 失败 %d 次", v) + } + if v := searchErr.Load(); v > 0 { + t.Fatalf("Search/Pending 失败 %d 次", v) + } + + // 全部应已描述完 + pending, err := s.Pending(1000) + if err != nil { + t.Fatal(err) + } + if len(pending) != 0 { + t.Fatalf("应全部描述完,仍有 %d 条未描述", len(pending)) + } + got, err := s.Search("图表", KindImage, 1000) + if err != nil { + t.Fatal(err) + } + if len(got) != n { + t.Fatalf("应检索到 %d 条,实际 %d", n, len(got)) + } +} + +func TestStress_CapacityGCUnderLoad(t *testing.T) { + // 容量上限在持续写入下必须真正生效,且不碰有引用的项。 + const cap = 256 * 1024 // 256KB + s := newTestStore(t, cap) + + // 先放 3 个有引用的大项(合计约 96KB),它们永不可删 + const keepN = 3 + keep := make([]string, keepN) + for i := range keep { + 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 + } + + // 持续写入无引用内容,交替 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 { + t.Fatalf("round %d Put: %v", round, err) + } + } + if _, _, err := s.GC(0); err != nil { + t.Fatalf("round %d GC: %v", round, err) + } + } + + st := s.Stats() + total := st["total_bytes"].(int64) + t.Logf("上限 %d,收尾总量 %d,条目 %v", cap, total, st["count"]) + + // 有引用的项必须都在 + for _, d := range keep { + if _, err := s.Get(d); err != nil { + t.Fatalf("有引用项被容量 GC 删了 %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) + if err != nil { + t.Fatal(err) + } + + var kept []string + for i := 0; i < 100; i++ { + data := append([]byte(fmt.Sprintf("churn-%d-", i)), randBytes(t, 256)...) + d, err := s1.Put(data, Item{MIME: "image/png"}) + if err != nil { + 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) + } + } + if _, _, err := s1.GC(0); err != nil { + t.Fatal(err) + } + beforeStats := s1.Stats() + s1.Close() + + s2, err := New(dir, 0) + if err != nil { + t.Fatalf("重开失败: %v", err) + } + defer s2.Close() + + afterStats := s2.Stats() + if beforeStats["count"] != afterStats["count"] { + t.Fatalf("重开后条目数变了: %v → %v", beforeStats["count"], afterStats["count"]) + } + + // 每条元数据都应有对应磁盘文件(无「元数据在文件没了」的孤儿) + rows, err := s2.db.Query(`SELECT digest FROM media`) + if err != nil { + t.Fatal(err) + } + var missing int + for rows.Next() { + var d string + if rows.Scan(&d) != nil { + continue + } + if _, err := os.Stat(s2.blobPath(d)); err != nil { + missing++ + if missing <= 3 { + t.Errorf("元数据存在但 blob 缺失: %s", shortDigest(d)) + } + } + } + rows.Close() + if missing > 0 { + t.Fatalf("%d 条元数据没有对应文件", missing) + } + + // 磁盘文件数应等于元数据条数(无「文件在元数据没了」的孤儿) + if n := blobFileCount(t, s2); n != afterStats["count"].(int) { + t.Fatalf("磁盘文件 %d 与元数据 %v 不一致", n, afterStats["count"]) + } + + for _, d := range kept { + if _, err := s2.Get(d); err != nil { + t.Fatalf("有引用项重开后读不到 %s: %v", shortDigest(d), err) + } + } + checkRefIntegrity(t, s2) +} + +func TestStress_LargeBlob(t *testing.T) { + // 单个大文件:see_video 10 帧 × 2MB 是现实上限附近。 + s := newTestStore(t, 0) + data := randBytes(t, 4*1024*1024) // 4MB + + d, err := s.Put(data, Item{MIME: "image/jpeg", Width: 1920, Height: 1080}) + if err != nil { + t.Fatalf("4MB Put 失败: %v", err) + } + got, err := s.Get(d) + if err != nil { + t.Fatalf("4MB Get 失败: %v", err) + } + if !bytes.Equal(got, data) { + t.Fatal("4MB 内容回读不一致") + } + it, _ := s.Stat(d) + if it.Size != int64(len(data)) { + t.Fatalf("Size 记录错: %d vs %d", it.Size, len(data)) + } +} + +func TestStress_DataURLRoundTripAtScale(t *testing.T) { + // data URL 往返是插件注入的实际路径(SetToolBlocks 给的就是 data URL)。 + s := newTestStore(t, 0) + for i := 0; i < 50; i++ { + raw := randBytes(t, 2048) + url := DataURL("image/png", raw) + mime, decoded, ok := ParseDataURL(url) + if !ok { + t.Fatalf("第 %d 次解析失败", i) + } + if mime != "image/png" || !bytes.Equal(decoded, raw) { + t.Fatalf("第 %d 次往返不一致", i) + } + d, err := s.Put(decoded, Item{MIME: mime}) + if err != nil { + t.Fatal(err) + } + back, err := s.Get(d) + if err != nil || !bytes.Equal(back, raw) { + t.Fatalf("第 %d 次入库回读不一致: %v", i, err) + } + } + checkRefIntegrity(t, s) +}