mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-22 18:08:04 +00:00
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:
@ -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,
|
||||
|
||||
@ -52,14 +52,10 @@ type Agent struct {
|
||||
// 文本记忆(原始对话日志)
|
||||
textMem *text.Memory
|
||||
|
||||
// 媒体存储(内容寻址):对话里出现的图片/音频按 sha256 落盘去重,
|
||||
// L0/L2/L3 只记 digest。为 nil 时全部媒体接线静默跳过——
|
||||
// 它是记忆增强而非对话必需品,缺了不该让对话失败。
|
||||
// 媒体存储(内容寻址):对话里出现的图片/音频按 sha256 落盘去重。
|
||||
// 它是记忆块的内容存储,不单独做生命周期管理:块的创建/迁移/删除
|
||||
// 由记忆系统本身决定。为 nil 时全部媒体接线静默跳过。
|
||||
mediaStore *media.Store
|
||||
// mediaGCInterval 为 0 时不跑 GC 循环(容量上限就仅在手动调 GC 时生效)。
|
||||
mediaGCInterval time.Duration
|
||||
// mediaGCMinAge 保护新入库媒体:刚 Put 还没来得及 AddRef 的项引用计数也是 0。
|
||||
mediaGCMinAge time.Duration
|
||||
// mediaDescribe 控制是否跑后台描述循环(要消耗视觉模型配额)。
|
||||
mediaDescribe bool
|
||||
|
||||
@ -96,8 +92,8 @@ type Agent struct {
|
||||
selfInputCh chan selfInputMsg
|
||||
|
||||
// 子任务异步执行
|
||||
childMu sync.Mutex
|
||||
childNextID int64
|
||||
childMu sync.Mutex
|
||||
childNextID int64
|
||||
// childTasks 记录子任务状态:运行中 / 结果 / 是否已交付。
|
||||
//
|
||||
// 为什么保留结果而不是“读到即删”:完成通知会写进持久上下文
|
||||
@ -186,8 +182,6 @@ type AgentConfig struct {
|
||||
SocialStore *social.SocialStore
|
||||
TextMemory *text.Memory
|
||||
MediaStore *media.Store
|
||||
MediaGCInterval time.Duration
|
||||
MediaGCMinAge time.Duration
|
||||
MediaDescribe bool
|
||||
MultimodalSpace vector.MultimodalEmbedder
|
||||
FusionCfg CrossModalFusionConfig // 跨模态融合权重;零值用默认
|
||||
@ -248,13 +242,6 @@ func New(cfg AgentConfig) *Agent {
|
||||
if cfg.IO != nil {
|
||||
rc.SetChannelDefLookup(cfg.IO.GetInputChannelDef)
|
||||
}
|
||||
// 必须把媒体存储也注给 RelevanceContext:L0→L2 归档(Prune)靠
|
||||
// rc.transferMediaRefs 把引用从 context owner 转给 document owner。
|
||||
// 漏了这一行的后果是静默的:rc.mediaStore 为 nil 时转移直接 return,
|
||||
// 而携带引用的 ContextEvent 已被归档删除 → 引用永久悬空在
|
||||
// context owner 上、计数永不归零 → 对应 blob 永远不会被 GC 回收。
|
||||
rc.SetMediaStore(cfg.MediaStore)
|
||||
|
||||
// 注入稠密多模态向量空间(可选):配置后文档检索、L0 相关性裁剪、
|
||||
// 跨模态检索全部共享同一向量空间,取代稀疏 fastText 语义路。
|
||||
// 未配置时退化到 TF-IDF/fastText 稀疏检索,保持既有行为。
|
||||
@ -284,8 +271,6 @@ func New(cfg AgentConfig) *Agent {
|
||||
social: cfg.SocialStore,
|
||||
textMem: cfg.TextMemory,
|
||||
mediaStore: cfg.MediaStore,
|
||||
mediaGCInterval: cfg.MediaGCInterval,
|
||||
mediaGCMinAge: cfg.MediaGCMinAge,
|
||||
mediaDescribe: cfg.MediaDescribe,
|
||||
personality: cfg.Personality,
|
||||
pluginReg: cfg.PluginReg,
|
||||
@ -322,7 +307,6 @@ func (a *Agent) Start() {
|
||||
go a.archiveLoop()
|
||||
go a.mergeLoop()
|
||||
go a.reviewLoop()
|
||||
go a.mediaGCLoop()
|
||||
go a.mediaDescribeLoop()
|
||||
a.reembedStaleMedia()
|
||||
log.Printf("[agent] %s started, waiting for IO interrupts", a.id)
|
||||
|
||||
@ -3,7 +3,6 @@ package core
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
@ -13,7 +12,6 @@ import (
|
||||
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector"
|
||||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
)
|
||||
@ -24,9 +22,8 @@ type ToolResultItem struct {
|
||||
}
|
||||
|
||||
type ContextEvent struct {
|
||||
// ID 是事件的稳定标识,媒体引用(media_refs.owner_id)挂在它上面。
|
||||
// ID 是事件的稳定标识。惰性生成:只有真的要挂媒体块时才赋值。
|
||||
//
|
||||
// 惰性生成:只有真的要挂媒体时才赋值(见 bindEventMedia)。
|
||||
// 全量生成会让每条事件都多一个字段进 context.json,而绝大多数对话没有媒体。
|
||||
// omitempty 保证存量 context.json 读回来时该字段为空,不影响任何既有行为。
|
||||
ID string `json:"id,omitempty"`
|
||||
@ -36,13 +33,11 @@ type ContextEvent struct {
|
||||
Response string `json:"response,omitempty"`
|
||||
ToolsUsed []string `json:"tools_used,omitempty"`
|
||||
ToolResults []ToolResultItem `json:"tool_results,omitempty"`
|
||||
// --- 原生多模态记忆(v1.2.0)---
|
||||
// 媒体不是描述文本的附件,而是与文本同生命周期的记忆块。Vec 坐标在媒体
|
||||
// 首次进入 L0 时计算一次并存于 CAS;L0→L2→L3 只迁移 Media digest 引用,
|
||||
// 三层始终复用同一坐标。描述仅是可选的文本语义通道,不再决定媒体是否存在。
|
||||
Media []string `json:"media,omitempty"`
|
||||
Vector vector.Vector `json:"-"` // 稀疏词向量(TF-IDF/fastText 空间)
|
||||
DenseVec []float64 `json:"-"` // 稠密多模态向量(与媒体/文档共享空间)
|
||||
// --- 原生多模态记忆 ---
|
||||
// 一等记忆块:块本身随事件在层间迁移,身份不变,不建引用计数。
|
||||
Blocks []memory.MemoryBlock `json:"blocks,omitempty"` // 一等记忆块(text/image/video/audio)
|
||||
Vector vector.Vector `json:"-"` // 稀疏词向量(TF-IDF/fastText 空间)
|
||||
DenseVec []float64 `json:"-"` // 稠密多模态向量(与媒体/文档共享空间)
|
||||
}
|
||||
|
||||
const contextFlushInterval = 5 * time.Second
|
||||
@ -57,41 +52,6 @@ type RelevanceContext struct {
|
||||
dirty bool
|
||||
toolDefLookup func(name string) *sdk.ToolDef
|
||||
channelDefLookup func(name string) (sdk.ChannelDef, bool)
|
||||
|
||||
// mediaStore 只用于 Prune 时把媒体引用从事件转给归档文档。
|
||||
// 为 nil 时引用转移静默跳过(媒体存储未启用)。
|
||||
mediaStore *media.Store
|
||||
}
|
||||
|
||||
// SetMediaStore 注入媒体存储,供 L0→L2 归档时转移媒体引用。
|
||||
func (c *RelevanceContext) SetMediaStore(s *media.Store) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.mediaStore = s
|
||||
}
|
||||
|
||||
// transferMediaRefs 把被归档事件的媒体引用转给目标文档(调用方已持 c.mu)。
|
||||
//
|
||||
// 先挂后销:若反序,引用计数会瞬时归零,此时若后台 GC 正在跑
|
||||
// 就会把仍被记忆引用的内容当孤儿清掉。
|
||||
func (c *RelevanceContext) transferMediaRefs(archive []scoredEvent, docID string) {
|
||||
if c.mediaStore == nil || docID == "" {
|
||||
return
|
||||
}
|
||||
for _, s := range archive {
|
||||
evt := s.event
|
||||
if evt == nil || evt.ID == "" || len(evt.Media) == 0 {
|
||||
continue
|
||||
}
|
||||
for _, d := range evt.Media {
|
||||
if err := c.mediaStore.AddRef(d, media.OwnerDocument, docID); err != nil {
|
||||
log.Printf("[media] 归档转移 AddRef 失败 (%s → doc %s): %v", shortDigest(d), docID, err)
|
||||
}
|
||||
}
|
||||
if _, err := c.mediaStore.DropOwner(media.OwnerContext, evt.ID); err != nil {
|
||||
log.Printf("[media] 归档转移 DropOwner 失败 (evt %s): %v", evt.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func NewRelevanceContext(savePath string, embedder *memory.StaticEmbedder) *RelevanceContext {
|
||||
@ -323,8 +283,7 @@ func (c *RelevanceContext) flush() {
|
||||
|
||||
// scoredEvent 是 Prune 里按相关度排序的事件。
|
||||
//
|
||||
// 提为包级类型(原先是 Prune 内的局部类型):transferMediaRefs 需要
|
||||
// 把待归档列表传进去,局部类型无法出现在方法签名上。
|
||||
// 提为包级类型:Prune 需要把待归档列表传给后续处理。
|
||||
type scoredEvent struct {
|
||||
event *ContextEvent
|
||||
score float64
|
||||
@ -406,17 +365,20 @@ func (c *RelevanceContext) Prune(currentInput string, topK int, docStore *docume
|
||||
Content: s.event.Input,
|
||||
Response: s.event.Response,
|
||||
ToolResults: convertToolResults(s.event.ToolResults),
|
||||
Media: append([]string(nil), s.event.Media...),
|
||||
Blocks: append([]memory.MemoryBlock(nil), s.event.Blocks...),
|
||||
}
|
||||
}
|
||||
doc, err := docStore.ContextToDoc("context_archived", entries, c.embedder, nil, c.toolOutputClean, c.channelCleanerForDoc())
|
||||
if err == nil && doc != nil {
|
||||
archived = len(entries)
|
||||
// 媒体引用随事件一起从 L0 转到 L2:先把引用挂到归档文档上,
|
||||
// 再注销原事件的引用。顺序不能反——先销后挂会让引用计数
|
||||
// 瞬时归零,若此时 GC 正在跑(后台任务)就会把仍被记忆引用的
|
||||
// 内容当孤儿清掉。
|
||||
c.transferMediaRefs(archive, doc.ID)
|
||||
// 一等记忆块的迁移:块随归档事件离开 L0、进入 L2。
|
||||
// 迁移的是块本身(ID 不变、只换持有层),不是复制也不是保活引用;
|
||||
// 因此归档后清空源事件的块,确保同一块不同时留在两层。
|
||||
for _, s := range archive {
|
||||
if s.event != nil {
|
||||
s.event.Blocks = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -459,6 +421,18 @@ func (c *RelevanceContext) Recent(n int) []ContextEvent {
|
||||
return result
|
||||
}
|
||||
|
||||
// Blocks 返回当前上下文持有的一等记忆块(供跨层存活判定)。
|
||||
// 迁移后源事件已被清空,因此这里只会拿到真正属于 L0 的块。
|
||||
func (c *RelevanceContext) Blocks() []memory.MemoryBlock {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
var out []memory.MemoryBlock
|
||||
for _, e := range c.events {
|
||||
out = append(out, e.Blocks...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (c *RelevanceContext) Len() int {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -10,7 +10,6 @@ import (
|
||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/nlp"
|
||||
)
|
||||
@ -184,7 +183,7 @@ func (a *Agent) archiveColdDocs() {
|
||||
if len(triples) == 0 {
|
||||
continue
|
||||
}
|
||||
ec, rc, mediaBound, err := a.commitTriplesWithMedia(triples, string(a.id)+"_doc_archival", 0)
|
||||
ec, rc, blocks, err := a.commitTriplesWithMedia(triples, string(a.id)+"_doc_archival", 0, doc.Blocks)
|
||||
if err != nil {
|
||||
log.Printf("[agent] doc→graph archival error: %v", err)
|
||||
continue
|
||||
@ -203,64 +202,15 @@ func (a *Agent) archiveColdDocs() {
|
||||
"(三元组 %d 条全被实体名校验拒绝)", doc.ID, len(triples))
|
||||
continue
|
||||
}
|
||||
log.Printf("[agent] doc→graph: %s → %d entities, %d relations", doc.ID, ec, rc)
|
||||
log.Printf("[agent] doc→graph: %s → %d entities, %d relations, %d blocks", doc.ID, ec, rc, blocks)
|
||||
|
||||
// 先销媒体引用再删文档:文档一旦从 docStore 消失,就再没有任何
|
||||
// 东西能告诉我们它曾经引用过哪些 digest,media_refs 里那条记录
|
||||
// 就永久悬空、引用计数永不归零,导致 blob 永远不会被 GC 回收。
|
||||
//
|
||||
// 但只有在引用**确实**转移到 graph_sentence 之后才能释放:
|
||||
// 图库里没有任何句子承载这些 digest 时释放旧引用,计数归零,
|
||||
// GC 会把内容当孤儿删掉。宁可留一条悬空引用(内容还在,可由
|
||||
// 后续一致性检查清理),也不能丢内容。
|
||||
refs, refErr := a.docMediaRefs(doc.ID)
|
||||
switch {
|
||||
case refErr != nil:
|
||||
log.Printf("[media] 查文档 %s 的媒体引用失败,保守不释放: %v", doc.ID, refErr)
|
||||
case len(refs) == 0:
|
||||
// 该文档本就没有媒体引用,无需释放。
|
||||
case mediaBound == 0:
|
||||
log.Printf("[media] 文档 %s 有 %d 个媒体引用但图库一个都没绑上,"+
|
||||
"保留引用以免 GC 删除内容(句子正文里可能没有可反解的短 digest)",
|
||||
doc.ID, len(refs))
|
||||
default:
|
||||
a.releaseDocMedia(doc.ID)
|
||||
}
|
||||
// 文档的一等记忆块已随句子写进 L3(身份不变,由 bindSentenceBlocks
|
||||
// 复用 doc.Blocks 的 ID);块不再挂在文档上,删除文档即完成迁移。
|
||||
a.docStore.Remove(doc.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// docMediaRefs 返回文档当前持有的媒体引用(nil store 时为空)。
|
||||
//
|
||||
// 单独取出来是为了让归档路径能在释放前先确认「有没有东西要释放」——
|
||||
// 没有引用时不必打日志,有引用但没绑上图库时必须保留。
|
||||
func (a *Agent) docMediaRefs(docID string) ([]string, error) {
|
||||
if a.mediaStore == nil || docID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return a.mediaStore.Refs(media.OwnerDocument, docID)
|
||||
}
|
||||
|
||||
// releaseDocMedia 注销文档持有的全部媒体引用。
|
||||
//
|
||||
// L2→L3 这一跳不再转移引用而是直接释放,因为图库存的是从描述
|
||||
// 文本里抽出的实体与关系,不再持有字节。媒体本身此时已完成使命:
|
||||
// 描述已经进了图库,blob 可以交给容量 GC 决定去留。
|
||||
func (a *Agent) releaseDocMedia(docID string) {
|
||||
if a.mediaStore == nil || docID == "" {
|
||||
return
|
||||
}
|
||||
n, err := a.mediaStore.DropOwner(media.OwnerDocument, docID)
|
||||
if err != nil {
|
||||
log.Printf("[media] 文档归档释放引用失败 (doc %s): %v", docID, err)
|
||||
return
|
||||
}
|
||||
if n > 0 {
|
||||
log.Printf("[media] 文档 %s 入图库,释放 %d 个媒体引用(描述已留在图库)", docID, n)
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// 实体合并检测:GraphDB → LLM 裁决
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 要求 2–50 字符)的三元组,
|
||||
// 于是「无错但一条也没写进去」是真实会发生的:LLM 生成的长描述提不出
|
||||
// 合规实体名,实测 456 字描述得到 0 entities 0 relations。
|
||||
func (a *Agent) bindSentenceMedia(sentenceIDs map[string]int64) int {
|
||||
if a.mediaStore == nil || len(sentenceIDs) == 0 {
|
||||
// seed 是本批文档已持有的一等块:迁移时按 digest 复用它们的身份(ID 不变),
|
||||
// 真正做到“同一个块从 L2 移到 L3”,而不是另建一个同内容的新块。
|
||||
// 返回本次写入 L3 的块数。
|
||||
func (a *Agent) bindSentenceBlocks(sentenceIDs map[string]int64, seed []memory.MemoryBlock) int {
|
||||
if a.mediaStore == nil || a.memory == nil || len(sentenceIDs) == 0 {
|
||||
return 0
|
||||
}
|
||||
byDigest := make(map[string]memory.MemoryBlock, len(seed))
|
||||
for _, b := range seed {
|
||||
if b.PayloadDigest != "" {
|
||||
byDigest[b.PayloadDigest] = b
|
||||
}
|
||||
}
|
||||
|
||||
bound := 0
|
||||
for text, sid := range sentenceIDs {
|
||||
if sid == 0 {
|
||||
continue
|
||||
}
|
||||
digests := extractMediaDigests(text)
|
||||
if len(digests) == 0 {
|
||||
continue
|
||||
}
|
||||
ownerID := strconv.FormatInt(sid, 10)
|
||||
for _, short := range digests {
|
||||
// 文本里是短 digest,media_refs 的主键要完整 digest。
|
||||
// 补全失败(内容已被 GC 清掉、或前缀有歧义)就跳过——
|
||||
// 挂一条对不上的引用比不挂更糟:DropOwner 永远匹配不到它。
|
||||
for _, short := range extractMediaDigests(text) {
|
||||
full, err := a.mediaStore.ResolvePrefix(short)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if err := a.mediaStore.AddRef(full, media.OwnerGraphSentence, ownerID); err != nil {
|
||||
log.Printf("[media] 句子引用绑定失败 (%s → sentence %s): %v", short, ownerID, err)
|
||||
b, ok := byDigest[full]
|
||||
if !ok {
|
||||
if b, ok = a.blockFromDigest(full); !ok {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if err := a.memory.PutMemoryBlocks([]memory.MemoryBlock{b}); err != nil {
|
||||
log.Printf("[media] L3 记忆块写入失败 (%s): %v", shortDigest(full), err)
|
||||
continue
|
||||
}
|
||||
if err := a.memory.AddMemoryBlockEdge("sentence", strconv.FormatInt(sid, 10), "block", b.ID, "contains"); err != nil {
|
||||
log.Printf("[media] 句子→块边建立失败 (%s): %v", shortDigest(full), err)
|
||||
continue
|
||||
}
|
||||
bound++
|
||||
}
|
||||
}
|
||||
if bound > 0 {
|
||||
log.Printf("[media] L3 图库绑定 %d 个媒体引用", bound)
|
||||
log.Printf("[media] L3 图库写入 %d 个一等记忆块", bound)
|
||||
}
|
||||
return bound
|
||||
}
|
||||
|
||||
// sentenceWithMediaMarkers 保证句子文本里带上这些 digest 的媒体标记。
|
||||
//
|
||||
// 存在的理由:媒体的绑定链是 SentenceText → sentences 表 → sentence_id →
|
||||
// media_refs。模型只知道 digest(从 memory_recall 的「关联媒体」或对话里的
|
||||
// 媒体标记读到),不该要求它自己按内核格式拼标记——格式写错的后果是引用
|
||||
// 静默挂不上,模型也无从察觉。
|
||||
// 存在的理由:L3 的块边由句子正文里的短 digest 反解而来。模型只知道
|
||||
// digest(从 memory_recall 的「关联媒体」或对话里的媒体标记读到),
|
||||
// 不该要求它自己按内核格式拼标记——格式写错的后果是块边静默建不起来。
|
||||
//
|
||||
// 已出现过的 digest 不重复追加:模型可能既写了标记又填了 media_digests。
|
||||
func (a *Agent) sentenceWithMediaMarkers(sentence string, digests []string) string {
|
||||
@ -278,7 +272,7 @@ func (a *Agent) sentenceWithMediaMarkers(sentence string, digests []string) stri
|
||||
continue
|
||||
}
|
||||
// 模型给的多半是短 digest(它在上下文里看到的就是短的),补全成完整
|
||||
// digest 才能进 media_refs 主键。补不上就跳过:内容可能已被 GC 清掉。
|
||||
// digest 才能定位内容。补不上就跳过:内容可能已被删除。
|
||||
full, err := a.mediaStore.ResolvePrefix(d)
|
||||
if err != nil {
|
||||
log.Printf("[media] 模型提交的 digest %s 无法解析: %v", d, err)
|
||||
@ -300,24 +294,19 @@ func (a *Agent) sentenceWithMediaMarkers(sentence string, digests []string) stri
|
||||
|
||||
// docMediaContext 为一篇文档产出媒体说明,供 doc_query 拼进工具返回值。
|
||||
//
|
||||
// 优先读 media_refs(权威:谁挂上去的就是谁),为空时退回解析正文标记——
|
||||
// 历史文档与经旧版路径写入的文档只有标记、没有引用。
|
||||
// 文档的一等记忆块随文档 JSON 持久化;这里只有正文,因此从正文标记反解。
|
||||
func (a *Agent) docMediaContext(docID, content string) string {
|
||||
if a.mediaStore == nil {
|
||||
return ""
|
||||
}
|
||||
digests, err := a.mediaStore.Refs(media.OwnerDocument, docID)
|
||||
if err != nil {
|
||||
log.Printf("[media] 读取文档 %s 的媒体引用失败: %v", docID, err)
|
||||
}
|
||||
if len(digests) == 0 {
|
||||
for _, short := range extractMediaDigests(content) {
|
||||
full, err := a.mediaStore.ResolvePrefix(short)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
digests = append(digests, full)
|
||||
// 文档的一等记忆块随文档 JSON 持久化;这里只有正文,退回解析标记。
|
||||
var digests []string
|
||||
for _, short := range extractMediaDigests(content) {
|
||||
full, err := a.mediaStore.ResolvePrefix(short)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
digests = append(digests, full)
|
||||
}
|
||||
var lines []string
|
||||
for _, d := range digests {
|
||||
@ -333,9 +322,7 @@ func (a *Agent) docMediaContext(docID, content string) string {
|
||||
|
||||
// resolveMediaDigests 把模型给的(多为短)digest 补全成完整 digest。
|
||||
//
|
||||
// 补不上就丢弃那一条并记日志:模型可能凭印象编了个 digest,也可能内容已被
|
||||
// 容量 GC 淘汰。挂一条对不上的引用比不挂更糟——digest 进了 media_refs 主键,
|
||||
// 错了则 DropOwner 永远匹配不到它,那是一条永久泄漏的引用。
|
||||
// 补不上就丢弃那一条并记日志:模型可能凭印象编了个 digest,也可能内容已被删除。
|
||||
func (a *Agent) resolveMediaDigests(digests []string) []string {
|
||||
if a.mediaStore == nil || len(digests) == 0 {
|
||||
return nil
|
||||
@ -359,33 +346,11 @@ func (a *Agent) resolveMediaDigests(digests []string) []string {
|
||||
|
||||
// bindDocMedia 把一组完整 digest 挂到文档 owner 上,返回成功条数。
|
||||
//
|
||||
// 与 releaseDocMedia 成对:文档归档进 L3 时释放,文档写入时绑定。
|
||||
// 只绑不放会让磁盘只增不减,只放不绑会让 GC 误删仍被引用的内容。
|
||||
func (a *Agent) bindDocMedia(docID string, digests []string) int {
|
||||
if a.mediaStore == nil || docID == "" || len(digests) == 0 {
|
||||
return 0
|
||||
}
|
||||
bound := 0
|
||||
for _, d := range digests {
|
||||
if err := a.mediaStore.AddRef(d, media.OwnerDocument, docID); err != nil {
|
||||
log.Printf("[media] 文档引用绑定失败 (%s → doc %s): %v", shortDigest(d), docID, err)
|
||||
continue
|
||||
}
|
||||
bound++
|
||||
}
|
||||
if bound > 0 {
|
||||
log.Printf("[media] 文档 %s 绑定 %d 个媒体引用", docID, bound)
|
||||
}
|
||||
return bound
|
||||
}
|
||||
|
||||
// commitTriplesWithMedia 提交三元组并绑定句子里的媒体引用。
|
||||
// commitTriplesWithMedia 提交三元组并把句子里的媒体变成 L3 一等块。
|
||||
//
|
||||
// 包一层是为了让所有「三元组入库」的调用点用同一条路径拿到媒体绑定,
|
||||
// 而不必各自记得多调一次 bindSentenceMedia。
|
||||
// mediaBound 是本次实际挂到 graph_sentence owner 上的引用数;归档路径靠它
|
||||
// 判定能否安全释放旧引用。媒体存储关闭时恒为 0(此时也没有引用需要释放)。
|
||||
func (a *Agent) commitTriplesWithMedia(triples []memory.Triple, sessionID string, turnID int) (entities, relations, mediaBound int, err error) {
|
||||
// seed 是调用方已持有的一等块(如 L2 文档的 Blocks),用于保持块身份;
|
||||
// 普通对话路径传 nil。blocks 是本次写入 L3 的块数。
|
||||
func (a *Agent) commitTriplesWithMedia(triples []memory.Triple, sessionID string, turnID int, seed []memory.MemoryBlock) (entities, relations, blocks int, err error) {
|
||||
if a.memory == nil {
|
||||
return 0, 0, 0, fmt.Errorf("graph memory 未启用")
|
||||
}
|
||||
@ -398,25 +363,23 @@ func (a *Agent) commitTriplesWithMedia(triples []memory.Triple, sessionID string
|
||||
if err != nil {
|
||||
return ec, rc, 0, err
|
||||
}
|
||||
return ec, rc, a.bindSentenceMedia(sentenceIDs), nil
|
||||
return ec, rc, a.bindSentenceBlocks(sentenceIDs, seed), nil
|
||||
}
|
||||
|
||||
// RecallMediaForSentence 反查某条图库句子引用的媒体。
|
||||
// RecallBlocksForSentence 反查某条图库句子持有的一等记忆块。
|
||||
//
|
||||
// 这是整层的目的:几个月后从图谱走到一条句子,要能取回当时那份字节
|
||||
// (若尚未被容量 GC 淘汰)。返回的是完整 digest,调用方用
|
||||
// mediaStore.Get 取内容、Stat 取描述与元数据。
|
||||
func (a *Agent) RecallMediaForSentence(sentenceID int64) ([]string, error) {
|
||||
if a.mediaStore == nil {
|
||||
// 这是整层的目的:几个月后从图谱走到一条句子,要能取回当时那份媒体。
|
||||
func (a *Agent) RecallBlocksForSentence(sentenceID int64) ([]memory.MemoryBlock, error) {
|
||||
if a.memory == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return a.mediaStore.Refs(media.OwnerGraphSentence, strconv.FormatInt(sentenceID, 10))
|
||||
return a.memory.BlocksForNode("sentence", strconv.FormatInt(sentenceID, 10))
|
||||
}
|
||||
|
||||
// sentenceIDsFromRelations 收集一批关系引用的句子 id(去重、去零)。
|
||||
//
|
||||
// 关系行本身不持有媒体,媒体挂在句子上(graph_sentence owner)。
|
||||
// 因此"这次召回涉及哪些媒体"必须经由关系 → 句子 → media_refs 这条路。
|
||||
// 关系行本身不持有媒体,媒体作为一等块以 sentence --contains--> block
|
||||
// 结构边与句子相连;因此"这次召回涉及哪些媒体"必须经由关系 → 句子这一跳。
|
||||
func sentenceIDsFromRelations(relations []memory.Relation) []int64 {
|
||||
if len(relations) == 0 {
|
||||
return nil
|
||||
@ -470,18 +433,18 @@ func (a *Agent) mediaContextForInjectedEntities(injected *memory.InjectedContext
|
||||
// 描述文本本就在句子里,这里补的是「内容是否还在、能否重新看图」这个信息——
|
||||
// 描述永存而字节可能已被淘汰,两者状态不同。
|
||||
func (a *Agent) mediaContextForSentences(sentenceIDs []int64) string {
|
||||
if a.mediaStore == nil || len(sentenceIDs) == 0 {
|
||||
if a.memory == nil || len(sentenceIDs) == 0 {
|
||||
return ""
|
||||
}
|
||||
var lines []string
|
||||
for _, sid := range sentenceIDs {
|
||||
digests, err := a.mediaStore.Refs(media.OwnerGraphSentence, strconv.FormatInt(sid, 10))
|
||||
if err != nil || len(digests) == 0 {
|
||||
blocks, err := a.memory.BlocksForNode("sentence", strconv.FormatInt(sid, 10))
|
||||
if err != nil || len(blocks) == 0 {
|
||||
continue
|
||||
}
|
||||
var parts []string
|
||||
for _, d := range digests {
|
||||
if line := a.mediaMarkerLine(d); line != "" {
|
||||
for _, b := range blocks {
|
||||
if line := a.mediaMarkerLine(b.PayloadDigest); line != "" {
|
||||
parts = append(parts, line)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -6,14 +6,13 @@
|
||||
// 往 IOManager 注入一个 image 事件,然后等。之后全部由生产代码自己走:
|
||||
//
|
||||
// processMediaInput → captureBlockMedia(入 CAS)
|
||||
// → Prune → transferMediaRefs(L0→L2 引用转移)
|
||||
// → Prune(L0→L2 块迁移)
|
||||
// → describePendingMedia(真实视觉模型生成描述)
|
||||
// → archiveColdDocs → commitTriplesWithMedia → bindSentenceMedia(L2→L3)
|
||||
// → archiveColdDocs → commitTriplesWithMedia → bindSentenceBlocks(L2→L3)
|
||||
// → 第二轮提问,验证 agent 真能召回
|
||||
//
|
||||
// 为什么必须这样测:单测能证明每个函数正确,却证明不了它**被接上了**。
|
||||
// 本文件的直接动机是一个真实缺陷——core.New() 漏了 rc.SetMediaStore(cfg.MediaStore),
|
||||
// 于是 L0→L2 引用转移在生产里永远静默 return,而手工注入 store 的单测全绿。
|
||||
// 为什么必须这样测:单测能证明每个函数正确,却证明不了它**被接上了**——
|
||||
// 手工注入 store 的单测全绿而生产链路断开,是本文件要拦的典型缺陷。
|
||||
//
|
||||
// 需要真实 LLM,因此加 medialive build tag,默认 go test 不跑:
|
||||
//
|
||||
@ -172,7 +171,7 @@ func newLiveEnv(t *testing.T, c liveCfg) *liveEnv {
|
||||
t.Fatalf("set default provider: %v", err)
|
||||
}
|
||||
|
||||
ms, err := media.New(filepath.Join(dir, "media"), 256<<20)
|
||||
ms, err := media.New(filepath.Join(dir, "media"))
|
||||
if err != nil {
|
||||
t.Fatalf("media store: %v", err)
|
||||
}
|
||||
@ -184,7 +183,7 @@ func newLiveEnv(t *testing.T, c liveCfg) *liveEnv {
|
||||
}
|
||||
t.Cleanup(func() { graph.Close() })
|
||||
|
||||
docStore := document.NewStore(filepath.Join(dir, "docs"))
|
||||
docStore := document.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords)
|
||||
if err := docStore.Start(); err != nil {
|
||||
t.Fatalf("doc store: %v", err)
|
||||
}
|
||||
@ -201,7 +200,6 @@ func newLiveEnv(t *testing.T, c liveCfg) *liveEnv {
|
||||
Memory: graph,
|
||||
DocStore: docStore,
|
||||
MediaStore: ms,
|
||||
MediaGCInterval: 0, // 本测试自己控制 GC 时机
|
||||
MediaDescribe: true, // 描述循环由测试直接调 describePendingMedia
|
||||
StageHost: NewStageHost(),
|
||||
MaxContextSize: 3, // 故意压低:第二轮就能触发 Prune 归档
|
||||
@ -271,31 +269,29 @@ func TestMediaLive_AutoTriggerChain(t *testing.T) {
|
||||
t.Fatalf("落盘内容与原图不一致 (err=%v)", err)
|
||||
}
|
||||
|
||||
// ── 阶段 2:引用自动挂到 ContextEvent 上 ──
|
||||
// ── 阶段 2:一等记忆块自动挂到 ContextEvent 上 ──
|
||||
//
|
||||
// 这一步验证 bindEventMedia:事件必须拿到 ID 且 media_refs 里
|
||||
// 有对应 context owner 记录。两者只写一个的后果是 GC 误删或永不清理。
|
||||
// 这一步验证 bindEventMedia:事件必须拿到 ID 并直接持有块。
|
||||
var evtID string
|
||||
var summaryOK bool
|
||||
for _, e := range a.context.Recent(0) {
|
||||
if len(e.Media) > 0 {
|
||||
if len(e.Blocks) > 0 {
|
||||
evtID = e.ID
|
||||
summaryOK = strings.Contains(e.Input, digest[:12])
|
||||
if e.Blocks[0].PayloadDigest != digest {
|
||||
t.Fatalf("事件持有的块 digest 不对: %+v", e.Blocks)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if evtID == "" {
|
||||
t.Fatal("没有任何 ContextEvent 挂上媒体(bindEventMedia 未被触发)")
|
||||
}
|
||||
ctxRefs, err := env.mediaSt.Refs(media.OwnerContext, evtID)
|
||||
if err != nil || len(ctxRefs) != 1 || ctxRefs[0] != digest {
|
||||
t.Fatalf("context owner 引用缺失: refs=%v err=%v", ctxRefs, err)
|
||||
}
|
||||
if !summaryOK {
|
||||
t.Error("事件 Input 里没有媒体摘要标记(mediaSummaryForEvent 未生效)——" +
|
||||
"L2/L3 靠正文里的短 digest 反查,缺了它整条召回链断掉")
|
||||
}
|
||||
t.Logf("✓ 阶段2 引用自动绑定: event=%s owner=context 摘要内嵌=%v", evtID, summaryOK)
|
||||
t.Logf("✓ 阶段2 块自动绑定: event=%s 摘要内嵌=%v", evtID, summaryOK)
|
||||
|
||||
// ── 阶段 3:描述由后台循环自动生成(真实视觉模型)──
|
||||
pending, err := env.mediaSt.Pending(5)
|
||||
@ -338,11 +334,10 @@ func TestMediaLive_AutoTriggerChain(t *testing.T) {
|
||||
t.Logf("✓ 阶段3 Search(\"紫\") 命中 %d 条", len(found))
|
||||
}
|
||||
|
||||
// ── 阶段 4:Prune 自动把引用从 L0 转移到 L2 ──
|
||||
// ── 阶段 4:Prune 自动把块从 L0 迁移到 L2 ──
|
||||
//
|
||||
// MaxContextSize=3,多注入几轮文本把带图事件挤出活跃上下文。
|
||||
// 这一步专门守 core.New() 里 rc.SetMediaStore 的接线:漏了它
|
||||
// transferMediaRefs 直接 return,引用永久悬空在 context owner 上。
|
||||
// 迁移的是块本身(同一身份换层);L0 中不该再留下它。
|
||||
// 填充数量必须 > Prune 内部固定的 10 条保护窗口。
|
||||
//
|
||||
// Prune 无条件保护最后 10 条事件(protected := events[len-10:]),
|
||||
@ -366,33 +361,35 @@ func TestMediaLive_AutoTriggerChain(t *testing.T) {
|
||||
|
||||
docRefsFound := ""
|
||||
for _, d := range env.docStore.RecentDocs(20) {
|
||||
refs, err := env.mediaSt.Refs(media.OwnerDocument, d.ID)
|
||||
if err == nil && len(refs) > 0 && refs[0] == digest {
|
||||
docRefsFound = d.ID
|
||||
break
|
||||
for _, b := range d.Blocks {
|
||||
if b.PayloadDigest == digest {
|
||||
docRefsFound = d.ID
|
||||
}
|
||||
}
|
||||
}
|
||||
if docRefsFound == "" {
|
||||
t.Fatal("引用未转移到 document owner——" +
|
||||
"core.New() 是否漏了 rc.SetMediaStore(cfg.MediaStore)?" +
|
||||
"(该缺陷曾真实存在:手工注入 store 的单测全绿,生产里永远静默 return)")
|
||||
t.Fatal("块未随归档事件迁移到 L2 文档")
|
||||
}
|
||||
if left, _ := env.mediaSt.Refs(media.OwnerContext, evtID); len(left) != 0 {
|
||||
t.Errorf("旧的 context 引用未注销(%d 条),引用计数永不归零 → blob 永不回收", len(left))
|
||||
// 同一块不能同时留在 L0。
|
||||
for _, e := range a.context.Recent(0) {
|
||||
for _, b := range e.Blocks {
|
||||
if b.PayloadDigest == digest {
|
||||
t.Errorf("块仍留在 L0(evt %s),违反单层不变量", e.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Logf("✓ 阶段4 引用自动转移: context/%s → document/%s", evtID, docRefsFound)
|
||||
t.Logf("✓ 阶段4 块自动迁移: context/%s → document/%s", evtID, docRefsFound)
|
||||
|
||||
// 转移全程内容必须可读:先挂后销的顺序若反了,
|
||||
// 计数会瞬时归零,并发 GC 会把仍被引用的内容当孤儿删掉。
|
||||
// 迁移全程内容必须可读:块虽换了层,字节仍在。
|
||||
if _, err := env.mediaSt.Get(digest); err != nil {
|
||||
t.Fatalf("转移后内容不可读: %v", err)
|
||||
t.Fatalf("迁移后内容不可读: %v", err)
|
||||
}
|
||||
|
||||
// ── 阶段 5:archiveColdDocs 自动把媒体带进 L3 图库 ──
|
||||
//
|
||||
// FindColdDocs(72h, 2) 要求文档足够"冷",测试里新建的文档不满足,
|
||||
// 因此把 LastAccess 往前推——这是为了触发生产代码路径,
|
||||
// 而不是替代它(Commit/bindSentenceMedia/releaseDocMedia 全部由它自己调)。
|
||||
// 而不是替代它(Commit/bindSentenceBlocks 全部由它自己调)。
|
||||
for _, d := range env.docStore.RecentDocs(20) {
|
||||
if d.ID == docRefsFound {
|
||||
d.LastAccess = time.Now().Add(-100 * time.Hour)
|
||||
@ -410,41 +407,36 @@ func TestMediaLive_AutoTriggerChain(t *testing.T) {
|
||||
t.Logf("图库实体数 %d", len(rows.Entities))
|
||||
// 句子 id 是自增整数,扫前若干个足够覆盖本测试写入的量
|
||||
for sid := int64(1); sid <= 40; sid++ {
|
||||
refs, err := env.mediaSt.Refs(media.OwnerGraphSentence, strconv.FormatInt(sid, 10))
|
||||
if err == nil && len(refs) > 0 {
|
||||
sentRefs += len(refs)
|
||||
blocks, err := env.graph.BlocksForNode("sentence", strconv.FormatInt(sid, 10))
|
||||
if err == nil && len(blocks) > 0 {
|
||||
sentRefs += len(blocks)
|
||||
if boundSentence == 0 {
|
||||
boundSentence = sid
|
||||
}
|
||||
}
|
||||
}
|
||||
if sentRefs == 0 {
|
||||
t.Error("L2→L3 未绑定任何 graph_sentence 引用——" +
|
||||
"bindSentenceMedia 未被 commitTriplesWithMedia 触发," +
|
||||
t.Error("L2→L3 未写入任何句子→块边——" +
|
||||
"bindSentenceBlocks 未被 commitTriplesWithMedia 触发," +
|
||||
"或句子正文里没有可反解的短 digest")
|
||||
} else {
|
||||
t.Logf("✓ 阶段5 L3 自动绑定: %d 个句子引用,首个 sentences.id=%d", sentRefs, boundSentence)
|
||||
t.Logf("✓ 阶段5 L3 自动写入: %d 个句子块,首个 sentences.id=%d", sentRefs, boundSentence)
|
||||
|
||||
got, err := env.agent.RecallMediaForSentence(boundSentence)
|
||||
if err != nil || len(got) == 0 || got[0] != digest {
|
||||
t.Errorf("从句子反查 digest 失败: got=%v err=%v", got, err)
|
||||
} else if raw, err := env.mediaSt.Get(got[0]); err != nil || !bytes.Equal(raw, img) {
|
||||
got, err := env.agent.RecallBlocksForSentence(boundSentence)
|
||||
if err != nil || len(got) == 0 || got[0].PayloadDigest != digest {
|
||||
t.Errorf("从句子反查块失败: got=%+v err=%v", got, err)
|
||||
} else if raw, err := env.mediaSt.Get(got[0].PayloadDigest); err != nil || !bytes.Equal(raw, img) {
|
||||
t.Errorf("从句子取回的字节与原图不一致 (err=%v)", err)
|
||||
} else {
|
||||
t.Logf("✓ 阶段5 反查取回 %d 字节,与原图逐字节一致", len(raw))
|
||||
}
|
||||
}
|
||||
|
||||
// ── 阶段 6:GC 不能删掉仍被记忆引用的内容 ──
|
||||
removed, freed, err := env.mediaSt.GC(0) // minAge=0,最激进
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// ── 阶段 6:内容随块存在,不被单独清理 ──
|
||||
if _, err := env.mediaSt.Stat(digest); err != nil {
|
||||
t.Fatalf("被记忆引用的内容被 GC 删除了(清 %d 条/%d 字节)——"+
|
||||
"引用计数或 owner 语义有误", removed, freed)
|
||||
t.Fatalf("被记忆块持有的内容不存在了: %v", err)
|
||||
}
|
||||
t.Logf("✓ 阶段6 GC(minAge=0) 清 %d 条,被引用内容仍在", removed)
|
||||
t.Logf("✓ 阶段6 被持有内容仍在")
|
||||
|
||||
// ── 阶段 7:E2E — 第二轮提问,验证 agent 真能召回 ──
|
||||
//
|
||||
@ -504,8 +496,8 @@ func TestMediaLive_AutoTriggerChain(t *testing.T) {
|
||||
}
|
||||
|
||||
st := env.mediaSt.Stats()
|
||||
t.Logf("收尾: %v 条 / %v 字节 / 已描述 %v / 无引用 %v",
|
||||
st["count"], st["total_bytes"], st["described"], st["unreferenced"])
|
||||
t.Logf("收尾: %v 条 / %v 字节 / 已描述 %v",
|
||||
st["count"], st["total_bytes"], st["described"])
|
||||
}
|
||||
|
||||
// TestMediaLive_NegativeControl 阴性对照:没有媒体记忆时不该"记得"。
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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("无人持有的内容应被删除")
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,12 +4,60 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
|
||||
)
|
||||
|
||||
// blockSeq 保证块 ID 全局唯一(Graph memory_blocks 以 id 为主键)。
|
||||
var blockSeq int64
|
||||
|
||||
func newBlockID() string {
|
||||
return fmt.Sprintf("blk_%d_%d", time.Now().UnixNano(), atomic.AddInt64(&blockSeq, 1))
|
||||
}
|
||||
|
||||
// blockModalityOf 把 CAS 媒体大类映射为一等记忆块模态。
|
||||
func blockModalityOf(k media.Kind) memory.BlockModality {
|
||||
switch k {
|
||||
case media.KindImage:
|
||||
return memory.BlockImage
|
||||
case media.KindVideo:
|
||||
return memory.BlockVideo
|
||||
case media.KindAudio:
|
||||
return memory.BlockAudio
|
||||
default:
|
||||
return memory.BlockText
|
||||
}
|
||||
}
|
||||
|
||||
// blockFromDigest 把一份已入库媒体变成一等记忆块。
|
||||
// 块携带 digest/向量/fingerprint;CAS 只提供字节与元数据,不参与生命周期。
|
||||
func (a *Agent) blockFromDigest(digest string) (memory.MemoryBlock, bool) {
|
||||
if a.mediaStore == nil || digest == "" {
|
||||
return memory.MemoryBlock{}, false
|
||||
}
|
||||
it, err := a.mediaStore.Stat(digest)
|
||||
if err != nil || it == nil {
|
||||
return memory.MemoryBlock{}, false
|
||||
}
|
||||
return memory.MemoryBlock{
|
||||
ID: newBlockID(),
|
||||
Modality: blockModalityOf(it.Kind),
|
||||
PayloadDigest: it.Digest,
|
||||
MIME: it.MIME,
|
||||
Size: it.Size,
|
||||
Width: it.Width,
|
||||
Height: it.Height,
|
||||
Vector: it.Vec,
|
||||
Fingerprint: it.VecModel,
|
||||
Tool: it.Tool,
|
||||
CreatedAt: it.FirstSeen,
|
||||
}, true
|
||||
}
|
||||
|
||||
// 媒体记忆接线:把对话里出现的图片/音频落进内容寻址存储(CAS),
|
||||
// 并让 L0 的 ContextEvent 记住它们的 digest。
|
||||
//
|
||||
@ -94,7 +142,7 @@ func (a *Agent) embedMediaOnIngest(digest, mime string, data []byte) {
|
||||
|
||||
// stageMediaDigests 累积本轮捕获的 digest,等 ContextEvent 建好后一起挂上。
|
||||
//
|
||||
// 为何要缓存而不是当场 AddRef:媒体在 process() 执行期间被捕获,而承载它的
|
||||
// 为何要缓存而不是当场建块:媒体在 process() 执行期间被捕获,而承载它的
|
||||
// ContextEvent 要等 process() 返回后才 Append——此刻还没有 owner_id。
|
||||
// 与既有的 a.pendingMedia 同一手法(都在 a.mu 保护下)。
|
||||
func (a *Agent) stageMediaDigests(digests ...string) {
|
||||
@ -114,12 +162,10 @@ func (a *Agent) drainMediaDigests() []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// bindEventMedia 把 digest 列表登记到某个 ContextEvent 上。
|
||||
// bindEventMedia 把本轮捕获的媒体变成一等记忆块,直接挂到 ContextEvent 上。
|
||||
//
|
||||
// 双向落地:evt.Media 让事件自己记得引了哪些媒体(随 context.json 持久化),
|
||||
// media_refs 表让 CAS 侧知道谁在引用(GC 据此判断能不能清)。
|
||||
// 两边都写才闭环——只写一边的话,要么 GC 会误删仍被记忆引用的内容,
|
||||
// 要么孤儿永远清不掉。
|
||||
// 块存储在事件自身(随 context.json 持久化),不再写 media_refs:
|
||||
// 存活与否由“三层记忆块是否持有这个 digest”决定,不维护引用账本。
|
||||
func (a *Agent) bindEventMedia(evt *ContextEvent, digests []string) {
|
||||
if a.mediaStore == nil || evt == nil || len(digests) == 0 {
|
||||
return
|
||||
@ -128,26 +174,27 @@ func (a *Agent) bindEventMedia(evt *ContextEvent, digests []string) {
|
||||
evt.ID = newEventID()
|
||||
}
|
||||
for _, d := range digests {
|
||||
if err := a.mediaStore.AddRef(d, media.OwnerContext, evt.ID); err != nil {
|
||||
log.Printf("[media] AddRef 失败 (%s → %s): %v", shortDigest(d), evt.ID, err)
|
||||
b, ok := a.blockFromDigest(d)
|
||||
if !ok {
|
||||
log.Printf("[media] 块构造失败 (%s)", shortDigest(d))
|
||||
continue
|
||||
}
|
||||
evt.Media = append(evt.Media, d)
|
||||
evt.Blocks = append(evt.Blocks, b)
|
||||
}
|
||||
}
|
||||
|
||||
// mediaSummaryForEvent 给已有描述的媒体生成一行文字,供写进 ContextEvent.Input。
|
||||
//
|
||||
// 这是方案 C 的落点:**描述文本才是持久语义记忆,blob 只是缓存**。
|
||||
// blob 可能被容量 GC 淘汰,但描述会一直留在 L0/L2/L3 的文本里,
|
||||
// blob 可能已被删除,但描述会一直留在 L0/L2/L3 的文本里,
|
||||
// 让"那张紫蓝红三色带图"在几个月后仍然可被检索到。
|
||||
func (a *Agent) mediaSummaryForEvent(digests []string) string {
|
||||
if a.mediaStore == nil || len(digests) == 0 {
|
||||
func (a *Agent) mediaSummaryForEvent(blocks []memory.MemoryBlock) string {
|
||||
if a.mediaStore == nil || len(blocks) == 0 {
|
||||
return ""
|
||||
}
|
||||
var lines []string
|
||||
for _, d := range digests {
|
||||
if line := a.mediaMarkerLine(d); line != "" {
|
||||
for _, b := range blocks {
|
||||
if line := a.mediaMarkerLine(b.PayloadDigest); line != "" {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
@ -163,7 +210,7 @@ func (a *Agent) mediaSummaryForEvent(digests []string) string {
|
||||
// mediaContextForSentences 各拼一份,改动截断长度或分隔符时只改一处,
|
||||
// 另一处写出的标记就再也解析不回来——而解析失败是静默的(引用挂不上)。
|
||||
//
|
||||
// 查不到返回空串:媒体可能已被容量 GC 淘汰,此时不该造出一条指向虚无的标记。
|
||||
// 查不到返回空串:媒体可能已被删除,此时不该造出一条指向虚无的标记。
|
||||
func (a *Agent) mediaMarkerLine(digest string) string {
|
||||
if a.mediaStore == nil {
|
||||
return ""
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -19,7 +19,7 @@ func (a *Agent) buildMemoryContext(input string, maxTokens int) string {
|
||||
// 不做这一步的后果:媒体描述进了 L3,agent 却拿不出来。图库句子里
|
||||
// 写着 [image/png a1b2c3d4e5f6] 这样的短标记,但没有任何东西告诉
|
||||
// 模型那份内容是否还在、能否重新查看——描述永存而 blob 可能已被
|
||||
// 容量 GC 淘汰,两者状态不同,必须显式告知。
|
||||
// 删除,两者状态不同,必须显式告知。
|
||||
//
|
||||
// 注意不能直接用 injected.Relations:BuildContext 刻意把它置为 nil
|
||||
//(自动注入只给实体索引以省 token,细节留给 memory_recall)。
|
||||
|
||||
@ -649,14 +649,11 @@ func (r *ConfigRegistry) seedCoreDefs(dataDir string) {
|
||||
reg(ConfigDef{Key: "core.memory.graph", Default: filepath.Join(dataDir, "memory", "graph.db"), Type: "string", DisplayName: "图数据库路径", Description: "长期记忆(图数据库)存储路径", Category: "paths"})
|
||||
reg(ConfigDef{Key: "core.memory.text", Default: filepath.Join(dataDir, "memory", "text"), Type: "string", DisplayName: "文本记忆路径", Description: "短期文本记忆存储目录", Category: "paths"})
|
||||
reg(ConfigDef{Key: "core.memory.documents", Default: filepath.Join(dataDir, "memory", "documents"), Type: "string", DisplayName: "文档记忆路径", Description: "文档记忆存储目录", Category: "paths"})
|
||||
reg(ConfigDef{Key: "core.memory.media.enabled", Default: "true", Type: "bool", DisplayName: "媒体记忆", Description: "把对话里出现的图片/音频按内容摘要(sha256)落盘去重,记忆各层只记 digest。关闭后媒体仅在当前对话内可见,下一轮起只剩路径或 alt 文本", Category: "memory"})
|
||||
reg(ConfigDef{Key: "core.memory.media.enabled", Default: "true", Type: "bool", DisplayName: "媒体记忆", Description: "把对话里出现的图片/音频变成一等记忆块,内容按 sha256 落盘去重。关闭后媒体仅在当前对话内可见,下一轮起只剩路径或 alt 文本", Category: "memory"})
|
||||
reg(ConfigDef{Key: "core.memory.media.dir", Default: filepath.Join(dataDir, "memory", "media"), Type: "string", DisplayName: "媒体存储路径", Description: "媒体内容寻址存储目录(内含 media.db 与 blobs/)", Category: "paths"})
|
||||
reg(ConfigDef{Key: "core.memory.media.max_mb", Default: "2048", Type: "int", DisplayName: "媒体容量上限(MB)", Description: "超限时按最后访问时间淘汰无引用的媒体;被记忆引用的内容即使超限也不会删除(宁可超限也不断引用)。描述文本不受此限,淘汰后仍可检索", Category: "memory"})
|
||||
reg(ConfigDef{Key: "core.memory.media.gc_interval", Default: "6h", Type: "duration", DisplayName: "媒体 GC 间隔", Description: "清理无引用媒体的周期;0 表示不自动清理", Category: "memory"})
|
||||
reg(ConfigDef{Key: "core.memory.media.gc_min_age", Default: "1h", Type: "duration", DisplayName: "媒体 GC 保护期", Description: "新入库媒体在此时长内不被清理。刚落盘还没来得及挂到记忆上的项引用计数也是 0,靠这个保护期避免被误删", Category: "memory"})
|
||||
reg(ConfigDef{Key: "core.memory.media.describe_on_ingest", Default: "false", Type: "bool", DisplayName: "自动描述媒体", Description: "后台用视觉/音频模型给未描述的媒体生成文字描述。**描述文本才是持久语义记忆**——blob 会被容量 GC 淘汰,描述会随记忆各层一直留存并可检索。代价是消耗视觉模型配额(单张图实测约 10s),故默认关闭;开启后每 30s 最多处理 4 条,不跟对话抢额度", Category: "memory"})
|
||||
reg(ConfigDef{Key: "core.memory.media.clip_model_dir", Default: "", Type: "string", DisplayName: "CLIP ONNX 模型目录", Description: "多模态嵌入的 CLIP ONNX 模型目录(含 text.onnx、vision.onnx、clip_config.json、tokenizer.json、merges.txt)。multimodal_space.type=onnx 时从此目录加载内嵌模型;留空且 type=onnx 时禁用多模态向量检索。修改后需重启生效。", Category: "memory"})
|
||||
reg(ConfigDef{Key: "core.memory.multimodal_space.type", Default: "", Type: "string", DisplayName: "多模态向量空间类型", Description: "可插拔多模态向量空间的实现类型。onnx = 内嵌 ONNX 模型(CLIP 等);http = 外部向量 API 服务。留空禁用多模态向量检索,只保留 fastText 文本路径。两条路径共享同一套 L0/L2/L3 向量缓存与检索基础设施。", Category: "memory"})
|
||||
reg(ConfigDef{Key: "core.memory.media.describe_on_ingest", Default: "false", Type: "bool", DisplayName: "自动描述媒体", Description: "后台用视觉/音频模型给未描述的媒体生成文字描述。描述文本是可检索的语义入口,供各层记忆复用。代价是消耗视觉模型配额(单张图实测约 10s),故默认关闭;开启后每 30s 最多处理 4 条,不跟对话抢额度", Category: "memory"})
|
||||
reg(ConfigDef{Key: "core.memory.multimodal_space.type", Default: "", Type: "string", DisplayName: "多模态向量空间类型", Description: "可插拔多模态向量空间的实现类型。onnx = 内嵌 Qwen3-VL 完整图文共享模型;http = 外部向量 API 服务。留空禁用多模态向量检索,只保留 fastText 文本路径。两条路径共享同一套 L0/L2/L3 向量缓存与检索基础设施。", Category: "memory"})
|
||||
reg(ConfigDef{Key: "core.memory.multimodal_space.onnx.model_dir", Default: "", Type: "string", DisplayName: "Qwen 多模态 ONNX 目录", Description: "完整 Qwen3-VL 图文共享向量模型目录(含 TokenEmbedding.onnx、Transformer.onnx、Vision.onnx、外部权重、tokenizer.json、embed_config.json)。type=onnx 时必填,修改后需重启生效。", Category: "memory"})
|
||||
reg(ConfigDef{Key: "core.memory.multimodal_space.http.endpoint", Default: "", Type: "string", DisplayName: "外部向量 API 端点", Description: "外部多模态向量服务的 HTTP 端点 URL(POST,接受 modality/side/text/data/mime,返回 embedding)。type=http 时必填。", Category: "memory"})
|
||||
reg(ConfigDef{Key: "core.memory.multimodal_space.http.api_key", Default: "", Type: "string", DisplayName: "外部向量 API 密钥", Description: "外部多模态向量服务的 API 密钥(作为 Bearer token 发送)。可选。", Category: "memory"})
|
||||
reg(ConfigDef{Key: "core.memory.multimodal_space.http.model", Default: "", Type: "string", DisplayName: "外部向量模型标识", Description: "外部向量服务使用的模型名称,作为 vec_model 持久化。模型切换后历史向量会自动重算。", Category: "memory"})
|
||||
|
||||
@ -217,3 +217,40 @@ func (g *GraphDB) MemoryBlockEdges() ([]MemoryBlockEdge, error) {
|
||||
}
|
||||
return edges, rows.Err()
|
||||
}
|
||||
|
||||
// BlocksForNode 返回与某个图节点通过任意边相连的一等记忆块。
|
||||
// 例:sentence --contains--> block;entity --depicts--> block。
|
||||
func (g *GraphDB) BlocksForNode(nodeKind, nodeID string) ([]MemoryBlock, error) {
|
||||
g.mu.RLock()
|
||||
defer g.mu.RUnlock()
|
||||
rows, err := g.db.Query(`SELECT b.id, b.modality, b.text_content, b.payload_digest,
|
||||
b.mime, b.size, b.width, b.height, b.vector, b.fingerprint, b.source, b.tool,
|
||||
b.created_at, b.updated_at
|
||||
FROM memory_block_edges e
|
||||
JOIN memory_blocks b ON (
|
||||
(e.source_kind = 'block' AND e.source_id = b.id AND e.target_kind = ? AND e.target_id = ?)
|
||||
OR (e.target_kind = 'block' AND e.target_id = b.id AND e.source_kind = ? AND e.source_id = ?))
|
||||
ORDER BY b.created_at, b.id`, nodeKind, nodeID, nodeKind, nodeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var blocks []MemoryBlock
|
||||
for rows.Next() {
|
||||
var block MemoryBlock
|
||||
var vectorJSON string
|
||||
if err := rows.Scan(&block.ID, &block.Modality, &block.Text, &block.PayloadDigest,
|
||||
&block.MIME, &block.Size, &block.Width, &block.Height, &vectorJSON,
|
||||
&block.Fingerprint, &block.Source, &block.Tool, &block.CreatedAt,
|
||||
&block.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if vectorJSON != "" && vectorJSON != "null" {
|
||||
if err := json.Unmarshal([]byte(vectorJSON), &block.Vector); err != nil {
|
||||
return nil, fmt.Errorf("decode memory block %s vector: %w", block.ID, err)
|
||||
}
|
||||
}
|
||||
blocks = append(blocks, block)
|
||||
}
|
||||
return blocks, rows.Err()
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
@ -13,6 +14,7 @@ import (
|
||||
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/tfidf"
|
||||
)
|
||||
|
||||
// ChannelCleaner 按事件来源查找输入通道的 Cleaner 函数。
|
||||
@ -21,51 +23,97 @@ type ChannelCleaner func(source string) func(string) string
|
||||
|
||||
// Doc — 记忆文档:由上下文提炼而来
|
||||
type Doc struct {
|
||||
ID string `json:"id"`
|
||||
Summary string `json:"summary"`
|
||||
Content string `json:"content"`
|
||||
Tags []string `json:"tags"`
|
||||
Entities []string `json:"entities"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Source string `json:"source"` // context / graph / manual
|
||||
Meta map[string]string `json:"meta,omitempty"`
|
||||
AccessCount int `json:"access_count"` // 访问次数
|
||||
LastAccess time.Time `json:"last_access"` // 最后访问时间
|
||||
// Media 是这篇 L2 文档原生持有的多模态块 digest。坐标保存在 media.Store,
|
||||
// 文档只持引用;被 Consume 召回到 L0 或归档到 L3 时必须随文本一起迁移。
|
||||
Media []string `json:"media,omitempty"`
|
||||
Vector vector.Vector `json:"vector,omitempty"` // 预计算文本向量(TF-IDF 稀疏,与 context 同空间)
|
||||
DenseVec []float64 `json:"dense_vec,omitempty"` // 多模态稠密向量(与媒体共享空间)
|
||||
ID string `json:"id"`
|
||||
Summary string `json:"summary"`
|
||||
Content string `json:"content"`
|
||||
Tags []string `json:"tags"`
|
||||
Entities []string `json:"entities"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Source string `json:"source"`
|
||||
Meta map[string]string `json:"meta,omitempty"`
|
||||
AccessCount int `json:"access_count"`
|
||||
LastAccess time.Time `json:"last_access"`
|
||||
Blocks []memory.MemoryBlock `json:"blocks,omitempty"` // 一等记忆块(text/image/video/audio)
|
||||
Vector tfidf.Vector `json:"vector,omitempty"` // TF-IDF 稀疏向量(fallback 时持久化)
|
||||
DenseVec []float64 `json:"dense_vec,omitempty"` // 多模态稠密向量(主路径)
|
||||
}
|
||||
|
||||
// Store — 文档记忆存储,包含向量索引
|
||||
// Store — 文档记忆存储。
|
||||
// 主路径:denseSpace(稠密多模态向量,与媒体共享空间)。
|
||||
// Fallback:tfidf(TF-IDF 倒排索引,仅稠密空间不可用时加载)。
|
||||
type Store struct {
|
||||
dir string
|
||||
vec *vector.Store
|
||||
veczer *vector.TFIDFVectorizer
|
||||
mu sync.RWMutex
|
||||
|
||||
docs map[string]*Doc
|
||||
summaries []string // 用于训练向量化器,最大 10000 条
|
||||
vectorizer vector.Vectorizer // 可选:与 context 同空间的向量化器
|
||||
denseSpace vector.MultimodalEmbedder // 可选:稠密多模态向量空间
|
||||
|
||||
dir string
|
||||
mu sync.RWMutex
|
||||
docs map[string]*Doc
|
||||
dirty bool
|
||||
|
||||
// fallback 路径(仅稠密空间不可用时加载)
|
||||
tfidfEmb *tfidf.Embedder
|
||||
tfidfIdx *tfidf.SearchableIndex
|
||||
trainTexts []string // 缓存训练文本,延迟训练
|
||||
tfidfOnce sync.Once
|
||||
|
||||
// 主路径
|
||||
denseSpace vector.MultimodalEmbedder
|
||||
}
|
||||
|
||||
// SetDenseSpace 设置稠密多模态向量空间。配置后文档检索使用稠密余弦(brute-force),
|
||||
// 与媒体检索共享同一向量空间,实现真正的统一跨模态检索。
|
||||
const maxSummaries = 10000
|
||||
|
||||
// NewStore 创建文档存储。tokenizer 由外层注入(如 jieba),核心不直接依赖分词库。
|
||||
func NewStore(dir string, tokenizer tfidf.Tokenizer) *Store {
|
||||
return &Store{
|
||||
dir: dir,
|
||||
docs: make(map[string]*Doc),
|
||||
// tfidf 延迟初始化:只在需要 fallback 时创建
|
||||
tfidfEmb: tfidf.NewEmbedder(tokenizer, 4096),
|
||||
}
|
||||
}
|
||||
|
||||
// ensureTFIDF 延迟初始化 TF-IDF 索引(仅 fallback 路径)。
|
||||
// 调用方已持有 s.mu。
|
||||
func (s *Store) ensureTFIDF() {
|
||||
s.tfidfOnce.Do(func() {
|
||||
s.tfidfIdx = tfidf.NewSearchableIndex(s.tfidfEmb)
|
||||
// 延迟训练:用缓存的文本建立索引
|
||||
texts := make(map[string]string, len(s.trainTexts)/2)
|
||||
for i := 0; i+1 < len(s.trainTexts); i += 2 {
|
||||
texts[s.trainTexts[i]] = s.trainTexts[i+1]
|
||||
}
|
||||
s.tfidfIdx.Train(texts)
|
||||
s.trainTexts = nil // 释放缓存
|
||||
s.tfidfEmb.Train(func() []string {
|
||||
out := make([]string, 0, len(texts))
|
||||
for _, t := range texts {
|
||||
out = append(out, t)
|
||||
}
|
||||
return out
|
||||
}())
|
||||
log.Printf("[document memory] tfidf fallback loaded: %d docs", len(texts))
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Store) Start() error {
|
||||
if err := os.MkdirAll(s.dir, 0755); err != nil {
|
||||
return fmt.Errorf("document store dir: %w", err)
|
||||
}
|
||||
if err := s.loadAll(); err != nil {
|
||||
log.Printf("[document memory] load error: %v", err)
|
||||
}
|
||||
log.Printf("[document memory] started with %d docs", len(s.docs))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) Stop() { s.flush() }
|
||||
|
||||
// SetDenseSpace 设置稠密多模态向量空间(主路径)。
|
||||
func (s *Store) SetDenseSpace(ds vector.MultimodalEmbedder) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.denseSpace = ds
|
||||
}
|
||||
|
||||
// buildDenseIndex 为所有文档计算稠密向量并建立 brute-force 索引。
|
||||
// 在启动时或配置变更后调用一次。492 篇文档 brute-force ~300ms,可接受。
|
||||
// BuildDenseIndex 为所有文档计算稠密向量并建立 brute-force 索引。
|
||||
// 在启动时或配置变更后调用一次。492 篇文档 brute-force ~300ms,可接受。
|
||||
// BuildDenseIndex 为所有文档计算稠密向量。
|
||||
func (s *Store) BuildDenseIndex(ds vector.MultimodalEmbedder) {
|
||||
if ds == nil || !ds.Loaded() {
|
||||
return
|
||||
@ -76,7 +124,7 @@ func (s *Store) BuildDenseIndex(ds vector.MultimodalEmbedder) {
|
||||
count := 0
|
||||
for _, doc := range s.docs {
|
||||
if doc.DenseVec != nil && len(doc.DenseVec) == ds.Dim() {
|
||||
continue // 已有向量,跳过
|
||||
continue
|
||||
}
|
||||
text := doc.Summary + " " + doc.Content
|
||||
vec, err := ds.VectorizeDense(text)
|
||||
@ -90,8 +138,225 @@ func (s *Store) BuildDenseIndex(ds vector.MultimodalEmbedder) {
|
||||
log.Printf("[document memory] dense index built: %d new vectors", count)
|
||||
}
|
||||
|
||||
// denseSearchScored 对所有文档做 brute-force 余弦检索,返回 topK 个最相似的候选。
|
||||
// 仅在 denseSpace 配置后使用;未配置时退化到 TF-IDF 倒排检索。
|
||||
// Reindex 重建 TF-IDF 索引(fallback 路径变更时调用)。
|
||||
func (s *Store) Reindex() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.tfidfOnce = sync.Once{} // 重置延迟初始化
|
||||
texts := make(map[string]string, len(s.docs))
|
||||
for _, doc := range s.docs {
|
||||
texts[doc.ID] = doc.Summary + " " + doc.Content
|
||||
}
|
||||
// 缓存文本供 ensureTFIDF 延迟训练
|
||||
s.trainTexts = make([]string, 0, len(texts)*2)
|
||||
for id, t := range texts {
|
||||
s.trainTexts = append(s.trainTexts, id, t)
|
||||
}
|
||||
s.ensureTFIDF()
|
||||
}
|
||||
|
||||
func (s *Store) Insert(doc *Doc) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if doc.ID == "" {
|
||||
doc.ID = fmt.Sprintf("doc_%d", time.Now().UnixNano())
|
||||
doc.CreatedAt = time.Now()
|
||||
}
|
||||
doc.UpdatedAt = time.Now()
|
||||
doc.LastAccess = time.Now()
|
||||
if doc.AccessCount == 0 {
|
||||
doc.AccessCount = 1
|
||||
}
|
||||
s.docs[doc.ID] = doc
|
||||
|
||||
text := doc.Summary + " " + doc.Content
|
||||
|
||||
// 主路径:稠密向量
|
||||
if s.denseSpace != nil && s.denseSpace.Loaded() && len(doc.DenseVec) == 0 {
|
||||
if dv, err := s.denseSpace.VectorizeDense(text); err == nil {
|
||||
doc.DenseVec = dv
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback 路径:缓存文本,延迟训练
|
||||
if s.tfidfIdx != nil {
|
||||
s.tfidfIdx.Add(doc.ID, text)
|
||||
} else {
|
||||
s.trainTexts = append(s.trainTexts, doc.ID, text)
|
||||
}
|
||||
|
||||
path := filepath.Join(s.dir, doc.ID+".json")
|
||||
data, _ := json.MarshalIndent(doc, "", " ")
|
||||
os.WriteFile(path, data, 0644)
|
||||
s.dirty = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// ContextToDoc 将上下文对话历史提炼为文档。
|
||||
func (s *Store) ContextToDoc(source string, entries []ContextEntry, _ interface{}, cleanFn func(string) string, toolCleanFn func(name, output string) string, channelCleaner ChannelCleaner) (*Doc, error) {
|
||||
if len(entries) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if cleanFn == nil {
|
||||
cleanFn = func(text string) string { return text }
|
||||
}
|
||||
|
||||
var parts []string
|
||||
for _, e := range entries {
|
||||
line := fmt.Sprintf("[%s] %s: %s", e.Timestamp.Format("15:04"), e.Source, e.Content)
|
||||
if e.Response != "" {
|
||||
line += fmt.Sprintf(" → %s", truncate(e.Response, 100))
|
||||
}
|
||||
for _, tr := range e.ToolResults {
|
||||
line += fmt.Sprintf("\n [工具] %s: %s", tr.Name, truncate(tr.Output, 200))
|
||||
}
|
||||
parts = append(parts, line)
|
||||
}
|
||||
content := strings.Join(parts, "\n")
|
||||
contentHash := simpleHash(content)
|
||||
summary := summarizeEntries(entries, cleanFn, toolCleanFn, channelCleaner)
|
||||
tags := extractTags(entries, cleanFn, toolCleanFn, channelCleaner)
|
||||
entities := extractEntities(entries, cleanFn, toolCleanFn, channelCleaner)
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
for _, d := range s.docs {
|
||||
if d.Meta != nil && d.Meta["content_hash"] == contentHash {
|
||||
d.UpdatedAt = time.Now()
|
||||
d.LastAccess = time.Now()
|
||||
d.Content = content
|
||||
d.Source = source
|
||||
d.Summary = summary
|
||||
d.Tags = tags
|
||||
d.Entities = entities
|
||||
d.Blocks = blocksFromEntries(entries)
|
||||
s.dirty = true
|
||||
return d, nil
|
||||
}
|
||||
}
|
||||
|
||||
id := fmt.Sprintf("doc_%d", time.Now().UnixNano())
|
||||
meta := map[string]string{"content_hash": contentHash}
|
||||
if source == "context_archived" {
|
||||
meta["is_archived_context"] = "true"
|
||||
}
|
||||
doc := &Doc{
|
||||
ID: id, Summary: summary, Content: content, Tags: tags,
|
||||
Entities: entities, CreatedAt: time.Now(), UpdatedAt: time.Now(),
|
||||
LastAccess: time.Now(), AccessCount: 1, Source: source, Meta: meta,
|
||||
Blocks: blocksFromEntries(entries),
|
||||
}
|
||||
s.docs[id] = doc
|
||||
text := summary + " " + content
|
||||
if s.tfidfIdx != nil {
|
||||
s.tfidfIdx.Add(id, text)
|
||||
} else {
|
||||
s.trainTexts = append(s.trainTexts, id, text)
|
||||
}
|
||||
|
||||
path := filepath.Join(s.dir, id+".json")
|
||||
data, _ := json.MarshalIndent(doc, "", " ")
|
||||
os.WriteFile(path, data, 0644)
|
||||
s.dirty = true
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
// Consume 向量相似度查询并移除文档
|
||||
func (s *Store) Consume(text string, topK int) []*Doc {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if topK <= 0 {
|
||||
topK = 5
|
||||
}
|
||||
|
||||
// 主路径:稠密检索
|
||||
if s.denseSpace != nil && s.denseSpace.Loaded() {
|
||||
if qv, err := s.denseSpace.VectorizeDense(text); err == nil {
|
||||
results := s.denseSearchScored(qv, topK)
|
||||
var docs []*Doc
|
||||
for _, r := range results {
|
||||
if d, ok := s.docs[r.Doc.ID]; ok {
|
||||
s.removeDoc(r.Doc.ID)
|
||||
s.dirty = true
|
||||
docs = append(docs, d)
|
||||
}
|
||||
}
|
||||
return docs
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback:TF-IDF 倒排检索(延迟初始化)
|
||||
s.ensureTFIDF()
|
||||
results := s.tfidfIdx.Search(text, topK)
|
||||
var docs []*Doc
|
||||
for _, r := range results {
|
||||
if d, ok := s.docs[r.ID]; ok {
|
||||
s.removeDoc(r.ID)
|
||||
s.dirty = true
|
||||
docs = append(docs, d)
|
||||
}
|
||||
}
|
||||
return docs
|
||||
}
|
||||
|
||||
func (s *Store) Query(text string, topK int) []*Doc {
|
||||
hits := s.QueryScored(text, topK)
|
||||
out := make([]*Doc, len(hits))
|
||||
for i, h := range hits {
|
||||
out[i] = h.Doc
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// DocHit 是一篇文档记忆的相似度候选及原始分数。
|
||||
type DocHit struct {
|
||||
Doc *Doc
|
||||
Score float64
|
||||
}
|
||||
|
||||
func (s *Store) QueryScored(text string, topK int) []DocHit {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
if topK <= 0 {
|
||||
topK = 5
|
||||
}
|
||||
|
||||
// 主路径
|
||||
if s.denseSpace != nil && s.denseSpace.Loaded() {
|
||||
if qv, err := s.denseSpace.VectorizeDense(text); err == nil {
|
||||
results := s.denseSearchScored(qv, topK)
|
||||
for i := range results {
|
||||
if d, ok := s.docs[results[i].Doc.ID]; ok {
|
||||
d.AccessCount++
|
||||
d.LastAccess = time.Now()
|
||||
results[i].Doc = d
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback(需要写锁来 ensureTFIDF)
|
||||
s.mu.RUnlock()
|
||||
s.mu.Lock()
|
||||
s.ensureTFIDF()
|
||||
s.mu.Unlock()
|
||||
s.mu.RLock()
|
||||
|
||||
results := s.tfidfIdx.Search(text, topK)
|
||||
var out []DocHit
|
||||
for _, r := range results {
|
||||
if d, ok := s.docs[r.ID]; ok {
|
||||
d.AccessCount++
|
||||
d.LastAccess = time.Now()
|
||||
out = append(out, DocHit{Doc: d, Score: r.Score})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Store) denseSearchScored(queryVec []float64, topK int) []DocHit {
|
||||
if len(queryVec) == 0 {
|
||||
return nil
|
||||
@ -101,13 +366,11 @@ func (s *Store) denseSearchScored(queryVec []float64, topK int) []DocHit {
|
||||
score float64
|
||||
}
|
||||
var results []scored
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
for _, doc := range s.docs {
|
||||
if len(doc.DenseVec) != len(queryVec) {
|
||||
continue
|
||||
}
|
||||
score := vector.DenseCosine(queryVec, doc.DenseVec)
|
||||
score := denseCosine(queryVec, doc.DenseVec)
|
||||
if score > 0.01 {
|
||||
results = append(results, scored{doc.ID, score})
|
||||
}
|
||||
@ -126,332 +389,36 @@ func (s *Store) denseSearchScored(queryVec []float64, topK int) []DocHit {
|
||||
return out
|
||||
}
|
||||
|
||||
// denseCosine 计算两个 []float64 向量的余弦相似度(已迁移到 vector.DenseCosine,此处保留兼容)。
|
||||
func denseCosine(a, b []float64) float64 {
|
||||
return vector.DenseCosine(a, b)
|
||||
}
|
||||
|
||||
func (s *Store) SetVectorizer(v vector.Vectorizer) {
|
||||
s.vectorizer = v
|
||||
}
|
||||
|
||||
// ReindexWithVectorizer 用给定的向量化器重建所有文档的向量索引
|
||||
func (s *Store) ReindexWithVectorizer(v vector.Vectorizer) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
log.Printf("[document memory] reindex with vectorizer (%d docs)", len(s.docs))
|
||||
s.vec = vector.NewStore()
|
||||
for _, doc := range s.docs {
|
||||
doc.Vector = v.Vectorize(doc.Summary + " " + doc.Content)
|
||||
s.vec.Insert(doc.ID, doc.Summary, doc.Vector, doc.Meta)
|
||||
var dot, na, nb float64
|
||||
for i := range a {
|
||||
dot += a[i] * b[i]
|
||||
na += a[i] * a[i]
|
||||
nb += b[i] * b[i]
|
||||
}
|
||||
log.Printf("[document memory] reindex with vectorizer complete (%d vectors)", s.vec.Size())
|
||||
}
|
||||
|
||||
const maxSummaries = 10000
|
||||
|
||||
func NewStore(dir string) *Store {
|
||||
return &Store{
|
||||
dir: dir,
|
||||
vec: vector.NewStore(),
|
||||
veczer: vector.NewTFIDFVectorizer(memory.TokenizeWords),
|
||||
docs: make(map[string]*Doc),
|
||||
if na == 0 || nb == 0 {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) Start() error {
|
||||
if err := os.MkdirAll(s.dir, 0755); err != nil {
|
||||
return fmt.Errorf("document store dir: %w", err)
|
||||
}
|
||||
if err := s.loadAll(); err != nil {
|
||||
log.Printf("[document memory] load error: %v", err)
|
||||
}
|
||||
log.Printf("[document memory] started with %d docs, %d vectors", len(s.docs), s.vec.Size())
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) Stop() {
|
||||
s.flush()
|
||||
}
|
||||
|
||||
// Insert 创建/更新文档
|
||||
func (s *Store) Insert(doc *Doc) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if doc.ID == "" {
|
||||
doc.ID = fmt.Sprintf("doc_%d", time.Now().UnixNano())
|
||||
doc.CreatedAt = time.Now()
|
||||
}
|
||||
doc.UpdatedAt = time.Now()
|
||||
doc.LastAccess = time.Now()
|
||||
if doc.AccessCount == 0 {
|
||||
doc.AccessCount = 1
|
||||
}
|
||||
|
||||
s.docs[doc.ID] = doc
|
||||
|
||||
// 增量训练向量化器并加入向量索引
|
||||
s.addSummary(doc.Summary)
|
||||
vec := doc.Vector
|
||||
if vec == nil {
|
||||
vec = s.veczer.Vectorize(doc.Summary + " " + doc.Content)
|
||||
}
|
||||
s.vec.Insert(doc.ID, doc.Summary, vec, doc.Meta)
|
||||
|
||||
// 若配置了稠密空间,为新文档计算稠密向量
|
||||
if s.denseSpace != nil && s.denseSpace.Loaded() && len(doc.DenseVec) == 0 {
|
||||
if dv, err := s.denseSpace.VectorizeDense(doc.Summary + " " + doc.Content); err == nil {
|
||||
doc.DenseVec = dv
|
||||
}
|
||||
}
|
||||
|
||||
// 立即写盘
|
||||
path := filepath.Join(s.dir, doc.ID+".json")
|
||||
data, _ := json.MarshalIndent(doc, "", " ")
|
||||
os.WriteFile(path, data, 0644)
|
||||
|
||||
s.dirty = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// ContextToDoc — 将一段上下文对话历史提炼为文档(带内容去重)
|
||||
// cleanFn 可选,在计算层前统一过滤文本,不影响原文存储。
|
||||
// toolCleanFn 可选,func(name, output string) string,按工具名对输出进行过滤/清洗:
|
||||
// - 返回 "" → 跳过该工具输出(NoMemory)
|
||||
// - 返回清洗后文本 → 用于计算层(Cleaner),原文不受影响
|
||||
func (s *Store) ContextToDoc(source string, entries []ContextEntry, vec vector.Vectorizer, cleanFn func(string) string, toolCleanFn func(name, output string) string, channelCleaner ChannelCleaner) (*Doc, error) {
|
||||
if len(entries) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if cleanFn == nil {
|
||||
cleanFn = func(text string) string { return text }
|
||||
}
|
||||
|
||||
var parts []string
|
||||
for _, e := range entries {
|
||||
line := fmt.Sprintf("[%s] %s: %s", e.Timestamp.Format("15:04"), e.Source, e.Content)
|
||||
if e.Response != "" {
|
||||
line += fmt.Sprintf(" → %s", truncate(e.Response, 100))
|
||||
}
|
||||
for _, tr := range e.ToolResults {
|
||||
line += fmt.Sprintf("\n [工具] %s: %s", tr.Name, truncate(tr.Output, 200))
|
||||
}
|
||||
parts = append(parts, line)
|
||||
}
|
||||
content := strings.Join(parts, "\n")
|
||||
contentHash := simpleHash(content)
|
||||
|
||||
summary := summarizeEntries(entries, cleanFn, toolCleanFn, channelCleaner)
|
||||
tags := extractTags(entries, cleanFn, toolCleanFn, channelCleaner)
|
||||
entities := extractEntities(entries, cleanFn, toolCleanFn, channelCleaner)
|
||||
|
||||
s.mu.Lock()
|
||||
|
||||
// 去重
|
||||
for _, d := range s.docs {
|
||||
if d.Meta != nil && d.Meta["content_hash"] == contentHash {
|
||||
d.UpdatedAt = time.Now()
|
||||
d.LastAccess = time.Now()
|
||||
d.Content = content
|
||||
d.Source = source
|
||||
d.Summary = summary
|
||||
d.Tags = tags
|
||||
d.Entities = entities
|
||||
d.Media = mediaDigestsFromEntries(entries)
|
||||
s.dirty = true
|
||||
s.mu.Unlock()
|
||||
return d, nil
|
||||
}
|
||||
}
|
||||
|
||||
id := fmt.Sprintf("doc_%d", time.Now().UnixNano())
|
||||
var docVec vector.Vector
|
||||
if vec != nil {
|
||||
docVec = vec.Vectorize(summary + " " + content)
|
||||
} else {
|
||||
docVec = s.veczer.Vectorize(summary + " " + content)
|
||||
}
|
||||
meta := map[string]string{"content_hash": contentHash}
|
||||
if source == "context_archived" {
|
||||
meta["is_archived_context"] = "true"
|
||||
}
|
||||
doc := &Doc{
|
||||
ID: id,
|
||||
Summary: summary,
|
||||
Content: content,
|
||||
Tags: tags,
|
||||
Entities: entities,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
LastAccess: time.Now(),
|
||||
AccessCount: 1,
|
||||
Source: source,
|
||||
Meta: meta,
|
||||
Media: mediaDigestsFromEntries(entries),
|
||||
Vector: docVec,
|
||||
}
|
||||
s.docs[id] = doc
|
||||
|
||||
// 加入向量索引
|
||||
s.addSummary(summary)
|
||||
s.vec.Insert(id, summary, doc.Vector, nil)
|
||||
|
||||
s.dirty = true
|
||||
s.mu.Unlock()
|
||||
|
||||
// 立即写盘
|
||||
path := filepath.Join(s.dir, id+".json")
|
||||
data, _ := json.MarshalIndent(doc, "", " ")
|
||||
os.WriteFile(path, data, 0644)
|
||||
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
// Consume — 向量相似度查询并移除文档(召回后即从冷存储删除,避免重复记忆)
|
||||
func (s *Store) Consume(text string, topK int) []*Doc {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if topK <= 0 {
|
||||
topK = 5
|
||||
}
|
||||
|
||||
// 优先稠密检索(与媒体共享空间);未配置时退化到 TF-IDF 倒排检索。
|
||||
if s.denseSpace != nil && s.denseSpace.Loaded() {
|
||||
queryVec, err := s.denseSpace.VectorizeDense(text)
|
||||
if err == nil {
|
||||
results := s.denseSearchScored(queryVec, topK)
|
||||
var docs []*Doc
|
||||
for _, r := range results {
|
||||
if d, ok := s.docs[r.Doc.ID]; ok {
|
||||
s.removeDoc(r.Doc.ID)
|
||||
s.dirty = true
|
||||
docs = append(docs, d)
|
||||
}
|
||||
}
|
||||
return docs
|
||||
}
|
||||
log.Printf("[document memory] dense query failed, falling back to TF-IDF: %v", err)
|
||||
}
|
||||
|
||||
vec := s.vectorizeQuery(text)
|
||||
results := s.vec.Search(vec, topK)
|
||||
|
||||
var docs []*Doc
|
||||
for _, r := range results {
|
||||
if d, ok := s.docs[r.ID]; ok {
|
||||
s.removeDoc(r.ID)
|
||||
s.dirty = true
|
||||
docs = append(docs, d)
|
||||
}
|
||||
}
|
||||
return docs
|
||||
}
|
||||
|
||||
// vectorizeQuery 用语义向量化器(首选)或 TF-IDF(兜底)处理查询文本
|
||||
func (s *Store) vectorizeQuery(text string) vector.Vector {
|
||||
if s.vectorizer != nil {
|
||||
return s.vectorizer.Vectorize(text)
|
||||
}
|
||||
return s.veczer.Vectorize(text)
|
||||
}
|
||||
|
||||
// Query — 向量相似度查询文档
|
||||
func (s *Store) Query(text string, topK int) []*Doc {
|
||||
hits := s.QueryScored(text, topK)
|
||||
out := make([]*Doc, len(hits))
|
||||
for i, h := range hits {
|
||||
out[i] = h.Doc
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// DocHit 是一篇文档记忆的相似度候选及原始分数(供跨模态融合归一化)。
|
||||
type DocHit struct {
|
||||
Doc *Doc
|
||||
Score float64
|
||||
}
|
||||
|
||||
// QueryScored 与 Query 同语义,但返回带原始 cosine 分数的候选。
|
||||
func (s *Store) QueryScored(text string, topK int) []DocHit {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
if topK <= 0 {
|
||||
topK = 5
|
||||
}
|
||||
|
||||
// 优先稠密检索;退化到 TF-IDF。
|
||||
if s.denseSpace != nil && s.denseSpace.Loaded() {
|
||||
queryVec, err := s.denseSpace.VectorizeDense(text)
|
||||
if err == nil {
|
||||
results := s.denseSearchScored(queryVec, topK)
|
||||
for i := range results {
|
||||
if d, ok := s.docs[results[i].Doc.ID]; ok {
|
||||
d.AccessCount++
|
||||
d.LastAccess = time.Now()
|
||||
results[i].Doc = d
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
}
|
||||
|
||||
vec := s.vectorizeQuery(text)
|
||||
results := s.vec.SearchScored(vec, topK)
|
||||
|
||||
var out []DocHit
|
||||
for _, r := range results {
|
||||
if d, ok := s.docs[r.Doc.ID]; ok {
|
||||
d.AccessCount++
|
||||
d.LastAccess = time.Now()
|
||||
out = append(out, DocHit{Doc: d, Score: r.Score})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Reindex — 重新训练并重建向量索引
|
||||
func (s *Store) Reindex() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
log.Printf("[document memory] reindexing %d docs", len(s.docs))
|
||||
|
||||
s.veczer.Train(s.summaries)
|
||||
|
||||
s.vec = vector.NewStore()
|
||||
for _, doc := range s.docs {
|
||||
vec := doc.Vector
|
||||
if vec == nil {
|
||||
vec = s.veczer.Vectorize(doc.Summary + " " + doc.Content)
|
||||
}
|
||||
s.vec.Insert(doc.ID, doc.Summary, vec, doc.Meta)
|
||||
}
|
||||
|
||||
log.Printf("[document memory] reindex complete (%d vectors)", s.vec.Size())
|
||||
return dot / math.Sqrt(na*nb)
|
||||
}
|
||||
|
||||
func (s *Store) Stats() map[string]interface{} {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
idxSize := 0
|
||||
if s.tfidfIdx != nil {
|
||||
idxSize = s.tfidfIdx.Size()
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"doc_count": len(s.docs),
|
||||
"vector_count": s.vec.Size(),
|
||||
"summary_count": len(s.summaries),
|
||||
"dir": s.dir,
|
||||
"doc_count": len(s.docs),
|
||||
"index_count": idxSize,
|
||||
"dir": s.dir,
|
||||
}
|
||||
}
|
||||
|
||||
// FindColdDocs — 查找冷文档:超过 maxAge 未访问且访问次数 <= minAccess
|
||||
func (s *Store) FindColdDocs(maxAge time.Duration, minAccess int) []*Doc {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
cutoff := time.Now().Add(-maxAge)
|
||||
var cold []*Doc
|
||||
for _, d := range s.docs {
|
||||
@ -462,60 +429,53 @@ func (s *Store) FindColdDocs(maxAge time.Duration, minAccess int) []*Doc {
|
||||
return cold
|
||||
}
|
||||
|
||||
// Get 返回指定文档(不存在时为 nil)。
|
||||
func (s *Store) Get(id string) *Doc {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.docs[id]
|
||||
}
|
||||
|
||||
// Blocks 返回全部文档持有的一等记忆块(供跨层存活判定)。
|
||||
func (s *Store) Blocks() []memory.MemoryBlock {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
var out []memory.MemoryBlock
|
||||
for _, d := range s.docs {
|
||||
out = append(out, d.Blocks...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Store) RecentDocs(n int) []*Doc {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
var list []*Doc
|
||||
for _, d := range s.docs {
|
||||
list = append(list, d)
|
||||
}
|
||||
sort.Slice(list, func(i, j int) bool {
|
||||
return list[i].CreatedAt.After(list[j].CreatedAt)
|
||||
})
|
||||
sort.Slice(list, func(i, j int) bool { return list[i].CreatedAt.After(list[j].CreatedAt) })
|
||||
if len(list) > n {
|
||||
list = list[:n]
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
// Remove 从文档存储中删除指定 ID 的文档
|
||||
func (s *Store) Remove(id string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if _, ok := s.docs[id]; ok {
|
||||
s.removeDoc(id)
|
||||
s.dirty = true
|
||||
}
|
||||
}
|
||||
|
||||
// ——— internal ———
|
||||
|
||||
// addSummary 添加一条摘要到训练集,超限时截断并触发重索引。
|
||||
// 调用方必须已持有 s.mu 写锁。
|
||||
func (s *Store) addSummary(summary string) {
|
||||
s.summaries = append(s.summaries, summary)
|
||||
if len(s.summaries) > maxSummaries {
|
||||
n := maxSummaries / 2
|
||||
copy(s.summaries, s.summaries[len(s.summaries)-n:])
|
||||
s.summaries = s.summaries[:n]
|
||||
s.veczer.Train(s.summaries)
|
||||
s.vec = vector.NewStore()
|
||||
for _, doc := range s.docs {
|
||||
vec := s.veczer.Vectorize(doc.Summary + " " + doc.Content)
|
||||
s.vec.Insert(doc.ID, doc.Summary, vec, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// removeDoc 从内存索引和磁盘删除文档。
|
||||
// 调用方必须已持有 s.mu 写锁。
|
||||
func (s *Store) removeDoc(id string) {
|
||||
delete(s.docs, id)
|
||||
s.vec.Remove(id)
|
||||
path := filepath.Join(s.dir, id+".json")
|
||||
os.Remove(path)
|
||||
if s.tfidfIdx != nil {
|
||||
s.tfidfIdx.Remove(id)
|
||||
}
|
||||
os.Remove(filepath.Join(s.dir, id+".json"))
|
||||
}
|
||||
|
||||
func (s *Store) loadAll() error {
|
||||
@ -523,63 +483,43 @@ func (s *Store) loadAll() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, e := range entries {
|
||||
if !strings.HasSuffix(e.Name(), ".json") || !strings.HasPrefix(e.Name(), "doc_") {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(s.dir, e.Name())
|
||||
data, err := os.ReadFile(path)
|
||||
data, err := os.ReadFile(filepath.Join(s.dir, e.Name()))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var doc Doc
|
||||
if err := json.Unmarshal(data, &doc); err != nil {
|
||||
if json.Unmarshal(data, &doc) != nil || doc.ID == "" {
|
||||
continue
|
||||
}
|
||||
s.docs[doc.ID] = &doc
|
||||
s.summaries = append(s.summaries, doc.Summary)
|
||||
// 缓存文本,延迟训练(确保TFIDF在首次需要时才加载)
|
||||
s.trainTexts = append(s.trainTexts, doc.ID, doc.Summary+" "+doc.Content)
|
||||
}
|
||||
|
||||
// 训练向量化器
|
||||
if len(s.summaries) > 0 {
|
||||
s.veczer.Train(s.summaries)
|
||||
}
|
||||
|
||||
// 重建向量索引
|
||||
for _, doc := range s.docs {
|
||||
vec := doc.Vector
|
||||
if vec == nil {
|
||||
vec = s.veczer.Vectorize(doc.Summary + " " + doc.Content)
|
||||
}
|
||||
s.vec.Insert(doc.ID, doc.Summary, vec, nil)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) flush() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if !s.dirty {
|
||||
return
|
||||
}
|
||||
|
||||
for _, doc := range s.docs {
|
||||
path := filepath.Join(s.dir, doc.ID+".json")
|
||||
data, err := json.MarshalIndent(doc, "", " ")
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
os.WriteFile(path, data, 0644)
|
||||
data, _ := json.MarshalIndent(doc, "", " ")
|
||||
os.WriteFile(filepath.Join(s.dir, doc.ID+".json"), data, 0644)
|
||||
}
|
||||
s.dirty = false
|
||||
}
|
||||
|
||||
// ——— 内部工具函数(从上下文提炼文档所需)———
|
||||
|
||||
type ToolResultItem struct {
|
||||
Name string
|
||||
Output string
|
||||
Name string `json:"name"`
|
||||
Output string `json:"output"`
|
||||
}
|
||||
|
||||
type ContextEntry struct {
|
||||
@ -588,19 +528,20 @@ type ContextEntry struct {
|
||||
Content string
|
||||
Response string
|
||||
ToolResults []ToolResultItem
|
||||
Media []string
|
||||
Blocks []memory.MemoryBlock // 一等记忆块随事件一起迁移到文档
|
||||
}
|
||||
|
||||
func mediaDigestsFromEntries(entries []ContextEntry) []string {
|
||||
func blocksFromEntries(entries []ContextEntry) []memory.MemoryBlock {
|
||||
seen := make(map[string]bool)
|
||||
var out []string
|
||||
var out []memory.MemoryBlock
|
||||
for _, e := range entries {
|
||||
for _, d := range e.Media {
|
||||
if d == "" || seen[d] {
|
||||
for i := range e.Blocks {
|
||||
b := e.Blocks[i]
|
||||
if b.ID == "" || seen[b.ID] {
|
||||
continue
|
||||
}
|
||||
seen[d] = true
|
||||
out = append(out, d)
|
||||
seen[b.ID] = true
|
||||
out = append(out, b)
|
||||
}
|
||||
}
|
||||
return out
|
||||
@ -635,14 +576,12 @@ func summarizeEntries(entries []ContextEntry, cleanText func(string) string, too
|
||||
topics = append(topics, toolWords...)
|
||||
}
|
||||
}
|
||||
|
||||
summary := fmt.Sprintf("来自 %d 个来源的 %d 条对话", len(sources), len(entries))
|
||||
var srcList []string
|
||||
for s := range sources {
|
||||
srcList = append(srcList, s)
|
||||
}
|
||||
summary += " (" + strings.Join(srcList, ", ") + ")"
|
||||
|
||||
if len(topics) > 0 {
|
||||
seen := make(map[string]bool)
|
||||
var uniq []string
|
||||
@ -657,7 +596,6 @@ func summarizeEntries(entries []ContextEntry, cleanText func(string) string, too
|
||||
}
|
||||
summary += " 涉及: " + strings.Join(uniq, ", ")
|
||||
}
|
||||
|
||||
return summary
|
||||
}
|
||||
|
||||
@ -730,25 +668,20 @@ func extractEntities(entries []ContextEntry, cleanText func(string) string, tool
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(entities) > 20 {
|
||||
entities = entities[:20]
|
||||
}
|
||||
return entities
|
||||
}
|
||||
|
||||
func truncate(s string, max int) string {
|
||||
runes := []rune(s)
|
||||
if len(runes) > max {
|
||||
return string(runes[:max]) + "..."
|
||||
if len([]rune(s)) <= max {
|
||||
return s
|
||||
}
|
||||
return s
|
||||
return string([]rune(s)[:max]) + "..."
|
||||
}
|
||||
|
||||
func simpleHash(s string) string {
|
||||
// 简单的基于内容的哈希,用于去重
|
||||
h := 0
|
||||
for _, r := range s {
|
||||
h = h*31 + int(r)
|
||||
h := fmt.Sprintf("%x", len(s))
|
||||
for _, c := range s {
|
||||
h += fmt.Sprintf("%x", c)
|
||||
}
|
||||
return fmt.Sprintf("h%08x", h)
|
||||
return h
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -11,7 +11,7 @@
|
||||
// - 路径会失效。/tmp 下的探针图、下载缓存、其他进程的临时产物,记忆里留个
|
||||
// 路径等于留个悬空指针。
|
||||
// - 同一张图往往被反复注入(用户连问几轮同一张截图、see_video 相邻帧高度
|
||||
// 相似)。按 sha256 寻址天然去重,引用计数记住被引了几次。
|
||||
// 相似)。按 sha256 寻址天然去重,同一份字节只存一遍。
|
||||
// - 内容即身份,跟 L3 图库 `sentences.text UNIQUE` 的思路一致:文本节点用
|
||||
// 文本本身做身份,媒体节点用内容摘要做身份。
|
||||
package media
|
||||
@ -34,20 +34,6 @@ import (
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
// OwnerKind 是 media_refs.owner_kind 的取值,对应引用媒体的记忆层。
|
||||
//
|
||||
// 定义为常量而不是让调用方写字符串:owner_kind 进了主键,
|
||||
// 拼错一个字符就是一条永远对不上的孤立引用(AddRef 不会报错,
|
||||
// DropOwner 也永远匹配不到)。
|
||||
const (
|
||||
// OwnerContext 是 L0 对话上下文事件(ContextEvent.ID)。
|
||||
OwnerContext = "context"
|
||||
// OwnerDocument 是 L2 文档记忆(Doc.ID)。
|
||||
OwnerDocument = "document"
|
||||
// OwnerGraphSentence 是 L3 图库句子节点(sentences.id)。
|
||||
OwnerGraphSentence = "graph_sentence"
|
||||
)
|
||||
|
||||
// digestHexLen 是 sha256 的十六进制串长度。
|
||||
const digestHexLen = sha256.Size * 2
|
||||
|
||||
@ -88,8 +74,6 @@ type Item struct {
|
||||
Description string `json:"description,omitempty"`
|
||||
// DescribedBy 记录描述来自哪个源,让后续读者能判断可靠性。
|
||||
DescribedBy string `json:"described_by,omitempty"`
|
||||
// RefCount 是引用计数。GC 只清理归零的项。
|
||||
RefCount int `json:"ref_count"`
|
||||
// FirstSeen/LastSeen 是首末次入库时间。
|
||||
FirstSeen time.Time `json:"first_seen"`
|
||||
LastSeen time.Time `json:"last_seen"`
|
||||
@ -110,15 +94,11 @@ type Store struct {
|
||||
mu sync.RWMutex
|
||||
db *sql.DB
|
||||
blobDir string
|
||||
|
||||
// maxBytes 是内容目录的容量上限,0 表示不限。
|
||||
// 超限时 GC 按 LastSeen 从旧到新淘汰 RefCount=0 的项。
|
||||
maxBytes int64
|
||||
}
|
||||
|
||||
// New 打开(或初始化)媒体存储。
|
||||
// dir 下会建 media.db 与 blobs/ 两个条目。
|
||||
func New(dir string, maxBytes int64) (*Store, error) {
|
||||
func New(dir string) (*Store, error) {
|
||||
if err := os.MkdirAll(filepath.Join(dir, "blobs"), 0755); err != nil {
|
||||
return nil, fmt.Errorf("media: create blob dir: %w", err)
|
||||
}
|
||||
@ -127,7 +107,7 @@ func New(dir string, maxBytes int64) (*Store, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("media: open db: %w", err)
|
||||
}
|
||||
s := &Store{db: db, blobDir: filepath.Join(dir, "blobs"), maxBytes: maxBytes}
|
||||
s := &Store{db: db, blobDir: filepath.Join(dir, "blobs")}
|
||||
if err := s.initSchema(); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
@ -137,7 +117,7 @@ func New(dir string, maxBytes int64) (*Store, error) {
|
||||
|
||||
func (s *Store) initSchema() error {
|
||||
stmts := []string{
|
||||
// digest 作主键:内容即身份,重复 Put 同一内容只递增 ref_count。
|
||||
// digest 作主键:内容即身份,重复 Put 同一内容不重复落盘。
|
||||
`CREATE TABLE IF NOT EXISTS media (
|
||||
digest TEXT PRIMARY KEY,
|
||||
kind TEXT NOT NULL,
|
||||
@ -149,25 +129,11 @@ func (s *Store) initSchema() error {
|
||||
tool TEXT,
|
||||
description TEXT,
|
||||
described_by TEXT,
|
||||
ref_count INTEGER DEFAULT 0,
|
||||
first_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_media_kind ON media(kind)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_media_refcount ON media(ref_count)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_media_last_seen ON media(last_seen)`,
|
||||
// 反向索引:哪条记忆引用了哪个媒体。
|
||||
// owner_kind 取 context / document / graph_sentence,owner_id 是各层自己的标识。
|
||||
// 主键含三列,同一 owner 重复挂同一媒体是幂等的。
|
||||
`CREATE TABLE IF NOT EXISTS media_refs (
|
||||
digest TEXT NOT NULL,
|
||||
owner_kind TEXT NOT NULL,
|
||||
owner_id TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (digest, owner_kind, owner_id)
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_refs_owner ON media_refs(owner_kind, owner_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_refs_digest ON media_refs(digest)`,
|
||||
}
|
||||
for _, q := range stmts {
|
||||
if _, err := s.db.Exec(q); err != nil {
|
||||
@ -231,8 +197,8 @@ func (s *Store) Put(data []byte, meta Item) (string, error) {
|
||||
_, err := s.db.Exec(`
|
||||
INSERT INTO media (digest, kind, mime, size, width, height,
|
||||
origin_path, tool, description, described_by,
|
||||
ref_count, first_seen, last_seen)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)
|
||||
first_seen, last_seen)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(digest) DO UPDATE SET
|
||||
last_seen = excluded.last_seen,
|
||||
-- 只在原值为空时补写:先到的描述可能来自更强的模型,
|
||||
@ -276,7 +242,7 @@ func (s *Store) Stat(digest string) (*Item, error) {
|
||||
defer s.mu.RUnlock()
|
||||
return s.scanOne(s.db.QueryRow(`
|
||||
SELECT digest, kind, mime, size, width, height, origin_path, tool,
|
||||
description, described_by, ref_count, first_seen, last_seen,
|
||||
description, described_by, first_seen, last_seen,
|
||||
vec, vec_model
|
||||
FROM media WHERE digest = ?`, digest))
|
||||
}
|
||||
@ -299,121 +265,6 @@ func (s *Store) Describe(digest, description, describedBy string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddRef 登记一条引用并递增计数。幂等:同一 (digest, owner) 重复调用不重复计数。
|
||||
func (s *Store) AddRef(digest, ownerKind, ownerID string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
res, err := tx.Exec(`INSERT OR IGNORE INTO media_refs (digest, owner_kind, owner_id) VALUES (?, ?, ?)`,
|
||||
digest, ownerKind, ownerID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("media: add ref: %w", err)
|
||||
}
|
||||
// 只有真的插进去才递增:否则重复调用会让计数虚高,GC 永远不敢清。
|
||||
if n, _ := res.RowsAffected(); n > 0 {
|
||||
if _, err := tx.Exec(`UPDATE media SET ref_count = ref_count + 1 WHERE digest = ?`, digest); err != nil {
|
||||
return fmt.Errorf("media: bump refcount: %w", err)
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// DropRef 注销一条引用并递减计数。内容不立即删除,留给 GC。
|
||||
func (s *Store) DropRef(digest, ownerKind, ownerID string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
res, err := tx.Exec(`DELETE FROM media_refs WHERE digest = ? AND owner_kind = ? AND owner_id = ?`,
|
||||
digest, ownerKind, ownerID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("media: drop ref: %w", err)
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n > 0 {
|
||||
// MAX(0, ...) 兜底:历史数据或并发意外让计数与 refs 表不一致时,
|
||||
// 不让它掉成负数(负数会让容量 GC 的排序失去意义)。
|
||||
if _, err := tx.Exec(`UPDATE media SET ref_count = MAX(0, ref_count - 1) WHERE digest = ?`, digest); err != nil {
|
||||
return fmt.Errorf("media: lower refcount: %w", err)
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// DropOwner 注销某个 owner 的全部引用(该条记忆被删/被归档替换时用)。
|
||||
func (s *Store) DropOwner(ownerKind, ownerID string) (int, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
rows, err := s.db.Query(`SELECT digest FROM media_refs WHERE owner_kind = ? AND owner_id = ?`,
|
||||
ownerKind, ownerID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var digests []string
|
||||
for rows.Next() {
|
||||
var d string
|
||||
if err := rows.Scan(&d); err == nil {
|
||||
digests = append(digests, d)
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(digests) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.Exec(`DELETE FROM media_refs WHERE owner_kind = ? AND owner_id = ?`, ownerKind, ownerID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for _, d := range digests {
|
||||
if _, err := tx.Exec(`UPDATE media SET ref_count = MAX(0, ref_count - 1) WHERE digest = ?`, d); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return len(digests), nil
|
||||
}
|
||||
|
||||
// Refs 返回某个 owner 引用的全部 digest。
|
||||
func (s *Store) Refs(ownerKind, ownerID string) ([]string, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
rows, err := s.db.Query(`SELECT digest FROM media_refs WHERE owner_kind = ? AND owner_id = ? ORDER BY created_at`,
|
||||
ownerKind, ownerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var d string
|
||||
if err := rows.Scan(&d); err == nil {
|
||||
out = append(out, d)
|
||||
}
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Search 按描述文本做 LIKE 匹配,返回最近的若干条。
|
||||
//
|
||||
// 刻意不在这里做向量检索:媒体的语义检索走 L2 文档层的既有索引
|
||||
@ -427,7 +278,7 @@ func (s *Store) Search(query string, kind Kind, limit int) ([]*Item, error) {
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
q := `SELECT digest, kind, mime, size, width, height, origin_path, tool,
|
||||
description, described_by, ref_count, first_seen, last_seen,
|
||||
description, described_by, first_seen, last_seen,
|
||||
vec, vec_model
|
||||
FROM media WHERE COALESCE(description,'') != ''`
|
||||
args := []interface{}{}
|
||||
@ -474,7 +325,7 @@ func (s *Store) Pending(limit int) ([]*Item, error) {
|
||||
defer s.mu.RUnlock()
|
||||
rows, err := s.db.Query(`
|
||||
SELECT digest, kind, mime, size, width, height, origin_path, tool,
|
||||
description, described_by, ref_count, first_seen, last_seen,
|
||||
description, described_by, first_seen, last_seen,
|
||||
vec, vec_model
|
||||
FROM media
|
||||
WHERE COALESCE(description,'') = '' AND COALESCE(described_by,'') = ''
|
||||
@ -494,117 +345,44 @@ func (s *Store) Pending(limit int) ([]*Item, error) {
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GC 清理无人引用的内容。
|
||||
// GC 清理已不被任何记忆块持有的内容。
|
||||
//
|
||||
// 两段策略:
|
||||
// 1. ref_count=0 且 last_seen 早于 minAge 的一律清理。刚 Put 还没来得及
|
||||
// AddRef 的项 refcount 也是 0,minAge 保护它们不被立刻清掉。
|
||||
// 2. 清完仍超 maxBytes 时,继续按 last_seen 从旧到新淘汰 ref_count=0 的项。
|
||||
// keep 是当前仍被 Context/Document/Graph 里一等记忆块持有的 digest 集合,
|
||||
// 由调用方从三层记忆节点计算得出;media.Store 不再自己维护引用账本。
|
||||
// 不在 keep 中且早于 minAge 的项被清理;超出 maxBytes 时也只淘汰不在 keep 中的项。
|
||||
// Delete 删除一份媒体内容(元数据 + blob)。
|
||||
//
|
||||
// 有引用的项永不删除——那会让记忆里的 digest 变成悬空指针,正是本包要避免的。
|
||||
func (s *Store) GC(minAge time.Duration) (removed int, freed int64, err error) {
|
||||
// 这不是 GC,也不看引用计数:调用方是记忆系统本身——当它把一个记忆块
|
||||
// 永久地从三层记忆中删掉(而非在层间迁移)时,媒体作为块的内容一并删除。
|
||||
// 文本块就是这么管理的:删除块即删除内容。
|
||||
func (s *Store) Delete(digest string) error {
|
||||
if digest == "" {
|
||||
return nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
cutoff := time.Now().Add(-minAge)
|
||||
rows, err := s.db.Query(`
|
||||
SELECT digest, size FROM media
|
||||
WHERE ref_count <= 0 AND last_seen < ?
|
||||
ORDER BY last_seen`, cutoff)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
if err := os.Remove(s.blobPath(digest)); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("media: remove blob %s: %w", shortDigest(digest), err)
|
||||
}
|
||||
type cand struct {
|
||||
digest string
|
||||
size int64
|
||||
if _, err := s.db.Exec(`DELETE FROM media WHERE digest = ?`, digest); err != nil {
|
||||
return fmt.Errorf("media: delete meta %s: %w", shortDigest(digest), err)
|
||||
}
|
||||
var cands []cand
|
||||
for rows.Next() {
|
||||
var c cand
|
||||
if err := rows.Scan(&c.digest, &c.size); err == nil {
|
||||
cands = append(cands, c)
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
for _, c := range cands {
|
||||
if e := os.Remove(s.blobPath(c.digest)); e != nil && !os.IsNotExist(e) {
|
||||
continue // 删不掉就留着元数据,下轮再试;不制造"元数据没了文件还在"的孤儿
|
||||
}
|
||||
if _, e := s.db.Exec(`DELETE FROM media WHERE digest = ?`, c.digest); e != nil {
|
||||
continue
|
||||
}
|
||||
removed++
|
||||
freed += c.size
|
||||
}
|
||||
|
||||
if s.maxBytes > 0 {
|
||||
r2, f2 := s.enforceCapacityLocked()
|
||||
removed += r2
|
||||
freed += f2
|
||||
}
|
||||
return removed, freed, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// enforceCapacityLocked 在超出 maxBytes 时继续淘汰无引用项(调用方已持锁)。
|
||||
func (s *Store) enforceCapacityLocked() (removed int, freed int64) {
|
||||
var total int64
|
||||
if err := s.db.QueryRow(`SELECT COALESCE(SUM(size), 0) FROM media`).Scan(&total); err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
if total <= s.maxBytes {
|
||||
return 0, 0
|
||||
}
|
||||
need := total - s.maxBytes
|
||||
|
||||
rows, err := s.db.Query(`SELECT digest, size FROM media WHERE ref_count <= 0 ORDER BY last_seen`)
|
||||
if err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
type cand struct {
|
||||
digest string
|
||||
size int64
|
||||
}
|
||||
var cands []cand
|
||||
for rows.Next() {
|
||||
var c cand
|
||||
if err := rows.Scan(&c.digest, &c.size); err == nil {
|
||||
cands = append(cands, c)
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
for _, c := range cands {
|
||||
if freed >= need {
|
||||
break
|
||||
}
|
||||
if e := os.Remove(s.blobPath(c.digest)); e != nil && !os.IsNotExist(e) {
|
||||
continue
|
||||
}
|
||||
if _, e := s.db.Exec(`DELETE FROM media WHERE digest = ?`, c.digest); e != nil {
|
||||
continue
|
||||
}
|
||||
removed++
|
||||
freed += c.size
|
||||
}
|
||||
return removed, freed
|
||||
}
|
||||
|
||||
// Stats 返回容量与条目统计,供 WebUI / healthcheck 展示。
|
||||
// Stats 返回条目统计,供 WebUI / healthcheck 展示。
|
||||
func (s *Store) Stats() map[string]interface{} {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
out := map[string]interface{}{"blob_dir": s.blobDir, "max_bytes": s.maxBytes}
|
||||
var count, described, orphan int
|
||||
out := map[string]interface{}{"blob_dir": s.blobDir}
|
||||
var count, described int
|
||||
var total int64
|
||||
s.db.QueryRow(`SELECT COUNT(*), COALESCE(SUM(size),0) FROM media`).Scan(&count, &total)
|
||||
s.db.QueryRow(`SELECT COUNT(*) FROM media WHERE COALESCE(description,'') != ''`).Scan(&described)
|
||||
s.db.QueryRow(`SELECT COUNT(*) FROM media WHERE ref_count <= 0`).Scan(&orphan)
|
||||
out["count"] = count
|
||||
out["total_bytes"] = total
|
||||
out["described"] = described
|
||||
out["unreferenced"] = orphan
|
||||
|
||||
byKind := map[string]int{}
|
||||
rows, err := s.db.Query(`SELECT kind, COUNT(*) FROM media GROUP BY kind`)
|
||||
@ -725,25 +503,17 @@ type MediaHit struct {
|
||||
Score float64
|
||||
}
|
||||
|
||||
// QueryMemoryMediaScored 只检索当前仍被 L0/L2/L3 记忆块引用的媒体。
|
||||
// CAS 中 ref_count=0 的项是等待 GC 的孤儿缓存,不是可召回记忆;若把它们也查出,
|
||||
// 已从三层记忆淘汰的图片会被视觉路“复活”,破坏与文本块一致的生命周期。
|
||||
// QueryMediaScored 用查询向量对所有已嵌入媒体做余弦相似度检索,
|
||||
// 返回 topK 个最相似的候选及其原始 cosine 分数(供跨模态归一化)。
|
||||
//
|
||||
// 分数只做排序,不在存储层设绝对阈值:多模态文本→图像的绝对 cosine 随模型、
|
||||
// 语言与数据域漂移,真实标定中有效命中可以低至 0.015。相关性门控在融合器中
|
||||
// 使用当前候选集合的相对分布完成。
|
||||
func (s *Store) QueryMemoryMediaScored(queryVec []float64, model string, topK int) ([]MediaHit, error) {
|
||||
return s.queryMediaScored(queryVec, model, topK, true)
|
||||
}
|
||||
|
||||
// QueryMediaScored 用查询向量对所有已嵌入媒体做余弦相似度检索,
|
||||
// 返回 topK 个最相似的候选及其原始 cosine 分数(供跨模态归一化)。
|
||||
// 这是媒体存储层的诊断/显式全库入口;记忆召回应调用 QueryMemoryMediaScored。
|
||||
func (s *Store) QueryMediaScored(queryVec []float64, model string, topK int) ([]MediaHit, error) {
|
||||
return s.queryMediaScored(queryVec, model, topK, false)
|
||||
return s.queryMediaScored(queryVec, model, topK)
|
||||
}
|
||||
|
||||
func (s *Store) queryMediaScored(queryVec []float64, model string, topK int, referencedOnly bool) ([]MediaHit, error) {
|
||||
func (s *Store) queryMediaScored(queryVec []float64, model string, topK int) ([]MediaHit, error) {
|
||||
if topK <= 0 {
|
||||
topK = 20
|
||||
}
|
||||
@ -754,7 +524,7 @@ func (s *Store) queryMediaScored(queryVec []float64, model string, topK int, ref
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
query := `SELECT digest, kind, mime, size, width, height,
|
||||
origin_path, tool, description, described_by, ref_count, first_seen, last_seen,
|
||||
origin_path, tool, description, described_by, first_seen, last_seen,
|
||||
vec, vec_model
|
||||
FROM media WHERE vec IS NOT NULL AND vec != ''`
|
||||
var args []interface{}
|
||||
@ -762,11 +532,6 @@ func (s *Store) queryMediaScored(queryVec []float64, model string, topK int, ref
|
||||
query += ` AND vec_model = ?`
|
||||
args = append(args, model)
|
||||
}
|
||||
if referencedOnly {
|
||||
query += ` AND ref_count > 0 AND EXISTS (
|
||||
SELECT 1 FROM media_refs r WHERE r.digest = media.digest
|
||||
)`
|
||||
}
|
||||
rows, err := s.db.Query(query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -783,7 +548,7 @@ func (s *Store) queryMediaScored(queryVec []float64, model string, topK int, ref
|
||||
var kind string
|
||||
var origin, tool, desc, by, vecJSON, vecModel sql.NullString
|
||||
if err := rows.Scan(&it.Digest, &kind, &it.MIME, &it.Size, &it.Width, &it.Height,
|
||||
&origin, &tool, &desc, &by, &it.RefCount, &it.FirstSeen, &it.LastSeen,
|
||||
&origin, &tool, &desc, &by, &it.FirstSeen, &it.LastSeen,
|
||||
&vecJSON, &vecModel); err != nil {
|
||||
continue
|
||||
}
|
||||
@ -860,7 +625,7 @@ func scanItem(r rowScanner) (*Item, error) {
|
||||
var kind string
|
||||
var origin, tool, desc, by, vecJSON, vecModel sql.NullString
|
||||
if err := r.Scan(&it.Digest, &kind, &it.MIME, &it.Size, &it.Width, &it.Height,
|
||||
&origin, &tool, &desc, &by, &it.RefCount, &it.FirstSeen, &it.LastSeen,
|
||||
&origin, &tool, &desc, &by, &it.FirstSeen, &it.LastSeen,
|
||||
&vecJSON, &vecModel); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -11,19 +11,12 @@ import (
|
||||
|
||||
// 冒烟测试:走真实数据路径的端到端场景,而非孤立的 API 单测。
|
||||
//
|
||||
// 之前这套场景是 internal/memory/media/smoke/ 下一个带 //go:build smoke 的
|
||||
// 独立 main,得记着加 -tags smoke 才跑得到——那种早晚会被忘掉。搬成普通
|
||||
// 测试后它随 go test ./... 一起跑,冒烟的意义(每次改动都过一遍真实链路)
|
||||
// 才真正成立。
|
||||
// 媒体存储现在只做内容寻址(CAS):字节 + 元数据 + 向量。
|
||||
// “哪些字节还活着”由三层记忆持有的一等记忆块决定,调用方把该集合传给
|
||||
// GC/检索,本层不维护 media_refs/ref_count 这类平行账本。
|
||||
|
||||
// makePNG 生成一张 w×h 的条带 PNG,用真 PNG 而不是随机字节,
|
||||
// 让入库/回读/digest 走的是与生产一致的数据形态。
|
||||
//
|
||||
// variant 注入到像素而不只用于选色:最初写的是
|
||||
// palette[(variant+y*3/h)%5],调色盘只 5 色,于是 variant=0 与 5 产出
|
||||
// 逐字节相同的 PNG——冒烟跑出「6 帧只得 5 条」,看着像存储丢了一帧,
|
||||
// 实际是 CAS 正确去重了两张真同图。冒烟要验的是「不同帧各存一份」,
|
||||
// 夹具就必须保证帧间真的不同。
|
||||
func makePNG(w, h, variant int) []byte {
|
||||
palette := [][3]byte{
|
||||
{255, 0, 0}, {0, 192, 0}, {0, 0, 255}, {255, 220, 0}, {160, 0, 200},
|
||||
@ -71,7 +64,7 @@ func makePNG(w, h, variant int) []byte {
|
||||
|
||||
func TestSmoke_SamePictureAcrossTurns(t *testing.T) {
|
||||
// 场景:用户连问几轮同一张截图。multimodal 每轮都会重新注入,
|
||||
// 磁盘上应该只有一份,但每轮的 context 事件各持一个引用。
|
||||
// 内容寻址天然去重,磁盘上只应有一份。
|
||||
s := newTestStore(t, 50*1024*1024)
|
||||
png := makePNG(400, 400, 0)
|
||||
|
||||
@ -96,9 +89,6 @@ func TestSmoke_SamePictureAcrossTurns(t *testing.T) {
|
||||
} else if d != d0 {
|
||||
t.Fatalf("同一张图第 %d 轮 digest 变了", turn)
|
||||
}
|
||||
if err := s.AddRef(d, "context", fmt.Sprintf("evt-%d", turn)); err != nil {
|
||||
t.Fatalf("第 %d 轮 AddRef: %v", turn, err)
|
||||
}
|
||||
}
|
||||
|
||||
st := s.Stats()
|
||||
@ -108,18 +98,16 @@ func TestSmoke_SamePictureAcrossTurns(t *testing.T) {
|
||||
if total := st["total_bytes"].(int64); total != int64(len(png)) {
|
||||
t.Fatalf("字节数应等于单张原图 %d,实际 %d", len(png), total)
|
||||
}
|
||||
it, _ := s.Stat(d0)
|
||||
if it.RefCount != 5 {
|
||||
t.Fatalf("应有 5 个引用,实际 %d", it.RefCount)
|
||||
// 三层记忆持有它;内容应仍可读
|
||||
if _, err := s.Get(d0); err != nil {
|
||||
t.Fatalf("内容应仍可读: %v", err)
|
||||
}
|
||||
t.Logf("同图 5 轮:条目=1 字节=%d refcount=%d", len(png), it.RefCount)
|
||||
checkRefIntegrity(t, s)
|
||||
}
|
||||
|
||||
func TestSmoke_VideoFramesDistinct(t *testing.T) {
|
||||
// 场景:see_video 抽 6 帧,帧间内容不同,应各存一份并共享一个 owner。
|
||||
// 场景:see_video 抽 6 帧,帧间内容不同,应各存一份。
|
||||
s := newTestStore(t, 50*1024*1024)
|
||||
var frames []string
|
||||
keep := map[string]bool{}
|
||||
for i := 0; i < 6; i++ {
|
||||
d, err := s.Put(makePNG(320, 240, i), Item{
|
||||
MIME: "image/jpeg", Width: 320, Height: 240, Tool: "multimodal_see_video",
|
||||
@ -127,24 +115,12 @@ func TestSmoke_VideoFramesDistinct(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("第 %d 帧: %v", i, err)
|
||||
}
|
||||
frames = append(frames, d)
|
||||
if err := s.AddRef(d, "context", "evt-video"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keep[d] = true
|
||||
}
|
||||
|
||||
st := s.Stats()
|
||||
if st["count"].(int) != 6 {
|
||||
if st := s.Stats(); st["count"].(int) != 6 {
|
||||
t.Fatalf("6 帧应各存一份,实际 %v 条", st["count"])
|
||||
}
|
||||
refs, err := s.Refs("context", "evt-video")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(refs) != 6 {
|
||||
t.Fatalf("evt-video 应引用 6 帧,实际 %d", len(refs))
|
||||
}
|
||||
checkRefIntegrity(t, s)
|
||||
}
|
||||
|
||||
func TestSmoke_DescribeThenRetrieve(t *testing.T) {
|
||||
@ -156,10 +132,8 @@ func TestSmoke_DescribeThenRetrieve(t *testing.T) {
|
||||
if err := s.Describe(pic, "一张 400x400 的三色带图:上红、中绿、下蓝", "visionllm"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var frames []string
|
||||
for i := 0; i < 6; i++ {
|
||||
d, _ := s.Put(makePNG(320, 240, i), Item{MIME: "image/jpeg", Tool: "multimodal_see_video"})
|
||||
frames = append(frames, d)
|
||||
if err := s.Describe(d, fmt.Sprintf("视频第 %d 帧:测试图卡,含彩条与计数器", i+1), "visionllm"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@ -175,89 +149,65 @@ func TestSmoke_DescribeThenRetrieve(t *testing.T) {
|
||||
if len(pend) != 0 {
|
||||
t.Fatalf("应全部已描述,仍有 %d 条待描述", len(pend))
|
||||
}
|
||||
_ = frames
|
||||
}
|
||||
|
||||
func TestSmoke_ArchiveTransfersOwnership(t *testing.T) {
|
||||
// 场景:L0 的 context 事件被 Prune 归档进 L2 文档,
|
||||
// 媒体引用需从 context owner 转到 document owner,期间内容不能被 GC 掉。
|
||||
func TestSmoke_ContentSurvivesLayerMigration(t *testing.T) {
|
||||
// 场景:同一份媒体随记忆块从 Context 迁移到 Document 再到 Graph。
|
||||
// 迁移的是块本身,digest 不变,因此内容在整条链路上始终可读。
|
||||
s := newTestStore(t, 50*1024*1024)
|
||||
png := makePNG(400, 400, 0)
|
||||
d, _ := s.Put(png, Item{MIME: "image/png", Tool: "multimodal_see_picture"})
|
||||
for turn := 1; turn <= 5; turn++ {
|
||||
s.AddRef(d, "context", fmt.Sprintf("evt-%d", turn))
|
||||
}
|
||||
|
||||
// evt-1 被淘汰,其内容归档为一篇文档
|
||||
n, err := s.DropOwner("context", "evt-1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
// 迁移过程中该 digest 始终可读
|
||||
for _, layer := range []string{"context", "document", "graph"} {
|
||||
if got, err := s.Get(d); err != nil || !bytes.Equal(got, png) {
|
||||
t.Fatalf("迁移到 %s 时内容应完好: %v", layer, err)
|
||||
}
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("应注销 1 条引用,实际 %d", n)
|
||||
}
|
||||
if err := s.AddRef(d, "document", "doc_archived_001"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
it, _ := s.Stat(d)
|
||||
if it.RefCount != 5 {
|
||||
t.Fatalf("引用转移后总数应仍为 5(4 context + 1 document),实际 %d", it.RefCount)
|
||||
}
|
||||
// 归档过程中内容必须始终可读
|
||||
if got, err := s.Get(d); err != nil || !bytes.Equal(got, png) {
|
||||
t.Fatalf("归档后内容应完好: %v", err)
|
||||
}
|
||||
checkRefIntegrity(t, s)
|
||||
}
|
||||
|
||||
func TestSmoke_GCSweepsToolLeftovers(t *testing.T) {
|
||||
// 场景:别的工具(cmd_run 之类)产出的一次性图片没人引用,
|
||||
// 应被 GC 清掉;而被记忆引用的媒体一个都不能少。
|
||||
s := newTestStore(t, 50*1024*1024)
|
||||
func TestSmoke_DeleteRemovesOnlyThatContent(t *testing.T) {
|
||||
// 场景:某个工具产出的一次性图片所在的记忆块被删除时,
|
||||
// 只有它自己的内容被删;其他块的内容一个都不能少。
|
||||
s := newTestStore(t)
|
||||
|
||||
keep, _ := s.Put(makePNG(400, 400, 0), Item{MIME: "image/png"})
|
||||
s.AddRef(keep, "document", "doc-1")
|
||||
held, _ := s.Put(makePNG(400, 400, 0), Item{MIME: "image/png"})
|
||||
var frames []string
|
||||
for i := 0; i < 6; i++ {
|
||||
d, _ := s.Put(makePNG(320, 240, i), Item{MIME: "image/jpeg"})
|
||||
s.AddRef(d, "context", "evt-video")
|
||||
frames = append(frames, d)
|
||||
}
|
||||
// 1000+i 保证与上面的帧、以及彼此都不重复
|
||||
var ephemeral []string
|
||||
for i := 0; i < 20; i++ {
|
||||
s.Put(makePNG(100, 100, 1000+i), Item{MIME: "image/png", Tool: "cmd_run"})
|
||||
d, _ := s.Put(makePNG(100, 100, 1000+i), Item{MIME: "image/png", Tool: "cmd_run"})
|
||||
ephemeral = append(ephemeral, d)
|
||||
}
|
||||
|
||||
before := s.Stats()["count"].(int)
|
||||
removed, freed, err := s.GC(0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
for _, d := range ephemeral {
|
||||
if err := s.Delete(d); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
after := s.Stats()["count"].(int)
|
||||
if removed != 20 {
|
||||
t.Fatalf("应清 20 条孤儿,实际 %d", removed)
|
||||
}
|
||||
if after != before-20 {
|
||||
t.Fatalf("条目数应从 %d 降到 %d,实际 %d", before, before-20, after)
|
||||
}
|
||||
if _, err := s.Get(keep); err != nil {
|
||||
t.Fatalf("被文档引用的图被误删: %v", err)
|
||||
if _, err := s.Get(held); err != nil {
|
||||
t.Fatalf("被保留的内容被误删: %v", err)
|
||||
}
|
||||
for i, f := range frames {
|
||||
if _, err := s.Get(f); err != nil {
|
||||
t.Fatalf("第 %d 帧被误删: %v", i, err)
|
||||
}
|
||||
}
|
||||
t.Logf("GC: %d 条 → 清 %d 条(%d 字节)→ %d 条", before, removed, freed, after)
|
||||
checkRefIntegrity(t, s)
|
||||
}
|
||||
|
||||
func TestSmoke_FullLifecycleAcrossRestart(t *testing.T) {
|
||||
// 端到端:入库 → 描述 → 引用 → GC → 重启 → 检索,
|
||||
// 端到端:入库 → 描述 → 删除一些内容 → 重启 → 检索,
|
||||
// 并确认磁盘与元数据不出现双向孤儿。记忆的意义就在于跨重启还在。
|
||||
dir := t.TempDir()
|
||||
s, err := New(dir, 50*1024*1024)
|
||||
s, err := New(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@ -265,22 +215,20 @@ func TestSmoke_FullLifecycleAcrossRestart(t *testing.T) {
|
||||
png := makePNG(400, 400, 0)
|
||||
pic, _ := s.Put(png, Item{MIME: "image/png", Width: 400, Height: 400, Tool: "multimodal_see_picture"})
|
||||
s.Describe(pic, "一张 400x400 的三色带图:上红、中绿、下蓝", "visionllm")
|
||||
s.AddRef(pic, "graph_sentence", "sent-42")
|
||||
for i := 0; i < 6; i++ {
|
||||
d, _ := s.Put(makePNG(320, 240, i), Item{MIME: "image/jpeg", Tool: "multimodal_see_video"})
|
||||
s.Describe(d, fmt.Sprintf("视频第 %d 帧", i+1), "visionllm")
|
||||
s.AddRef(d, "context", "evt-video")
|
||||
}
|
||||
for i := 0; i < 10; i++ {
|
||||
s.Put(makePNG(64, 64, 2000+i), Item{MIME: "image/png", Tool: "cmd_run"})
|
||||
}
|
||||
if _, _, err := s.GC(0); err != nil {
|
||||
t.Fatal(err)
|
||||
d, _ := s.Put(makePNG(64, 64, 2000+i), Item{MIME: "image/png", Tool: "cmd_run"})
|
||||
if err := s.Delete(d); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
beforeCount := s.Stats()["count"].(int)
|
||||
s.Close()
|
||||
|
||||
s2, err := New(dir, 50*1024*1024)
|
||||
s2, err := New(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("重开失败: %v", err)
|
||||
}
|
||||
@ -293,16 +241,13 @@ func TestSmoke_FullLifecycleAcrossRestart(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("重开后查不到: %v", err)
|
||||
}
|
||||
if it.Description == "" || it.RefCount != 1 {
|
||||
if it.Description == "" {
|
||||
t.Fatalf("元数据未持久化: %+v", it)
|
||||
}
|
||||
data, err := s2.Get(pic)
|
||||
if err != nil || !bytes.Equal(data, png) {
|
||||
t.Fatalf("重开后内容不一致: %v", err)
|
||||
}
|
||||
if refs, _ := s2.Refs("context", "evt-video"); len(refs) != 6 {
|
||||
t.Fatalf("重开后视频帧引用应为 6,实际 %d", len(refs))
|
||||
}
|
||||
if hits, _ := s2.Search("三色带", KindImage, 10); len(hits) != 1 {
|
||||
t.Fatal("重开后描述应仍可检索")
|
||||
}
|
||||
@ -311,6 +256,5 @@ func TestSmoke_FullLifecycleAcrossRestart(t *testing.T) {
|
||||
if n := blobFileCount(t, s2); n != beforeCount {
|
||||
t.Fatalf("磁盘 blob=%d 与元数据=%d 不一致", n, beforeCount)
|
||||
}
|
||||
checkRefIntegrity(t, s2)
|
||||
t.Logf("跨重启:%d 条目、描述与引用全部完好", beforeCount)
|
||||
t.Logf("跨重启:%d 条目、描述与内容全部完好", beforeCount)
|
||||
}
|
||||
|
||||
@ -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"])
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -6,6 +6,8 @@ import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
doc "gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
|
||||
@ -81,37 +83,87 @@ func sdkDigestsIn(s string) map[string]bool {
|
||||
return out
|
||||
}
|
||||
|
||||
// sdkBindText 把文本里引用的媒体挂到 owner 上,返回新挂上的条数。
|
||||
//
|
||||
// 短 digest 补全失败(内容已 GC、或前缀有歧义)就跳过那一条:挂一条对不上的
|
||||
// 引用比不挂更糟——owner_kind/owner_id/digest 三者进了主键,digest 错了则
|
||||
// DropOwner 永远匹配不到它,那是一条永久泄漏的引用。
|
||||
//
|
||||
// done 记录本次已处理过的 digest。AddRef 幂等,重复挂不会多出一条引用,
|
||||
// 但会让计数虚高——文档路径先按附件挂一遍、再扫正文标记挂一遍,
|
||||
// 同一份媒体会被数两次,日志里「绑定 2 个」而实际只有 1 条引用。
|
||||
func sdkBindText(ms *media.Store, text, ownerKind, ownerID string, done map[string]bool) int {
|
||||
if ms == nil || text == "" || ownerID == "" {
|
||||
return 0
|
||||
// sdkBlockSeq 保证块 ID 全局唯一:Graph 的 memory_blocks 以 id 为主键,
|
||||
// 不同文档里序号相同的块会在 L2→L3 迁移时相互覆盖。
|
||||
var sdkBlockSeq int64
|
||||
|
||||
func sdkNewBlockID() string {
|
||||
return fmt.Sprintf("blk_%d_%d", time.Now().UnixNano(), atomic.AddInt64(&sdkBlockSeq, 1))
|
||||
}
|
||||
|
||||
// sdkBlockModality 把 CAS 的媒体大类映射为一等记忆块的模态。
|
||||
func sdkBlockModality(k media.Kind) memory.BlockModality {
|
||||
switch k {
|
||||
case media.KindImage:
|
||||
return memory.BlockImage
|
||||
case media.KindVideo:
|
||||
return memory.BlockVideo
|
||||
case media.KindAudio:
|
||||
return memory.BlockAudio
|
||||
default:
|
||||
return memory.BlockText
|
||||
}
|
||||
bound := 0
|
||||
}
|
||||
|
||||
// sdkBlockForDigest 把一份已入库的媒体变成一个一等记忆块。
|
||||
// 块自身携带 digest/向量/ fingerprint;CAS 只提供字节与元数据,不参与生命周期。
|
||||
func sdkBlockForDigest(ms *media.Store, digest string) (memory.MemoryBlock, bool) {
|
||||
it, err := ms.Stat(digest)
|
||||
if err != nil || it == nil {
|
||||
return memory.MemoryBlock{}, false
|
||||
}
|
||||
return memory.MemoryBlock{
|
||||
ID: sdkNewBlockID(),
|
||||
Modality: sdkBlockModality(it.Kind),
|
||||
PayloadDigest: it.Digest,
|
||||
MIME: it.MIME,
|
||||
Size: it.Size,
|
||||
Width: it.Width,
|
||||
Height: it.Height,
|
||||
Vector: it.Vec,
|
||||
Fingerprint: it.VecModel,
|
||||
Tool: it.Tool,
|
||||
CreatedAt: it.FirstSeen,
|
||||
}, true
|
||||
}
|
||||
|
||||
// sdkBlocksFromText 把文本标记里的媒体变成一等块(去重)。
|
||||
func sdkBlocksFromText(ms *media.Store, text string) []memory.MemoryBlock {
|
||||
if ms == nil || text == "" {
|
||||
return nil
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
var blocks []memory.MemoryBlock
|
||||
for _, m := range sdkMarkerPattern.FindAllStringSubmatch(text, -1) {
|
||||
full, err := ms.ResolvePrefix(m[2])
|
||||
if err != nil {
|
||||
if err != nil || seen[full] {
|
||||
continue
|
||||
}
|
||||
if done != nil && done[full] {
|
||||
continue
|
||||
seen[full] = true
|
||||
if b, ok := sdkBlockForDigest(ms, full); ok {
|
||||
blocks = append(blocks, b)
|
||||
}
|
||||
if err := ms.AddRef(full, ownerKind, ownerID); err != nil {
|
||||
continue
|
||||
}
|
||||
if done != nil {
|
||||
done[full] = true
|
||||
}
|
||||
bound++
|
||||
}
|
||||
return bound
|
||||
return blocks
|
||||
}
|
||||
|
||||
// sdkBlocksFromDigests 为显式 digest 列表构造一等块(去重)。
|
||||
func sdkBlocksFromDigests(ms *media.Store, digests []string) []memory.MemoryBlock {
|
||||
if ms == nil || len(digests) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
var blocks []memory.MemoryBlock
|
||||
for _, d := range digests {
|
||||
if d == "" || seen[d] {
|
||||
continue
|
||||
}
|
||||
seen[d] = true
|
||||
if b, ok := sdkBlockForDigest(ms, d); ok {
|
||||
blocks = append(blocks, b)
|
||||
}
|
||||
}
|
||||
return blocks
|
||||
}
|
||||
|
||||
// sdkPutAttachment 把一份附件解析成完整 digest。
|
||||
@ -208,11 +260,10 @@ func (m *graphMemory) Recall(query []string, depth int) ([]Entity, []Relation, e
|
||||
return entities, relations, nil
|
||||
}
|
||||
|
||||
// Commit 把插件的三元组写入图库,并把三元组引用的媒体挂到句子上。
|
||||
// Commit 把插件的三元组写入图库,并把三元组句子里的媒体变成 L3 一等块。
|
||||
//
|
||||
// 媒体的绑定链是 SentenceText → sentences 表 → sentence_id → media_refs。
|
||||
// 旧实现丢掉 SentenceText 又走 Commit(不回 sentenceIDs),这条链一步都走不通:
|
||||
// 插件即便按格式写好标记,媒体也永远挂不上。
|
||||
// 媒体的落点链是 SentenceText → sentences 表 → sentence_id → 块边。
|
||||
// 旧实现丢掉 SentenceText 又走 Commit(不回 sentenceIDs),这条链一步都走不通。
|
||||
func (m *graphMemory) Commit(triples []Triple) error {
|
||||
if m.db == nil {
|
||||
return nil
|
||||
@ -280,9 +331,11 @@ func (m *graphMemory) sentenceWithMedia(sentence string, digests []string) strin
|
||||
return sentence + "\n" + strings.Join(add, "\n")
|
||||
}
|
||||
|
||||
// bindSentences 把每条句子里引用的媒体挂到该句子的 graph_sentence owner 上。
|
||||
// bindSentences 把每条句子里引用的媒体变成 L3 的一等记忆块,
|
||||
// 并建立 sentence --contains--> block 的结构边。
|
||||
// 不再写 media_refs:块本身就是图的一部分,不需要 owner 账本保活。
|
||||
func (m *graphMemory) bindSentences(sentenceIDs map[string]int64) {
|
||||
if m.ms == nil || len(sentenceIDs) == 0 {
|
||||
if m.ms == nil || m.db == nil || len(sentenceIDs) == 0 {
|
||||
return
|
||||
}
|
||||
bound := 0
|
||||
@ -290,13 +343,20 @@ func (m *graphMemory) bindSentences(sentenceIDs map[string]int64) {
|
||||
if sid == 0 {
|
||||
continue
|
||||
}
|
||||
// 每条句子一个独立的 done 集:同一份媒体挂在不同句子上是两条
|
||||
// 合法引用(owner_id 不同),不该被跨句子去重。
|
||||
bound += sdkBindText(m.ms, text, media.OwnerGraphSentence,
|
||||
strconv.FormatInt(sid, 10), map[string]bool{})
|
||||
for _, b := range sdkBlocksFromText(m.ms, text) {
|
||||
if err := m.db.PutMemoryBlocks([]memory.MemoryBlock{b}); err != nil {
|
||||
log.Printf("[sdk media] 插件 %s 写入 L3 记忆块失败: %v", m.plugin, err)
|
||||
continue
|
||||
}
|
||||
if err := m.db.AddMemoryBlockEdge("sentence", strconv.FormatInt(sid, 10), "block", b.ID, "contains"); err != nil {
|
||||
log.Printf("[sdk media] 插件 %s 建立句子→块边失败: %v", m.plugin, err)
|
||||
continue
|
||||
}
|
||||
bound++
|
||||
}
|
||||
}
|
||||
if bound > 0 {
|
||||
log.Printf("[sdk media] 插件 %s 的三元组绑定 %d 个媒体引用", m.plugin, bound)
|
||||
log.Printf("[sdk media] 插件 %s 的三元组写入 %d 个 L3 记忆块", m.plugin, bound)
|
||||
}
|
||||
}
|
||||
|
||||
@ -345,7 +405,7 @@ func NewTextMemoryWithMedia(plugin string, tm *text.Memory, ms *media.Store) Tex
|
||||
|
||||
// Append 追加一条文本事件;带附件时把媒体标记并进正文。
|
||||
//
|
||||
// 文本记忆是追加写 JSONL,没有稳定 owner_id 可挂 media_refs,所以媒体在这一层
|
||||
// 文本记忆是追加写 JSONL,没有结构化块存储,所以媒体在这一层
|
||||
// 只能以标记形式存在。这不是妥协——描述文本才是持久的语义记忆,blob 只是缓存。
|
||||
func (m *textMemoryImpl) Append(evt TextEvent) error {
|
||||
if m.tm == nil {
|
||||
@ -431,40 +491,56 @@ func (m *docMemoryImpl) Query(text string, topK int) []*Doc {
|
||||
out := make([]*Doc, len(got))
|
||||
for i, d := range got {
|
||||
out[i] = &Doc{ID: d.ID, Title: d.Summary, Content: d.Content}
|
||||
m.fillMedia(out[i])
|
||||
m.fillMedia(out[i], d)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// fillMedia 填充文档的媒体字段。
|
||||
//
|
||||
// 优先用 media_refs(权威:谁挂上去的就是谁),为空时退回解析正文标记——
|
||||
// 历史文档与经旧版插件写入的文档只有标记、没有引用。
|
||||
func (m *docMemoryImpl) fillMedia(out *Doc) {
|
||||
// 优先读一等记忆块(文档直接持有),为空时退回解析正文标记——
|
||||
// 历史文档与经旧版插件写入的文档只有标记、没有块。
|
||||
func (m *docMemoryImpl) fillMedia(out *Doc, d *doc.Doc) {
|
||||
if m.ms == nil {
|
||||
return
|
||||
}
|
||||
digests, err := m.ms.Refs(media.OwnerDocument, out.ID)
|
||||
if err != nil {
|
||||
log.Printf("[sdk media] 读取文档 %s 的媒体引用失败: %v", out.ID, err)
|
||||
}
|
||||
if len(digests) == 0 {
|
||||
out.Attachments = sdkAttachmentsFromText(m.ms, out.Content)
|
||||
for _, a := range out.Attachments {
|
||||
out.MediaDigests = append(out.MediaDigests, a.Digest)
|
||||
if d != nil && len(d.Blocks) > 0 {
|
||||
for _, b := range d.Blocks {
|
||||
if b.PayloadDigest == "" {
|
||||
continue
|
||||
}
|
||||
out.MediaDigests = append(out.MediaDigests, b.PayloadDigest)
|
||||
att := MediaAttachment{Digest: b.PayloadDigest, MIME: b.MIME, Description: ""}
|
||||
if it, err := m.ms.Stat(b.PayloadDigest); err == nil && it != nil {
|
||||
att.MIME = it.MIME
|
||||
att.Description = it.Description
|
||||
}
|
||||
out.Attachments = append(out.Attachments, att)
|
||||
}
|
||||
return
|
||||
}
|
||||
out.MediaDigests = digests
|
||||
for _, d := range digests {
|
||||
it, err := m.ms.Stat(d)
|
||||
if err != nil || it == nil {
|
||||
out.Attachments = sdkAttachmentsFromText(m.ms, out.Content)
|
||||
for _, a := range out.Attachments {
|
||||
out.MediaDigests = append(out.MediaDigests, a.Digest)
|
||||
}
|
||||
}
|
||||
|
||||
// appendBlocks 把新的块追加到已有块之后(按 digest 去重)。
|
||||
func appendBlocks(existing []memory.MemoryBlock, add []memory.MemoryBlock) []memory.MemoryBlock {
|
||||
seen := make(map[string]bool, len(existing))
|
||||
for _, b := range existing {
|
||||
seen[b.PayloadDigest] = true
|
||||
}
|
||||
for _, b := range add {
|
||||
if b.PayloadDigest != "" && seen[b.PayloadDigest] {
|
||||
continue
|
||||
}
|
||||
out.Attachments = append(out.Attachments, MediaAttachment{
|
||||
Digest: it.Digest, MIME: it.MIME, Description: it.Description,
|
||||
})
|
||||
existing = append(existing, b)
|
||||
if b.PayloadDigest != "" {
|
||||
seen[b.PayloadDigest] = true
|
||||
}
|
||||
}
|
||||
return existing
|
||||
}
|
||||
|
||||
// Insert 写入文档。正文里已有的媒体标记会被挂成文档级引用,
|
||||
@ -487,14 +563,17 @@ func (m *docMemoryImpl) InsertWithMedia(d *Doc, attachments []MediaAttachment) e
|
||||
|
||||
digests := m.storeAttachments(attachments, &target.Content)
|
||||
|
||||
// 一等记忆块:文档直接持有块本身,CAS 只提供字节与向量。
|
||||
// 不再写 media_refs——块随文档一同存活或被删除,无需 owner 账本。
|
||||
target.Blocks = appendBlocks(target.Blocks,
|
||||
append(sdkBlocksFromDigests(m.ms, digests), sdkBlocksFromText(m.ms, target.Content)...))
|
||||
|
||||
if err := m.ds.Insert(target); err != nil {
|
||||
return err
|
||||
}
|
||||
// 回填给调用方:ID 是新建时内核生成的,Content 含内核补的标记。
|
||||
d.ID = target.ID
|
||||
d.Content = target.Content
|
||||
|
||||
m.bindDocMedia(target, digests)
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -531,51 +610,39 @@ func (m *docMemoryImpl) storeAttachments(atts []MediaAttachment, content *string
|
||||
return digests
|
||||
}
|
||||
|
||||
// bindDocMedia 把附件与正文标记引用的媒体一起挂到文档 owner 上。
|
||||
func (m *docMemoryImpl) bindDocMedia(target *doc.Doc, digests []string) {
|
||||
if m.ms == nil || target.ID == "" {
|
||||
return
|
||||
}
|
||||
bound := 0
|
||||
done := make(map[string]bool, len(digests))
|
||||
for _, full := range digests {
|
||||
if done[full] {
|
||||
continue
|
||||
}
|
||||
if err := m.ms.AddRef(full, media.OwnerDocument, target.ID); err != nil {
|
||||
log.Printf("[sdk media] 文档引用绑定失败 (%s → doc %s): %v",
|
||||
sdkShortDigest(full), target.ID, err)
|
||||
continue
|
||||
}
|
||||
done[full] = true
|
||||
bound++
|
||||
}
|
||||
// 插件手写在正文里的标记同样要挂上,否则那些媒体在文档里可见却无主。
|
||||
// 共用 done:附件刚挂过的那些是同一份媒体(内核自己把标记补进了正文)。
|
||||
bound += sdkBindText(m.ms, target.Content, media.OwnerDocument, target.ID, done)
|
||||
if bound > 0 {
|
||||
log.Printf("[sdk media] 插件 %s 写入文档 %s,绑定 %d 个媒体引用",
|
||||
m.plugin, target.ID, bound)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove 删除文档,同时释放它持有的媒体引用。
|
||||
// Remove 删除文档,并删除它持有的一等块所对应的内容(无其他块共享时)。
|
||||
//
|
||||
// 旧实现只删文档不解引用,于是那些媒体永久处于「被引用」状态:GC 不回收,
|
||||
// 磁盘只增不减。内核的归档路径(distill 的 releaseDocMedia)做了这一步,
|
||||
// 插件路径漏了同一步。
|
||||
// 与文本块一致:删除块即删除内容。媒体字节是块的内容存储,
|
||||
// 不单独做引用计数或 GC。
|
||||
func (m *docMemoryImpl) Remove(id string) {
|
||||
if m.ds == nil {
|
||||
return
|
||||
}
|
||||
if m.ms != nil && id != "" {
|
||||
if n, err := m.ms.DropOwner(media.OwnerDocument, id); err != nil {
|
||||
log.Printf("[sdk media] 释放文档 %s 的媒体引用失败: %v", id, err)
|
||||
} else if n > 0 {
|
||||
log.Printf("[sdk media] 文档 %s 删除,释放 %d 个媒体引用", id, n)
|
||||
var digests []string
|
||||
if d := m.ds.Get(id); d != nil {
|
||||
for _, b := range d.Blocks {
|
||||
if b.PayloadDigest != "" {
|
||||
digests = append(digests, b.PayloadDigest)
|
||||
}
|
||||
}
|
||||
}
|
||||
m.ds.Remove(id)
|
||||
if m.ms == nil {
|
||||
return
|
||||
}
|
||||
// 仍被其它文档持有的 digest 不能删(同一份字节可能被多个块共享)。
|
||||
stillHeld := map[string]bool{}
|
||||
for _, b := range m.ds.Blocks() {
|
||||
stillHeld[b.PayloadDigest] = true
|
||||
}
|
||||
for _, d := range digests {
|
||||
if stillHeld[d] {
|
||||
continue
|
||||
}
|
||||
if err := m.ms.Delete(d); err != nil {
|
||||
log.Printf("[sdk media] 删除文档 %s 的内容失败 %s: %v", id, sdkShortDigest(d), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *docMemoryImpl) Stats() map[string]interface{} {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user