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,46 @@
package memory
import (
"context"
"database/sql"
"fmt"
"strings"
)
// 本文件是「敏感标记」轴(迁移 0028)的 schema 前滚:给**既有**的 memory_items
// 补 `sensitive` 列。手法与 retention_schema.go / temporal_schema.go 逐字同族,
// 原因也一样:`createMemoryItemsTable` 是 `CREATE TABLE IF NOT EXISTS`,只对
// **新库**生效,既有部署的表不会自己长出新列;而记忆存储必须自包含、能在任意
// *sql.DB 上使用,所以这条前滚不能只靠 `internal/db/migrations`。
//
// 🔴 与迁移的分工(两份真源,必须同形):权威机制是
// `internal/db/migrations/0028_memory_sensitive`;这里是「任意 *sql.DB」的前滚。
// 启动顺序是「先 MigrateUp、后 NewStore」(cmd/cyrene/wire.go),因此生产库里
// 迁移先落地、这里走到 `duplicate column name` 是**预期路径**。
//
// 默认值 0(= 非敏感):既有行因此**行为逐字不变**(受限车道照旧看得到它们),
// 本前滚刻意**不做**任何回填判定 —— 回填是独立的一次性数据动作
// (`BackfillSensitive`,sensitive_backfill.go):把「结构前滚」与「给历史数据
// 打标」分成两步,才能使两者的验收互不牵连(前滚永远幂等且零副作用;回填可
// dry-run、可重跑、可只跑一次)。
//
// 本列**不建索引**:受限车道的过滤在内存里做(候选集本来就已 `status` + 时效 +
// scope 过滤过,行数在千级),而「按 sensitive 取样巡检」是人工低频查询。将来
// 若真出现按它筛的读路径,两条真源(迁移与本文件)一起加。
// sensitiveColumn 是被测列名(迁移与自举 DDL 里的字面量)。
const sensitiveColumn = "sensitive"
// ensureSensitiveSchema 幂等地给 memory_items 补上敏感标记列(列缺失时 ALTER,
// 已存在时忽略 SQLite 的 "duplicate column name" —— 那是预期路径,不是错误)。
func ensureSensitiveSchema(ctx context.Context, db *sql.DB) error {
_, err := db.ExecContext(ctx, `ALTER TABLE memory_items
ADD COLUMN sensitive INTEGER NOT NULL DEFAULT 0`)
if err == nil {
return nil
}
if strings.Contains(err.Error(), "duplicate column name") {
return nil
}
return fmt.Errorf("memory: roll forward sensitive column: %w", err)
}