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

+47 -1
View File
@@ -38,6 +38,19 @@ import (
// 结论集与没有它逐字相同 —— 保留它只为积累「模型到底怎么标」的证据(temporal.go
// 头注记着两次被生产重放否掉的接线方案与接线前必须看的那份分布)。因此这段 prompt
// 的**唯一**作用是让观测面有数据;即便模型完全不听,判定的行为与今天一致。
//
// 🆕 2026-10-30(切片 M63,审计 §7 P0-2):prompt 里追加**敏感位** `sensitive`。
// 它**参与**受限车道的可见性(落库是 `sensitive` 列,迁移 0028;消费方是
// sensitive.go 的 SharedVisible),但判据不走「靠提示词」那条老路:
// - 模型只做**类目级标注**(住址 / 私人称呼 / 家人 / 健康 / 作息与行程 / 收入
// → true),因为新私事的措辞无法穷举;
// - 确定性那一半在代码里:`resolveSensitive` 会把产出与**已实证短语白名单**
// 取「或」(sensitive.go 的 privateFacts),模型漏标时由白名单补上。
//
// ⚠️ 注意 prompt 的**提取面没有缩小**:住址 / 家人 / 健康 / 称呼习惯仍然要提取,
// 只是必须标 sensitive —— 塔裁决 2026-10-30 明确「私事用 sensitive 标记,不迁
// scope」,因为迁进 persona 域会让其他角色可读(memory/rules.go 跨角色只读)。
// 主人车道照常读到这些记忆,受限车道才看不见。
const extractSystemPrompt = `你是记忆提取器。从对话中提取**值得长期记住**的记忆项。
只保留这些(正向清单):
@@ -58,6 +71,25 @@ const extractSystemPrompt = `你是记忆提取器。从对话中提取**值得
**允许返回空数组 []**:一轮对话里没有值得长期记住的内容时,正确做法就是回
[],不要为了凑数而提取。
每条还要标一个**敏感位** "sensitive"(布尔值 true / false)。判据只有一句话:
**这句话能不能说给主人的朋友听**。
- true —— 主人只愿在私下被提起的事:住址与所在城市、真实姓名与私人称呼(主人
希望被怎么叫、专属昵称)、家庭成员的姓名与情况、健康与身体约束、作息与出行
行程、收入与工作单位,以及任何「主人告诉了助手、但不该转述给第三方」的内容。
- false —— 对谁都能说的稳定信息:口味与饮食偏好、兴趣爱好、喜欢的作品与游戏、
不涉及上面那些私事的相处方式、项目约定与编号规则。
正反例(照这个尺度标,不要自己另立一套):
- 「用户喜欢冰美式」→ sensitive=false(口味习惯,谁都能知道)。
- 「主人住在花溪」→ sensitive=true(住址)。
- 「主人希望被称作叶酱」→ sensitive=true(私人称呼)。
- 「主人常熬夜,助手会提醒早睡」→ sensitive=true(作息)。
- 「用户喜欢《塞尔达传说》」→ sensitive=false(兴趣爱好)。
- 「项目编号规则是 CY-日期-序号」→ sensitive=false(项目约定)。
拿不准就标 true —— 标成 true 的代价只是群聊里少提一句,标成 false 的代价是主人
的私事被说给外人。这个字段必填,不要省掉、不要填字符串。
每条还要标一个**时间性位** "temporal",只能取下面四个英文值之一:
- "one_off":这条说的是**某一次**发生的事(今天 / 昨天 / 这次 / 某个具体日子),
即使你已经把它写成「用户……」这样的陈述句;
@@ -69,7 +101,7 @@ temporal 只许填这四个英文值之一(不要换成中文、不要自造
"unknown"。这个标记会被记录下来供以后核对用途,如实标注即可,不要为了省字省掉它。
只输出一个 JSON 数组,不要任何其他文字。每个元素形如:
{"content":"记忆内容","scope":"user|persona|project","kind":"relationship|world_event","temporal":"one_off|recurring|durable|unknown","tags":["标签"],"confidence":0.9,"project_id":"可选项目id"}
{"content":"记忆内容","scope":"user|persona|project","kind":"relationship|world_event","sensitive":true,"temporal":"one_off|recurring|durable|unknown","tags":["标签"],"confidence":0.9,"project_id":"可选项目id"}
去噪去重:只保留值得长期记住的,忽略寒暄与临时信息;确实发生过的**一次性事件**
如果值得记,也照实提取,并把 temporal 标成 one_off。`
@@ -86,6 +118,15 @@ type llmCandidate struct {
Tags []string `json:"tags"`
Confidence float64 `json:"confidence"`
ProjectID string `json:"project_id"`
// Sensitive 是模型给出的**敏感位**(迁移 0028,切片 M63):true = 这条是主人的
// 私事(住址 / 私人称呼 / 家人 / 健康 / 作息/行程 / 收入 …),受限车道不许召回。
//
// 🔴 它**不是**权限判定(AGENTS §9 只约束身份判定):它只是一个数据层标记,主人
// 车道照常读到。模型漏标或干脆没给这个键 → 缺席即 false,再落到 resolveSensitive
// 的第二道判据(已实证短语白名单)上;键的类型填错(例如给字符串 "true")会让
// **整批** JSON 解码失败 —— 与其它字段同一口径,由调用方决定重试还是回退,
// 这里不为了容错去猜模型的意图(猜错的方向是把私事当成可外说)。
Sensitive bool `json:"sensitive"`
}
// LLMExtractor 让 model.Adapter 从对话中推导候选记忆条目
@@ -266,6 +307,11 @@ func (e *LLMExtractor) dedupeAndBuild(cands []llmCandidate) []MemoryItem {
// 其余(空 / 非法 / 自造)一律归 unknown —— 解析失败绝不报错,
// 也不拖累同批的其他候选(见 temporal.go 的 normalizeTemporal)。
Temporal: normalizeTemporal(c.Temporal),
// 敏感位(迁移 0028)在**产出侧**合成:模型的类目判断与已实证短语白名单
// 取或(resolveSensitive)。两次判据都放在这里而不是只等 Store.Add 兜底,
// 是为了让 **dry-run**(只跑 Extract、不落库)看到的位就是将来落库的位 ——
// 否则「提取器判据」这条验收只能靠真落库才看得见。
Sensitive: resolveSensitive(c.Sensitive, content),
}
if !it.Scope.valid() || !it.Kind.valid() {
continue