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

77 lines
3.1 KiB
Go

package memory
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"time"
)
// 本文件是 memory_items 的**写入侧行装配**(唯一 INSERT 语句、id 与时间戳分配),
// 从 store.go 拆出来只为守 AGENTS §10 的文件行数上限(≤500 行)与「模块按职责拆
// 源文件」那条规范 —— 口径逐字未变。
//
// 与 store_rows.go(读侧行映射)成对:那里是「列 → MemoryItem」,这里是
// 「MemoryItem → 列」。两边的列序必须**同一套**:scanItem 读 15 列,
// 本文件的 INSERT 也写 15 列(`… temporal, sensitive` 收尾)。
// insertMemoryItemSQL 是全仓唯一的一条 memory_items INSERT。列序与 store_rows.go
// 的 scanItem 同源(同一张表的同一套列),新增列一律追加在末尾。
//
// 🔴 本语句**不经** `status` / `expires_at` / `retention_tier` / `temporal` /
// `sensitive` 的默认值路径:前四者由 `ingestGatePlan` 与条目自身给出(档位与时效
// 是**同一个判定**的产物,见 store.go 的 Add),`sensitive` 由条目给出(提取器判据
// + 白名单兜底,见 sensitive.go),一句话都写死在这里,别处不再拼第二份。
const insertMemoryItemSQL = `INSERT INTO memory_items
(id, persona_id, role_id, project_id, scope, kind, content, tags, confidence,
source_conversation, created_at, updated_at, decay_weight, status, merged_into,
expires_at, retention_tier, temporal, sensitive)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
// insertItem 落一行 memory_items。调用方(Add)负责在此前完成规范化、时间戳与
// 档位判定;这里只做「条目 → 列」的映射。
func (s *Store) insertItem(ctx context.Context, item MemoryItem, tagsJSON []byte, plan ingestGatePlan) error {
_, err := s.db.ExecContext(ctx, insertMemoryItemSQL,
item.ID, item.PersonaID, item.RoleID, item.ProjectID, string(item.Scope), string(item.Kind),
item.Content, string(tagsJSON), item.Confidence, item.SourceConversation,
formatTime(item.CreatedAt), formatTime(item.UpdatedAt), item.DecayWeight,
plan.insertStatus(), plan.absorbedID(), plan.expiresAt(), plan.retention(),
item.temporalColumn(), item.sensitiveColumn())
if err != nil {
return fmt.Errorf("memory: insert item: %w", err)
}
return nil
}
// applyTimestamps 分配 id 与缺失的 CreatedAt/UpdatedAt 值。
// 仅在无法生成新 id 时返回错误。
func applyTimestamps(item MemoryItem) (MemoryItem, error) {
now := time.Now().UTC()
if item.ID == "" {
id, err := newID()
if err != nil {
return MemoryItem{}, err
}
item.ID = id
}
if item.CreatedAt.IsZero() {
item.CreatedAt = now
}
if item.UpdatedAt.IsZero() {
item.UpdatedAt = now
}
return item, nil
}
// newID 返回一个 128 位密码学随机 id,十六进制编码。它避开
// UUID 依赖,使本包保持自包含。rand.Read 只在
// 平台熵源损坏时失败,因此错误向上传播。
func newID() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("memory: generate id: %w", err)
}
return hex.EncodeToString(b), nil
}