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:
JianFeeeee
2026-10-29 23:53:48 +08:00
parent a3e50b395f
commit b850feaef2
113 changed files with 6512 additions and 790 deletions

View File

@ -0,0 +1,118 @@
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,否则存量块会被错误聚成同一帧")
}
}