Files
HomeAgent/internal/agent/core/medialive_test.go
JianFeeeee 33e1a0bfaf test(memory)!: 块 union 化判据重写(CAS 淘汰第二阶段:测试)
生产已在上一提交(86572d13)全绿;本提交把 CAS 专属测试改到块语义,
并修掉几处判据自身的缺陷(判据错 ≠ 生产错):

- sdk/memory_impl_test: media.Store→块;NewGraphMemoryWithPlugin
- core/{graphmedia,mediaref,medialive,medialoop,inputunify}_test:
  mediaStore→memory;PayloadDigest→Data/BlockData;shortDigest→shortID
- graphmedia: 无引用句子用独立块 ID(shortID 20 字符会与 blk_src_ 撞长度)
- memory/sentence_block: BlockByIDPrefix 加 len<12 下限(短引用守卫)
- core/medialoop: reembedOneBlock 按 BlockData 兜底取字节(lean 列无 data)
- knowledge: broken.png 改目录造读失败(chmod 0000 对 root 无效);
  dense/attach 测试补 Data 并先落盘后 AttachMedia
- webui: graph_pulse fixture payload_digest→has_data

注:internal/plugin/proc 的 shmVersion 不匹配(内核1/插件2)是
SDK 仓(777c3a0,v2 模板)与本分支内核(v1)的跨仓版本错位,
与块 union 无关(stash 全部改动后仍复现),属 proc-extra 特性线。
2026-10-12 14:26:13 +08:00

517 lines
18 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
// 媒体记忆自动触发链的集成测试(块 union 化之后)。
//
// 与其他媒体测试的区别:**不手工调用任何一步**。这里只做两件事——
// 往 IOManager 注入一个 image 事件,然后等。之后全部由生产代码自己走:
//
// processMediaInput → captureBlockMedia(字节直接进块)
// → Prune(L0→L2 块迁移)
// → archiveColdDocs → commitTriplesWithMedia → bindSentenceBlocks(L2→L3)
// → 第二轮提问,验证 agent 真能召回
//
// 为什么必须这样测:单测能证明每个函数正确,却证明不了它**被接上了**——
// 手工注入 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/base64"
"encoding/binary"
"fmt"
"hash/crc32"
"os"
"path/filepath"
"strings"
"testing"
"time"
agentAPI "github.com/JianFeeeee/HomeAgent/internal/agent/api"
agentIO "github.com/JianFeeeee/HomeAgent/internal/agent/io"
luaVM "github.com/JianFeeeee/HomeAgent/internal/lua"
"github.com/JianFeeeee/HomeAgent/internal/memory"
"github.com/JianFeeeee/HomeAgent/internal/memory/document"
"github.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
docStore *document.Store
graph *memory.GraphDB
dir string
}
// newLiveEnv 构造真 Agent:真 provider、真图库、真文档库。
//
// 不注册任何插件:本测试关心记忆链路,插件会引入无关的外部副作用
// (网络轮询、写文件),而且生产插件目录里的进程不该被测试碰到。
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)
}
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"), memory.TokenizeWords)
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,
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, 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 事件,验证块自动捕获 ──
//
// 直接调 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())
// 媒体不再有文字描述:字节直接在块的 Data 里,身份内容派生。
// 这里直接按块字节定位刚捕获的图(不再有 Pending 队列)。
var blockID string
var found bool
for _, e := range a.context.Recent(0) {
for _, b := range e.Blocks {
if bytes.Equal(b.Data, img) {
blockID, found = b.ID, true
}
}
}
if !found {
t.Fatal("无法从上下文块定位刚捕获的图(captureBlockMedia 未被触发?)")
}
t.Logf("✓ 阶段1 块自动捕获: id=%s", shortID(blockID))
// ── 阶段 2:一等记忆块自动挂到 ContextEvent 上 ──
//
// 这一步验证 bindEventMedia:事件必须拿到 ID 并直接持有块;
// 事件文本必须保持原样(不再往正文里贴媒体标记)。
var evtID string
for _, e := range a.context.Recent(0) {
if len(e.Blocks) > 0 {
evtID = e.ID
if strings.Contains(e.Input, shortID(blockID)) {
t.Error("事件 Input 里被写入了媒体标记——描述式索引链应该已经拆除")
}
if !bytes.Equal(e.Blocks[0].Data, img) {
t.Fatalf("事件持有的块字节不对: %+v", e.Blocks[0])
}
break
}
}
if evtID == "" {
t.Fatal("没有任何 ContextEvent 挂上媒体(bindEventMedia 未被触发)")
}
t.Logf("✓ 阶段2 块自动绑定: event=%s", evtID)
// ── 阶段 3:媒体只按自己的向量被索引,不再生成任何描述 ──
for _, e := range a.context.Recent(0) {
for _, b := range e.Blocks {
if b.ID == blockID {
if len(b.Vector) == 0 {
// 未配置多模态空间时就没有向量——这是合法的降级状态,
// 但要明确报出来,而不是靠描述文本假装能检索。
t.Log("未配置多模态空间:本图无向量,之后只能靠块结构召回")
} else {
t.Logf("✓ 阶段3 已写入原生向量: dim=%d", len(b.Vector))
}
}
}
}
// ── 阶段 4:Prune 自动把块从 L0 迁移到 L2 ──
//
// MaxContextSize=3,多注入几轮文本把带图事件挤出活跃上下文。
// 迁移的是块本身(同一身份换层);L0 中不该再留下它。
// 填充数量必须 > 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) {
for _, b := range d.Blocks {
if b.ID == blockID {
docRefsFound = d.ID
}
}
}
if docRefsFound == "" {
t.Fatal("块未随归档事件迁移到 L2 文档")
}
// 同一块不能同时留在 L0。
for _, e := range a.context.Recent(0) {
for _, b := range e.Blocks {
if b.ID == blockID {
t.Errorf("块仍留在 L0(evt %s),违反单层不变量", e.ID)
}
}
}
t.Logf("✓ 阶段4 块自动迁移: context/%s → document/%s", evtID, docRefsFound)
// ── 阶段 5:archiveColdDocs 自动把块连到 L3 文档节点 ──
//
// FindColdDocs(72h, 2) 要求文档足够"冷",测试里新建的文档不满足,
// 因此把 LastAccess 往前推——这是为了触发生产代码路径,
// 而不是替代它(commitTriplesWithMedia/linkBlocksToDocument 全由它自己调)。
for _, d := range env.docStore.RecentDocs(20) {
if d.ID == docRefsFound {
d.LastAccess = time.Now().Add(-100 * time.Hour)
d.AccessCount = 0
}
}
a.archiveColdDocs()
// 块可能以 document --contains--> block(文档归档)或
// sentence --contains--> block(对话三元组)两种边存在。
sentRefs := 0
var boundSentence string
docBound := 0
docBlocks, err := env.graph.BlocksForNode("document", docRefsFound)
if err != nil {
t.Fatal(err)
}
docBound = len(docBlocks)
// 原句块 ID 按 SentenceBlockID 现算(sentences 表退场后行号不存在)
for _, e := range a.context.Recent(0) {
if e.Input != "" {
sid := memory.SentenceBlockID(e.Input)
blocks, err := env.graph.BlocksForNode("block", sid)
if err == nil && len(blocks) > 0 {
sentRefs += len(blocks)
if boundSentence == "" {
boundSentence = sid
}
}
}
}
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].ID != blockID {
t.Errorf("文档节点持有的块身份不对: %+v", got)
} else if raw, err := env.graph.BlockData(blockID); err != nil || !bytes.Equal(raw, img) {
t.Errorf("从文档块取回的字节与原图不一致 (err=%v)", err)
} else {
t.Logf("✓ 阶段5 反查取回 %d 字节,与原图逐字节一致", len(raw))
}
} else {
t.Logf("✓ 阶段5 L3 自动写入: %d 个句子块,首个原句块=%s", sentRefs, shortID(boundSentence))
got, err := env.agent.RecallBlocksForSentence(boundSentence)
if err != nil || len(got) == 0 || got[0].ID != blockID {
t.Errorf("从句子反查块失败: got=%+v err=%v", got, err)
} else if raw, err := env.graph.BlockData(blockID); err != nil || !bytes.Equal(raw, img) {
t.Errorf("从句子取回的字节与原图不一致 (err=%v)", err)
} else {
t.Logf("✓ 阶段5 反查取回 %d 字节,与原图逐字节一致", len(raw))
}
}
// ── 阶段 6:内容随块存在,不被单独清理 ──
if raw, err := env.graph.BlockData(blockID); err != nil || len(raw) == 0 {
t.Fatalf("被记忆块持有的内容不存在了: %v", err)
}
t.Logf("✓ 阶段6 被持有内容仍在")
// ── 阶段 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, nil); mc != "" {
t.Logf("注入的记忆上下文: %s", truncRunes(mc, 200))
} else {
t.Log("图库召回为空(本测试不再依赖文本描述,仅记录现状)")
}
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())
// 第二轮仍走真实 LLM:这里只验证链路不报错、有回复。
// 不再断言"答出紫/蓝/红":图片的颜色信息只在原生向量里,
// 未配置多模态空间时模型本来就无从得知——那不属于记忆接线缺陷。
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))
if strings.HasPrefix(answer, "处理错误:") {
t.Skipf("上游 LLM 调用失败,端到端召回无法判定: %s", truncRunes(answer, 160))
}
t.Logf("✓ 阶段7 E2E 链路贯通(召回能力取决于是否配置多模态向量空间)")
}
// TestMediaLive_NegativeControl 阴性对照:没有媒体记忆时不该"记得"。
//
// 没有这条对照,任何"答出了具体内容"的结果都可能只是模型先验,
// 无法区分真召回与猜测。
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 base64.StdEncoding.EncodeToString(b)
}
func truncRunes(s string, n int) string {
r := []rune(strings.ReplaceAll(s, "\n", " "))
if len(r) <= n {
return string(r)
}
return string(r[:n]) + "…"
}