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)。
This commit is contained in:
AskaEth committed 2026-10-13 20:22:48 +08:00
1 parent 68ba85389b
commit 21b906ffb0
18 files changed
+1352 -35

No files matched your search

+68 -16
View File
@@ -21,8 +21,10 @@ import (
//
// 五条不可走样(逐条对应本目录既有的不变量,改坏任何一条都是架构偏离):
//
// 1. **只许回三态 + 理由**:`merge` / `modify` / `keep`。坏 JSON、非法枚举、
// 编号越界、任何别的词 —— 一律回落 `create`(保守,**绝不猜**)。
// 1. **只许回四态 + 理由**:`merge` / `modify` / `keep` / `defer`。坏 JSON、
// 非法枚举、编号越界、任何别的词 —— 一律回落 `create`(保守,**绝不猜**)。
// `defer`(第五态,§18.2)还多一条不变式:**target 必须为 0** —— 它声称
// 「关系判为真新」,而一个指向某条候选的 defer 自相矛盾,同样判非法回落。
// 2. **禁产新内容**:模型只提供「选哪一条 + 什么动作 + 一句理由」,它的正文 /
// 摘要 / 改写文本**一个字都不采纳**。`merge` 落地时存活方是既有行(正文
// 一字不改);`modify` 落地时存活方是**新行自己的原文**、既有行置为被合并
@@ -45,8 +47,9 @@ import (
// 厂商协议,也不新增依赖。
//
// 可观测(§15 D3-8):T1 是否参与、结论是什么,在留痕 `reason` 列上如实可辨
// (`t1_merge` / `t1_modify` / `t1_keep` / `t1_reply_invalid` / `t1_unavailable` /
// `t1_throttled`),规则版本随之升到 `IngestRuleVersion`。模型的自由文本理由
// (`t1_merge` / `t1_modify` / `t1_keep` / `t1_defer` / `t1_reply_invalid` /
// `t1_unavailable` / `t1_throttled`),规则版本随之升到 `IngestRuleVersion`
// (`d3.3` 起含第五态)。模型的自由文本理由
// **既不落库也不落日志**:留痕表按设计不存正文,而模型的理由可能改述正文 ⇒ 一律
// 丢弃(它在 prompt 里的唯一作用,是逼模型先表态,从而把动作判得更稳)。
@@ -64,10 +67,18 @@ const (
// 让用量面板能把「灰区判定」与「对话」分开算账。
t1Purpose = "memory_t1_judge"
// T1 允许的动作枚举(模型只许回这三个,别的一律回落 create)。
// T1 允许的动作枚举(模型只许回这四个,别的一律回落 create)。
//
// `defer` 是 2026-10-13 加入的**第五态**(design/23 §18.2)—— 它**不是第二次
// 调用**:关系判定(merge/modify/keep)与价值判定(值不值得长期留)被写进
// **同一个 prompt**,由**同一次**补全回答。语义上 `defer` ≡「关系判为**真新**
// ∧ 价值判为**短期**」,因此它要求 target=0(真新就没得可指),而**当关系
// 判成 merge/modify 时价值判定不适用**(这条已被既有记忆代表,留不留由既有行
// 决定,§18.2 的「分工」)。
t1ActionMerge = "merge"
t1ActionModify = "modify"
t1ActionKeep = "keep"
t1ActionDefer = "defer"
// T1 的留痕原因码(进 `reason` 列,结构化、可机器判定、与既有码同族)。
t1ReasonMerge = "t1_merge"
@@ -75,6 +86,10 @@ const (
t1ReasonKeep = "t1_keep"
t1ReasonReplyInvalid = "t1_reply_invalid"
t1ReasonUnavailable = "t1_unavailable"
// t1ReasonDefer 是第五态在留痕 `reason` 列上的编码(design/23 §18.4 的闭集)。
// 它与 t1_keep 分列:两者都是「关系判为真新」,但只有 defer 会写短
// `expires_at` —— 面板要能一眼看出「有多少条被判成了临时」。
t1ReasonDefer = "t1_defer"
// t1ReasonThrottled 是**全局限速**命中时的原因码(preinsert_t1_limit.go,
// design/23 §15.4)。它与上面两个失败码同族、同样只改 `reason`:决定恒
// `create`、零改写。与它们的唯一区别是语义 —— 失败是「模型没答上」,
@@ -82,7 +97,16 @@ const (
t1ReasonThrottled = "t1_throttled"
)
// ErrT1ReplyInvalid 是模型回复解不出三态枚举(或候选编号越界)时的分类错误。
// 一处**刻意不实现**(如实登记):design/23 §18.3 的失败原因码
// `t1_value_unavailable` / `t1_keep_unavailable`。关系判定与价值判定被写进
// **同一次**补全(§18.2 要求「不新增第二次调用」),因此「回复解不开」时**结构上
// 无法分辨**是哪一问失败 —— 硬造第二个码只会得到一个自己也不确定的标签。既有
// 的 `t1_reply_invalid` / `t1_unavailable` 已经如实描述了那条路径(且 §18.2 对
// 「非法 defer」明写的处置就是 `t1_reply_invalid`)。真要分开观测,得先有第二次
// 调用,而那正是本条明令禁止的。
// ErrT1ReplyInvalid 是模型回复解不出四态枚举(或候选编号越界,或 defer 带了
// 非 0 的 target)时的分类错误。
// 它不是「模型挂了」,而是「这一次的回复不能用」—— 两者的回落结果相同
// (create),但留痕原因码不同,运维才分得清该修 prompt 还是该查网络。
var ErrT1ReplyInvalid = errors.New("memory/review: t1 reply is not a valid action")
@@ -232,6 +256,22 @@ func t1Verdict(reply t1Reply, item MemoryItem, gray []ingestCandidate, base inge
base.Gray = nil
return base, true
}
if action == t1ActionDefer {
// 第五态(design/23 §18.2):关系判为**真新**、但价值判为**短期** ⇒
// 照常保留这条新记忆,只给它一个短 `expires_at`(落临时层)。
//
// 🔴 **target 必须为 0**(与 keep 同形):§18.2 的不变式是
// 「defer ≡ 真新 ∧ 短期」,一个指向某条候选的 defer 自相矛盾(既说
// 「和它像」又说「这条独立」)。出现这种回复 ⇒ 判非法、回落 create
// (reason=t1_reply_invalid,§18.2 明写的处置),**绝不猜**。
if reply.Target != 0 {
return ingestVerdict{}, false
}
base.Decision = IngestDefer
base.Reason = t1ReasonDefer
base.Gray = nil
return base, true
}
if action != t1ActionMerge && action != t1ActionModify {
return ingestVerdict{}, false
}
@@ -271,32 +311,44 @@ func buildT1Prompt(item MemoryItem, gray []ingestCandidate) []model.Message {
}
// t1SystemPrompt 是 T1 的固定指令。它把三件事写死:
// - 只许回三态 + 一句理由(别的词一律不许);
// - 只许回**四态** + 一句理由(别的词一律不许);
// - **禁产新内容**(不许输出记忆正文/摘要/改写文本);
// - 判据与**负例**(design/23 §15 D3-2「负例进 prompt」,Mem0 口径):
// 「相似但并列」必须 keep,拿不准必须 keep。
//
// 2026-10-13 起它同时承载**第五态的价值判定**(design/23 §18.2):prompt 里明写
// 「先问关系、只有关系判为真新时才问价值」,四个动作**一次回答** —— 因此
// 「第五态与 T1 在同一次调用里问」是这条 prompt 的结构性事实,不是约定:**没有
// 第二次模型调用**。
//
// 负例刻意取自 design/23 §1.3 与 §5.2 的点名反例(红牛 vs 冰美式是「同主题但
// 不同事实」,X vs 戒了X 是「新事实覆盖旧事实」),它们是 T1 最容易被相似度
// 带偏的两处。
const t1SystemPrompt = `你是记忆库的入库审查员。给你一条刚入库的新记忆,以及同一分区里若干条既有记忆候选。
判断新记忆与既有候选的关系,只回一个 JSON 对象,不要任何其他文字、不要代码块。
要回答的是**两个问题**,但只回一个 JSON 对象,不要任何其他文字、不要代码块。
动作只许取这三个值之一:
第一问(关系):这条新记忆和某条既有候选,是不是在说同一件事?
第二问(价值):只有当第一问的答案是「谁都不像、它是新的一件事」时,才再问一句——**它值不值得长期留在记忆库里**?
若第一问答的是「和某条候选是同一件事」,第二问就不适用(留不留由那条既有记忆决定),直接回 merge 或 modify。
动作只许取这四个值之一:
- "merge":新记忆与某条既有记忆说的是**同一件事、只是换了措辞**,且新记忆没有那条既有记忆之外的任何新信息 ⇒ 新记忆并入那条既有记忆(既有记忆的原文保持不变)。
- "modify":某条既有记忆是**新记忆的不完整版本**(新记忆覆盖它、并含它没有的细节)⇒ 那条既有记忆退役,由新记忆承载。
- "keep":其余一切情形(不同事实、不同偏好、不同对象、只是同一主题)⇒ 新记忆独立保留。
- "keep":是**新的一件事**,而且值得**长期**记住 ⇒ 新记忆独立长期保留。
- "defer":是**新的一件事**,但**不值得长期记住**(只是一次性的、短期的、过了这阵子就没用的信息)⇒ 新记忆先留着,过一阵子自动消失。
必须遵守的判据:
- 只有「同一件事换措辞」才可以 merge;「相似但并列」的两件不同事实必须 keep。反例:"主人喜欢喝红牛来提神" 与 "主人喜欢喝冰美式来提神" 是两种不同的偏好,必须 keep,即便它们很相似。
- 一方带否定、另一方没有("X" 与 "已经戒了 X"),那是新事实覆盖旧事实、不是重复,必须 keep。
- 描述某一次具体事件、或带具体日期/时间的记忆,不要与长期习惯互相合并,必须 keep。
- 只有「同一件事换措辞」才可以 merge;「相似但并列」的两件不同事实必须 keep 或 defer。反例:"主人喜欢喝红牛来提神" 与 "主人喜欢喝冰美式来提神" 是两种不同的偏好,必须 keep,即便它们很相似。
- 一方带否定、另一方没有("X" 与 "已经戒了 X"),那是新事实覆盖旧事实、不是重复,必须 keep 或 defer。
- 描述某一次具体事件、或带具体日期/时间的记忆,不要与长期习惯互相合并。
- 只是「都在说同一个领域」不算同一件事。
- **拿不准就 keep**。宁可多留一条,也绝不把两件不同的事并成一条。
- **长期事实、稳定偏好、习惯、约定与承诺、关系里程碑、主人的明确要求 = keep**(它们长期有用)。
- **只对「这一次」有用的信息 = defer**,例如:一次性的安排或请求、当前临时的状态、时效性数据、转瞬即逝的琐事。
- **拿不准就 keep**。宁可多留一条长期记忆,也绝不把两件不同的事并成一条;更不要因为拿不准就把一条真记忆判成临时的。
- **绝对不要输出任何记忆正文、摘要或改写文本**,只回动作、候选编号和一句理由。
输出格式(严格 JSON,无代码块):{"action":"merge 或 modify 或 keep","target":候选编号,"reason":"一句话理由"}
keep 时 target 写 0。`
输出格式(严格 JSON,无代码块):{"action":"merge 或 modify 或 keep 或 defer","target":候选编号,"reason":"一句话理由"}
回 keep 或 defer 时 target 写 0(这两者都表示「不与任何候选合并」)。`
// parseT1Reply 解析模型的 JSON 回复。被围栏包裹的 ```json 块会被剥离,前后夹带
// 的解说文字也被忽略(只取第一个 `{` 到最后一个 `}`)—— 容忍格式噪声,但**不猜**: