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

+36 -49
View File
@@ -29,9 +29,7 @@ package memory
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
@@ -124,7 +122,7 @@ type MemoryItem struct {
// - 但 `Get` / `List*` 一律不 SELECT 该列(读侧闸是纯 SQL,见 timegate.go),
// 故**从库里读回来的**条目 ExpiresAt 恒为零值。
//
// 要读回时效需同步 14 列 SELECT 与 scanItem,并连带 tidy/preinsert 的内部召回
// 要读回时效需同步 15 列 SELECT 与 scanItem,并连带 tidy/preinsert 的内部召回
// SQL —— 那是改扫描口径,不在本切片授权范围。
ExpiresAt time.Time
@@ -146,10 +144,20 @@ type MemoryItem struct {
//
// ⚠️ **与 ExpiresAt 同样是「只写不读」的列**:`Add` 把它写进 `temporal` 列,
// 但四条列表读路径的 SELECT 与 scanItem 都不读它 —— 改扫描口径要动全部读点
// (rules.go / store_list.go / tidy_store.go / preinsert_store.go,共 14→15 列),
// 那是另一件事,不在本片授权范围。要按时间性位巡检/回填时直读该列即可;
// 写入侧的校验/编码见 store_temporal.go。
// (rules.go / store_list.go / tidy_store.go / preinsert_store.go,现为 15 列,
// 加它就是 16 列),那是另一件事,不在本片授权范围。要按时间性位巡检/回填时
// 直读该列即可;写入侧的校验/编码见 store_temporal.go。
Temporal TemporalKind
// Sensitive 是**敏感标记**(迁移 0028、sensitive.go):true = 这条记忆是主人的
// 私事(住址 / 私称 / 作息 ……),因此**受限车道**(群聊 / 陌生人)的召回过滤器
// 会把整条排除在候选池外。
//
// 🔴 它是**数据层标记,不是权限系统**:主人车道(非受限环境)照常读到全部记忆
// (含 sensitive=1),没有任何读路径因它拒绝主人;它只影响
// `MemoryItem.SharedVisible` 这一条判据。谁是主人仍只由 actor 元数据 + 白名单
// + 策略引擎决定(AGENTS §9)。判据与理由见 sensitive.go 头注。
Sensitive bool
}
// 区分 memory 包各类失败的哨兵错误。每个都在
@@ -265,6 +273,11 @@ func NewStore(ctx context.Context, db *sql.DB, log *slog.Logger) (*Store, error)
if err := ensureTemporalSchema(ctx, db); err != nil {
return nil, err
}
// 敏感标记(迁移 0028):同上,只补列、不回填 —— 历史数据的打标是独立的一次性
// 动作(BackfillSensitive),使结构前滚保持零副作用(见 sensitive_schema.go)。
if err := ensureSensitiveSchema(ctx, db); err != nil {
return nil, err
}
if err := normalizeMemoryTimestamps(ctx, db); err != nil {
return nil, err
}
@@ -309,6 +322,11 @@ func NewStore(ctx context.Context, db *sql.DB, log *slog.Logger) (*Store, error)
// 不读这一位,因此档位/有效期与「没有这一位」逐字相同;列值的作用是**观测**——
// 事后能按它分组统计、抽样看模型把哪些行标成了 `one_off`(这是将来决定要不要接线
// 的唯一证据来源,见 temporal.go 头注的查询)。
//
// 🆕 切片 M63:`item.Sensitive`(敏感标记,迁移 0028)也随**这一笔** INSERT 落下。
// 它**不参与任何判定**:档位 / 时效 / 读路径的可见性集合与「没有这一位」逐字相同
// —— 唯一的消费者是受限车道的过滤判据(sensitive.go 的 SharedVisible),而主人
// 车道根本不过那道判据。写入侧另有一条**白名单兜底**(见下面 Add 内的判定)。
func (s *Store) Add(ctx context.Context, item MemoryItem) (MemoryItem, error) {
if err := item.normalize(); err != nil {
return MemoryItem{}, err
@@ -317,6 +335,16 @@ func (s *Store) Add(ctx context.Context, item MemoryItem) (MemoryItem, error) {
if item, err = applyTimestamps(item); err != nil {
return MemoryItem{}, err
}
// 🆕 切片 M63(迁移 0028):敏感标记的**写入侧兜底** —— 判据靠代码,不靠提示词
// (AGENTS §9)。提取器的 `sensitive` 位是**类目级**判断(新私事的措辞无法穷举,
// 见 llmextractor.go 的正反例);这里是**已实证短语**的确定性兜底
// (IsPrivateContent,判据与理由见 sensitive.go)。合成只在 resolveSensitive
// 一处做(「或」,只加标不撤标),提取器产出侧与这里走同一个函数。
if resolved := resolveSensitive(item.Sensitive, item.Content); resolved != item.Sensitive {
item.Sensitive = resolved
s.log.Debug("memory: sensitive flag set by private-fact whitelist",
"id", item.ID, "scope", string(item.Scope))
}
// 🔴 档位判定(唯一入口):把结果写回条目,闸与异步审查因此**只读**这一位,
// 不再各自跑一遍留存规则(见 preinsert_judge.go 的 defer 分支)。
verdict, err := resolveRetention(item)
@@ -337,18 +365,8 @@ func (s *Store) Add(ctx context.Context, item MemoryItem) (MemoryItem, error) {
plan := s.planIngest(ctx, item)
item.ExpiresAt = plan.ExpiresAt
_, err = s.db.ExecContext(ctx, `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)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
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())
if err != nil {
return MemoryItem{}, fmt.Errorf("memory: insert item: %w", err)
if err := s.insertItem(ctx, item, tagsJSON, plan); err != nil {
return MemoryItem{}, err
}
s.finishIngest(ctx, item, plan)
if plan.Action != ingestPlanAbsorbNew {
@@ -460,37 +478,6 @@ func (it *MemoryItem) validate() error {
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
}
// discardLogger 在 log 非 nil 时返回 log,否则返回一个不写任何地方的 logger。
func discardLogger(log *slog.Logger) *slog.Logger {
if log != nil {