Files
Cyrene_v2/internal/runtime/memory/sensitive_backfill_test.go
T
AskaEth 44fbd29836 feat(memory): sensitive 标记 + 提取器敏感判据,私事退出受限车道召回
M63(M60 审计 P0-1/P0-2)。作用域 ≠ 敏感度:`scope='user'` 是共享域,里
面却装着住址(花溪 64 条 active)/私称(叶酱 63)/作息(早睡 38),于是受限车
道(群聊/陌生人)把共享域整锅注入上下文 —— 2026-10-29 群 531082168 的住址泄漏
即此路径。本片给 memory_items 加 `sensitive` 列,把「这条归谁」(scope)与
「这条能不能外说」(sensitive)拆成两根正交的轴。

- 迁移 0028:加列(INTEGER NOT NULL DEFAULT 0)+ **同迁移幂等回填**(精确短语
  白名单 花溪/叶酱/早睡,`AND sensitive=0`,不改 updated_at),down 只撤结构;
  memory 包内 `ensureSensitiveSchema` 同形前滚(任意 *sql.DB 自举)。
- 受限判据:新增 `MemoryItem.SharedVisible()` = 共享域 且 !sensitive,成为
  restricted 侧唯一起点;`loop.FilterSharedVisible` 与复用它的 knowledge 工具
  自动跟随。主人车道不过这道判据,照常读到全部记忆(sensitive 不是权限系统)。
- 提取器:prompt 追加 sensitive 位(住址/私人称呼/家人/健康/作息与行程 → true,
  口味/兴趣/项目约定 → false,含正反例);产出侧与落库侧共用
  `resolveSensitive`(模型的类目判断 ∨ 已实证短语白名单),只加标不撤标。
- 回填入口 `BackfillSensitive(ctx, db, dryRun)`:幂等、可 dry-run、返回候选池
  前后规模与逐短语计数(生产快照实测 3116 → 2948,标 725 行)。
- 读路径并到 15 列(scanItem + 7 处 SELECT);store.go 拆分 store_insert.go
  守住 500 行上限。

验收:go build/vet/test 全绿(memory + loop 单包复跑绿);删实现三处变异分别打红
(sensitiveColumn 恒 0 / SharedVisible 忽略 Sensitive / Extractor 跳过白名单);
生产库快照上跑 0028 后 Go 侧回填 marked=0(迁移 SQL 与 privateFacts 等价)。
2026-10-30 09:26:15 +08:00

163 lines
6.4 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"
"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
}