Files
Cyrene_v2/internal/runtime/memory/extract_retention.go
T
AskaEth 21b906ffb0 feat(memory): D3 第五态 defer(入库审查)+ 提取留存规则闸 + 临时 TTL 配置面
主人原话:「收窄提取器,确实要给他一个主动式的记忆留存规则,不能想到什么都
往记忆里写」「.env 只做兜底用 …这些键优先从面板配置上取」「临时记忆到期删除」。
按 design/23 §18(D3 第五态 defer)、§19(到期处置)、design/03 §9.6
(tempTtlSec 默认 7 天 + 坏值口径)、§10(提取留存规则)落地。

- IngestDecision 闭集加第五态 defer:照常 active 落库 + 写短 expires_at 落
  临时层(不删行、不改正文,回滚 = 清空该列);IngestRuleVersion d3.2 → d3.3。
- 落点:ingestGatePlan 增 ExpiresAt + expiresAt()(走 expiryText/formatTime 的
  定宽 30 字节 UTC,与读侧时效闸同一套文本口径),由 finishIngest 在 defer 一态
  补写 expires_at(INSERT 与补写之间的短暂窗口如实登记,方向 = 宁可多留)。
  非 defer 三态一笔都不多写。
- T0 提取留存规则闸(extract_retention.go):design/03 §10.5 的 N1~N5 纯规则,
  接进 judgeIngest 的候选比对之前,命中即 defer + reason=t0_low_value,不进 T1;
  两道护栏(显式「记住」/ 持久信号)一票放行,fail-safe 方向 = 宁可多留。
- T1 闭集扩为 merge/modify/keep/defer(同一个 prompt、同一次调用,绝不新增
  第二次模型调用):只有关系判为真新时才问价值;defer 要求 target=0,否则判
  非法回落 create(reason=t1_reply_invalid)。
- 提取器提示词加固(design/03 §10.3 B):正向清单 + 反例清单 + 「允许返回 []」。
- 临时记忆 TTL:代码默认 tempTtlSec = 604800(7 天);面板
  $DATA_DIR/config/memory-tidy.json 的 tempTtlSec 优先(ApplyReviewTuning 逐键
  解析,坏值回落默认 + WARN,绝不写进 cfg)。该键在 .env 里没有出处且不新增
  (.env 已冻结)⇒ 实际生效链是「面板 > 代码默认」。启动播种同步该键(*int +
  omitempty,nil 省略、绝不写 null),保证播种文件与生效值逐字一致。
- preinsert_store.go 的 listReviewCandidatesSQL 原位注释:这条内部召回 SQL
  刻意不过时效闸的裁定与理由(design/03 §11.2 第 3 条)。
- 测试:defer 落库带短 expires_at(闸 + T1 两条路径)、写入口径与时效闸同源、
  TTL 坏值与回落、规则版本 d3.3、整理候选幂等键不含 IngestRuleVersion、
  提取规则正例 11 条 / 反例 11 条。

验收:go build ./... + go vet ./... + go test ./... 全绿;windows/darwin 交叉
编译通过(GOOS=darwin GOARCH=arm64 / GOOS=windows GOARCH=amd64)。
2026-10-13 20:22:48 +08:00

295 lines
14 KiB
Go
Raw 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 (
"strings"
)
// 本文件是**提取留存规则闸**(design/03 §10,即 design/23 §15.1 D2 点名的
// 「另立规格」)的 A 级实现:**纯规则 + 词项匹配**,对一条**刚落库的**新记忆判
// 「它是不是结构上显然不值得长期留存」(design/03 §10.5 的反例清单 N1~N5)。
//
// 它回应主人的两条原话:「**收窄提取器,确实要给他一个主动式的记忆留存规则,
// 不能想到什么都往记忆里写**」与「**临时记忆到期删除**」。
//
// 🔴 落点硬约束(design/03 §10.3):**不许新造第二条写路径**。本闸**不新开入口**,
// 而是接进既有的唯一写路径 —— `judgeIngest`(preinsert_judge.go)在**候选比对
// 之前**先过一遍这里,命中即返回与 design/23 §18.1 第五态**同一枚举 `defer`、
// 同一落点 `expires_at`**。于是「提取得宽」由**同一条**入库路径收敛,不存在
// 绕过闸的写口;命中者**不进 T1**(§10.6:规则闸拦下的不进 T1,避免「模型推翻
// 规则」的架构回归)。
//
// 判据为什么必须是纯函数而不是提示词(design/03 §10.2):现行全仓唯一的留存判据
// 是一句自然语言(`llmextractor.go` 的 extractSystemPrompt),那**违反 AGENTS §9
// 「判据靠元数据,不靠提示词」**,且**不可验收**(提示词产出是自由文本,测试只能
// 断言「模型有没有听话」)。B 级提示词加固仍在做(正例 + 反例清单进 prompt,
// 见 llmextractor.go),但**验收防线在本文件**。
//
// 🔴 **fail-safe 方向 = 宁可多留,不可误弃**(design/23 §18.3)。因此每一条规则都
// 写得**保守**:宁可有噪声漏过闸(那条走 T1 / 长期层),也不把一条真记忆判成临时
// 层(到期会被删)。落实为两道**护栏**:
//
// 1. **正向清单一票否决**:内容里出现任何「长期事实 / 稳定偏好 / 关系里程碑 /
// 项目约定」的信号(复用 tidyHabitMarkers 的「持久」词族 + 本文件的正向词),
// 直接放行 —— 「主人喜欢用喵打招呼」虽然短,但它有「喜欢」,不是寒暄;
// 2. **显式「记住」一票否决**:内容里出现「记住 / 记下 / 别忘了」时放行 ——
// 主人明确要求记的东西,闸不许替他改成临时记忆。
//
// 一处**刻意不实现**(如实登记,不是遗漏):design/03 §10.5 的 **N6(助手自己的
// 输出 / 发言人归属)**。它是**来源属性**(这句话是谁说的),不是内容属性;本闸
// 只拿到落库条目(结构化溯源里没有「谁说的」这一位),无法机器判定。归属由 B 级
// prompt 的负例承担(「不要记助手自己说过的话」),上游提取侧若要硬判,需另立
// 结构化字段 —— 那超出本切片授权。**N7(不确定推断)**同理交给 T1(§10.5 明写
// 「拿不准交第五态」),不进本闸。
// retentionRule 是一条提取留存反例规则的匹配结果(留痕/日志用;`retentionNone`
// 表示「不是显然不值得记的」)。
type retentionRule string
const (
// retentionNone 表示没有任何反例规则命中 ⇒ 照常走既有判定(可能长期留存)。
retentionNone retentionRule = ""
// retentionChatter 对 design/03 §10.5 的 **N1 寒暄 / 致谢**。
retentionChatter retentionRule = "n1_chatter"
// retentionEmotion 对应 **N2 情绪波动**。
retentionEmotion retentionRule = "n2_emotion"
// retentionOneOffAsk 对应 **N3 一次性琐事**(祈使 + 无持续实体)。
retentionOneOffAsk retentionRule = "n3_one_off_ask"
// retentionTimelyData 对应 **N4 时效性数据**(当前时间 / 天气)。
retentionTimelyData retentionRule = "n4_timely_data"
// retentionOneOffTask 对应 **N5 一次性任务指令**。
retentionOneOffTask retentionRule = "n5_one_off_task"
)
// retentionLowValueReason 是第五态在**留痕 `reason` 列**上的编码(而不是上面那些
// 规则码)。它是刻意的:design/23 §18.4 把 `reason` 的闭集钉成
// `{t0_low_value, t1_defer, …}` —— 面板与运维按它分「谁判的 defer」,而**具体命中
// 哪条反例规则**属于更细的观测面,进日志、不进留痕列(留痕表的列宽与语义不改)。
const retentionLowValueReason = "t0_low_value"
// 规则的**保守阈值**:只对「短到装不下一个事实」的内容开火。
const (
// retentionMaxChatterRunes 是「只可能是寒暄」的长度上限(rune 计,不是字节:
// 中文 1 rune = 3 字节,按字节算会把阈值放大三倍)。
retentionMaxChatterRunes = 16
// retentionMaxEmotionRunes 是纯情绪陈述的长度上限。
retentionMaxEmotionRunes = 24
// retentionMaxImperativeRunes 是一次性指令的长度上限。它比前两者宽:
// 「帮我把 D 盘那个文档转成 pdf」这类真·一次性任务本来就长一些。
retentionMaxImperativeRunes = 40
)
// judgeExtractionRetention 对一条新记忆的正文施加提取留存规则(design/03 §10.5
// 的 N1~N5)。命中返回该规则码(调用方据此判 `defer`),否则返回 retentionNone。
//
// 它**不读任何状态、不写任何东西、零模型调用**:入参只有正文,出参只有规则码,
// 因此在写路径上是微秒级且可完全单测(design/03 §10.3 A 级的「零成本」要求)。
func judgeExtractionRetention(content string) retentionRule {
text := normalizeRetentionText(content)
if text == "" {
// 空 / 纯空白内容在 Add 的 normalize 就被拒了,走不到这里;真走到了也只
// 放行(**fail-safe**:闸的职责是「拦住显然不值得记的」,不是「替上游
// 判断该不该存在」—— 判成 defer 才是替它做决定)。
return retentionNone
}
if len([]rune(text)) > retentionMaxImperativeRunes {
// 长内容一律放行:它已经装得下可检索的事实,闸只拦「显然不值得记」。
return retentionNone
}
// 护栏①:显式「记住」是主人的直接指令,闸不许替他改判。
if containsAny(text, retentionRememberDirectives()) {
return retentionNone
}
// 护栏②:任何「持久」信号(习惯 / 稳定偏好 / 约定 / 长期事实)一票放行。
if containsAny(text, tidyHabitMarkers()) || containsAny(text, retentionDurableMarkers()) {
return retentionNone
}
// **情绪先于寒暄**是刻意的:「心情不错」既是情绪陈述、又含致谢词表里的
// 「不错」——先判情绪才能给出更具体的规则码(两条规则都判 defer,差别只在
// 「为什么」,而那正是留痕/日志要回答的)。
if rule := judgeRetentionEmotion(text); rule != retentionNone {
return rule
}
if rule := judgeRetentionChatter(text); rule != retentionNone {
return rule
}
if rule := judgeRetentionTimelyData(text); rule != retentionNone {
return rule
}
return judgeRetentionOneOff(text)
}
// judgeRetentionChatter 施加 **N1**:短且只由寒暄/致谢词构成的内容。
//
// 「短」是这条规则的**安全阀**:寒暄的真实形态就是一句话里没有别的信息,
// 一旦它长到装得下事实,就不该由本闸判(交 T1 / 长期层)。
func judgeRetentionChatter(text string) retentionRule {
if len([]rune(text)) > retentionMaxChatterRunes {
return retentionNone
}
if !containsAny(text, retentionChatterWords()) {
return retentionNone
}
// 带数字的短句一律放行:「7 点」「3 月 5 日」这类是事实的载体,
// 哪怕句子再短(design/23 §5.2-2 把数值当硬门,同一个取向)。
if hasRetentionDigit(text) {
return retentionNone
}
return retentionChatter
}
// judgeRetentionEmotion 施加 **N2**:短的情绪陈述(「今天好累」「心情不错」)。
func judgeRetentionEmotion(text string) retentionRule {
if len([]rune(text)) > retentionMaxEmotionRunes {
return retentionNone
}
if !containsAny(text, retentionEmotionWords()) {
return retentionNone
}
if hasRetentionDigit(text) {
return retentionNone
}
return retentionEmotion
}
// judgeRetentionTimelyData 施加 **N4**:时效性查询(当前时间 / 天气)。
//
// 它的词项是**整句形态**而不是单词(「几点」「天气」),因为「天气」单独出现可能
// 是主人对天气的**稳定偏好**(「主人怕冷,阴天心情差」)—— 那种句子带持久信号,
// 已被护栏②放行;这里只吃「现在几点」「明天天气怎么样」这类纯查询。
func judgeRetentionTimelyData(text string) retentionRule {
if containsAny(text, retentionTimelyQueries()) {
return retentionTimelyData
}
return retentionNone
}
// judgeRetentionOneOff 施加 **N3 / N5**:一次性的祈使请求(「帮我订个 7 点的闹钟」
// 「把这个文件转成 pdf」)。
//
// 判据是「**以祈使词开头或含一次性动作词**」而不是「含动词」:后者会把
// 「主人喜欢把这个文件转成 pdf」(稳定偏好)也拦下 —— 那种句子带持久信号,
// 已被护栏②放行。
func judgeRetentionOneOff(text string) retentionRule {
if hasRetentionPrefix(text, retentionAskPrefixes()) {
return retentionOneOffAsk
}
if containsAny(text, retentionOneOffActions()) {
return retentionOneOffTask
}
return retentionNone
}
// normalizeRetentionText 规范化正文供词项匹配:转小写(对 ASCII 词如 `hi` 有用,
// 中文不受影响)、把连续空白折叠成**一个空格**、去掉结尾的标点。
//
// 空白**折叠**而不是**删除**是刻意的:删光会让相邻两个英文单词粘成一个词,
// 于是 `as hi` 里冒出子串 `hi` 而被误判成寒暄 —— 折叠则保住词边界。
// 词项表里因此同时列了 `转成 pdf` 与 `转成pdf` 两种写法。
func normalizeRetentionText(content string) string {
text := strings.ToLower(strings.TrimSpace(content))
text = strings.Join(strings.Fields(text), " ")
return strings.TrimRight(text, "。!?!?.,,、~~ ")
}
// containsAny 报告 text 是否含 markers 里任一词项。
func containsAny(text string, markers []string) bool {
for _, m := range markers {
if m != "" && strings.Contains(text, m) {
return true
}
}
return false
}
// hasRetentionPrefix 报告 text 是否以 prefixes 里任一词开头。
func hasRetentionPrefix(text string, prefixes []string) bool {
for _, p := range prefixes {
if p != "" && strings.HasPrefix(text, p) {
return true
}
}
return false
}
// hasRetentionDigit 报告 text 是否含阿拉伯数字(数值是事实的载体,带数值即放行)。
func hasRetentionDigit(text string) bool {
for _, r := range text {
if r >= '0' && r <= '9' {
return true
}
}
return false
}
// 下列词表一律是**函数**而不是包级变量(AGENTS §10 禁包级全局变量;
// 同 tidySystemSessions / tidyHabitMarkers 的手法)。
// retentionRememberDirectives 是「主人明确要求记住」的信号(护栏①)。
func retentionRememberDirectives() []string {
return []string{"记住", "记下", "别忘了", "不要忘", "别忘记"}
}
// retentionDurableMarkers 是正向清单(design/03 §10.4)的词族里、**不被
// tidyHabitMarkers 覆盖**的那些:长期事实 / 关系里程碑 / 项目约定 / 能力边界。
func retentionDurableMarkers() []string {
return []string{
// 长期事实
"生日", "纪念日", "住", "职业", "工作", "家庭", "家人", "孩子", "父母",
"过敏", "忌口", "健康状况", "疾病", "吃药", "身高", "体重", "年龄",
// 关系里程碑 / 约定
"约定", "承诺", "答应", "说好", "暗号", "口令", "称呼",
// 项目约定
"项目", "迁移号", "端口", "路径", "命名", "编号", "约定俗成",
// 能力边界(主人明确的「不要做 X」)
"不要做", "别做", "禁止", "不许",
// 稳定偏好(补 tidyHabitMarkers 之外的)
"口味", "爱好", "讨厌", "不喜欢", "偏爱", "嗜好",
}
}
// retentionChatterWords 是 N1 的寒暄 / 致谢词表。
func retentionChatterWords() []string {
return []string{
"谢谢", "多谢", "感谢", "辛苦", "麻烦你了", "早上好", "早安", "中午好",
"下午好", "晚上好", "晚安", "你好", "您好", "在吗", "收到", "好的",
"好呀", "好嘞", "嗯嗯", "哈哈", "嘿嘿", "呵呵", "么么", "拜拜", "再见",
"加油", "不错", "真棒", "太棒了", "hi", "hello", "hey", "thx", "thanks",
}
}
// retentionEmotionWords 是 N2 的情绪词表。
func retentionEmotionWords() []string {
return []string{
"好累", "累了", "开心", "高兴", "难过", "不开心", "焦虑", "压力大",
"心情", "疲惫", "难受", "无聊", "郁闷", "沮丧", "生气", "委屈",
"烦死", "好烦", "烦躁", "兴奋", "紧张", "害怕", "孤单", "寂寞",
}
}
// retentionTimelyQueries 是 N4 的时效性**查询整句**词表(不是单词,理由见
// judgeRetentionTimelyData)。
func retentionTimelyQueries() []string {
return []string{
"现在几点", "几点了", "现在时间", "当前时间",
"今天天气", "明天天气", "天气怎么样", "天气如何", "天气怎样",
"温度多少", "多少度", "气温多少",
}
}
// retentionAskPrefixes 是 N3 的祈使开头词表。
func retentionAskPrefixes() []string {
return []string{
"帮我", "请帮", "麻烦帮", "帮个忙", "提醒我", "叫我", "替我",
"给我订", "给我买", "帮我查", "帮我搜", "帮我订", "帮我买",
}
}
// retentionOneOffActions 是 N5 的一次性动作词表(位置不限 —— 它们描述的是
// 「转一次文件」这类有始有终的动作,而不是可以长期成立的偏好)。
func retentionOneOffActions() []string {
return []string{
"转成pdf", "转成 pdf", "转换成", "截图", "打开这个", "关掉这个",
"搜一下", "查一下", "翻译一下", "订个闹钟", "设个闹钟",
}
}