Files
HomeAgent/internal/agent/core/mediaref_test.go
JianFeeeee b850feaef2 feat: 段页式记忆流转、coldcontext 重命名与安全治理
记忆层:
- MemoryBlock 承载 FrameTS/PageSeq/PageKind/BlockRole/CallID
- coldcontext 归档与回填按完整页传递,工具调用参数和配对键不丢失
- 召回先估算总 token,超预算时按全局页相似度标记 inactive
- inactive 仅是 context 渲染期状态,不落盘、不进入 coldcontext,Prune 后重置
- Peek/确认/Remove 两阶段召回,失败保留冷上下文副本
- stablemem 记录 last_recalled_at,删块连带清理边

命名:
- internal/memory/document → internal/memory/coldcontext
- Doc/DocEvent/DocHit → ColdDoc/ColdEvent/ColdHit
- ContextToColdContext/RecentColdContexts/FindColdContexts
- 保留既有 JSON 标签、doc_ 文件前缀与 doc_count 状态键,兼容存量数据

安全治理:
- 工具策略门禁、安全配置、事件总线发布者身份与入站脱敏
- 声明式 SecurityAPI 与 QQ owner/guest profile
- 日志保留策略与相关 e2e/回归判据

验证:go build ./...、go vet ./...、go test ./...(62 包);
生产副本 e2e:7013 块迁移后 ID/向量/指纹 0 变化、0 缺失。
2026-10-29 23:53:48 +08:00

343 lines
11 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.

package core
import (
"encoding/base64"
"os"
"path/filepath"
"strings"
"testing"
"time"
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
"github.com/JianFeeeee/HologramAgent/internal/memory"
"github.com/JianFeeeee/HologramAgent/internal/memory/coldcontext"
)
// 媒体记忆接线测试(块 union 化之后):验证媒体从对话进入块、挂到 L0 事件、
// 随归档转到 L2 文档的完整链路。
//
// 核心断言不是"函数被调用了",而是不变量:
// 1. 媒体进不了块时对话照常(它是记忆增强,不是对话必需品)
// 2. 块的身份是内容派生:同内容同块,跨层迁移身份不变
// 3. 图库为 nil 时全链路静默跳过,行为与本特性上线前一致
func newTestAgentWithMedia(t *testing.T) *Agent {
t.Helper()
dir := t.TempDir()
g, err := memory.NewGraphDB(filepath.Join(dir, "graph.db"))
if err != nil {
t.Fatalf("NewGraphDB: %v", err)
}
t.Cleanup(func() { g.Close() })
emb := memory.NewStaticEmbedder()
a := &Agent{
memory: g,
context: NewRelevanceContext(filepath.Join(dir, "context.json"), emb),
}
return a
}
// dataURLString 造 data URL(media 包退场后由本测试自持,与 parseDataURL 对偶)。
func dataURLString(mime string, data []byte) string {
return "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(data)
}
// imageBlockURL 造一个带指定 URL 的图片块。
// 名字带 URL 后缀是为了不与 modalfallback_test.go 里固定用 testPNG 的
// imageBlock() 撞名——两者用途不同:那个验回退链,这个验入库。
func imageBlockURL(dataURL string) agentAPI.ContentBlock {
return agentAPI.ContentBlock{
Type: "image_url",
ImageURL: &agentAPI.ImageURL{URL: dataURL, Detail: "auto"},
}
}
func TestCaptureBlockMedia_StoresDataURL(t *testing.T) {
a := newTestAgentWithMedia(t)
raw := []byte{0x89, 'P', 'N', 'G', 1, 2, 3}
blocks := []agentAPI.ContentBlock{
{Type: "text", Text: "看这张图"},
imageBlockURL(dataURLString("image/png", raw)),
}
got := a.captureBlockMedia(blocks, "multimodal_see_picture")
if len(got) != 1 {
t.Fatalf("应捕获 1 个媒体块,实际 %d", len(got))
}
b := got[0]
// 字节就在块里(union),身份内容派生
if string(b.Data) != string(raw) {
t.Fatal("块字节与输入不一致")
}
if b.ID != memory.MediaBlockID("image/png", raw) {
t.Errorf("块 ID 应内容派生: %q", b.ID)
}
if b.MIME != "image/png" || b.Tool != "multimodal_see_picture" || b.Modality != memory.BlockImage {
t.Fatalf("元数据不对: %+v", b)
}
// capture 之后由 task/processInput 做 stage(见 task.go:397/1102)——
// capture 本身只产出块,不暂存。
}
func TestCaptureBlockMedia_SkipsHTTPURL(t *testing.T) {
// http(s) URL 拿不到字节就无法成块;"下载它再存"会把一次对话
// 变成一次网络请求(超时、鉴权、SSRF 全来了),不在本层解决。
a := newTestAgentWithMedia(t)
blocks := []agentAPI.ContentBlock{
imageBlockURL("https://example.com/x.png"),
}
if d := a.captureBlockMedia(blocks, "t"); len(d) != 0 {
t.Fatalf("http URL 不该被捕获,实际 %d 个", len(d))
}
}
func TestCaptureBlockMedia_NilMemoryIsNoop(t *testing.T) {
// 块 union 化后 captureBlockMedia 不再依赖外部存储(字节就在块里),
// 但 stage/drain、bindEventMedia、blockLabel 这些纯内存通路必须照常可用。
a := &Agent{}
blocks := []agentAPI.ContentBlock{imageBlockURL(dataURLString("image/png", []byte("x")))}
got := a.captureBlockMedia(blocks, "t")
if len(got) != 1 {
t.Fatalf("捕获不依赖图库,应得 1 个块,实际 %v", got)
}
a.stageMediaBlocks(got...)
if drained := a.drainMediaBlocks(); len(drained) != 1 {
t.Fatal("stage/drain 不依赖图库,应正常工作")
}
// bindEventMedia 对空块列表也必须安全
evt := &ContextEvent{}
a.bindEventMedia(evt, nil)
if len(evt.Blocks) != 0 || evt.ID != "" {
t.Fatalf("空块列表时不该改动事件: %+v", evt)
}
if s := blockLabel(nil); s != "" {
t.Fatalf("nil 块应产出空标签,得到 %q", s)
}
}
func TestCaptureBlockMedia_AudioAndVideo(t *testing.T) {
a := newTestAgentWithMedia(t)
blocks := []agentAPI.ContentBlock{
imageBlockURL(dataURLString("image/jpeg", []byte("frame"))),
{Type: "audio_url", AudioURL: &agentAPI.AudioURL{URL: dataURLString("audio/wav", []byte("sound"))}},
}
got := a.captureBlockMedia(blocks, "multimodal_see_video")
if len(got) != 2 {
t.Fatalf("应捕获 2 个,实际 %d", len(got))
}
if got[0].Modality != memory.BlockImage || got[0].MIME != "image/jpeg" {
t.Fatalf("图像块元数据不对: %+v", got[0])
}
if got[1].Modality != memory.BlockAudio || got[1].MIME != "audio/wav" {
t.Fatalf("音频块元数据不对: %+v", got[1])
}
}
func TestStageDrainMediaBlocks(t *testing.T) {
a := newTestAgentWithMedia(t)
b1 := mediaBlock("image/png", []byte("a"))
b2 := mediaBlock("image/png", []byte("b"))
b3 := mediaBlock("image/png", []byte("c"))
a.stageMediaBlocks(b1, b2)
a.stageMediaBlocks(b3)
got := a.drainMediaBlocks()
if len(got) != 3 {
t.Fatalf("应累积 3 个,实际 %d", len(got))
}
// drain 后必须清空——否则下一轮对话会把上一轮的媒体又挂一遍
if again := a.drainMediaBlocks(); again != nil {
t.Fatalf("drain 后应为空,实际 %v", again)
}
}
func TestBindEventMedia_CreatesBlocks(t *testing.T) {
a := newTestAgentWithMedia(t)
b := mediaBlock("image/png", []byte("img"))
evt := &ContextEvent{Timestamp: time.Now(), Source: "qq", Input: "看图"}
a.bindEventMedia(evt, []memory.MemoryBlock{b})
if evt.ID == "" {
t.Fatal("应懒生成事件 ID")
}
if len(evt.Blocks) != 1 || evt.Blocks[0].ID != b.ID {
t.Fatalf("事件应持有一等记忆块: %+v", evt.Blocks)
}
if evt.Blocks[0].Modality != memory.BlockImage || evt.Blocks[0].MIME != "image/png" {
t.Fatalf("块元数据不对: %+v", evt.Blocks[0])
}
// 字节随事件里的块一起走(union)
if string(evt.Blocks[0].Data) != "img" {
t.Fatalf("块字节应随事件: %q", evt.Blocks[0].Data)
}
}
func TestBindEventMedia_LazyIDOnlyWhenNeeded(t *testing.T) {
// 绝大多数对话没有媒体,不该为它们都生成 ID 塞进 context.json
a := newTestAgentWithMedia(t)
evt := &ContextEvent{Input: "纯文本"}
a.bindEventMedia(evt, nil)
if evt.ID != "" {
t.Fatalf("无媒体时不该生成 ID,得到 %q", evt.ID)
}
}
func TestBlockLabel_NoGeneratedDescription(t *testing.T) {
// 标签只用来告诉模型「这条记忆带着哪份媒体、可用该块 ID 取回字节」。
// 它不包含任何生成的描述:描述式索引是旧机制,已彻底废弃。
a := newTestAgentWithMedia(t)
b := mediaBlock("image/png", []byte("img"))
s := blockLabel(&b)
if s == "" {
t.Fatal("应产出标签")
}
if !strings.Contains(s, "image/png") {
t.Fatalf("标签应含 MIME 标注: %q", s)
}
if !strings.Contains(s, shortID(b.ID)) {
t.Fatalf("标签应含短块 ID 供反查: %q", s)
}
_ = a
}
func TestPrune_NilDocStoreStillArchives(t *testing.T) {
// 归档链路不依赖任何外部媒体存储:块随事件走,归档时整体迁移。
dir := t.TempDir()
emb := memory.NewStaticEmbedder()
coldcontextStore := coldcontext.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords)
if err := coldcontextStore.Start(); err != nil {
t.Fatal(err)
}
rc := NewRelevanceContext(filepath.Join(dir, "context.json"), emb)
for i := 0; i < 15; i++ {
rc.Append(ContextEvent{
Timestamp: time.Now().Add(time.Duration(i) * time.Second),
Source: "qq",
Input: "内容",
})
}
if n := rc.Prune("查询", 5, coldcontextStore); n == 0 {
t.Fatal("无媒体参与时归档也应正常")
}
}
func TestContextEvent_BlocksFieldRoundTrip(t *testing.T) {
// context.json 加字段必须向后兼容:存量文件读回来 Blocks 为空、ID 为空,
// 不影响任何既有行为。
dir := t.TempDir()
path := filepath.Join(dir, "context.json")
// 写一份"存量格式"(无 id / blocks 字段)
legacy := `[{"timestamp":"2026-09-04T10:00:00Z","source":"qq","input":"老数据","response":"回复"}]`
if err := os.WriteFile(path, []byte(legacy), 0644); err != nil {
t.Fatal(err)
}
emb := memory.NewStaticEmbedder()
rc := NewRelevanceContext(path, emb)
if rc.Len() != 1 {
t.Fatalf("应读回 1 条,实际 %d", rc.Len())
}
// 新写入带记忆块的事件,再读回
a := newTestAgentWithMedia(t)
a.context = rc
b := mediaBlock("image/png", []byte("img"))
evt := ContextEvent{Timestamp: time.Now(), Source: "qq", Input: "新数据"}
a.bindEventMedia(&evt, []memory.MemoryBlock{b})
rc.Append(evt)
rc.flush()
rc2 := NewRelevanceContext(path, emb)
if rc2.Len() != 2 {
t.Fatalf("应有 2 条,实际 %d", rc2.Len())
}
var persisted int
for _, e := range rc2.Recent(10) {
persisted += len(e.Blocks)
}
if persisted != 1 {
t.Fatalf("块应随 context.json 持久化,实际 %d 个", persisted)
}
}
func TestPruneMigratesBlocksToDocument(t *testing.T) {
// 一等记忆块的 L0→L2 迁移:块随事件离开 Context、进入 Document,
// 身份(ID/模态/MIME/向量)原样保留;同一块不能同时留在两层。
// 这条路径不依赖任何引用账本。
dir := t.TempDir()
emb := memory.NewStaticEmbedder()
coldcontextStore := coldcontext.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords)
if err := coldcontextStore.Start(); err != nil {
t.Fatal(err)
}
rc := NewRelevanceContext(filepath.Join(dir, "context.json"), emb)
block := mediaBlock("image/png", []byte("migrate-me"))
block.Vector = []float64{0.1, 0.2, 0.3}
block.Fingerprint = "qwen:test"
rc.Append(ContextEvent{
Timestamp: time.Now().Add(-time.Hour),
Source: "qq", Input: "很久以前的一张图",
Blocks: []memory.MemoryBlock{block},
})
for i := 0; i < 12; i++ {
rc.Append(ContextEvent{
Timestamp: time.Now().Add(time.Duration(i) * time.Second),
Source: "qq", Input: "无关内容",
})
}
if n := rc.Prune("完全不相关的查询", 5, coldcontextStore); n == 0 {
t.Fatal("应有事件被归档")
}
// 块应已到达 L2,且身份不变。
//
// ★ 2026-10-22 更新:L2 与 L1 同构后,事件正文也各自成为文本块
// (Input 一块、Response 一块),所以这里不能再假设「文档只有 1 个块」。
// 要断言的是**媒体块原样迁移**,而不是块总数。
var found *coldcontext.ColdDoc
for _, d := range coldcontextStore.RecentColdContexts(20) {
for _, b := range d.Blocks {
if b.ID == block.ID {
found = d
}
}
}
if found == nil {
t.Fatal("媒体块未随归档事件迁移到 L2")
}
var got *memory.MemoryBlock
for i := range found.Blocks {
if found.Blocks[i].ID == block.ID {
got = &found.Blocks[i]
}
}
if got.ID != block.ID || got.Modality != block.Modality || got.MIME != block.MIME ||
got.Fingerprint != block.Fingerprint || len(got.Vector) != len(block.Vector) {
t.Fatalf("块身份应原样迁移:\n got %+v\n want %+v", *got, block)
}
// 字节随块迁移(union)
if string(got.Data) != string(block.Data) {
t.Fatalf("块字节应原样迁移: %q vs %q", got.Data, block.Data)
}
// 同一块不能同时留在 L0(L1)。
for _, e := range rc.Recent(100) {
for _, b := range e.Blocks {
if b.ID == block.ID {
t.Fatalf("块仍留在 L1(同一块同时存在于两层): %+v", b)
}
}
}
}