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

346 lines
16 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 (
"context"
"encoding/json"
"fmt"
"strings"
"sync"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// extractSystemPrompt 告诉 model.Adapter 如何产出候选记忆:
// 一个 JSON 对象数组,每个对象一条值得持久化的记忆。格式固定,
// 以便 parseCandidates 确定性地反序列化。溯源(source_conversation)
// 刻意不在其中:它由持久化层盖真实 session/message id,绝不由模型生成
// (裁决 04.4.3,见 source.go)。
//
// 2026-10-13(design/03 §10.3 的 **B 级:提示词加固**):prompt 里补上
// **正向清单一句话 + 反例清单一句话 + 明确允许返回 `[]`**。回应主人原话
// 「**收窄提取器,确实要给他一个主动式的记忆留存规则,不能想到什么都往记忆里
// 写**」。
//
// 🔴 为什么提示词**只是辅助**(design/03 §10.2):提示词产出是自由文本,**不可
// 验收**(测试只能断言「模型有没有听话」),且判据落在提示词上违反 AGENTS §9
// 「判据靠元数据,不靠提示词」。**真正的防线是 A 级规则闸**(extract_retention.go
// → judgeIngest,纯函数、可单测、在唯一写路径上);这里的两份清单是**同源口径的
// 第二道**,让模型与规则闸朝同一个方向使劲,而不是各写各的。
//
// 「允许返回 `[]`」是 Mem0 官方口径(design/23 §2:官方原话「Include examples
// that should produce []」)—— 不写这一句,模型会为了「有产出」而硬凑。
//
// 🆕 2026-10-27(切片 M45):prompt 里追加**时间性位** `temporal`(四个枚举值)。
// 它**不是**「又一段靠自然语言描述判据」的提示词加固:模型只负责**标注**一个值为
// 四个枚举之一,落库是 `temporal` 列(迁移 0027)。
//
// 🔴 **这一位目前不参与判定**(r1 复审裁决 D):`resolveRetention` 不读它,判定的
// 结论集与没有它逐字相同 —— 保留它只为积累「模型到底怎么标」的证据(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 = `你是记忆提取器。从对话中提取**值得长期记住**的记忆项。
只保留这些(正向清单):
- 长期事实:身份、住址、职业、家庭成员、健康约束(过敏、忌口、长期用药)。
- 稳定偏好:口味、作息、喜欢什么、讨厌什么。
- 关系里程碑:约定、承诺、共同经历、纪念日、称呼习惯。
- 项目约定:编号规则、暗号、路径、命名约定。
- 能力边界:主人明确说过的「不要做 X」。
下面这些**不要提取**(反例清单):
- 寒暄与致谢:「谢谢」「早上好」「辛苦啦」「晚安」。
- 情绪波动:「今天好累」「心情不错」。
- 一次性琐事与任务指令:「帮我订个 7 点的闹钟」「把这个文件转成 pdf」。
- 时效性数据:当前时间、天气、「现在几点了」。
- 助手自己说过的话 —— 只提取主人这边的长期事实与偏好。
- 不确定的推断(「他可能喜欢 X」):拿不准就不要提取。
**允许返回空数组 []**:一轮对话里没有值得长期记住的内容时,正确做法就是回
[],不要为了凑数而提取。
每条还要标一个**敏感位** "sensitive"(布尔值 true / false)。判据只有一句话:
**这句话能不能说给主人的朋友听**。
- true —— 主人只愿在私下被提起的事:住址与所在城市、真实姓名与私人称呼(主人
希望被怎么叫、专属昵称)、家庭成员的姓名与情况、健康与身体约束、作息与出行
行程、收入与工作单位,以及任何「主人告诉了助手、但不该转述给第三方」的内容。
- false —— 对谁都能说的稳定信息:口味与饮食偏好、兴趣爱好、喜欢的作品与游戏、
不涉及上面那些私事的相处方式、项目约定与编号规则。
正反例(照这个尺度标,不要自己另立一套):
- 「用户喜欢冰美式」→ sensitive=false(口味习惯,谁都能知道)。
- 「主人住在花溪」→ sensitive=true(住址)。
- 「主人希望被称作叶酱」→ sensitive=true(私人称呼)。
- 「主人常熬夜,助手会提醒早睡」→ sensitive=true(作息)。
- 「用户喜欢《塞尔达传说》」→ sensitive=false(兴趣爱好)。
- 「项目编号规则是 CY-日期-序号」→ sensitive=false(项目约定)。
拿不准就标 true —— 标成 true 的代价只是群聊里少提一句,标成 false 的代价是主人
的私事被说给外人。这个字段必填,不要省掉、不要填字符串。
每条还要标一个**时间性位** "temporal",只能取下面四个英文值之一:
- "one_off":这条说的是**某一次**发生的事(今天 / 昨天 / 这次 / 某个具体日子),
即使你已经把它写成「用户……」这样的陈述句;
- "recurring":说的是**反复发生**的规律(每天 / 每周 / 每次都…);
- "durable":说的是**长期成立**的事实 / 偏好 / 约定 / 称呼 / 身份;
- "unknown":拿不准,或者上面三个都不贴切。
temporal 只许填这四个英文值之一(不要换成中文、不要自造新值);拿不准就填
"unknown"。这个标记会被记录下来供以后核对用途,如实标注即可,不要为了省字省掉它。
只输出一个 JSON 数组,不要任何其他文字。每个元素形如:
{"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。`
// llmCandidate 是提取器要求 LLM 输出的线上形状:每条候选
// 记忆一个 JSON 对象。模型未给出合法值时,Scope/Kind 取提取器配置的
// 默认值;Temporal 则**一律归 unknown**(temporal.go 的 normalizeTemporal,
// 绝不猜:猜成 one_off 的方向是把该留的记忆删掉)。不含 source_conversation:
// 模型即使回填该键也会被忽略(结构化溯源只来自持久化层)。
type llmCandidate struct {
Content string `json:"content"`
Scope string `json:"scope"`
Kind string `json:"kind"`
Temporal string `json:"temporal"`
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 从对话中推导候选记忆条目
// 来实现 Extractor(design §1 LLM 异步自动提取)。它把 LLM
// 调用在 Extract 中同步执行,在 ExtractAsync 中放到后台 goroutine 执行。
//
// 它是幂等且并发安全的:候选内容会规范化,
// 并在互斥锁保护的 seen 集合上去重,因此对同一条
// 记忆重复提取不会产生第二份,同一对话中两次出现的
// 相同内容会合并为一条。
type LLMExtractor struct {
adapter model.Adapter
personaID string
roleID string
scope Scope
kind Kind
mu sync.Mutex
seen map[string]struct{}
}
// NewLLMExtractor 构建 LLMExtractor,当模型未另行指定时,其提取出的条目默认为
// ScopePersona + KindRelationship,并以
// personaID/roleID 为作者。角色是*默认值*,不是
// 权威:多角色调用方(运行时循环,design/22 §4.2)以空
// 角色构建提取器,并在持久化时用产出该轮对话的角色给每条条目
// 盖章,因此要构建与角色无关的提取器就传 ""。nil adapter 在
// 使用期经 ErrNilAdapter 拒绝,与运行时其余部分的
// 惰性构造模式一致;调用方可先传 nil 构建结构体、之后再接上
// adapter。
func NewLLMExtractor(adapter model.Adapter, personaID, roleID string) *LLMExtractor {
return &LLMExtractor{
adapter: adapter,
personaID: personaID,
roleID: roleID,
scope: ScopePersona,
kind: KindRelationship,
seen: make(map[string]struct{}),
}
}
// Extract 返回 adapter 从 conversation 推导出的候选记忆条目。
// nil adapter 返回 ErrNilAdapter;ctx 被取消会在 LLM 调用前
// 中止。响应按 JSON 数组解析,然后
// 去重,因此本提取器已提取过的条目会被丢弃。
func (e *LLMExtractor) Extract(ctx context.Context, conversation []model.Message) ([]MemoryItem, error) {
if e.adapter == nil {
return nil, ErrNilAdapter
}
if err := ctx.Err(); err != nil {
return nil, err
}
resp, err := e.adapter.Chat(ctx, model.Request{
Messages: buildExtractionPrompt(conversation),
Purpose: "memory_extract",
}, model.Options{})
if err != nil {
return nil, fmt.Errorf("memory: llm extract: %w", err)
}
cands, err := parseCandidates(resp.Content)
if err != nil {
return nil, err
}
return e.dedupeAndBuild(cands), nil
}
// AsyncResult 是一次 ExtractAsync 调用的结果:候选条目
// 或中止提取的错误。
type AsyncResult struct {
Items []MemoryItem
Err error
}
// ExtractAsync 把对话提交给后台 goroutine,并返回一个
// channel:提取完成时投递恰好一条 AsyncResult,随后由该 goroutine
// 关闭。nil adapter 立即返回 ErrNilAdapter。若 ctx 在 goroutine 运行前
// 被取消,结果携带取消错误。
// 由于 Extract 幂等,对同一条记忆重复异步提取是
// 安全的,绝不会双写。
func (e *LLMExtractor) ExtractAsync(ctx context.Context, conversation []model.Message) (<-chan AsyncResult, error) {
if e.adapter == nil {
return nil, ErrNilAdapter
}
out := make(chan AsyncResult, 1)
go func() {
defer close(out)
if err := ctx.Err(); err != nil {
out <- AsyncResult{Err: err}
return
}
items, err := e.Extract(ctx, conversation)
out <- AsyncResult{Items: items, Err: err}
}()
return out, nil
}
// buildExtractionPrompt 把对话转成模型请求消息:
// 系统指令,外加一条携带对话记录的用户消息。
func buildExtractionPrompt(conversation []model.Message) []model.Message {
var b strings.Builder
for _, m := range conversation {
b.WriteString(string(m.Role))
b.WriteString(": ")
b.WriteString(messageText(m))
b.WriteByte('\n')
}
return []model.Message{
model.TextMessage(model.RoleSystem, extractSystemPrompt),
model.TextMessage(model.RoleUser, b.String()),
}
}
// parseCandidates 把 adapter 的文本响应解码为候选。被围栏包裹的
// ```json 块会被剥离,模型因此可以用 markdown 包住数组。空
// 或非数组内容不产出候选;JSON 格式错误则报错,
// 由调用方决定重试还是回退。
func parseCandidates(s string) ([]llmCandidate, error) {
s = strings.TrimSpace(s)
if strings.HasPrefix(s, "```") {
s = strings.TrimPrefix(s, "```")
s = strings.TrimSuffix(s, "```")
s = strings.TrimSpace(s)
s = strings.TrimPrefix(s, "json")
s = strings.TrimSpace(s)
}
if s == "" {
return nil, nil
}
if strings.HasPrefix(s, "{") {
return nil, fmt.Errorf("memory: llm candidates: expected a JSON array")
}
var cands []llmCandidate
if err := json.Unmarshal([]byte(s), &cands); err != nil {
return nil, fmt.Errorf("memory: decode llm candidates: %w", err)
}
if cands == nil {
return nil, nil
}
return cands, nil
}
// dedupeAndBuild 把合法候选转成 MemoryItem,丢弃空内容
// 以及本提取器已产出的内容(幂等去重)。它
// 要求持有 e.mu;只有合法条目才标记其去重键,因此格式错误的候选
// 不会压掉之后内容相同的合法候选。
//
// 这里不要求 persona,尽管 MemoryItem.validate 要求一个:
// 与角色无关的提取器(personaID == "")是合法的 —— 知道
// 该轮角色的调用方(循环)在持久化条目时盖上角色 —— 而
// 在此拒绝条目会静默丢掉多角色
// 进程的每一条记忆。只有本包能判断的
// content/scope/kind 仍会施加。SourceConversation 同理刻意留空:由提取调用点(持久化层)
// 盖真实 session/message id(裁决 04.4.3,见 source.go)。
func (e *LLMExtractor) dedupeAndBuild(cands []llmCandidate) []MemoryItem {
e.mu.Lock()
defer e.mu.Unlock()
out := make([]MemoryItem, 0, len(cands))
for _, c := range cands {
content := strings.TrimSpace(c.Content)
if content == "" {
continue
}
key := dedupeKey(content)
if _, ok := e.seen[key]; ok {
continue
}
it := MemoryItem{
PersonaID: e.personaID,
RoleID: e.roleID,
ProjectID: strings.TrimSpace(c.ProjectID),
Scope: Scope(normalizeScope(c.Scope, string(e.scope))),
Kind: Kind(normalizeKind(c.Kind, string(e.kind))),
Content: content,
Tags: c.Tags,
Confidence: c.Confidence,
// 时间性位(迁移 0027)在**产出侧**归一:模型只能选四个值之一,
// 其余(空 / 非法 / 自造)一律归 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
}
e.seen[key] = struct{}{}
out = append(out, it)
}
return out
}
// dedupeKey 为幂等去重规范化内容:大小写折叠并
// 压缩空白,因此「记住 A」与「记住 a」会归并为同一条记忆。
func dedupeKey(content string) string {
return strings.Join(strings.Fields(strings.ToLower(content)), " ")
}
// normalizeScope 在 s 命名了已定义作用域时返回 s,否则返回 fallback。
func normalizeScope(s, fallback string) string {
if Scope(s).valid() {
return s
}
return fallback
}
// normalizeKind 在 s 命名了已定义 kind 时返回 s,否则返回 fallback。
func normalizeKind(s, fallback string) string {
if Kind(s).valid() {
return s
}
return fallback
}