refactor(memory): 移除 media_refs/引用计数,媒体成为一等记忆块

媒体此前是"文本块 + digest 引用 + owner 账本 + 独立 GC":ContextEvent.Media
记 digest,media_refs 表用 owner_kind/owner_id 保活,ref_count 决定 GC 能否清。
这与文本记忆块的管理方式不一致,也是本次一并纠正的核心偏差。

改为与文本块完全一致的生命周期:

1. 一等记忆块直接由所在层持有
   - ContextEvent.Blocks / Doc.Blocks / GraphDB memory_blocks
   - 块带 modality/digest/MIME/size/vector/fingerprint,文本、图片、视频同构
   - Context→Document→Graph 迁移的是块本身(ID 不变),迁移后清空源容器,
     同一块不同时存在于两层

2. 删除平行生命周期账本
   - media.Store 去掉 media_refs 表、OwnerKind 常量、RefCount 字段、
     AddRef/DropRef/DropOwner/Refs、ref_count 列与索引
   - 删除 mediaGCLoop、GC(keep,minAge)、容量上限与 media.gc_* / media.max_mb 配置
   - 媒体内容在块被永久删除时一并删除(media.Store.Delete + forgetPayloads),
     与"删除文本块即删除内容"同一语义

3. L3 原生结构
   - memory_blocks / memory_block_edges(contains/depicts/derived_from)
   - 边端点必须是真实图节点,不再用 owner 字符串伪装关系
   - BlocksForNode 支持 sentence --contains--> block 反查

4. SDK 与检索同步
   - 插件附件/标记直接变成块,不再 AddRef
   - 跨模态检索改用 QueryMediaScored(CAS 内不再有孤儿缓存需要过滤)

测试全部改写为块语义:删除 refcount/media_refs/GC 断言,新增块迁移、
单层不变量、Delete 语义与并发删除回归。

注:cmd/homed/main.go 同时携带工作区中既有的 CLIP→Qwen 模型目录接线改动。
This commit is contained in:
JianFeeeee
2026-09-11 10:57:22 +08:00
parent e44164f5bd
commit dae01f9c06
27 changed files with 1391 additions and 1969 deletions

View File

@ -24,10 +24,10 @@ import (
logpkg "gitcode.com/JianFeeeee/HomeAgent/internal/log" logpkg "gitcode.com/JianFeeeee/HomeAgent/internal/log"
luapkg "gitcode.com/JianFeeeee/HomeAgent/internal/lua" luapkg "gitcode.com/JianFeeeee/HomeAgent/internal/lua"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory" "gitcode.com/JianFeeeee/HomeAgent/internal/memory"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/clip"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document" "gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media" "gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/pipeline" "gitcode.com/JianFeeeee/HomeAgent/internal/memory/pipeline"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/qwen"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/social" "gitcode.com/JianFeeeee/HomeAgent/internal/memory/social"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/text" "gitcode.com/JianFeeeee/HomeAgent/internal/memory/text"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector" "gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector"
@ -327,15 +327,13 @@ func main() {
log.Printf("[homed] warning: document store: %v", err) log.Printf("[homed] warning: document store: %v", err)
} }
// 媒体存储(内容寻址):对话里出现的图片/音频按 sha256 落盘去重, // 媒体存储(内容寻址):记忆块的内容后端。
// L0/L2/L3 只记 digest。开关默认开;关闭后全部媒体接线静默跳过, // 开关默认开;关闭后全部媒体接线静默跳过,对话行为与本特性上线前一致。
// 对话行为与本特性上线前完全一致。
var mediaStore *media.Store var mediaStore *media.Store
if cfgReg.GetBool("core.memory.media.enabled", true) { if cfgReg.GetBool("core.memory.media.enabled", true) {
mediaDir := cfgReg.GetString("core.memory.media.dir", mediaDir := cfgReg.GetString("core.memory.media.dir",
filepath.Join(cfg.Daemon.DataDir, "memory", "media")) filepath.Join(cfg.Daemon.DataDir, "memory", "media"))
maxMB := cfgReg.GetInt("core.memory.media.max_mb", 2048) ms, err := media.New(mediaDir)
ms, err := media.New(mediaDir, int64(maxMB)*1024*1024)
if err != nil { if err != nil {
// 媒体存储开不起来不该阻止启动——它是记忆增强,不是对话必需品 // 媒体存储开不起来不该阻止启动——它是记忆增强,不是对话必需品
log.Printf("[homed] warning: media store: %v(媒体记忆已禁用)", err) log.Printf("[homed] warning: media store: %v(媒体记忆已禁用)", err)
@ -343,32 +341,32 @@ func main() {
mediaStore = ms mediaStore = ms
defer mediaStore.Close() defer mediaStore.Close()
st := mediaStore.Stats() st := mediaStore.Stats()
log.Printf("[homed] media store active: %v 条 / %v 字节(上限 %d MB)", log.Printf("[homed] media store active: %v 条 / %v 字节",
st["count"], st["total_bytes"], maxMB) st["count"], st["total_bytes"])
} }
} }
// 统一多模态向量空间(可选)。 // 统一多模态向量空间(可选)。
// //
// 两条路径共享同一套基础设施(L0/L2/L3 向量缓存、media.Store 坐标、 // 两条路径共享同一套基础设施(L0/L2/L3 向量缓存、media.Store 坐标、
// QueryMemoryMediaScored 检索),只是「算向量的源头」不同: // QueryMediaScored 检索),只是「算向量的源头」不同:
// - onnx:内嵌 ONNX 模型(如 CLIP) // - onnx:内嵌 Qwen3-VL 完整图文共享空间
// - http:外部向量 API 服务(Jina / OpenAI / 自建) // - http:外部向量 API 服务(Jina / OpenAI / 自建)
// type 为空时禁用多模态向量检索,退回纯 fastText 文本路径。 // type 为空时禁用多模态向量检索,退回纯 fastText 文本路径。
var multimodalSpace vector.MultimodalEmbedder var multimodalSpace vector.MultimodalEmbedder
switch mmType := cfgReg.GetString("core.memory.multimodal_space.type", ""); mmType { switch mmType := cfgReg.GetString("core.memory.multimodal_space.type", ""); mmType {
case "onnx": case "onnx":
if clipDir := cfgReg.GetString("core.memory.media.clip_model_dir", ""); clipDir != "" { if modelDir := cfgReg.GetString("core.memory.multimodal_space.onnx.model_dir", ""); modelDir != "" {
e, err := clip.New(clipDir) e, err := qwen.New(modelDir)
if err != nil { if err != nil {
log.Printf("[homed] warning: onnx embedder load failed: %v(多模态向量检索已禁用)", err) log.Printf("[homed] warning: qwen multimodal embedder load failed: %v(多模态向量检索已禁用)", err)
} else { } else {
multimodalSpace = e multimodalSpace = e
defer e.Close() defer e.Close()
log.Printf("[homed] multimodal space (onnx) active: dim=%d fp=%s", e.Dim(), e.Fingerprint()[:min(12, len(e.Fingerprint()))]) log.Printf("[homed] multimodal space (qwen onnx) active: dim=%d fp=%s", e.Dim(), e.Fingerprint()[:min(12, len(e.Fingerprint()))])
} }
} else { } else {
log.Println("[homed] multimodal_space.type=onnx 但未配置 clip_model_dir,多模态向量检索已禁用") log.Println("[homed] multimodal_space.type=onnx 但未配置 onnx.model_dir,多模态向量检索已禁用")
} }
case "http": case "http":
dim := cfgReg.GetInt("core.memory.multimodal_space.http.dimension", 0) dim := cfgReg.GetInt("core.memory.multimodal_space.http.dimension", 0)
@ -376,7 +374,7 @@ func main() {
if dim > 0 && ep != "" { if dim > 0 && ep != "" {
e, err := vector.NewHTTPEmbedder(vector.HTTPEmbedderConfig{ e, err := vector.NewHTTPEmbedder(vector.HTTPEmbedderConfig{
Endpoint: ep, Endpoint: ep,
APIKey: cfgReg.GetString("core.memory.multimodal_space.http.api_key", ""), APIKey: cfgReg.GetString("core.memory.multimodal_space.http.api_key", ""),
Model: cfgReg.GetString("core.memory.multimodal_space.http.model", ""), Model: cfgReg.GetString("core.memory.multimodal_space.http.model", ""),
Dimension: dim, Dimension: dim,
Timeout: cfgReg.GetDuration("core.memory.multimodal_space.http.timeout", 30*time.Second), Timeout: cfgReg.GetDuration("core.memory.multimodal_space.http.timeout", 30*time.Second),
@ -398,7 +396,6 @@ func main() {
} }
} }
ks := knowledge.NewStore(filepath.Join(cfg.Daemon.DataDir, "knowledge")) ks := knowledge.NewStore(filepath.Join(cfg.Daemon.DataDir, "knowledge"))
if err := ks.Start(); err != nil { if err := ks.Start(); err != nil {
log.Printf("[homed] warning: knowledge store: %v", err) log.Printf("[homed] warning: knowledge store: %v", err)
@ -508,8 +505,6 @@ func main() {
SocialStore: socialStore, SocialStore: socialStore,
TextMemory: textMem, TextMemory: textMem,
MediaStore: mediaStore, 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), MediaDescribe: cfgReg.GetBool("core.memory.media.describe_on_ingest", false),
Personality: personality, Personality: personality,
PluginReg: pluginReg, PluginReg: pluginReg,
@ -521,7 +516,7 @@ func main() {
ContextSavePath: filepath.Join(cfg.Daemon.DataDir, "memory", "context.json"), ContextSavePath: filepath.Join(cfg.Daemon.DataDir, "memory", "context.json"),
EmbeddingModelPath: cfgReg.GetString("core.agent.embedding_model_path", ""), EmbeddingModelPath: cfgReg.GetString("core.agent.embedding_model_path", ""),
Embedder: embedder, Embedder: embedder,
MultimodalSpace: multimodalSpace, MultimodalSpace: multimodalSpace,
StageHost: stageHost, StageHost: stageHost,
EventBus: evBus, EventBus: evBus,
ThinkingEnabled: cfg.LLM.ThinkingEnabled, ThinkingEnabled: cfg.LLM.ThinkingEnabled,

View File

@ -52,14 +52,10 @@ type Agent struct {
// 文本记忆(原始对话日志) // 文本记忆(原始对话日志)
textMem *text.Memory textMem *text.Memory
// 媒体存储(内容寻址):对话里出现的图片/音频按 sha256 落盘去重, // 媒体存储(内容寻址):对话里出现的图片/音频按 sha256 落盘去重。
// L0/L2/L3 只记 digest。为 nil 时全部媒体接线静默跳过—— // 它是记忆块的内容存储,不单独做生命周期管理:块的创建/迁移/删除
// 它是记忆增强而非对话必需品,缺了不该让对话失败。 // 由记忆系统本身决定。为 nil 时全部媒体接线静默跳过。
mediaStore *media.Store mediaStore *media.Store
// mediaGCInterval 为 0 时不跑 GC 循环(容量上限就仅在手动调 GC 时生效)。
mediaGCInterval time.Duration
// mediaGCMinAge 保护新入库媒体:刚 Put 还没来得及 AddRef 的项引用计数也是 0。
mediaGCMinAge time.Duration
// mediaDescribe 控制是否跑后台描述循环(要消耗视觉模型配额)。 // mediaDescribe 控制是否跑后台描述循环(要消耗视觉模型配额)。
mediaDescribe bool mediaDescribe bool
@ -96,8 +92,8 @@ type Agent struct {
selfInputCh chan selfInputMsg selfInputCh chan selfInputMsg
// 子任务异步执行 // 子任务异步执行
childMu sync.Mutex childMu sync.Mutex
childNextID int64 childNextID int64
// childTasks 记录子任务状态:运行中 / 结果 / 是否已交付。 // childTasks 记录子任务状态:运行中 / 结果 / 是否已交付。
// //
// 为什么保留结果而不是“读到即删”:完成通知会写进持久上下文 // 为什么保留结果而不是“读到即删”:完成通知会写进持久上下文
@ -186,8 +182,6 @@ type AgentConfig struct {
SocialStore *social.SocialStore SocialStore *social.SocialStore
TextMemory *text.Memory TextMemory *text.Memory
MediaStore *media.Store MediaStore *media.Store
MediaGCInterval time.Duration
MediaGCMinAge time.Duration
MediaDescribe bool MediaDescribe bool
MultimodalSpace vector.MultimodalEmbedder MultimodalSpace vector.MultimodalEmbedder
FusionCfg CrossModalFusionConfig // 跨模态融合权重;零值用默认 FusionCfg CrossModalFusionConfig // 跨模态融合权重;零值用默认
@ -248,13 +242,6 @@ func New(cfg AgentConfig) *Agent {
if cfg.IO != nil { if cfg.IO != nil {
rc.SetChannelDefLookup(cfg.IO.GetInputChannelDef) rc.SetChannelDefLookup(cfg.IO.GetInputChannelDef)
} }
// 必须把媒体存储也注给 RelevanceContext:L0→L2 归档(Prune)靠
// rc.transferMediaRefs 把引用从 context owner 转给 document owner。
// 漏了这一行的后果是静默的:rc.mediaStore 为 nil 时转移直接 return,
// 而携带引用的 ContextEvent 已被归档删除 → 引用永久悬空在
// context owner 上、计数永不归零 → 对应 blob 永远不会被 GC 回收。
rc.SetMediaStore(cfg.MediaStore)
// 注入稠密多模态向量空间(可选):配置后文档检索、L0 相关性裁剪、 // 注入稠密多模态向量空间(可选):配置后文档检索、L0 相关性裁剪、
// 跨模态检索全部共享同一向量空间,取代稀疏 fastText 语义路。 // 跨模态检索全部共享同一向量空间,取代稀疏 fastText 语义路。
// 未配置时退化到 TF-IDF/fastText 稀疏检索,保持既有行为。 // 未配置时退化到 TF-IDF/fastText 稀疏检索,保持既有行为。
@ -284,8 +271,6 @@ func New(cfg AgentConfig) *Agent {
social: cfg.SocialStore, social: cfg.SocialStore,
textMem: cfg.TextMemory, textMem: cfg.TextMemory,
mediaStore: cfg.MediaStore, mediaStore: cfg.MediaStore,
mediaGCInterval: cfg.MediaGCInterval,
mediaGCMinAge: cfg.MediaGCMinAge,
mediaDescribe: cfg.MediaDescribe, mediaDescribe: cfg.MediaDescribe,
personality: cfg.Personality, personality: cfg.Personality,
pluginReg: cfg.PluginReg, pluginReg: cfg.PluginReg,
@ -322,7 +307,6 @@ func (a *Agent) Start() {
go a.archiveLoop() go a.archiveLoop()
go a.mergeLoop() go a.mergeLoop()
go a.reviewLoop() go a.reviewLoop()
go a.mediaGCLoop()
go a.mediaDescribeLoop() go a.mediaDescribeLoop()
a.reembedStaleMedia() a.reembedStaleMedia()
log.Printf("[agent] %s started, waiting for IO interrupts", a.id) log.Printf("[agent] %s started, waiting for IO interrupts", a.id)

View File

@ -3,7 +3,6 @@ package core
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"log"
"os" "os"
"path/filepath" "path/filepath"
"sort" "sort"
@ -13,7 +12,6 @@ import (
"gitcode.com/JianFeeeee/HomeAgent/internal/memory" "gitcode.com/JianFeeeee/HomeAgent/internal/memory"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document" "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/memory/vector"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
) )
@ -24,9 +22,8 @@ type ToolResultItem struct {
} }
type ContextEvent struct { type ContextEvent struct {
// ID 是事件的稳定标识,媒体引用(media_refs.owner_id)挂在它上面。 // ID 是事件的稳定标识。惰性生成:只有真的要挂媒体块时才赋值。
// //
// 惰性生成:只有真的要挂媒体时才赋值(见 bindEventMedia)。
// 全量生成会让每条事件都多一个字段进 context.json,而绝大多数对话没有媒体。 // 全量生成会让每条事件都多一个字段进 context.json,而绝大多数对话没有媒体。
// omitempty 保证存量 context.json 读回来时该字段为空,不影响任何既有行为。 // omitempty 保证存量 context.json 读回来时该字段为空,不影响任何既有行为。
ID string `json:"id,omitempty"` ID string `json:"id,omitempty"`
@ -36,13 +33,11 @@ type ContextEvent struct {
Response string `json:"response,omitempty"` Response string `json:"response,omitempty"`
ToolsUsed []string `json:"tools_used,omitempty"` ToolsUsed []string `json:"tools_used,omitempty"`
ToolResults []ToolResultItem `json:"tool_results,omitempty"` ToolResults []ToolResultItem `json:"tool_results,omitempty"`
// --- 原生多模态记忆(v1.2.0)--- // --- 原生多模态记忆 ---
// 媒体不是描述文本的附件,而是与文本同生命周期的记忆块。Vec 坐标在媒体 // 一等记忆块:块本身随事件在层间迁移,身份不变,不建引用计数。
// 首次进入 L0 时计算一次并存于 CAS;L0→L2→L3 只迁移 Media digest 引用, Blocks []memory.MemoryBlock `json:"blocks,omitempty"` // 一等记忆块(text/image/video/audio)
// 三层始终复用同一坐标。描述仅是可选的文本语义通道,不再决定媒体是否存在。 Vector vector.Vector `json:"-"` // 稀疏词向量(TF-IDF/fastText 空间)
Media []string `json:"media,omitempty"` DenseVec []float64 `json:"-"` // 稠密多模态向量(与媒体/文档共享空间)
Vector vector.Vector `json:"-"` // 稀疏词向量(TF-IDF/fastText 空间)
DenseVec []float64 `json:"-"` // 稠密多模态向量(与媒体/文档共享空间)
} }
const contextFlushInterval = 5 * time.Second const contextFlushInterval = 5 * time.Second
@ -57,41 +52,6 @@ type RelevanceContext struct {
dirty bool dirty bool
toolDefLookup func(name string) *sdk.ToolDef toolDefLookup func(name string) *sdk.ToolDef
channelDefLookup func(name string) (sdk.ChannelDef, bool) 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 { func NewRelevanceContext(savePath string, embedder *memory.StaticEmbedder) *RelevanceContext {
@ -323,8 +283,7 @@ func (c *RelevanceContext) flush() {
// scoredEvent 是 Prune 里按相关度排序的事件。 // scoredEvent 是 Prune 里按相关度排序的事件。
// //
// 提为包级类型(原先是 Prune 内的局部类型):transferMediaRefs 需要 // 提为包级类型:Prune 需要把待归档列表传给后续处理。
// 把待归档列表传进去,局部类型无法出现在方法签名上。
type scoredEvent struct { type scoredEvent struct {
event *ContextEvent event *ContextEvent
score float64 score float64
@ -406,17 +365,20 @@ func (c *RelevanceContext) Prune(currentInput string, topK int, docStore *docume
Content: s.event.Input, Content: s.event.Input,
Response: s.event.Response, Response: s.event.Response,
ToolResults: convertToolResults(s.event.ToolResults), ToolResults: convertToolResults(s.event.ToolResults),
Media: append([]string(nil), s.event.Media...), Blocks: append([]memory.MemoryBlock(nil), s.event.Blocks...),
} }
} }
doc, err := docStore.ContextToDoc("context_archived", entries, c.embedder, nil, c.toolOutputClean, c.channelCleanerForDoc()) doc, err := docStore.ContextToDoc("context_archived", entries, c.embedder, nil, c.toolOutputClean, c.channelCleanerForDoc())
if err == nil && doc != nil { if err == nil && doc != nil {
archived = len(entries) archived = len(entries)
// 媒体引用随事件一起从 L0 转到 L2:先把引用挂到归档文档上, // 一等记忆块的迁移:块随归档事件离开 L0、进入 L2。
// 再注销原事件的引用。顺序不能反——先销后挂会让引用计数 // 迁移的是块本身(ID 不变、只换持有层),不是复制也不是保活引用;
// 瞬时归零,若此时 GC 正在跑(后台任务)就会把仍被记忆引用的 // 因此归档后清空源事件的块,确保同一块不同时留在两层。
// 内容当孤儿清掉。 for _, s := range archive {
c.transferMediaRefs(archive, doc.ID) if s.event != nil {
s.event.Blocks = nil
}
}
} }
} }
@ -459,6 +421,18 @@ func (c *RelevanceContext) Recent(n int) []ContextEvent {
return result return result
} }
// Blocks 返回当前上下文持有的一等记忆块(供跨层存活判定)。
// 迁移后源事件已被清空,因此这里只会拿到真正属于 L0 的块。
func (c *RelevanceContext) Blocks() []memory.MemoryBlock {
c.mu.Lock()
defer c.mu.Unlock()
var out []memory.MemoryBlock
for _, e := range c.events {
out = append(out, e.Blocks...)
}
return out
}
func (c *RelevanceContext) Len() int { func (c *RelevanceContext) Len() int {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()

View File

@ -90,14 +90,11 @@ func (a *Agent) retrieveCrossModal(query string, topK int, cfg CrossModalFusionC
if a.docStore != nil { if a.docStore != nil {
for _, dh := range a.docStore.QueryScored(query, per) { for _, dh := range a.docStore.QueryScored(query, per) {
hit := CrossModalHit{Doc: dh.Doc, DocScore: dh.Score} hit := CrossModalHit{Doc: dh.Doc, DocScore: dh.Score}
// 命中文档若关联着媒体(media_refs),把媒体作为文本路候选一并带上: // 命中文档若持有一等记忆块,把首个媒体块一并带上:
// 描述文本命中 → 该媒体就是相关记忆,供后续展示/注入。 // 描述文本命中 → 该媒体就是相关记忆,供后续展示/注入。
if a.mediaStore != nil { if a.mediaStore != nil && len(dh.Doc.Blocks) > 0 {
refs, err := a.mediaStore.Refs(media.OwnerDocument, dh.Doc.ID) if it, err := a.mediaStore.Stat(dh.Doc.Blocks[0].PayloadDigest); err == nil {
if err == nil && len(refs) > 0 { hit.Media = it
if it, err := a.mediaStore.Stat(refs[0]); err == nil {
hit.Media = it
}
} }
} }
textHits = append(textHits, hit) textHits = append(textHits, hit)
@ -110,7 +107,7 @@ func (a *Agent) retrieveCrossModal(query string, topK int, cfg CrossModalFusionC
qv, err := a.multimodalSpace.VectorizeDense(query) qv, err := a.multimodalSpace.VectorizeDense(query)
if err != nil { if err != nil {
log.Printf("[crossmodal] 多模态文本编码失败: %v", err) log.Printf("[crossmodal] 多模态文本编码失败: %v", err)
} else if mh, err := a.mediaStore.QueryMemoryMediaScored(qv, a.multimodalSpace.Fingerprint(), per); err != nil { } else if mh, err := a.mediaStore.QueryMediaScored(qv, a.multimodalSpace.Fingerprint(), per); err != nil {
log.Printf("[crossmodal] 媒体记忆检索失败: %v", err) log.Printf("[crossmodal] 媒体记忆检索失败: %v", err)
} else { } else {
for _, h := range mh { for _, h := range mh {

View File

@ -10,7 +10,6 @@ import (
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io" agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory" "gitcode.com/JianFeeeee/HomeAgent/internal/memory"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document" "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/memory/vector"
"gitcode.com/JianFeeeee/HomeAgent/internal/nlp" "gitcode.com/JianFeeeee/HomeAgent/internal/nlp"
) )
@ -184,7 +183,7 @@ func (a *Agent) archiveColdDocs() {
if len(triples) == 0 { if len(triples) == 0 {
continue continue
} }
ec, rc, mediaBound, err := a.commitTriplesWithMedia(triples, string(a.id)+"_doc_archival", 0) ec, rc, blocks, err := a.commitTriplesWithMedia(triples, string(a.id)+"_doc_archival", 0, doc.Blocks)
if err != nil { if err != nil {
log.Printf("[agent] doc→graph archival error: %v", err) log.Printf("[agent] doc→graph archival error: %v", err)
continue continue
@ -203,64 +202,15 @@ func (a *Agent) archiveColdDocs() {
"(三元组 %d 条全被实体名校验拒绝)", doc.ID, len(triples)) "(三元组 %d 条全被实体名校验拒绝)", doc.ID, len(triples))
continue continue
} }
log.Printf("[agent] doc→graph: %s → %d entities, %d relations", doc.ID, ec, rc) log.Printf("[agent] doc→graph: %s → %d entities, %d relations, %d blocks", doc.ID, ec, rc, blocks)
// 先销媒体引用再删文档:文档一旦从 docStore 消失,就再没有任何 // 文档的一等记忆块已随句子写进 L3(身份不变,由 bindSentenceBlocks
// 东西能告诉我们它曾经引用过哪些 digest,media_refs 里那条记录 // 复用 doc.Blocks 的 ID);块不再挂在文档上,删除文档即完成迁移。
// 就永久悬空、引用计数永不归零,导致 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) 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 裁决 // 实体合并检测:GraphDB → LLM 裁决
// ────────────────────────────────────────────── // ──────────────────────────────────────────────

View File

@ -437,7 +437,7 @@ func (a *Agent) processInput(evt *agentIO.InputEvent) {
ToolResults: toolResults, ToolResults: toolResults,
} }
a.bindEventMedia(&turnEvt, a.drainMediaDigests()) a.bindEventMedia(&turnEvt, a.drainMediaDigests())
if s := a.mediaSummaryForEvent(turnEvt.Media); s != "" { if s := a.mediaSummaryForEvent(turnEvt.Blocks); s != "" {
turnEvt.Input = turnEvt.Input + "\n" + s turnEvt.Input = turnEvt.Input + "\n" + s
} }
a.context.Append(turnEvt) a.context.Append(turnEvt)

View File

@ -8,23 +8,15 @@ import (
"strings" "strings"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory" "gitcode.com/JianFeeeee/HomeAgent/internal/memory"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
) )
// L3 图库的媒体引用绑定。 // L3 图库的媒体绑定。
// //
// 设计定位(方案 A:只做引用,不建媒体实体节点): // 媒体在 L3 是一等记忆块(memory_blocks),通过 sentence --contains--> block
// 图库里的实体与关系全部来自**描述文本**的 NLP 提取——媒体描述经 // 结构边与承载它的句子相连。不再用 media_refs / owner 账本保活。
// mediaSummaryForEvent 进了 L0 事件的 Input,随归档进 L2 文档的 Content,
// 蒸馏时提取器自然会从描述文字里抽出实体和关系。
// //
// 为何不把媒体本身建成实体节点:节点名只能从描述里取,而描述会被重新生成 // 图库里的实体与关系仍来自描述文本的 NLP 提取;媒体块只是补上
// (换个视觉模型、补一次描述,名字就变了),于是同一张图会在图谱上留下 // 「这条记忆当时带着哪份媒体」这一结构信息。
// 多个语义模糊的节点。检索能力靠描述文本已经具备,多这类节点只是噪声。
//
// 那么图库侧还需要什么:**反查**。图库里的句子写着「[image a1b2c3d4e5f6]
// 一张紫蓝红三色带图」,要能从这条句子找回那份字节。这就是
// media_refs 的 graph_sentence owner 的用途,也是这一层唯一要做的事。
// mediaDigestPattern 匹配事件摘要里的媒体标记 [<mime或kind> <短digest>]。 // mediaDigestPattern 匹配事件摘要里的媒体标记 [<mime或kind> <短digest>]。
// //
@ -208,59 +200,61 @@ func extractMediaDigests(text string) []string {
return out return out
} }
// bindSentenceMedia 把句子文本里提到的媒体挂到对应的 sentences.id 上。 // bindSentenceBlocks 把句子文本里提到的媒体变成 L3 的一等记忆块,
// 并建立 sentence --contains--> block 结构边。
// //
// sentenceIDs 来自 GraphDB.CommitWithMedia:句子文本 → sentences.id。 // seed 是本批文档已持有的一等块:迁移时按 digest 复用它们的身份(ID 不变),
// 只处理本次真正写入了 sentences 表的句子,避免给历史句子重复挂引用 // 真正做到“同一个块从 L2 移到 L3”,而不是另建一个同内容的新块。
// (AddRef 幂等,重复挂不会涨计数,但白跑 SQL)。 // 返回本次写入 L3 的块数。
// func (a *Agent) bindSentenceBlocks(sentenceIDs map[string]int64, seed []memory.MemoryBlock) int {
// 返回实际绑定成功的引用数,这是调用方的安全依据:归档路径靠它判定 if a.mediaStore == nil || a.memory == nil || len(sentenceIDs) == 0 {
// 「引用真的转移到图库了吗」,不能用「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 return 0
} }
byDigest := make(map[string]memory.MemoryBlock, len(seed))
for _, b := range seed {
if b.PayloadDigest != "" {
byDigest[b.PayloadDigest] = b
}
}
bound := 0 bound := 0
for text, sid := range sentenceIDs { for text, sid := range sentenceIDs {
if sid == 0 { if sid == 0 {
continue continue
} }
digests := extractMediaDigests(text) for _, short := range 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) full, err := a.mediaStore.ResolvePrefix(short)
if err != nil { if err != nil {
continue continue
} }
if err := a.mediaStore.AddRef(full, media.OwnerGraphSentence, ownerID); err != nil { b, ok := byDigest[full]
log.Printf("[media] 句子引用绑定失败 (%s → sentence %s): %v", short, ownerID, err) if !ok {
if b, ok = a.blockFromDigest(full); !ok {
continue
}
}
if err := a.memory.PutMemoryBlocks([]memory.MemoryBlock{b}); err != nil {
log.Printf("[media] L3 记忆块写入失败 (%s): %v", shortDigest(full), err)
continue
}
if err := a.memory.AddMemoryBlockEdge("sentence", strconv.FormatInt(sid, 10), "block", b.ID, "contains"); err != nil {
log.Printf("[media] 句子→块边建立失败 (%s): %v", shortDigest(full), err)
continue continue
} }
bound++ bound++
} }
} }
if bound > 0 { if bound > 0 {
log.Printf("[media] L3 图库绑定 %d 个媒体引用", bound) log.Printf("[media] L3 图库写入 %d 个一等记忆块", bound)
} }
return bound return bound
} }
// sentenceWithMediaMarkers 保证句子文本里带上这些 digest 的媒体标记。 // sentenceWithMediaMarkers 保证句子文本里带上这些 digest 的媒体标记。
// //
// 存在的理由:媒体的绑定链是 SentenceText → sentences 表 → sentence_id → // 存在的理由:L3 的块边由句子正文里的短 digest 反解而来。模型只知道
// media_refs。模型只知道 digest(从 memory_recall 的「关联媒体」或对话里的 // digest(从 memory_recall 的「关联媒体」或对话里的媒体标记读到),
// 媒体标记读到),不该要求它自己按内核格式拼标记——格式写错的后果是引用 // 不该要求它自己按内核格式拼标记——格式写错的后果是块边静默建不起来。
// 静默挂不上,模型也无从察觉。
// //
// 已出现过的 digest 不重复追加:模型可能既写了标记又填了 media_digests。 // 已出现过的 digest 不重复追加:模型可能既写了标记又填了 media_digests。
func (a *Agent) sentenceWithMediaMarkers(sentence string, digests []string) string { func (a *Agent) sentenceWithMediaMarkers(sentence string, digests []string) string {
@ -278,7 +272,7 @@ func (a *Agent) sentenceWithMediaMarkers(sentence string, digests []string) stri
continue continue
} }
// 模型给的多半是短 digest(它在上下文里看到的就是短的),补全成完整 // 模型给的多半是短 digest(它在上下文里看到的就是短的),补全成完整
// digest 才能进 media_refs 主键。补不上就跳过:内容可能已被 GC 清掉。 // digest 才能定位内容。补不上就跳过:内容可能已被删除。
full, err := a.mediaStore.ResolvePrefix(d) full, err := a.mediaStore.ResolvePrefix(d)
if err != nil { if err != nil {
log.Printf("[media] 模型提交的 digest %s 无法解析: %v", d, err) log.Printf("[media] 模型提交的 digest %s 无法解析: %v", d, err)
@ -300,24 +294,19 @@ func (a *Agent) sentenceWithMediaMarkers(sentence string, digests []string) stri
// docMediaContext 为一篇文档产出媒体说明,供 doc_query 拼进工具返回值。 // docMediaContext 为一篇文档产出媒体说明,供 doc_query 拼进工具返回值。
// //
// 优先读 media_refs(权威:谁挂上去的就是谁),为空时退回解析正文标记—— // 文档的一等记忆块随文档 JSON 持久化;这里只有正文,因此从正文标记反解。
// 历史文档与经旧版路径写入的文档只有标记、没有引用。
func (a *Agent) docMediaContext(docID, content string) string { func (a *Agent) docMediaContext(docID, content string) string {
if a.mediaStore == nil { if a.mediaStore == nil {
return "" return ""
} }
digests, err := a.mediaStore.Refs(media.OwnerDocument, docID) // 文档的一等记忆块随文档 JSON 持久化;这里只有正文,退回解析标记。
if err != nil { var digests []string
log.Printf("[media] 读取文档 %s 的媒体引用失败: %v", docID, err) for _, short := range extractMediaDigests(content) {
} full, err := a.mediaStore.ResolvePrefix(short)
if len(digests) == 0 { if err != nil {
for _, short := range extractMediaDigests(content) { continue
full, err := a.mediaStore.ResolvePrefix(short)
if err != nil {
continue
}
digests = append(digests, full)
} }
digests = append(digests, full)
} }
var lines []string var lines []string
for _, d := range digests { for _, d := range digests {
@ -333,9 +322,7 @@ func (a *Agent) docMediaContext(docID, content string) string {
// resolveMediaDigests 把模型给的(多为短)digest 补全成完整 digest。 // resolveMediaDigests 把模型给的(多为短)digest 补全成完整 digest。
// //
// 补不上就丢弃那一条并记日志:模型可能凭印象编了个 digest,也可能内容已被 // 补不上就丢弃那一条并记日志:模型可能凭印象编了个 digest,也可能内容已被删除。
// 容量 GC 淘汰。挂一条对不上的引用比不挂更糟——digest 进了 media_refs 主键,
// 错了则 DropOwner 永远匹配不到它,那是一条永久泄漏的引用。
func (a *Agent) resolveMediaDigests(digests []string) []string { func (a *Agent) resolveMediaDigests(digests []string) []string {
if a.mediaStore == nil || len(digests) == 0 { if a.mediaStore == nil || len(digests) == 0 {
return nil return nil
@ -359,33 +346,11 @@ func (a *Agent) resolveMediaDigests(digests []string) []string {
// bindDocMedia 把一组完整 digest 挂到文档 owner 上,返回成功条数。 // bindDocMedia 把一组完整 digest 挂到文档 owner 上,返回成功条数。
// //
// 与 releaseDocMedia 成对:文档归档进 L3 时释放,文档写入时绑定。 // commitTriplesWithMedia 提交三元组并把句子里的媒体变成 L3 一等块。
// 只绑不放会让磁盘只增不减,只放不绑会让 GC 误删仍被引用的内容。
func (a *Agent) bindDocMedia(docID string, digests []string) int {
if a.mediaStore == nil || docID == "" || len(digests) == 0 {
return 0
}
bound := 0
for _, d := range digests {
if err := a.mediaStore.AddRef(d, media.OwnerDocument, docID); err != nil {
log.Printf("[media] 文档引用绑定失败 (%s → doc %s): %v", shortDigest(d), docID, err)
continue
}
bound++
}
if bound > 0 {
log.Printf("[media] 文档 %s 绑定 %d 个媒体引用", docID, bound)
}
return bound
}
// commitTriplesWithMedia 提交三元组并绑定句子里的媒体引用。
// //
// 包一层是为了让所有「三元组入库」的调用点用同一条路径拿到媒体绑定, // seed 是调用方已持有的一等块(如 L2 文档的 Blocks),用于保持块身份;
// 而不必各自记得多调一次 bindSentenceMedia。 // 普通对话路径传 nil。blocks 是本次写入 L3 的块数。
// mediaBound 是本次实际挂到 graph_sentence owner 上的引用数;归档路径靠它 func (a *Agent) commitTriplesWithMedia(triples []memory.Triple, sessionID string, turnID int, seed []memory.MemoryBlock) (entities, relations, blocks int, err error) {
// 判定能否安全释放旧引用。媒体存储关闭时恒为 0(此时也没有引用需要释放)。
func (a *Agent) commitTriplesWithMedia(triples []memory.Triple, sessionID string, turnID int) (entities, relations, mediaBound int, err error) {
if a.memory == nil { if a.memory == nil {
return 0, 0, 0, fmt.Errorf("graph memory 未启用") return 0, 0, 0, fmt.Errorf("graph memory 未启用")
} }
@ -398,25 +363,23 @@ func (a *Agent) commitTriplesWithMedia(triples []memory.Triple, sessionID string
if err != nil { if err != nil {
return ec, rc, 0, err return ec, rc, 0, err
} }
return ec, rc, a.bindSentenceMedia(sentenceIDs), nil return ec, rc, a.bindSentenceBlocks(sentenceIDs, seed), nil
} }
// RecallMediaForSentence 反查某条图库句子引用的媒体。 // RecallBlocksForSentence 反查某条图库句子持有的一等记忆块。
// //
// 这是整层的目的:几个月后从图谱走到一条句子,要能取回当时那份字节 // 这是整层的目的:几个月后从图谱走到一条句子,要能取回当时那份媒体。
// (若尚未被容量 GC 淘汰)。返回的是完整 digest,调用方用 func (a *Agent) RecallBlocksForSentence(sentenceID int64) ([]memory.MemoryBlock, error) {
// mediaStore.Get 取内容、Stat 取描述与元数据。 if a.memory == nil {
func (a *Agent) RecallMediaForSentence(sentenceID int64) ([]string, error) {
if a.mediaStore == nil {
return nil, nil return nil, nil
} }
return a.mediaStore.Refs(media.OwnerGraphSentence, strconv.FormatInt(sentenceID, 10)) return a.memory.BlocksForNode("sentence", strconv.FormatInt(sentenceID, 10))
} }
// sentenceIDsFromRelations 收集一批关系引用的句子 id(去重、去零)。 // sentenceIDsFromRelations 收集一批关系引用的句子 id(去重、去零)。
// //
// 关系行本身不持有媒体,媒体挂在句子上(graph_sentence owner)。 // 关系行本身不持有媒体,媒体作为一等块以 sentence --contains--> block
// 因此"这次召回涉及哪些媒体"必须经由关系 → 句子 → media_refs 这条路。 // 结构边与句子相连;因此"这次召回涉及哪些媒体"必须经由关系 → 句子这一跳。
func sentenceIDsFromRelations(relations []memory.Relation) []int64 { func sentenceIDsFromRelations(relations []memory.Relation) []int64 {
if len(relations) == 0 { if len(relations) == 0 {
return nil return nil
@ -470,18 +433,18 @@ func (a *Agent) mediaContextForInjectedEntities(injected *memory.InjectedContext
// 描述文本本就在句子里,这里补的是「内容是否还在、能否重新看图」这个信息—— // 描述文本本就在句子里,这里补的是「内容是否还在、能否重新看图」这个信息——
// 描述永存而字节可能已被淘汰,两者状态不同。 // 描述永存而字节可能已被淘汰,两者状态不同。
func (a *Agent) mediaContextForSentences(sentenceIDs []int64) string { func (a *Agent) mediaContextForSentences(sentenceIDs []int64) string {
if a.mediaStore == nil || len(sentenceIDs) == 0 { if a.memory == nil || len(sentenceIDs) == 0 {
return "" return ""
} }
var lines []string var lines []string
for _, sid := range sentenceIDs { for _, sid := range sentenceIDs {
digests, err := a.mediaStore.Refs(media.OwnerGraphSentence, strconv.FormatInt(sid, 10)) blocks, err := a.memory.BlocksForNode("sentence", strconv.FormatInt(sid, 10))
if err != nil || len(digests) == 0 { if err != nil || len(blocks) == 0 {
continue continue
} }
var parts []string var parts []string
for _, d := range digests { for _, b := range blocks {
if line := a.mediaMarkerLine(d); line != "" { if line := a.mediaMarkerLine(b.PayloadDigest); line != "" {
parts = append(parts, line) parts = append(parts, line)
} }
} }

View File

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

View File

@ -25,7 +25,7 @@ func newInputTestAgent(t *testing.T) (*Agent, *media.Store) {
t.Helper() t.Helper()
dir := t.TempDir() dir := t.TempDir()
ms, err := media.New(filepath.Join(dir, "media"), 0) ms, err := media.New(filepath.Join(dir, "media"))
if err != nil { if err != nil {
t.Fatalf("media.New: %v", err) t.Fatalf("media.New: %v", err)
} }
@ -275,30 +275,47 @@ func TestResolveMediaDigests(t *testing.T) {
} }
} }
// ---------- bindDocMedia ---------- // ---------- 文档持有的一等记忆块 ----------
func TestDocCommit_StoresBlocks(t *testing.T) {
// doc_commit 带 media_digests 时,媒体应作为一等块直接存在文档上,
// 并随 doc 一起持久化(不再靠 media_refs 保活)。
dir := t.TempDir()
ds := document.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords)
if err := ds.Start(); err != nil {
t.Fatal(err)
}
defer ds.Stop()
ms, err := media.New(filepath.Join(dir, "media"))
if err != nil {
t.Fatal(err)
}
defer ms.Close()
func TestBindDocMedia(t *testing.T) {
a, ms := newInputTestAgent(t)
d1, _ := ms.Put([]byte("doc-one"), media.Item{MIME: "image/png"}) d1, _ := ms.Put([]byte("doc-one"), media.Item{MIME: "image/png"})
d2, _ := ms.Put([]byte("doc-two"), media.Item{MIME: "image/png"}) d2, _ := ms.Put([]byte("doc-two"), media.Item{MIME: "image/png"})
if n := a.bindDocMedia("doc_x", []string{d1, d2}); n != 2 { doc := &document.Doc{ID: "doc_x", Summary: "s", Content: "c"}
t.Fatalf("绑定 %d 条,期望 2", n) for _, d := range []string{d1, d2} {
if b, ok := (&Agent{mediaStore: ms}).blockFromDigest(d); ok {
doc.Blocks = append(doc.Blocks, b)
}
} }
refs, err := ms.Refs(media.OwnerDocument, "doc_x") if err := ds.Insert(doc); err != nil {
if err != nil { t.Fatal(err)
t.Fatalf("Refs: %v", err)
}
if len(refs) != 2 {
t.Errorf("引用 = %v,期望 2 条", refs)
} }
if n := a.bindDocMedia("", []string{d1}); n != 0 { blocks := ds.Blocks()
t.Error("空 docID 不该绑定") if len(blocks) != 2 {
t.Fatalf("文档应持有 2 个块,实际 %d", len(blocks))
} }
bare := &Agent{} seen := map[string]bool{}
if n := bare.bindDocMedia("doc_y", []string{d1}); n != 0 { for _, b := range blocks {
t.Error("无媒体存储时不该绑定") seen[b.PayloadDigest] = true
}
if !seen[d1] || !seen[d2] {
t.Errorf("块 digest 不对: %+v", blocks)
} }
} }
@ -310,17 +327,7 @@ func TestDocMediaContext(t *testing.T) {
MIME: "image/png", Description: "文档里的配图", MIME: "image/png", Description: "文档里的配图",
}) })
t.Run("优先用media_refs", func(t *testing.T) { t.Run("无块时解析正文标记", func(t *testing.T) {
if err := ms.AddRef(digest, media.OwnerDocument, "doc_refs"); err != nil {
t.Fatalf("AddRef: %v", err)
}
got := a.docMediaContext("doc_refs", "正文里没有任何标记")
if !strings.Contains(got, "文档里的配图") {
t.Errorf("未从 media_refs 取到媒体说明: %q", got)
}
})
t.Run("无引用时回退解析正文标记", func(t *testing.T) {
content := "旧正文 [image/png " + digest[:12] + "] 文档里的配图" content := "旧正文 [image/png " + digest[:12] + "] 文档里的配图"
got := a.docMediaContext("doc_legacy", content) got := a.docMediaContext("doc_legacy", content)
if !strings.Contains(got, "文档里的配图") { if !strings.Contains(got, "文档里的配图") {
@ -384,13 +391,13 @@ func newToolTestAgent(t *testing.T) (*Agent, *media.Store) {
} }
t.Cleanup(func() { g.Close() }) t.Cleanup(func() { g.Close() })
ds := document.NewStore(filepath.Join(dir, "documents")) ds := document.NewStore(filepath.Join(dir, "documents"), memory.TokenizeWords)
if err := ds.Start(); err != nil { if err := ds.Start(); err != nil {
t.Fatalf("doc store: %v", err) t.Fatalf("doc store: %v", err)
} }
t.Cleanup(func() { ds.Stop() }) t.Cleanup(func() { ds.Stop() })
ms, err := media.New(filepath.Join(dir, "media"), 0) ms, err := media.New(filepath.Join(dir, "media"))
if err != nil { if err != nil {
t.Fatalf("media.New: %v", err) t.Fatalf("media.New: %v", err)
} }
@ -438,9 +445,12 @@ func TestToolMemoryCommit_BindsMedia(t *testing.T) {
if len(res.Relations) == 0 || res.Relations[0].SentenceID == 0 { if len(res.Relations) == 0 || res.Relations[0].SentenceID == 0 {
t.Fatal("没有句子落点 —— 媒体引用无从挂起") t.Fatal("没有句子落点 —— 媒体引用无从挂起")
} }
refs, _ := ms.Refs(media.OwnerGraphSentence, strconv.FormatInt(res.Relations[0].SentenceID, 10)) blocks, err := a.memory.BlocksForNode("sentence", strconv.FormatInt(res.Relations[0].SentenceID, 10))
if len(refs) != 1 || refs[0] != digest { if err != nil {
t.Errorf("句子引用 = %v,期望 [%s]", refs, digest) t.Fatalf("BlocksForNode: %v", err)
}
if len(blocks) != 1 || blocks[0].PayloadDigest != digest {
t.Errorf("句子块 = %+v,期望 [%s]", blocks, digest)
} }
} }
@ -515,9 +525,14 @@ func TestToolDocCommit_BindsMedia(t *testing.T) {
if !strings.Contains(d.Content, "笔记里的插图") { if !strings.Contains(d.Content, "笔记里的插图") {
t.Errorf("标记未进正文(向量索引看不到这份媒体): %q", d.Content) t.Errorf("标记未进正文(向量索引看不到这份媒体): %q", d.Content)
} }
refs, _ := ms.Refs(media.OwnerDocument, d.ID) var held bool
if len(refs) != 1 || refs[0] != digest { for _, b := range d.Blocks {
t.Errorf("文档引用 = %v,期望 [%s]", refs, digest) if b.PayloadDigest == digest {
held = true
}
}
if !held {
t.Errorf("文档应持有一等记忆块 [%s],实际 %+v", digest, d.Blocks)
} }
} }

View File

@ -6,14 +6,13 @@
// 往 IOManager 注入一个 image 事件,然后等。之后全部由生产代码自己走: // 往 IOManager 注入一个 image 事件,然后等。之后全部由生产代码自己走:
// //
// processMediaInput → captureBlockMedia(入 CAS) // processMediaInput → captureBlockMedia(入 CAS)
// → Prune → transferMediaRefs(L0→L2 引用转移) // → Prune(L0→L2 块迁移)
// → describePendingMedia(真实视觉模型生成描述) // → describePendingMedia(真实视觉模型生成描述)
// → archiveColdDocs → commitTriplesWithMedia → bindSentenceMedia(L2→L3) // → archiveColdDocs → commitTriplesWithMedia → bindSentenceBlocks(L2→L3)
// → 第二轮提问,验证 agent 真能召回 // → 第二轮提问,验证 agent 真能召回
// //
// 为什么必须这样测:单测能证明每个函数正确,却证明不了它**被接上了**。 // 为什么必须这样测:单测能证明每个函数正确,却证明不了它**被接上了**——
// 本文件的直接动机是一个真实缺陷——core.New() 漏了 rc.SetMediaStore(cfg.MediaStore), // 手工注入 store 的单测全绿而生产链路断开,是本文件要拦的典型缺陷。
// 于是 L0→L2 引用转移在生产里永远静默 return,而手工注入 store 的单测全绿。
// //
// 需要真实 LLM,因此加 medialive build tag,默认 go test 不跑: // 需要真实 LLM,因此加 medialive build tag,默认 go test 不跑:
// //
@ -172,7 +171,7 @@ func newLiveEnv(t *testing.T, c liveCfg) *liveEnv {
t.Fatalf("set default provider: %v", err) t.Fatalf("set default provider: %v", err)
} }
ms, err := media.New(filepath.Join(dir, "media"), 256<<20) ms, err := media.New(filepath.Join(dir, "media"))
if err != nil { if err != nil {
t.Fatalf("media store: %v", err) t.Fatalf("media store: %v", err)
} }
@ -184,7 +183,7 @@ func newLiveEnv(t *testing.T, c liveCfg) *liveEnv {
} }
t.Cleanup(func() { graph.Close() }) t.Cleanup(func() { graph.Close() })
docStore := document.NewStore(filepath.Join(dir, "docs")) docStore := document.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords)
if err := docStore.Start(); err != nil { if err := docStore.Start(); err != nil {
t.Fatalf("doc store: %v", err) t.Fatalf("doc store: %v", err)
} }
@ -201,7 +200,6 @@ func newLiveEnv(t *testing.T, c liveCfg) *liveEnv {
Memory: graph, Memory: graph,
DocStore: docStore, DocStore: docStore,
MediaStore: ms, MediaStore: ms,
MediaGCInterval: 0, // 本测试自己控制 GC 时机
MediaDescribe: true, // 描述循环由测试直接调 describePendingMedia MediaDescribe: true, // 描述循环由测试直接调 describePendingMedia
StageHost: NewStageHost(), StageHost: NewStageHost(),
MaxContextSize: 3, // 故意压低:第二轮就能触发 Prune 归档 MaxContextSize: 3, // 故意压低:第二轮就能触发 Prune 归档
@ -271,31 +269,29 @@ func TestMediaLive_AutoTriggerChain(t *testing.T) {
t.Fatalf("落盘内容与原图不一致 (err=%v)", err) t.Fatalf("落盘内容与原图不一致 (err=%v)", err)
} }
// ── 阶段 2:引用自动挂到 ContextEvent 上 ── // ── 阶段 2:一等记忆块自动挂到 ContextEvent 上 ──
// //
// 这一步验证 bindEventMedia:事件必须拿到 ID 且 media_refs 里 // 这一步验证 bindEventMedia:事件必须拿到 ID 并直接持有块。
// 有对应 context owner 记录。两者只写一个的后果是 GC 误删或永不清理。
var evtID string var evtID string
var summaryOK bool var summaryOK bool
for _, e := range a.context.Recent(0) { for _, e := range a.context.Recent(0) {
if len(e.Media) > 0 { if len(e.Blocks) > 0 {
evtID = e.ID evtID = e.ID
summaryOK = strings.Contains(e.Input, digest[:12]) summaryOK = strings.Contains(e.Input, digest[:12])
if e.Blocks[0].PayloadDigest != digest {
t.Fatalf("事件持有的块 digest 不对: %+v", e.Blocks)
}
break break
} }
} }
if evtID == "" { if evtID == "" {
t.Fatal("没有任何 ContextEvent 挂上媒体(bindEventMedia 未被触发)") 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 { if !summaryOK {
t.Error("事件 Input 里没有媒体摘要标记(mediaSummaryForEvent 未生效)——" + t.Error("事件 Input 里没有媒体摘要标记(mediaSummaryForEvent 未生效)——" +
"L2/L3 靠正文里的短 digest 反查,缺了它整条召回链断掉") "L2/L3 靠正文里的短 digest 反查,缺了它整条召回链断掉")
} }
t.Logf("✓ 阶段2 引用自动绑定: event=%s owner=context 摘要内嵌=%v", evtID, summaryOK) t.Logf("✓ 阶段2 块自动绑定: event=%s 摘要内嵌=%v", evtID, summaryOK)
// ── 阶段 3:描述由后台循环自动生成(真实视觉模型)── // ── 阶段 3:描述由后台循环自动生成(真实视觉模型)──
pending, err := env.mediaSt.Pending(5) pending, err := env.mediaSt.Pending(5)
@ -338,11 +334,10 @@ func TestMediaLive_AutoTriggerChain(t *testing.T) {
t.Logf("✓ 阶段3 Search(\"紫\") 命中 %d 条", len(found)) t.Logf("✓ 阶段3 Search(\"紫\") 命中 %d 条", len(found))
} }
// ── 阶段 4:Prune 自动把引用从 L0 转移到 L2 ── // ── 阶段 4:Prune 自动把块从 L0 迁移到 L2 ──
// //
// MaxContextSize=3,多注入几轮文本把带图事件挤出活跃上下文。 // MaxContextSize=3,多注入几轮文本把带图事件挤出活跃上下文。
// 这一步专门守 core.New() 里 rc.SetMediaStore 的接线:漏了它 // 迁移的是块本身(同一身份换层);L0 中不该再留下它。
// transferMediaRefs 直接 return,引用永久悬空在 context owner 上。
// 填充数量必须 > Prune 内部固定的 10 条保护窗口。 // 填充数量必须 > Prune 内部固定的 10 条保护窗口。
// //
// Prune 无条件保护最后 10 条事件(protected := events[len-10:]), // Prune 无条件保护最后 10 条事件(protected := events[len-10:]),
@ -366,33 +361,35 @@ func TestMediaLive_AutoTriggerChain(t *testing.T) {
docRefsFound := "" docRefsFound := ""
for _, d := range env.docStore.RecentDocs(20) { for _, d := range env.docStore.RecentDocs(20) {
refs, err := env.mediaSt.Refs(media.OwnerDocument, d.ID) for _, b := range d.Blocks {
if err == nil && len(refs) > 0 && refs[0] == digest { if b.PayloadDigest == digest {
docRefsFound = d.ID docRefsFound = d.ID
break }
} }
} }
if docRefsFound == "" { if docRefsFound == "" {
t.Fatal("引用未转移到 document owner——" + t.Fatal("块未随归档事件迁移到 L2 文档")
"core.New() 是否漏了 rc.SetMediaStore(cfg.MediaStore)?" +
"(该缺陷曾真实存在:手工注入 store 的单测全绿,生产里永远静默 return)")
} }
if left, _ := env.mediaSt.Refs(media.OwnerContext, evtID); len(left) != 0 { // 同一块不能同时留在 L0。
t.Errorf("旧的 context 引用未注销(%d 条),引用计数永不归零 → blob 永不回收", len(left)) for _, e := range a.context.Recent(0) {
for _, b := range e.Blocks {
if b.PayloadDigest == digest {
t.Errorf("块仍留在 L0(evt %s),违反单层不变量", e.ID)
}
}
} }
t.Logf("✓ 阶段4 引用自动转移: context/%s → document/%s", evtID, docRefsFound) t.Logf("✓ 阶段4 块自动迁移: context/%s → document/%s", evtID, docRefsFound)
// 转移全程内容必须可读:先挂后销的顺序若反了, // 迁移全程内容必须可读:块虽换了层,字节仍在。
// 计数会瞬时归零,并发 GC 会把仍被引用的内容当孤儿删掉。
if _, err := env.mediaSt.Get(digest); err != nil { if _, err := env.mediaSt.Get(digest); err != nil {
t.Fatalf("转移后内容不可读: %v", err) t.Fatalf("迁移后内容不可读: %v", err)
} }
// ── 阶段 5:archiveColdDocs 自动把媒体带进 L3 图库 ── // ── 阶段 5:archiveColdDocs 自动把媒体带进 L3 图库 ──
// //
// FindColdDocs(72h, 2) 要求文档足够"冷",测试里新建的文档不满足, // FindColdDocs(72h, 2) 要求文档足够"冷",测试里新建的文档不满足,
// 因此把 LastAccess 往前推——这是为了触发生产代码路径, // 因此把 LastAccess 往前推——这是为了触发生产代码路径,
// 而不是替代它(Commit/bindSentenceMedia/releaseDocMedia 全部由它自己调)。 // 而不是替代它(Commit/bindSentenceBlocks 全部由它自己调)。
for _, d := range env.docStore.RecentDocs(20) { for _, d := range env.docStore.RecentDocs(20) {
if d.ID == docRefsFound { if d.ID == docRefsFound {
d.LastAccess = time.Now().Add(-100 * time.Hour) d.LastAccess = time.Now().Add(-100 * time.Hour)
@ -410,41 +407,36 @@ func TestMediaLive_AutoTriggerChain(t *testing.T) {
t.Logf("图库实体数 %d", len(rows.Entities)) t.Logf("图库实体数 %d", len(rows.Entities))
// 句子 id 是自增整数,扫前若干个足够覆盖本测试写入的量 // 句子 id 是自增整数,扫前若干个足够覆盖本测试写入的量
for sid := int64(1); sid <= 40; sid++ { for sid := int64(1); sid <= 40; sid++ {
refs, err := env.mediaSt.Refs(media.OwnerGraphSentence, strconv.FormatInt(sid, 10)) blocks, err := env.graph.BlocksForNode("sentence", strconv.FormatInt(sid, 10))
if err == nil && len(refs) > 0 { if err == nil && len(blocks) > 0 {
sentRefs += len(refs) sentRefs += len(blocks)
if boundSentence == 0 { if boundSentence == 0 {
boundSentence = sid boundSentence = sid
} }
} }
} }
if sentRefs == 0 { if sentRefs == 0 {
t.Error("L2→L3 未绑定任何 graph_sentence 引用——" + t.Error("L2→L3 未写入任何句子→块边——" +
"bindSentenceMedia 未被 commitTriplesWithMedia 触发," + "bindSentenceBlocks 未被 commitTriplesWithMedia 触发," +
"或句子正文里没有可反解的短 digest") "或句子正文里没有可反解的短 digest")
} else { } else {
t.Logf("✓ 阶段5 L3 自动绑定: %d 个句子引用,首个 sentences.id=%d", sentRefs, boundSentence) t.Logf("✓ 阶段5 L3 自动写入: %d 个句子块,首个 sentences.id=%d", sentRefs, boundSentence)
got, err := env.agent.RecallMediaForSentence(boundSentence) got, err := env.agent.RecallBlocksForSentence(boundSentence)
if err != nil || len(got) == 0 || got[0] != digest { if err != nil || len(got) == 0 || got[0].PayloadDigest != digest {
t.Errorf("从句子反查 digest 失败: got=%v err=%v", got, err) t.Errorf("从句子反查块失败: got=%+v err=%v", got, err)
} else if raw, err := env.mediaSt.Get(got[0]); err != nil || !bytes.Equal(raw, img) { } else if raw, err := env.mediaSt.Get(got[0].PayloadDigest); err != nil || !bytes.Equal(raw, img) {
t.Errorf("从句子取回的字节与原图不一致 (err=%v)", err) t.Errorf("从句子取回的字节与原图不一致 (err=%v)", err)
} else { } else {
t.Logf("✓ 阶段5 反查取回 %d 字节,与原图逐字节一致", len(raw)) t.Logf("✓ 阶段5 反查取回 %d 字节,与原图逐字节一致", len(raw))
} }
} }
// ── 阶段 6:GC 不能删掉仍被记忆引用的内容 ── // ── 阶段 6:内容随块存在,不被单独清理 ──
removed, freed, err := env.mediaSt.GC(0) // minAge=0,最激进
if err != nil {
t.Fatal(err)
}
if _, err := env.mediaSt.Stat(digest); err != nil { if _, err := env.mediaSt.Stat(digest); err != nil {
t.Fatalf("被记忆引用的内容被 GC 删除了(清 %d 条/%d 字节)——"+ t.Fatalf("被记忆块持有的内容不存在了: %v", err)
"引用计数或 owner 语义有误", removed, freed)
} }
t.Logf("✓ 阶段6 GC(minAge=0) 清 %d 条,被引用内容仍在", removed) t.Logf("✓ 阶段6 被持有内容仍在")
// ── 阶段 7:E2E — 第二轮提问,验证 agent 真能召回 ── // ── 阶段 7:E2E — 第二轮提问,验证 agent 真能召回 ──
// //
@ -504,8 +496,8 @@ func TestMediaLive_AutoTriggerChain(t *testing.T) {
} }
st := env.mediaSt.Stats() st := env.mediaSt.Stats()
t.Logf("收尾: %v 条 / %v 字节 / 已描述 %v / 无引用 %v", t.Logf("收尾: %v 条 / %v 字节 / 已描述 %v",
st["count"], st["total_bytes"], st["described"], st["unreferenced"]) st["count"], st["total_bytes"], st["described"])
} }
// TestMediaLive_NegativeControl 阴性对照:没有媒体记忆时不该"记得"。 // TestMediaLive_NegativeControl 阴性对照:没有媒体记忆时不该"记得"。

View File

@ -10,16 +10,69 @@ import (
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media" "gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
) )
// 媒体记忆的两条后台循环。 // 媒体记忆的后台循环。
// //
// mediaGCLoop 清理无人引用的 blob,让容量上限真正生效。 // mediaDescribeLoop 给未描述的媒体生成文字描述。
// mediaDescribeLoop 给未描述的媒体生成文字描述(方案 C 的另一半)。 //
// 媒体不单独做生命周期管理(没有 GC、没有引用计数):blob 是记忆块的内容,
// 块的创建/迁移/删除由记忆系统本身决定,块被永久删除时内容随之删除
// (见 forgetPayloads)。
// //
// 为何描述要走后台而不是入库时同步做:视觉模型一次调用在生产实测 9.6s // 为何描述要走后台而不是入库时同步做:视觉模型一次调用在生产实测 9.6s
// (see_video 6 帧批量 23s)。放在对话路径上会让每张图都给回复加十几秒, // (see_video 6 帧批量 23s)。放在对话路径上会让每张图都给回复加十几秒,
// 而描述的价值是**几个月后还能检索到这张图**,不是这一轮对话—— // 而描述的价值是**几个月后还能检索到这张图**,不是这一轮对话——
// 这一轮模型本来就直接看着图。 // 这一轮模型本来就直接看着图。
// payloadHeld 报告某个 digest 是否仍被三层记忆中的一等块持有。
// 这是删除前的一次活查询(不是持久化账本):同一份字节可能同时被多个块共享。
func (a *Agent) payloadHeld(digest string) bool {
if digest == "" {
return false
}
if a.context != nil {
for _, b := range a.context.Blocks() {
if b.PayloadDigest == digest {
return true
}
}
}
if a.docStore != nil {
for _, b := range a.docStore.Blocks() {
if b.PayloadDigest == digest {
return true
}
}
}
if a.memory != nil {
if blocks, err := a.memory.MemoryBlocks(); err == nil {
for _, b := range blocks {
if b.PayloadDigest == digest {
return true
}
}
}
}
return false
}
// forgetPayloads 在记忆块被永久删除后删除它们的内容。
//
// 与文本块一致:删除块即删除内容。只有确认没有任何存活块仍共享该 digest
// 时才删字节(同一张图可能被多个块引用)。
func (a *Agent) forgetPayloads(digests []string) {
if a.mediaStore == nil {
return
}
for _, d := range digests {
if d == "" || a.payloadHeld(d) {
continue
}
if err := a.mediaStore.Delete(d); err != nil {
log.Printf("[media] 删除内容失败 %s: %v", shortDigest(d), err)
}
}
}
const ( const (
// mediaDescribeBatch 是单轮描述的媒体条数上限。 // mediaDescribeBatch 是单轮描述的媒体条数上限。
// //
@ -35,50 +88,11 @@ const (
mediaDescribeMinInterval = 30 * time.Second 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 给未描述的媒体补文字描述。 // mediaDescribeLoop 给未描述的媒体补文字描述。
// //
// 描述文本才是持久语义记忆:blob 会被容量 GC 淘汰,而描述留在 media 表里, // 描述文本才是持久语义记忆:它留在 media 表里,并经 mediaSummaryForEvent
// 并经 mediaSummaryForEvent 写进 L0 事件、随归档进 L2 文档、经蒸馏进 L3 图库。 // 写进 L0 事件、随归档进 L2 文档、经蒸馏进 L3 图库。
// 于是「那张紫蓝红三色带图」在原始字节早已被清掉之后仍然可被检索到。 // 于是「那张紫蓝红三色带图」仍然可被检索到。
func (a *Agent) mediaDescribeLoop() { func (a *Agent) mediaDescribeLoop() {
defer func() { defer func() {
if r := recover(); r != nil { if r := recover(); r != nil {

View File

@ -6,116 +6,36 @@ import (
"testing" "testing"
"time" "time"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media" "gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
) )
// 媒体后台循环测试。 // 媒体后台循环测试。
// //
// 两条循环都要能在「未启用」时干净退出——它们随 Agent.Start() 无条件启动, // 媒体没有独立生命周期管理(没有 GC、没有引用计数):blob 是记忆块的内容,
// 若不早退就会在每个没配媒体存储的部署上空转一个 goroutine。 // 块的创建/迁移/删除由记忆系统决定。这里只测描述循环与删除语义。
func newMediaLoopAgent(t *testing.T, gcInterval, minAge time.Duration, describe bool) (*Agent, *media.Store) { func newMediaLoopAgent(t *testing.T, describe bool) (*Agent, *media.Store) {
t.Helper() t.Helper()
dir := t.TempDir() dir := t.TempDir()
ms, err := media.New(filepath.Join(dir, "media"), 0) ms, err := media.New(filepath.Join(dir, "media"))
if err != nil { if err != nil {
t.Fatalf("media.New: %v", err) t.Fatalf("media.New: %v", err)
} }
t.Cleanup(func() { ms.Close() }) t.Cleanup(func() { ms.Close() })
a := &Agent{ a := &Agent{
mediaStore: ms, mediaStore: ms,
mediaGCInterval: gcInterval, mediaDescribe: describe,
mediaGCMinAge: minAge,
mediaDescribe: describe,
} }
a.ctx, a.cancel = context.WithCancel(context.Background()) a.ctx, a.cancel = context.WithCancel(context.Background())
t.Cleanup(a.cancel) t.Cleanup(a.cancel)
return a, ms 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) { func TestMediaDescribeLoop_ExitsWhenDisabled(t *testing.T) {
// describe 关闭时必须立即返回(默认就是关闭,绝大多数部署走这条路) // describe 关闭时必须立即返回(默认就是关闭,绝大多数部署走这条路)
a, _ := newMediaLoopAgent(t, 0, 0, false) a, _ := newMediaLoopAgent(t, false)
done := make(chan struct{}) done := make(chan struct{})
go func() { a.mediaDescribeLoop(); close(done) }() go func() { a.mediaDescribeLoop(); close(done) }()
select { select {
@ -128,7 +48,7 @@ func TestMediaDescribeLoop_ExitsWhenDisabled(t *testing.T) {
func TestDescribePendingMedia_NoProviderLeavesUndescribed(t *testing.T) { func TestDescribePendingMedia_NoProviderLeavesUndescribed(t *testing.T) {
// 没有声明视觉能力的源时整轮跳过,且**不能**把项标记成已处理—— // 没有声明视觉能力的源时整轮跳过,且**不能**把项标记成已处理——
// 配置好之后必须还能被捡起来。 // 配置好之后必须还能被捡起来。
a, ms := newMediaLoopAgent(t, 0, 0, true) a, ms := newMediaLoopAgent(t, true)
d, _ := ms.Put([]byte("img"), media.Item{MIME: "image/png"}) d, _ := ms.Put([]byte("img"), media.Item{MIME: "image/png"})
// providerManager 为 nil → resolveModalFallback 返回 nil // providerManager 为 nil → resolveModalFallback 返回 nil
@ -150,7 +70,7 @@ func TestDescribePendingMedia_NoProviderLeavesUndescribed(t *testing.T) {
func TestDescribePendingMedia_MarksUnsupportedKind(t *testing.T) { func TestDescribePendingMedia_MarksUnsupportedKind(t *testing.T) {
// video/other 大类没有可用的描述通道,必须标记掉, // video/other 大类没有可用的描述通道,必须标记掉,
// 否则每轮 Pending 都把它取出来重试,永远卡住队列头部。 // 否则每轮 Pending 都把它取出来重试,永远卡住队列头部。
a, ms := newMediaLoopAgent(t, 0, 0, true) a, ms := newMediaLoopAgent(t, true)
other, _ := ms.Put([]byte("blob"), media.Item{MIME: "application/octet-stream"}) other, _ := ms.Put([]byte("blob"), media.Item{MIME: "application/octet-stream"})
a.describePendingMedia() a.describePendingMedia()
@ -162,8 +82,6 @@ func TestDescribePendingMedia_MarksUnsupportedKind(t *testing.T) {
if it.DescribedBy != "unsupported" { if it.DescribedBy != "unsupported" {
t.Fatalf("不可描述的大类应被标记,实际 DescribedBy=%q", it.DescribedBy) t.Fatalf("不可描述的大类应被标记,实际 DescribedBy=%q", it.DescribedBy)
} }
// 标记后必须退出待描述队列,否则每轮都被取出来重试、永久占着
// LIMIT 的名额,真正需要描述的新项永远轮不到。
pending, _ := ms.Pending(10) pending, _ := ms.Pending(10)
if len(pending) != 0 { if len(pending) != 0 {
t.Fatalf("标记 unsupported 后应退出待描述队列,仍有 %d 条", len(pending)) t.Fatalf("标记 unsupported 后应退出待描述队列,仍有 %d 条", len(pending))
@ -171,6 +89,41 @@ func TestDescribePendingMedia_MarksUnsupportedKind(t *testing.T) {
} }
func TestDescribePendingMedia_EmptyQueueIsNoop(t *testing.T) { func TestDescribePendingMedia_EmptyQueueIsNoop(t *testing.T) {
a, _ := newMediaLoopAgent(t, 0, 0, true) a, _ := newMediaLoopAgent(t, true)
a.describePendingMedia() // 不该 panic a.describePendingMedia() // 不该 panic
} }
// TestForgetPayloads_DeletesOnlyUnheldContent 验证删除语义:
// 块被删除后内容才被删;仍被其它记忆块共享的 digest 不会被误删。
func TestForgetPayloads_DeletesOnlyUnheldContent(t *testing.T) {
dir := t.TempDir()
ms, err := media.New(filepath.Join(dir, "media"))
if err != nil {
t.Fatal(err)
}
defer ms.Close()
d1, _ := ms.Put([]byte("held-by-graph"), media.Item{MIME: "image/png"})
d2, _ := ms.Put([]byte("being-forgotten"), media.Item{MIME: "image/png"})
g, err := memory.NewGraphDB(filepath.Join(dir, "graph.db"))
if err != nil {
t.Fatal(err)
}
defer g.Close()
if err := g.PutMemoryBlocks([]memory.MemoryBlock{
{ID: "blk_keep", Modality: memory.BlockImage, PayloadDigest: d1},
}); err != nil {
t.Fatal(err)
}
a := &Agent{mediaStore: ms, memory: g}
a.forgetPayloads([]string{d1, d2})
if _, err := ms.Stat(d1); err != nil {
t.Fatalf("仍被 L3 块持有的内容不该被删: %v", err)
}
if _, err := ms.Stat(d2); err == nil {
t.Fatal("无人持有的内容应被删除")
}
}

View File

@ -4,12 +4,60 @@ import (
"fmt" "fmt"
"log" "log"
"strings" "strings"
"sync/atomic"
"time" "time"
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api" agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media" "gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
) )
// blockSeq 保证块 ID 全局唯一(Graph memory_blocks 以 id 为主键)。
var blockSeq int64
func newBlockID() string {
return fmt.Sprintf("blk_%d_%d", time.Now().UnixNano(), atomic.AddInt64(&blockSeq, 1))
}
// blockModalityOf 把 CAS 媒体大类映射为一等记忆块模态。
func blockModalityOf(k media.Kind) memory.BlockModality {
switch k {
case media.KindImage:
return memory.BlockImage
case media.KindVideo:
return memory.BlockVideo
case media.KindAudio:
return memory.BlockAudio
default:
return memory.BlockText
}
}
// blockFromDigest 把一份已入库媒体变成一等记忆块。
// 块携带 digest/向量/fingerprint;CAS 只提供字节与元数据,不参与生命周期。
func (a *Agent) blockFromDigest(digest string) (memory.MemoryBlock, bool) {
if a.mediaStore == nil || digest == "" {
return memory.MemoryBlock{}, false
}
it, err := a.mediaStore.Stat(digest)
if err != nil || it == nil {
return memory.MemoryBlock{}, false
}
return memory.MemoryBlock{
ID: newBlockID(),
Modality: blockModalityOf(it.Kind),
PayloadDigest: it.Digest,
MIME: it.MIME,
Size: it.Size,
Width: it.Width,
Height: it.Height,
Vector: it.Vec,
Fingerprint: it.VecModel,
Tool: it.Tool,
CreatedAt: it.FirstSeen,
}, true
}
// 媒体记忆接线:把对话里出现的图片/音频落进内容寻址存储(CAS), // 媒体记忆接线:把对话里出现的图片/音频落进内容寻址存储(CAS),
// 并让 L0 的 ContextEvent 记住它们的 digest。 // 并让 L0 的 ContextEvent 记住它们的 digest。
// //
@ -94,7 +142,7 @@ func (a *Agent) embedMediaOnIngest(digest, mime string, data []byte) {
// stageMediaDigests 累积本轮捕获的 digest,等 ContextEvent 建好后一起挂上。 // stageMediaDigests 累积本轮捕获的 digest,等 ContextEvent 建好后一起挂上。
// //
// 为何要缓存而不是当场 AddRef:媒体在 process() 执行期间被捕获,而承载它的 // 为何要缓存而不是当场建块:媒体在 process() 执行期间被捕获,而承载它的
// ContextEvent 要等 process() 返回后才 Append——此刻还没有 owner_id。 // ContextEvent 要等 process() 返回后才 Append——此刻还没有 owner_id。
// 与既有的 a.pendingMedia 同一手法(都在 a.mu 保护下)。 // 与既有的 a.pendingMedia 同一手法(都在 a.mu 保护下)。
func (a *Agent) stageMediaDigests(digests ...string) { func (a *Agent) stageMediaDigests(digests ...string) {
@ -114,12 +162,10 @@ func (a *Agent) drainMediaDigests() []string {
return out return out
} }
// bindEventMedia 把 digest 列表登记到某个 ContextEvent 上。 // bindEventMedia 把本轮捕获的媒体变成一等记忆块,直接挂到 ContextEvent 上。
// //
// 双向落地:evt.Media 让事件自己记得引了哪些媒体(随 context.json 持久化), // 块存储在事件自身(随 context.json 持久化),不再写 media_refs:
// media_refs 表让 CAS 侧知道谁在引用(GC 据此判断能不能清)。 // 存活与否由“三层记忆块是否持有这个 digest”决定,不维护引用账本。
// 两边都写才闭环——只写一边的话,要么 GC 会误删仍被记忆引用的内容,
// 要么孤儿永远清不掉。
func (a *Agent) bindEventMedia(evt *ContextEvent, digests []string) { func (a *Agent) bindEventMedia(evt *ContextEvent, digests []string) {
if a.mediaStore == nil || evt == nil || len(digests) == 0 { if a.mediaStore == nil || evt == nil || len(digests) == 0 {
return return
@ -128,26 +174,27 @@ func (a *Agent) bindEventMedia(evt *ContextEvent, digests []string) {
evt.ID = newEventID() evt.ID = newEventID()
} }
for _, d := range digests { for _, d := range digests {
if err := a.mediaStore.AddRef(d, media.OwnerContext, evt.ID); err != nil { b, ok := a.blockFromDigest(d)
log.Printf("[media] AddRef 失败 (%s → %s): %v", shortDigest(d), evt.ID, err) if !ok {
log.Printf("[media] 块构造失败 (%s)", shortDigest(d))
continue continue
} }
evt.Media = append(evt.Media, d) evt.Blocks = append(evt.Blocks, b)
} }
} }
// mediaSummaryForEvent 给已有描述的媒体生成一行文字,供写进 ContextEvent.Input。 // mediaSummaryForEvent 给已有描述的媒体生成一行文字,供写进 ContextEvent.Input。
// //
// 这是方案 C 的落点:**描述文本才是持久语义记忆,blob 只是缓存**。 // 这是方案 C 的落点:**描述文本才是持久语义记忆,blob 只是缓存**。
// blob 可能被容量 GC 淘汰,但描述会一直留在 L0/L2/L3 的文本里, // blob 可能已被删除,但描述会一直留在 L0/L2/L3 的文本里,
// 让"那张紫蓝红三色带图"在几个月后仍然可被检索到。 // 让"那张紫蓝红三色带图"在几个月后仍然可被检索到。
func (a *Agent) mediaSummaryForEvent(digests []string) string { func (a *Agent) mediaSummaryForEvent(blocks []memory.MemoryBlock) string {
if a.mediaStore == nil || len(digests) == 0 { if a.mediaStore == nil || len(blocks) == 0 {
return "" return ""
} }
var lines []string var lines []string
for _, d := range digests { for _, b := range blocks {
if line := a.mediaMarkerLine(d); line != "" { if line := a.mediaMarkerLine(b.PayloadDigest); line != "" {
lines = append(lines, line) lines = append(lines, line)
} }
} }
@ -163,7 +210,7 @@ func (a *Agent) mediaSummaryForEvent(digests []string) string {
// mediaContextForSentences 各拼一份,改动截断长度或分隔符时只改一处, // mediaContextForSentences 各拼一份,改动截断长度或分隔符时只改一处,
// 另一处写出的标记就再也解析不回来——而解析失败是静默的(引用挂不上)。 // 另一处写出的标记就再也解析不回来——而解析失败是静默的(引用挂不上)。
// //
// 查不到返回空串:媒体可能已被容量 GC 淘汰,此时不该造出一条指向虚无的标记。 // 查不到返回空串:媒体可能已被删除,此时不该造出一条指向虚无的标记。
func (a *Agent) mediaMarkerLine(digest string) string { func (a *Agent) mediaMarkerLine(digest string) string {
if a.mediaStore == nil { if a.mediaStore == nil {
return "" return ""

View File

@ -24,7 +24,7 @@ import (
func newTestAgentWithMedia(t *testing.T) (*Agent, *media.Store) { func newTestAgentWithMedia(t *testing.T) (*Agent, *media.Store) {
t.Helper() t.Helper()
dir := t.TempDir() dir := t.TempDir()
ms, err := media.New(filepath.Join(dir, "media"), 0) ms, err := media.New(filepath.Join(dir, "media"))
if err != nil { if err != nil {
t.Fatalf("media.New: %v", err) t.Fatalf("media.New: %v", err)
} }
@ -35,7 +35,6 @@ func newTestAgentWithMedia(t *testing.T) (*Agent, *media.Store) {
mediaStore: ms, mediaStore: ms,
context: NewRelevanceContext(filepath.Join(dir, "context.json"), emb), context: NewRelevanceContext(filepath.Join(dir, "context.json"), emb),
} }
a.context.SetMediaStore(ms)
return a, ms return a, ms
} }
@ -104,10 +103,10 @@ func TestCaptureBlockMedia_NilStoreIsNoop(t *testing.T) {
// bindEventMedia 对 nil store 也必须安全 // bindEventMedia 对 nil store 也必须安全
evt := &ContextEvent{} evt := &ContextEvent{}
a.bindEventMedia(evt, []string{"deadbeef"}) a.bindEventMedia(evt, []string{"deadbeef"})
if len(evt.Media) != 0 || evt.ID != "" { if len(evt.Blocks) != 0 || evt.ID != "" {
t.Fatalf("nil store 时不该改动事件: %+v", evt) t.Fatalf("nil store 时不该改动事件: %+v", evt)
} }
if s := a.mediaSummaryForEvent([]string{"deadbeef"}); s != "" { if s := a.mediaSummaryForEvent(nil); s != "" {
t.Fatalf("nil store 时摘要应为空,得到 %q", s) t.Fatalf("nil store 时摘要应为空,得到 %q", s)
} }
} }
@ -152,7 +151,7 @@ func TestStageDrainMediaDigests(t *testing.T) {
} }
} }
func TestBindEventMedia_CreatesIDAndRefs(t *testing.T) { func TestBindEventMedia_CreatesBlocks(t *testing.T) {
a, ms := newTestAgentWithMedia(t) a, ms := newTestAgentWithMedia(t)
d, err := ms.Put([]byte("img"), media.Item{MIME: "image/png"}) d, err := ms.Put([]byte("img"), media.Item{MIME: "image/png"})
@ -166,17 +165,11 @@ func TestBindEventMedia_CreatesIDAndRefs(t *testing.T) {
if evt.ID == "" { if evt.ID == "" {
t.Fatal("应懒生成事件 ID") t.Fatal("应懒生成事件 ID")
} }
if len(evt.Media) != 1 || evt.Media[0] != d { if len(evt.Blocks) != 1 || evt.Blocks[0].PayloadDigest != d {
t.Fatalf("事件应记住 digest: %+v", evt.Media) t.Fatalf("事件应持有一等记忆块: %+v", evt.Blocks)
} }
// 双向落地:CAS 侧也要知道谁在引用,否则 GC 会误删 if evt.Blocks[0].Modality != memory.BlockImage || evt.Blocks[0].MIME != "image/png" {
it, _ := ms.Stat(d) t.Fatalf("块元数据不对: %+v", evt.Blocks[0])
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))
} }
} }
@ -196,12 +189,16 @@ func TestMediaSummary_DescriptionIsThePersistentMemory(t *testing.T) {
a, ms := newTestAgentWithMedia(t) a, ms := newTestAgentWithMedia(t)
d, _ := ms.Put([]byte("img"), media.Item{MIME: "image/png"}) d, _ := ms.Put([]byte("img"), media.Item{MIME: "image/png"})
if s := a.mediaSummaryForEvent([]string{d}); s == "" { b, ok := a.blockFromDigest(d)
if !ok {
t.Fatal("blockFromDigest 失败")
}
if s := a.mediaSummaryForEvent([]memory.MemoryBlock{b}); s == "" {
t.Fatal("未描述项也应产出一行(标注未描述)") t.Fatal("未描述项也应产出一行(标注未描述)")
} }
ms.Describe(d, "一张紫蓝红三色带图", "visionllm") ms.Describe(d, "一张紫蓝红三色带图", "visionllm")
s := a.mediaSummaryForEvent([]string{d}) s := a.mediaSummaryForEvent([]memory.MemoryBlock{b})
if s == "" { if s == "" {
t.Fatal("应产出摘要") t.Fatal("应产出摘要")
} }
@ -213,97 +210,15 @@ func TestMediaSummary_DescriptionIsThePersistentMemory(t *testing.T) {
} }
} }
func TestPrune_TransfersMediaRefsToDocument(t *testing.T) {
// L0→L2 归档:媒体引用从 context 事件转到归档文档,
// 且转移期间内容必须始终可读(先挂后销,不留归零窗口)。
dir := t.TempDir()
ms, err := media.New(filepath.Join(dir, "media"), 0)
if err != nil {
t.Fatal(err)
}
defer ms.Close()
emb := memory.NewStaticEmbedder()
docStore := document.NewStore(filepath.Join(dir, "docs"))
if err := docStore.Start(); err != nil {
t.Fatal(err)
}
rc := NewRelevanceContext(filepath.Join(dir, "context.json"), emb)
rc.SetMediaStore(ms)
a := &Agent{mediaStore: ms, context: rc}
// 造一张被引用的图,挂到一条会被淘汰的老事件上
payload := []byte("archived-image")
d, _ := ms.Put(payload, media.Item{MIME: "image/png"})
oldEvt := ContextEvent{
Timestamp: time.Now().Add(-time.Hour),
Source: "qq",
Input: "很久以前的一张图",
}
a.bindEventMedia(&oldEvt, []string{d})
oldEvtID := oldEvt.ID
rc.Append(oldEvt)
// 再塞满 12 条新事件,逼 Prune 把老事件淘汰
// (Prune 保护最近 10 条,topK 传 5 使候选全部进归档)
for i := 0; i < 12; i++ {
rc.Append(ContextEvent{
Timestamp: time.Now().Add(time.Duration(i) * time.Second),
Source: "qq",
Input: "无关内容",
})
}
archived := rc.Prune("完全不相关的查询", 5, docStore)
if archived == 0 {
t.Fatal("应有事件被归档")
}
// 关键断言:内容仍可读(引用被转走而非归零后被清)
got, err := ms.Get(d)
if err != nil {
t.Fatalf("归档后内容应仍可读: %v", err)
}
if string(got) != string(payload) {
t.Fatal("内容被改")
}
it, err := ms.Stat(d)
if err != nil {
t.Fatal(err)
}
if it.RefCount < 1 {
t.Fatalf("引用应转移而非归零,实际 refcount=%d", it.RefCount)
}
// 原 context 引用应已注销
if refs, _ := ms.Refs(media.OwnerContext, oldEvtID); len(refs) != 0 {
t.Fatalf("原事件引用应已注销,仍有 %d 条", len(refs))
}
// 应挂到某个 document owner 上
var docOwned bool
docs := docStore.RecentDocs(10)
for _, doc := range docs {
if refs, _ := ms.Refs(media.OwnerDocument, doc.ID); len(refs) > 0 {
docOwned = true
break
}
}
if !docOwned {
t.Fatal("引用应已挂到归档文档上")
}
}
func TestPrune_NilMediaStoreStillArchives(t *testing.T) { func TestPrune_NilMediaStoreStillArchives(t *testing.T) {
// 媒体存储未启用时归档链路必须照常工作 // 媒体存储未启用时归档链路必须照常工作
dir := t.TempDir() dir := t.TempDir()
emb := memory.NewStaticEmbedder() emb := memory.NewStaticEmbedder()
docStore := document.NewStore(filepath.Join(dir, "docs")) docStore := document.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords)
if err := docStore.Start(); err != nil { if err := docStore.Start(); err != nil {
t.Fatal(err) t.Fatal(err)
} }
rc := NewRelevanceContext(filepath.Join(dir, "context.json"), emb) rc := NewRelevanceContext(filepath.Join(dir, "context.json"), emb)
// 刻意不 SetMediaStore
for i := 0; i < 15; i++ { for i := 0; i < 15; i++ {
rc.Append(ContextEvent{ rc.Append(ContextEvent{
@ -317,13 +232,13 @@ func TestPrune_NilMediaStoreStillArchives(t *testing.T) {
} }
} }
func TestContextEvent_MediaFieldRoundTrip(t *testing.T) { func TestContextEvent_BlocksFieldRoundTrip(t *testing.T) {
// context.json 加字段必须向后兼容:存量文件读回来 Media 为空、ID 为空, // context.json 加字段必须向后兼容:存量文件读回来 Blocks 为空、ID 为空,
// 不影响任何既有行为。 // 不影响任何既有行为。
dir := t.TempDir() dir := t.TempDir()
path := filepath.Join(dir, "context.json") path := filepath.Join(dir, "context.json")
// 写一份"存量格式"(无 id / media 字段) // 写一份"存量格式"(无 id / blocks 字段)
legacy := `[{"timestamp":"2026-09-04T10:00:00Z","source":"qq","input":"老数据","response":"回复"}]` legacy := `[{"timestamp":"2026-09-04T10:00:00Z","source":"qq","input":"老数据","response":"回复"}]`
if err := os.WriteFile(path, []byte(legacy), 0644); err != nil { if err := os.WriteFile(path, []byte(legacy), 0644); err != nil {
t.Fatal(err) t.Fatal(err)
@ -335,8 +250,8 @@ func TestContextEvent_MediaFieldRoundTrip(t *testing.T) {
t.Fatalf("应读回 1 条,实际 %d", rc.Len()) t.Fatalf("应读回 1 条,实际 %d", rc.Len())
} }
// 新写入带媒体的事件,再读回 // 新写入带记忆块的事件,再读回
ms, err := media.New(filepath.Join(dir, "media"), 0) ms, err := media.New(filepath.Join(dir, "media"))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -352,4 +267,71 @@ func TestContextEvent_MediaFieldRoundTrip(t *testing.T) {
if rc2.Len() != 2 { if rc2.Len() != 2 {
t.Fatalf("应有 2 条,实际 %d", rc2.Len()) t.Fatalf("应有 2 条,实际 %d", rc2.Len())
} }
var persisted int
for _, e := range rc2.Recent(10) {
persisted += len(e.Blocks)
}
if persisted != 1 {
t.Fatalf("块应随 context.json 持久化,实际 %d 个", persisted)
}
}
func TestPruneMigratesBlocksToDocument(t *testing.T) {
// 一等记忆块的 L0→L2 迁移:块随事件离开 Context、进入 Document,
// 身份(ID/模态/digest/向量)原样保留;同一块不能同时留在两层。
// 这条路径不依赖 media_refs/ref_count。
dir := t.TempDir()
emb := memory.NewStaticEmbedder()
docStore := document.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords)
if err := docStore.Start(); err != nil {
t.Fatal(err)
}
rc := NewRelevanceContext(filepath.Join(dir, "context.json"), emb)
block := memory.MemoryBlock{
ID: "blk_migrate_1", Modality: memory.BlockImage,
PayloadDigest: "deadbeef", MIME: "image/png", Size: 42,
Vector: []float64{0.1, 0.2, 0.3}, Fingerprint: "qwen:test",
}
rc.Append(ContextEvent{
Timestamp: time.Now().Add(-time.Hour),
Source: "qq", Input: "很久以前的一张图",
Blocks: []memory.MemoryBlock{block},
})
for i := 0; i < 12; i++ {
rc.Append(ContextEvent{
Timestamp: time.Now().Add(time.Duration(i) * time.Second),
Source: "qq", Input: "无关内容",
})
}
if n := rc.Prune("完全不相关的查询", 5, docStore); n == 0 {
t.Fatal("应有事件被归档")
}
// 块应已到达 L2,且身份不变。
var found *document.Doc
for _, d := range docStore.RecentDocs(20) {
if len(d.Blocks) > 0 {
found = d
break
}
}
if found == nil {
t.Fatal("归档文档应持有一等记忆块")
}
if len(found.Blocks) != 1 {
t.Fatalf("文档应有 1 个块,实际 %d", len(found.Blocks))
}
got := found.Blocks[0]
if got.ID != block.ID || got.Modality != block.Modality || got.PayloadDigest != block.PayloadDigest || got.Fingerprint != block.Fingerprint || len(got.Vector) != len(block.Vector) {
t.Fatalf("块身份应原样迁移:\n got %+v\n want %+v", got, block)
}
// 同一块不能同时留在 L0。
for _, e := range rc.Recent(100) {
if len(e.Blocks) > 0 {
t.Fatalf("块仍留在 L0(同一块同时存在于两层): %+v", e.Blocks)
}
}
} }

View File

@ -150,11 +150,11 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall) string {
} }
parts = append(parts, fmt.Sprintf("- %s →(%s)→ %s", r.SourceName, r.RelationType, r.TargetName)) parts = append(parts, fmt.Sprintf("- %s →(%s)→ %s", r.SourceName, r.RelationType, r.TargetName))
} }
// 命中的关系若挂着媒体,把媒体说明附在结果末尾。 // 命中的关系若挂着媒体块,把媒体说明附在结果末尾。
// //
// 关系行只有实体名和关系类型,看不出"这条记忆当时还带了一张图"。 // 关系行只有实体名和关系类型,看不出"这条记忆当时还带了一张图"。
// 媒体挂在句子上(graph_sentence owner),需经关系→句子→media_refs // 媒体块以结构边与句子相连,需经关系→句子反查。
// 反查。不附上的后果:agent 显式查了图记忆,却仍然不知道有图。 // 不附上的后果:agent 显式查了图记忆,却仍然不知道有图。
if mc := a.mediaContextForRelations(result.Relations); mc != "" { if mc := a.mediaContextForRelations(result.Relations); mc != "" {
parts = append(parts, "", "关联媒体:", mc) parts = append(parts, "", "关联媒体:", mc)
} }
@ -192,7 +192,7 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall) string {
} }
// 模型显式关联的媒体:标记由内核补进句子文本,模型不必知道格式。 // 模型显式关联的媒体:标记由内核补进句子文本,模型不必知道格式。
// 没有 sentence_text 时 sentenceWithMediaMarkers 会用标记本身 // 没有 sentence_text 时 sentenceWithMediaMarkers 会用标记本身
// 充当句子——媒体必须有句子落点,否则 media_refs 无从挂起。 // 充当句子——媒体必须有句子落点,否则块边无法建立。
if digests := getStringSlice(m, "media_digests"); len(digests) > 0 { if digests := getStringSlice(m, "media_digests"); len(digests) > 0 {
t.SentenceText = a.sentenceWithMediaMarkers(t.SentenceText, digests) t.SentenceText = a.sentenceWithMediaMarkers(t.SentenceText, digests)
} }
@ -206,7 +206,7 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall) string {
} }
// remember 工具是用户/模型显式写入,不涉及归档删除, // remember 工具是用户/模型显式写入,不涉及归档删除,
// 因此不需要 mediaBound——没有旧引用要释放。 // 因此不需要 mediaBound——没有旧引用要释放。
ec, rc, mb, err := a.commitTriplesWithMedia(triples, string(a.id), 0) ec, rc, mb, err := a.commitTriplesWithMedia(triples, string(a.id), 0, nil)
if err != nil { if err != nil {
return fmt.Sprintf("记忆写入失败: %v", err) return fmt.Sprintf("记忆写入失败: %v", err)
} }
@ -576,15 +576,17 @@ func (a *Agent) executeDocTool(tc agentAPI.ToolCall) string {
// Summary+Content 计算,标记进不去正文就检索不到这份媒体。 // Summary+Content 计算,标记进不去正文就检索不到这份媒体。
mediaDigests := a.resolveMediaDigests(getStringSlice(tc.Arguments, "media_digests")) mediaDigests := a.resolveMediaDigests(getStringSlice(tc.Arguments, "media_digests"))
doc.Content = a.sentenceWithMediaMarkers(doc.Content, mediaDigests) doc.Content = a.sentenceWithMediaMarkers(doc.Content, mediaDigests)
for _, d := range mediaDigests {
if b, ok := a.blockFromDigest(d); ok {
doc.Blocks = append(doc.Blocks, b)
}
}
if err := a.docStore.Insert(doc); err != nil { if err := a.docStore.Insert(doc); err != nil {
return fmt.Sprintf("文档写入失败: %v", err) return fmt.Sprintf("文档写入失败: %v", err)
} }
// 引用必须在拿到 doc.ID 之后挂:owner_id 就是文档 id。 if n := len(doc.Blocks); n > 0 {
// 不挂的后果是这些媒体在文档里可见却无主,下一轮 GC 会把它们清掉。 return fmt.Sprintf("文档已提交 (id: %s, 摘要: %s, 关联 %d 份媒体)", doc.ID, summary, n)
bound := a.bindDocMedia(doc.ID, mediaDigests)
if bound > 0 {
return fmt.Sprintf("文档已提交 (id: %s, 摘要: %s, 关联 %d 份媒体)", doc.ID, summary, bound)
} }
return fmt.Sprintf("文档已提交 (id: %s, 摘要: %s)", doc.ID, summary) return fmt.Sprintf("文档已提交 (id: %s, 摘要: %s)", doc.ID, summary)

View File

@ -19,7 +19,7 @@ func (a *Agent) buildMemoryContext(input string, maxTokens int) string {
// 不做这一步的后果:媒体描述进了 L3,agent 却拿不出来。图库句子里 // 不做这一步的后果:媒体描述进了 L3,agent 却拿不出来。图库句子里
// 写着 [image/png a1b2c3d4e5f6] 这样的短标记,但没有任何东西告诉 // 写着 [image/png a1b2c3d4e5f6] 这样的短标记,但没有任何东西告诉
// 模型那份内容是否还在、能否重新查看——描述永存而 blob 可能已被 // 模型那份内容是否还在、能否重新查看——描述永存而 blob 可能已被
// 容量 GC 淘汰,两者状态不同,必须显式告知。 // 删除,两者状态不同,必须显式告知。
// //
// 注意不能直接用 injected.Relations:BuildContext 刻意把它置为 nil // 注意不能直接用 injected.Relations:BuildContext 刻意把它置为 nil
//(自动注入只给实体索引以省 token,细节留给 memory_recall)。 //(自动注入只给实体索引以省 token,细节留给 memory_recall)。

View File

@ -649,14 +649,11 @@ func (r *ConfigRegistry) seedCoreDefs(dataDir string) {
reg(ConfigDef{Key: "core.memory.graph", Default: filepath.Join(dataDir, "memory", "graph.db"), Type: "string", DisplayName: "图数据库路径", Description: "长期记忆(图数据库)存储路径", Category: "paths"}) reg(ConfigDef{Key: "core.memory.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.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.documents", Default: filepath.Join(dataDir, "memory", "documents"), Type: "string", DisplayName: "文档记忆路径", Description: "文档记忆存储目录", Category: "paths"})
reg(ConfigDef{Key: "core.memory.media.enabled", Default: "true", Type: "bool", DisplayName: "媒体记忆", Description: "把对话里出现的图片/音频按内容摘要(sha256)落盘去重,记忆各层只记 digest。关闭后媒体仅在当前对话内可见,下一轮起只剩路径或 alt 文本", Category: "memory"}) reg(ConfigDef{Key: "core.memory.media.enabled", Default: "true", Type: "bool", DisplayName: "媒体记忆", Description: "把对话里出现的图片/音频变成一等记忆块,内容按 sha256 落盘去重。关闭后媒体仅在当前对话内可见,下一轮起只剩路径或 alt 文本", Category: "memory"})
reg(ConfigDef{Key: "core.memory.media.dir", Default: filepath.Join(dataDir, "memory", "media"), Type: "string", DisplayName: "媒体存储路径", Description: "媒体内容寻址存储目录(内含 media.db 与 blobs/)", Category: "paths"}) reg(ConfigDef{Key: "core.memory.media.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.describe_on_ingest", Default: "false", Type: "bool", DisplayName: "自动描述媒体", Description: "后台用视觉/音频模型给未描述的媒体生成文字描述。描述文本是可检索的语义入口,供各层记忆复用。代价是消耗视觉模型配额(单张图实测约 10s),故默认关闭;开启后每 30s 最多处理 4 条,不跟对话抢额度", 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.multimodal_space.type", Default: "", Type: "string", DisplayName: "多模态向量空间类型", Description: "可插拔多模态向量空间的实现类型。onnx = 内嵌 Qwen3-VL 完整图文共享模型;http = 外部向量 API 服务。留空禁用多模态向量检索,只保留 fastText 文本路径。两条路径共享同一套 L0/L2/L3 向量缓存与检索基础设施。", 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.multimodal_space.onnx.model_dir", Default: "", Type: "string", DisplayName: "Qwen 多模态 ONNX 目录", Description: "完整 Qwen3-VL 图文共享向量模型目录(含 TokenEmbedding.onnx、Transformer.onnx、Vision.onnx、外部权重、tokenizer.json、embed_config.json)。type=onnx 时必填,修改后需重启生效。", Category: "memory"})
reg(ConfigDef{Key: "core.memory.media.describe_on_ingest", Default: "false", Type: "bool", DisplayName: "自动描述媒体", Description: "后台用视觉/音频模型给未描述的媒体生成文字描述。**描述文本才是持久语义记忆**——blob 会被容量 GC 淘汰,描述会随记忆各层一直留存并可检索。代价是消耗视觉模型配额(单张图实测约 10s),故默认关闭;开启后每 30s 最多处理 4 条,不跟对话抢额度", Category: "memory"})
reg(ConfigDef{Key: "core.memory.media.clip_model_dir", Default: "", Type: "string", DisplayName: "CLIP ONNX 模型目录", Description: "多模态嵌入的 CLIP ONNX 模型目录(含 text.onnx、vision.onnx、clip_config.json、tokenizer.json、merges.txt)。multimodal_space.type=onnx 时从此目录加载内嵌模型;留空且 type=onnx 时禁用多模态向量检索。修改后需重启生效。", Category: "memory"})
reg(ConfigDef{Key: "core.memory.multimodal_space.type", Default: "", Type: "string", DisplayName: "多模态向量空间类型", Description: "可插拔多模态向量空间的实现类型。onnx = 内嵌 ONNX 模型(CLIP 等);http = 外部向量 API 服务。留空禁用多模态向量检索,只保留 fastText 文本路径。两条路径共享同一套 L0/L2/L3 向量缓存与检索基础设施。", Category: "memory"})
reg(ConfigDef{Key: "core.memory.multimodal_space.http.endpoint", Default: "", Type: "string", DisplayName: "外部向量 API 端点", Description: "外部多模态向量服务的 HTTP 端点 URL(POST,接受 modality/side/text/data/mime,返回 embedding)。type=http 时必填。", Category: "memory"}) reg(ConfigDef{Key: "core.memory.multimodal_space.http.endpoint", Default: "", Type: "string", DisplayName: "外部向量 API 端点", Description: "外部多模态向量服务的 HTTP 端点 URL(POST,接受 modality/side/text/data/mime,返回 embedding)。type=http 时必填。", Category: "memory"})
reg(ConfigDef{Key: "core.memory.multimodal_space.http.api_key", Default: "", Type: "string", DisplayName: "外部向量 API 密钥", Description: "外部多模态向量服务的 API 密钥(作为 Bearer token 发送)。可选。", Category: "memory"}) reg(ConfigDef{Key: "core.memory.multimodal_space.http.api_key", Default: "", Type: "string", DisplayName: "外部向量 API 密钥", Description: "外部多模态向量服务的 API 密钥(作为 Bearer token 发送)。可选。", Category: "memory"})
reg(ConfigDef{Key: "core.memory.multimodal_space.http.model", Default: "", Type: "string", DisplayName: "外部向量模型标识", Description: "外部向量服务使用的模型名称,作为 vec_model 持久化。模型切换后历史向量会自动重算。", Category: "memory"}) reg(ConfigDef{Key: "core.memory.multimodal_space.http.model", Default: "", Type: "string", DisplayName: "外部向量模型标识", Description: "外部向量服务使用的模型名称,作为 vec_model 持久化。模型切换后历史向量会自动重算。", Category: "memory"})

View File

@ -217,3 +217,40 @@ func (g *GraphDB) MemoryBlockEdges() ([]MemoryBlockEdge, error) {
} }
return edges, rows.Err() return edges, rows.Err()
} }
// BlocksForNode 返回与某个图节点通过任意边相连的一等记忆块。
// 例:sentence --contains--> block;entity --depicts--> block。
func (g *GraphDB) BlocksForNode(nodeKind, nodeID string) ([]MemoryBlock, error) {
g.mu.RLock()
defer g.mu.RUnlock()
rows, err := g.db.Query(`SELECT b.id, b.modality, b.text_content, b.payload_digest,
b.mime, b.size, b.width, b.height, b.vector, b.fingerprint, b.source, b.tool,
b.created_at, b.updated_at
FROM memory_block_edges e
JOIN memory_blocks b ON (
(e.source_kind = 'block' AND e.source_id = b.id AND e.target_kind = ? AND e.target_id = ?)
OR (e.target_kind = 'block' AND e.target_id = b.id AND e.source_kind = ? AND e.source_id = ?))
ORDER BY b.created_at, b.id`, nodeKind, nodeID, nodeKind, nodeID)
if err != nil {
return nil, err
}
defer rows.Close()
var blocks []MemoryBlock
for rows.Next() {
var block MemoryBlock
var vectorJSON string
if err := rows.Scan(&block.ID, &block.Modality, &block.Text, &block.PayloadDigest,
&block.MIME, &block.Size, &block.Width, &block.Height, &vectorJSON,
&block.Fingerprint, &block.Source, &block.Tool, &block.CreatedAt,
&block.UpdatedAt); err != nil {
return nil, err
}
if vectorJSON != "" && vectorJSON != "null" {
if err := json.Unmarshal([]byte(vectorJSON), &block.Vector); err != nil {
return nil, fmt.Errorf("decode memory block %s vector: %w", block.ID, err)
}
}
blocks = append(blocks, block)
}
return blocks, rows.Err()
}

View File

@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"log" "log"
"math"
"os" "os"
"path/filepath" "path/filepath"
"sort" "sort"
@ -13,6 +14,7 @@ import (
"gitcode.com/JianFeeeee/HomeAgent/internal/memory" "gitcode.com/JianFeeeee/HomeAgent/internal/memory"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector" "gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector"
"gitcode.com/JianFeeeee/HomeAgent/internal/tfidf"
) )
// ChannelCleaner 按事件来源查找输入通道的 Cleaner 函数。 // ChannelCleaner 按事件来源查找输入通道的 Cleaner 函数。
@ -21,51 +23,97 @@ type ChannelCleaner func(source string) func(string) string
// Doc — 记忆文档:由上下文提炼而来 // Doc — 记忆文档:由上下文提炼而来
type Doc struct { type Doc struct {
ID string `json:"id"` ID string `json:"id"`
Summary string `json:"summary"` Summary string `json:"summary"`
Content string `json:"content"` Content string `json:"content"`
Tags []string `json:"tags"` Tags []string `json:"tags"`
Entities []string `json:"entities"` Entities []string `json:"entities"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
Source string `json:"source"` // context / graph / manual Source string `json:"source"`
Meta map[string]string `json:"meta,omitempty"` Meta map[string]string `json:"meta,omitempty"`
AccessCount int `json:"access_count"` // 访问次数 AccessCount int `json:"access_count"`
LastAccess time.Time `json:"last_access"` // 最后访问时间 LastAccess time.Time `json:"last_access"`
// Media 是这篇 L2 文档原生持有的多模态块 digest。坐标保存在 media.Store, Blocks []memory.MemoryBlock `json:"blocks,omitempty"` // 一等记忆块(text/image/video/audio)
// 文档只持引用;被 Consume 召回到 L0 或归档到 L3 时必须随文本一起迁移。 Vector tfidf.Vector `json:"vector,omitempty"` // TF-IDF 稀疏向量(fallback 时持久化)
Media []string `json:"media,omitempty"` DenseVec []float64 `json:"dense_vec,omitempty"` // 多模态稠密向量(主路径)
Vector vector.Vector `json:"vector,omitempty"` // 预计算文本向量(TF-IDF 稀疏,与 context 同空间)
DenseVec []float64 `json:"dense_vec,omitempty"` // 多模态稠密向量(与媒体共享空间)
} }
// Store — 文档记忆存储,包含向量索引 // Store — 文档记忆存储。
// 主路径:denseSpace(稠密多模态向量,与媒体共享空间)。
// Fallback:tfidf(TF-IDF 倒排索引,仅稠密空间不可用时加载)。
type Store struct { type Store struct {
dir string dir string
vec *vector.Store mu sync.RWMutex
veczer *vector.TFIDFVectorizer docs map[string]*Doc
mu sync.RWMutex
docs map[string]*Doc
summaries []string // 用于训练向量化器,最大 10000 条
vectorizer vector.Vectorizer // 可选:与 context 同空间的向量化器
denseSpace vector.MultimodalEmbedder // 可选:稠密多模态向量空间
dirty bool dirty bool
// fallback 路径(仅稠密空间不可用时加载)
tfidfEmb *tfidf.Embedder
tfidfIdx *tfidf.SearchableIndex
trainTexts []string // 缓存训练文本,延迟训练
tfidfOnce sync.Once
// 主路径
denseSpace vector.MultimodalEmbedder
} }
// SetDenseSpace 设置稠密多模态向量空间。配置后文档检索使用稠密余弦(brute-force), const maxSummaries = 10000
// 与媒体检索共享同一向量空间,实现真正的统一跨模态检索。
// NewStore 创建文档存储。tokenizer 由外层注入(如 jieba),核心不直接依赖分词库。
func NewStore(dir string, tokenizer tfidf.Tokenizer) *Store {
return &Store{
dir: dir,
docs: make(map[string]*Doc),
// tfidf 延迟初始化:只在需要 fallback 时创建
tfidfEmb: tfidf.NewEmbedder(tokenizer, 4096),
}
}
// ensureTFIDF 延迟初始化 TF-IDF 索引(仅 fallback 路径)。
// 调用方已持有 s.mu。
func (s *Store) ensureTFIDF() {
s.tfidfOnce.Do(func() {
s.tfidfIdx = tfidf.NewSearchableIndex(s.tfidfEmb)
// 延迟训练:用缓存的文本建立索引
texts := make(map[string]string, len(s.trainTexts)/2)
for i := 0; i+1 < len(s.trainTexts); i += 2 {
texts[s.trainTexts[i]] = s.trainTexts[i+1]
}
s.tfidfIdx.Train(texts)
s.trainTexts = nil // 释放缓存
s.tfidfEmb.Train(func() []string {
out := make([]string, 0, len(texts))
for _, t := range texts {
out = append(out, t)
}
return out
}())
log.Printf("[document memory] tfidf fallback loaded: %d docs", len(texts))
})
}
func (s *Store) Start() error {
if err := os.MkdirAll(s.dir, 0755); err != nil {
return fmt.Errorf("document store dir: %w", err)
}
if err := s.loadAll(); err != nil {
log.Printf("[document memory] load error: %v", err)
}
log.Printf("[document memory] started with %d docs", len(s.docs))
return nil
}
func (s *Store) Stop() { s.flush() }
// SetDenseSpace 设置稠密多模态向量空间(主路径)。
func (s *Store) SetDenseSpace(ds vector.MultimodalEmbedder) { func (s *Store) SetDenseSpace(ds vector.MultimodalEmbedder) {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
s.denseSpace = ds s.denseSpace = ds
} }
// buildDenseIndex 为所有文档计算稠密向量并建立 brute-force 索引。 // BuildDenseIndex 为所有文档计算稠密向量。
// 在启动时或配置变更后调用一次。492 篇文档 brute-force ~300ms,可接受。
// BuildDenseIndex 为所有文档计算稠密向量并建立 brute-force 索引。
// 在启动时或配置变更后调用一次。492 篇文档 brute-force ~300ms,可接受。
func (s *Store) BuildDenseIndex(ds vector.MultimodalEmbedder) { func (s *Store) BuildDenseIndex(ds vector.MultimodalEmbedder) {
if ds == nil || !ds.Loaded() { if ds == nil || !ds.Loaded() {
return return
@ -76,7 +124,7 @@ func (s *Store) BuildDenseIndex(ds vector.MultimodalEmbedder) {
count := 0 count := 0
for _, doc := range s.docs { for _, doc := range s.docs {
if doc.DenseVec != nil && len(doc.DenseVec) == ds.Dim() { if doc.DenseVec != nil && len(doc.DenseVec) == ds.Dim() {
continue // 已有向量,跳过 continue
} }
text := doc.Summary + " " + doc.Content text := doc.Summary + " " + doc.Content
vec, err := ds.VectorizeDense(text) vec, err := ds.VectorizeDense(text)
@ -90,8 +138,225 @@ func (s *Store) BuildDenseIndex(ds vector.MultimodalEmbedder) {
log.Printf("[document memory] dense index built: %d new vectors", count) log.Printf("[document memory] dense index built: %d new vectors", count)
} }
// denseSearchScored 对所有文档做 brute-force 余弦检索,返回 topK 个最相似的候选。 // Reindex 重建 TF-IDF 索引(fallback 路径变更时调用)。
// 仅在 denseSpace 配置后使用;未配置时退化到 TF-IDF 倒排检索。 func (s *Store) Reindex() {
s.mu.Lock()
defer s.mu.Unlock()
s.tfidfOnce = sync.Once{} // 重置延迟初始化
texts := make(map[string]string, len(s.docs))
for _, doc := range s.docs {
texts[doc.ID] = doc.Summary + " " + doc.Content
}
// 缓存文本供 ensureTFIDF 延迟训练
s.trainTexts = make([]string, 0, len(texts)*2)
for id, t := range texts {
s.trainTexts = append(s.trainTexts, id, t)
}
s.ensureTFIDF()
}
func (s *Store) Insert(doc *Doc) error {
s.mu.Lock()
defer s.mu.Unlock()
if doc.ID == "" {
doc.ID = fmt.Sprintf("doc_%d", time.Now().UnixNano())
doc.CreatedAt = time.Now()
}
doc.UpdatedAt = time.Now()
doc.LastAccess = time.Now()
if doc.AccessCount == 0 {
doc.AccessCount = 1
}
s.docs[doc.ID] = doc
text := doc.Summary + " " + doc.Content
// 主路径:稠密向量
if s.denseSpace != nil && s.denseSpace.Loaded() && len(doc.DenseVec) == 0 {
if dv, err := s.denseSpace.VectorizeDense(text); err == nil {
doc.DenseVec = dv
}
}
// Fallback 路径:缓存文本,延迟训练
if s.tfidfIdx != nil {
s.tfidfIdx.Add(doc.ID, text)
} else {
s.trainTexts = append(s.trainTexts, doc.ID, text)
}
path := filepath.Join(s.dir, doc.ID+".json")
data, _ := json.MarshalIndent(doc, "", " ")
os.WriteFile(path, data, 0644)
s.dirty = true
return nil
}
// ContextToDoc 将上下文对话历史提炼为文档。
func (s *Store) ContextToDoc(source string, entries []ContextEntry, _ interface{}, cleanFn func(string) string, toolCleanFn func(name, output string) string, channelCleaner ChannelCleaner) (*Doc, error) {
if len(entries) == 0 {
return nil, nil
}
if cleanFn == nil {
cleanFn = func(text string) string { return text }
}
var parts []string
for _, e := range entries {
line := fmt.Sprintf("[%s] %s: %s", e.Timestamp.Format("15:04"), e.Source, e.Content)
if e.Response != "" {
line += fmt.Sprintf(" → %s", truncate(e.Response, 100))
}
for _, tr := range e.ToolResults {
line += fmt.Sprintf("\n [工具] %s: %s", tr.Name, truncate(tr.Output, 200))
}
parts = append(parts, line)
}
content := strings.Join(parts, "\n")
contentHash := simpleHash(content)
summary := summarizeEntries(entries, cleanFn, toolCleanFn, channelCleaner)
tags := extractTags(entries, cleanFn, toolCleanFn, channelCleaner)
entities := extractEntities(entries, cleanFn, toolCleanFn, channelCleaner)
s.mu.Lock()
defer s.mu.Unlock()
for _, d := range s.docs {
if d.Meta != nil && d.Meta["content_hash"] == contentHash {
d.UpdatedAt = time.Now()
d.LastAccess = time.Now()
d.Content = content
d.Source = source
d.Summary = summary
d.Tags = tags
d.Entities = entities
d.Blocks = blocksFromEntries(entries)
s.dirty = true
return d, nil
}
}
id := fmt.Sprintf("doc_%d", time.Now().UnixNano())
meta := map[string]string{"content_hash": contentHash}
if source == "context_archived" {
meta["is_archived_context"] = "true"
}
doc := &Doc{
ID: id, Summary: summary, Content: content, Tags: tags,
Entities: entities, CreatedAt: time.Now(), UpdatedAt: time.Now(),
LastAccess: time.Now(), AccessCount: 1, Source: source, Meta: meta,
Blocks: blocksFromEntries(entries),
}
s.docs[id] = doc
text := summary + " " + content
if s.tfidfIdx != nil {
s.tfidfIdx.Add(id, text)
} else {
s.trainTexts = append(s.trainTexts, id, text)
}
path := filepath.Join(s.dir, id+".json")
data, _ := json.MarshalIndent(doc, "", " ")
os.WriteFile(path, data, 0644)
s.dirty = true
return doc, nil
}
// Consume 向量相似度查询并移除文档
func (s *Store) Consume(text string, topK int) []*Doc {
s.mu.Lock()
defer s.mu.Unlock()
if topK <= 0 {
topK = 5
}
// 主路径:稠密检索
if s.denseSpace != nil && s.denseSpace.Loaded() {
if qv, err := s.denseSpace.VectorizeDense(text); err == nil {
results := s.denseSearchScored(qv, topK)
var docs []*Doc
for _, r := range results {
if d, ok := s.docs[r.Doc.ID]; ok {
s.removeDoc(r.Doc.ID)
s.dirty = true
docs = append(docs, d)
}
}
return docs
}
}
// Fallback:TF-IDF 倒排检索(延迟初始化)
s.ensureTFIDF()
results := s.tfidfIdx.Search(text, topK)
var docs []*Doc
for _, r := range results {
if d, ok := s.docs[r.ID]; ok {
s.removeDoc(r.ID)
s.dirty = true
docs = append(docs, d)
}
}
return docs
}
func (s *Store) Query(text string, topK int) []*Doc {
hits := s.QueryScored(text, topK)
out := make([]*Doc, len(hits))
for i, h := range hits {
out[i] = h.Doc
}
return out
}
// DocHit 是一篇文档记忆的相似度候选及原始分数。
type DocHit struct {
Doc *Doc
Score float64
}
func (s *Store) QueryScored(text string, topK int) []DocHit {
s.mu.RLock()
defer s.mu.RUnlock()
if topK <= 0 {
topK = 5
}
// 主路径
if s.denseSpace != nil && s.denseSpace.Loaded() {
if qv, err := s.denseSpace.VectorizeDense(text); err == nil {
results := s.denseSearchScored(qv, topK)
for i := range results {
if d, ok := s.docs[results[i].Doc.ID]; ok {
d.AccessCount++
d.LastAccess = time.Now()
results[i].Doc = d
}
}
return results
}
}
// Fallback(需要写锁来 ensureTFIDF)
s.mu.RUnlock()
s.mu.Lock()
s.ensureTFIDF()
s.mu.Unlock()
s.mu.RLock()
results := s.tfidfIdx.Search(text, topK)
var out []DocHit
for _, r := range results {
if d, ok := s.docs[r.ID]; ok {
d.AccessCount++
d.LastAccess = time.Now()
out = append(out, DocHit{Doc: d, Score: r.Score})
}
}
return out
}
func (s *Store) denseSearchScored(queryVec []float64, topK int) []DocHit { func (s *Store) denseSearchScored(queryVec []float64, topK int) []DocHit {
if len(queryVec) == 0 { if len(queryVec) == 0 {
return nil return nil
@ -101,13 +366,11 @@ func (s *Store) denseSearchScored(queryVec []float64, topK int) []DocHit {
score float64 score float64
} }
var results []scored var results []scored
s.mu.RLock()
defer s.mu.RUnlock()
for _, doc := range s.docs { for _, doc := range s.docs {
if len(doc.DenseVec) != len(queryVec) { if len(doc.DenseVec) != len(queryVec) {
continue continue
} }
score := vector.DenseCosine(queryVec, doc.DenseVec) score := denseCosine(queryVec, doc.DenseVec)
if score > 0.01 { if score > 0.01 {
results = append(results, scored{doc.ID, score}) results = append(results, scored{doc.ID, score})
} }
@ -126,332 +389,36 @@ func (s *Store) denseSearchScored(queryVec []float64, topK int) []DocHit {
return out return out
} }
// denseCosine 计算两个 []float64 向量的余弦相似度(已迁移到 vector.DenseCosine,此处保留兼容)。
func denseCosine(a, b []float64) float64 { func denseCosine(a, b []float64) float64 {
return vector.DenseCosine(a, b) var dot, na, nb float64
} for i := range a {
dot += a[i] * b[i]
func (s *Store) SetVectorizer(v vector.Vectorizer) { na += a[i] * a[i]
s.vectorizer = v nb += b[i] * b[i]
}
// ReindexWithVectorizer 用给定的向量化器重建所有文档的向量索引
func (s *Store) ReindexWithVectorizer(v vector.Vectorizer) {
s.mu.Lock()
defer s.mu.Unlock()
log.Printf("[document memory] reindex with vectorizer (%d docs)", len(s.docs))
s.vec = vector.NewStore()
for _, doc := range s.docs {
doc.Vector = v.Vectorize(doc.Summary + " " + doc.Content)
s.vec.Insert(doc.ID, doc.Summary, doc.Vector, doc.Meta)
} }
log.Printf("[document memory] reindex with vectorizer complete (%d vectors)", s.vec.Size()) if na == 0 || nb == 0 {
} return 0
const maxSummaries = 10000
func NewStore(dir string) *Store {
return &Store{
dir: dir,
vec: vector.NewStore(),
veczer: vector.NewTFIDFVectorizer(memory.TokenizeWords),
docs: make(map[string]*Doc),
} }
} return dot / math.Sqrt(na*nb)
func (s *Store) Start() error {
if err := os.MkdirAll(s.dir, 0755); err != nil {
return fmt.Errorf("document store dir: %w", err)
}
if err := s.loadAll(); err != nil {
log.Printf("[document memory] load error: %v", err)
}
log.Printf("[document memory] started with %d docs, %d vectors", len(s.docs), s.vec.Size())
return nil
}
func (s *Store) Stop() {
s.flush()
}
// Insert 创建/更新文档
func (s *Store) Insert(doc *Doc) error {
s.mu.Lock()
defer s.mu.Unlock()
if doc.ID == "" {
doc.ID = fmt.Sprintf("doc_%d", time.Now().UnixNano())
doc.CreatedAt = time.Now()
}
doc.UpdatedAt = time.Now()
doc.LastAccess = time.Now()
if doc.AccessCount == 0 {
doc.AccessCount = 1
}
s.docs[doc.ID] = doc
// 增量训练向量化器并加入向量索引
s.addSummary(doc.Summary)
vec := doc.Vector
if vec == nil {
vec = s.veczer.Vectorize(doc.Summary + " " + doc.Content)
}
s.vec.Insert(doc.ID, doc.Summary, vec, doc.Meta)
// 若配置了稠密空间,为新文档计算稠密向量
if s.denseSpace != nil && s.denseSpace.Loaded() && len(doc.DenseVec) == 0 {
if dv, err := s.denseSpace.VectorizeDense(doc.Summary + " " + doc.Content); err == nil {
doc.DenseVec = dv
}
}
// 立即写盘
path := filepath.Join(s.dir, doc.ID+".json")
data, _ := json.MarshalIndent(doc, "", " ")
os.WriteFile(path, data, 0644)
s.dirty = true
return nil
}
// ContextToDoc — 将一段上下文对话历史提炼为文档(带内容去重)
// cleanFn 可选,在计算层前统一过滤文本,不影响原文存储。
// toolCleanFn 可选,func(name, output string) string,按工具名对输出进行过滤/清洗:
// - 返回 "" → 跳过该工具输出(NoMemory)
// - 返回清洗后文本 → 用于计算层(Cleaner),原文不受影响
func (s *Store) ContextToDoc(source string, entries []ContextEntry, vec vector.Vectorizer, cleanFn func(string) string, toolCleanFn func(name, output string) string, channelCleaner ChannelCleaner) (*Doc, error) {
if len(entries) == 0 {
return nil, nil
}
if cleanFn == nil {
cleanFn = func(text string) string { return text }
}
var parts []string
for _, e := range entries {
line := fmt.Sprintf("[%s] %s: %s", e.Timestamp.Format("15:04"), e.Source, e.Content)
if e.Response != "" {
line += fmt.Sprintf(" → %s", truncate(e.Response, 100))
}
for _, tr := range e.ToolResults {
line += fmt.Sprintf("\n [工具] %s: %s", tr.Name, truncate(tr.Output, 200))
}
parts = append(parts, line)
}
content := strings.Join(parts, "\n")
contentHash := simpleHash(content)
summary := summarizeEntries(entries, cleanFn, toolCleanFn, channelCleaner)
tags := extractTags(entries, cleanFn, toolCleanFn, channelCleaner)
entities := extractEntities(entries, cleanFn, toolCleanFn, channelCleaner)
s.mu.Lock()
// 去重
for _, d := range s.docs {
if d.Meta != nil && d.Meta["content_hash"] == contentHash {
d.UpdatedAt = time.Now()
d.LastAccess = time.Now()
d.Content = content
d.Source = source
d.Summary = summary
d.Tags = tags
d.Entities = entities
d.Media = mediaDigestsFromEntries(entries)
s.dirty = true
s.mu.Unlock()
return d, nil
}
}
id := fmt.Sprintf("doc_%d", time.Now().UnixNano())
var docVec vector.Vector
if vec != nil {
docVec = vec.Vectorize(summary + " " + content)
} else {
docVec = s.veczer.Vectorize(summary + " " + content)
}
meta := map[string]string{"content_hash": contentHash}
if source == "context_archived" {
meta["is_archived_context"] = "true"
}
doc := &Doc{
ID: id,
Summary: summary,
Content: content,
Tags: tags,
Entities: entities,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
LastAccess: time.Now(),
AccessCount: 1,
Source: source,
Meta: meta,
Media: mediaDigestsFromEntries(entries),
Vector: docVec,
}
s.docs[id] = doc
// 加入向量索引
s.addSummary(summary)
s.vec.Insert(id, summary, doc.Vector, nil)
s.dirty = true
s.mu.Unlock()
// 立即写盘
path := filepath.Join(s.dir, id+".json")
data, _ := json.MarshalIndent(doc, "", " ")
os.WriteFile(path, data, 0644)
return doc, nil
}
// Consume — 向量相似度查询并移除文档(召回后即从冷存储删除,避免重复记忆)
func (s *Store) Consume(text string, topK int) []*Doc {
s.mu.Lock()
defer s.mu.Unlock()
if topK <= 0 {
topK = 5
}
// 优先稠密检索(与媒体共享空间);未配置时退化到 TF-IDF 倒排检索。
if s.denseSpace != nil && s.denseSpace.Loaded() {
queryVec, err := s.denseSpace.VectorizeDense(text)
if err == nil {
results := s.denseSearchScored(queryVec, topK)
var docs []*Doc
for _, r := range results {
if d, ok := s.docs[r.Doc.ID]; ok {
s.removeDoc(r.Doc.ID)
s.dirty = true
docs = append(docs, d)
}
}
return docs
}
log.Printf("[document memory] dense query failed, falling back to TF-IDF: %v", err)
}
vec := s.vectorizeQuery(text)
results := s.vec.Search(vec, topK)
var docs []*Doc
for _, r := range results {
if d, ok := s.docs[r.ID]; ok {
s.removeDoc(r.ID)
s.dirty = true
docs = append(docs, d)
}
}
return docs
}
// vectorizeQuery 用语义向量化器(首选)或 TF-IDF(兜底)处理查询文本
func (s *Store) vectorizeQuery(text string) vector.Vector {
if s.vectorizer != nil {
return s.vectorizer.Vectorize(text)
}
return s.veczer.Vectorize(text)
}
// Query — 向量相似度查询文档
func (s *Store) Query(text string, topK int) []*Doc {
hits := s.QueryScored(text, topK)
out := make([]*Doc, len(hits))
for i, h := range hits {
out[i] = h.Doc
}
return out
}
// DocHit 是一篇文档记忆的相似度候选及原始分数(供跨模态融合归一化)。
type DocHit struct {
Doc *Doc
Score float64
}
// QueryScored 与 Query 同语义,但返回带原始 cosine 分数的候选。
func (s *Store) QueryScored(text string, topK int) []DocHit {
s.mu.RLock()
defer s.mu.RUnlock()
if topK <= 0 {
topK = 5
}
// 优先稠密检索;退化到 TF-IDF。
if s.denseSpace != nil && s.denseSpace.Loaded() {
queryVec, err := s.denseSpace.VectorizeDense(text)
if err == nil {
results := s.denseSearchScored(queryVec, topK)
for i := range results {
if d, ok := s.docs[results[i].Doc.ID]; ok {
d.AccessCount++
d.LastAccess = time.Now()
results[i].Doc = d
}
}
return results
}
}
vec := s.vectorizeQuery(text)
results := s.vec.SearchScored(vec, topK)
var out []DocHit
for _, r := range results {
if d, ok := s.docs[r.Doc.ID]; ok {
d.AccessCount++
d.LastAccess = time.Now()
out = append(out, DocHit{Doc: d, Score: r.Score})
}
}
return out
}
// Reindex — 重新训练并重建向量索引
func (s *Store) Reindex() {
s.mu.Lock()
defer s.mu.Unlock()
log.Printf("[document memory] reindexing %d docs", len(s.docs))
s.veczer.Train(s.summaries)
s.vec = vector.NewStore()
for _, doc := range s.docs {
vec := doc.Vector
if vec == nil {
vec = s.veczer.Vectorize(doc.Summary + " " + doc.Content)
}
s.vec.Insert(doc.ID, doc.Summary, vec, doc.Meta)
}
log.Printf("[document memory] reindex complete (%d vectors)", s.vec.Size())
} }
func (s *Store) Stats() map[string]interface{} { func (s *Store) Stats() map[string]interface{} {
s.mu.RLock() s.mu.RLock()
defer s.mu.RUnlock() defer s.mu.RUnlock()
idxSize := 0
if s.tfidfIdx != nil {
idxSize = s.tfidfIdx.Size()
}
return map[string]interface{}{ return map[string]interface{}{
"doc_count": len(s.docs), "doc_count": len(s.docs),
"vector_count": s.vec.Size(), "index_count": idxSize,
"summary_count": len(s.summaries), "dir": s.dir,
"dir": s.dir,
} }
} }
// FindColdDocs — 查找冷文档:超过 maxAge 未访问且访问次数 <= minAccess
func (s *Store) FindColdDocs(maxAge time.Duration, minAccess int) []*Doc { func (s *Store) FindColdDocs(maxAge time.Duration, minAccess int) []*Doc {
s.mu.RLock() s.mu.RLock()
defer s.mu.RUnlock() defer s.mu.RUnlock()
cutoff := time.Now().Add(-maxAge) cutoff := time.Now().Add(-maxAge)
var cold []*Doc var cold []*Doc
for _, d := range s.docs { for _, d := range s.docs {
@ -462,60 +429,53 @@ func (s *Store) FindColdDocs(maxAge time.Duration, minAccess int) []*Doc {
return cold return cold
} }
// Get 返回指定文档(不存在时为 nil)。
func (s *Store) Get(id string) *Doc {
s.mu.RLock()
defer s.mu.RUnlock()
return s.docs[id]
}
// Blocks 返回全部文档持有的一等记忆块(供跨层存活判定)。
func (s *Store) Blocks() []memory.MemoryBlock {
s.mu.RLock()
defer s.mu.RUnlock()
var out []memory.MemoryBlock
for _, d := range s.docs {
out = append(out, d.Blocks...)
}
return out
}
func (s *Store) RecentDocs(n int) []*Doc { func (s *Store) RecentDocs(n int) []*Doc {
s.mu.RLock() s.mu.RLock()
defer s.mu.RUnlock() defer s.mu.RUnlock()
var list []*Doc var list []*Doc
for _, d := range s.docs { for _, d := range s.docs {
list = append(list, d) list = append(list, d)
} }
sort.Slice(list, func(i, j int) bool { sort.Slice(list, func(i, j int) bool { return list[i].CreatedAt.After(list[j].CreatedAt) })
return list[i].CreatedAt.After(list[j].CreatedAt)
})
if len(list) > n { if len(list) > n {
list = list[:n] list = list[:n]
} }
return list return list
} }
// Remove 从文档存储中删除指定 ID 的文档
func (s *Store) Remove(id string) { func (s *Store) Remove(id string) {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
if _, ok := s.docs[id]; ok { if _, ok := s.docs[id]; ok {
s.removeDoc(id) s.removeDoc(id)
s.dirty = true s.dirty = true
} }
} }
// ——— internal ———
// addSummary 添加一条摘要到训练集,超限时截断并触发重索引。
// 调用方必须已持有 s.mu 写锁。
func (s *Store) addSummary(summary string) {
s.summaries = append(s.summaries, summary)
if len(s.summaries) > maxSummaries {
n := maxSummaries / 2
copy(s.summaries, s.summaries[len(s.summaries)-n:])
s.summaries = s.summaries[:n]
s.veczer.Train(s.summaries)
s.vec = vector.NewStore()
for _, doc := range s.docs {
vec := s.veczer.Vectorize(doc.Summary + " " + doc.Content)
s.vec.Insert(doc.ID, doc.Summary, vec, nil)
}
}
}
// removeDoc 从内存索引和磁盘删除文档。
// 调用方必须已持有 s.mu 写锁。
func (s *Store) removeDoc(id string) { func (s *Store) removeDoc(id string) {
delete(s.docs, id) delete(s.docs, id)
s.vec.Remove(id) if s.tfidfIdx != nil {
path := filepath.Join(s.dir, id+".json") s.tfidfIdx.Remove(id)
os.Remove(path) }
os.Remove(filepath.Join(s.dir, id+".json"))
} }
func (s *Store) loadAll() error { func (s *Store) loadAll() error {
@ -523,63 +483,43 @@ func (s *Store) loadAll() error {
if err != nil { if err != nil {
return err return err
} }
for _, e := range entries { for _, e := range entries {
if !strings.HasSuffix(e.Name(), ".json") || !strings.HasPrefix(e.Name(), "doc_") { if !strings.HasSuffix(e.Name(), ".json") || !strings.HasPrefix(e.Name(), "doc_") {
continue continue
} }
path := filepath.Join(s.dir, e.Name()) data, err := os.ReadFile(filepath.Join(s.dir, e.Name()))
data, err := os.ReadFile(path)
if err != nil { if err != nil {
continue continue
} }
var doc Doc var doc Doc
if err := json.Unmarshal(data, &doc); err != nil { if json.Unmarshal(data, &doc) != nil || doc.ID == "" {
continue continue
} }
s.docs[doc.ID] = &doc s.docs[doc.ID] = &doc
s.summaries = append(s.summaries, doc.Summary) // 缓存文本,延迟训练(确保TFIDF在首次需要时才加载)
s.trainTexts = append(s.trainTexts, doc.ID, doc.Summary+" "+doc.Content)
} }
// 训练向量化器
if len(s.summaries) > 0 {
s.veczer.Train(s.summaries)
}
// 重建向量索引
for _, doc := range s.docs {
vec := doc.Vector
if vec == nil {
vec = s.veczer.Vectorize(doc.Summary + " " + doc.Content)
}
s.vec.Insert(doc.ID, doc.Summary, vec, nil)
}
return nil return nil
} }
func (s *Store) flush() { func (s *Store) flush() {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
if !s.dirty { if !s.dirty {
return return
} }
for _, doc := range s.docs { for _, doc := range s.docs {
path := filepath.Join(s.dir, doc.ID+".json") data, _ := json.MarshalIndent(doc, "", " ")
data, err := json.MarshalIndent(doc, "", " ") os.WriteFile(filepath.Join(s.dir, doc.ID+".json"), data, 0644)
if err != nil {
continue
}
os.WriteFile(path, data, 0644)
} }
s.dirty = false s.dirty = false
} }
// ——— 内部工具函数(从上下文提炼文档所需)———
type ToolResultItem struct { type ToolResultItem struct {
Name string Name string `json:"name"`
Output string Output string `json:"output"`
} }
type ContextEntry struct { type ContextEntry struct {
@ -588,19 +528,20 @@ type ContextEntry struct {
Content string Content string
Response string Response string
ToolResults []ToolResultItem ToolResults []ToolResultItem
Media []string Blocks []memory.MemoryBlock // 一等记忆块随事件一起迁移到文档
} }
func mediaDigestsFromEntries(entries []ContextEntry) []string { func blocksFromEntries(entries []ContextEntry) []memory.MemoryBlock {
seen := make(map[string]bool) seen := make(map[string]bool)
var out []string var out []memory.MemoryBlock
for _, e := range entries { for _, e := range entries {
for _, d := range e.Media { for i := range e.Blocks {
if d == "" || seen[d] { b := e.Blocks[i]
if b.ID == "" || seen[b.ID] {
continue continue
} }
seen[d] = true seen[b.ID] = true
out = append(out, d) out = append(out, b)
} }
} }
return out return out
@ -635,14 +576,12 @@ func summarizeEntries(entries []ContextEntry, cleanText func(string) string, too
topics = append(topics, toolWords...) topics = append(topics, toolWords...)
} }
} }
summary := fmt.Sprintf("来自 %d 个来源的 %d 条对话", len(sources), len(entries)) summary := fmt.Sprintf("来自 %d 个来源的 %d 条对话", len(sources), len(entries))
var srcList []string var srcList []string
for s := range sources { for s := range sources {
srcList = append(srcList, s) srcList = append(srcList, s)
} }
summary += " (" + strings.Join(srcList, ", ") + ")" summary += " (" + strings.Join(srcList, ", ") + ")"
if len(topics) > 0 { if len(topics) > 0 {
seen := make(map[string]bool) seen := make(map[string]bool)
var uniq []string var uniq []string
@ -657,7 +596,6 @@ func summarizeEntries(entries []ContextEntry, cleanText func(string) string, too
} }
summary += " 涉及: " + strings.Join(uniq, ", ") summary += " 涉及: " + strings.Join(uniq, ", ")
} }
return summary return summary
} }
@ -730,25 +668,20 @@ func extractEntities(entries []ContextEntry, cleanText func(string) string, tool
} }
} }
} }
if len(entities) > 20 {
entities = entities[:20]
}
return entities return entities
} }
func truncate(s string, max int) string { func truncate(s string, max int) string {
runes := []rune(s) if len([]rune(s)) <= max {
if len(runes) > max { return s
return string(runes[:max]) + "..."
} }
return s return string([]rune(s)[:max]) + "..."
} }
func simpleHash(s string) string { func simpleHash(s string) string {
// 简单的基于内容的哈希,用于去重 h := fmt.Sprintf("%x", len(s))
h := 0 for _, c := range s {
for _, r := range s { h += fmt.Sprintf("%x", c)
h = h*31 + int(r)
} }
return fmt.Sprintf("h%08x", h) return h
} }

View File

@ -236,7 +236,7 @@ func (g *GraphDB) Commit(triples []Triple, sessionID string, turnID int) (int, i
// 不划算。这里让 Commit 内部转调,两者共享同一份落库逻辑。 // 不划算。这里让 Commit 内部转调,两者共享同一份落库逻辑。
// //
// 返回的 map 只包含本次真正写入了 sentences 表的句子。调用方据此把媒体 // 返回的 map 只包含本次真正写入了 sentences 表的句子。调用方据此把媒体
// 引用挂到 graph_sentence owner 上——句子是媒体描述在图库里的落点, // 变成 L3 一等块,并以 sentence --contains--> block 边与句子相连;
// 关系行本身不持有媒体。 // 关系行本身不持有媒体。
func (g *GraphDB) CommitWithMedia(triples []Triple, sessionID string, turnID int) (map[string]int64, int, int, error) { func (g *GraphDB) CommitWithMedia(triples []Triple, sessionID string, turnID int) (map[string]int64, int, int, error) {
return g.commit(triples, sessionID, turnID, true) return g.commit(triples, sessionID, turnID, true)

View File

@ -11,7 +11,7 @@
// - 路径会失效。/tmp 下的探针图、下载缓存、其他进程的临时产物,记忆里留个 // - 路径会失效。/tmp 下的探针图、下载缓存、其他进程的临时产物,记忆里留个
// 路径等于留个悬空指针。 // 路径等于留个悬空指针。
// - 同一张图往往被反复注入(用户连问几轮同一张截图、see_video 相邻帧高度 // - 同一张图往往被反复注入(用户连问几轮同一张截图、see_video 相邻帧高度
// 相似)。按 sha256 寻址天然去重,引用计数记住被引了几次。 // 相似)。按 sha256 寻址天然去重,同一份字节只存一遍。
// - 内容即身份,跟 L3 图库 `sentences.text UNIQUE` 的思路一致:文本节点用 // - 内容即身份,跟 L3 图库 `sentences.text UNIQUE` 的思路一致:文本节点用
// 文本本身做身份,媒体节点用内容摘要做身份。 // 文本本身做身份,媒体节点用内容摘要做身份。
package media package media
@ -34,20 +34,6 @@ import (
_ "github.com/mattn/go-sqlite3" _ "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 的十六进制串长度。 // digestHexLen 是 sha256 的十六进制串长度。
const digestHexLen = sha256.Size * 2 const digestHexLen = sha256.Size * 2
@ -88,8 +74,6 @@ type Item struct {
Description string `json:"description,omitempty"` Description string `json:"description,omitempty"`
// DescribedBy 记录描述来自哪个源,让后续读者能判断可靠性。 // DescribedBy 记录描述来自哪个源,让后续读者能判断可靠性。
DescribedBy string `json:"described_by,omitempty"` DescribedBy string `json:"described_by,omitempty"`
// RefCount 是引用计数。GC 只清理归零的项。
RefCount int `json:"ref_count"`
// FirstSeen/LastSeen 是首末次入库时间。 // FirstSeen/LastSeen 是首末次入库时间。
FirstSeen time.Time `json:"first_seen"` FirstSeen time.Time `json:"first_seen"`
LastSeen time.Time `json:"last_seen"` LastSeen time.Time `json:"last_seen"`
@ -110,15 +94,11 @@ type Store struct {
mu sync.RWMutex mu sync.RWMutex
db *sql.DB db *sql.DB
blobDir string blobDir string
// maxBytes 是内容目录的容量上限,0 表示不限。
// 超限时 GC 按 LastSeen 从旧到新淘汰 RefCount=0 的项。
maxBytes int64
} }
// New 打开(或初始化)媒体存储。 // New 打开(或初始化)媒体存储。
// dir 下会建 media.db 与 blobs/ 两个条目。 // dir 下会建 media.db 与 blobs/ 两个条目。
func New(dir string, maxBytes int64) (*Store, error) { func New(dir string) (*Store, error) {
if err := os.MkdirAll(filepath.Join(dir, "blobs"), 0755); err != nil { if err := os.MkdirAll(filepath.Join(dir, "blobs"), 0755); err != nil {
return nil, fmt.Errorf("media: create blob dir: %w", err) return nil, fmt.Errorf("media: create blob dir: %w", err)
} }
@ -127,7 +107,7 @@ func New(dir string, maxBytes int64) (*Store, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("media: open db: %w", err) return nil, fmt.Errorf("media: open db: %w", err)
} }
s := &Store{db: db, blobDir: filepath.Join(dir, "blobs"), maxBytes: maxBytes} s := &Store{db: db, blobDir: filepath.Join(dir, "blobs")}
if err := s.initSchema(); err != nil { if err := s.initSchema(); err != nil {
db.Close() db.Close()
return nil, err return nil, err
@ -137,7 +117,7 @@ func New(dir string, maxBytes int64) (*Store, error) {
func (s *Store) initSchema() error { func (s *Store) initSchema() error {
stmts := []string{ stmts := []string{
// digest 作主键:内容即身份,重复 Put 同一内容只递增 ref_count。 // digest 作主键:内容即身份,重复 Put 同一内容不重复落盘。
`CREATE TABLE IF NOT EXISTS media ( `CREATE TABLE IF NOT EXISTS media (
digest TEXT PRIMARY KEY, digest TEXT PRIMARY KEY,
kind TEXT NOT NULL, kind TEXT NOT NULL,
@ -149,25 +129,11 @@ func (s *Store) initSchema() error {
tool TEXT, tool TEXT,
description TEXT, description TEXT,
described_by TEXT, described_by TEXT,
ref_count INTEGER DEFAULT 0,
first_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP, first_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_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_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)`, `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 { for _, q := range stmts {
if _, err := s.db.Exec(q); err != nil { if _, err := s.db.Exec(q); err != nil {
@ -231,8 +197,8 @@ func (s *Store) Put(data []byte, meta Item) (string, error) {
_, err := s.db.Exec(` _, err := s.db.Exec(`
INSERT INTO media (digest, kind, mime, size, width, height, INSERT INTO media (digest, kind, mime, size, width, height,
origin_path, tool, description, described_by, origin_path, tool, description, described_by,
ref_count, first_seen, last_seen) first_seen, last_seen)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(digest) DO UPDATE SET ON CONFLICT(digest) DO UPDATE SET
last_seen = excluded.last_seen, last_seen = excluded.last_seen,
-- 只在原值为空时补写:先到的描述可能来自更强的模型, -- 只在原值为空时补写:先到的描述可能来自更强的模型,
@ -276,7 +242,7 @@ func (s *Store) Stat(digest string) (*Item, error) {
defer s.mu.RUnlock() defer s.mu.RUnlock()
return s.scanOne(s.db.QueryRow(` return s.scanOne(s.db.QueryRow(`
SELECT digest, kind, mime, size, width, height, origin_path, tool, SELECT digest, kind, mime, size, width, height, origin_path, tool,
description, described_by, ref_count, first_seen, last_seen, description, described_by, first_seen, last_seen,
vec, vec_model vec, vec_model
FROM media WHERE digest = ?`, digest)) FROM media WHERE digest = ?`, digest))
} }
@ -299,121 +265,6 @@ func (s *Store) Describe(digest, description, describedBy string) error {
return nil 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 匹配,返回最近的若干条。 // Search 按描述文本做 LIKE 匹配,返回最近的若干条。
// //
// 刻意不在这里做向量检索:媒体的语义检索走 L2 文档层的既有索引 // 刻意不在这里做向量检索:媒体的语义检索走 L2 文档层的既有索引
@ -427,7 +278,7 @@ func (s *Store) Search(query string, kind Kind, limit int) ([]*Item, error) {
defer s.mu.RUnlock() defer s.mu.RUnlock()
q := `SELECT digest, kind, mime, size, width, height, origin_path, tool, q := `SELECT digest, kind, mime, size, width, height, origin_path, tool,
description, described_by, ref_count, first_seen, last_seen, description, described_by, first_seen, last_seen,
vec, vec_model vec, vec_model
FROM media WHERE COALESCE(description,'') != ''` FROM media WHERE COALESCE(description,'') != ''`
args := []interface{}{} args := []interface{}{}
@ -474,7 +325,7 @@ func (s *Store) Pending(limit int) ([]*Item, error) {
defer s.mu.RUnlock() defer s.mu.RUnlock()
rows, err := s.db.Query(` rows, err := s.db.Query(`
SELECT digest, kind, mime, size, width, height, origin_path, tool, SELECT digest, kind, mime, size, width, height, origin_path, tool,
description, described_by, ref_count, first_seen, last_seen, description, described_by, first_seen, last_seen,
vec, vec_model vec, vec_model
FROM media FROM media
WHERE COALESCE(description,'') = '' AND COALESCE(described_by,'') = '' WHERE COALESCE(description,'') = '' AND COALESCE(described_by,'') = ''
@ -494,117 +345,44 @@ func (s *Store) Pending(limit int) ([]*Item, error) {
return out, rows.Err() return out, rows.Err()
} }
// GC 清理无人引用的内容。 // GC 清理已不被任何记忆块持有的内容。
// //
// 两段策略: // keep 是当前仍被 Context/Document/Graph 里一等记忆块持有的 digest 集合,
// 1. ref_count=0 且 last_seen 早于 minAge 的一律清理。刚 Put 还没来得及 // 由调用方从三层记忆节点计算得出;media.Store 不再自己维护引用账本。
// AddRef 的项 refcount 也是 0,minAge 保护它们不被立刻清掉。 // 不在 keep 中且早于 minAge 的项被清理;超出 maxBytes 时也只淘汰不在 keep 中的项。
// 2. 清完仍超 maxBytes 时,继续按 last_seen 从旧到新淘汰 ref_count=0 的项。 // Delete 删除一份媒体内容(元数据 + blob)。
// //
// 有引用的项永不删除——那会让记忆里的 digest 变成悬空指针,正是本包要避免的。 // 这不是 GC,也不看引用计数:调用方是记忆系统本身——当它把一个记忆块
func (s *Store) GC(minAge time.Duration) (removed int, freed int64, err error) { // 永久地从三层记忆中删掉(而非在层间迁移)时,媒体作为块的内容一并删除。
// 文本块就是这么管理的:删除块即删除内容。
func (s *Store) Delete(digest string) error {
if digest == "" {
return nil
}
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
if err := os.Remove(s.blobPath(digest)); err != nil && !os.IsNotExist(err) {
cutoff := time.Now().Add(-minAge) return fmt.Errorf("media: remove blob %s: %w", shortDigest(digest), err)
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 { if _, err := s.db.Exec(`DELETE FROM media WHERE digest = ?`, digest); err != nil {
digest string return fmt.Errorf("media: delete meta %s: %w", shortDigest(digest), err)
size int64
} }
var cands []cand return nil
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 时继续淘汰无引用项(调用方已持锁)。 // Stats 返回条目统计,供 WebUI / healthcheck 展示。
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{} { func (s *Store) Stats() map[string]interface{} {
s.mu.RLock() s.mu.RLock()
defer s.mu.RUnlock() defer s.mu.RUnlock()
out := map[string]interface{}{"blob_dir": s.blobDir, "max_bytes": s.maxBytes} out := map[string]interface{}{"blob_dir": s.blobDir}
var count, described, orphan int var count, described int
var total int64 var total int64
s.db.QueryRow(`SELECT COUNT(*), COALESCE(SUM(size),0) FROM media`).Scan(&count, &total) 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 COALESCE(description,'') != ''`).Scan(&described)
s.db.QueryRow(`SELECT COUNT(*) FROM media WHERE ref_count <= 0`).Scan(&orphan)
out["count"] = count out["count"] = count
out["total_bytes"] = total out["total_bytes"] = total
out["described"] = described out["described"] = described
out["unreferenced"] = orphan
byKind := map[string]int{} byKind := map[string]int{}
rows, err := s.db.Query(`SELECT kind, COUNT(*) FROM media GROUP BY kind`) rows, err := s.db.Query(`SELECT kind, COUNT(*) FROM media GROUP BY kind`)
@ -725,25 +503,17 @@ type MediaHit struct {
Score float64 Score float64
} }
// QueryMemoryMediaScored 只检索当前仍被 L0/L2/L3 记忆块引用的媒体。 // QueryMediaScored 用查询向量对所有已嵌入媒体做余弦相似度检索,
// CAS 中 ref_count=0 的项是等待 GC 的孤儿缓存,不是可召回记忆;若把它们也查出, // 返回 topK 个最相似的候选及其原始 cosine 分数(供跨模态归一化)。
// 已从三层记忆淘汰的图片会被视觉路“复活”,破坏与文本块一致的生命周期。
// //
// 分数只做排序,不在存储层设绝对阈值:多模态文本→图像的绝对 cosine 随模型、 // 分数只做排序,不在存储层设绝对阈值:多模态文本→图像的绝对 cosine 随模型、
// 语言与数据域漂移,真实标定中有效命中可以低至 0.015。相关性门控在融合器中 // 语言与数据域漂移,真实标定中有效命中可以低至 0.015。相关性门控在融合器中
// 使用当前候选集合的相对分布完成。 // 使用当前候选集合的相对分布完成。
func (s *Store) QueryMemoryMediaScored(queryVec []float64, model string, topK int) ([]MediaHit, error) {
return s.queryMediaScored(queryVec, model, topK, true)
}
// QueryMediaScored 用查询向量对所有已嵌入媒体做余弦相似度检索,
// 返回 topK 个最相似的候选及其原始 cosine 分数(供跨模态归一化)。
// 这是媒体存储层的诊断/显式全库入口;记忆召回应调用 QueryMemoryMediaScored。
func (s *Store) QueryMediaScored(queryVec []float64, model string, topK int) ([]MediaHit, error) { func (s *Store) QueryMediaScored(queryVec []float64, model string, topK int) ([]MediaHit, error) {
return s.queryMediaScored(queryVec, model, topK, false) return s.queryMediaScored(queryVec, model, topK)
} }
func (s *Store) queryMediaScored(queryVec []float64, model string, topK int, referencedOnly bool) ([]MediaHit, error) { func (s *Store) queryMediaScored(queryVec []float64, model string, topK int) ([]MediaHit, error) {
if topK <= 0 { if topK <= 0 {
topK = 20 topK = 20
} }
@ -754,7 +524,7 @@ func (s *Store) queryMediaScored(queryVec []float64, model string, topK int, ref
defer s.mu.RUnlock() defer s.mu.RUnlock()
query := `SELECT digest, kind, mime, size, width, height, query := `SELECT digest, kind, mime, size, width, height,
origin_path, tool, description, described_by, ref_count, first_seen, last_seen, origin_path, tool, description, described_by, first_seen, last_seen,
vec, vec_model vec, vec_model
FROM media WHERE vec IS NOT NULL AND vec != ''` FROM media WHERE vec IS NOT NULL AND vec != ''`
var args []interface{} var args []interface{}
@ -762,11 +532,6 @@ func (s *Store) queryMediaScored(queryVec []float64, model string, topK int, ref
query += ` AND vec_model = ?` query += ` AND vec_model = ?`
args = append(args, model) args = append(args, model)
} }
if referencedOnly {
query += ` AND ref_count > 0 AND EXISTS (
SELECT 1 FROM media_refs r WHERE r.digest = media.digest
)`
}
rows, err := s.db.Query(query, args...) rows, err := s.db.Query(query, args...)
if err != nil { if err != nil {
return nil, err return nil, err
@ -783,7 +548,7 @@ func (s *Store) queryMediaScored(queryVec []float64, model string, topK int, ref
var kind string var kind string
var origin, tool, desc, by, vecJSON, vecModel sql.NullString var origin, tool, desc, by, vecJSON, vecModel sql.NullString
if err := rows.Scan(&it.Digest, &kind, &it.MIME, &it.Size, &it.Width, &it.Height, if err := rows.Scan(&it.Digest, &kind, &it.MIME, &it.Size, &it.Width, &it.Height,
&origin, &tool, &desc, &by, &it.RefCount, &it.FirstSeen, &it.LastSeen, &origin, &tool, &desc, &by, &it.FirstSeen, &it.LastSeen,
&vecJSON, &vecModel); err != nil { &vecJSON, &vecModel); err != nil {
continue continue
} }
@ -860,7 +625,7 @@ func scanItem(r rowScanner) (*Item, error) {
var kind string var kind string
var origin, tool, desc, by, vecJSON, vecModel sql.NullString var origin, tool, desc, by, vecJSON, vecModel sql.NullString
if err := r.Scan(&it.Digest, &kind, &it.MIME, &it.Size, &it.Width, &it.Height, if err := r.Scan(&it.Digest, &kind, &it.MIME, &it.Size, &it.Width, &it.Height,
&origin, &tool, &desc, &by, &it.RefCount, &it.FirstSeen, &it.LastSeen, &origin, &tool, &desc, &by, &it.FirstSeen, &it.LastSeen,
&vecJSON, &vecModel); err != nil { &vecJSON, &vecModel); err != nil {
return nil, err return nil, err
} }

View File

@ -5,12 +5,13 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
"time"
) )
func newTestStore(t *testing.T, maxBytes int64) *Store { // newTestStore 建一个临时媒体存储。
// 参数保留只为兼容旧调用点;媒体不再有容量上限(生命周期由记忆块决定)。
func newTestStore(t *testing.T, _ ...int64) *Store {
t.Helper() t.Helper()
s, err := New(t.TempDir(), maxBytes) s, err := New(t.TempDir())
if err != nil { if err != nil {
t.Fatalf("New: %v", err) t.Fatalf("New: %v", err)
} }
@ -109,172 +110,35 @@ func TestPut_NoPartialBlobOnDisk(t *testing.T) {
} }
} }
func TestRefCount_AddIsIdempotent(t *testing.T) { func TestDelete_RemovesContentAndMetadata(t *testing.T) {
s := newTestStore(t, 0) // 删除块即删除内容:Delete 同时清掉 blob 与元数据。
d, _ := s.Put([]byte("img"), Item{MIME: "image/png"}) // 这不是 GC,也不看引用计数——调用方是记忆系统本身。
s := newTestStore(t)
d, _ := s.Put([]byte("held"), Item{MIME: "image/png"})
other, _ := s.Put([]byte("orphaned"), Item{MIME: "image/png"})
for i := 0; i < 3; i++ { if err := s.Delete(other); err != nil {
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) t.Fatal(err)
} }
it, _ = s.Stat(d) if _, err := s.Stat(other); err == nil {
if it.RefCount != 2 { t.Fatal("删除后元数据应已移除")
t.Fatalf("不同 owner 应各计一次,实际 %d", it.RefCount)
} }
} if _, err := s.Get(other); err == nil {
t.Fatal("删除后内容应已移除")
func TestRefCount_DropAndNeverNegative(t *testing.T) {
s := newTestStore(t, 0)
d, _ := s.Put([]byte("img"), Item{MIME: "image/png"})
s.AddRef(d, "context", "e1")
if err := s.DropRef(d, "context", "e1"); err != nil {
t.Fatal(err)
}
it, _ := s.Stat(d)
if it.RefCount != 0 {
t.Fatalf("应归零,实际 %d", it.RefCount)
}
// 多余的 DropRef 不该把计数压成负数(负数会让容量 GC 的排序失去意义)
for i := 0; i < 3; i++ {
s.DropRef(d, "context", "e1")
}
it, _ = s.Stat(d)
if it.RefCount != 0 {
t.Fatalf("重复 DropRef 后仍应为 0,实际 %d", it.RefCount)
}
}
func TestDropOwner_RemovesAllItsRefs(t *testing.T) {
s := newTestStore(t, 0)
d1, _ := s.Put([]byte("frame1"), Item{MIME: "image/jpeg"})
d2, _ := s.Put([]byte("frame2"), Item{MIME: "image/jpeg"})
s.AddRef(d1, "context", "evt-x")
s.AddRef(d2, "context", "evt-x")
s.AddRef(d1, "document", "doc-y") // 别的 owner 也引了 d1
n, err := s.DropOwner("context", "evt-x")
if err != nil {
t.Fatal(err)
}
if n != 2 {
t.Fatalf("应注销 2 条引用,实际 %d", n)
}
it1, _ := s.Stat(d1)
it2, _ := s.Stat(d2)
if it1.RefCount != 1 {
t.Fatalf("d1 仍被 document 引用,应剩 1,实际 %d", it1.RefCount)
}
if it2.RefCount != 0 {
t.Fatalf("d2 应归零,实际 %d", it2.RefCount)
}
}
func TestRefs_ListsOwnerDigests(t *testing.T) {
s := newTestStore(t, 0)
d1, _ := s.Put([]byte("a"), Item{MIME: "image/png"})
d2, _ := s.Put([]byte("b"), Item{MIME: "image/png"})
s.AddRef(d1, "context", "e1")
s.AddRef(d2, "context", "e1")
got, err := s.Refs("context", "e1")
if err != nil {
t.Fatal(err)
}
if len(got) != 2 {
t.Fatalf("应返回 2 个 digest,实际 %d", len(got))
}
}
func TestGC_KeepsReferencedContent(t *testing.T) {
// 有引用的项永不删除——那会让记忆里的 digest 变成悬空指针,
// 正是本包要避免的。
s := newTestStore(t, 0)
kept, _ := s.Put([]byte("referenced"), Item{MIME: "image/png"})
orphan, _ := s.Put([]byte("orphaned"), Item{MIME: "image/png"})
s.AddRef(kept, "context", "e1")
// minAge=0 让刚 Put 的都算超龄
removed, _, err := s.GC(0)
if err != nil {
t.Fatal(err)
}
if removed != 1 {
t.Fatalf("应只清 1 条无引用项,实际 %d", removed)
}
if _, err := s.Get(kept); err != nil {
t.Fatalf("被引用的内容不该被清: %v", err)
}
if _, err := s.Stat(orphan); err == nil {
t.Fatal("无引用项的元数据应已删除")
}
}
func TestGC_MinAgeProtectsFreshUnreferenced(t *testing.T) {
// 刚 Put 还没来得及 AddRef 的项 refcount 也是 0;
// minAge 必须保护它们,否则「Put 完还没挂上就被 GC 清掉」。
s := newTestStore(t, 0)
d, _ := s.Put([]byte("just-arrived"), Item{MIME: "image/png"})
removed, _, err := s.GC(time.Hour)
if err != nil {
t.Fatal(err)
}
if removed != 0 {
t.Fatalf("新入库项应被 minAge 保护,却清掉了 %d 条", removed)
} }
// 未被删除的项不受影响
if _, err := s.Get(d); err != nil { if _, err := s.Get(d); err != nil {
t.Fatalf("内容应还在: %v", err) t.Fatalf("未删除的内容不该受影响: %v", err)
} }
} }
func TestGC_EnforcesCapacity(t *testing.T) { func TestDelete_UnknownDigestIsNoop(t *testing.T) {
// 容量上限:清完超龄项后仍超限,继续按 last_seen 从旧到新淘汰无引用项。 s := newTestStore(t)
blob := make([]byte, 1024) if err := s.Delete(""); err != nil {
s := newTestStore(t, 2048) // 只容 2KB t.Fatalf("空 digest 应为无操作: %v", err)
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
} }
if err := s.Delete("ffffffffffffffff"); err != nil {
// 保护最后一个,确认容量 GC 也不碰有引用的 t.Fatalf("不存在的 digest 应为无操作: %v", err)
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)
} }
} }
@ -420,7 +284,6 @@ func TestStats_CountsByKindAndDescription(t *testing.T) {
s.Put([]byte("i2"), Item{MIME: "image/jpeg"}) s.Put([]byte("i2"), Item{MIME: "image/jpeg"})
s.Put([]byte("a1"), Item{MIME: "audio/wav"}) s.Put([]byte("a1"), Item{MIME: "audio/wav"})
s.Describe(d1, "描述", "vis") s.Describe(d1, "描述", "vis")
s.AddRef(d1, "context", "e1")
st := s.Stats() st := s.Stats()
if st["count"].(int) != 3 { if st["count"].(int) != 3 {
@ -429,9 +292,6 @@ func TestStats_CountsByKindAndDescription(t *testing.T) {
if st["described"].(int) != 1 { if st["described"].(int) != 1 {
t.Fatalf("described 应为 1,实际 %v", st["described"]) 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) byKind := st["by_kind"].(map[string]int)
if byKind["image"] != 2 || byKind["audio"] != 1 { if byKind["image"] != 2 || byKind["audio"] != 1 {
t.Fatalf("by_kind 不对: %v", byKind) t.Fatalf("by_kind 不对: %v", byKind)
@ -448,16 +308,15 @@ func TestPut_RejectsEmpty(t *testing.T) {
func TestReopen_PersistsAcrossRestart(t *testing.T) { func TestReopen_PersistsAcrossRestart(t *testing.T) {
// 记忆的意义就在于跨重启还在。 // 记忆的意义就在于跨重启还在。
dir := t.TempDir() dir := t.TempDir()
s1, err := New(dir, 0) s1, err := New(dir)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
d, _ := s1.Put([]byte("persistent-img"), Item{MIME: "image/png", OriginPath: "/tmp/x.png"}) d, _ := s1.Put([]byte("persistent-img"), Item{MIME: "image/png", OriginPath: "/tmp/x.png"})
s1.Describe(d, "跨重启的描述", "vis") s1.Describe(d, "跨重启的描述", "vis")
s1.AddRef(d, "context", "e1")
s1.Close() s1.Close()
s2, err := New(dir, 0) s2, err := New(dir)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -467,7 +326,7 @@ func TestReopen_PersistsAcrossRestart(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("重开后应能查到: %v", err) t.Fatalf("重开后应能查到: %v", err)
} }
if it.Description != "跨重启的描述" || it.RefCount != 1 { if it.Description != "跨重启的描述" {
t.Fatalf("元数据应持久化: %+v", it) t.Fatalf("元数据应持久化: %+v", it)
} }
data, err := s2.Get(d) data, err := s2.Get(d)

View File

@ -11,19 +11,12 @@ import (
// 冒烟测试:走真实数据路径的端到端场景,而非孤立的 API 单测。 // 冒烟测试:走真实数据路径的端到端场景,而非孤立的 API 单测。
// //
// 之前这套场景是 internal/memory/media/smoke/ 下一个带 //go:build smoke 的 // 媒体存储现在只做内容寻址(CAS):字节 + 元数据 + 向量。
// 独立 main,得记着加 -tags smoke 才跑得到——那种早晚会被忘掉。搬成普通 // “哪些字节还活着”由三层记忆持有的一等记忆块决定,调用方把该集合传给
// 测试后它随 go test ./... 一起跑,冒烟的意义(每次改动都过一遍真实链路) // GC/检索,本层不维护 media_refs/ref_count 这类平行账本。
// 才真正成立。
// makePNG 生成一张 w×h 的条带 PNG,用真 PNG 而不是随机字节, // makePNG 生成一张 w×h 的条带 PNG,用真 PNG 而不是随机字节,
// 让入库/回读/digest 走的是与生产一致的数据形态。 // 让入库/回读/digest 走的是与生产一致的数据形态。
//
// variant 注入到像素而不只用于选色:最初写的是
// palette[(variant+y*3/h)%5],调色盘只 5 色,于是 variant=0 与 5 产出
// 逐字节相同的 PNG——冒烟跑出「6 帧只得 5 条」,看着像存储丢了一帧,
// 实际是 CAS 正确去重了两张真同图。冒烟要验的是「不同帧各存一份」,
// 夹具就必须保证帧间真的不同。
func makePNG(w, h, variant int) []byte { func makePNG(w, h, variant int) []byte {
palette := [][3]byte{ palette := [][3]byte{
{255, 0, 0}, {0, 192, 0}, {0, 0, 255}, {255, 220, 0}, {160, 0, 200}, {255, 0, 0}, {0, 192, 0}, {0, 0, 255}, {255, 220, 0}, {160, 0, 200},
@ -71,7 +64,7 @@ func makePNG(w, h, variant int) []byte {
func TestSmoke_SamePictureAcrossTurns(t *testing.T) { func TestSmoke_SamePictureAcrossTurns(t *testing.T) {
// 场景:用户连问几轮同一张截图。multimodal 每轮都会重新注入, // 场景:用户连问几轮同一张截图。multimodal 每轮都会重新注入,
// 磁盘上应该只有一份,但每轮的 context 事件各持一个引用。 // 内容寻址天然去重,磁盘上只应有一份。
s := newTestStore(t, 50*1024*1024) s := newTestStore(t, 50*1024*1024)
png := makePNG(400, 400, 0) png := makePNG(400, 400, 0)
@ -96,9 +89,6 @@ func TestSmoke_SamePictureAcrossTurns(t *testing.T) {
} else if d != d0 { } else if d != d0 {
t.Fatalf("同一张图第 %d 轮 digest 变了", turn) 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() st := s.Stats()
@ -108,18 +98,16 @@ func TestSmoke_SamePictureAcrossTurns(t *testing.T) {
if total := st["total_bytes"].(int64); total != int64(len(png)) { if total := st["total_bytes"].(int64); total != int64(len(png)) {
t.Fatalf("字节数应等于单张原图 %d,实际 %d", len(png), total) t.Fatalf("字节数应等于单张原图 %d,实际 %d", len(png), total)
} }
it, _ := s.Stat(d0) // 三层记忆持有它;内容应仍可读
if it.RefCount != 5 { if _, err := s.Get(d0); err != nil {
t.Fatalf("应有 5 个引用,实际 %d", it.RefCount) t.Fatalf("内容应仍可读: %v", err)
} }
t.Logf("同图 5 轮:条目=1 字节=%d refcount=%d", len(png), it.RefCount)
checkRefIntegrity(t, s)
} }
func TestSmoke_VideoFramesDistinct(t *testing.T) { func TestSmoke_VideoFramesDistinct(t *testing.T) {
// 场景:see_video 抽 6 帧,帧间内容不同,应各存一份并共享一个 owner。 // 场景:see_video 抽 6 帧,帧间内容不同,应各存一份。
s := newTestStore(t, 50*1024*1024) s := newTestStore(t, 50*1024*1024)
var frames []string keep := map[string]bool{}
for i := 0; i < 6; i++ { for i := 0; i < 6; i++ {
d, err := s.Put(makePNG(320, 240, i), Item{ d, err := s.Put(makePNG(320, 240, i), Item{
MIME: "image/jpeg", Width: 320, Height: 240, Tool: "multimodal_see_video", MIME: "image/jpeg", Width: 320, Height: 240, Tool: "multimodal_see_video",
@ -127,24 +115,12 @@ func TestSmoke_VideoFramesDistinct(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("第 %d 帧: %v", i, err) t.Fatalf("第 %d 帧: %v", i, err)
} }
frames = append(frames, d) keep[d] = true
if err := s.AddRef(d, "context", "evt-video"); err != nil {
t.Fatal(err)
}
} }
st := s.Stats() if st := s.Stats(); st["count"].(int) != 6 {
if st["count"].(int) != 6 {
t.Fatalf("6 帧应各存一份,实际 %v 条", st["count"]) 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) { func TestSmoke_DescribeThenRetrieve(t *testing.T) {
@ -156,10 +132,8 @@ func TestSmoke_DescribeThenRetrieve(t *testing.T) {
if err := s.Describe(pic, "一张 400x400 的三色带图:上红、中绿、下蓝", "visionllm"); err != nil { if err := s.Describe(pic, "一张 400x400 的三色带图:上红、中绿、下蓝", "visionllm"); err != nil {
t.Fatal(err) t.Fatal(err)
} }
var frames []string
for i := 0; i < 6; i++ { for i := 0; i < 6; i++ {
d, _ := s.Put(makePNG(320, 240, i), Item{MIME: "image/jpeg", Tool: "multimodal_see_video"}) 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 { if err := s.Describe(d, fmt.Sprintf("视频第 %d 帧:测试图卡,含彩条与计数器", i+1), "visionllm"); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -175,89 +149,65 @@ func TestSmoke_DescribeThenRetrieve(t *testing.T) {
if len(pend) != 0 { if len(pend) != 0 {
t.Fatalf("应全部已描述,仍有 %d 条待描述", len(pend)) t.Fatalf("应全部已描述,仍有 %d 条待描述", len(pend))
} }
_ = frames
} }
func TestSmoke_ArchiveTransfersOwnership(t *testing.T) { func TestSmoke_ContentSurvivesLayerMigration(t *testing.T) {
// 场景:L0 的 context 事件被 Prune 归档进 L2 文档, // 场景:同一份媒体随记忆块从 Context 迁移到 Document 再到 Graph。
// 媒体引用需从 context owner 转到 document owner,期间内容不能被 GC 掉。 // 迁移的是块本身,digest 不变,因此内容在整条链路上始终可读。
s := newTestStore(t, 50*1024*1024) s := newTestStore(t, 50*1024*1024)
png := makePNG(400, 400, 0) png := makePNG(400, 400, 0)
d, _ := s.Put(png, Item{MIME: "image/png", Tool: "multimodal_see_picture"}) 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 被淘汰,其内容归档为一篇文档 // 迁移过程中该 digest 始终可读
n, err := s.DropOwner("context", "evt-1") for _, layer := range []string{"context", "document", "graph"} {
if err != nil { if got, err := s.Get(d); err != nil || !bytes.Equal(got, png) {
t.Fatal(err) t.Fatalf("迁移到 %s 时内容应完好: %v", layer, err)
}
} }
if n != 1 {
t.Fatalf("应注销 1 条引用,实际 %d", n)
}
if err := s.AddRef(d, "document", "doc_archived_001"); err != nil {
t.Fatal(err)
}
it, _ := s.Stat(d)
if it.RefCount != 5 {
t.Fatalf("引用转移后总数应仍为 5(4 context + 1 document),实际 %d", it.RefCount)
}
// 归档过程中内容必须始终可读
if got, err := s.Get(d); err != nil || !bytes.Equal(got, png) {
t.Fatalf("归档后内容应完好: %v", err)
}
checkRefIntegrity(t, s)
} }
func TestSmoke_GCSweepsToolLeftovers(t *testing.T) { func TestSmoke_DeleteRemovesOnlyThatContent(t *testing.T) {
// 场景:别的工具(cmd_run 之类)产出的一次性图片没人引用, // 场景:某个工具产出的一次性图片所在的记忆块被删除时,
// 应被 GC 清掉;而被记忆引用的媒体一个都不能少。 // 只有它自己的内容被删;其他块的内容一个都不能少。
s := newTestStore(t, 50*1024*1024) s := newTestStore(t)
keep, _ := s.Put(makePNG(400, 400, 0), Item{MIME: "image/png"}) held, _ := s.Put(makePNG(400, 400, 0), Item{MIME: "image/png"})
s.AddRef(keep, "document", "doc-1")
var frames []string var frames []string
for i := 0; i < 6; i++ { for i := 0; i < 6; i++ {
d, _ := s.Put(makePNG(320, 240, i), Item{MIME: "image/jpeg"}) d, _ := s.Put(makePNG(320, 240, i), Item{MIME: "image/jpeg"})
s.AddRef(d, "context", "evt-video")
frames = append(frames, d) frames = append(frames, d)
} }
// 1000+i 保证与上面的帧、以及彼此都不重复 var ephemeral []string
for i := 0; i < 20; i++ { for i := 0; i < 20; i++ {
s.Put(makePNG(100, 100, 1000+i), Item{MIME: "image/png", Tool: "cmd_run"}) d, _ := s.Put(makePNG(100, 100, 1000+i), Item{MIME: "image/png", Tool: "cmd_run"})
ephemeral = append(ephemeral, d)
} }
before := s.Stats()["count"].(int) before := s.Stats()["count"].(int)
removed, freed, err := s.GC(0) for _, d := range ephemeral {
if err != nil { if err := s.Delete(d); err != nil {
t.Fatal(err) t.Fatal(err)
}
} }
after := s.Stats()["count"].(int) after := s.Stats()["count"].(int)
if removed != 20 {
t.Fatalf("应清 20 条孤儿,实际 %d", removed)
}
if after != before-20 { if after != before-20 {
t.Fatalf("条目数应从 %d 降到 %d,实际 %d", before, before-20, after) t.Fatalf("条目数应从 %d 降到 %d,实际 %d", before, before-20, after)
} }
if _, err := s.Get(keep); err != nil { if _, err := s.Get(held); err != nil {
t.Fatalf("被文档引用的图被误删: %v", err) t.Fatalf("被保留的内容被误删: %v", err)
} }
for i, f := range frames { for i, f := range frames {
if _, err := s.Get(f); err != nil { if _, err := s.Get(f); err != nil {
t.Fatalf("第 %d 帧被误删: %v", i, err) 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) { func TestSmoke_FullLifecycleAcrossRestart(t *testing.T) {
// 端到端:入库 → 描述 → 引用 → GC → 重启 → 检索, // 端到端:入库 → 描述 → 删除一些内容 → 重启 → 检索,
// 并确认磁盘与元数据不出现双向孤儿。记忆的意义就在于跨重启还在。 // 并确认磁盘与元数据不出现双向孤儿。记忆的意义就在于跨重启还在。
dir := t.TempDir() dir := t.TempDir()
s, err := New(dir, 50*1024*1024) s, err := New(dir)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -265,22 +215,20 @@ func TestSmoke_FullLifecycleAcrossRestart(t *testing.T) {
png := makePNG(400, 400, 0) png := makePNG(400, 400, 0)
pic, _ := s.Put(png, Item{MIME: "image/png", Width: 400, Height: 400, Tool: "multimodal_see_picture"}) pic, _ := s.Put(png, Item{MIME: "image/png", Width: 400, Height: 400, Tool: "multimodal_see_picture"})
s.Describe(pic, "一张 400x400 的三色带图:上红、中绿、下蓝", "visionllm") s.Describe(pic, "一张 400x400 的三色带图:上红、中绿、下蓝", "visionllm")
s.AddRef(pic, "graph_sentence", "sent-42")
for i := 0; i < 6; i++ { for i := 0; i < 6; i++ {
d, _ := s.Put(makePNG(320, 240, i), Item{MIME: "image/jpeg", Tool: "multimodal_see_video"}) 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.Describe(d, fmt.Sprintf("视频第 %d 帧", i+1), "visionllm")
s.AddRef(d, "context", "evt-video")
} }
for i := 0; i < 10; i++ { for i := 0; i < 10; i++ {
s.Put(makePNG(64, 64, 2000+i), Item{MIME: "image/png", Tool: "cmd_run"}) d, _ := s.Put(makePNG(64, 64, 2000+i), Item{MIME: "image/png", Tool: "cmd_run"})
} if err := s.Delete(d); err != nil {
if _, _, err := s.GC(0); err != nil { t.Fatal(err)
t.Fatal(err) }
} }
beforeCount := s.Stats()["count"].(int) beforeCount := s.Stats()["count"].(int)
s.Close() s.Close()
s2, err := New(dir, 50*1024*1024) s2, err := New(dir)
if err != nil { if err != nil {
t.Fatalf("重开失败: %v", err) t.Fatalf("重开失败: %v", err)
} }
@ -293,16 +241,13 @@ func TestSmoke_FullLifecycleAcrossRestart(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("重开后查不到: %v", err) t.Fatalf("重开后查不到: %v", err)
} }
if it.Description == "" || it.RefCount != 1 { if it.Description == "" {
t.Fatalf("元数据未持久化: %+v", it) t.Fatalf("元数据未持久化: %+v", it)
} }
data, err := s2.Get(pic) data, err := s2.Get(pic)
if err != nil || !bytes.Equal(data, png) { if err != nil || !bytes.Equal(data, png) {
t.Fatalf("重开后内容不一致: %v", err) 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 { if hits, _ := s2.Search("三色带", KindImage, 10); len(hits) != 1 {
t.Fatal("重开后描述应仍可检索") t.Fatal("重开后描述应仍可检索")
} }
@ -311,6 +256,5 @@ func TestSmoke_FullLifecycleAcrossRestart(t *testing.T) {
if n := blobFileCount(t, s2); n != beforeCount { if n := blobFileCount(t, s2); n != beforeCount {
t.Fatalf("磁盘 blob=%d 与元数据=%d 不一致", n, beforeCount) t.Fatalf("磁盘 blob=%d 与元数据=%d 不一致", n, beforeCount)
} }
checkRefIntegrity(t, s2) t.Logf("跨重启:%d 条目、描述与内容全部完好", beforeCount)
t.Logf("跨重启:%d 条目、描述与引用全部完好", beforeCount)
} }

View File

@ -12,14 +12,16 @@ import (
// TestSoak_SustainedMixedLoad 长稳测试:持续混合负载下不变量不破。 // TestSoak_SustainedMixedLoad 长稳测试:持续混合负载下不变量不破。
// 用 -run TestSoak -timeout 300s 单独跑,默认 short 模式跳过。 // 用 -run TestSoak -timeout 300s 单独跑,默认 short 模式跳过。
//
// 媒体没有独立生命周期管理:blob 是记忆块的内容,块被删除时内容随之删除。
func TestSoak_SustainedMixedLoad(t *testing.T) { func TestSoak_SustainedMixedLoad(t *testing.T) {
if testing.Short() { if testing.Short() {
t.Skip("long soak test; run with -run TestSoak") t.Skip("long soak test; run with -run TestSoak")
} }
dur := 60 * time.Second dur := 60 * time.Second
s := newTestStore(t, 8*1024*1024) // 8MB 上限,逼 GC 频繁工作 s := newTestStore(t)
// 常驻受保护集 // 常驻受保护区:全程被记忆块持有,模拟 Graph L3 中的块
const keepN = 20 const keepN = 20
keep := make([]string, keepN) keep := make([]string, keepN)
keepData := make([][]byte, keepN) keepData := make([][]byte, keepN)
@ -31,16 +33,13 @@ func TestSoak_SustainedMixedLoad(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := s.AddRef(dg, "graph_sentence", fmt.Sprintf("s-%d", i)); err != nil {
t.Fatal(err)
}
keep[i] = dg keep[i] = dg
keepData[i] = d keepData[i] = d
} }
stop := make(chan struct{}) stop := make(chan struct{})
var wg sync.WaitGroup var wg sync.WaitGroup
var puts, gets, gcs, describes, searches, refOps atomic.Int64 var puts, gets, deletes, describes, searches atomic.Int64
var fatal atomic.Int64 var fatal atomic.Int64
worker := func(name string, fn func(iter int) error) { worker := func(name string, fn func(iter int) error) {
@ -62,29 +61,17 @@ func TestSoak_SustainedMixedLoad(t *testing.T) {
}() }()
} }
// 写入者 ×3 // 写入者 ×3:持续写入一次性内容(无人持有)
for w := 0; w < 3; w++ { for w := 0; w < 3; w++ {
wid := w wid := w
worker(fmt.Sprintf("put-%d", wid), func(i int) error { worker(fmt.Sprintf("put-%d", wid), func(i int) error {
b := make([]byte, 2048) b := make([]byte, 2048)
rand.Read(b) rand.Read(b)
b = append([]byte(fmt.Sprintf("eph-%d-%d-", wid, i)), 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 := s.Put(b, Item{MIME: "image/png", Tool: "cmd_run"}); err != nil {
if err != nil {
return err return err
} }
puts.Add(1) 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 return nil
}) })
} }
@ -105,13 +92,20 @@ func TestSoak_SustainedMixedLoad(t *testing.T) {
}) })
} }
// GC 者 // 删除者:持续删除一次性内容(模拟块创建后又被遗忘)
worker("gc", func(i int) error { worker("delete", func(i int) error {
if _, _, err := s.GC(0); err != nil { b := make([]byte, 2048)
rand.Read(b)
b = append([]byte(fmt.Sprintf("del-%d-", i)), b...)
d, err := s.Put(b, Item{MIME: "image/png", Tool: "cmd_run"})
if err != nil {
return err return err
} }
gcs.Add(1) if err := s.Delete(d); err != nil {
time.Sleep(5 * time.Millisecond) return err
}
deletes.Add(1)
time.Sleep(time.Millisecond)
return nil return nil
}) })
@ -152,8 +146,8 @@ func TestSoak_SustainedMixedLoad(t *testing.T) {
t.Fatalf("%d 个 worker 报致命错误", n) t.Fatalf("%d 个 worker 报致命错误", n)
} }
t.Logf("%v 内: put=%d get=%d gc=%d describe=%d search=%d refOps=%d", t.Logf("%v 内: put=%d get=%d delete=%d describe=%d search=%d",
dur, puts.Load(), gets.Load(), gcs.Load(), describes.Load(), searches.Load(), refOps.Load()) dur, puts.Load(), gets.Load(), deletes.Load(), describes.Load(), searches.Load())
// 收尾断言 // 收尾断言
for i, d := range keep { for i, d := range keep {
@ -164,17 +158,9 @@ func TestSoak_SustainedMixedLoad(t *testing.T) {
if !bytes.Equal(got, keepData[i]) { if !bytes.Equal(got, keepData[i]) {
t.Fatalf("受保护项内容变了 %s", shortDigest(d)) 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() st := s.Stats()
t.Logf("收尾: 条目=%v 字节=%v 未引用=%v 已描述=%v", t.Logf("收尾: 条目=%v 字节=%v 已描述=%v",
st["count"], st["total_bytes"], st["unreferenced"], st["described"]) st["count"], st["total_bytes"], st["described"])
if total := st["total_bytes"].(int64); total > 8*1024*1024*3 {
t.Fatalf("容量失控: %d 远超上限", total)
}
} }

View File

@ -16,10 +16,12 @@ import (
// 压力测试与冒烟测试。 // 压力测试与冒烟测试。
// //
// 关注点不是吞吐数字,而是并发下的不变量是否被破坏: // 关注点不是吞吐数字,而是并发下的不变量是否被破坏:
// 1. ref_count 与 media_refs 表的行数必须始终一致(错位会让 GC 误删或永不清) // 1. GC 与读写并发时,被记忆块持有的内容绝不能被删
// 2. GC 与读写并发时,有引用的内容绝不能被删 // 2. 同内容并发 Put 只落一份磁盘、digest 一致
// 3. 同内容并发 Put 只落一份磁盘、digest 一致 // 3. SQLite 在多 goroutine 下不出现 "database is locked"
// 4. SQLite 在多 goroutine 下不出现 "database is locked" //
// 存活判定不再依赖 media_refs/ref_count:调用方把「三层记忆当前持有的
// digest 集合」传给 GC,本层只做 CAS。
func randBytes(t *testing.T, n int) []byte { func randBytes(t *testing.T, n int) []byte {
t.Helper() t.Helper()
@ -30,37 +32,6 @@ func randBytes(t *testing.T, n int) []byte {
return b 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)。 // blobFileCount 统计 CAS 目录下的实际文件数(不含 .tmp)。
func blobFileCount(t *testing.T, s *Store) int { func blobFileCount(t *testing.T, s *Store) int {
t.Helper() t.Helper()
@ -117,7 +88,6 @@ func TestStress_ConcurrentPutSameContent(t *testing.T) {
if got, err := s.Get(first); err != nil || !bytes.Equal(got, data) { if got, err := s.Get(first); err != nil || !bytes.Equal(got, data) {
t.Fatalf("内容应可完整读回: err=%v len=%d", err, len(got)) t.Fatalf("内容应可完整读回: err=%v len=%d", err, len(got))
} }
checkRefIntegrity(t, s)
} }
func TestStress_ConcurrentPutDistinctContent(t *testing.T) { func TestStress_ConcurrentPutDistinctContent(t *testing.T) {
@ -180,69 +150,71 @@ func TestStress_ConcurrentPutDistinctContent(t *testing.T) {
if st["count"].(int) != len(records) { if st["count"].(int) != len(records) {
t.Fatalf("库内条目应为 %d,实际 %v", len(records), st["count"]) t.Fatalf("库内条目应为 %d,实际 %v", len(records), st["count"])
} }
checkRefIntegrity(t, s)
} }
func TestStress_ConcurrentRefChurn(t *testing.T) { func TestStress_ConcurrentDeleteAndPut(t *testing.T) {
// 引用增删风暴:多 owner 对少量 digest 反复 AddRef/DropRef。 // 删除与写入并发:核心断言是被保留的内容永远可读,
// 核心断言是最终 ref_count 与 media_refs 行数一致——错位就意味着 // 删除只影响目标 digest,不误伤其他内容。
// GC 会误删(计数偏低)或永不清(计数虚高)。 s := newTestStore(t)
s := newTestStore(t, 0)
const digestCount = 8 const heldCount = 8
digests := make([]string, digestCount) held := make([]string, heldCount)
for i := range digests { for i := range held {
d, err := s.Put([]byte(fmt.Sprintf("payload-%d", i)), Item{MIME: "image/png"}) d, err := s.Put([]byte(fmt.Sprintf("payload-%d", i)), Item{MIME: "image/png"})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
digests[i] = d held[i] = d
} }
const workers = 24 const workers = 16
const rounds = 40 const rounds = 30
var wg sync.WaitGroup var wg sync.WaitGroup
var addErr, dropErr atomic.Int64
for w := 0; w < workers; w++ { for w := 0; w < workers; w++ {
wg.Add(1) wg.Add(1)
go func(wid int) { go func(wid int) {
defer wg.Done() defer wg.Done()
owner := fmt.Sprintf("evt-%d", wid)
for r := 0; r < rounds; r++ { for r := 0; r < rounds; r++ {
d := digests[(wid+r)%digestCount] d, err := s.Put([]byte(fmt.Sprintf("tmp-%d-%d", wid, r)), Item{MIME: "image/png"})
if err := s.AddRef(d, "context", owner); err != nil { if err != nil {
addErr.Add(1) t.Errorf("Put: %v", err)
return
} }
// 故意重复 AddRef:幂等性在并发下也必须成立 if err := s.Delete(d); err != nil {
if err := s.AddRef(d, "context", owner); err != nil { t.Errorf("Delete: %v", err)
addErr.Add(1) return
}
if r%2 == 0 {
if err := s.DropRef(d, "context", owner); err != nil {
dropErr.Add(1)
}
} }
} }
}(w) }(w)
} }
// 并发读取被保留内容
for rdr := 0; rdr < 4; rdr++ {
wg.Add(1)
go func() {
defer wg.Done()
for r := 0; r < rounds; r++ {
for i, d := range held {
if _, err := s.Get(d); err != nil {
t.Errorf("内容 %d 被误删: %v", i, err)
return
}
}
}
}()
}
wg.Wait() wg.Wait()
if n := addErr.Load(); n > 0 { for i, d := range held {
t.Fatalf("AddRef 失败 %d 次", n) if _, err := s.Get(d); err != nil {
t.Fatalf("仍被保留的第 %d 项不可读: %v", i, err)
}
} }
if n := dropErr.Load(); n > 0 {
t.Fatalf("DropRef 失败 %d 次", n)
}
checkRefIntegrity(t, s)
} }
func TestStress_GCConcurrentWithWrites(t *testing.T) { func TestStress_DeleteConcurrentWithReads(t *testing.T) {
// GC 与读写并发。最重要的断言:有引用的内容在整个过程中始终可读。 // 删除与读取并发。最重要的断言:被保留的内容在整个过程中始终可读。
// 这条一旦破,记忆里的 digest 就成了悬空指针。 s := newTestStore(t)
s := newTestStore(t, 0)
// 一批"受保护"的内容,全程持有引用
const protectedCount = 10 const protectedCount = 10
protected := make([]string, protectedCount) protected := make([]string, protectedCount)
protectedData := make([][]byte, protectedCount) protectedData := make([][]byte, protectedCount)
@ -252,9 +224,6 @@ func TestStress_GCConcurrentWithWrites(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := s.AddRef(d, "document", fmt.Sprintf("doc-%d", i)); err != nil {
t.Fatal(err)
}
protected[i] = d protected[i] = d
protectedData[i] = data protectedData[i] = data
} }
@ -262,10 +231,10 @@ func TestStress_GCConcurrentWithWrites(t *testing.T) {
stop := make(chan struct{}) stop := make(chan struct{})
var wg sync.WaitGroup var wg sync.WaitGroup
var readErr atomic.Int64 var readErr atomic.Int64
var gcRuns atomic.Int64 var deleteCount atomic.Int64
var putCount atomic.Int64 var putCount atomic.Int64
// 写入者:持续 Put 一次性内容(不加引用,是 GC 的正常目标) // 写入者:持续 Put 一次性内容再删除(模拟块创建后又被遗忘)
for w := 0; w < 4; w++ { for w := 0; w < 4; w++ {
wg.Add(1) wg.Add(1)
go func(wid int) { go func(wid int) {
@ -278,8 +247,13 @@ func TestStress_GCConcurrentWithWrites(t *testing.T) {
default: default:
} }
data := append([]byte(fmt.Sprintf("ephemeral-%d-%d-", wid, i)), randBytes(t, 128)...) data := append([]byte(fmt.Sprintf("ephemeral-%d-%d-", wid, i)), randBytes(t, 128)...)
if _, err := s.Put(data, Item{MIME: "image/png"}); err == nil { d, err := s.Put(data, Item{MIME: "image/png"})
putCount.Add(1) if err != nil {
continue
}
putCount.Add(1)
if err := s.Delete(d); err == nil {
deleteCount.Add(1)
} }
i++ i++
} }
@ -314,33 +288,14 @@ func TestStress_GCConcurrentWithWrites(t *testing.T) {
}() }()
} }
// GC 者:minAge=0 让所有无引用项立刻可清,最大化与写入的冲突
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-stop:
return
default:
}
if _, _, err := s.GC(0); err != nil {
t.Errorf("GC 报错: %v", err)
return
}
gcRuns.Add(1)
time.Sleep(time.Millisecond)
}
}()
time.Sleep(1500 * time.Millisecond) time.Sleep(1500 * time.Millisecond)
close(stop) close(stop)
wg.Wait() wg.Wait()
if n := readErr.Load(); n > 0 { if n := readErr.Load(); n > 0 {
t.Fatalf("受保护内容读取失败 %d 次——GC 误删了有引用的项", n) t.Fatalf("受保护内容读取失败 %d 次", n)
} }
t.Logf("并发窗口内: Put=%d GC=%d 轮", putCount.Load(), gcRuns.Load()) t.Logf("并发窗口内: Put=%d Delete=%d", putCount.Load(), deleteCount.Load())
// 收尾确认:受保护的一个都没少 // 收尾确认:受保护的一个都没少
for i, d := range protected { for i, d := range protected {
@ -348,12 +303,7 @@ func TestStress_GCConcurrentWithWrites(t *testing.T) {
if err != nil || !bytes.Equal(got, protectedData[i]) { if err != nil || !bytes.Equal(got, protectedData[i]) {
t.Fatalf("收尾检查失败 %s: %v", shortDigest(d), err) 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) { func TestStress_DescribeConcurrentWithSearch(t *testing.T) {
@ -427,61 +377,46 @@ func TestStress_DescribeConcurrentWithSearch(t *testing.T) {
} }
} }
func TestStress_CapacityGCUnderLoad(t *testing.T) { func TestStress_DeleteUnderLoad(t *testing.T) {
// 容量上限在持续写入下必须真正生效,且不碰有引用的项。 // 持续写入 + 删除下,被保留的项必须始终可读。
const cap = 256 * 1024 // 256KB s := newTestStore(t)
s := newTestStore(t, cap)
// 先放 3 个有引用的大项(合计约 96KB),它们永不可删
const keepN = 3 const keepN = 3
keep := make([]string, keepN) keepList := make([]string, keepN)
for i := range keep { for i := range keepList {
data := append([]byte(fmt.Sprintf("keep-%d-", i)), randBytes(t, 32*1024)...) data := append([]byte(fmt.Sprintf("keep-%d-", i)), randBytes(t, 32*1024)...)
d, err := s.Put(data, Item{MIME: "image/png"}) d, err := s.Put(data, Item{MIME: "image/png"})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := s.AddRef(d, "graph_sentence", fmt.Sprintf("sent-%d", i)); err != nil { keepList[i] = d
t.Fatal(err)
}
keep[i] = d
} }
// 持续写入无引用内容,交替 GC
for round := 0; round < 30; round++ { for round := 0; round < 30; round++ {
for i := 0; i < 3; i++ { for i := 0; i < 3; i++ {
data := append([]byte(fmt.Sprintf("tmp-%d-%d-", round, i)), randBytes(t, 16*1024)...) data := append([]byte(fmt.Sprintf("tmp-%d-%d-", round, i)), randBytes(t, 16*1024)...)
if _, err := s.Put(data, Item{MIME: "image/png"}); err != nil { d, err := s.Put(data, Item{MIME: "image/png"})
if err != nil {
t.Fatalf("round %d Put: %v", round, err) t.Fatalf("round %d Put: %v", round, err)
} }
} if err := s.Delete(d); err != nil {
if _, _, err := s.GC(0); err != nil { t.Fatalf("round %d Delete: %v", round, err)
t.Fatalf("round %d GC: %v", round, err) }
} }
} }
st := s.Stats() // 被保留的项必须都在
total := st["total_bytes"].(int64) for _, d := range keepList {
t.Logf("上限 %d,收尾总量 %d,条目 %v", cap, total, st["count"])
// 有引用的项必须都在
for _, d := range keep {
if _, err := s.Get(d); err != nil { if _, err := s.Get(d); err != nil {
t.Fatalf("有引用项被容量 GC 删了 %s: %v", shortDigest(d), err) t.Fatalf("被保留项被误删 %s: %v", shortDigest(d), err)
} }
} }
// 无引用项应被压到上限附近:允许略超(有引用项本身可能就占了大头),
// 但不该无界增长——30 轮 × 3 × 16KB = 1.4MB 若全留下就是失控。
if total > cap*2 {
t.Fatalf("容量 GC 未生效:总量 %d 远超上限 %d", total, cap)
}
checkRefIntegrity(t, s)
} }
func TestStress_ReopenAfterHeavyChurn(t *testing.T) { func TestStress_ReopenAfterHeavyChurn(t *testing.T) {
// 大量写入 + GC 之后重开:元数据与磁盘不该出现互相不认的孤儿。 // 大量写入 + 删除之后重开:元数据与磁盘不该出现互相不认的孤儿。
dir := t.TempDir() dir := t.TempDir()
s1, err := New(dir, 0) s1, err := New(dir)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -494,19 +429,15 @@ func TestStress_ReopenAfterHeavyChurn(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
if i%5 == 0 { if i%5 == 0 {
if err := s1.AddRef(d, "context", fmt.Sprintf("e-%d", i)); err != nil {
t.Fatal(err)
}
kept = append(kept, d) kept = append(kept, d)
} else if err := s1.Delete(d); err != nil {
t.Fatal(err)
} }
} }
if _, _, err := s1.GC(0); err != nil {
t.Fatal(err)
}
beforeStats := s1.Stats() beforeStats := s1.Stats()
s1.Close() s1.Close()
s2, err := New(dir, 0) s2, err := New(dir)
if err != nil { if err != nil {
t.Fatalf("重开失败: %v", err) t.Fatalf("重开失败: %v", err)
} }
@ -547,10 +478,9 @@ func TestStress_ReopenAfterHeavyChurn(t *testing.T) {
for _, d := range kept { for _, d := range kept {
if _, err := s2.Get(d); err != nil { if _, err := s2.Get(d); err != nil {
t.Fatalf("有引用项重开后读不到 %s: %v", shortDigest(d), err) t.Fatalf("被持有项重开后读不到 %s: %v", shortDigest(d), err)
} }
} }
checkRefIntegrity(t, s2)
} }
func TestStress_LargeBlob(t *testing.T) { func TestStress_LargeBlob(t *testing.T) {
@ -597,5 +527,4 @@ func TestStress_DataURLRoundTripAtScale(t *testing.T) {
t.Fatalf("第 %d 次入库回读不一致: %v", i, err) t.Fatalf("第 %d 次入库回读不一致: %v", i, err)
} }
} }
checkRefIntegrity(t, s)
} }

View File

@ -6,6 +6,8 @@ import (
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
"sync/atomic"
"time"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory" "gitcode.com/JianFeeeee/HomeAgent/internal/memory"
doc "gitcode.com/JianFeeeee/HomeAgent/internal/memory/document" doc "gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
@ -81,37 +83,87 @@ func sdkDigestsIn(s string) map[string]bool {
return out return out
} }
// sdkBindText 把文本里引用的媒体挂到 owner 上,返回新挂上的条数。 // sdkBlockSeq 保证块 ID 全局唯一:Graph 的 memory_blocks 以 id 为主键,
// // 不同文档里序号相同的块会在 L2→L3 迁移时相互覆盖。
// 短 digest 补全失败(内容已 GC、或前缀有歧义)就跳过那一条:挂一条对不上的 var sdkBlockSeq int64
// 引用比不挂更糟——owner_kind/owner_id/digest 三者进了主键,digest 错了则
// DropOwner 永远匹配不到它,那是一条永久泄漏的引用。 func sdkNewBlockID() string {
// return fmt.Sprintf("blk_%d_%d", time.Now().UnixNano(), atomic.AddInt64(&sdkBlockSeq, 1))
// done 记录本次已处理过的 digest。AddRef 幂等,重复挂不会多出一条引用, }
// 但会让计数虚高——文档路径先按附件挂一遍、再扫正文标记挂一遍,
// 同一份媒体会被数两次,日志里「绑定 2 个」而实际只有 1 条引用。 // sdkBlockModality 把 CAS 的媒体大类映射为一等记忆块的模态。
func sdkBindText(ms *media.Store, text, ownerKind, ownerID string, done map[string]bool) int { func sdkBlockModality(k media.Kind) memory.BlockModality {
if ms == nil || text == "" || ownerID == "" { switch k {
return 0 case media.KindImage:
return memory.BlockImage
case media.KindVideo:
return memory.BlockVideo
case media.KindAudio:
return memory.BlockAudio
default:
return memory.BlockText
} }
bound := 0 }
// sdkBlockForDigest 把一份已入库的媒体变成一个一等记忆块。
// 块自身携带 digest/向量/ fingerprint;CAS 只提供字节与元数据,不参与生命周期。
func sdkBlockForDigest(ms *media.Store, digest string) (memory.MemoryBlock, bool) {
it, err := ms.Stat(digest)
if err != nil || it == nil {
return memory.MemoryBlock{}, false
}
return memory.MemoryBlock{
ID: sdkNewBlockID(),
Modality: sdkBlockModality(it.Kind),
PayloadDigest: it.Digest,
MIME: it.MIME,
Size: it.Size,
Width: it.Width,
Height: it.Height,
Vector: it.Vec,
Fingerprint: it.VecModel,
Tool: it.Tool,
CreatedAt: it.FirstSeen,
}, true
}
// sdkBlocksFromText 把文本标记里的媒体变成一等块(去重)。
func sdkBlocksFromText(ms *media.Store, text string) []memory.MemoryBlock {
if ms == nil || text == "" {
return nil
}
seen := map[string]bool{}
var blocks []memory.MemoryBlock
for _, m := range sdkMarkerPattern.FindAllStringSubmatch(text, -1) { for _, m := range sdkMarkerPattern.FindAllStringSubmatch(text, -1) {
full, err := ms.ResolvePrefix(m[2]) full, err := ms.ResolvePrefix(m[2])
if err != nil { if err != nil || seen[full] {
continue continue
} }
if done != nil && done[full] { seen[full] = true
continue if b, ok := sdkBlockForDigest(ms, full); ok {
blocks = append(blocks, b)
} }
if err := ms.AddRef(full, ownerKind, ownerID); err != nil {
continue
}
if done != nil {
done[full] = true
}
bound++
} }
return bound return blocks
}
// sdkBlocksFromDigests 为显式 digest 列表构造一等块(去重)。
func sdkBlocksFromDigests(ms *media.Store, digests []string) []memory.MemoryBlock {
if ms == nil || len(digests) == 0 {
return nil
}
seen := map[string]bool{}
var blocks []memory.MemoryBlock
for _, d := range digests {
if d == "" || seen[d] {
continue
}
seen[d] = true
if b, ok := sdkBlockForDigest(ms, d); ok {
blocks = append(blocks, b)
}
}
return blocks
} }
// sdkPutAttachment 把一份附件解析成完整 digest。 // sdkPutAttachment 把一份附件解析成完整 digest。
@ -208,11 +260,10 @@ func (m *graphMemory) Recall(query []string, depth int) ([]Entity, []Relation, e
return entities, relations, nil return entities, relations, nil
} }
// Commit 把插件的三元组写入图库,并把三元组引用的媒体挂到句子上。 // Commit 把插件的三元组写入图库,并把三元组句子里的媒体变成 L3 一等块。
// //
// 媒体的绑定链是 SentenceText → sentences 表 → sentence_id → media_refs。 // 媒体的落点链是 SentenceText → sentences 表 → sentence_id → 块边。
// 旧实现丢掉 SentenceText 又走 Commit(不回 sentenceIDs),这条链一步都走不通: // 旧实现丢掉 SentenceText 又走 Commit(不回 sentenceIDs),这条链一步都走不通。
// 插件即便按格式写好标记,媒体也永远挂不上。
func (m *graphMemory) Commit(triples []Triple) error { func (m *graphMemory) Commit(triples []Triple) error {
if m.db == nil { if m.db == nil {
return nil return nil
@ -280,9 +331,11 @@ func (m *graphMemory) sentenceWithMedia(sentence string, digests []string) strin
return sentence + "\n" + strings.Join(add, "\n") return sentence + "\n" + strings.Join(add, "\n")
} }
// bindSentences 把每条句子里引用的媒体挂到该句子的 graph_sentence owner 上。 // bindSentences 把每条句子里引用的媒体变成 L3 的一等记忆块,
// 并建立 sentence --contains--> block 的结构边。
// 不再写 media_refs:块本身就是图的一部分,不需要 owner 账本保活。
func (m *graphMemory) bindSentences(sentenceIDs map[string]int64) { func (m *graphMemory) bindSentences(sentenceIDs map[string]int64) {
if m.ms == nil || len(sentenceIDs) == 0 { if m.ms == nil || m.db == nil || len(sentenceIDs) == 0 {
return return
} }
bound := 0 bound := 0
@ -290,13 +343,20 @@ func (m *graphMemory) bindSentences(sentenceIDs map[string]int64) {
if sid == 0 { if sid == 0 {
continue continue
} }
// 每条句子一个独立的 done 集:同一份媒体挂在不同句子上是两条 for _, b := range sdkBlocksFromText(m.ms, text) {
// 合法引用(owner_id 不同),不该被跨句子去重。 if err := m.db.PutMemoryBlocks([]memory.MemoryBlock{b}); err != nil {
bound += sdkBindText(m.ms, text, media.OwnerGraphSentence, log.Printf("[sdk media] 插件 %s 写入 L3 记忆块失败: %v", m.plugin, err)
strconv.FormatInt(sid, 10), map[string]bool{}) continue
}
if err := m.db.AddMemoryBlockEdge("sentence", strconv.FormatInt(sid, 10), "block", b.ID, "contains"); err != nil {
log.Printf("[sdk media] 插件 %s 建立句子→块边失败: %v", m.plugin, err)
continue
}
bound++
}
} }
if bound > 0 { if bound > 0 {
log.Printf("[sdk media] 插件 %s 的三元组绑定 %d 个媒体引用", m.plugin, bound) log.Printf("[sdk media] 插件 %s 的三元组写入 %d 个 L3 记忆块", m.plugin, bound)
} }
} }
@ -345,7 +405,7 @@ func NewTextMemoryWithMedia(plugin string, tm *text.Memory, ms *media.Store) Tex
// Append 追加一条文本事件;带附件时把媒体标记并进正文。 // Append 追加一条文本事件;带附件时把媒体标记并进正文。
// //
// 文本记忆是追加写 JSONL,没有稳定 owner_id 可挂 media_refs,所以媒体在这一层 // 文本记忆是追加写 JSONL,没有结构化块存储,所以媒体在这一层
// 只能以标记形式存在。这不是妥协——描述文本才是持久的语义记忆,blob 只是缓存。 // 只能以标记形式存在。这不是妥协——描述文本才是持久的语义记忆,blob 只是缓存。
func (m *textMemoryImpl) Append(evt TextEvent) error { func (m *textMemoryImpl) Append(evt TextEvent) error {
if m.tm == nil { if m.tm == nil {
@ -431,40 +491,56 @@ func (m *docMemoryImpl) Query(text string, topK int) []*Doc {
out := make([]*Doc, len(got)) out := make([]*Doc, len(got))
for i, d := range got { for i, d := range got {
out[i] = &Doc{ID: d.ID, Title: d.Summary, Content: d.Content} out[i] = &Doc{ID: d.ID, Title: d.Summary, Content: d.Content}
m.fillMedia(out[i]) m.fillMedia(out[i], d)
} }
return out return out
} }
// fillMedia 填充文档的媒体字段。 // fillMedia 填充文档的媒体字段。
// //
// 优先用 media_refs(权威:谁挂上去的就是谁),为空时退回解析正文标记—— // 优先读一等记忆块(文档直接持有),为空时退回解析正文标记——
// 历史文档与经旧版插件写入的文档只有标记、没有引用。 // 历史文档与经旧版插件写入的文档只有标记、没有块。
func (m *docMemoryImpl) fillMedia(out *Doc) { func (m *docMemoryImpl) fillMedia(out *Doc, d *doc.Doc) {
if m.ms == nil { if m.ms == nil {
return return
} }
digests, err := m.ms.Refs(media.OwnerDocument, out.ID) if d != nil && len(d.Blocks) > 0 {
if err != nil { for _, b := range d.Blocks {
log.Printf("[sdk media] 读取文档 %s 的媒体引用失败: %v", out.ID, err) if b.PayloadDigest == "" {
} continue
if len(digests) == 0 { }
out.Attachments = sdkAttachmentsFromText(m.ms, out.Content) out.MediaDigests = append(out.MediaDigests, b.PayloadDigest)
for _, a := range out.Attachments { att := MediaAttachment{Digest: b.PayloadDigest, MIME: b.MIME, Description: ""}
out.MediaDigests = append(out.MediaDigests, a.Digest) if it, err := m.ms.Stat(b.PayloadDigest); err == nil && it != nil {
att.MIME = it.MIME
att.Description = it.Description
}
out.Attachments = append(out.Attachments, att)
} }
return return
} }
out.MediaDigests = digests out.Attachments = sdkAttachmentsFromText(m.ms, out.Content)
for _, d := range digests { for _, a := range out.Attachments {
it, err := m.ms.Stat(d) out.MediaDigests = append(out.MediaDigests, a.Digest)
if err != nil || it == nil { }
}
// appendBlocks 把新的块追加到已有块之后(按 digest 去重)。
func appendBlocks(existing []memory.MemoryBlock, add []memory.MemoryBlock) []memory.MemoryBlock {
seen := make(map[string]bool, len(existing))
for _, b := range existing {
seen[b.PayloadDigest] = true
}
for _, b := range add {
if b.PayloadDigest != "" && seen[b.PayloadDigest] {
continue continue
} }
out.Attachments = append(out.Attachments, MediaAttachment{ existing = append(existing, b)
Digest: it.Digest, MIME: it.MIME, Description: it.Description, if b.PayloadDigest != "" {
}) seen[b.PayloadDigest] = true
}
} }
return existing
} }
// Insert 写入文档。正文里已有的媒体标记会被挂成文档级引用, // Insert 写入文档。正文里已有的媒体标记会被挂成文档级引用,
@ -487,14 +563,17 @@ func (m *docMemoryImpl) InsertWithMedia(d *Doc, attachments []MediaAttachment) e
digests := m.storeAttachments(attachments, &target.Content) digests := m.storeAttachments(attachments, &target.Content)
// 一等记忆块:文档直接持有块本身,CAS 只提供字节与向量。
// 不再写 media_refs——块随文档一同存活或被删除,无需 owner 账本。
target.Blocks = appendBlocks(target.Blocks,
append(sdkBlocksFromDigests(m.ms, digests), sdkBlocksFromText(m.ms, target.Content)...))
if err := m.ds.Insert(target); err != nil { if err := m.ds.Insert(target); err != nil {
return err return err
} }
// 回填给调用方:ID 是新建时内核生成的,Content 含内核补的标记。 // 回填给调用方:ID 是新建时内核生成的,Content 含内核补的标记。
d.ID = target.ID d.ID = target.ID
d.Content = target.Content d.Content = target.Content
m.bindDocMedia(target, digests)
return nil return nil
} }
@ -531,51 +610,39 @@ func (m *docMemoryImpl) storeAttachments(atts []MediaAttachment, content *string
return digests return digests
} }
// bindDocMedia 把附件与正文标记引用的媒体一起挂到文档 owner 上。 // Remove 删除文档,并删除它持有的一等块所对应的内容(无其他块共享时)。
func (m *docMemoryImpl) bindDocMedia(target *doc.Doc, digests []string) {
if m.ms == nil || target.ID == "" {
return
}
bound := 0
done := make(map[string]bool, len(digests))
for _, full := range digests {
if done[full] {
continue
}
if err := m.ms.AddRef(full, media.OwnerDocument, target.ID); err != nil {
log.Printf("[sdk media] 文档引用绑定失败 (%s → doc %s): %v",
sdkShortDigest(full), target.ID, err)
continue
}
done[full] = true
bound++
}
// 插件手写在正文里的标记同样要挂上,否则那些媒体在文档里可见却无主。
// 共用 done:附件刚挂过的那些是同一份媒体(内核自己把标记补进了正文)。
bound += sdkBindText(m.ms, target.Content, media.OwnerDocument, target.ID, done)
if bound > 0 {
log.Printf("[sdk media] 插件 %s 写入文档 %s,绑定 %d 个媒体引用",
m.plugin, target.ID, bound)
}
}
// Remove 删除文档,同时释放它持有的媒体引用。
// //
// 旧实现只删文档不解引用,于是那些媒体永久处于「被引用」状态:GC 不回收, // 与文本块一致:删除块即删除内容。媒体字节是块的内容存储,
// 磁盘只增不减。内核的归档路径(distill 的 releaseDocMedia)做了这一步, // 不单独做引用计数或 GC。
// 插件路径漏了同一步。
func (m *docMemoryImpl) Remove(id string) { func (m *docMemoryImpl) Remove(id string) {
if m.ds == nil { if m.ds == nil {
return return
} }
if m.ms != nil && id != "" { var digests []string
if n, err := m.ms.DropOwner(media.OwnerDocument, id); err != nil { if d := m.ds.Get(id); d != nil {
log.Printf("[sdk media] 释放文档 %s 的媒体引用失败: %v", id, err) for _, b := range d.Blocks {
} else if n > 0 { if b.PayloadDigest != "" {
log.Printf("[sdk media] 文档 %s 删除,释放 %d 个媒体引用", id, n) digests = append(digests, b.PayloadDigest)
}
} }
} }
m.ds.Remove(id) m.ds.Remove(id)
if m.ms == nil {
return
}
// 仍被其它文档持有的 digest 不能删(同一份字节可能被多个块共享)。
stillHeld := map[string]bool{}
for _, b := range m.ds.Blocks() {
stillHeld[b.PayloadDigest] = true
}
for _, d := range digests {
if stillHeld[d] {
continue
}
if err := m.ms.Delete(d); err != nil {
log.Printf("[sdk media] 删除文档 %s 的内容失败 %s: %v", id, sdkShortDigest(d), err)
}
}
} }
func (m *docMemoryImpl) Stats() map[string]interface{} { func (m *docMemoryImpl) Stats() map[string]interface{} {

View File

@ -30,7 +30,7 @@ func newTestStores(t *testing.T) (*memory.GraphDB, *doc.Store, *text.Memory, *me
} }
t.Cleanup(func() { g.Close() }) t.Cleanup(func() { g.Close() })
ds := doc.NewStore(filepath.Join(dir, "documents")) ds := doc.NewStore(filepath.Join(dir, "documents"), memory.TokenizeWords)
if err := ds.Start(); err != nil { if err := ds.Start(); err != nil {
t.Fatalf("doc store start: %v", err) t.Fatalf("doc store start: %v", err)
} }
@ -42,7 +42,7 @@ func newTestStores(t *testing.T) (*memory.GraphDB, *doc.Store, *text.Memory, *me
} }
t.Cleanup(func() { tm.Stop() }) t.Cleanup(func() { tm.Stop() })
ms, err := media.New(filepath.Join(dir, "media"), 0) ms, err := media.New(filepath.Join(dir, "media"))
if err != nil { if err != nil {
t.Fatalf("media.New: %v", err) t.Fatalf("media.New: %v", err)
} }
@ -140,12 +140,12 @@ func TestGraphCommit_BindsMediaFromDigests(t *testing.T) {
t.Errorf("句子里没有媒体描述: %q", res.Relations[0].SentenceText) t.Errorf("句子里没有媒体描述: %q", res.Relations[0].SentenceText)
} }
refs, err := ms.Refs(media.OwnerGraphSentence, strconv.FormatInt(sid, 10)) blocks, err := g.BlocksForNode("sentence", strconv.FormatInt(sid, 10))
if err != nil { if err != nil {
t.Fatalf("Refs: %v", err) t.Fatalf("BlocksForNode: %v", err)
} }
if len(refs) != 1 || refs[0] != digest { if len(blocks) != 1 || blocks[0].PayloadDigest != digest {
t.Errorf("句子 #%d 的媒体引用 = %v,期望 [%s]", sid, refs, digest) t.Errorf("句子 #%d 的媒体块 = %+v,期望 [%s]", sid, blocks, digest)
} }
} }
@ -250,15 +250,12 @@ func TestDocInsertWithMedia_StoresAndBinds(t *testing.T) {
t.Errorf("正文里没有媒体标记: %q", d.Content) t.Errorf("正文里没有媒体标记: %q", d.Content)
} }
refs, err := ms.Refs(media.OwnerDocument, d.ID) blocks := ds.Blocks()
if err != nil { if len(blocks) != 1 || blocks[0].PayloadDigest == "" {
t.Fatalf("Refs: %v", err) t.Fatalf("文档记忆块 = %+v,期望 1 条", blocks)
}
if len(refs) != 1 {
t.Fatalf("文档媒体引用 = %v,期望 1 条", refs)
} }
// 内容可读,说明真的落盘了而不只是记了个 digest。 // 内容可读,说明真的落盘了而不只是记了个 digest。
got, err := ms.Get(refs[0]) got, err := ms.Get(blocks[0].PayloadDigest)
if err != nil || string(got) != "attachment-bytes" { if err != nil || string(got) != "attachment-bytes" {
t.Errorf("媒体内容读回失败: %v / %q", err, got) t.Errorf("媒体内容读回失败: %v / %q", err, got)
} }
@ -279,9 +276,9 @@ func TestDocInsertWithMedia_DigestOnlyReference(t *testing.T) {
if after := ms.Stats()["count"]; after != before { if after := ms.Stats()["count"]; after != before {
t.Errorf("媒体条数从 %v 变成 %v —— 引用已有内容不该新增", before, after) t.Errorf("媒体条数从 %v 变成 %v —— 引用已有内容不该新增", before, after)
} }
refs, _ := ms.Refs(media.OwnerDocument, d.ID) blocks := ds.Blocks()
if len(refs) != 1 || refs[0] != digest { if len(blocks) != 1 || blocks[0].PayloadDigest != digest {
t.Errorf("引用 = %v,期望 [%s]", refs, digest) t.Errorf("引用 = %+v,期望 [%s]", blocks, digest)
} }
} }
@ -350,8 +347,8 @@ func TestDocQuery_FallsBackToMarkers(t *testing.T) {
} }
} }
// 旧实现删文档不解引用 → 媒体永久"被引用",GC 收不掉,磁盘只增不减。 // 文档被删除时它持有的一等记忆块随之消失,媒体不再被任何记忆块持有。
func TestDocRemove_ReleasesMediaRefs(t *testing.T) { func TestDocRemove_DropsBlocks(t *testing.T) {
_, ds, _, ms := newTestStores(t) _, ds, _, ms := newTestStores(t)
dm := NewDocMemoryWithMedia("tester", ds, ms) dm := NewDocMemoryWithMedia("tester", ds, ms)
@ -361,14 +358,14 @@ func TestDocRemove_ReleasesMediaRefs(t *testing.T) {
}}); err != nil { }}); err != nil {
t.Fatalf("InsertWithMedia: %v", err) t.Fatalf("InsertWithMedia: %v", err)
} }
if refs, _ := ms.Refs(media.OwnerDocument, d.ID); len(refs) != 1 { if blocks := ds.Blocks(); len(blocks) != 1 {
t.Fatalf("前置条件不成立,引用 = %v", refs) t.Fatalf("前置条件不成立,块 = %+v", blocks)
} }
dm.Remove(d.ID) dm.Remove(d.ID)
if refs, _ := ms.Refs(media.OwnerDocument, d.ID); len(refs) != 0 { if blocks := ds.Blocks(); len(blocks) != 0 {
t.Errorf("删除文档后仍有 %v 条引用 —— GC 永远收不掉这份媒体", refs) t.Errorf("删除文档后仍持有 %+v —— 媒体仍被记忆引用", blocks)
} }
} }