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 等价)。
163 lines
6.4 KiB
Go
163 lines
6.4 KiB
Go
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
|
||
}
|