From 9126d653e54f6d27dba0fde134d320b4fbfbffe7 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 7 Oct 2026 09:48:37 +0800 Subject: [PATCH] =?UTF-8?q?fix(memory):=20=E8=AF=BB=E4=BE=A7=E8=BF=87=20st?= =?UTF-8?q?atus=20=E9=97=B8=20=E2=80=94=E2=80=94=20=E8=BD=AF=E5=88=A0?= =?UTF-8?q?=E8=A1=8C=E4=B8=8D=E5=86=8D=E8=A2=AB=E6=A3=80=E7=B4=A2=E3=80=81?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E8=BF=9B=E9=9D=A2=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 整理(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 个读路径用例与检索端到端用例全红。 --- internal/runtime/memory/rules.go | 8 +- internal/runtime/memory/status_gate_test.go | 209 ++++++++++++++++++++ internal/runtime/memory/store.go | 23 ++- internal/services/gateway/admin.go | 4 + 4 files changed, 233 insertions(+), 11 deletions(-) create mode 100644 internal/runtime/memory/status_gate_test.go diff --git a/internal/runtime/memory/rules.go b/internal/runtime/memory/rules.go index 428589a..47ccb11 100644 --- a/internal/runtime/memory/rules.go +++ b/internal/runtime/memory/rules.go @@ -82,8 +82,8 @@ func (s *Store) ListScope(ctx context.Context, scope Scope, personaID string) ([ } rows, err := s.db.QueryContext(ctx, `SELECT id, persona_id, role_id, project_id, scope, kind, content, tags, confidence, source_conversation, created_at, updated_at, - decay_weight FROM memory_items WHERE scope = ? - ORDER BY created_at DESC, rowid DESC`, string(scope)) + decay_weight FROM memory_items WHERE scope = ? AND status = ? + ORDER BY created_at DESC, rowid DESC`, string(scope), string(tidyStatusActive)) if err != nil { return nil, fmt.Errorf("memory: list scope items: %w", err) } @@ -103,8 +103,8 @@ func (s *Store) ListProject(ctx context.Context, projectID string) ([]MemoryItem } rows, err := s.db.QueryContext(ctx, `SELECT id, persona_id, role_id, project_id, scope, kind, content, tags, confidence, source_conversation, created_at, updated_at, - decay_weight FROM memory_items WHERE project_id = ? - ORDER BY created_at DESC, rowid DESC`, projectID) + decay_weight FROM memory_items WHERE project_id = ? AND status = ? + ORDER BY created_at DESC, rowid DESC`, projectID, string(tidyStatusActive)) if err != nil { return nil, fmt.Errorf("memory: list project items: %w", err) } diff --git a/internal/runtime/memory/status_gate_test.go b/internal/runtime/memory/status_gate_test.go new file mode 100644 index 0000000..31a2c6f --- /dev/null +++ b/internal/runtime/memory/status_gate_test.go @@ -0,0 +1,209 @@ +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 +} diff --git a/internal/runtime/memory/store.go b/internal/runtime/memory/store.go index 48bbb8f..78f821f 100644 --- a/internal/runtime/memory/store.go +++ b/internal/runtime/memory/store.go @@ -284,31 +284,40 @@ func (s *Store) Get(ctx context.Context, id string) (MemoryItem, error) { // 命名空间(design §4)。它只是检索候选集的*自有* // 那一半 —— 检索器在排名前把它与共享的用户/项目作用域 // 取并集(design §3/§4,见 Retriever.visibleCandidates)。 +// +// 只返回**在册**(status='active')条目:整理与入库前审查都只做软动作, +// 被合并/被归档的行内容原样留在表里,因此「能看到什么记忆」的每条读路径 +// 都必须过 status 闸,否则清理过的行照样会被检索到、照样占满面板。 func (s *Store) ListByPersona(ctx context.Context, personaID string) ([]MemoryItem, error) { if strings.TrimSpace(personaID) == "" { return nil, ErrMissingPersona } rows, err := s.db.QueryContext(ctx, `SELECT id, persona_id, role_id, project_id, scope, kind, content, tags, confidence, source_conversation, created_at, updated_at, - decay_weight FROM memory_items WHERE persona_id = ? - ORDER BY created_at DESC, rowid DESC`, personaID) + decay_weight FROM memory_items WHERE persona_id = ? AND status = ? + ORDER BY created_at DESC, rowid DESC`, personaID, string(tidyStatusActive)) if err != nil { return nil, fmt.Errorf("memory: list persona items: %w", err) } return scanItems(rows, "persona item") } -// List 返回跨所有角色的每条记忆条目,最新的在前。rowid +// List 返回跨所有角色的每条**在册**记忆条目,最新的在前。rowid // 打破 created_at 并列,使同一时钟刻内写入的条目保持 -// 稳定的最新优先顺序。它是聚合/管理视图,枚举全部 -// 已持久化记忆,不受角色隔离边界限制;必须 +// 稳定的最新优先顺序。它是聚合/管理视图,不受角色隔离边界限制;必须 // 遵守隔离的调用方应改用 ListByPersona 或 ListScope。返回的 // 切片非 nil。 +// +// 「在册」= status='active':被软合并('merged')与被归档('archived') +// 的行**内容与行都还在**,但不再参与检索、不再进上下文、也不再出现在 +// 管理面板的记忆列表里(design/23 §8/§10 的软动作口径)。要看它们走 +// 审查留痕 `GET /admin/memories/reviews` 或按 id 用 Get —— 后者刻意 +// 不过 status 闸,因为撤销软合并必须先读得到被吸收的行。 func (s *Store) List(ctx context.Context) ([]MemoryItem, error) { rows, err := s.db.QueryContext(ctx, `SELECT id, persona_id, role_id, project_id, scope, kind, content, tags, confidence, source_conversation, created_at, updated_at, - decay_weight FROM memory_items - ORDER BY created_at DESC, rowid DESC`) + decay_weight FROM memory_items WHERE status = ? + ORDER BY created_at DESC, rowid DESC`, string(tidyStatusActive)) if err != nil { return nil, fmt.Errorf("memory: list items: %w", err) } diff --git a/internal/services/gateway/admin.go b/internal/services/gateway/admin.go index f1b20e0..4a09a7f 100644 --- a/internal/services/gateway/admin.go +++ b/internal/services/gateway/admin.go @@ -211,6 +211,10 @@ func (s *Server) collectSessions(ctx context.Context) []SessionSummary { } // collectMemories 从已配置的存储返回非 nil 的记忆切片。 +// +// 存储层的 List 已经过 status 闸(只给在册条目),因此面板记忆列表天然 +// 不显示被软合并/被归档的行 —— 这里刻意**不加**第二次过滤,避免两处 +// 口径各自漂移(design/23 §8/§10)。 func (s *Server) collectMemories(ctx context.Context) []MemorySummary { out := make([]MemorySummary, 0) if s.memories == nil {