refactor(memory): 拆除描述式媒体索引,媒体成为一等块并按原生向量融合

背景:此前媒体是靠「生成的描述文本」将就进记忆的——写 marker 进正文、
再由正则反解成 media_refs 与图库里的 type=Media 实体。这条链路有三个
致命缺陷:描述由异步模型生成(未生成前媒体等于不存在)、语义检索实质上
只搜描述文字、图库里的「媒体节点」是描述文本的投影而不是媒体本身。

本提交把这条链路整体拆除,媒体改为按自己的原生向量参与记忆:

一、描述链彻底删除(无残留、无兼容分支)
- media.Item 去掉 Description/DescribedBy 与对应列;
- 删除 Store.Describe / Store.Search / Store.Pending;
- 删除 Agent.mediaDescribeLoop / describePendingMedia 与配置项
  core.memory.media.describe_on_ingest;
- SDK 侧 MediaAttachment 去掉 Description(见 SDK 仓独立提交)。

二、marker 机制删除,媒体归属改为结构化块边
- 删除 mediaMarkerLine/parseMediaMarkers/mediaEntityName/mediaTriplesFromText/
  extractMediaDigests/sentenceWithMediaMarkers/docMediaContext;
- memory.Triple 新增 MediaDigests 结构化字段;句子文本保持原样,
  不再被 marker 污染;
- 块以 sentence --contains--> block / document --contains--> block 结构边
  挂到承载节点(新增 documents 表与 document 节点种类);
- 模型未给原句时用「主谓宾。」拼一句自然语言作落点,不造 marker 文本。

三、旧数据迁移(幂等)
- 新增 GraphDB.MigrateLegacyMediaEntities:把 type=Media 的旧实体按短 digest
  还原成原生块、挂回原句子、删除旧实体与描述关系;Agent 启动时执行;
- CleanupOrphanedSentences 同时看关系引用与块边,避免把只靠块存活的句子
  连同块边一起删掉。

四、向量融合:媒体按图本身被召回
- 新增 vector.FuseVectors(逐维求和 + L2 归一化);
- Doc.DenseVec = 文本向量 ⊕ 文档块的媒体向量(同 fingerprint 才融合),
  新增 Doc.DenseFP,指纹变化触发重算;
- ContextEvent.DenseVec 同理融合事件块;事件新增 DenseFP,Prune 只在
  同一统一空间内比稠密余弦;
- 跨模态视觉路只召回「仍被某层记忆块持有」的媒体,CAS 全库字节不再
  直接充当记忆检索结果。

五、同时纳入本分支既有的嵌入基础改造(此前工作区未提交,缺它 HEAD 不可构建)
- internal/tfidf 懒回退包、千问三段式多模态 ONNX 空间的 Go 侧
  (qwen/embedder.go、image.go、model_input.go)、CLIP 移除、
  sdk.NewStore 分词器签名与调用点、embed 侧车 systemd 单元。

验证:go build ./... 、go vet ./...(含 -tags medialive)均通过;
在 HEAD 的独立 worktree 上重放本次暂存集后 go test -short ./internal/...
全部通过(端口冲突类用例在隔离环境中亦通过)。未提交工作区中与本改造
无关的改动(HarmonyOS、waiter、devicebridge、plan.md 等)。
This commit is contained in:
JianFeeeee
2026-09-11 11:45:24 +08:00
parent dae01f9c06
commit 5836c2ce5c
56 changed files with 2686 additions and 2834 deletions

View File

@ -56,8 +56,6 @@ type Agent struct {
// 它是记忆块的内容存储,不单独做生命周期管理:块的创建/迁移/删除
// 由记忆系统本身决定。为 nil 时全部媒体接线静默跳过。
mediaStore *media.Store
// mediaDescribe 控制是否跑后台描述循环(要消耗视觉模型配额)。
mediaDescribe bool
// 人格设定
personality *agentPkg.Personality
@ -182,7 +180,6 @@ type AgentConfig struct {
SocialStore *social.SocialStore
TextMemory *text.Memory
MediaStore *media.Store
MediaDescribe bool
MultimodalSpace vector.MultimodalEmbedder
FusionCfg CrossModalFusionConfig // 跨模态融合权重;零值用默认
Personality *agentPkg.Personality
@ -271,7 +268,6 @@ func New(cfg AgentConfig) *Agent {
social: cfg.SocialStore,
textMem: cfg.TextMemory,
mediaStore: cfg.MediaStore,
mediaDescribe: cfg.MediaDescribe,
personality: cfg.Personality,
pluginReg: cfg.PluginReg,
pluginDir: cfg.PluginDir,
@ -307,8 +303,8 @@ func (a *Agent) Start() {
go a.archiveLoop()
go a.mergeLoop()
go a.reviewLoop()
go a.mediaDescribeLoop()
a.reembedStaleMedia()
a.migrateLegacyGraphMedia()
log.Printf("[agent] %s started, waiting for IO interrupts", a.id)
}

View File

@ -15,14 +15,14 @@ type mockOutputDevice struct {
toolFn func(string, map[string]interface{}) (interface{}, error)
}
func (d *mockOutputDevice) Name() string { return d.name }
func (d *mockOutputDevice) Type() agentIO.DeviceType { return agentIO.DeviceOutput }
func (d *mockOutputDevice) Description() string { return "mock " + d.name }
func (d *mockOutputDevice) Tools() []agentIO.ToolDef { return d.tools }
func (d *mockOutputDevice) Start() error { return nil }
func (d *mockOutputDevice) Stop() error { return nil }
func (d *mockOutputDevice) Name() string { return d.name }
func (d *mockOutputDevice) Type() agentIO.DeviceType { return agentIO.DeviceOutput }
func (d *mockOutputDevice) Description() string { return "mock " + d.name }
func (d *mockOutputDevice) Tools() []agentIO.ToolDef { return d.tools }
func (d *mockOutputDevice) Start() error { return nil }
func (d *mockOutputDevice) Stop() error { return nil }
func (d *mockOutputDevice) OutputCapabilities() agentIO.OutputCapability { return d.caps }
func (d *mockOutputDevice) ChannelDef() agentIO.ChannelDef { return agentIO.ChannelDef{} }
func (d *mockOutputDevice) ChannelDef() agentIO.ChannelDef { return agentIO.ChannelDef{} }
func (d *mockOutputDevice) Execute(tool string, args map[string]interface{}) (interface{}, error) {
if d.toolFn != nil {
return d.toolFn(tool, args)
@ -67,8 +67,8 @@ func TestExecuteOutputSendTool(t *testing.T) {
"type": "text",
}}
result := a.executeOutputSendTool(tc)
if !strings.Contains(result, "screen") {
t.Errorf("unexpected result: %s", result)
if result != "ok" {
t.Errorf("expected ok, got: %s", result)
}
}

View File

@ -38,6 +38,7 @@ type ContextEvent struct {
Blocks []memory.MemoryBlock `json:"blocks,omitempty"` // 一等记忆块text/image/video/audio
Vector vector.Vector `json:"-"` // 稀疏词向量TF-IDF/fastText 空间)
DenseVec []float64 `json:"-"` // 稠密多模态向量(与媒体/文档共享空间)
DenseFP string `json:"-"` // DenseVec 所属统一空间指纹(缓存字段,不持久化)
}
const contextFlushInterval = 5 * time.Second
@ -196,16 +197,29 @@ func (c *RelevanceContext) channelCleanerForDoc() document.ChannelCleaner {
func (c *RelevanceContext) computeVector(evt *ContextEvent) {
text := textForVector(evt, c.toolDefLookup, c.channelDefLookup)
if text == "" {
return
}
// 稀疏向量始终计算TF-IDF/fastText退化时仍可用
evt.Vector = c.embedder.Vectorize(text)
// 稠密向量仅在配置了多模态空间时计算
if text != "" {
evt.Vector = c.embedder.Vectorize(text)
}
// 稠密向量:文本向量 ⊕ 本事件持有的一等记忆块媒体向量(同一统一空间)。
// 只有媒体的输入(无文本)也要有可比较的坐标,因此不再按 text=="" 提前返回。
if c.denseSpace != nil && c.denseSpace.Loaded() {
if dv, err := c.denseSpace.VectorizeDense(text); err == nil {
evt.DenseVec = dv
fp := c.denseSpace.Fingerprint()
var parts [][]float64
if text != "" {
if dv, err := c.denseSpace.VectorizeDense(text); err == nil && len(dv) > 0 {
parts = append(parts, dv)
}
}
for _, b := range evt.Blocks {
// 只融合同指纹的块向量:另一套坐标系的向量混进来会算出
// 两边都不像的方向。
if len(b.Vector) > 0 && b.Fingerprint == fp {
parts = append(parts, b.Vector)
}
}
evt.DenseVec = vector.FuseVectors(parts...)
evt.DenseFP = fp
}
}
@ -312,9 +326,11 @@ func (c *RelevanceContext) Prune(currentInput string, topK int, docStore *docume
// 优先使用稠密向量余弦(与媒体/文档共享空间);退化到稀疏词向量。
var queryDense []float64
useDense := false
queryFP := ""
if c.denseSpace != nil && c.denseSpace.Loaded() {
if dv, err := c.denseSpace.VectorizeDense(currentInput); err == nil {
queryDense = dv
queryFP = c.denseSpace.Fingerprint()
useDense = true
}
}
@ -323,7 +339,9 @@ func (c *RelevanceContext) Prune(currentInput string, topK int, docStore *docume
scoredEvents := make([]scoredEvent, len(candidates))
for i, evt := range candidates {
var score float64
if useDense && len(evt.DenseVec) == len(queryDense) {
// 只在同一统一空间内比稠密余弦:换了模型/维度后旧事件的向量
// 属于另一个坐标系,拿来比会得到无意义的分数。
if useDense && evt.DenseFP == queryFP && len(evt.DenseVec) == len(queryDense) {
score = vector.DenseCosine(queryDense, evt.DenseVec)
} else {
score = vector.CosineSimilarity(queryVec, evt.Vector)

View File

@ -12,8 +12,8 @@ import (
// CrossModalHit 是跨模态检索融合后的一条候选。
//
// 统一的检索单元是记忆块而非 CAS 全库:媒体在 L0/L2/L3 都和文本一样有 owner
// 只有仍被某层记忆引用的媒体才可召回。Doc 是 L2 文档Media 是该块携带的
// 统一的检索单元是记忆块而非 CAS 全库:媒体在 L0/L2/L3 都由层容器持有
// 只有仍被某层记忆块持有的媒体才可召回。Doc 是 L2 文档Media 是该块携带的
// 原生媒体坐标。两路分数尺度不同,融合前各自归一化,见 fuseCrossModal。
type CrossModalHit struct {
Doc *document.Doc // 文本路命中的文档;视觉路命中时为 nil
@ -67,10 +67,10 @@ func (c CrossModalFusionConfig) minMaxEps() float64 {
// retrieveCrossModal 是跨模态并行检索的统一入口。
//
// 策略(两路并行,召回真正最相似的):
// 1. 文本路query 整段文本用现有方法fastText/TF-IDF 稀疏 cosine查文档层
// 每个命中文档再反查其关联媒体docMediaContext——描述文本命中即媒体命中
// 2. 视觉路query 整段文本经多模态模型文本编码 → 与媒体库全部图像坐标比余弦
// QueryMediaScored覆盖描述文本没写到的视觉内容。
// 1. 文本路query 整段文本编码后查文档层Doc.DenseVec 已融合其块的媒体向量)
// 命中文档若持有媒体块,直接带上该块
// 2. 视觉路query 经多模态模型文本编码 → 与媒体块向量比余弦
// QueryMediaScored覆盖文本向量没写到的视觉内容。
// 3. 融合:两条路候选各自 min-max 归一化到 [0,1],加权求和后降序,取 topK。
// 同一媒体被两路同时命中视为双信号确认,额外加权。
//
@ -90,8 +90,7 @@ func (a *Agent) retrieveCrossModal(query string, topK int, cfg CrossModalFusionC
if a.docStore != nil {
for _, dh := range a.docStore.QueryScored(query, per) {
hit := CrossModalHit{Doc: dh.Doc, DocScore: dh.Score}
// 命中文档若持有一等记忆块,把首个媒体块一并带上
// 描述文本命中 → 该媒体就是相关记忆,供后续展示/注入。
// 命中文档若持有一等记忆块,把首个媒体块一并带上
if a.mediaStore != nil && len(dh.Doc.Blocks) > 0 {
if it, err := a.mediaStore.Stat(dh.Doc.Blocks[0].PayloadDigest); err == nil {
hit.Media = it
@ -110,7 +109,13 @@ func (a *Agent) retrieveCrossModal(query string, topK int, cfg CrossModalFusionC
} else if mh, err := a.mediaStore.QueryMediaScored(qv, a.multimodalSpace.Fingerprint(), per); err != nil {
log.Printf("[crossmodal] 媒体记忆检索失败: %v", err)
} else {
// 只有仍被某层记忆块持有的媒体才可召回CAS 是全库字节存储,
// 直接拿它的检索结果会把已无处可归的内容也从记忆里翻出来。
held := a.heldMediaDigests()
for _, h := range mh {
if h.Item == nil || !held[h.Item.Digest] {
continue
}
visualHits = append(visualHits, CrossModalHit{
Media: h.Item, MediaScore: h.Score,
})
@ -228,8 +233,7 @@ func fuseCrossModal(textHits, visualHits []CrossModalHit, topK int, cfg CrossMod
}
// crossModalMarkdown 把融合候选渲染成注入上下文的文本。
// 文档行对齐既有【相关记忆文档】格式;媒体行复用 mediaMarkerLine 的
// `[<mime> <短digest>] <描述>` 格式(那是解析回媒体引用的唯一合法格式)。
// 文档行给出摘要;媒体行只给 MIME + 短 digest不再有生成的描述
func (a *Agent) crossModalMarkdown(hits []CrossModalHit) string {
if len(hits) == 0 {
return ""
@ -253,7 +257,7 @@ func (a *Agent) crossModalMarkdown(hits []CrossModalHit) string {
}
}
if h.Media != nil {
if line := a.mediaMarkerLine(h.Media.Digest); line != "" {
if line := mediaLabel(h.Media); line != "" {
parts = append(parts, line)
}
}

View File

@ -204,8 +204,15 @@ func (a *Agent) archiveColdDocs() {
}
log.Printf("[agent] doc→graph: %s → %d entities, %d relations, %d blocks", doc.ID, ec, rc, blocks)
// 文档的一等记忆块已随句子写进 L3身份不变由 bindSentenceBlocks
// 复用 doc.Blocks 的 ID块不再挂在文档上删除文档即完成迁移。
// 文档持有的一等块写入 L3并以 document --contains--> block 边关联;
// 块 ID 原样保留(迁移而非重建)。块迁走后删除文档即完成迁移。
if len(doc.Blocks) > 0 {
if bound := a.linkBlocksToDocument(doc.ID, doc.Blocks); bound != len(doc.Blocks) {
log.Printf("[agent] doc→graph: %s 块迁移不完整 (%d/%d),保留文档待下轮重试",
doc.ID, bound, len(doc.Blocks))
continue
}
}
a.docStore.Remove(doc.ID)
}
}
@ -430,14 +437,9 @@ 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)...)
// 媒体不再参与三元组:它作为一等块由 linkBlocksToDocument
// 写入 L3 并以 document --contains--> block 边关联,
// 不经过文本描述与 NLP 提取器。
// NLP 通用提取
e := nlp.NewExtractor(nil)

View File

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

View File

@ -3,321 +3,146 @@ package core
import (
"fmt"
"log"
"regexp"
"strconv"
"strings"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
)
// L3 图库的媒体绑定。
//
// 媒体在 L3 是一等记忆块memory_blocks通过 sentence --contains--> block
// 结构边与承载它的句子相连。不再用 media_refs / owner 账本保活。
// 媒体在 L3 是一等记忆块memory_blocks以结构边与承载它的节点相连:
// sentence --contains--> block对话/三元组产生的记忆)
// document --contains--> blockL2 文档归档进 L3
//
// 图库里的实体与关系仍来自描述文本的 NLP 提取;媒体块只是补上
// 「这条记忆当时带着哪份媒体」这一结构信息
// 这里不再有任何 marker 文本、正则反解或"描述文本当索引"的路径:
// 媒体只按自己的统一空间向量被检索,图库/文档只记录它的结构归属
// mediaDigestPattern 匹配事件摘要里的媒体标记 [<mime或kind> <短digest>]
// migrateLegacyGraphMedia 把 marker 反解出来的旧媒体实体迁移成原生一等块
//
// 与 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
// 旧数据里媒体是 type=Media 的普通实体(「图片 a1b2c3d4e5f6」
// 靠生成的描述文本当索引。迁移后它变成真正的记忆块,以
// sentence --contains--> block 结构边挂回原句子,旧实体与描述关系删除
// 迁移幂等(实体处理完即删除),因此在每个 Agent 启动时跑一次是安全的。
func (a *Agent) migrateLegacyGraphMedia() {
if a.memory == nil || a.mediaStore == nil {
return
}
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
blocks, entities, err := a.memory.MigrateLegacyMediaEntities(func(short string) (memory.MemoryBlock, bool) {
full, err := a.mediaStore.ResolvePrefix(short)
if err != nil {
return memory.MemoryBlock{}, false
}
seen[d] = true
out = append(out, mediaMarker{
label: m[1],
shortDigest: d,
description: strings.TrimSpace(m[3]),
raw: strings.TrimSpace(m[0]),
})
return a.blockFromDigest(full)
})
if err != nil {
log.Printf("[media] 旧媒体实体迁移失败(下轮重试): %v", err)
return
}
if blocks > 0 || entities > 0 {
log.Printf("[media] 旧媒体实体迁移完成: 新建 %d 个原生块,删除 %d 个描述式实体", blocks, entities)
}
return out
}
// mediaEntityName 是媒体在图库里的实体名
//
// 形如「图片 a1b2c3d4e5f6」。刻意用 digest 而非描述文本构成名字:
// 描述会被重新生成(换视觉模型、补描述),若名字取自描述,同一张图
// 就会在图谱上留下多个节点。digest 不变则名字不变。
// 长度也天然合规validEntityName 要求 250 字符)。
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
}
// bindSentenceBlocks 把句子文本里提到的媒体变成 L3 的一等记忆块,
// 并建立 sentence --contains--> block 结构边。
//
// seed 是本批文档已持有的一等块:迁移时按 digest 复用它们的身份ID 不变),
// 真正做到“同一个块从 L2 移到 L3”而不是另建一个同内容的新块。
// 返回本次写入 L3 的块数。
func (a *Agent) bindSentenceBlocks(sentenceIDs map[string]int64, seed []memory.MemoryBlock) int {
if a.mediaStore == nil || a.memory == nil || len(sentenceIDs) == 0 {
// attachBlocksToSentence 把一组 digest 变成 L3 一等块并挂到句子上
// seed 允许复用已持有块的 IDL2→L3 迁移保持块身份不变)。
func (a *Agent) attachBlocksToSentence(sentenceID int64, digests []string, seed map[string]memory.MemoryBlock) int {
if a.mediaStore == nil || a.memory == nil || sentenceID == 0 {
return 0
}
bound := 0
for _, d := range digests {
full, err := a.mediaStore.ResolvePrefix(d)
if err != nil {
log.Printf("[media] digest %s 无法解析: %v", d, err)
continue
}
b, ok := seed[full]
if !ok {
if b, ok = a.blockFromDigest(full); !ok {
continue
}
}
if err := a.memory.PutMemoryBlocks([]memory.MemoryBlock{b}); err != nil {
log.Printf("[media] L3 块写入失败 (%s): %v", shortDigest(full), err)
continue
}
if err := a.memory.AddMemoryBlockEdge("sentence", strconv.FormatInt(sentenceID, 10), "block", b.ID, "contains"); err != nil {
log.Printf("[media] 句子→块边建立失败 (%s): %v", shortDigest(full), err)
continue
}
bound++
}
return bound
}
// linkBlocksToDocument 把文档持有的块写入 L3并建立
// document --contains--> block 边。块的 ID 原样保留(迁移而非重建)。
func (a *Agent) linkBlocksToDocument(docID string, blocks []memory.MemoryBlock) int {
if a.memory == nil || docID == "" || len(blocks) == 0 {
return 0
}
if err := a.memory.PutDocumentNode(docID, ""); err != nil {
log.Printf("[media] 写入 L3 文档节点失败 (%s): %v", docID, err)
return 0
}
if err := a.memory.PutMemoryBlocks(blocks); err != nil {
log.Printf("[media] 写入 L3 记忆块失败 (doc %s): %v", docID, err)
return 0
}
bound := 0
for _, b := range blocks {
if err := a.memory.AddMemoryBlockEdge("document", docID, "block", b.ID, "contains"); err != nil {
log.Printf("[media] 文档→块边建立失败 (%s): %v", shortDigest(b.PayloadDigest), err)
continue
}
bound++
}
return bound
}
// commitTriplesWithMedia 提交三元组并把三元组显式携带的媒体变成 L3 一等块。
//
// seed 是调用方已持有的一等块(如 L2 文档的 Blocks用于保持块身份
// 普通对话路径传 nil。blocks 是本次写入 L3 的块数。
func (a *Agent) commitTriplesWithMedia(triples []memory.Triple, sessionID string, turnID int, seed []memory.MemoryBlock) (entities, relations, blocks int, err error) {
if a.memory == nil {
return 0, 0, 0, fmt.Errorf("graph memory 未启用")
}
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
}
byDigest := make(map[string]memory.MemoryBlock, len(seed))
for _, b := range seed {
if b.PayloadDigest != "" {
byDigest[b.PayloadDigest] = b
}
}
bound := 0
for text, sid := range sentenceIDs {
for _, t := range triples {
if len(t.MediaDigests) == 0 {
continue
}
sid := sentenceIDs[t.SentenceText]
if sid == 0 {
continue
}
for _, short := range extractMediaDigests(text) {
full, err := a.mediaStore.ResolvePrefix(short)
if err != nil {
continue
}
b, ok := byDigest[full]
if !ok {
if b, ok = a.blockFromDigest(full); !ok {
continue
}
}
if err := a.memory.PutMemoryBlocks([]memory.MemoryBlock{b}); err != nil {
log.Printf("[media] L3 记忆块写入失败 (%s): %v", shortDigest(full), err)
continue
}
if err := a.memory.AddMemoryBlockEdge("sentence", strconv.FormatInt(sid, 10), "block", b.ID, "contains"); err != nil {
log.Printf("[media] 句子→块边建立失败 (%s): %v", shortDigest(full), err)
continue
}
bound++
}
blocks += a.attachBlocksToSentence(sid, t.MediaDigests, byDigest)
}
if bound > 0 {
log.Printf("[media] L3 图库写入 %d 个一等记忆块", bound)
}
return bound
return ec, rc, blocks, nil
}
// sentenceWithMediaMarkers 保证句子文本里带上这些 digest 的媒体标记
//
// 存在的理由L3 的块边由句子正文里的短 digest 反解而来。模型只知道
// digest从 memory_recall 的「关联媒体」或对话里的媒体标记读到),
// 不该要求它自己按内核格式拼标记——格式写错的后果是块边静默建不起来。
//
// 已出现过的 digest 不重复追加:模型可能既写了标记又填了 media_digests。
func (a *Agent) sentenceWithMediaMarkers(sentence string, digests []string) string {
if a.mediaStore == nil || len(digests) == 0 {
return sentence
// RecallBlocksForSentence 反查某条图库句子持有的一等记忆块
func (a *Agent) RecallBlocksForSentence(sentenceID int64) ([]memory.MemoryBlock, error) {
if a.memory == nil {
return nil, nil
}
present := make(map[string]bool)
for _, d := range extractMediaDigests(sentence) {
present[d] = true
}
var add []string
for _, d := range digests {
if d == "" || present[shortDigest(d)] {
continue
}
// 模型给的多半是短 digest它在上下文里看到的就是短的补全成完整
// digest 才能定位内容。补不上就跳过:内容可能已被删除。
full, err := a.mediaStore.ResolvePrefix(d)
if err != nil {
log.Printf("[media] 模型提交的 digest %s 无法解析: %v", d, err)
continue
}
if line := a.mediaMarkerLine(full); line != "" {
add = append(add, line)
present[shortDigest(full)] = true
}
}
if len(add) == 0 {
return sentence
}
if sentence == "" {
return strings.Join(add, "\n")
}
return sentence + "\n" + strings.Join(add, "\n")
}
// docMediaContext 为一篇文档产出媒体说明,供 doc_query 拼进工具返回值。
//
// 文档的一等记忆块随文档 JSON 持久化;这里只有正文,因此从正文标记反解。
func (a *Agent) docMediaContext(docID, content string) string {
if a.mediaStore == nil {
return ""
}
// 文档的一等记忆块随文档 JSON 持久化;这里只有正文,退回解析标记。
var digests []string
for _, short := range extractMediaDigests(content) {
full, err := a.mediaStore.ResolvePrefix(short)
if err != nil {
continue
}
digests = append(digests, full)
}
var lines []string
for _, d := range digests {
if line := a.mediaMarkerLine(d); line != "" {
lines = append(lines, line)
}
}
if len(lines) == 0 {
return ""
}
return strings.Join(lines, "")
return a.memory.BlocksForNode("sentence", strconv.FormatInt(sentenceID, 10))
}
// resolveMediaDigests 把模型给的多为短digest 补全成完整 digest。
@ -344,38 +169,6 @@ func (a *Agent) resolveMediaDigests(digests []string) []string {
return out
}
// bindDocMedia 把一组完整 digest 挂到文档 owner 上,返回成功条数。
//
// commitTriplesWithMedia 提交三元组并把句子里的媒体变成 L3 一等块。
//
// seed 是调用方已持有的一等块(如 L2 文档的 Blocks用于保持块身份
// 普通对话路径传 nil。blocks 是本次写入 L3 的块数。
func (a *Agent) commitTriplesWithMedia(triples []memory.Triple, sessionID string, turnID int, seed []memory.MemoryBlock) (entities, relations, blocks int, err error) {
if a.memory == nil {
return 0, 0, 0, fmt.Errorf("graph memory 未启用")
}
// 媒体存储关闭时退回普通 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.bindSentenceBlocks(sentenceIDs, seed), nil
}
// RecallBlocksForSentence 反查某条图库句子持有的一等记忆块。
//
// 这是整层的目的:几个月后从图谱走到一条句子,要能取回当时那份媒体。
func (a *Agent) RecallBlocksForSentence(sentenceID int64) ([]memory.MemoryBlock, error) {
if a.memory == nil {
return nil, nil
}
return a.memory.BlocksForNode("sentence", strconv.FormatInt(sentenceID, 10))
}
// sentenceIDsFromRelations 收集一批关系引用的句子 id去重、去零
//
// 关系行本身不持有媒体,媒体作为一等块以 sentence --contains--> block
@ -397,21 +190,14 @@ func sentenceIDsFromRelations(relations []memory.Relation) []int64 {
}
// 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。
// Indexer.BuildContext 刻意不返回关系(只给实体索引以省 token
// 因此这里用命中的实体名再查一次关系,只为拿到 sentence_id
func (a *Agent) mediaContextForInjectedEntities(injected *memory.InjectedContext) string {
if a.mediaStore == nil || a.memory == nil || injected == nil || len(injected.Entities) == 0 {
return ""
@ -427,13 +213,30 @@ func (a *Agent) mediaContextForInjectedEntities(injected *memory.InjectedContext
return a.mediaContextForRelations(res.Relations)
}
// mediaContextForSentences 给一组句子附上媒体说明,供召回时拼进提示词
// blockLabelsForDoc 渲染文档持有块的标签MIME + 短 digest供 doc_query 展示
func (a *Agent) blockLabelsForDoc(d *document.Doc) string {
if a.mediaStore == nil || d == nil || len(d.Blocks) == 0 {
return ""
}
var parts []string
for _, b := range d.Blocks {
it, err := a.mediaStore.Stat(b.PayloadDigest)
if err != nil || it == nil {
continue
}
if line := mediaLabel(it); line != "" {
parts = append(parts, line)
}
}
return strings.Join(parts, "")
}
// mediaContextForSentences 给一组句子附上其持有的一等块标签。
//
// 输出形如「句子 #12 关联媒体:[image/png a1b2c3d4e5f6] 一张紫蓝红三色带图」。
// 描述文本本就在句子里,这里补的是「内容是否还在、能否重新看图」这个信息——
// 描述永存而字节可能已被淘汰,两者状态不同。
// 标签只含 MIME 与短 digest图片按向量检索标签的作用是告诉模型
// "这条记忆当时带着哪份媒体、可用该 digest 取回字节"。
func (a *Agent) mediaContextForSentences(sentenceIDs []int64) string {
if a.memory == nil || len(sentenceIDs) == 0 {
if a.mediaStore == nil || a.memory == nil || len(sentenceIDs) == 0 {
return ""
}
var lines []string
@ -444,7 +247,11 @@ func (a *Agent) mediaContextForSentences(sentenceIDs []int64) string {
}
var parts []string
for _, b := range blocks {
if line := a.mediaMarkerLine(b.PayloadDigest); line != "" {
it, err := a.mediaStore.Stat(b.PayloadDigest)
if err != nil || it == nil {
continue
}
if line := mediaLabel(it); line != "" {
parts = append(parts, line)
}
}

View File

@ -13,14 +13,18 @@ import (
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
)
// L3 图库媒体引用测试。
// L3 图库媒体绑定测试。
//
// 这一层的目的只有一个:几个月后从图谱走到一条句子,要能取回当时那份媒体。
// 媒体不再靠 media_refs 挂载,而是作为一等块进入 L3并以
// sentence --contains--> block 的结构边与句子相连。
// 媒体作为一等块进入 L3以结构边与承载节点相连
//
// sentence --contains--> block对话/三元组产生的记忆)
// document --contains--> blockL2 文档归档进 L3
//
// 描述文本、marker 反解、由 marker 反推出的「媒体实体」全部已废弃,
// 因此这些测试也不存在任何按描述检索的断言。
// attachBlockToSentence 提交一条句子把媒体变成 L3 一等块,并以
// sentence --contains--> block 相连,返回句子 id 与块。
// attachBlockToSentence 提交一条句子把媒体变成 L3 一等块
// 必须走真实提交:边要求两端都是真实图节点。
func attachBlockToSentence(t *testing.T, g *memory.GraphDB, ms *media.Store, sentenceText, digest string) (int64, memory.MemoryBlock) {
t.Helper()
@ -77,41 +81,10 @@ func newGraphMediaAgent(t *testing.T) (*Agent, *memory.GraphDB, *media.Store) {
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] 一张紫蓝红三色带"
sentence := "这张图是紫蓝红三色带"
triples := []memory.Triple{{
Subject: "图片", Relation: "内容", Object: "三色带",
SentenceText: sentence,
@ -164,7 +137,7 @@ func TestCommit_StillWorksAfterRefactor(t *testing.T) {
}
}
func TestBindSentenceMedia_RoundTrip(t *testing.T) {
func TestCommitTriplesWithMedia_RoundTrip(t *testing.T) {
// 整层的核心断言:写入 → 提交 → 反查取回原始字节
a, _, ms := newGraphMediaAgent(t)
@ -173,18 +146,20 @@ func TestBindSentenceMedia_RoundTrip(t *testing.T) {
if err != nil {
t.Fatal(err)
}
short := shortDigest(digest)
sentence := "[image/png " + short + "] 一张紫蓝红三色带图"
sentence := "用户发来一张紫蓝红三色带图"
triples := []memory.Triple{{
Subject: "图片", Relation: "内容", Object: "三色带", SentenceText: sentence,
Subject: "图片", Relation: "内容", Object: "三色带",
SentenceText: sentence,
MediaDigests: []string{digest[:12]}, // 模型手里通常只有短 digest
}}
if _, _, _, err := a.commitTriplesWithMedia(triples, "s1", 0, nil); err != nil {
if _, _, bound, err := a.commitTriplesWithMedia(triples, "s1", 0, nil); err != nil {
t.Fatal(err)
} else if bound != 1 {
t.Fatalf("应绑定 1 个块,实际 %d", bound)
}
// 找到句子 id
ids, _, _, err := a.memory.CommitWithMedia(triples, "s1", 0)
if err != nil {
t.Fatal(err)
@ -216,31 +191,83 @@ func TestBindSentenceMedia_RoundTrip(t *testing.T) {
}
}
func TestBindSentenceMedia_SkipsUnresolvable(t *testing.T) {
// 文本里的 digest 在库里不存在时必须跳过,不能建一条指向虚无的块边。
func TestAttachBlocksToSentence_SkipsUnresolvable(t *testing.T) {
// digest 在库里不存在时必须跳过,不能建一条指向虚无的块边。
a, g, _ := newGraphMediaAgent(t)
sentence := "[image/png deadbeefdead] 一张不存在的图"
ids := map[string]int64{sentence: 42}
a.bindSentenceBlocks(ids, nil)
if n := a.attachBlocksToSentence(42, []string{"deadbeefdead"}, nil); n != 0 {
t.Fatalf("无法补全的 digest 不该建块,实际绑定 %d", n)
}
blocks, err := g.BlocksForNode("sentence", "42")
if err != nil {
t.Fatal(err)
}
if len(blocks) != 0 {
t.Fatalf("无法补全的 digest 不该块,实际 %+v", blocks)
t.Fatalf("不该块,实际 %+v", blocks)
}
}
func TestBindSentenceMedia_NilStoreNoop(t *testing.T) {
func TestAttachBlocksToSentence_NilStoreNoop(t *testing.T) {
a := &Agent{}
a.bindSentenceBlocks(map[string]int64{"[image aaaaaaaaaaaa] x": 1}, nil)
if n := a.attachBlocksToSentence(1, []string{"aaaaaaaaaaaa"}, nil); n != 0 {
t.Fatalf("媒体关闭时应静默无操作,实际 %d", n)
}
if got, err := a.RecallBlocksForSentence(1); err != nil || got != nil {
t.Fatalf("媒体关闭时应静默无操作,实际 %v / %v", got, err)
}
}
func TestAttachBlocksToSentence_ReusesSeedIdentity(t *testing.T) {
// L2→L3 迁移必须保持块身份:同一个块换层,而不是另建一个同内容的新块。
a, g, ms := newGraphMediaAgent(t)
digest, _ := ms.Put([]byte("seed-img"), media.Item{MIME: "image/png"})
seedBlock, ok := a.blockFromDigest(digest)
if !ok {
t.Fatal("blockFromDigest 失败")
}
ids, _, _, err := g.CommitWithMedia([]memory.Triple{{
Subject: "迁移", Relation: "包含", Object: "媒体", SentenceText: "迁移测试句。",
}}, "seed", 0)
if err != nil {
t.Fatal(err)
}
sid := ids["迁移测试句。"]
byDigest := map[string]memory.MemoryBlock{digest: seedBlock}
if n := a.attachBlocksToSentence(sid, []string{digest}, byDigest); n != 1 {
t.Fatalf("应绑定 1 个块,实际 %d", n)
}
blocks, err := g.BlocksForNode("sentence", strconv.FormatInt(sid, 10))
if err != nil {
t.Fatal(err)
}
if len(blocks) != 1 || blocks[0].ID != seedBlock.ID {
t.Fatalf("块身份应保持为 %s实际 %+v", seedBlock.ID, blocks)
}
}
func TestLinkBlocksToDocument_CreatesDocumentNodeEdge(t *testing.T) {
// 文档归档进 L3块原样迁入document --contains--> block 边建立。
a, g, ms := newGraphMediaAgent(t)
digest, _ := ms.Put([]byte("doc-img"), media.Item{MIME: "image/png"})
b, ok := a.blockFromDigest(digest)
if !ok {
t.Fatal("blockFromDigest 失败")
}
if n := a.linkBlocksToDocument("doc_42", []memory.MemoryBlock{b}); n != 1 {
t.Fatalf("应建立 1 条文档→块边,实际 %d", n)
}
blocks, err := g.BlocksForNode("document", "doc_42")
if err != nil {
t.Fatal(err)
}
if len(blocks) != 1 || blocks[0].ID != b.ID {
t.Fatalf("文档应持有块 %s实际 %+v", b.ID, blocks)
}
}
func TestCommitTriplesWithMedia_FallsBackWithoutStore(t *testing.T) {
// 媒体关闭时退回普通 Commit行为与直接调 Commit 完全一致
dir := t.TempDir()
@ -262,73 +289,105 @@ func TestCommitTriplesWithMedia_FallsBackWithoutStore(t *testing.T) {
}
}
func TestMediaBlocksHeldByDocumentSurviveGC(t *testing.T) {
// 文档持有的一等块把内容钉住;文档被删后块随之消失,内容才可回收。
a, g, ms := newGraphMediaAgent(t)
_ = a
digest, err := ms.Put([]byte("doc image"), media.Item{MIME: "image/png"})
if err != nil {
t.Fatal(err)
}
dir := t.TempDir()
ds := document.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords)
if err := ds.Start(); err != nil {
t.Fatal(err)
}
defer ds.Stop()
it, _ := ms.Stat(digest)
doc := &document.Doc{
ID: "doc_1", Summary: "带图的文档", Content: "正文",
Blocks: []memory.MemoryBlock{{ID: "blk_doc_1", Modality: memory.BlockImage,
PayloadDigest: it.Digest, MIME: it.MIME, Size: it.Size}},
}
if err := ds.Insert(doc); err != nil {
t.Fatal(err)
}
_ = g
// 文档仍持有块 → 内容在
if _, err := ms.Stat(digest); err != nil {
t.Fatal("有文档块持有内容时不该被清")
}
// 删除文档 → 一并删除其内容(与文本块一致:删块即删内容)
ds.Remove(doc.ID)
if blocks := ds.Blocks(); len(blocks) != 0 {
t.Fatalf("删除文档后不该还有块,实际 %+v", blocks)
}
if err := ms.Delete(digest); err != nil {
t.Fatal(err)
}
if _, err := ms.Stat(digest); err == nil {
t.Fatal("删除后内容应已移除")
}
}
func TestMediaContextForSentences(t *testing.T) {
a, g, ms := newGraphMediaAgent(t)
digest, _ := ms.Put([]byte("img"), media.Item{MIME: "image/png"})
if err := ms.Describe(digest, "一张紫蓝红三色带图", "visionllm"); err != nil {
t.Fatal(err)
}
sid, _ := attachBlockToSentence(t, g, ms, "[image/png "+shortDigest(digest)+"] 一张紫蓝红三色带图", digest)
sid, _ := attachBlockToSentence(t, g, ms, "一张紫蓝红三色带图", digest)
out := a.mediaContextForSentences([]int64{sid, sid + 100})
if out == "" {
t.Fatal("应产出媒体说明")
}
if !contains(out, fmt.Sprintf("句子 #%d", sid)) || !contains(out, "一张紫蓝红三色带图") {
if !contains(out, fmt.Sprintf("句子 #%d", sid)) || !contains(out, shortDigest(digest)) {
t.Fatalf("说明内容不对: %q", out)
}
// 说明只含 MIME 与短 digest不含任何生成的描述
if contains(out, "紫蓝红") {
t.Fatalf("说明里不该有描述文本(描述式索引已废弃): %q", out)
}
// 无引用的句子不该出现
if contains(out, fmt.Sprintf("句子 #%d", sid+100)) {
t.Fatalf("无引用的句子不该出现: %q", out)
}
}
func TestMediaContextForRelations_SurfacesMediaToAgent(t *testing.T) {
// L3 检索接线回归媒体作为一等块进了图库agent 必须拿得出来。
a, g, ms := newGraphMediaAgent(t)
digest, err := ms.Put([]byte("img bytes"), media.Item{MIME: "image/png"})
if err != nil {
t.Fatal(err)
}
sid, _ := attachBlockToSentence(t, g, ms, "一张紫蓝红三色带图。", digest)
// 命中的关系挂着该句子 → 应产出媒体说明
out := a.mediaContextForRelations([]memory.Relation{{ID: 1, SentenceID: sid}})
if out == "" {
t.Fatal("关系挂着有媒体的句子却没产出媒体说明——L3 检索接线断了")
}
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)
}
sentence := "用户发来的图片。"
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 := graph.PutMemoryBlocks([]memory.MemoryBlock{{
ID: "blk_auto_1", Modality: memory.BlockImage,
PayloadDigest: digest, MIME: "image/png",
}}); err != nil {
t.Fatal(err)
}
if err := graph.AddMemoryBlockEdge("sentence", strconv.FormatInt(sid, 10), "block", "blk_auto_1", "contains"); 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, shortDigest(digest)) {
t.Errorf("媒体段里应含短 digest: %q", out)
}
}
func TestResolvePrefix(t *testing.T) {
dir := t.TempDir()
ms, err := media.New(filepath.Join(dir, "m"))
@ -357,20 +416,17 @@ func TestResolvePrefix(t *testing.T) {
if _, err := ms.ResolvePrefix("deadbeefdead"); err == nil {
t.Fatal("不存在的前缀应报错")
}
// 完整但不存在的 digest 也要报错,否则调用方会挂一条孤儿引用
fake := ""
for i := 0; i < 64; i++ {
fake += "0"
}
// 完整但不存在的 digest 也要报错,否则调用方会挂一条孤儿
fake := strings.Repeat("0", 64)
if _, err := ms.ResolvePrefix(fake); err == nil {
t.Fatal("不存在的完整 digest 应报错")
}
}
func TestResolvePrefix_AmbiguityIsError(t *testing.T) {
// 前缀歧义视为错误而非"取第一个":挂错引用会让 GC 删掉仍被引用的内容
// 前缀歧义视为错误而非"取第一个":挂错块会让内容被误删
// 构造歧义需要两个同前缀 digest——sha256 无法人为构造,
// 因此这里退而验证「8 位前缀在大量样本下的行为是确定的」:
// 因此这里退而验证「12 位前缀在大量样本下的行为是确定的」:
// 要么唯一命中,要么明确报歧义,绝不静默取第一个。
dir := t.TempDir()
ms, err := media.New(filepath.Join(dir, "m"))
@ -391,7 +447,6 @@ func TestResolvePrefix_AmbiguityIsError(t *testing.T) {
for _, d := range digests {
got, err := ms.ResolvePrefix(d[:12])
if err != nil {
// 报歧义是可接受结果;静默取错才是缺陷
if !contains(err.Error(), "歧义") {
t.Fatalf("非歧义错误: %v", err)
}
@ -405,11 +460,7 @@ func TestResolvePrefix_AmbiguityIsError(t *testing.T) {
func TestArchiveColdDocs_KeepsDocWhenGraphWriteEmpty(t *testing.T) {
// 数据丢失回归三元组全被实体名校验拒绝时Commit 无错但 0 entities
// 0 relations文档不能删、媒体引用不能释放
//
// 该缺陷曾真实发生LLM 生成的 456 字图片描述提不出合规实体名
//validEntityName 要求 250 字符archiveColdDocs 只检查
// len(triples) > 0 就释放引用并删文档 → GC 清掉 blob → 图片与描述全丢。
// 0 relations文档不能删、其持有的块不能丢
a, _, ms := newGraphMediaAgent(t)
dir := t.TempDir()
@ -427,25 +478,11 @@ func TestArchiveColdDocs_KeepsDocWhenGraphWriteEmpty(t *testing.T) {
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 字,触发长度门槛被跳过
// 文档持有的一等块(模拟“文档有媒体但正文标记已在清洗中丢失”)。
// 精确构造「三元组非空 + Commit 全部拒绝」这个状态
// Source/Summary 都超过 validEntityName 的 50 字符上限,
// 于是 docToTriples 产出的两条元数据三元组都被跳过。
longSource := strings.Repeat("超长来源名", 20) // 100 字
longSummary := strings.Repeat("超长摘要文本", 20) // >80 字触发长度门槛被跳过
it, _ := ms.Stat(digest)
doc := &document.Doc{
ID: "doc_keep",
@ -485,50 +522,183 @@ func TestArchiveColdDocs_KeepsDocWhenGraphWriteEmpty(t *testing.T) {
if !held {
t.Error("文档或块被释放了——图库没有句子承载它,内容会被删除")
}
if _, err := ms.Stat(digest); err != nil {
t.Fatalf("未归档成功时内容不该被删: %v", err)
}
func TestArchiveColdDocs_MigratesBlocksToGraph(t *testing.T) {
// 归档成功时块必须迁进 L3 并以 document --contains--> block 关联,
// 然后文档才被删除(迁移而非复制/引用保活)。
a, g, ms := newGraphMediaAgent(t)
dir := t.TempDir()
ds := document.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords)
if err := ds.Start(); err != nil {
t.Fatal(err)
}
defer ds.Stop()
a.docStore = ds
a.embedder = memory.NewStaticEmbedder()
digest, _ := ms.Put([]byte("archived-image"), media.Item{MIME: "image/png"})
it, _ := ms.Stat(digest)
doc := &document.Doc{
ID: "doc_arch",
Summary: "带图的冷文档",
Content: "张三把三色带图交给了李四。",
Source: "manual",
Blocks: []memory.MemoryBlock{{ID: "blk_arch_1", Modality: memory.BlockImage,
PayloadDigest: it.Digest, MIME: it.MIME, Size: it.Size}},
}
if err := ds.Insert(doc); err != nil {
t.Fatal(err)
}
for _, d := range ds.RecentDocs(10) {
if d.ID == doc.ID {
d.LastAccess = time.Now().Add(-200 * time.Hour)
d.AccessCount = 0
}
}
a.archiveColdDocs()
if d := ds.Get("doc_arch"); d != nil {
t.Fatal("块已迁入 L3文档应被删除")
}
blocks, err := g.BlocksForNode("document", "doc_arch")
if err != nil {
t.Fatal(err)
}
if len(blocks) != 1 || blocks[0].ID != "blk_arch_1" {
t.Fatalf("L3 文档节点应持有原块(身份不变),实际 %+v", blocks)
}
if _, err := ms.Get(digest); err != nil {
t.Fatalf("块被 L3 持有,内容应仍可读: %v", err)
}
}
func TestCommitTriplesWithMedia_ReportsBoundCount(t *testing.T) {
// mediaBound 必须反映真实绑定数:归档路径靠它决定能否释放旧引用
a, _, ms := newGraphMediaAgent(t)
func TestMigrateLegacyMediaEntities(t *testing.T) {
// 旧数据:媒体被伪装成 type=Media 的实体,靠描述文本当索引
// 迁移必须把它还原成原生块(挂回原句子)并删掉旧实体与描述关系。
_, g, ms := newGraphMediaAgent(t)
digest, err := ms.Put([]byte("img"), media.Item{MIME: "image/png"})
digest, _ := ms.Put([]byte("legacy-img"), media.Item{MIME: "image/png"})
sentence := "老数据里的三色带图 [image/png " + digest[:12] + "]"
// 直接构造旧的实体/关系形态(不走已删除的 marker 代码)。
ids, _, _, err := g.CommitWithMedia([]memory.Triple{{
Subject: "图片 " + digest[:12],
SubjectType: "Media",
Relation: "内容",
Object: "三色带的描述文本",
ObjectType: "Description",
SentenceText: sentence,
}}, "legacy", 0)
if err != nil {
t.Fatal(err)
}
short := shortDigest(digest)
sid := ids[sentence]
if sid == 0 {
t.Fatal("拿不到句子 id")
}
// 句子含可反解的短 digest → 应绑定 1 个
_, _, bound, err := a.commitTriplesWithMedia([]memory.Triple{{
Subject: "图片", Relation: "内容", Object: "三色带",
SentenceText: "[image/png " + short + "] 一张三色带图",
}}, "s1", 0, nil)
blocks, entities, err := g.MigrateLegacyMediaEntities(func(short string) (memory.MemoryBlock, bool) {
full, err := ms.ResolvePrefix(short)
if err != nil {
return memory.MemoryBlock{}, false
}
it, err := ms.Stat(full)
if err != nil {
return memory.MemoryBlock{}, false
}
return memory.MemoryBlock{
ID: "blk_legacy_" + short, Modality: memory.BlockImage,
PayloadDigest: it.Digest, MIME: it.MIME, Size: it.Size,
}, true
})
if err != nil {
t.Fatal(err)
}
if bound != 1 {
t.Fatalf("应绑定 1 个媒体引用,实际 %d", bound)
if blocks != 1 || entities != 1 {
t.Fatalf("应迁移 1 块 / 删 1 实体,实际 %d / %d", blocks, entities)
}
// 句子无 digest → 绑定 0 个
_, _, bound2, err := a.commitTriplesWithMedia([]memory.Triple{{
Subject: "张三", Relation: "喜欢", Object: "咖啡",
SentenceText: "张三喜欢咖啡",
}}, "s2", 0, nil)
// 旧媒体实体与描述关系必须消失
res, err := g.Recall([]string{"图片 " + digest[:12]}, nil, 2, "")
if err != nil {
t.Fatal(err)
}
if bound2 != 0 {
t.Fatalf("无媒体标记的句子不该绑定引用,实际 %d", bound2)
for _, e := range res.Entities {
if e.Type == "Media" {
t.Fatalf("旧媒体实体仍存在: %+v", e)
}
}
// 块必须挂回原句子
got, err := g.BlocksForNode("sentence", strconv.FormatInt(sid, 10))
if err != nil {
t.Fatal(err)
}
if len(got) != 1 || got[0].PayloadDigest != digest {
t.Fatalf("句子应持有原生块,实际 %+v", got)
}
// 幂等:再跑一遍不应重复建块
blocks2, entities2, err := g.MigrateLegacyMediaEntities(nil)
if err != nil {
t.Fatal(err)
}
if blocks2 != 0 || entities2 != 0 {
t.Fatalf("无 resolver 时应空操作,实际 %d / %d", blocks2, entities2)
}
}
func TestCleanupOrphanedSentences_KeepsBlockBackedSentences(t *testing.T) {
// 旧媒体实体被删除后,承载它的句子可能再无关系引用,
// 但它还挂着媒体块——清理孤儿句子时不能把它删掉。
a, g, ms := newGraphMediaAgent(t)
digest, _ := ms.Put([]byte("orphan-img"), media.Item{MIME: "image/png"})
sentence := "只靠媒体块存活的句子。"
ids, _, _, err := g.CommitWithMedia([]memory.Triple{{
Subject: "媒体载体", Relation: "包含", Object: "内容", SentenceText: sentence,
}}, "orphan", 0)
if err != nil {
t.Fatal(err)
}
sid := ids[sentence]
b, ok := a.blockFromDigest(digest)
if !ok {
t.Fatal("blockFromDigest 失败")
}
if err := g.PutMemoryBlocks([]memory.MemoryBlock{b}); err != nil {
t.Fatal(err)
}
if err := g.AddMemoryBlockEdge("sentence", strconv.FormatInt(sid, 10), "block", b.ID, "contains"); err != nil {
t.Fatal(err)
}
// 解除关系引用,句子只剩块边
res, err := g.Recall([]string{"媒体载体"}, nil, 2, "")
if err != nil {
t.Fatal(err)
}
for _, r := range res.Relations {
if err := g.ClearSentenceID(r.ID); err != nil {
t.Fatal(err)
}
}
if _, err := g.CleanupOrphanedSentences(); err != nil {
t.Fatal(err)
}
blocks, err := g.BlocksForNode("sentence", strconv.FormatInt(sid, 10))
if err != nil {
t.Fatal(err)
}
if len(blocks) != 1 {
t.Fatalf("承载媒体块的句子被误删,块反查失败: %+v", blocks)
}
}
func TestSentenceIDsFromRelations(t *testing.T) {
// 关系行不持有媒体,媒体挂在句子上。这个函数负责"关系→句子"这一跳,
// 去重与去零都不能少sentence_id=0 表示该关系没有关联句子
// 拿 0 去查 media_refs 会命中一个不存在的 owner。
// 去重与去零都不能少sentence_id=0 表示该关系没有关联句子
rels := []memory.Relation{
{ID: 1, SentenceID: 5},
{ID: 2, SentenceID: 0}, // 无句子
@ -547,199 +717,45 @@ func TestSentenceIDsFromRelations(t *testing.T) {
}
}
func TestMediaContextForRelations_SurfacesMediaToAgent(t *testing.T) {
// L3 检索接线回归媒体描述进了图库agent 必须拿得出来
//
// 第四层做完了"存和反查的能力"RecallMediaForSentence /
// mediaContextForSentences但那两个函数一度没有任何调用方——
// 媒体能进 L3进去之后 agent 检索不到。这个测试守住那条接线。
a, g, ms := newGraphMediaAgent(t)
func TestMediaBlocksHeldByDocumentSurviveDeletion(t *testing.T) {
// 文档持有的一等块把内容钉住;文档被删后块随之消失,内容才可回收
_, _, ms := newGraphMediaAgent(t)
digest, err := ms.Put([]byte("img bytes"), media.Item{MIME: "image/png"})
digest, err := ms.Put([]byte("doc image"), media.Item{MIME: "image/png"})
if err != nil {
t.Fatal(err)
}
if err := ms.Describe(digest, "一张紫蓝红三色带图", "visionllm"); err != nil {
dir := t.TempDir()
ds := document.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords)
if err := ds.Start(); err != nil {
t.Fatal(err)
}
sid, _ := attachBlockToSentence(t, g, ms, "[image/png "+shortDigest(digest)+"] 一张紫蓝红三色带图", digest)
defer ds.Stop()
// 命中的关系挂着该句子 → 应产出媒体说明
out := a.mediaContextForRelations([]memory.Relation{{ID: 1, SentenceID: sid}})
if out == "" {
t.Fatal("关系挂着有媒体的句子却没产出媒体说明——L3 检索接线断了")
it, _ := ms.Stat(digest)
doc := &document.Doc{
ID: "doc_1", Summary: "带图的文档", Content: "正文",
Blocks: []memory.MemoryBlock{{ID: "blk_doc_1", Modality: memory.BlockImage,
PayloadDigest: it.Digest, MIME: it.MIME, Size: it.Size}},
}
if !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 {
if err := ds.Insert(doc); 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 {
// 文档仍持有块 → 内容在
if _, err := ms.Stat(digest); err != nil {
t.Fatal("有文档块持有内容时不该被清")
}
// 删除文档 → 一并删除其内容(与文本块一致:删块即删内容)
ds.Remove(doc.ID)
if blocks := ds.Blocks(); len(blocks) != 0 {
t.Fatalf("删除文档后不该还有块,实际 %+v", blocks)
}
if err := ms.Delete(digest); err != nil {
t.Fatal(err)
}
sid := sids[sentence]
if sid == 0 {
t.Fatal("拿不到句子 id")
}
attachBlockToSentence(t, graph, ms, sentence, digest)
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 的 250 字符门槛,否则 Commit 会静默跳过
if n := len([]rune(got)); n < 2 || n > 50 {
t.Errorf("实体名长度 %d 不在 250 之间: %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])
if _, err := ms.Stat(digest); err == nil {
t.Fatal("删除后内容应已移除")
}
}

View File

@ -192,63 +192,84 @@ func TestResolveInput_UnifiesAllModalities(t *testing.T) {
})
}
// ---------- 模型工具侧:sentenceWithMediaMarkers ----------
// ---------- 模型工具侧:memory_digests 结构化传递 ----------
// 模型只知道 digest从对话或 memory_recall 的「关联媒体」读到)
// 不该要求它自己按内核格式拼标记——格式写错的后果是引用静默挂不上
func TestSentenceWithMediaMarkers(t *testing.T) {
// 模型只知道 digest从对话或 memory_recall 的「关联媒体」读到)
// 它不再需要自己拼任何标记digest 作为结构化字段随三元组提交
func TestResolveMediaDigestsAndNoMarkerText(t *testing.T) {
a, ms := newInputTestAgent(t)
digest, err := ms.Put([]byte("marker-bytes"), media.Item{
MIME: "image/png", Description: "一张紫蓝红三色带图",
})
digest, err := ms.Put([]byte("marker-bytes"), media.Item{MIME: "image/png"})
if err != nil {
t.Fatalf("Put: %v", err)
}
t.Run("短digest补全并生成标记", func(t *testing.T) {
got := a.sentenceWithMediaMarkers("用户发来一张图。", []string{digest[:12]})
if !strings.Contains(got, "三色带图") {
t.Errorf("描述未并入句子: %q", got)
}
if !strings.Contains(got, digest[:12]) {
t.Errorf("digest 未并入句子(反查会失效): %q", got)
}
// 反解必须成功,否则 bindSentenceMedia 挂不上引用
if got := extractMediaDigests(got); len(got) != 1 {
t.Errorf("生成的标记无法被 extractMediaDigests 反解: %v", got)
t.Run("短digest补全", func(t *testing.T) {
got := a.resolveMediaDigests([]string{digest[:12]})
if len(got) != 1 || got[0] != digest {
t.Fatalf("短 digest 应补全为完整 digest得到 %v", got)
}
})
t.Run("模型已写标记时不重复追加", func(t *testing.T) {
sentence := "看这个 [image/png " + digest[:12] + "] 三色带图"
got := a.sentenceWithMediaMarkers(sentence, []string{digest[:12]})
if n := strings.Count(got, digest[:12]); n != 1 {
t.Errorf("digest 出现 %d 次,期望 1 次: %q", n, got)
t.Run("无法解析的digest被丢弃", func(t *testing.T) {
if got := a.resolveMediaDigests([]string{"ffffffffffff"}); len(got) != 0 {
t.Errorf("不存在的 digest 不该保留: %v", got)
}
})
t.Run("空句子时标记本身充当句子", func(t *testing.T) {
got := a.sentenceWithMediaMarkers("", []string{digest})
if got == "" {
t.Error("媒体必须有句子落点,否则 media_refs 无从挂起")
}
})
t.Run("无法解析的digest被跳过", func(t *testing.T) {
got := a.sentenceWithMediaMarkers("原句。", []string{"ffffffffffff"})
if got != "原句。" {
t.Errorf("不存在的 digest 不该造出标记: %q", got)
}
})
t.Run("无媒体存储时原样返回", func(t *testing.T) {
t.Run("无媒体存储时返回nil", func(t *testing.T) {
bare := &Agent{}
if got := bare.sentenceWithMediaMarkers("原句。", []string{digest}); got != "原句。" {
t.Errorf("无媒体存储时应原样返回: %q", got)
if got := bare.resolveMediaDigests([]string{digest}); got != nil {
t.Errorf("无媒体存储时应返回 nil: %v", got)
}
})
}
// 句子文本必须保持原样:媒体归属走结构化块边,不往文本里贴 marker。
func TestMemoryCommit_DoesNotPolluteSentenceText(t *testing.T) {
dir := t.TempDir()
g, err := memory.NewGraphDB(filepath.Join(dir, "graph.db"))
if err != nil {
t.Fatal(err)
}
defer g.Close()
ms, err := media.New(filepath.Join(dir, "media"))
if err != nil {
t.Fatal(err)
}
defer ms.Close()
a := &Agent{memory: g, mediaStore: ms}
digest, _ := ms.Put([]byte("clean-sentence"), media.Item{MIME: "image/png"})
sentence := "用户发来一张图。"
triples := []memory.Triple{{
Subject: "用户", Relation: "发来", Object: "图片",
SentenceText: sentence,
MediaDigests: a.resolveMediaDigests([]string{digest[:12]}),
}}
if _, _, _, err := a.commitTriplesWithMedia(triples, "s1", 0, nil); err != nil {
t.Fatal(err)
}
res, err := a.memory.Recall([]string{"用户"}, nil, 2, "")
if err != nil {
t.Fatal(err)
}
if len(res.Relations) == 0 {
t.Fatal("召回为空")
}
if res.Relations[0].SentenceText != sentence {
t.Errorf("句子文本被污染: %q", res.Relations[0].SentenceText)
}
blocks, err := a.memory.BlocksForNode("sentence", strconv.FormatInt(res.Relations[0].SentenceID, 10))
if err != nil {
t.Fatal(err)
}
if len(blocks) != 1 || blocks[0].PayloadDigest != digest {
t.Errorf("块应挂到句子,实际 %+v", blocks)
}
}
// ---------- resolveMediaDigests ----------
func TestResolveMediaDigests(t *testing.T) {
@ -319,63 +340,61 @@ func TestDocCommit_StoresBlocks(t *testing.T) {
}
}
// ---------- docMediaContext ----------
// ---------- 文档持有块标签doc_query 展示用) ----------
func TestDocMediaContext(t *testing.T) {
func TestBlockLabelsForDoc(t *testing.T) {
a, ms := newInputTestAgent(t)
digest, _ := ms.Put([]byte("ctx-bytes"), media.Item{
MIME: "image/png", Description: "文档里的配图",
})
digest, _ := ms.Put([]byte("ctx-bytes"), media.Item{MIME: "image/png"})
b, ok := a.blockFromDigest(digest)
if !ok {
t.Fatal("blockFromDigest 失败")
}
t.Run("无块时解析正文标记", func(t *testing.T) {
content := "旧正文 [image/png " + digest[:12] + "] 文档里的配图"
got := a.docMediaContext("doc_legacy", content)
if !strings.Contains(got, "文档里的配图") {
t.Errorf("历史文档只有标记时应回退解析: %q", got)
t.Run("从文档持有的一等块渲染", func(t *testing.T) {
got := a.blockLabelsForDoc(&document.Doc{ID: "doc_1", Blocks: []memory.MemoryBlock{b}})
if !strings.Contains(got, shortDigest(digest)) {
t.Errorf("标签应含短 digest: %q", got)
}
if !strings.Contains(got, "image/png") {
t.Errorf("标签应含 MIME: %q", got)
}
})
t.Run("既无引用也无标记", func(t *testing.T) {
if got := a.docMediaContext("doc_empty", "普通正文"); got != "" {
t.Run("无块时为空", func(t *testing.T) {
if got := a.blockLabelsForDoc(&document.Doc{ID: "doc_x", Content: "普通正文"}); got != "" {
t.Errorf("应返回空串,实际 %q", got)
}
})
t.Run("无媒体存储", func(t *testing.T) {
bare := &Agent{}
if got := bare.docMediaContext("doc_x", "任意"); got != "" {
if got := bare.blockLabelsForDoc(&document.Doc{ID: "doc_x"}); got != "" {
t.Errorf("无媒体存储时应返回空串,实际 %q", got)
}
})
}
// ---------- mediaMarkerLine ----------
// ---------- mediaLabel ----------
// 标记格式的唯一生成处。此前 mediaSummaryForEvent 与 mediaContextForSentences
// 各拼一份,改动截断长度或分隔符时只改一处,另一处写出的标记就再也解析不回来。
func TestMediaMarkerLine(t *testing.T) {
// 媒体标签的唯一生成处:只含 MIME 与短 digest不含任何生成的描述。
func TestMediaLabel(t *testing.T) {
a, ms := newInputTestAgent(t)
_ = a
described, _ := ms.Put([]byte("with-desc"), media.Item{
MIME: "image/png", Description: "已描述的图",
})
if got := a.mediaMarkerLine(described); !strings.Contains(got, "已描述的图") {
t.Errorf("有描述时应带描述: %q", got)
digest, _ := ms.Put([]byte("labelled"), media.Item{MIME: "image/png"})
it, err := ms.Stat(digest)
if err != nil {
t.Fatal(err)
}
// 「已入库但还没描述」与「压根没有媒体」必须可区分
bare, _ := ms.Put([]byte("no-desc"), media.Item{MIME: "image/png"})
got := a.mediaMarkerLine(bare)
if !strings.Contains(got, "(未描述)") {
t.Errorf("无描述时应有占位符: %q", got)
got := mediaLabel(it)
if !strings.Contains(got, "image/png") {
t.Errorf("标签应含 MIME: %q", got)
}
if !strings.Contains(got, shortDigest(bare)) {
if !strings.Contains(got, shortDigest(digest)) {
t.Errorf("必须带短 digest 供反查: %q", got)
}
// 查不到返回空串:媒体可能已被容量 GC 淘汰,此时不该造出指向虚无的标记
if got := a.mediaMarkerLine("ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"); got != "" {
t.Errorf("查不到的 digest 应返回空串,实际 %q", got)
if got := mediaLabel(nil); got != "" {
t.Errorf("nil 应返回空串,实际 %q", got)
}
}
@ -417,9 +436,7 @@ func newToolTestAgent(t *testing.T) (*Agent, *media.Store) {
// memory_commit 带 media_digests三元组入库后必须能从句子反查回那份字节。
func TestToolMemoryCommit_BindsMedia(t *testing.T) {
a, ms := newToolTestAgent(t)
digest, _ := ms.Put([]byte("commit-bytes"), media.Item{
MIME: "image/png", Description: "提交时关联的图",
})
digest, _ := ms.Put([]byte("commit-bytes"), media.Item{MIME: "image/png"})
out := a.executeMemoryTool(agentAPI.ToolCall{
Name: "memory_commit",
@ -498,12 +515,10 @@ func TestToolMemoryCommit_CarriesSentenceText(t *testing.T) {
}
}
// doc_commit 带 media_digests标记进正文(否则检索不到)+ 引用挂文档 owner否则 GC 会清)
// doc_commit 带 media_digests媒体成为文档直接持有的一等块;正文保持原样
func TestToolDocCommit_BindsMedia(t *testing.T) {
a, ms := newToolTestAgent(t)
digest, _ := ms.Put([]byte("doc-commit-bytes"), media.Item{
MIME: "image/png", Description: "笔记里的插图",
})
digest, _ := ms.Put([]byte("doc-commit-bytes"), media.Item{MIME: "image/png"})
out := a.executeDocTool(agentAPI.ToolCall{
Name: "doc_commit",
@ -522,8 +537,8 @@ func TestToolDocCommit_BindsMedia(t *testing.T) {
t.Fatal("文档未写入")
}
d := docs[0]
if !strings.Contains(d.Content, "笔记里的插图") {
t.Errorf("标记未进正文(向量索引看不到这份媒体): %q", d.Content)
if strings.Contains(d.Content, "image/png") {
t.Errorf("正文不该被媒体标记污染: %q", d.Content)
}
var held bool
for _, b := range d.Blocks {
@ -539,9 +554,7 @@ func TestToolDocCommit_BindsMedia(t *testing.T) {
// doc_query 必须把媒体说明附在返回值里,否则模型检索到带图文档也不知道有图。
func TestToolDocQuery_ShowsMedia(t *testing.T) {
a, ms := newToolTestAgent(t)
digest, _ := ms.Put([]byte("query-bytes"), media.Item{
MIME: "image/png", Description: "检索命中的配图",
})
digest, _ := ms.Put([]byte("query-bytes"), media.Item{MIME: "image/png"})
a.executeDocTool(agentAPI.ToolCall{
Name: "doc_commit",
@ -560,7 +573,7 @@ func TestToolDocQuery_ShowsMedia(t *testing.T) {
// 正文进的是 cold_storage 事件(工具返回值只给引用编号),媒体说明也在那里。
var found bool
for _, e := range a.context.Recent(10) {
if strings.Contains(e.Response, "检索命中的配图") {
if strings.Contains(e.Response, shortDigest(digest)) {
found = true
}
}

View File

@ -200,7 +200,6 @@ func newLiveEnv(t *testing.T, c liveCfg) *liveEnv {
Memory: graph,
DocStore: docStore,
MediaStore: ms,
MediaDescribe: true, // 描述循环由测试直接调 describePendingMedia
StageHost: NewStageHost(),
MaxContextSize: 3, // 故意压低:第二轮就能触发 Prune 归档
InputProcessing: types.InputProcessingConfig{},
@ -250,19 +249,28 @@ func TestMediaLive_AutoTriggerChain(t *testing.T) {
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)
// 媒体不再有文字描述CAS 里只有字节、元数据与向量。
// 这里直接按 digest 定位刚落的图(不再有 Pending 队列)。
st := env.mediaSt.Stats()
if st["count"].(int) != 1 {
t.Fatalf("CAS 应自动收到 1 张图,实际 %v 张captureBlockMedia 未被触发?)", st["count"])
}
var digest string
var found bool
for _, e := range a.context.Recent(0) {
for _, b := range e.Blocks {
digest, found = b.PayloadDigest, true
}
}
if !found {
t.Fatal("无法从上下文块定位刚落盘的图")
}
it0, err := env.mediaSt.Stat(digest)
if err != nil {
t.Fatalf("pending: %v", err)
t.Fatal(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)
digest[:12], it0.Size, it0.Tool)
stored, err := env.mediaSt.Get(digest)
if err != nil || !bytes.Equal(stored, img) {
@ -271,13 +279,15 @@ func TestMediaLive_AutoTriggerChain(t *testing.T) {
// ── 阶段 2一等记忆块自动挂到 ContextEvent 上 ──
//
// 这一步验证 bindEventMedia事件必须拿到 ID 并直接持有块
// 这一步验证 bindEventMedia事件必须拿到 ID 并直接持有块
// 事件文本必须保持原样(不再往正文里贴媒体标记)。
var evtID string
var summaryOK bool
for _, e := range a.context.Recent(0) {
if len(e.Blocks) > 0 {
evtID = e.ID
summaryOK = strings.Contains(e.Input, digest[:12])
if strings.Contains(e.Input, digest[:12]) {
t.Error("事件 Input 里被写入了媒体标记——描述式索引链应该已经拆除")
}
if e.Blocks[0].PayloadDigest != digest {
t.Fatalf("事件持有的块 digest 不对: %+v", e.Blocks)
}
@ -287,51 +297,17 @@ func TestMediaLive_AutoTriggerChain(t *testing.T) {
if evtID == "" {
t.Fatal("没有任何 ContextEvent 挂上媒体bindEventMedia 未被触发)")
}
if !summaryOK {
t.Error("事件 Input 里没有媒体摘要标记mediaSummaryForEvent 未生效)——" +
"L2/L3 靠正文里的短 digest 反查,缺了它整条召回链断掉")
}
t.Logf("✓ 阶段2 块自动绑定: event=%s 摘要内嵌=%v", evtID, summaryOK)
t.Logf("✓ 阶段2 块自动绑定: event=%s", evtID)
// ── 阶段 3描述由后台循环自动生成(真实视觉模型)──
pending, err := env.mediaSt.Pending(5)
if err != nil {
// ── 阶段 3媒体只按自己的向量被索引,不再生成任何描述 ──
if it, err := env.mediaSt.Stat(digest); 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 if len(it.Vec) == 0 {
// 未配置多模态空间时就没有向量——这是合法的降级状态,
// 但要明确报出来,而不是靠描述文本假装能检索。
t.Log("未配置多模态空间:本图无向量,之后只能靠块结构召回 digest")
} else {
t.Logf("✓ 阶段3 Search(\"紫\") 命中 %d 条", len(found))
t.Logf("✓ 阶段3 已写入原生向量: dim=%d", len(it.Vec))
}
// ── 阶段 4Prune 自动把块从 L0 迁移到 L2 ──
@ -385,11 +361,11 @@ func TestMediaLive_AutoTriggerChain(t *testing.T) {
t.Fatalf("迁移后内容不可读: %v", err)
}
// ── 阶段 5archiveColdDocs 自动把媒体带进 L3 图库 ──
// ── 阶段 5archiveColdDocs 自动把块连到 L3 文档节点 ──
//
// FindColdDocs(72h, 2) 要求文档足够"冷",测试里新建的文档不满足,
// 因此把 LastAccess 往前推——这是为了触发生产代码路径,
// 而不是替代它(Commit/bindSentenceBlocks由它自己调)。
// 而不是替代它(commitTriplesWithMedia/linkBlocksToDocument 全由它自己调)。
for _, d := range env.docStore.RecentDocs(20) {
if d.ID == docRefsFound {
d.LastAccess = time.Now().Add(-100 * time.Hour)
@ -398,13 +374,21 @@ func TestMediaLive_AutoTriggerChain(t *testing.T) {
}
a.archiveColdDocs()
// 块可能以 document --contains--> block文档归档
// sentence --contains--> block对话三元组两种边存在。
sentRefs := 0
var boundSentence int64
docBound := 0
rows, err := env.graph.Recall(nil, nil, 1, "")
if err != nil {
t.Fatalf("graph recall: %v", err)
}
t.Logf("图库实体数 %d", len(rows.Entities))
docBlocks, err := env.graph.BlocksForNode("document", docRefsFound)
if err != nil {
t.Fatal(err)
}
docBound = len(docBlocks)
// 句子 id 是自增整数,扫前若干个足够覆盖本测试写入的量
for sid := int64(1); sid <= 40; sid++ {
blocks, err := env.graph.BlocksForNode("sentence", strconv.FormatInt(sid, 10))
@ -415,10 +399,18 @@ func TestMediaLive_AutoTriggerChain(t *testing.T) {
}
}
}
if sentRefs == 0 {
t.Error("L2→L3 未写入任何句子→块边——" +
"bindSentenceBlocks 未被 commitTriplesWithMedia 触发," +
"或句子正文里没有可反解的短 digest")
if sentRefs == 0 && docBound == 0 {
t.Error("L2→L3 未写入任何块边——linkBlocksToDocument 未被 archiveColdDocs 触发")
} else if docBound > 0 {
t.Logf("✓ 阶段5 L3 自动写入: 文档 %s 持有 %d 个块", docRefsFound, docBound)
got := docBlocks
if got[0].PayloadDigest != digest {
t.Errorf("文档节点持有的块 digest 不对: %+v", got)
} else if raw, err := env.mediaSt.Get(got[0].PayloadDigest); err != nil || !bytes.Equal(raw, img) {
t.Errorf("从文档块取回的字节与原图不一致 (err=%v)", err)
} else {
t.Logf("✓ 阶段5 反查取回 %d 字节,与原图逐字节一致", len(raw))
}
} else {
t.Logf("✓ 阶段5 L3 自动写入: %d 个句子块,首个 sentences.id=%d", sentRefs, boundSentence)
@ -448,15 +440,10 @@ func TestMediaLive_AutoTriggerChain(t *testing.T) {
if err := a.indexer.Sync(); err != nil {
t.Fatalf("indexer sync: %v", err)
}
if mc := a.buildMemoryContext("图片 颜色", 0); mc != "" {
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 无从得知历史媒体")
t.Log("图库召回为空(本测试不再依赖文本描述,仅记录现状)")
}
ask := &agentIO.InputEvent{
@ -475,6 +462,9 @@ func TestMediaLive_AutoTriggerChain(t *testing.T) {
a.handleInput(ask)
t.Logf("第二轮耗时 %.1fs", time.Since(t2).Seconds())
// 第二轮仍走真实 LLM这里只验证链路不报错、有回复。
// 不再断言"答出紫/蓝/红":图片的颜色信息只在原生向量里,
// 未配置多模态空间时模型本来就无从得知——那不属于记忆接线缺陷。
var answer string
select {
case out := <-respCh:
@ -483,27 +473,20 @@ func TestMediaLive_AutoTriggerChain(t *testing.T) {
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 答出紫/蓝/红")
if strings.HasPrefix(answer, "处理错误:") {
t.Skipf("上游 LLM 调用失败,端到端召回无法判定: %s", truncRunes(answer, 160))
}
t.Logf("✓ 阶段7 E2E 链路贯通(召回能力取决于是否配置多模态向量空间)")
st := env.mediaSt.Stats()
t.Logf("收尾: %v 条 / %v 字节 / 已描述 %v",
st["count"], st["total_bytes"], st["described"])
st = env.mediaSt.Stats()
t.Logf("收尾: %v 条 / %v 字节 / 类型 %v",
st["count"], st["total_bytes"], st["by_kind"])
}
// TestMediaLive_NegativeControl 阴性对照:没有媒体记忆时不该"记得"。
//
// 没有这条对照,阶段7 的"答出紫蓝红"可能只是模型在猜常见配色
// 无法区分真召回与先验偏好
// 没有这条对照,任何"答出了具体内容"的结果都可能只是模型先验
// 无法区分真召回与猜测
func TestMediaLive_NegativeControl(t *testing.T) {
c := requireLiveCfg(t)
env := newLiveEnv(t, c)

View File

@ -2,26 +2,44 @@ package core
import (
"log"
"runtime/debug"
"sync"
"sync/atomic"
"time"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
)
// 媒体记忆的后台循环
//
// mediaDescribeLoop 给未描述的媒体生成文字描述。
// 媒体记忆块的生命周期辅助
//
// 媒体不单独做生命周期管理(没有 GC、没有引用计数blob 是记忆块的内容,
// 块的创建/迁移/删除由记忆系统本身决定,块被永久删除时内容随之删除
// (见 forgetPayloads
// 块的创建/迁移/删除由记忆系统本身决定,块被永久删除时内容随之删除
// 图片不靠文本描述索引——它只按自己的统一空间向量被检索
// heldMediaDigests 汇总三层记忆当前持有的媒体 digest 集合。
//
// 为何描述要走后台而不是入库时同步做:视觉模型一次调用在生产实测 9.6s
// see_video 6 帧批量 23s。放在对话路径上会让每张图都给回复加十几秒
// 而描述的价值是**几个月后还能检索到这张图**,不是这一轮对话——
// 这一轮模型本来就直接看着图。
// CAS 是全库字节存储,它的检索结果不等于「记忆里的媒体」——
// 召回前用它把已无处可归的内容过滤掉。
func (a *Agent) heldMediaDigests() map[string]bool {
held := map[string]bool{}
collect := func(blocks []memory.MemoryBlock) {
for _, b := range blocks {
if b.PayloadDigest != "" {
held[b.PayloadDigest] = true
}
}
}
if a.context != nil {
collect(a.context.Blocks())
}
if a.docStore != nil {
collect(a.docStore.Blocks())
}
if a.memory != nil {
if blocks, err := a.memory.MemoryBlocks(); err == nil {
collect(blocks)
}
}
return held
}
// payloadHeld 报告某个 digest 是否仍被三层记忆中的一等块持有。
// 这是删除前的一次活查询(不是持久化账本):同一份字节可能同时被多个块共享。
@ -73,135 +91,6 @@ func (a *Agent) forgetPayloads(digests []string) {
}
}
const (
// mediaDescribeBatch 是单轮描述的媒体条数上限。
//
// 取 4既有回退链的 modalFallbackMaxBlocks 是 6一次请求最多带 6 个媒体),
// 这里留出余量,且每条单独请求以便逐条落库——批量描述拿回来一整段文字
// 无法可靠切分回各自的 digest。
mediaDescribeBatch = 4
// mediaDescribeMinInterval 是两轮描述之间的最小间隔。
//
// 描述是纯后台的锦上添花,不该跟对话抢视觉模型配额。取 30s 让它
// 慢慢消化积压,而不是一上线就把几百条历史媒体全打过去。
mediaDescribeMinInterval = 30 * time.Second
)
// mediaDescribeLoop 给未描述的媒体补文字描述。
//
// 描述文本才是持久语义记忆:它留在 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)
// 描述成功后无需再次做视觉嵌入:图片在进入 L0 记忆块时已由
// embedMediaOnIngest 计算并写入 CASL0→L2→L3 只转移引用并复用坐标。
// 历史已有图片或模型切换由启动时 reembedStaleMedia 一次性补算。
}
}
// reembedStaleMedia 在启动时批量迁移历史媒体向量到当前向量空间。
//
// 触发场景(任一变化都会导致旧向量无法参与查询):
@ -270,8 +159,7 @@ func (a *Agent) reembedStaleMedia() {
done, failed, len(digests), shortFP(fp))
}
// reembedOne 为单条媒体重新计算向量并写入stat 错误时跳过(可能已被 GC 清除)。
// Get 错误或 Embed 错误时静默跳过该条目(不影响迁移其他条目)。
// reembedOne 为单条媒体重新计算向量并写入stat/get 失败时跳过该条目)。
func (a *Agent) reembedOne(digest, fp string) error {
it, err := a.mediaStore.Stat(digest)
if err != nil {

View File

@ -4,18 +4,18 @@ import (
"context"
"path/filepath"
"testing"
"time"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
)
// 媒体后台循环测试。
// 媒体与记忆块的生命周期测试。
//
// 媒体没有独立生命周期管理(没有 GC、没有引用计数blob 是记忆块的内容,
// 块的创建/迁移/删除由记忆系统决定。这里只测描述循环与删除语义
// 块的创建/迁移/删除由记忆系统决定。图片也不靠文本描述索引
func newMediaLoopAgent(t *testing.T, describe bool) (*Agent, *media.Store) {
func newMediaLoopAgent(t *testing.T) (*Agent, *media.Store) {
t.Helper()
dir := t.TempDir()
ms, err := media.New(filepath.Join(dir, "media"))
@ -24,73 +24,81 @@ func newMediaLoopAgent(t *testing.T, describe bool) (*Agent, *media.Store) {
}
t.Cleanup(func() { ms.Close() })
a := &Agent{
mediaStore: ms,
mediaDescribe: describe,
}
a := &Agent{mediaStore: ms}
a.ctx, a.cancel = context.WithCancel(context.Background())
t.Cleanup(a.cancel)
return a, ms
}
func TestMediaDescribeLoop_ExitsWhenDisabled(t *testing.T) {
// describe 关闭时必须立即返回(默认就是关闭,绝大多数部署走这条路)
a, _ := newMediaLoopAgent(t, false)
done := make(chan struct{})
go func() { a.mediaDescribeLoop(); close(done) }()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("describe 关闭时 mediaDescribeLoop 未立即返回")
// heldMediaDigests 汇总三层记忆持有的媒体:只有这些才可被召回。
func TestHeldMediaDigests_CollectsAcrossLayers(t *testing.T) {
a, ms := newMediaLoopAgent(t)
d1, _ := ms.Put([]byte("ctx-layer"), media.Item{MIME: "image/png"})
d2, _ := ms.Put([]byte("doc-layer"), media.Item{MIME: "image/png"})
d3, _ := ms.Put([]byte("graph-layer"), media.Item{MIME: "image/png"})
d4, _ := ms.Put([]byte("orphan"), media.Item{MIME: "image/png"})
a.context = NewRelevanceContext("", memory.NewStaticEmbedder(""))
a.context.Append(ContextEvent{Input: "带图的一轮", Blocks: []memory.MemoryBlock{
{ID: "blk_ctx", Modality: memory.BlockImage, PayloadDigest: d1},
}})
dir := t.TempDir()
bo, ok := a.blockFromDigest(d2)
if !ok {
t.Fatal("blockFromDigest 失败")
}
}
ds := document.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords)
if err := ds.Start(); err != nil {
t.Fatal(err)
}
defer ds.Stop()
if err := ds.Insert(&document.Doc{ID: "doc_1", Summary: "s", Blocks: []memory.MemoryBlock{bo}}); err != nil {
t.Fatal(err)
}
a.docStore = ds
func TestDescribePendingMedia_NoProviderLeavesUndescribed(t *testing.T) {
// 没有声明视觉能力的源时整轮跳过,且**不能**把项标记成已处理——
// 配置好之后必须还能被捡起来。
a, ms := newMediaLoopAgent(t, true)
d, _ := ms.Put([]byte("img"), media.Item{MIME: "image/png"})
// providerManager 为 nil → resolveModalFallback 返回 nil
a.describePendingMedia()
it, err := ms.Stat(d)
g, err := memory.NewGraphDB(filepath.Join(dir, "graph.db"))
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, true)
other, _ := ms.Put([]byte("blob"), media.Item{MIME: "application/octet-stream"})
a.describePendingMedia()
it, err := ms.Stat(other)
if err != nil {
defer g.Close()
if err := g.PutMemoryBlocks([]memory.MemoryBlock{
{ID: "blk_g", Modality: memory.BlockImage, PayloadDigest: d3},
}); err != nil {
t.Fatal(err)
}
if it.DescribedBy != "unsupported" {
t.Fatalf("不可描述的大类应被标记,实际 DescribedBy=%q", it.DescribedBy)
a.memory = g
held := a.heldMediaDigests()
for _, want := range []string{d1, d2, d3} {
if !held[want] {
t.Errorf("层次持有 %s 却不在结果里: %v", shortDigest(want), held)
}
}
pending, _ := ms.Pending(10)
if len(pending) != 0 {
t.Fatalf("标记 unsupported 后应退出待描述队列,仍有 %d 条", len(pending))
if held[d4] {
t.Errorf("无人持有的 %s 不该出现在结果里", shortDigest(d4))
}
}
func TestDescribePendingMedia_EmptyQueueIsNoop(t *testing.T) {
a, _ := newMediaLoopAgent(t, true)
a.describePendingMedia() // 不该 panic
// payloadHeld 是删除前的活查询。
func TestPayloadHeld(t *testing.T) {
a, ms := newMediaLoopAgent(t)
d, _ := ms.Put([]byte("held"), media.Item{MIME: "image/png"})
if a.payloadHeld(d) {
t.Fatal("尚无块持有时不该报已持有")
}
a.context = NewRelevanceContext("", memory.NewStaticEmbedder(""))
a.context.Append(ContextEvent{Input: "x", Blocks: []memory.MemoryBlock{
{ID: "blk_1", Modality: memory.BlockImage, PayloadDigest: d},
}})
if !a.payloadHeld(d) {
t.Fatal("L0 持有却报未持有")
}
if a.payloadHeld("") {
t.Fatal("空 digest 应为 false")
}
}
// TestForgetPayloads_DeletesOnlyUnheldContent 验证删除语义:

View File

@ -59,21 +59,15 @@ func (a *Agent) blockFromDigest(digest string) (memory.MemoryBlock, bool) {
}
// 媒体记忆接线:把对话里出现的图片/音频落进内容寻址存储CAS
// 并让 L0 的 ContextEvent 记住它们的 digest
// 并让 L0 的 ContextEvent 直接持有一等记忆块
//
// 为何需要这一层:媒体进入对话有两条路,两条都只把**文字**留给记忆——
// 媒体进入对话有两条路用户直接发图ContentBlock data URL、插件注入
// SetToolBlocks。两条都在这里收口从 data URL 取出字节存进 CAS
// 用其向量构造一等记忆块挂到当轮 ContextEvent 上;事件被 Prune 时
// 块随之迁移到 L2 文档。
//
// 1. 用户直接发图 → processInput/resolveInput → 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 时引用随之转移。
// 不再生成任何描述文本,也不再往正文写 media marker图片只按自己的
// 统一空间向量被检索,描述式索引是将就方案。
// captureBlockMedia 把 blocks 里的 data URL 媒体落进 CAS返回 digest 列表。
//
@ -183,53 +177,20 @@ func (a *Agent) bindEventMedia(evt *ContextEvent, digests []string) {
}
}
// mediaSummaryForEvent 给已有描述的媒体生成一行文字,供写进 ContextEvent.Input
// mediaLabel 渲染一行媒体标签,供提示词告知"这条记忆带着哪份媒体"
//
// 这是方案 C 的落点:**描述文本才是持久语义记忆blob 只是缓存**。
// blob 可能已被删除,但描述会一直留在 L0/L2/L3 的文本里,
// 让"那张紫蓝红三色带图"在几个月后仍然可被检索到
func (a *Agent) mediaSummaryForEvent(blocks []memory.MemoryBlock) string {
if a.mediaStore == nil || len(blocks) == 0 {
return ""
}
var lines []string
for _, b := range blocks {
if line := a.mediaMarkerLine(b.PayloadDigest); line != "" {
lines = append(lines, line)
}
}
if len(lines) == 0 {
return ""
}
return "媒体内容:\n" + strings.Join(lines, "\n")
}
// mediaMarkerLine 为一份媒体生成一行标记文本 `[<mime> <短digest>] <描述>`。
//
// 这是媒体标记格式的唯一生成处。此前 mediaSummaryForEvent 与
// mediaContextForSentences 各拼一份,改动截断长度或分隔符时只改一处,
// 另一处写出的标记就再也解析不回来——而解析失败是静默的(引用挂不上)。
//
// 查不到返回空串:媒体可能已被删除,此时不该造出一条指向虚无的标记。
func (a *Agent) mediaMarkerLine(digest string) string {
if a.mediaStore == nil {
return ""
}
it, err := a.mediaStore.Stat(digest)
if err != nil || it == nil {
// 不再包含任何生成的描述文本:图片只按自己的向量被检索,标签仅提供
// MIME 与短 digest让模型知道有这份媒体、可据 digest 取回字节。
// 查不到返回空串:内容可能已被删除,不该造出一条指向虚无的标签
func mediaLabel(it *media.Item) string {
if it == nil {
return ""
}
label := string(it.Kind)
if it.MIME != "" {
label = it.MIME
}
desc := it.Description
if desc == "" {
// 「已入库但还没描述」与「压根没有媒体」必须可区分:描述由后台循环
// 异步补齐,占位符保证补齐前这份媒体也不会从文本里消失。
desc = "(未描述)"
}
return fmt.Sprintf("[%s %s] %s", label, shortDigest(digest), desc)
return fmt.Sprintf("[%s %s]", label, shortDigest(it.Digest))
}
// newEventID 生成 ContextEvent 的稳定标识。

View File

@ -106,8 +106,8 @@ func TestCaptureBlockMedia_NilStoreIsNoop(t *testing.T) {
if len(evt.Blocks) != 0 || evt.ID != "" {
t.Fatalf("nil store 时不该改动事件: %+v", evt)
}
if s := a.mediaSummaryForEvent(nil); s != "" {
t.Fatalf("nil store 时摘要应为空,得到 %q", s)
if s := mediaLabel(nil); s != "" {
t.Fatalf("nil 媒体应产出空标签,得到 %q", s)
}
}
@ -183,31 +183,27 @@ func TestBindEventMedia_LazyIDOnlyWhenNeeded(t *testing.T) {
}
}
func TestMediaSummary_DescriptionIsThePersistentMemory(t *testing.T) {
// 方案 C 的核心描述文本才是持久语义记忆blob 只是缓存
// blob 被容量 GC 淘汰后,描述仍留在 L0/L2/L3 的文本里可被检索
func TestMediaLabel_NoGeneratedDescription(t *testing.T) {
// 标签只用来告诉模型「这条记忆带着哪份媒体、可用该 digest 取回字节」
// 它不包含任何生成的描述:描述式索引是把就机制,已彻底废弃
a, ms := newTestAgentWithMedia(t)
d, _ := ms.Put([]byte("img"), media.Item{MIME: "image/png"})
b, ok := a.blockFromDigest(d)
if !ok {
t.Fatal("blockFromDigest 失败")
it, err := ms.Stat(d)
if err != nil {
t.Fatal(err)
}
if s := a.mediaSummaryForEvent([]memory.MemoryBlock{b}); s == "" {
t.Fatal("未描述项也应产出一行(标注未描述)")
}
ms.Describe(d, "一张紫蓝红三色带图", "visionllm")
s := a.mediaSummaryForEvent([]memory.MemoryBlock{b})
s := mediaLabel(it)
if s == "" {
t.Fatal("应产出摘要")
}
if !strings.Contains(s, "紫蓝红三色带图") {
t.Fatalf("摘要应含描述文本: %q", s)
t.Fatal("应产出标签")
}
if !strings.Contains(s, "image/png") {
t.Fatalf("摘要应含 MIME 标注: %q", s)
t.Fatalf("标签应含 MIME 标注: %q", s)
}
if !strings.Contains(s, shortDigest(d)) {
t.Fatalf("标签应含短 digest 供反查: %q", s)
}
_ = a
}
func TestPrune_NilMediaStoreStillArchives(t *testing.T) {

View File

@ -190,11 +190,16 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall) string {
Object: getString(m, "object"),
SentenceText: getString(m, "sentence_text"),
}
// 模型显式关联的媒体:标记由内核补进句子文本,模型不必知道格式。
// 没有 sentence_text 时 sentenceWithMediaMarkers 会用标记本身
// 充当句子——媒体必须有句子落点,否则块边无法建立
// 模型显式关联的媒体:结构化字段随三元组一起提交,
// 由 commitTriplesWithMedia 变成 L3 一等块并与句子建边——
// 不再把 marker 写进句子文本
if digests := getStringSlice(m, "media_digests"); len(digests) > 0 {
t.SentenceText = a.sentenceWithMediaMarkers(t.SentenceText, digests)
t.MediaDigests = a.resolveMediaDigests(digests)
// 块边需要句子作端点。模型没给原句时用三元组本身拼一句
// 自然语言——不能造一段 marker 文本,那正是被废弃的东西。
if t.SentenceText == "" && len(t.MediaDigests) > 0 {
t.SentenceText = fmt.Sprintf("%s%s%s。", t.Subject, t.Relation, t.Object)
}
}
if t.Subject != "" && t.Relation != "" && t.Object != "" {
triples = append(triples, t)
@ -531,10 +536,10 @@ func (a *Agent) executeDocTool(tc agentAPI.ToolCall) string {
if len(content) > 2000 {
content = content[:2000] + "..."
}
// 媒体说明单独一行进冷存事件:正文可能被上面的 2000 字截断,
// 而媒体标记往往在文档末尾——截掉之后模型就不知道这篇文档带过图。
if mc := a.docMediaContext(d.ID, d.Content); mc != "" {
content = content + "\n关联媒体: " + mc
// 媒体块标签单独一行进冷存事件:正文可能被上面的 2000 字截断,
// 截掉之后模型就不知道这篇文档带过图。
if labels := a.blockLabelsForDoc(d); labels != "" {
content = content + "\n关联媒体: " + labels
}
a.context.InsertByTimestamp(ContextEvent{
Timestamp: d.CreatedAt,
@ -572,11 +577,10 @@ func (a *Agent) executeDocTool(tc agentAPI.ToolCall) string {
Source: "manual",
}
// 模型显式关联的媒体:标记补进正文后再写入。顺序关键——向量索引用
// Summary+Content 计算,标记进不去正文就检索不到这份媒体。
mediaDigests := a.resolveMediaDigests(getStringSlice(tc.Arguments, "media_digests"))
doc.Content = a.sentenceWithMediaMarkers(doc.Content, mediaDigests)
for _, d := range mediaDigests {
// 模型显式关联的媒体:直接变成文档持有的一等块。
// 不再往正文写 marker——文档向量会融合这些块的媒体向量
// 图片按自己的向量被检索。
for _, d := range a.resolveMediaDigests(getStringSlice(tc.Arguments, "media_digests")) {
if b, ok := a.blockFromDigest(d); ok {
doc.Blocks = append(doc.Blocks, b)
}