mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-01 06:47:29 +00:00
记忆层: - 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 缺失。
155 lines
4.9 KiB
Go
155 lines
4.9 KiB
Go
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
|
||
}
|