package memory import ( "context" "database/sql" "testing" ) // 本文件钉住**历史数据回填**(sensitive_backfill.go)的四条: // // 1. dry-run 一行都不写(生产执行的第 1 步是在备份上核对数量,写坏就没法核对); // 2. 幂等:连跑两次,第二次 Marked=0、AlreadyFlagged=Matched; // 3. 只动 sensitive,**不动 updated_at**(否则一次打标会扰动「最新在前」的排序与 // 衰减基准); // 4. 报告的候选池前后对比与白名单逐短语计数可用于验收(回填前后受限召回候选数)。 // // 历史行在这里用**裸 SQL** 造(只给必填列),sensitive 走列默认值 0 —— 这正是 // 0028 之前落库的行的形态;若改用 Store.Add 造,写入侧兜底会先把它们标成 1, // 就考不出「回填有没有生效」了。 // seedLegacySensitiveRow 以 0028 之前的形态写一行(不列 sensitive)。 func seedLegacySensitiveRow(t *testing.T, d *sql.DB, id, scope, content string) { t.Helper() if _, err := d.ExecContext(context.Background(), `INSERT INTO memory_items (id, persona_id, scope, kind, content, created_at, updated_at, status) VALUES (?, 'persona.a', ?, 'relationship', ?, `+legacyTimeLiteral+`, `+legacyTimeLiteral+`, 'active')`, id, scope, content); err != nil { t.Fatalf("seed legacy row %s: %v", id, err) } } // readSensitiveCell 直读 sensitive 列(不走 scanItem:这里要验的是库里那一格)。 func readSensitiveCell(t *testing.T, d *sql.DB, id string) int { t.Helper() var v int if err := d.QueryRowContext(context.Background(), `SELECT sensitive FROM memory_items WHERE id = ?`, id).Scan(&v); err != nil { t.Fatalf("read sensitive of %s: %v", id, err) } return v } // seedSensitiveFixture 造一份「0028 之前的库」:两条私事(user / project 各一)+ // 一条习惯 + 一条角色日常私事。 func seedSensitiveFixture(t *testing.T, s *Store) { t.Helper() seedLegacySensitiveRow(t, s.db, "legacy-addr", string(ScopeUser), "用户居住在花溪(贵阳花溪区)") seedLegacySensitiveRow(t, s.db, "legacy-name", string(ScopeProject), "称呼约定:主人只叫「叶酱」") seedLegacySensitiveRow(t, s.db, "legacy-habit", string(ScopeUser), "用户喜欢冰美式") seedLegacySensitiveRow(t, s.db, "legacy-persona", string(ScopePersona), "主人常熬夜,助手提醒早睡") } // TestBackfillSensitive_干跑不写库 钉住 dry-run 的零副作用。 func TestBackfillSensitive_干跑不写库(t *testing.T) { t.Parallel() ctx := context.Background() s := newTestStore(t) seedSensitiveFixture(t, s) report, err := BackfillSensitive(ctx, s.db, true) if err != nil { t.Fatalf("BackfillSensitive(dry-run): %v", err) } if report.Marked != 0 { t.Fatalf("dry-run 的 Marked = %d, want 0(干跑一行都不许写)", report.Marked) } if report.Matched != 3 { t.Fatalf("Matched = %d, want 3(花溪 / 叶酱 / 早睡 各一条)", report.Matched) } for _, id := range []string{"legacy-addr", "legacy-name", "legacy-persona", "legacy-habit"} { if got := readSensitiveCell(t, s.db, id); got != 0 { t.Fatalf("dry-run 之后 %s 的 sensitive = %d, want 0", id, got) } } } // TestBackfillSensitive_幂等且不动updatedAt 是回填的主用例:标对、标全、 // 重跑无副作用、且不碰 updated_at。 func TestBackfillSensitive_幂等且不动updatedAt(t *testing.T) { t.Parallel() ctx := context.Background() s := newTestStore(t) seedSensitiveFixture(t, s) report, err := BackfillSensitive(ctx, s.db, false) if err != nil { t.Fatalf("BackfillSensitive: %v", err) } if report.Marked != 3 { t.Fatalf("Marked = %d, want 3", report.Marked) } if report.Matched != 3 || report.AlreadyFlagged != 0 { t.Fatalf("Matched/AlreadyFlagged = %d/%d, want 3/0(首跑前没有任何一行已标)", report.Matched, report.AlreadyFlagged) } for _, id := range []string{"legacy-addr", "legacy-name", "legacy-persona"} { if got := readSensitiveCell(t, s.db, id); got != 1 { t.Fatalf("%s 的 sensitive = %d, want 1", id, got) } } if got := readSensitiveCell(t, s.db, "legacy-habit"); got != 0 { t.Fatalf("习惯行被误标 sensitive = %d, want 0", got) } // updated_at 必须一个字节都没变(打标不是活跃度事件)。 var updated string if err := s.db.QueryRowContext(ctx, `SELECT updated_at FROM memory_items WHERE id = 'legacy-addr'`).Scan(&updated); err != nil { t.Fatalf("read updated_at: %v", err) } if updated != "2026-10-01T10:00:00.000000000Z" { t.Fatalf("回填改写了 updated_at = %q(不许动它:会扰动排序与衰减基准)", updated) } // 幂等:重跑只补标。 again, err := BackfillSensitive(ctx, s.db, false) if err != nil { t.Fatalf("BackfillSensitive(第二次): %v", err) } if again.Marked != 0 || again.Matched != 3 || again.AlreadyFlagged != 3 { t.Fatalf("重跑 = Marked %d / Matched %d / Already %d, want 0/3/3(必须幂等)", again.Marked, again.Matched, again.AlreadyFlagged) } } // TestBackfillSensitive_候选池收窄 是验收口径:受限车道候选池(active 且 // scope ∈ {user, project} 且 sensitive=0)在回填后按标记条数下降。 func TestBackfillSensitive_候选池收窄(t *testing.T) { t.Parallel() ctx := context.Background() s := newTestStore(t) seedSensitiveFixture(t, s) before := mustCandidateCount(t, ctx, s) report, err := BackfillSensitive(ctx, s.db, false) if err != nil { t.Fatalf("BackfillSensitive: %v", err) } after := mustCandidateCount(t, ctx, s) if report.CandidatesBefore != before || report.CandidatesAfter != after { t.Fatalf("报告候选池 = %d→%d, 实际 = %d→%d(报告必须能当验收证据)", report.CandidatesBefore, report.CandidatesAfter, before, after) } // 前后各 3 条共享域行(2 user + 1 project),回填标掉 2 条(花溪 / 叶酱)。 if before != 3 || after != 1 { t.Fatalf("候选池 = %d→%d, want 3→1", before, after) } if report.ByPhrase["住址"] != 1 || report.ByPhrase["私称"] != 1 || report.ByPhrase["作息"] != 1 { t.Fatalf("逐短语计数 = %v, want 每条各 1", report.ByPhrase) } } // mustCandidateCount 取受限车道候选池规模(与回填报告同一个查询)。 func mustCandidateCount(t *testing.T, ctx context.Context, s *Store) int { t.Helper() n, err := countSharedCandidates(ctx, s.db) if err != nil { t.Fatalf("count shared candidates: %v", err) } return n }