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

155 lines
4.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
// 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
}