mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-01 06:47:29 +00:00
块化重构(memory-restructure-plan)换了底层数据结构,但 L1↔L2 的流动没跟着改,
导致这条链出现结构性失效。本次按设计原文修复。
## 断点(生产实测)
**① L2 只存渲染视图,事件本体丢失**
ContextToDoc 把每条事件拍平成一行:truncate(Response,100)、truncate(工具输出,200)。
生产库 1000 篇归档文档实测:
- response 段恰好 103 字符(=100+"..."):311/2094 = 15.1%
- 工具输出段恰好 203 字符:7593/13261
- ToolsUsed 完全不存
⇒ 原文在落库那一刻就不存在了,召回再全也拿不回来。
**② L2 里没有文本块,与 L1 不同构**
L2 块分布 image 437 / audio 7 / text 0 —— 文本全靠 Content 这个视图。
**③ 召回造合成事件,时间戳用归档时刻**
doc_query 用 d.CreatedAt 造一条 cold_storage 事件、正文取 Content 再截 2000 字、
且完全不回填 Blocks。块自带 created_at(捕获时刻),实测与 doc.created_at
差近两小时,却没人用。
**④ 块在 Consume 时两层同时消失**
Consume 从内存与磁盘同时删除文档,而块的字节只存在于那个 .json 里。
生产 L2 有 444 个块,其中 396 个不在 L3(约 248MB)—— 一旦被命中即永久丢失。
## 修法(按设计,不是按我的推断)
设计前提(写进 ARCHITECTURE 与计划文档):
· 记忆块是唯一物理载体,层是持有关系
· L1 与 L2 结构同构,唯一区别是「是否直接投喂给模型」
· 正文(含多媒体正文)内联在块里;事件只是块的容器
· 任何一层都不压缩(视图不是记忆)
· 内核不得硬编码任何插件行为
1. Doc 增加 Events []DocEvent(与 ContextEvent 同构),成为文档主体
2. 正文各自成块:Input 一块、Response 一块(BlockText,Text 即正文)
3. restoreDocEvents 逐条按原始时间戳迁回 L1,块随事件迁回
4. Content 保留为渲染视图(contentFromEvents 生成),不再是原文载体
5. 蒸馏 docToTriples 改用 documentOriginalText:从块读原文,不再用视图
6. 删掉 ARCHITECTURE 里「过滤 agentcli/terminal 来源」——那是内核硬编码
插件名,改由插件经 NoMemory/Cleaner 声明
## 顺带修掉的真实缺陷
simpleHash 是 O(n²) 且哈希串长度正比于内容:
for _, c := range s { h += fmt.Sprintf("%x", c) }
压力测试实测 1.25MB 正文归档耗时 **270 秒**;生产实测 content_hash
是 content 的 2.2 倍(52072 字 → 114491 字)。
换成 sha256 前 16 字节:**270s → 0.52s(520 倍)**,定长 32 hex。
## 存量
按拍板不迁移,等系统自然冲刷。降级路径:无 Events 的旧文档仍按旧行为召回
(整篇渲成一条事件),不伪造成「有原始事件」。生产副本实测 1104 篇旧文档
全部正常加载。
## 判据(16 条,全部有变异自证)
document/blockflow_test.go(11 条):正文完整性、文本块粒度、逐条保留、
视图定位、幂等、存量兼容、原文可用性、hash 线性、3 条压力
core/recalldoc_test.go(5 条):逐条回填、原始时间戳、块迁回、
回填不截断、存量降级、批量压力
变异全部被抓:
· 落库 truncate(100) → ArchivePreservesFullText(实测 len=303/原 10215)
· Input/Response 合成一块 → InputAndResponseAreSeparateTextBlocks(实际 1)
· 召回用 d.CreatedAt → RestoresEachEventAtOriginalTimestamp(回填 1/期望 3)
· 召回不回填 Blocks → MigratesBlocksBackToContext
· 召回再截 2000 字 → TextIsNotTruncatedOnTheWayBack(2003/原 18020)
· 恢复 O(n²) hash → HashIsLinearAndFixedLength(超时 >300s)
## 验证
- go build ./... 通过
- go test ./... -count=1:58 包 0 失败
- go test -race ./internal/memory/document/ ./internal/agent/core/:通过
- 生产数据副本实际启动:1104 篇文档加载正常、0 崩溃、干净退出
- 副本 e2e:新归档 2 事件 / 4 文本块、正文 3207 字完整回读、时间戳精确
## 不改
- 公开 SDK 契约(TextEvent / DocMemoryAPI / InsertWithMedia 已等价)
- 存量文档(等自然冲刷)
- 渲染层截断常数(独立议题,避免与本次修复互相掩盖)
343 lines
11 KiB
Go
343 lines
11 KiB
Go
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/document"
|
||
)
|
||
|
||
// 媒体记忆接线测试(块 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()
|
||
docStore := document.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords)
|
||
if err := docStore.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, docStore); 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()
|
||
docStore := document.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords)
|
||
if err := docStore.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, docStore); n == 0 {
|
||
t.Fatal("应有事件被归档")
|
||
}
|
||
|
||
// 块应已到达 L2,且身份不变。
|
||
//
|
||
// ★ 2026-10-22 更新:L2 与 L1 同构后,事件正文也各自成为文本块
|
||
// (Input 一块、Response 一块),所以这里不能再假设「文档只有 1 个块」。
|
||
// 要断言的是**媒体块原样迁移**,而不是块总数。
|
||
var found *document.Doc
|
||
for _, d := range docStore.RecentDocs(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)
|
||
}
|
||
}
|
||
}
|
||
}
|