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 个读路径用例与检索端到端用例全红。
This commit is contained in:
1 parent
c2e413ec38
commit
9126d653e5
4 files changed
+233
-11
No files matched your search
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user