mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-21 17:38:10 +00:00
自动触发链实测(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。
571 lines
20 KiB
Go
571 lines
20 KiB
Go
//go:build medialive
|
||
|
||
// 媒体记忆自动触发链的集成测试。
|
||
//
|
||
// 与其他媒体测试的区别:**不手工调用任何一步**。这里只做两件事——
|
||
// 往 IOManager 注入一个 image 事件,然后等。之后全部由生产代码自己走:
|
||
//
|
||
// processMediaInput → captureBlockMedia(入 CAS)
|
||
// → Prune → transferMediaRefs(L0→L2 引用转移)
|
||
// → describePendingMedia(真实视觉模型生成描述)
|
||
// → archiveColdDocs → commitTriplesWithMedia → bindSentenceMedia(L2→L3)
|
||
// → 第二轮提问,验证 agent 真能召回
|
||
//
|
||
// 为什么必须这样测:单测能证明每个函数正确,却证明不了它**被接上了**。
|
||
// 本文件的直接动机是一个真实缺陷——core.New() 漏了 rc.SetMediaStore(cfg.MediaStore),
|
||
// 于是 L0→L2 引用转移在生产里永远静默 return,而手工注入 store 的单测全绿。
|
||
//
|
||
// 需要真实 LLM,因此加 medialive build tag,默认 go test 不跑:
|
||
//
|
||
// MEDIALIVE_BASE_URL=http://127.0.0.1:8081/v1 \
|
||
// MEDIALIVE_API_KEY=sk-xxx \
|
||
// MEDIALIVE_MODEL=claude-opus-5 \
|
||
// MEDIALIVE_ADAPTER=openai \
|
||
// go test -tags medialive ./internal/agent/core/ -run TestMediaLive -v -timeout 20m
|
||
//
|
||
// 源、模型、密钥全部由调用方显式指定,测试自己不猜任何默认值——
|
||
// 猜一个默认端点会让测试在别人机器上打到意料之外的服务。
|
||
package core
|
||
|
||
import (
|
||
"bytes"
|
||
"compress/zlib"
|
||
"encoding/binary"
|
||
"fmt"
|
||
"hash/crc32"
|
||
"os"
|
||
"path/filepath"
|
||
"strconv"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||
luaVM "gitcode.com/JianFeeeee/HomeAgent/internal/lua"
|
||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
|
||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
|
||
"gitcode.com/JianFeeeee/HomeAgent/pkg/types"
|
||
)
|
||
|
||
// liveCfg 是调用方通过环境变量显式提供的 LLM 源配置。
|
||
type liveCfg struct {
|
||
baseURL string
|
||
apiKey string
|
||
model string
|
||
adapter string
|
||
}
|
||
|
||
// requireLiveCfg 读取环境变量;缺任何一项就 Skip 而非猜默认值。
|
||
//
|
||
// 刻意不提供 fallback:一个猜出来的 base_url 可能打到调用者机器上
|
||
// 完全不相干的服务,而测试会把那次调用的失败报成"媒体记忆有问题"。
|
||
func requireLiveCfg(t *testing.T) liveCfg {
|
||
t.Helper()
|
||
c := liveCfg{
|
||
baseURL: os.Getenv("MEDIALIVE_BASE_URL"),
|
||
apiKey: os.Getenv("MEDIALIVE_API_KEY"),
|
||
model: os.Getenv("MEDIALIVE_MODEL"),
|
||
adapter: os.Getenv("MEDIALIVE_ADAPTER"),
|
||
}
|
||
var missing []string
|
||
if c.baseURL == "" {
|
||
missing = append(missing, "MEDIALIVE_BASE_URL")
|
||
}
|
||
if c.apiKey == "" {
|
||
missing = append(missing, "MEDIALIVE_API_KEY")
|
||
}
|
||
if c.model == "" {
|
||
missing = append(missing, "MEDIALIVE_MODEL")
|
||
}
|
||
if c.adapter == "" {
|
||
missing = append(missing, "MEDIALIVE_ADAPTER")
|
||
}
|
||
if len(missing) > 0 {
|
||
t.Skipf("缺少环境变量 %s——本测试要求调用方显式指定源/模型/密钥,不使用任何默认值",
|
||
strings.Join(missing, ", "))
|
||
}
|
||
return c
|
||
}
|
||
|
||
// livePNG 造一张横向三色带真 PNG(手工拼 IHDR/IDAT/IEND)。
|
||
//
|
||
// 用可辨认的纯色而非随机字节:断言要能检查"模型是否真的看到了内容",
|
||
// 随机噪声无法产生可验证的描述。
|
||
func livePNG(t *testing.T, w, h int, colors [][3]byte) []byte {
|
||
t.Helper()
|
||
chunk := func(typ string, data []byte) []byte {
|
||
var b bytes.Buffer
|
||
if err := binary.Write(&b, binary.BigEndian, uint32(len(data))); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
body := append([]byte(typ), data...)
|
||
b.Write(body)
|
||
if err := binary.Write(&b, binary.BigEndian, crc32.ChecksumIEEE(body)); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return b.Bytes()
|
||
}
|
||
var raw bytes.Buffer
|
||
for y := 0; y < h; y++ {
|
||
raw.WriteByte(0) // filter type: none
|
||
c := colors[y*len(colors)/h]
|
||
for x := 0; x < w; x++ {
|
||
raw.Write(c[:])
|
||
}
|
||
}
|
||
var comp bytes.Buffer
|
||
zw := zlib.NewWriter(&comp)
|
||
if _, err := zw.Write(raw.Bytes()); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
zw.Close()
|
||
|
||
var ihdr bytes.Buffer
|
||
binary.Write(&ihdr, binary.BigEndian, uint32(w))
|
||
binary.Write(&ihdr, binary.BigEndian, uint32(h))
|
||
ihdr.Write([]byte{8, 2, 0, 0, 0}) // 8-bit truecolor
|
||
|
||
var out bytes.Buffer
|
||
out.Write([]byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'})
|
||
out.Write(chunk("IHDR", ihdr.Bytes()))
|
||
out.Write(chunk("IDAT", comp.Bytes()))
|
||
out.Write(chunk("IEND", nil))
|
||
return out.Bytes()
|
||
}
|
||
|
||
// liveEnv 是一套完整但完全独立的 agent 运行环境。
|
||
type liveEnv struct {
|
||
agent *Agent
|
||
io *agentIO.IOManager
|
||
mediaSt *media.Store
|
||
docStore *document.Store
|
||
graph *memory.GraphDB
|
||
dir string
|
||
}
|
||
|
||
// newLiveEnv 构造真 Agent:真 provider、真 CAS、真图库、真文档库。
|
||
//
|
||
// 不注册任何插件:本测试关心记忆链路,插件会引入无关的外部副作用
|
||
// (网络轮询、写文件),而且生产插件目录里的进程不该被测试碰到。
|
||
func newLiveEnv(t *testing.T, c liveCfg) *liveEnv {
|
||
t.Helper()
|
||
dir := t.TempDir()
|
||
|
||
vm := luaVM.NewVM(filepath.Join(dir, "adapters"))
|
||
if err := vm.Start(); err != nil {
|
||
t.Fatalf("lua vm: %v", err)
|
||
}
|
||
t.Cleanup(vm.Stop)
|
||
|
||
// Vision: true —— 能力是声明的,不是探测的。网关可能静默剥离
|
||
// image_url 后仍返回 200,从响应无法推断它到底看见了没有。
|
||
prov := agentAPI.NewLuaAdaptedProvider(agentAPI.BaseConfig{
|
||
Model: c.model, BaseURL: c.baseURL, APIKey: c.apiKey,
|
||
MaxTokens: 1200, Temperature: 0.3, Vision: true,
|
||
}, vm, "medialive", c.adapter)
|
||
|
||
pm := agentAPI.NewProviderManager()
|
||
pm.Register("medialive", prov)
|
||
if err := pm.SetDefault("medialive"); err != nil {
|
||
t.Fatalf("set default provider: %v", err)
|
||
}
|
||
|
||
ms, err := media.New(filepath.Join(dir, "media"), 256<<20)
|
||
if err != nil {
|
||
t.Fatalf("media store: %v", err)
|
||
}
|
||
t.Cleanup(func() { ms.Close() })
|
||
|
||
graph, err := memory.NewGraphDB(filepath.Join(dir, "graph.db"))
|
||
if err != nil {
|
||
t.Fatalf("graph: %v", err)
|
||
}
|
||
t.Cleanup(func() { graph.Close() })
|
||
|
||
docStore := document.NewStore(filepath.Join(dir, "docs"))
|
||
if err := docStore.Start(); err != nil {
|
||
t.Fatalf("doc store: %v", err)
|
||
}
|
||
t.Cleanup(docStore.Stop)
|
||
|
||
io := agentIO.NewIOManager()
|
||
|
||
a := New(AgentConfig{
|
||
ID: types.AgentID("medialive"),
|
||
SystemPrompt: "你是一个有长期记忆的助手。回答简洁准确。",
|
||
Provider: prov,
|
||
ProviderManager: pm,
|
||
IO: io,
|
||
Memory: graph,
|
||
DocStore: docStore,
|
||
MediaStore: ms,
|
||
MediaGCInterval: 0, // 本测试自己控制 GC 时机
|
||
MediaDescribe: true, // 描述循环由测试直接调 describePendingMedia
|
||
StageHost: NewStageHost(),
|
||
MaxContextSize: 3, // 故意压低:第二轮就能触发 Prune 归档
|
||
InputProcessing: types.InputProcessingConfig{},
|
||
})
|
||
|
||
// 排空 outputCh:容量 256,但长跑不消费会堵住 emitResponse。
|
||
go func() {
|
||
for {
|
||
select {
|
||
case <-io.OutputChan():
|
||
case <-a.ctx.Done():
|
||
return
|
||
}
|
||
}
|
||
}()
|
||
|
||
return &liveEnv{agent: a, io: io, mediaSt: ms, docStore: docStore, graph: graph, dir: dir}
|
||
}
|
||
|
||
// TestMediaLive_AutoTriggerChain 全自动触发链:只注入事件,不手工调任何一步。
|
||
func TestMediaLive_AutoTriggerChain(t *testing.T) {
|
||
c := requireLiveCfg(t)
|
||
env := newLiveEnv(t, c)
|
||
a := env.agent
|
||
defer a.Stop()
|
||
|
||
img := livePNG(t, 96, 96, [][3]byte{{128, 0, 255}, {0, 64, 255}, {255, 0, 0}})
|
||
t.Logf("测试图片: %d 字节(紫/蓝/红三色带)", len(img))
|
||
|
||
// ── 阶段 1:注入 image 事件,验证 CAS 自动落盘 ──
|
||
//
|
||
// 直接调 handleInput 而不启 eventLoop:eventLoop 是纯转发(select →
|
||
// handleInput),走同一条代码路径,但同步调用让断言不必猜时序。
|
||
evt := &agentIO.InputEvent{
|
||
RequestID: "live-1",
|
||
Source: "test_channel",
|
||
Type: "image",
|
||
OutputChannel: "test_channel",
|
||
Payload: map[string]interface{}{
|
||
"data": mediaB64(img),
|
||
"mime": "image/png",
|
||
"alt": "一张测试图片",
|
||
},
|
||
}
|
||
|
||
t0 := time.Now()
|
||
a.handleInput(evt)
|
||
t.Logf("第一轮(含真实 LLM 往返)耗时 %.1fs", time.Since(t0).Seconds())
|
||
|
||
// 用 Pending 而非 Search 查刚落盘的项:Search 的 WHERE 里带
|
||
// `COALESCE(description,'') != ''`,只返回**已描述**的媒体,
|
||
// 此刻描述还没生成(阶段3 才做),Search 必然返回 0 条。
|
||
items, err := env.mediaSt.Pending(10)
|
||
if err != nil {
|
||
t.Fatalf("pending: %v", err)
|
||
}
|
||
if len(items) != 1 {
|
||
t.Fatalf("CAS 应自动收到 1 张图,实际 %d 张(captureBlockMedia 未被触发?)", len(items))
|
||
}
|
||
digest := items[0].Digest
|
||
t.Logf("✓ 阶段1 CAS 自动落盘: digest=%s size=%d tool=%s",
|
||
digest[:12], items[0].Size, items[0].Tool)
|
||
|
||
stored, err := env.mediaSt.Get(digest)
|
||
if err != nil || !bytes.Equal(stored, img) {
|
||
t.Fatalf("落盘内容与原图不一致 (err=%v)", err)
|
||
}
|
||
|
||
// ── 阶段 2:引用自动挂到 ContextEvent 上 ──
|
||
//
|
||
// 这一步验证 bindEventMedia:事件必须拿到 ID 且 media_refs 里
|
||
// 有对应 context owner 记录。两者只写一个的后果是 GC 误删或永不清理。
|
||
var evtID string
|
||
var summaryOK bool
|
||
for _, e := range a.context.Recent(0) {
|
||
if len(e.Media) > 0 {
|
||
evtID = e.ID
|
||
summaryOK = strings.Contains(e.Input, digest[:12])
|
||
break
|
||
}
|
||
}
|
||
if evtID == "" {
|
||
t.Fatal("没有任何 ContextEvent 挂上媒体(bindEventMedia 未被触发)")
|
||
}
|
||
ctxRefs, err := env.mediaSt.Refs(media.OwnerContext, evtID)
|
||
if err != nil || len(ctxRefs) != 1 || ctxRefs[0] != digest {
|
||
t.Fatalf("context owner 引用缺失: refs=%v err=%v", ctxRefs, err)
|
||
}
|
||
if !summaryOK {
|
||
t.Error("事件 Input 里没有媒体摘要标记(mediaSummaryForEvent 未生效)——" +
|
||
"L2/L3 靠正文里的短 digest 反查,缺了它整条召回链断掉")
|
||
}
|
||
t.Logf("✓ 阶段2 引用自动绑定: event=%s owner=context 摘要内嵌=%v", evtID, summaryOK)
|
||
|
||
// ── 阶段 3:描述由后台循环自动生成(真实视觉模型)──
|
||
pending, err := env.mediaSt.Pending(5)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(pending) != 1 {
|
||
t.Fatalf("应有 1 条待描述,实际 %d 条", len(pending))
|
||
}
|
||
|
||
t1 := time.Now()
|
||
a.describePendingMedia()
|
||
t.Logf("描述生成耗时 %.1fs", time.Since(t1).Seconds())
|
||
|
||
it, err := env.mediaSt.Stat(digest)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if it.Description == "" {
|
||
t.Fatal("描述为空——describePendingMedia 未能通过视觉源生成描述")
|
||
}
|
||
sawColors := strings.Contains(it.Description, "紫") &&
|
||
strings.Contains(it.Description, "蓝") &&
|
||
strings.Contains(it.Description, "红")
|
||
t.Logf("✓ 阶段3 描述自动生成 (%d 字, 源=%s): %s",
|
||
len([]rune(it.Description)), it.DescribedBy, truncRunes(it.Description, 90))
|
||
if !sawColors {
|
||
t.Errorf("描述未含紫/蓝/红三色,视觉模型可能没真正看到图片: %s",
|
||
truncRunes(it.Description, 200))
|
||
}
|
||
if left, _ := env.mediaSt.Pending(5); len(left) != 0 {
|
||
t.Errorf("描述完成后仍在待描述队列(%d 条)——会被反复重描述", len(left))
|
||
}
|
||
// 有描述之后 Search 才应能命中(它按 description 做 LIKE)
|
||
if found, err := env.mediaSt.Search("紫", media.KindImage, 5); err != nil {
|
||
t.Errorf("search: %v", err)
|
||
} else if len(found) == 0 {
|
||
t.Error("描述已生成但 Search(\"紫\") 命中 0 条——媒体库关键词入口失效")
|
||
} else {
|
||
t.Logf("✓ 阶段3 Search(\"紫\") 命中 %d 条", len(found))
|
||
}
|
||
|
||
// ── 阶段 4:Prune 自动把引用从 L0 转移到 L2 ──
|
||
//
|
||
// MaxContextSize=3,多注入几轮文本把带图事件挤出活跃上下文。
|
||
// 这一步专门守 core.New() 里 rc.SetMediaStore 的接线:漏了它
|
||
// transferMediaRefs 直接 return,引用永久悬空在 context owner 上。
|
||
// 填充数量必须 > Prune 内部固定的 10 条保护窗口。
|
||
//
|
||
// Prune 无条件保护最后 10 条事件(protected := events[len-10:]),
|
||
// 只在更早的部分里挑归档对象。填 4 条时总数才 5,全落进保护窗口、
|
||
// candidates 为空、直接返回 0——这不是缺陷,是"最近的对话不该被归档"
|
||
// 的设计。带图事件必须被推到第 11 条之前才可能被归档。
|
||
const fillerCount = 14
|
||
for i := 0; i < fillerCount; i++ {
|
||
a.context.Append(ContextEvent{
|
||
Timestamp: time.Now(),
|
||
Source: "filler",
|
||
Input: fmt.Sprintf("无关的填充对话 %d,用来把带图事件挤出活跃窗口", i),
|
||
Response: "好的。",
|
||
})
|
||
}
|
||
archived := a.context.Prune("当前输入", a.maxContextSize-1, env.docStore)
|
||
t.Logf("Prune 归档 %d 条事件", archived)
|
||
if archived == 0 {
|
||
t.Fatal("Prune 未归档任何事件,无法验证引用转移")
|
||
}
|
||
|
||
docRefsFound := ""
|
||
for _, d := range env.docStore.RecentDocs(20) {
|
||
refs, err := env.mediaSt.Refs(media.OwnerDocument, d.ID)
|
||
if err == nil && len(refs) > 0 && refs[0] == digest {
|
||
docRefsFound = d.ID
|
||
break
|
||
}
|
||
}
|
||
if docRefsFound == "" {
|
||
t.Fatal("引用未转移到 document owner——" +
|
||
"core.New() 是否漏了 rc.SetMediaStore(cfg.MediaStore)?" +
|
||
"(该缺陷曾真实存在:手工注入 store 的单测全绿,生产里永远静默 return)")
|
||
}
|
||
if left, _ := env.mediaSt.Refs(media.OwnerContext, evtID); len(left) != 0 {
|
||
t.Errorf("旧的 context 引用未注销(%d 条),引用计数永不归零 → blob 永不回收", len(left))
|
||
}
|
||
t.Logf("✓ 阶段4 引用自动转移: context/%s → document/%s", evtID, docRefsFound)
|
||
|
||
// 转移全程内容必须可读:先挂后销的顺序若反了,
|
||
// 计数会瞬时归零,并发 GC 会把仍被引用的内容当孤儿删掉。
|
||
if _, err := env.mediaSt.Get(digest); err != nil {
|
||
t.Fatalf("转移后内容不可读: %v", err)
|
||
}
|
||
|
||
// ── 阶段 5:archiveColdDocs 自动把媒体带进 L3 图库 ──
|
||
//
|
||
// FindColdDocs(72h, 2) 要求文档足够"冷",测试里新建的文档不满足,
|
||
// 因此把 LastAccess 往前推——这是为了触发生产代码路径,
|
||
// 而不是替代它(Commit/bindSentenceMedia/releaseDocMedia 全部由它自己调)。
|
||
for _, d := range env.docStore.RecentDocs(20) {
|
||
if d.ID == docRefsFound {
|
||
d.LastAccess = time.Now().Add(-100 * time.Hour)
|
||
d.AccessCount = 0
|
||
}
|
||
}
|
||
a.archiveColdDocs()
|
||
|
||
sentRefs := 0
|
||
var boundSentence int64
|
||
rows, err := env.graph.Recall(nil, nil, 1, "")
|
||
if err != nil {
|
||
t.Fatalf("graph recall: %v", err)
|
||
}
|
||
t.Logf("图库实体数 %d", len(rows.Entities))
|
||
// 句子 id 是自增整数,扫前若干个足够覆盖本测试写入的量
|
||
for sid := int64(1); sid <= 40; sid++ {
|
||
refs, err := env.mediaSt.Refs(media.OwnerGraphSentence, strconv.FormatInt(sid, 10))
|
||
if err == nil && len(refs) > 0 {
|
||
sentRefs += len(refs)
|
||
if boundSentence == 0 {
|
||
boundSentence = sid
|
||
}
|
||
}
|
||
}
|
||
if sentRefs == 0 {
|
||
t.Error("L2→L3 未绑定任何 graph_sentence 引用——" +
|
||
"bindSentenceMedia 未被 commitTriplesWithMedia 触发," +
|
||
"或句子正文里没有可反解的短 digest")
|
||
} else {
|
||
t.Logf("✓ 阶段5 L3 自动绑定: %d 个句子引用,首个 sentences.id=%d", sentRefs, boundSentence)
|
||
|
||
got, err := env.agent.RecallMediaForSentence(boundSentence)
|
||
if err != nil || len(got) == 0 || got[0] != digest {
|
||
t.Errorf("从句子反查 digest 失败: got=%v err=%v", got, err)
|
||
} else if raw, err := env.mediaSt.Get(got[0]); err != nil || !bytes.Equal(raw, img) {
|
||
t.Errorf("从句子取回的字节与原图不一致 (err=%v)", err)
|
||
} else {
|
||
t.Logf("✓ 阶段5 反查取回 %d 字节,与原图逐字节一致", len(raw))
|
||
}
|
||
}
|
||
|
||
// ── 阶段 6:GC 不能删掉仍被记忆引用的内容 ──
|
||
removed, freed, err := env.mediaSt.GC(0) // minAge=0,最激进
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, err := env.mediaSt.Stat(digest); err != nil {
|
||
t.Fatalf("被记忆引用的内容被 GC 删除了(清 %d 条/%d 字节)——"+
|
||
"引用计数或 owner 语义有误", removed, freed)
|
||
}
|
||
t.Logf("✓ 阶段6 GC(minAge=0) 清 %d 条,被引用内容仍在", removed)
|
||
|
||
// ── 阶段 7:E2E — 第二轮提问,验证 agent 真能召回 ──
|
||
//
|
||
// 不再提供图片,只问"还记得吗"。能答出三色说明记忆链路端到端可用。
|
||
// L2 文档此刻已被 archiveColdDocs 删除(归档的语义就是搬完删源),
|
||
// 所以这一轮只能靠 L3 图库召回——而自动注入路径依赖 indexer。
|
||
// 生产由 main.go 注入并周期 Sync;测试里手工建一个并同步一次。
|
||
a.indexer = memory.NewIndexer(env.graph)
|
||
if err := a.indexer.Sync(); err != nil {
|
||
t.Fatalf("indexer sync: %v", err)
|
||
}
|
||
if mc := a.buildMemoryContext("图片 颜色", 0); mc != "" {
|
||
t.Logf("注入的记忆上下文: %s", truncRunes(mc, 200))
|
||
if strings.Contains(mc, "【关联媒体】") {
|
||
t.Logf("✓ 记忆上下文含媒体段")
|
||
} else {
|
||
t.Error("记忆上下文缺少媒体段——L3 媒体检索接线未生效")
|
||
}
|
||
} else {
|
||
t.Error("图库召回为空,agent 无从得知历史媒体")
|
||
}
|
||
|
||
ask := &agentIO.InputEvent{
|
||
RequestID: "live-2",
|
||
Source: "test_channel",
|
||
Type: "text",
|
||
OutputChannel: "test_channel",
|
||
Payload: map[string]interface{}{
|
||
"content": "你还记得我之前发给你的那张图片吗?它是什么样子的?请说出具体颜色。",
|
||
},
|
||
}
|
||
respCh := make(chan *agentIO.OutputEvent, 4)
|
||
ask.ResponseCh = respCh
|
||
|
||
t2 := time.Now()
|
||
a.handleInput(ask)
|
||
t.Logf("第二轮耗时 %.1fs", time.Since(t2).Seconds())
|
||
|
||
var answer string
|
||
select {
|
||
case out := <-respCh:
|
||
answer, _ = out.Payload["content"].(string)
|
||
case <-time.After(5 * time.Second):
|
||
t.Fatal("第二轮没有收到回复")
|
||
}
|
||
t.Logf("agent 回答: %s", truncRunes(answer, 220))
|
||
|
||
recalled := strings.Contains(answer, "紫") &&
|
||
strings.Contains(answer, "蓝") &&
|
||
strings.Contains(answer, "红")
|
||
if !recalled {
|
||
t.Errorf("agent 未能召回三色。这可能是记忆注入链路问题,"+
|
||
"也可能是本轮上下文里已无相关记忆(描述在 L2/L3 但未被检索命中)。回答: %s",
|
||
truncRunes(answer, 300))
|
||
} else {
|
||
t.Logf("✓ 阶段7 E2E 召回成功:不给图,agent 答出紫/蓝/红")
|
||
}
|
||
|
||
st := env.mediaSt.Stats()
|
||
t.Logf("收尾: %v 条 / %v 字节 / 已描述 %v / 无引用 %v",
|
||
st["count"], st["total_bytes"], st["described"], st["unreferenced"])
|
||
}
|
||
|
||
// TestMediaLive_NegativeControl 阴性对照:没有媒体记忆时不该"记得"。
|
||
//
|
||
// 没有这条对照,阶段7 的"答出紫蓝红"可能只是模型在猜常见配色,
|
||
// 无法区分真召回与先验偏好。
|
||
func TestMediaLive_NegativeControl(t *testing.T) {
|
||
c := requireLiveCfg(t)
|
||
env := newLiveEnv(t, c)
|
||
a := env.agent
|
||
defer a.Stop()
|
||
|
||
ask := &agentIO.InputEvent{
|
||
RequestID: "neg-1",
|
||
Source: "test_channel",
|
||
Type: "text",
|
||
OutputChannel: "test_channel",
|
||
Payload: map[string]interface{}{
|
||
"content": "你还记得我之前发给你的那张图片吗?它是什么样子的?请说出具体颜色。",
|
||
},
|
||
}
|
||
respCh := make(chan *agentIO.OutputEvent, 4)
|
||
ask.ResponseCh = respCh
|
||
|
||
a.handleInput(ask)
|
||
|
||
var answer string
|
||
select {
|
||
case out := <-respCh:
|
||
answer, _ = out.Payload["content"].(string)
|
||
case <-time.After(5 * time.Second):
|
||
t.Fatal("阴性对照没有收到回复")
|
||
}
|
||
t.Logf("无记忆时的回答: %s", truncRunes(answer, 200))
|
||
|
||
// 上游不可用时这条对照没有意义:它只能证明"没答出颜色",
|
||
// 而原因是调用失败而非缺少记忆。据此判 PASS 属于假阳性。
|
||
if strings.HasPrefix(answer, "处理错误:") {
|
||
t.Skipf("上游 LLM 调用失败,阴性对照无法判定: %s", truncRunes(answer, 160))
|
||
}
|
||
|
||
guessed := strings.Contains(answer, "紫") &&
|
||
strings.Contains(answer, "蓝") &&
|
||
strings.Contains(answer, "红")
|
||
if guessed {
|
||
t.Errorf("无任何媒体记忆却猜中紫/蓝/红——"+
|
||
"说明阳性用例的通过可能只是先验偏好而非真召回: %s", truncRunes(answer, 300))
|
||
}
|
||
}
|
||
|
||
// mediaB64 返回不带 data URL 前缀的 base64(processMediaInput 自己拼前缀)。
|
||
func mediaB64(b []byte) string {
|
||
return media.DataURL("image/png", b)[len("data:image/png;base64,"):]
|
||
}
|
||
|
||
func truncRunes(s string, n int) string {
|
||
r := []rune(strings.ReplaceAll(s, "\n", " "))
|
||
if len(r) <= n {
|
||
return string(r)
|
||
}
|
||
return string(r[:n]) + "…"
|
||
}
|