Files
Cyrene_v2/internal/runtime/memory/sensitive_backfill.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

169 lines
7.7 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"
"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
}