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

@ -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