mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-06 09:17:31 +00:00
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 缺失。
This commit is contained in:
250
internal/memory/prod_segpaging_e2e_test.go
Normal file
250
internal/memory/prod_segpaging_e2e_test.go
Normal file
@ -0,0 +1,250 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user