Files
HomeAgent/internal/agent/core/medialive_test.go
JianFeeeee 387b28ce09 fix(memory): 媒体归档三缺陷——数据丢失、L3 入库不可靠、L3 检索未接线
自动触发链实测(medialive)连续暴露的三个缺陷,全部是「手工调 API 的
单测无法发现」的类型。附带该实测本身。

## 缺陷一:三元组全被拒时仍释放引用并删文档(数据丢失)

archiveColdDocs 只检查 len(triples) > 0 就释放媒体引用、删除文档。
但 Commit 会静默跳过实体名不合法的三元组(validEntityName 要求
2–50 字符),于是「无错但一条也没写进去」真实发生:

  [agent] doc→graph: doc_xxx → 0 entities, 0 relations
  [media] 文档 doc_xxx 入图库,释放 1 个媒体引用(描述已留在图库)
  被记忆引用的内容被 GC 删除了(清 1 条/318 字节)

图库里没有任何句子承载引用,文档也被删,blob 被 GC 回收 → 图片与描述
彻底消失。我在上一层写的注释「Commit 之后引用已挂到 graph_sentence」
是错的:ec=0 rc=0 时它什么也没挂。

修法(用户选定 B+A):
  B. ec==0 && rc==0 时保留文档、跳过归档——归档的实质是「信息从 L2
     搬到 L3」,搬不过去就不该删源,下轮再试。
  A. bindSentenceMedia 返回实际绑定数,commitTriplesWithMedia 透出为
     mediaBound;释放前四路判断(查引用出错→保守不释放/本无引用→无需
     释放/mediaBound==0→保留并记录原因/否则释放)。宁可留一条悬空
     引用(内容还在,可由后续一致性检查清理)也不能丢内容。

反向验证:旧行为下新测试确实 FAIL,报「引用被释放了」+「GC 删掉了本该
保留的内容」;恢复修复后 PASS。

## 缺陷二:媒体入 L3 依赖 NLP 提取器运气(可靠性)

媒体能否进图库,取决于提取器碰巧从描述文本里提出合规三元组。实测 LLM
的 477 字图片描述只产出「水平 -分割-> 成」,obj 仅 1 字被拒 → 整条媒体
记忆进不了图库。表现为「阶段 5 时好时坏」,取决于描述文本。

但媒体自身的 digest / mime / 描述都是确定的,不该受提取器支配。

新增 parseMediaMarkers + mediaTriplesFromText:从文档正文的媒体标记
直接产出确定三元组,先于 NLP 提取。同一份真实文档由 0 entities 0
relations 变为 ec=4 rc=2 且拿到句子 id。

三个设计点:
  - 实体名用「图片 <短digest>」而非描述:描述会被重新生成(换视觉模型、
    补描述),若名字取自描述,同一张图会在图谱上留下多个节点。digest
    不变则名字不变,长度也天然合规。
  - SentenceText 用原始标记段,保证 bindSentenceMedia 的正则必然能反解
    到 digest——绑定从概率事件变成确定行为。
  - 描述为空时仍产出「类型」三元组:描述是后台异步补的,媒体节点不该
    因为还没描述就不存在于图谱。
  - summarizeForEntity 按 rune 截断而非字节:按字节切会破坏 UTF-8,
    图库里会留下乱码实体名。同时清 Markdown 强调符。

这是过渡方案,用户已定:下个 feature 换多模态嵌入后不再依赖
「描述文本 → 提取三元组 → 图谱节点」这条链路。

## 缺陷三:L3 媒体检索没有任何调用方(接线缺失)

第四层实现的 RecallMediaForSentence / mediaContextForSentences 从未被
调用——媒体能存进 L3、能反查,但 agent 拿不出来。实测第二轮 agent 显式
调了 doc_query,回答「没有找到那张图片的任何记录」。

接两个入口:
  - buildMemoryContext(自动注入,每次 LLM 调用都走)
  - memory_recall 工具结果末尾(显式查询)

关系行只有实体名和关系类型,看不出「这条记忆当时还带了一张图」,
媒体挂在句子上,必须经 关系→句子→media_refs 反查。

一处折返:最初直接用 injected.Relations 取 sentence_id,测试失败。
Indexer.BuildContext 刻意把 Relations 置 nil(自动注入只给实体索引以省
token,细节留给 memory_recall)。改为用命中的实体名再查一次关系,
深度固定 1——媒体是「这条记忆当时带的图」,顺关系网扩散只会带出无关
媒体并挤占 token。

## medialive 自动触发链实测

internal/agent/core/medialive_test.go,medialive build tag,默认
go test 不收录。源/模型/密钥全部由调用方经环境变量显式指定,缺任何一项
Skip 并列出缺哪个——刻意不提供 fallback,猜一个 base_url 可能打到调用者
机器上不相干的服务,而失败会被误报成「媒体记忆有问题」。

  MEDIALIVE_BASE_URL=... MEDIALIVE_API_KEY=... \
  MEDIALIVE_MODEL=... MEDIALIVE_ADAPTER=... \
  go test -tags medialive ./internal/agent/core/ -run TestMediaLive -v

只注入一个 image 事件,之后七个阶段全由生产代码自己触发:CAS 落盘 →
引用绑定 → 描述生成 → L0→L2 转移 → L2→L3 绑定 → GC 保护 → 第二轮召回。
另有阴性对照:不给记忆时不该「记得」,否则阳性用例的通过可能只是模型
猜常见配色。上游不可用时 Skip 而非假 PASS。

真实 claude-opus-5 实测通过:第二轮不给图,agent 答出
「上:紫罗兰色 #8800DD / 中:蓝色 #0055EE / 下:纯红 #EE0000」。

## 测试

graphmedia_test.go 新增 8 例:数据丢失回归(反向验证过)、mediaBound
计数、媒体标记解析、实体名生成、描述截断、确定性三元组必然可入库、
关系→句子映射、L3 检索接线(自动注入与显式查询两路)。

全仓 go build / go vet / go test 通过,internal/agent/core 与
internal/memory 全绿,SDK 冻结 diff = 0。
2026-09-05 12:03:30 +08:00

571 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//go:build medialive
// 媒体记忆自动触发链的集成测试。
//
// 与其他媒体测试的区别:**不手工调用任何一步**。这里只做两件事——
// 往 IOManager 注入一个 image 事件,然后等。之后全部由生产代码自己走:
//
// processMediaInput → captureBlockMedia入 CAS
// → Prune → transferMediaRefsL0→L2 引用转移)
// → describePendingMedia真实视觉模型生成描述
// → archiveColdDocs → commitTriplesWithMedia → bindSentenceMediaL2→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 而不启 eventLoopeventLoop 是纯转发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))
}
// ── 阶段 4Prune 自动把引用从 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)
}
// ── 阶段 5archiveColdDocs 自动把媒体带进 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))
}
}
// ── 阶段 6GC 不能删掉仍被记忆引用的内容 ──
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)
// ── 阶段 7E2E — 第二轮提问,验证 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 前缀的 base64processMediaInput 自己拼前缀)。
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]) + "…"
}