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