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