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 等价)。
This commit is contained in:
AskaEth committed 2026-10-30 09:26:15 +08:00
1 parent a8bc2b4470
commit 44fbd29836
22 files changed
+1360 -102

No files matched your search

@@ -0,0 +1,168 @@
package memory
import (
"context"
"database/sql"
"fmt"
"strings"
)
// 本文件是**历史数据的敏感标记回填**(切片 M63,审计 §7 P0-1 的回填面)。
//
// 它是一次性数据动作,与结构前滚(sensitive_schema.go)**刻意分开**:
// 前滚永远幂等且零副作用,回填则可 dry-run、可重跑、只在需要时跑一次 ——
// 两个验收因此互不牵连(前滚失败是启动失败,回填标错是可以回头修的数据问题)。
//
// 🔴 **回填判据 = 人工确认清单的精确短语白名单**(sensitive.go 的 privateFacts,
// 理由见那里的三条)。不用泛类目关键词(「住」「称呼」「睡」「家人」「健康」):
// 误标会让受限车道永久丢掉合法习惯,而缺口主人看不见、也不会改回来。
//
// 🔴 **不依赖「先去重」**(审计 §7 P2-2 建议的先做去重):重复行全部标上才是安全
// 方向 —— 标一条重复行没有额外代价(合并/归档后它本来就不进召回),而漏标一条
// 就等于留一条泄漏面。去重只影响工作量,不是回填的前置条件。
//
// 🔴 **不改写 updated_at**:本列是数据层标记,不是内容/活跃度事件。改 updated_at
// 会连带篡改「最新在前」的列表顺序与衰减基准(decay 由 updated_at 参与排序),
// 让一次打标变成一次全库召回排序的扰动。
//
// **幂等**:UPDATE 带 `sensitive = 0` 前置条件,重跑只补标、不撤标、不改已标行;
// 重跑一次的 `Marked` 恒为 0(tests 钉住这条)。
//
// **生产执行口径**(本片实际执行的就是这一步,见交付报告):先在**备份**上跑
// `dryRun=true` 核对 `Matched`,再对生产库跑一次 `dryRun=false`;
// 生产库此刻若还没有 `sensitive` 列,先由 NewStore(或迁移 0028)补列 —— 顺序是
// 「补列 → 回填」,反过来会直接报 no such column。
// SensitiveBackfillReport 是一次回填(含 dry-run)的结果。它同时是**验收证据**:
// 受限车道的候选池规模(CandidatesBefore → CandidatesAfter)下降多少、每条白名单
// 短语各命中多少行,都在这里,调用方不必再跑去数据库里数一遍。
type SensitiveBackfillReport struct {
// DryRun 为 true 时本报告只统计、不写库(Marked 恒为 0)。
DryRun bool
// Matched 是命中白名单的行数(**全部** scope 与 status:仓库里还有 merged /
// archived 的行,撤销软合并会让 merged 的行重新进 active,故一并标)。
Matched int
// AlreadyFlagged 是命中白名单且**已是** sensitive=1 的行数(重跑时为 Matched
// 的子集,等于 Matched 即「已经跑过」)。
AlreadyFlagged int
// Marked 是本次实际把 sensitive 从 0 改成 1 的行数(dry-run 恒为 0)。
Marked int
// ByPhrase 是每条白名单短语各自命中的行数(标签见 privateFacts 的 Category)。
ByPhrase map[string]int
// CandidatesBefore / CandidatesAfter 是**受限车道候选池**的规模(active 且
// scope ∈ {user, project} 且 sensitive=0 的行数)—— 回填前后各取一次,差值就是
// 本片从群聊/陌生人候选池里移除的条数。
CandidatesBefore int
CandidatesAfter int
}
// sensitiveCandidatePoolSQL 数的是「受限车道(群聊 / 陌生人)的候选池」:四条列表
// 读路径给检索器的共享域候选,与 loop.FilterSharedVisible 的判据同源
// (active + scope ∈ {user, project} + 未标 sensitive)。回填前后各跑一次即可量化
// 本片收窄了多少。**它不接时效闸**(timeGateClause):这里量的是「表里有多少行
// 属于共享域」,不是「此刻能不能被召回」,接上闸会让两个时点的口径随墙上时钟漂移。
const sensitiveCandidatePoolSQL = `SELECT COUNT(*) FROM memory_items
WHERE status = ? AND scope IN (?, ?) AND sensitive = 0`
// BackfillSensitive 按 privateFacts 白名单给 memory_items 打敏感标记。
//
// dryRun=true 只统计不写库(生产执行的第 1 步:先在备份上核对 Matched)。
// 返回的报告同时是回填验收证据(见 SensitiveBackfillReport 各字段)。
// 调用前 `sensitive` 列必须已存在(NewStore 或迁移 0028 负责补列)。
func BackfillSensitive(ctx context.Context, db *sql.DB, dryRun bool) (SensitiveBackfillReport, error) {
report := SensitiveBackfillReport{DryRun: dryRun, ByPhrase: map[string]int{}}
clause, args := privateMatchClause()
before, err := countSharedCandidates(ctx, db)
if err != nil {
return report, err
}
report.CandidatesBefore = before
if err := countByPhrase(ctx, db, &report); err != nil {
return report, err
}
if report.Matched, err = countWhere(ctx, db, clause, args); err != nil {
return report, err
}
alreadyArgs := append(append([]any{}, args...), 1)
if report.AlreadyFlagged, err = countWhere(ctx, db, clause+" AND sensitive = ?", alreadyArgs); err != nil {
return report, err
}
if !dryRun && report.Matched > report.AlreadyFlagged {
marked, err := markSensitive(ctx, db, clause, args)
if err != nil {
return report, err
}
report.Marked = marked
}
if report.CandidatesAfter, err = countSharedCandidates(ctx, db); err != nil {
return report, err
}
return report, nil
}
// privateMatchClause 把白名单翻成 SQL 条件(`instr(content, ?) > 0` 的 OR 组合)与
// 参数。用占位符而不是把短语拼进 SQL:白名单是代码常量,但把「判据字符串」
// 与「SQL 文本」分开,才不会有人为了加一条短语去改 SQL 结构。
func privateMatchClause() (string, []any) {
parts := make([]string, 0, len(privateFacts))
args := make([]any, 0, len(privateFacts))
for _, f := range privateFacts {
parts = append(parts, "instr(content, ?) > 0")
args = append(args, f.Phrase)
}
return "(" + strings.Join(parts, " OR ") + ")", args
}
// countSharedCandidates 数受限车道的候选池规模(SQL 与口径见 sensitiveCandidatePoolSQL)。
func countSharedCandidates(ctx context.Context, db *sql.DB) (int, error) {
var n int
err := db.QueryRowContext(ctx, sensitiveCandidatePoolSQL,
string(tidyStatusActive), string(ScopeUser), string(ScopeProject)).Scan(&n)
if err != nil {
return 0, fmt.Errorf("memory: count shared candidates: %w", err)
}
return n, nil
}
// countByPhrase 逐条短语统计命中行数,填进 report.ByPhrase(键为 Category)。
func countByPhrase(ctx context.Context, db *sql.DB, report *SensitiveBackfillReport) error {
for _, f := range privateFacts {
var n int
if err := db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM memory_items WHERE instr(content, ?) > 0`,
f.Phrase).Scan(&n); err != nil {
return fmt.Errorf("memory: count private fact %s: %w", f.Category, err)
}
report.ByPhrase[f.Category] = n
}
return nil
}
// countWhere 数满足 where 的行数。
func countWhere(ctx context.Context, db *sql.DB, where string, args []any) (int, error) {
var n int
if err := db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM memory_items WHERE `+where, args...).Scan(&n); err != nil {
return 0, fmt.Errorf("memory: count sensitive candidates: %w", err)
}
return n, nil
}
// markSensitive 执行回填 UPDATE,返回实际改动的行数。`sensitive = 0` 前置条件
// 与「不动 updated_at」是本函数的两条硬要求(理由见文件头注)。
func markSensitive(ctx context.Context, db *sql.DB, clause string, args []any) (int, error) {
res, err := db.ExecContext(ctx,
`UPDATE memory_items SET sensitive = 1 WHERE sensitive = 0 AND `+clause, args...)
if err != nil {
return 0, fmt.Errorf("memory: backfill sensitive: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return 0, fmt.Errorf("memory: backfill sensitive row count: %w", err)
}
return int(n), nil
}