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 }