Files
Cyrene_v2/internal/runtime/memory/status_gate_test.go
T
AskaEth 9126d653e5
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(memory): 读侧过 status 闸 —— 软删行不再被检索、不再进面板
整理(tidy)与入库前审查(D3)都只做软动作:status 改 merged/archived,
正文一个字不改、行一行不删。因此只要有一条「能看到什么记忆」的读路径
漏了 status 闸,清理就等于白清 —— 生产库上那 10 条 merged 行就是反例。

- store.go:ListByPersona / List 加 status='active'
- rules.go:ListScope / ListProject 加 status='active'
- Get 刻意不过闸(撤销软合并要先读得到被吸收的行),并在注释里写明
- gateway/admin.go collectMemories 只加注释:口径只有一处,不重复过滤

验证:go build ./... + go vet 绿;memory 包全量测试绿;
新增 status_gate_test.go(4 条读路径逐条断言 + Retriever 端到端
「内容逐字相同的两条,软合并后只召回一条」+ Get 反向护栏);
用 -overlay 把闸门变异成恒真 ⇒ 5 个读路径用例与检索端到端用例全红。
2026-10-07 09:48:37 +08:00

210 lines
7.5 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
import (
"context"
"strings"
"testing"
)
// 本文件是「软动作必须真的从读侧消失」的回归(design/23 §8/§10)。
//
// 整理(tidy)与入库前审查(D3)都只做软动作:改 status、写 merged_into/
// merged_from,**正文一个字不改、行一行不删**。因此只要有一条「能看到什么
// 记忆」的读路径忘了过 status 闸,清理就等于白清 —— 生产库上那 10 条
// merged 行就是现成的反例(它们当时照样参与召回、照样出现在面板里)。
// setItemStatusForTest 直接改一行的 status,模拟整理/审查落地后的软动作
// 结果。走 SQL 而不是走 apply 路径,是为了让本文件只验证**读侧**:
// 写侧口径由 tidy_apply_*_test.go 与 preinsert_test.go 各自覆盖。
func setItemStatusForTest(t *testing.T, s *Store, id, status string) {
t.Helper()
if _, err := s.db.ExecContext(context.Background(),
`UPDATE memory_items SET status = ? WHERE id = ?`, status, id); err != nil {
t.Fatalf("set status=%s on %s: %v", status, id, err)
}
}
// TestStore_读路径_过滤非在册行 逐条走四个列表读路径,证明 merged/archived
// 行一条都不出现。四条第 2、4 条所在作用域(用户共享域、项目域)是跨角色
// 可读的,因此它们过去最容易漏闸。
func TestStore_读路径_过滤非在册行(t *testing.T) {
t.Parallel()
ctx := context.Background()
s := newTestStore(t)
activePersonaItem := addItem(t, s, MemoryItem{
PersonaID: "persona.a", Content: "在册-角色", Scope: ScopePersona, Kind: KindRelationship,
})
mergedPersona := addItem(t, s, MemoryItem{
PersonaID: "persona.a", Content: "已合并-角色", Scope: ScopePersona, Kind: KindRelationship,
})
activeUserItem := addItem(t, s, MemoryItem{
PersonaID: "persona.b", Content: "在册-用户", Scope: ScopeUser, Kind: KindWorldEvent,
})
archivedUser := addItem(t, s, MemoryItem{
PersonaID: "persona.a", Content: "已归档-用户", Scope: ScopeUser, Kind: KindWorldEvent,
})
activeProjectItem := addItem(t, s, MemoryItem{
PersonaID: "persona.a", Content: "在册-项目", Scope: ScopeProject,
Kind: KindWorldEvent, ProjectID: "proj.1",
})
mergedProject := addItem(t, s, MemoryItem{
PersonaID: "persona.a", Content: "已合并-项目", Scope: ScopeProject,
Kind: KindWorldEvent, ProjectID: "proj.1",
})
setItemStatusForTest(t, s, mergedPersona.ID, string(ingestStatusMerged))
setItemStatusForTest(t, s, archivedUser.ID, string(tidyStatusArchived))
setItemStatusForTest(t, s, mergedProject.ID, string(ingestStatusMerged))
for _, tc := range []struct {
name string
read func() ([]MemoryItem, error)
want []string
}{
{
name: "ListByPersona",
read: func() ([]MemoryItem, error) { return s.ListByPersona(ctx, "persona.a") },
// persona.a 自己的条目:角色域的在册行 + 它写的项目域在册行
// (项目域也属于本人身份的可见集)。
want: []string{"在册-项目", "在册-角色"},
},
{
name: "List",
read: func() ([]MemoryItem, error) { return s.List(ctx) },
want: []string{"在册-用户", "在册-项目", "在册-角色"},
},
{
name: "ListScope/user",
read: func() ([]MemoryItem, error) { return s.ListScope(ctx, ScopeUser, "persona.a") },
want: []string{"在册-用户"},
},
{
name: "ListScope/project",
read: func() ([]MemoryItem, error) { return s.ListScope(ctx, ScopeProject, "persona.a") },
want: []string{"在册-项目"},
},
{
name: "ListProject",
read: func() ([]MemoryItem, error) { return s.ListProject(ctx, "proj.1") },
want: []string{"在册-项目"},
},
} {
t.Run(tc.name, func(t *testing.T) {
got, err := tc.read()
if err != nil {
t.Fatalf("%s: %v", tc.name, err)
}
if len(got) != len(tc.want) {
t.Fatalf("%s = %d items (%v), want %d (%v)",
tc.name, len(got), contentsOf(got), len(tc.want), tc.want)
}
seen := make(map[string]bool, len(got))
for _, it := range got {
seen[it.Content] = true
if it.ID == mergedPersona.ID || it.ID == archivedUser.ID || it.ID == mergedProject.ID {
t.Errorf("%s 返回了非在册行 %q(status 闸失效)", tc.name, it.Content)
}
}
for _, want := range tc.want {
if !seen[want] {
t.Errorf("%s 缺少在册行 %q(got %v)", tc.name, want, contentsOf(got))
}
}
})
}
// 反向护栏:Get 刻意不过 status 闸 —— 撤销软合并必须先读得到被吸收的行,
// 否则 revert 无从下手(design/23 §8 的「无损可逆」)。
// 在册行这里一并断言,证明「不过滤」不是「读不到」。
for _, want := range []MemoryItem{
activePersonaItem, activeUserItem, activeProjectItem,
mergedPersona, archivedUser, mergedProject,
} {
got, err := s.Get(ctx, want.ID)
if err != nil {
t.Errorf("Get(%s) err = %v, want readable", want.ID, err)
continue
}
if got.Content != want.Content {
t.Errorf("Get(%s).Content = %q, want %q", want.ID, got.Content, want.Content)
}
}
}
// TestRetriever_Retrieve_不召回已软合并行 是主人那条投诉的端到端判据:
// 两条内容逐字相同的记忆,其中一条被软合并后,召回只能给出**一条**。
// 内容相同 ⇒ 相关度同分,闸一松两条都会进(这正是「重复记忆反复出现」
// 的现场机制)。
func TestRetriever_Retrieve_不召回已软合并行(t *testing.T) {
t.Parallel()
ctx := context.Background()
s := newTestStore(t)
survivor := addItem(t, s, MemoryItem{
PersonaID: "persona.a", Content: "主人喜欢喝冰美式", Scope: ScopePersona, Kind: KindRelationship,
})
absorbed := addItem(t, s, MemoryItem{
PersonaID: "persona.a", Content: "主人喜欢喝冰美式", Scope: ScopePersona, Kind: KindRelationship,
})
r := NewRetriever(s)
before, err := r.Retrieve(ctx, "persona.a", "冰美式", 10)
if err != nil {
t.Fatalf("Retrieve before merge: %v", err)
}
if len(before) != 2 {
t.Fatalf("合并前召回 = %d 条, want 2(前置条件:两条都进得去)", len(before))
}
setItemStatusForTest(t, s, absorbed.ID, string(ingestStatusMerged))
after, err := r.Retrieve(ctx, "persona.a", "冰美式", 10)
if err != nil {
t.Fatalf("Retrieve after merge: %v", err)
}
if len(after) != 1 {
t.Fatalf("合并后召回 = %d 条 (%v), want 1", len(after), idsOf(after))
}
if after[0].Item.ID != survivor.ID {
t.Errorf("合并后召回 = %s, want 存活的 %s", after[0].Item.ID, survivor.ID)
}
// Inject 是真正进上下文的那一步,也必须只剩一行。
injected, err := r.Inject(ctx, "persona.a", "冰美式", 10)
if err != nil {
t.Fatalf("Inject: %v", err)
}
if n := strings.Count(injected, "主人喜欢喝冰美式"); n != 1 {
t.Errorf("Inject 文本里出现 %d 次重复记忆, want 1(%q)", n, injected)
}
// 归档路径同理:archived 也不该被召回。
setItemStatusForTest(t, s, survivor.ID, string(tidyStatusArchived))
gone, err := r.Retrieve(ctx, "persona.a", "冰美式", 10)
if err != nil {
t.Fatalf("Retrieve after archive: %v", err)
}
if len(gone) != 0 {
t.Errorf("归档后召回 = %d 条 (%v), want 0", len(gone), idsOf(gone))
}
}
// contentsOf 取条目正文,仅用于失败信息。
func contentsOf(items []MemoryItem) []string {
out := make([]string, 0, len(items))
for _, it := range items {
out = append(out, it.Content)
}
return out
}
// idsOf 取条目 id,仅用于失败信息。
func idsOf(results []Result) []string {
out := make([]string, 0, len(results))
for _, res := range results {
out = append(out, res.Item.ID)
}
return out
}