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,154 @@
package memory
// stablemem 回收的 e2e 判据(P6,2026-10-29)。
//
// ## 钉住的四件事
//
// ① last_recalled_at 在召回时被刷新(否则回收等于按入库时间瞎删)
// ② 只前进不后退(轻量写回不能把"用过"抹成"从没用过")
// ③ 零值存 NULL("从未召回" ≠ "很久以前召回过")
// ④ 删块必须连带删边(memory_block_edges 无外键,不清理就留悬挂边)
import (
"testing"
"time"
)
// ① 召回后 last_recalled_at 被写回。
func TestRecallMarksLastRecalledAt(t *testing.T) {
g := newTestGraph(t)
b := MemoryBlock{
ID: "blk_txt_recal1", Modality: BlockText, Text: "关于磁盘清理的办法",
Vector: []float64{1, 0, 0}, Fingerprint: "fp-r",
}
if err := g.PutMemoryBlocks([]MemoryBlock{b}); err != nil {
t.Fatal(err)
}
hits, err := g.RecallBlocks(BlockRecallQuery{
Vector: []float64{1, 0, 0}, TopK: 5, Fingerprint: "fp-r",
})
if err != nil {
t.Fatal(err)
}
if len(hits) == 0 {
t.Fatal("未召回")
}
// markRecalled 是 defer 调用的,这里等一下再读(它同步执行,defer 在返回前)
got, ok, err := g.BlockByIDPrefix("blk_txt_recal1")
if err != nil || !ok {
t.Fatalf("读回失败: %v ok=%v", err, ok)
}
if got.LastRecalledAt.IsZero() {
t.Fatalf("★ 召回未记录 last_recalled_at —— 回收会按入库时间瞎删")
}
}
// ② 只前进不后退:轻量写回(零值)不能抹掉已记录的召回时间。
func TestLastRecalledAtOnlyMovesForward(t *testing.T) {
g := newTestGraph(t)
past := time.Date(2026, 9, 1, 8, 0, 0, 0, time.UTC)
full := MemoryBlock{
ID: "blk_txt_recal2", Modality: BlockText, Text: "正文",
LastRecalledAt: past,
}
if err := g.PutMemoryBlocks([]MemoryBlock{full}); err != nil {
t.Fatal(err)
}
// 轻量写回:不带 last_recalled_at
if err := g.PutMemoryBlocks([]MemoryBlock{
{ID: "blk_txt_recal2", Modality: BlockText, Text: "正文"},
}); err != nil {
t.Fatal(err)
}
got, _, _ := g.BlockByIDPrefix("blk_txt_recal2")
if got.LastRecalledAt.IsZero() {
t.Fatalf("★ 轻量写回把 last_recalled_at 抹成零值 —— 「活块」会被误判为死块删掉")
}
if !got.LastRecalledAt.Equal(past) {
t.Fatalf("时间被改写: %v(期望 %v)", got.LastRecalledAt, past)
}
// 更新的时间要能推进
newer := past.Add(72 * time.Hour)
if err := g.PutMemoryBlocks([]MemoryBlock{
{ID: "blk_txt_recal2", Modality: BlockText, Text: "正文", LastRecalledAt: newer},
}); err != nil {
t.Fatal(err)
}
got2, _, _ := g.BlockByIDPrefix("blk_txt_recal2")
if !got2.LastRecalledAt.Equal(newer) {
t.Fatalf("更新的召回时间未生效: %v(期望 %v)", got2.LastRecalledAt, newer)
}
}
// ③ 从未召回存 NULL(区别于"很久以前召回过")。
func TestNeverRecalledStoredAsNull(t *testing.T) {
g := newTestGraph(t)
if err := g.PutMemoryBlocks([]MemoryBlock{
{ID: "blk_txt_never", Modality: BlockText, Text: "从未被召回"},
}); err != nil {
t.Fatal(err)
}
var n int
if err := g.db.QueryRow(
`SELECT COUNT(*) FROM memory_blocks WHERE id='blk_txt_never' AND last_recalled_at IS NULL`).
Scan(&n); err != nil {
t.Fatal(err)
}
if n != 1 {
t.Fatal("★ 从未召回应存 NULL(零值时间会与'很久以前召回过'混淆)")
}
}
// ④ 删块必须连带删边(否则留悬挂边)。
//
// memory_block_edges **没有外键约束**(边是独立单位,见 DDL 注释),
// 所以不能指望 DB 级联 —— 清理必须在删除代码里显式做。
//
// 走真实路径 DeleteEntity:它按 text_content 精确匹配找出块,
// 然后①删关联边 ②删块 ③同步删旧表。
func TestDeleteBlockRemovesEdges(t *testing.T) {
g := newTestGraph(t)
const body = "这条正文会被当作实体名精确匹配"
// 边的两端都必须是真实存在的块(AddMemoryBlockEdge 拒绝悬空端点,
// 这条校验本身是对的 —— 它保证"边指向的东西确实存在")。
if err := g.PutMemoryBlocks([]MemoryBlock{
{ID: "blk_txt_orphan", Modality: BlockText, Text: body},
{ID: "blk_other", Modality: BlockText, Text: "对端块"},
}); err != nil {
t.Fatal(err)
}
if err := g.AddMemoryBlockEdge("block", "blk_txt_orphan", "block", "blk_other", "contains"); err != nil {
t.Fatal(err)
}
before := countBlockEdges(t, g, "blk_txt_orphan")
if before == 0 {
t.Fatal("前置条件失败:边未建立")
}
if _, err := g.DeleteEntity(body); err != nil {
t.Fatal(err)
}
after := countBlockEdges(t, g, "blk_txt_orphan")
if after != 0 {
t.Fatalf("★ 删除块后残留 %d 条悬挂边(无外键,必须显式清理)", after)
}
}
// countBlockEdges 数某个块作为端点的边。
func countBlockEdges(t *testing.T, g *GraphDB, blockID string) int {
t.Helper()
var n int
if err := g.db.QueryRow(
`SELECT COUNT(*) FROM memory_block_edges
WHERE (source_kind='block' AND source_id=?) OR (target_kind='block' AND target_id=?)`,
blockID, blockID).Scan(&n); err != nil {
t.Fatal(err)
}
return n
}