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

View File

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

View File

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

View File

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

View File

@ -10,7 +10,6 @@ import (
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector"
"gitcode.com/JianFeeeee/HomeAgent/internal/nlp"
)
@ -184,7 +183,7 @@ func (a *Agent) archiveColdDocs() {
if len(triples) == 0 {
continue
}
ec, rc, mediaBound, err := a.commitTriplesWithMedia(triples, string(a.id)+"_doc_archival", 0)
ec, rc, blocks, err := a.commitTriplesWithMedia(triples, string(a.id)+"_doc_archival", 0, doc.Blocks)
if err != nil {
log.Printf("[agent] doc→graph archival error: %v", err)
continue
@ -203,64 +202,15 @@ func (a *Agent) archiveColdDocs() {
"(三元组 %d 条全被实体名校验拒绝)", doc.ID, len(triples))
continue
}
log.Printf("[agent] doc→graph: %s → %d entities, %d relations", doc.ID, ec, rc)
log.Printf("[agent] doc→graph: %s → %d entities, %d relations, %d blocks", doc.ID, ec, rc, blocks)
// 先销媒体引用再删文档:文档一旦从 docStore 消失,就再没有任何
// 东西能告诉我们它曾经引用过哪些 digestmedia_refs 里那条记录
// 就永久悬空、引用计数永不归零,导致 blob 永远不会被 GC 回收。
//
// 但只有在引用**确实**转移到 graph_sentence 之后才能释放:
// 图库里没有任何句子承载这些 digest 时释放旧引用,计数归零,
// GC 会把内容当孤儿删掉。宁可留一条悬空引用(内容还在,可由
// 后续一致性检查清理),也不能丢内容。
refs, refErr := a.docMediaRefs(doc.ID)
switch {
case refErr != nil:
log.Printf("[media] 查文档 %s 的媒体引用失败,保守不释放: %v", doc.ID, refErr)
case len(refs) == 0:
// 该文档本就没有媒体引用,无需释放。
case mediaBound == 0:
log.Printf("[media] 文档 %s 有 %d 个媒体引用但图库一个都没绑上,"+
"保留引用以免 GC 删除内容(句子正文里可能没有可反解的短 digest",
doc.ID, len(refs))
default:
a.releaseDocMedia(doc.ID)
}
// 文档的一等记忆块已随句子写进 L3身份不变由 bindSentenceBlocks
// 复用 doc.Blocks 的 ID块不再挂在文档上删除文档即完成迁移。
a.docStore.Remove(doc.ID)
}
}
}
// docMediaRefs 返回文档当前持有的媒体引用nil store 时为空)。
//
// 单独取出来是为了让归档路径能在释放前先确认「有没有东西要释放」——
// 没有引用时不必打日志,有引用但没绑上图库时必须保留。
func (a *Agent) docMediaRefs(docID string) ([]string, error) {
if a.mediaStore == nil || docID == "" {
return nil, nil
}
return a.mediaStore.Refs(media.OwnerDocument, docID)
}
// releaseDocMedia 注销文档持有的全部媒体引用。
//
// L2→L3 这一跳不再转移引用而是直接释放,因为图库存的是从描述
// 文本里抽出的实体与关系,不再持有字节。媒体本身此时已完成使命:
// 描述已经进了图库blob 可以交给容量 GC 决定去留。
func (a *Agent) releaseDocMedia(docID string) {
if a.mediaStore == nil || docID == "" {
return
}
n, err := a.mediaStore.DropOwner(media.OwnerDocument, docID)
if err != nil {
log.Printf("[media] 文档归档释放引用失败 (doc %s): %v", docID, err)
return
}
if n > 0 {
log.Printf("[media] 文档 %s 入图库,释放 %d 个媒体引用(描述已留在图库)", docID, n)
}
}
// ──────────────────────────────────────────────
// 实体合并检测GraphDB → LLM 裁决
// ──────────────────────────────────────────────

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@ -19,7 +19,7 @@ func (a *Agent) buildMemoryContext(input string, maxTokens int) string {
// 不做这一步的后果:媒体描述进了 L3agent 却拿不出来。图库句子里
// 写着 [image/png a1b2c3d4e5f6] 这样的短标记,但没有任何东西告诉
// 模型那份内容是否还在、能否重新查看——描述永存而 blob 可能已被
// 容量 GC 淘汰,两者状态不同,必须显式告知。
// 删除,两者状态不同,必须显式告知。
//
// 注意不能直接用 injected.RelationsBuildContext 刻意把它置为 nil
//(自动注入只给实体索引以省 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.text", Default: filepath.Join(dataDir, "memory", "text"), Type: "string", DisplayName: "文本记忆路径", Description: "短期文本记忆存储目录", Category: "paths"})
reg(ConfigDef{Key: "core.memory.documents", Default: filepath.Join(dataDir, "memory", "documents"), Type: "string", DisplayName: "文档记忆路径", Description: "文档记忆存储目录", Category: "paths"})
reg(ConfigDef{Key: "core.memory.media.enabled", Default: "true", Type: "bool", DisplayName: "媒体记忆", Description: "把对话里出现的图片/音频按内容摘要(sha256落盘去重,记忆各层只记 digest。关闭后媒体仅在当前对话内可见,下一轮起只剩路径或 alt 文本", Category: "memory"})
reg(ConfigDef{Key: "core.memory.media.enabled", Default: "true", Type: "bool", DisplayName: "媒体记忆", Description: "把对话里出现的图片/音频变成一等记忆块,内容按 sha256 落盘去重。关闭后媒体仅在当前对话内可见,下一轮起只剩路径或 alt 文本", Category: "memory"})
reg(ConfigDef{Key: "core.memory.media.dir", Default: filepath.Join(dataDir, "memory", "media"), Type: "string", DisplayName: "媒体存储路径", Description: "媒体内容寻址存储目录(内含 media.db 与 blobs/", Category: "paths"})
reg(ConfigDef{Key: "core.memory.media.max_mb", Default: "2048", Type: "int", DisplayName: "媒体容量上限(MB)", Description: "超限时按最后访问时间淘汰无引用的媒体;被记忆引用的内容即使超限也不会删除(宁可超限也不断引用)。描述文本不受此限,淘汰后仍可检索", Category: "memory"})
reg(ConfigDef{Key: "core.memory.media.gc_interval", Default: "6h", Type: "duration", DisplayName: "媒体 GC 间隔", Description: "清理无引用媒体的周期0 表示不自动清理", Category: "memory"})
reg(ConfigDef{Key: "core.memory.media.gc_min_age", Default: "1h", Type: "duration", DisplayName: "媒体 GC 保护期", Description: "新入库媒体在此时长内不被清理。刚落盘还没来得及挂到记忆上的项引用计数也是 0靠这个保护期避免被误删", Category: "memory"})
reg(ConfigDef{Key: "core.memory.media.describe_on_ingest", Default: "false", Type: "bool", DisplayName: "自动描述媒体", Description: "后台用视觉/音频模型给未描述的媒体生成文字描述。**描述文本才是持久语义记忆**——blob 会被容量 GC 淘汰,描述会随记忆各层一直留存并可检索。代价是消耗视觉模型配额(单张图实测约 10s故默认关闭开启后每 30s 最多处理 4 条,不跟对话抢额度", Category: "memory"})
reg(ConfigDef{Key: "core.memory.media.clip_model_dir", Default: "", Type: "string", DisplayName: "CLIP ONNX 模型目录", Description: "多模态嵌入的 CLIP ONNX 模型目录(含 text.onnx、vision.onnx、clip_config.json、tokenizer.json、merges.txt。multimodal_space.type=onnx 时从此目录加载内嵌模型;留空且 type=onnx 时禁用多模态向量检索。修改后需重启生效。", Category: "memory"})
reg(ConfigDef{Key: "core.memory.multimodal_space.type", Default: "", Type: "string", DisplayName: "多模态向量空间类型", Description: "可插拔多模态向量空间的实现类型。onnx = 内嵌 ONNX 模型CLIP 等http = 外部向量 API 服务。留空禁用多模态向量检索,只保留 fastText 文本路径。两条路径共享同一套 L0/L2/L3 向量缓存与检索基础设施。", Category: "memory"})
reg(ConfigDef{Key: "core.memory.media.describe_on_ingest", Default: "false", Type: "bool", DisplayName: "自动描述媒体", Description: "后台用视觉/音频模型给未描述的媒体生成文字描述。描述文本是可检索的语义入口,供各层记忆复用。代价是消耗视觉模型配额(单张图实测约 10s故默认关闭开启后每 30s 最多处理 4 条,不跟对话抢额度", Category: "memory"})
reg(ConfigDef{Key: "core.memory.multimodal_space.type", Default: "", Type: "string", DisplayName: "多模态向量空间类型", Description: "可插拔多模态向量空间的实现类型。onnx = 内嵌 Qwen3-VL 完整图文共享模型http = 外部向量 API 服务。留空禁用多模态向量检索,只保留 fastText 文本路径。两条路径共享同一套 L0/L2/L3 向量缓存与检索基础设施。", Category: "memory"})
reg(ConfigDef{Key: "core.memory.multimodal_space.onnx.model_dir", Default: "", Type: "string", DisplayName: "Qwen 多模态 ONNX 目录", Description: "完整 Qwen3-VL 图文共享向量模型目录(含 TokenEmbedding.onnx、Transformer.onnx、Vision.onnx、外部权重、tokenizer.json、embed_config.json。type=onnx 时必填,修改后需重启生效。", Category: "memory"})
reg(ConfigDef{Key: "core.memory.multimodal_space.http.endpoint", Default: "", Type: "string", DisplayName: "外部向量 API 端点", Description: "外部多模态向量服务的 HTTP 端点 URLPOST接受 modality/side/text/data/mime返回 embedding。type=http 时必填。", Category: "memory"})
reg(ConfigDef{Key: "core.memory.multimodal_space.http.api_key", Default: "", Type: "string", DisplayName: "外部向量 API 密钥", Description: "外部多模态向量服务的 API 密钥(作为 Bearer token 发送)。可选。", Category: "memory"})
reg(ConfigDef{Key: "core.memory.multimodal_space.http.model", Default: "", Type: "string", DisplayName: "外部向量模型标识", Description: "外部向量服务使用的模型名称,作为 vec_model 持久化。模型切换后历史向量会自动重算。", Category: "memory"})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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