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