Files
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

86 lines
3.0 KiB
Go
Raw Permalink 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 (
"database/sql"
"encoding/json"
"fmt"
)
// 本文件是 memory_items 的**行映射**(scanItem / scanItems 与它们的公共接口
// rowScanner),从 store.go 拆出来只为守 AGENTS §10 的文件行数上限(≤500 行)
// 与「模块按职责拆源文件」那条规范 —— 口径逐字未变。
//
// 放在独立的源文件里还有一层好处:读路径的每一处「哪些列被读出来」都集中在此,
// 与写路径(store.go 的 Add 与 preinsert_gate.go 的闸)对照时不必在 500 行里翻。
// rowScanner 同时被 *sql.Row 与 *sql.Rows 满足,因此单个 scanItem
// 辅助函数即可服务 Get 与 ListByPersona。
type rowScanner interface {
Scan(dest ...any) error
}
// scanItems 把每一行读入 MemoryItem 切片,关闭 rows 并用
// 操作名包裹 scan/遍历失败。成功时总是返回
// 非 nil、可能为空的切片。
func scanItems(rows *sql.Rows, op string) ([]MemoryItem, error) {
defer func() { _ = rows.Close() }()
items := make([]MemoryItem, 0)
for rows.Next() {
item, err := scanItem(rows)
if err != nil {
return nil, fmt.Errorf("memory: scan %s: %w", op, err)
}
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("memory: iterate %s: %w", op, err)
}
return items, nil
}
// scanItem 读取一行 memory_items。tags 以 JSON 数组字符串存储;
// 时间戳以定宽 UTC 字符串(timeLayout)存储,因此文本
// 比较即可按时间排序。
//
// retention_tier 是**第 14 列**(迁移 0026):它与 `kind` 是两根独立的轴,
// 因此这里只做「读出来」,不参与任何推导。空串原样成为
// RetentionTierUnset(= 尚未分型),绝不被折成某一档。
//
// sensitive 是**第 15 列**(迁移 0028):SQLite 的 INTEGER 0/1 在这里折回 bool。
// 它被读出来的**唯一**理由是要给受限车道的可见性判据用(SharedVisible)——
// 条目不带这一位,过滤就无从过滤;它不参与任何排名/判定。
func scanItem(sc rowScanner) (MemoryItem, error) {
var (
it MemoryItem
scope, kind, tier string
tagsJSON string
sensitive int
createdAt, updatedAt string
)
if err := sc.Scan(&it.ID, &it.PersonaID, &it.RoleID, &it.ProjectID, &scope, &kind,
&it.Content, &tagsJSON, &it.Confidence, &it.SourceConversation,
&createdAt, &updatedAt, &it.DecayWeight, &tier, &sensitive); err != nil {
return MemoryItem{}, err
}
it.Scope = Scope(scope)
it.Kind = Kind(kind)
it.RetentionTier = RetentionTier(tier)
it.Sensitive = sensitive != 0
if err := json.Unmarshal([]byte(tagsJSON), &it.Tags); err != nil {
return MemoryItem{}, fmt.Errorf("memory: decode tags: %w", err)
}
if it.Tags == nil {
it.Tags = []string{}
}
var err error
if it.CreatedAt, err = parseTime(createdAt); err != nil {
return MemoryItem{}, err
}
if it.UpdatedAt, err = parseTime(updatedAt); err != nil {
return MemoryItem{}, err
}
return it, nil
}