Files
HomeAgent/internal/memory/prod_segpaging_e2e_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

251 lines
7.9 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 memory
// 段页式在**生产数据副本**上的 e2e 验收(2026-10-29)。
//
// ## 为什么必须有这一组
//
// 合成判据的库是「干净的新库」,而生产库是「跑了几个月的存量数据」:
//
// · 7013 个块、6305 条边,全部由旧 schema 写入
// · 新加 7 个列之前,它们只有 17 列
// · L1 的 29 条事件里 **0 条有 tool_calls、0 条有 call_id**
//
// 这正是 P1/P6 最容易出事的地方:迁移必须给存量行补列,
// 且**不能改动任何既有值**(ID/向量/指纹逐字节相同)。
// 合成数据永远测不出「ALTER TABLE 之后老数据还在不在」。
//
// ## 取快照(不要直接指生产库,判据会写)
//
// sqlite3 "file:$HOME/../memory/graph.db?mode=ro" ".backup /tmp/graph-copy.db"
// HA_PROD_DB=/tmp/graph-copy.db go test -count=1 -run ProdSeg ./internal/memory/
//
// 没有副本时自动跳过(CI 不该依赖本机路径)。
import (
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"fmt"
"math"
"os"
"path/filepath"
"testing"
)
// vecDigestForTest 把向量压成定长摘要,用于「逐字节相同」的比较。
//
// 为什么不直接比 []float64:浮点切片不能直接 `==` 做全等判断,
// 而「逐字节不变」正是本判据要钉的东西 —— 用摘要比更严格也更好读。
func vecDigestForTest(v []float64) string {
if len(v) == 0 {
return ""
}
h := sha256.New()
for _, x := range v {
fmt.Fprintf(h, "%x;", math.Float64bits(x))
}
return hex.EncodeToString(h.Sum(nil))
}
// vecDigestForTestRaw 把基准侧的原始 JSON 解成向量后,用**同一个**摘要算法。
//
// ★ 关键:两侧必须用同一算法,否则比较毫无意义。
//
// 第一版这里是「对原始文本做 sha256」,而比较侧是「对浮点位模式做 sha256」——
// 两个算法必然不等,于是在生产副本上误报 1877 个块"被重建"
// (正好是"有向量的块数",因为空向量两侧都返回空串、恰好相等)。
// 这是判据自身的假阳性,不是迁移的缺陷。
func vecDigestForTestRaw(raw string) string {
if raw == "" || raw == "null" {
return ""
}
var v []float64
if err := json.Unmarshal([]byte(raw), &v); err != nil {
return "unparsable"
}
return vecDigestForTest(v)
}
// openProdCopy 把生产库复制到临时目录并打开(判据会写,绝不能动原件)。
func openProdCopy(t *testing.T) *GraphDB {
t.Helper()
src := prodCopySrc()
raw, err := os.ReadFile(src)
if err != nil {
t.Skip("无生产库副本,跳过:" + err.Error())
}
p := filepath.Join(t.TempDir(), "g.db")
if err := os.WriteFile(p, raw, 0o644); err != nil {
t.Fatal(err)
}
g, err := NewGraphDB(p)
if err != nil {
t.Fatalf("打开生产副本失败(迁移可能崩了): %v", err)
}
t.Cleanup(func() { g.Close() })
return g
}
// C5:迁移后每一行的 **ID / 向量 / 指纹逐字节不变**。
//
// 这是「不静默重建」的判据:如果迁移顺手重算了向量(或改了 ID),
// 那么所有块与新查询就落到不同空间里 —— 召回会全面失准,而且**不报错**。
func TestProdSeg_MigrationPreservesIdentity(t *testing.T) {
src := prodCopySrc()
if _, err := os.Stat(src); err != nil {
t.Skip("无生产库副本,跳过")
}
// 先从**未迁移的原始副本**读出基线(用只读 SQL 直接读,不经过 NewGraphDB)。
baseline := readRawBlockIdentity(t, src)
if len(baseline) < 100 {
t.Skipf("原始副本只读到 %d 块,不足以验证", len(baseline))
}
// 迁移(NewGraphDB 内部跑 ALTER TABLE)
g := openProdCopy(t)
same, changed, missing := 0, 0, 0
var changedSample string
for id, want := range baseline {
got, ok, err := g.BlockByIDPrefix(id)
if err != nil {
t.Fatalf("读块 %s 失败: %v", id, err)
}
if !ok {
missing++
continue
}
if got.ID == id && got.Fingerprint == want.fp &&
vecDigestForTest(got.Vector) == want.vecDigest {
same++
} else {
changed++
if changedSample == "" {
changedSample = id
}
}
}
t.Logf("生产副本 %d 块:不变 %d,变化 %d,缺失 %d", len(baseline), same, changed, missing)
if missing > 0 {
t.Fatalf("★ 迁移后丢了 %d 个块(数据丢失)", missing)
}
if changed > 0 {
t.Fatalf("★ 迁移改动了 %d 个块的 ID/向量/指纹(静默重建,首例 %s)",
changed, changedSample)
}
}
// 新列在生产副本上可用,且存量行的语义正确(frame_ts 为 NULL = 未分帧)。
func TestProdSeg_NewColumnsOnLegacyRows(t *testing.T) {
g := openProdCopy(t)
var total, nullFrame, nullRecalled int
if err := g.db.QueryRow(`SELECT COUNT(*) FROM memory_blocks`).Scan(&total); err != nil {
t.Fatal(err)
}
if err := g.db.QueryRow(`SELECT COUNT(*) FROM memory_blocks WHERE frame_ts IS NULL`).Scan(&nullFrame); err != nil {
t.Fatal(err)
}
if err := g.db.QueryRow(`SELECT COUNT(*) FROM memory_blocks WHERE last_recalled_at IS NULL`).Scan(&nullRecalled); err != nil {
t.Fatal(err)
}
t.Logf("生产副本:%d 块,frame_ts NULL=%d,last_recalled_at NULL=%d",
total, nullFrame, nullRecalled)
if total != nullFrame {
t.Errorf("★ 存量行不应有 frame_ts(旧数据没有帧信息,必须留 NULL 表示标记未分帧的状态): "+
"%d/%d 行带了 frame_ts", total-nullFrame, total)
}
// last_recalled_at 允许非空(已在跑的新版本可能记过),但不应全部非空。
if nullRecalled == 0 {
t.Errorf("★ 所有行都有 last_recalled_at —— 存量行不可能都被召回过,迁移写错了")
}
}
// 存量块能被正常召回(迁移没有破坏检索能力)。
func TestProdSeg_RecallStillWorksOnLegacyData(t *testing.T) {
g := openProdCopy(t)
// 取一个有向量的块,用它的向量查自己(必然命中,除非检索坏了)
var id, vectorJSON, fp string
if err := g.db.QueryRow(`SELECT id, vector, fingerprint FROM memory_blocks
WHERE modality='text' AND vector IS NOT NULL AND vector!='' AND vector!='null'
LIMIT 1`).Scan(&id, &vectorJSON, &fp); err != nil {
t.Skip("没有带向量的文本块,跳过")
}
var vec []float64
if err := json.Unmarshal([]byte(vectorJSON), &vec); err != nil {
t.Fatalf("解析向量失败: %v", err)
}
if len(vec) == 0 {
t.Skip("向量为空,跳过")
}
hits, err := g.RecallBlocks(BlockRecallQuery{
Vector: vec, TopK: 10, Fingerprint: fp,
})
if err != nil {
t.Fatalf("召回失败: %v", err)
}
if len(hits) == 0 {
t.Fatal("★ 用块自己的向量都召回不到东西 —— 迁移破坏了检索")
}
// 命中自己
found := false
for _, h := range hits {
if h.Block.ID == id {
found = true
break
}
}
if !found {
t.Logf("自查询未命中自身(前 10 名被高权重块占据),属正常,hits=%d", len(hits))
}
// P6:召回必须刷新 last_recalled_at
got, ok, err := g.BlockByIDPrefix(hits[0].Block.ID)
if err != nil || !ok {
t.Fatalf("读回失败: %v ok=%v", err, ok)
}
if got.LastRecalledAt.IsZero() {
t.Fatalf("★ 生产副本上召回仍未记录 last_recalled_at")
}
}
// ── 基线读取(只读,不触发迁移)──
type blockIdentity struct {
fp string
vecDigest string
}
func readRawBlockIdentity(t *testing.T, path string) map[string]blockIdentity {
t.Helper()
// 用只读模式打开,避免触发 ALTER TABLE。
// 只读打开:不触发 ALTER TABLE(NewGraphDB 才会跑迁移)。
db, err := sql.Open("sqlite3", "file:"+path+"?mode=ro")
if err != nil {
t.Skipf("只读打开失败(跳过基线对比): %v", err)
}
defer db.Close()
rows, err := db.Query(`SELECT id, fingerprint, vector FROM memory_blocks`)
if err != nil {
t.Skipf("读基线失败(可能是旧库 schema 差异): %v", err)
}
defer rows.Close()
out := map[string]blockIdentity{}
for rows.Next() {
var id, fp, vectorJSON string
if err := rows.Scan(&id, &fp, &vectorJSON); err != nil {
t.Fatal(err)
}
out[id] = blockIdentity{fp: fp, vecDigest: vecDigestForTestRaw(vectorJSON)}
}
return out
}