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

119 lines
4.2 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
// P1 段页式字段的判据(C5:迁移后 ID / 帧号 / 指纹逐字节相同)。
//
// ## 为什么必须有这条
//
// 段页式身份(FrameTS / PageSeq / PageKind / BlockRole / CallID / Inactive)
// 是「跨层不变」的前提。一旦某个写入路径把字段抹掉,块就再也归不回原来的页,
// 渲染时会破配对 —— 而这类错**不报错**,只是行为悄悄变了。
//
// 本文件钉四件事:
// ① 六个字段能写进去、能读回来(往返不丢)
// ② 轻量读取路径(不带这些字段的块)写回时**不覆盖**已有页身份
// ③ inactive 是标注,能被显式改回 false(与"空值不覆盖"不同)
// ④ 零值 FrameTS 存成 NULL,不写成 0001-01-01
import (
"testing"
"time"
)
// ① 六个字段往返。
func TestBlockPageFieldsRoundTrip(t *testing.T) {
g := newTestGraph(t)
ts := time.Date(2026, 10, 29, 15, 4, 5, 123456789, time.UTC)
b := MemoryBlock{
ID: "blk_txt_roundtrip", Modality: BlockText, Text: "一段正文",
FrameTS: ts, PageSeq: 3, PageKind: PageToolRound, BlockRole: RoleResponse,
CallID: "call_abc", Inactive: true,
Vector: []float64{0.1, 0.2}, Fingerprint: "fp-1",
}
if err := g.PutMemoryBlocks([]MemoryBlock{b}); err != nil {
t.Fatal(err)
}
got, ok, err := g.BlockByIDPrefix("blk_txt_roundtrip")
if err != nil || !ok {
t.Fatalf("读回失败: %v ok=%v", err, ok)
}
if !got.FrameTS.Equal(ts) {
t.Errorf("FrameTS 往返不一致: 写 %v 读 %v", ts, got.FrameTS)
}
if got.PageSeq != 3 || got.PageKind != PageToolRound ||
got.BlockRole != RoleResponse || got.CallID != "call_abc" || !got.Inactive {
t.Errorf("页字段往返不一致: %+v", got)
}
if got.Fingerprint != "fp-1" || len(got.Vector) != 2 {
t.Errorf("指纹/向量往返不一致: fp=%q vec=%v", got.Fingerprint, got.Vector)
}
}
// ② 轻量写回不覆盖页身份。
//
// 场景:BlockByIDPrefix 这类 lean 读取拿到的块不带页字段,
// 某些路径会把它重新 Put 回去(如挂场景)。若直接覆盖,
// 页身份被抹 → 块归不回页 → 渲染破配对。
func TestPageFieldsNotOverwrittenByLeanWrite(t *testing.T) {
g := newTestGraph(t)
ts := time.Date(2026, 10, 29, 16, 0, 0, 0, time.UTC)
full := MemoryBlock{
ID: "blk_txt_keep", Modality: BlockText, Text: "正文",
FrameTS: ts, PageSeq: 2, PageKind: PageLLMRound, BlockRole: RoleRequest,
CallID: "call_keep",
}
if err := g.PutMemoryBlocks([]MemoryBlock{full}); err != nil {
t.Fatal(err)
}
// 模拟 lean 写回:只有 ID + 文本,页字段全零
lean := MemoryBlock{ID: "blk_txt_keep", Modality: BlockText, Text: "正文"}
if err := g.PutMemoryBlocks([]MemoryBlock{lean}); err != nil {
t.Fatal(err)
}
got, _, _ := g.BlockByIDPrefix("blk_txt_keep")
if !got.FrameTS.Equal(ts) || got.PageSeq != 2 || got.CallID != "call_keep" {
t.Fatalf("★ 轻量写回抹掉了页身份: FrameTS=%v PageSeq=%d CallID=%q",
got.FrameTS, got.PageSeq, got.CallID)
}
}
// ③ inactive 必须能被显式改回 false("读回后恢复渲染")。
func TestInactiveCanBeCleared(t *testing.T) {
g := newTestGraph(t)
b := MemoryBlock{ID: "blk_txt_inact", Modality: BlockText, Text: "x", Inactive: true}
if err := g.PutMemoryBlocks([]MemoryBlock{b}); err != nil {
t.Fatal(err)
}
b.Inactive = false
if err := g.PutMemoryBlocks([]MemoryBlock{b}); err != nil {
t.Fatal(err)
}
got, _, _ := g.BlockByIDPrefix("blk_txt_inact")
if got.Inactive {
t.Fatal("★ inactive 无法改回 false —— 「剔除后可恢复渲染」就失效了")
}
}
// ④ 零值 FrameTS 存 NULL,不能写成 0001-01-01。
//
// 若写成 0001-01-01,迁移脚本会把它当成一个真实的极早帧,
// 于是所有存量块被错误归入同一页。
func TestZeroFrameTSStoredAsNull(t *testing.T) {
g := newTestGraph(t)
if err := g.PutMemoryBlocks([]MemoryBlock{
{ID: "blk_txt_noft", Modality: BlockText, Text: "旧数据"},
}); err != nil {
t.Fatal(err)
}
var nullCount int
if err := g.db.QueryRow(
`SELECT COUNT(*) FROM memory_blocks WHERE id='blk_txt_noft' AND frame_ts IS NULL`).
Scan(&nullCount); err != nil {
t.Fatal(err)
}
if nullCount != 1 {
t.Fatal("★ 零值 FrameTS 应存成 NULL,否则存量块会被错误聚成同一帧")
}
}