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

+38 -1
View File
@@ -41,7 +41,10 @@ var errT1ModelNotReady = errors.New("memory review: t1 judge: model adapter not
// - `{"ingestGate": false}` ⇒ 一键回到「只插不审」(同步入库闸);
// - `{"t1Judge": false}` ⇒ 一键回到纯规则(T1 灰区判定,**零模型调用**);
// - `{"t1PerWindow": 120, "t1WindowSec": 3600}` ⇒ T1 的**全局限速**额度
// (design/23 §15.4);缺位 = 代码默认 120 次/60 分钟。
// (design/23 §15.4);缺位 = 代码默认 120 次/60 分钟;
// - `{"tempTtlSec": 604800}` ⇒ 第五态 `defer` 的**临时记忆存活秒数**
// (design/03 §9.6);缺位/坏值 = 代码默认 7 天。**它没有 `.env` 档**
// (`.env` 已冻结,本键在那里从来没有出处)⇒ 实际是「面板 > 代码默认」。
func (a *app) wireMemoryReview() error {
cfg, err := config.LoadMemoryReviewConfig(envPath)
if err != nil {
@@ -61,6 +64,10 @@ func (a *app) wireMemoryReview() error {
// 交零值给 normalizeReviewConfig 回落**代码默认值**(120 次/60 分钟)——
// 坏配置绝不能让一处限速静默变成「无上限」。
t1PerWindow, t1WindowSec := a.memoryT1RateLimit()
// 临时记忆 TTL(design/03 §9.6):面板 `tempTtlSec` 优先,缺位/坏值回落代码
// 默认 **7 天**(经 normalizeReviewConfig 落地)。**中间那一档(`.env`)在本键
// 上结构性为空** —— `.env` 已冻结(design/13 §2.1),这个键在那里从来没有出处。
tempTTL := a.memoryTempTTL()
reviewer, err := memory.NewPreInsertReviewer(memory.ReviewConfig{
Enabled: cfg.Enabled,
QueueSize: cfg.QueueSize,
@@ -82,6 +89,9 @@ func (a *app) wireMemoryReview() error {
// (120 次/60 分钟)。**没有「无上限」这个状态**:要零花费请关 t1Judge。
T1PerWindow: t1PerWindow,
T1Window: time.Duration(t1WindowSec) * time.Second,
// 第五态 `defer` 的落点(design/23 §18.1):`expires_at` = 判定时刻 + 它。
// 零值 = 没配 ⇒ 由 normalizeReviewConfig 回落代码默认 7 天(tempTtlSec)。
TempTTL: tempTTL,
}, memory.ReviewDeps{
Store: a.memStore,
Embedder: a.embedder,
@@ -151,6 +161,33 @@ func (a *app) memoryT1RateLimit() (perWindow int, windowSec int) {
return tuning.T1PerWindow, tuning.T1WindowSec
}
// memoryTempTTL 返回**临时记忆 TTL**(design/03 §9.6 的 `tempTtlSec`):面板
// `$DATA_DIR/config/memory-tidy.json` 的值优先,缺位 / 坏值一律回落**代码默认
// 7 天**(返回零值即「不覆盖」,由 NewPreInsertReviewer 的 normalizeReviewConfig
// 落地 —— 与 memoryT1RateLimit 返回零值的口径逐字相同)。
//
// 解析与坏值 WARN 都交给 `memory.ApplyReviewTuning`(与 tidy_tuning.go 对
// TidyConfig 是同一手法):runtimeconfig 只管**文件形状**,而「缺位 / 坏值怎么
// 回落」属于 ReviewConfig 的归一化语义 —— 那里是**唯一**实现点,组合根不再抄一遍
// (抄一遍就意味着两条路径各自决定「什么是坏值」)。
//
// 读不到 / 解不开那个文件时返回零值 + WARN:坏配置绝不能让 TTL 静默变成
// 「不过期」,也不能让每条临时记忆**落库即过期**(一个 ≤0 的 TTL 正是那个形态)。
//
// 🔴 中间那一档(`.env`)在本键上**结构性为空**:`.env` 已冻结
// (design/13 §2.1「不加、不改、不删」),而这个键在那里**从来没有出处**。
// 因此本键的实际生效链是「**面板 > 代码默认**」——不是漏配,设计原文
// (design/03 §9.6)写的就是这一档。
func (a *app) memoryTempTTL() time.Duration {
tuning, path, err := a.readMemoryTidyTuning()
log := logger.WithSource(a.log, "memory/review")
if err != nil {
log.Warn("memory temp ttl tuning unreadable, falling back to code default (7 days)",
"path", path, "error", err)
}
return memory.ApplyReviewTuning(memory.ReviewConfig{}, tuning, log).TempTTL
}
// memoryT1Judge 把「调用时才解析的模型适配器」包成 T1 的一次性调用口。
//
// 为什么是闭包而不是直接塞 `a.modelAdapter`:组合根的顺序是 **wireMemory(建
@@ -157,6 +157,20 @@ type MemoryTidyTuning struct {
// 两端都没配 ⇒ 代码默认 23:00-07:00。
QuietStartHour *int `json:"quietStartHour"`
QuietEndHour *int `json:"quietEndHour"`
// TempTtlSec 是**临时记忆**的存活秒数(第五态 `defer` 写进 `expires_at` 的
// TTL,design/23 §18.1 / design/03 §9.6)。nil = 没配 ⇒ 代码默认
// **604800 秒(7 天)**;写非正数 ⇒ 坏值,回落代码默认 + WARN
// (一个 ≤0 的 TTL 会让每条临时记忆**落库即过期**,与「不过期」恰好相反)。
//
// 🔴 **它在 `.env` 里没有出处,也刻意不新增**(`.env` 已冻结,design/13
// §2.1):所以这一键的实际生效链是「**面板 > 代码默认**」——「面板 > `.env` >
// 代码默认」里的中间那一档在 TTL 上是**结构性为空**,不是漏配。
//
// **指针 + `omitempty`** 的理由与 AutoApply 同源、但更强一层:nil = 没配,
// 而没配时该键从**播种文件**里**整键省略**(播种文档明禁写 `null` —— 一份
// 带 null 的配置对主人是噪音)。只有面板/人工显式写了才出现这个键。
TempTtlSec *int `json:"tempTtlSec,omitempty"`
}
// AutoApplyOff 报告自动档是否被这份配置**显式关掉**。nil(没配)返回 false ——
@@ -59,7 +59,7 @@ const (
var memoryTidySeedKeys = []string{
"autoApply", "autoApplyPerRound", "ingestGate", "t1Judge",
"t1PerWindow", "t1WindowSec", "intervalSec", "quietIntervalSec",
"quietStartHour", "quietEndHour",
"quietStartHour", "quietEndHour", "tempTtlSec",
}
// MemoryTidySeed 是一次播种的可诊断产物(口径对齐 materialize.go 的 Result:
@@ -189,6 +189,13 @@ func memoryTidySeedComplete(seed MemoryTidyTuning) error {
if seed.QuietEndHour == nil {
missing = append(missing, "quietEndHour")
}
// tempTtlSec 同样要求非 nil:它是 `*int` + `omitempty`,**nil 会让该键从播种
// 文件里消失**,于是「播种文件与当时的生效值逐字一致」这句话就不再成立 ——
// 文件少一个键、读回来却是代码默认值,主人打开文件无从判断到底配没配。
// 快照侧(memory.TidyTuningSnapshot)恒填它,因此这条不会拦住正常路径。
if seed.TempTtlSec == nil {
missing = append(missing, "tempTtlSec")
}
if len(missing) > 0 {
return fmt.Errorf("runtimeconfig: memory tidy seed missing keys: %s", strings.Join(missing, ", "))
}
@@ -213,5 +220,12 @@ func memoryTidySeedComplete(seed MemoryTidyTuning) error {
if *seed.QuietEndHour < 0 || *seed.QuietEndHour > 23 {
return fmt.Errorf("runtimeconfig: memory tidy seed quietEndHour = %d (want 0..23)", *seed.QuietEndHour)
}
// tempTtlSec(临时记忆 TTL,design/03 §9.6)的**非 nil 要求**见上面 missing
// 那一段(理由:nil 会让键从文件里消失,而播种要「与生效值逐字一致」)。
// 这里再加一道值域:≤0 的 TTL 会让每条临时记忆**落库即过期**(与「不过期」
// 恰好相反),播出去等于播了一份自己读回都要 WARN 的配置。
if *seed.TempTtlSec <= 0 {
return fmt.Errorf("runtimeconfig: memory tidy seed tempTtlSec = %d (want > 0)", *seed.TempTtlSec)
}
return nil
}
@@ -24,9 +24,14 @@ import (
// 用例全部落在**临时目录**(`t.TempDir()`):生产那份 `data/config/memory-tidy.json`
// 是主人手工建的,播种逻辑只在文件缺失时触发,用例不许依赖它存在与否。
// completeSeedTuning 造一份「十键齐全」的播种内容。值刻意都**不是**代码默认值
// (autoApply=false / 3600 / 10800),这样「播的是调用方给的快照」与「播了一份硬编码
// 的默认值」在断言里一眼可分。
// completeSeedTuning 造一份「十一键齐全」的播种内容。值刻意都**不是**代码默认值
// (autoApply=false / 3600 / 10800 / tempTtlSec=259200),这样「播的是调用方给的
// 快照」与「播了一份硬编码的默认值」在断言里一眼可分。
//
// 它必须给出**全部** `memoryTidySeedKeys`(含 tempTtlSec):该键带 `omitempty`,
// nil 时会被整键省略 —— 那正是「没配 ⇒ 用代码默认」的正常形态,但本用例要验的是
// 「键齐全时逐一落盘」,因此这里显式给值(见 TestSeedMemoryTidy_SeedsMissingFile
// 的键集合断言)。
func completeSeedTuning() MemoryTidyTuning {
return MemoryTidyTuning{
AutoApply: boolSeedPtr(false),
@@ -39,6 +44,7 @@ func completeSeedTuning() MemoryTidyTuning {
QuietIntervalSec: intSeedPtr(10800),
QuietStartHour: intSeedPtr(23),
QuietEndHour: intSeedPtr(7),
TempTtlSec: intSeedPtr(259200),
}
}
@@ -1,9 +1,11 @@
package runtimeconfig
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
@@ -278,3 +280,54 @@ func TestLoadMemoryTidyTuning_ScheduleKeys(t *testing.T) {
t.Fatalf("intervalSec 写成字符串应当返回 ErrInvalidMemoryTidy,得到 %v", err)
}
}
// TestLoadMemoryTidyTuning_TempTtlSec 锁定**临时记忆 TTL**这一键(design/03 §9.6:
// 默认 7 天 = 604800 秒,放 `$DATA_DIR/config/memory-tidy.json`,**零新 env 键**)。
//
// 三条口径与既有指针键同源、但多一条 `omitempty`:
//
// ① 没写 ⇒ nil(「没配」⇒ 上层用代码默认,**不是**「配成 0」);
// ② 写正数 ⇒ 原值读出;
// ③ 写 0 ⇒ **仍是「有值」**(0 由 memory 侧判为坏值并回落 + WARN,本层不拦 ——
// 与 intervalSec 同一口径:读取层只管形状,好坏由归一化层判);
// ④ nil 时**序列化整键省略**(`omitempty`):播种文档明禁写 `null`,一份带 null
// 的配置对主人是噪音。
func TestLoadMemoryTidyTuning_TempTtlSec(t *testing.T) {
t.Parallel()
// ① 没写 ⇒ nil。
bare, err := LoadMemoryTidyTuning(writeMemoryTidyFile(t, `{"autoApply": true}`))
if err != nil {
t.Fatalf("LoadMemoryTidyTuning(bare): %v", err)
}
if bare.TempTtlSec != nil {
t.Fatalf("没写 tempTtlSec 应当是 nil(= 用代码默认),得到 %v", *bare.TempTtlSec)
}
// ② 写正数 ⇒ 原值。
got, err := LoadMemoryTidyTuning(writeMemoryTidyFile(t, `{"tempTtlSec": 259200}`))
if err != nil {
t.Fatalf("LoadMemoryTidyTuning(tempTtlSec): %v", err)
}
if got.TempTtlSec == nil || *got.TempTtlSec != 259200 {
t.Fatalf("tempTtlSec = %v, want 259200", got.TempTtlSec)
}
// ③ 写 0 ⇒ 仍是「有值」(好坏由 memory 侧的归一化判)。
zero, err := LoadMemoryTidyTuning(writeMemoryTidyFile(t, `{"tempTtlSec": 0}`))
if err != nil {
t.Fatalf("LoadMemoryTidyTuning(zero): %v", err)
}
if zero.TempTtlSec == nil || *zero.TempTtlSec != 0 {
t.Fatalf("显式写 0 的 tempTtlSec 应当被读出来(好坏由上层判),得到 %v", zero.TempTtlSec)
}
// ④ nil ⇒ 序列化时整键省略(不会写出 `"tempTtlSec": null`)。
raw, err := json.Marshal(MemoryTidyTuning{})
if err != nil {
t.Fatalf("Marshal: %v", err)
}
if strings.Contains(string(raw), "tempTtlSec") {
t.Fatalf("nil 的 tempTtlSec 不该被序列化出来(omitempty),得到 %s", raw)
}
}
@@ -0,0 +1,294 @@
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", "转换成", "截图", "打开这个", "关掉这个",
"搜一下", "查一下", "翻译一下", "订个闹钟", "设个闹钟",
}
}
@@ -0,0 +1,147 @@
package memory
import (
"testing"
)
// 本文件是**提取留存规则闸**(extract_retention.go,design/03 §10)的验收用例集:
// 正例(值得长期记 ⇒ 一条都不许被拦)+ 反例(显然不值得记 ⇒ 必须被拦成第五态)
// 各远超 3 条,并逐条钉住**命中哪一条反例规则**(N1~N5)。
//
// 为什么正例与反例**一样重要**:本闸判错的方向不对称 —— 漏判只是「库里多一条
// 噪声」(有 T1 与整理兜底),误判却会把一条真记忆丢进临时层(到期删除)。
// design/23 §18.3 的 fail-safe 方向是「**宁可多留,不可误弃**」,所以「正例必须
// 放行」是比「反例必须拦下」更硬的要求。
//
// 「闸真在起作用」而不是「恰好如此」的证据是 TestJudgeIngestDefersLowValueItem:
// 它在**同一个判定链**上既断言低价值条目拿到 `defer`+`t0_low_value`,又断言它
// **不进灰区**(于是 T1 一个 token 都花不出去);把闸摘掉时那条断言会立刻变红
// (判成 create 且灰区非空)。
// retentionPositiveCases 是正向清单(design/03 §10.4)的脱敏样本与**两道护栏**
// 的样本:它们必须**全部**放行(retentionNone)。
func retentionPositiveCases() []struct {
name string
content string
} {
return []struct {
name string
content string
}{
{"长期事实", "主人住在杭州"},
{"稳定偏好", "主人喜欢喝冰美式来提神"},
{"习惯", "主人常在深夜与助手聊天,有熬夜习惯"},
{"关系里程碑", "主人与助手约定验收暗号喵喵七号"},
{"项目约定", "迁移号约定从 0023 起"},
{"能力边界", "主人说过不要在群里提她的工作"},
{"健康约束", "主人对花生过敏"},
{"带数值的偏好", "主人每天要喝 2 杯咖啡"},
// 护栏①:显式「记住」是主人的直接指令,短句也不许被拦。
{"显式记住(短)", "记住主人周日去看牙医"},
// 护栏②:短寒暄词出现,但整句带持久信号 ⇒ 放行。
{"寒暄词+持久信号", "主人喜欢用喵打招呼"},
// 长度安全阀:寒暄词出现,但句子长到装得下事实 ⇒ 放行。
{"长句里的致谢", "谢谢你今天特地提醒我这件事,真是帮了大忙了"},
}
}
// retentionNegativeCases 是反例清单(design/03 §10.5)的脱敏样本,逐条钉住
// 命中的规则码。
func retentionNegativeCases() []struct {
name string
content string
rule retentionRule
} {
return []struct {
name string
content string
rule retentionRule
}{
{"N1 致谢", "谢谢", retentionChatter},
{"N1 寒暄", "早上好呀", retentionChatter},
{"N1 晚安", "晚安~", retentionChatter},
{"N2 情绪", "今天好累", retentionEmotion},
{"N2 情绪(带标点)", "心情不错!", retentionEmotion},
{"N4 时效查询", "现在几点了", retentionTimelyData},
{"N4 天气", "明天天气怎么样", retentionTimelyData},
{"N3 一次性琐事", "帮我订个 7 点的闹钟", retentionOneOffAsk},
{"N3 一次性提醒", "提醒我明天把快递取回来", retentionOneOffAsk},
{"N5 一次性任务", "把这个文件转成 pdf", retentionOneOffTask},
{"N5 一次性查询", "查一下明天的航班", retentionOneOffTask},
}
}
// TestExtractionRetention_PositivesAreNeverDeferred 钉住正向清单一票放行:
// 任何一条正例被判成 defer(= 进临时层、到期删除)都是**记忆丢失**,
// 因此这里逐条断言 retentionNone。
func TestExtractionRetention_PositivesAreNeverDeferred(t *testing.T) {
t.Parallel()
for _, tc := range retentionPositiveCases() {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if rule := judgeExtractionRetention(tc.content); rule != retentionNone {
t.Fatalf("正例 %q 被拦成 %q —— 那是把真记忆丢进临时层(fail-safe 方向被违反)",
tc.content, rule)
}
})
}
}
// TestExtractionRetention_NegativesAreDeferred 钉住反例清单:每条都必须命中
// 它该命中的规则码(而不是「反正被拦了」——规则码要能解释**为什么**)。
func TestExtractionRetention_NegativesAreDeferred(t *testing.T) {
t.Parallel()
for _, tc := range retentionNegativeCases() {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if rule := judgeExtractionRetention(tc.content); rule != tc.rule {
t.Fatalf("反例 %q 命中 %q, want %q", tc.content, rule, tc.rule)
}
})
}
}
// TestExtractionRetention_UpperCaseASCII 钉住 ASCII 寒暄词(中英混排):
// 规范化会把 `HI` / `Thanks` 折成小写再匹配。
func TestExtractionRetention_UpperCaseASCII(t *testing.T) {
t.Parallel()
for _, content := range []string{"HI", "Thanks", "hello~"} {
if rule := judgeExtractionRetention(content); rule != retentionChatter {
t.Fatalf("%q 命中 %q, want %q(ASCII 词应先折小写)", content, rule, retentionChatter)
}
}
}
// TestJudgeIngestDefersLowValueItem 是**接线**断言:留存规则闸真的接在判定链上,
// 命中的新条目拿到第五态 `defer` + 留痕原因码 `t0_low_value`,且**不进灰区**
// (design/03 §10.6:规则闸拦下的不进 T1)。
func TestJudgeIngestDefersLowValueItem(t *testing.T) {
t.Parallel()
item := habitItem("谢谢啦", tidyTestTime(t), 0.9)
// 故意给一条「相似候选」:它本该进灰区,但留存规则先命中 ⇒ 灰区必须为空,
// 于是 T1 一个 token 都花不出去。
cands := []ingestCandidate{{item: habitItem("谢谢", tidyTestTime(t), 0.9), score: 0.9, mode: tidyRecallLexical}}
v := judgeIngest(item, cands)
if v.Decision != IngestDefer {
t.Fatalf("低价值条目 decision = %q, want %q", v.Decision, IngestDefer)
}
if v.Reason != retentionLowValueReason {
t.Fatalf("低价值条目 reason = %q, want %q", v.Reason, retentionLowValueReason)
}
if len(v.Gray) != 0 {
t.Fatalf("低价值条目仍收集了 %d 条灰区候选 —— T1 会被白调一次(§10.6 不许)", len(v.Gray))
}
}
// TestJudgeIngestKeepsValuableItemAsCreate 是**变异用例**的对偶:同一条判定链上,
// 值得长期留的条目仍然走既有四态(这里是 create),证明闸只拦「显然不值得记」。
func TestJudgeIngestKeepsValuableItemAsCreate(t *testing.T) {
t.Parallel()
item := habitItem(fixtureHabitShort, tidyTestTime(t), 0.9)
v := judgeIngest(item, nil)
if v.Decision != IngestCreate {
t.Fatalf("正常条目 decision = %q, want %q(闸不许误伤)", v.Decision, IngestCreate)
}
}
+34 -1
View File
@@ -15,7 +15,40 @@ import (
// 以便 parseCandidates 确定性地反序列化。溯源(source_conversation)
// 刻意不在其中:它由持久化层盖真实 session/message id,绝不由模型生成
// (裁决 04.4.3,见 source.go)。
const extractSystemPrompt = `你是记忆提取器。从对话中提取值得长期记住的记忆项(用户偏好、习惯、约定、重要事件)。
//
// 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 []」)—— 不写这一句,模型会为了「有产出」而硬凑。
const extractSystemPrompt = `你是记忆提取器。从对话中提取**值得长期记住**的记忆项。
只保留这些(正向清单):
- 长期事实:身份、住址、职业、家庭成员、健康约束(过敏、忌口、长期用药)。
- 稳定偏好:口味、作息、喜欢什么、讨厌什么。
- 关系里程碑:约定、承诺、共同经历、纪念日、称呼习惯。
- 项目约定:编号规则、暗号、路径、命名约定。
- 能力边界:主人明确说过的「不要做 X」。
下面这些**不要提取**(反例清单):
- 寒暄与致谢:「谢谢」「早上好」「辛苦啦」「晚安」。
- 情绪波动:「今天好累」「心情不错」。
- 一次性琐事与任务指令:「帮我订个 7 点的闹钟」「把这个文件转成 pdf」。
- 时效性数据:当前时间、天气、「现在几点了」。
- 助手自己说过的话 —— 只提取主人这边的长期事实与偏好。
- 不确定的推断(「他可能喜欢 X」):拿不准就不要提取。
**允许返回空数组 []**:一轮对话里没有值得长期记住的内容时,正确做法就是回
[],不要为了凑数而提取。
只输出一个 JSON 数组,不要任何其他文字。每个元素形如:
{"content":"记忆内容","scope":"user|persona|project","kind":"relationship|world_event","tags":["标签"],"confidence":0.9,"project_id":"可选项目id"}
去噪去重:只保留值得长期记住的,忽略寒暄与临时信息。`
+36 -1
View File
@@ -35,6 +35,14 @@ import (
// 调用还要先过**全局限速**(默认 120 次/小时,preinsert_t1_limit.go):
// 超限即跳过 T1、回落 create + `t1_throttled`。
// **同步入库闸(preinsert_gate.go)不碰 T1**,写入路径因此仍是零模型调用。
// 4. **第五态 `defer` 的判定在两层各有一处来源**(design/23 §18.2):
// **T0 提取留存规则闸**(`extract_retention.go` → `judgeIngest`,纯规则、
// 零模型)拦「结构上显然不值得留」的(`reason=t0_low_value`);**T1 则把
// defer 收进同一个闭集**(`merge/modify/keep/defer`,**同一次调用**、
// 同一个 prompt、同一套候选编号),只在关系判为「真新」时才问「值不值得
// 长期留」(`reason=t1_defer`)。**绝不新增第二次模型调用**。
// 两条失败路径都回落 `create`(= 当作长期记住),fail-safe 方向恒为
// 「**宁可多留,不可误弃**」(§18.3)。
//
// 代价(如实登记):极短窗口内库里会多一行待裁定的重复条目,随后被软合并
// (status='merged' + merged_into,可 revert,绝不物理删除,design §8)。
@@ -55,6 +63,19 @@ const (
// IngestCreate 表示确属新条目(互不包含 / 被规则否决 / 没有召回):
// 正常新建,不做任何改写(§15 D3-2 T0-3)。
IngestCreate IngestDecision = "create"
// IngestDefer 是**第五态**(design/23 §18.1):与既有记忆不冲突(真新),
// 但不值得长期留存 ⇒ **照常插入**,只把 `expires_at` 写成「现在 + 临时 TTL」
// (默认 7 天,见 tempTtlSec)落进**临时层**:到期由读侧时效闸隐藏
// (design/03 §9.6)、再由后台清扫物理删除(design/23 §19)。
//
// 它与其余四态的正交关系(§18.2 的不变式):`defer` ≡「关系判为**真新**
// ∧ 价值判为**短期**」。因此它**只在被判为真新时**才可能产生 —— 已被既有
// 记忆代表的那条(merge/modify/skip)留不留由既有行决定,与价值判定无关。
//
// 🔴 仍是**软动作**:只写 `expires_at` 一列,**不删行、不改正文**;回滚 =
// 清空该列(§18.4)。选 `defer` 而非 `drop` 是 fail-safe 取向的落点
// (§18.3「宁可多留,不可误弃」):最坏后果只是「多留 7 天」。
IngestDefer IngestDecision = "defer"
)
// IngestRuleVersion 是入库前审查规则集的版本号,进每条审查留痕(§15 D3-8)。
@@ -65,7 +86,11 @@ const (
// - `d3.2` 加入 **T1 灰区模型判定**(preinsert_t1.go,design/23 §15.1 D3-2):
// 灰区(相似、互不包含、未被规则否决)在模型出口接上时会被改判成
// merge/modify,因此 `reason` 上出现了 `t1_*` 族。
const IngestRuleVersion = "d3.2"
// - `d3.3` 加入 **第五态 `defer`**(design/23 §18.1):T0 提取留存规则闸
// (`t0_low_value`)与 T1 的同一次调用(`t1_defer`)都能给出它,落点 =
// 照常插入 + 写短 `expires_at`。**升版是硬要求**:同一对条目在 d3.2 下是
// `create`、在 d3.3 下可能是 `defer`,不升版新旧留痕无法区分(§18.4)。
const IngestRuleVersion = "d3.3"
// 调优旋钮(ReviewConfig)与它们的默认值常量在 preinsert_config.go。
@@ -402,6 +427,16 @@ func (r *PreInsertReviewer) applyVerdict(ctx context.Context, v ingestVerdict, i
if v.Decision == IngestCreate {
return nil, nil
}
if v.Decision == IngestDefer {
// 第五态(design/23 §18.1):条目**早已落库**(异步审查跑在写入之后),
// 这里只把 `expires_at` 写成「现在 + 临时 TTL」把它放进临时层。
// **不删行、不改正文、不碰任何其它行** ⇒ 回滚 = 清空该列(§18.4)。
// 写不进去就如实返回错误(fail-safe:行留在长期层 = 宁可多留)。
if err := r.deps.Store.setIngestExpiry(ctx, item.ID, expiryText(r.deferDeadline(now))); err != nil {
return nil, err
}
return nil, nil
}
if v.Absorbed.ID == "" || v.Existing.ID == "" {
return nil, fmt.Errorf("memory/review: %s without a matched existing item", v.Decision)
}
@@ -36,6 +36,16 @@ const (
// 留痕 `t1_throttled`,记忆一条不丢、一个字不改。
defaultT1PerWindow = 120
defaultT1Window = 60 * time.Minute
// tempTtlSec 是**临时记忆**(第五态 `defer`,design/23 §18.1)的默认存活秒数:
// **7 天 = 604800 秒**。它是 design/03 §9.6 [替主人定 · Q3] 定死的代码默认值
// (设计稿点名的常量名就是 `tempTtlSec = 604800`)。
//
// 为什么「多久算临时」不进代码死值:7 天是保守默认,而它本质是**主人的口味**
// 而不是判据 ⇒ 面板可调,走 `$DATA_DIR/config/memory-tidy.json` 的
// `tempTtlSec`(与既有整理旋钮同一个文件、同一套「面板 > 代码默认」优先级,
// 见 ApplyReviewTuning)。**`.env` 不加键**(已冻结,design/13 §2.1)。
tempTtlSec = 604800
)
// ReviewConfig 携带入库前审查的调优旋钮。零值经 normalize 选取安全默认值。
@@ -99,6 +109,16 @@ type ReviewConfig struct {
T1PerWindow int
// T1Window 是 T1PerWindow 的时间窗。非正用代码默认值 defaultT1Window。
T1Window time.Duration
// TempTTL 是**临时记忆**的存活时长(design/23 §18.1 第五态 `defer` /
// design/03 §9.6):`defer` 命中时,落库行的 `expires_at` = 当时 + TempTTL。
// 非正用代码默认值 **7 天**(tempTtlSec = 604800)。
//
// 🔴 **非正绝不放行**(design/03 §9.6 的明文口径):一个 ≤0 的 TTL 会让每条
// defer 记忆**落库即过期**(读侧立刻隐藏、后台清扫下一轮就删)—— 那是坏配置
// 而不是「不过期」,与既有 `panelPositiveSec` 同一口径(tidy_tuning.go)。
// 因此面板写了坏值时由 ApplyReviewTuning 记 WARN 并**不覆盖**默认值,这里
// 再兜一道(任何绕过解析器的构造路径同样拿不到 ≤0)。
TempTTL time.Duration
}
// normalizeReviewConfig 用设计稿默认值填充零值字段。
@@ -133,5 +153,11 @@ func normalizeReviewConfig(cfg ReviewConfig) ReviewConfig {
if cfg.T1Window <= 0 {
cfg.T1Window = defaultT1Window
}
// 临时记忆的 TTL 同样在这里回落(design/03 §9.6:「坏值(≤0)绝不放行」)。
// 面板侧的那道 WARN 在 ApplyReviewTuning;这里是把「未归一的 ≤0 直达
// 判据」这条静默失败形态堵死 —— 与 T1 限流两项同一教训。
if cfg.TempTTL <= 0 {
cfg.TempTTL = time.Duration(tempTtlSec) * time.Second
}
return cfg
}
@@ -0,0 +1,387 @@
package memory
import (
"bytes"
"context"
"log/slog"
"os"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
)
// 本文件是**第五态 `defer`**(design/23 §18)与**临时 TTL 配置面**
// (design/03 §9.6)的验收用例集。它逐条覆盖任务书的验收点:
//
// 1. defer 落库带**短** `expires_at`(闸路径 + 异步 T1 路径);
// 2. 写进去的 `expires_at` 与读侧时效闸**同一套规范文本**
// (定宽 30 字节 UTC —— 写错布局会让闸静默判错,本仓有历史事故);
// 3. TTL 坏值(≤0)**绝不放行**:回落代码默认 7 天 + WARN;
// 4. `IngestRuleVersion` 升到 `d3.3`(改判定规则必升版,否则新旧留痕无法区分);
// 5. defer 是**软动作**:回滚 = 清空 `expires_at`,行与正文一字不动。
// readIngestExpiry 直读一行记忆的 `expires_at`(该列不在 Store.Get 的读侧面上:
// 读侧只拿它做 SQL 判据,不回表 SELECT)。
func readIngestExpiry(t *testing.T, store *Store, id string) string {
t.Helper()
var exp string
if err := store.db.QueryRowContext(context.Background(),
`SELECT expires_at FROM memory_items WHERE id = ?`, id).Scan(&exp); err != nil {
t.Fatalf("read expires_at %s: %v", id, err)
}
return exp
}
// personaListHas 报告一条记忆是否出现在 ListByPersona(**过时效闸**)的结果里。
func personaListHas(t *testing.T, store *Store, personaID, id string) bool {
t.Helper()
items, err := store.ListByPersona(context.Background(), personaID)
if err != nil {
t.Fatalf("ListByPersona: %v", err)
}
for _, it := range items {
if it.ID == id {
return true
}
}
return false
}
// newRealClockReviewer 构建一个用**真实墙钟**的审查服务(闸开着)。读侧时效闸比
// 的是真实当下,因此「这条临时记忆现在还看得见吗」这类断言必须用真实时钟,否则
// 固定夹具时刻(2026-10-01)算出来的有效期早就过去了。
func newRealClockReviewer(t *testing.T, store *Store, ttl time.Duration) *PreInsertReviewer {
t.Helper()
r, err := NewPreInsertReviewer(ReviewConfig{
Enabled: true, QueueSize: 8, ScanLimit: 50, Timeout: 5 * time.Second,
Threshold: 0.80, TempTTL: ttl,
}, ReviewDeps{
Store: store,
Now: func() time.Time { return time.Now() },
Log: testLogger(),
})
if err != nil {
t.Fatalf("NewPreInsertReviewer: %v", err)
}
return r
}
// deferFixtureItem 是一条**留存规则闸必然判 defer** 的条目(短寒暄,同分区、
// owner 来源)。
func deferFixtureItem(content string, at time.Time) MemoryItem {
return gateFixtureItem(content, at)
}
// ttlPanelPtr 造「面板里显式写了 tempTtlSec」的指针(nil 表示键没写)。
func ttlPanelPtr(v int) *int { return &v }
// TestIngestGateDeferWritesShortExpiresAt 是任务书验收点①的**闸路径**:
// 低价值条目照常 active 落库(一个字不丢),`expires_at` = 判定时刻 + 临时 TTL,
// 留痕是 `defer` / `t0_low_value` / 规则版本 `d3.3`。
func TestIngestGateDeferWritesShortExpiresAt(t *testing.T) {
t.Parallel()
store := newTestStore(t)
ttl := time.Hour
base := tidyTestTime(t)
r := newGateReviewer(t, store, ReviewConfig{TempTTL: ttl}, nil)
store.AttachIngestReviewer(r)
got := seedTidyItem(t, store, deferFixtureItem("谢谢啦", base))
want := expiryText(base.Add(ttl))
if exp := readIngestExpiry(t, store, got.ID); exp != want {
t.Fatalf("expires_at = %q, want %q(= 判定时刻 + 临时 TTL)", exp, want)
}
if exp := readIngestExpiry(t, store, got.ID); len(exp) != storedTimeLen {
t.Fatalf("expires_at 宽度 = %d 字节, want %d(读侧是纯文本比较,宽度必须同源)",
len(exp), storedTimeLen)
}
row := readReviewRow(t, store, got.ID)
if row.status != tidyStatusActive || row.content != "谢谢啦" {
t.Fatalf("defer 行 = %+v, want 仍在册且正文一字不动(软动作)", row)
}
rev := gateRecordFor(t, store, got.ID)
if rev.Decision != IngestDefer || rev.Reason != retentionLowValueReason {
t.Fatalf("闸留痕 = %+v, want decision=defer reason=%q", rev, retentionLowValueReason)
}
if rev.RuleVer != "d3.3" {
t.Fatalf("闸留痕规则版本 = %q, want d3.3", rev.RuleVer)
}
if s := r.Stats(); s.GateDeferred != 1 {
t.Fatalf("闸计数 = %+v, want gate_deferred=1", s)
}
}
// TestIngestGateDeferIsNotEnqueuedForAsyncReview 钉住「defer 已是终局判断」:
// 它**不投**异步审查队列 —— 再送语义审查只会把这条临时记忆并进一条长期记忆、
// 把临时性抹掉(design/23 §18.1 的落点)。
func TestIngestGateDeferIsNotEnqueuedForAsyncReview(t *testing.T) {
t.Parallel()
store := newTestStore(t)
base := tidyTestTime(t)
r := newGateReviewer(t, store, ReviewConfig{TempTTL: time.Hour}, nil)
store.AttachIngestReviewer(r)
seedTidyItem(t, store, deferFixtureItem("谢谢啦", base))
if n := len(r.queue); n != 0 {
t.Fatalf("defer 条目被投进了异步队列(队列长度 %d)—— defer 是终局判断,不该再送审", n)
}
// 对偶:正常条目仍照旧投队列(证明上面那条不是因为队列压根不工作)。
seedTidyItem(t, store, deferFixtureItem(fixtureHabitShort, base.Add(time.Minute)))
if n := len(r.queue); n != 1 {
t.Fatalf("正常条目未被投进异步队列(队列长度 %d, want 1)", n)
}
}
// TestT1DeferWritesExpiresAtOnKeptNewRow 是验收点①的**异步 T1 路径**:T1 在
// **同一次调用**里回 `defer` ⇒ 新行保留、正文不动、既有行不动,只写短 TTL。
func TestT1DeferWritesExpiresAtOnKeptNewRow(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
existing, fresh := seedT1GrayPair(t, store)
ttl := 2 * time.Hour
judge := &t1JudgeStub{reply: `{"action":"defer","target":0,"reason":"只是一次性的信息"}`}
r := newT1Reviewer(t, store, t1GrayEmbedder(), ReviewConfig{TempTTL: ttl}, judge.judge, nil)
rev, err := r.Review(ctx, fresh)
if err != nil {
t.Fatalf("Review: %v", err)
}
if rev.Decision != IngestDefer || rev.Reason != t1ReasonDefer {
t.Fatalf("verdict = %+v, want defer + %q", rev, t1ReasonDefer)
}
if judge.count() != 1 {
t.Fatalf("模型调用 = %d 次, want 恰为 1(第五态与关系判定同一次调用)", judge.count())
}
want := expiryText(tidyTestTime(t).Add(ttl))
if exp := readIngestExpiry(t, store, fresh.ID); exp != want {
t.Fatalf("expires_at = %q, want %q", exp, want)
}
row := readReviewRow(t, store, fresh.ID)
if row.status != tidyStatusActive || row.content != fixtureT1CodeViaChannel {
t.Fatalf("新行 = %+v, want 仍在册且承载自己的原文", row)
}
if got := readReviewRow(t, store, existing.ID); got.mergeCount != 1 {
t.Fatalf("既有行被改了:%+v(defer 只该碰新行的 expires_at 一列)", got)
}
}
// TestT1DeferWithTargetIsReplyInvalid 钉住 §18.2 的不变式「defer ≡ 真新 ∧ 短期」:
// 一个**指向候选**的 defer 自相矛盾(既说和它像、又说这条独立)⇒ 判非法回复、
// 回落 `create`(**宁可多留**),绝不猜。
func TestT1DeferWithTargetIsReplyInvalid(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
_, fresh := seedT1GrayPair(t, store)
judge := &t1JudgeStub{reply: `{"action":"defer","target":1,"reason":"自相矛盾"}`}
r := newT1Reviewer(t, store, t1GrayEmbedder(), ReviewConfig{TempTTL: time.Hour}, judge.judge, nil)
rev, err := r.Review(ctx, fresh)
if err != nil {
t.Fatalf("Review: %v", err)
}
if rev.Decision != IngestCreate || rev.Reason != t1ReasonReplyInvalid {
t.Fatalf("verdict = %+v, want create + %q", rev, t1ReasonReplyInvalid)
}
if exp := readIngestExpiry(t, store, fresh.ID); exp != "" {
t.Fatalf("非法 defer 却写了 expires_at = %q(回落路径必须零改写)", exp)
}
}
// TestDeferExpiryHiddenByReadGateAndRollbackRestores 把第五态接到**读侧时效闸**
// (design/03 §9.6 第 ① 步隐藏)与**回滚**(§18.4:清空 expires_at 即升回长期)上:
// 未到期可见 ⇒ 置成过去即隐藏 ⇒ 清空又可见,且 `Get`(刻意不过闸)始终读得到。
func TestDeferExpiryHiddenByReadGateAndRollbackRestores(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
r := newRealClockReviewer(t, store, time.Hour)
store.AttachIngestReviewer(r)
got := seedTidyItem(t, store, deferFixtureItem("谢谢啦", time.Now().UTC()))
if !personaListHas(t, store, got.PersonaID, got.ID) {
t.Fatal("刚落的临时记忆应当在有效期内可见(TTL = 1h)")
}
if err := store.setIngestExpiry(ctx, got.ID, expiryText(time.Now().UTC().Add(-time.Minute))); err != nil {
t.Fatalf("setIngestExpiry: %v", err)
}
if personaListHas(t, store, got.PersonaID, got.ID) {
t.Fatal("已到期的临时记忆不该出现在读路径上(时效闸失效)")
}
if _, err := store.Get(ctx, got.ID); err != nil {
t.Fatalf("Get 刻意不过时效闸(撤销/清算要读得到过期行),却报错:%v", err)
}
// 回滚(design/23 §18.4):清空 expires_at ⇒ 升回长期,立即可见。
if err := store.setIngestExpiry(ctx, got.ID, ""); err != nil {
t.Fatalf("setIngestExpiry(清空): %v", err)
}
if !personaListHas(t, store, got.PersonaID, got.ID) {
t.Fatal("清空 expires_at 后应当升回长期(回滚是无损的)")
}
if row := readReviewRow(t, store, got.ID); row.content != "谢谢啦" || row.status != tidyStatusActive {
t.Fatalf("回滚改了正文/状态:%+v", row)
}
}
// TestIngestGatePlanExpiresAtUsesSameLayoutAsTimeGate 是写入/读取**同一套规范文本**
// 的钉子(塔在派工里点名要求补的那条)。
//
// 读侧闸是**纯文本比较**(timegate.go 的 timeGateClause,不碰 SQLite 时间函数),
// 所以写侧的布局一旦分叉,闸会**静默判错**:本仓历史事故里,`' '`(0x20) 分隔的
// `datetime('now')` 与 `'T'`(0x54) 分隔的列值比较,会让**任何当日行都「大于当下」**。
func TestIngestGatePlanExpiresAtUsesSameLayoutAsTimeGate(t *testing.T) {
t.Parallel()
at := tidyTestTime(t).UTC()
// 写入侧:defer 计划给出的文本必须就是 expiryText/formatTime 的产出。
if got := (ingestGatePlan{ExpiresAt: at}).expiresAt(); got != formatTime(at) {
t.Fatalf("plan.expiresAt() = %q, want formatTime 的产出 %q", got, formatTime(at))
}
if got := (ingestGatePlan{ExpiresAt: at}).expiresAt(); len(got) != storedTimeLen {
t.Fatalf("plan.expiresAt() 宽度 = %d, want %d", len(got), storedTimeLen)
}
// 零值 = 不设过期(长期/日常记忆)⇒ 空串,恒可见。
if got := (ingestGatePlan{}).expiresAt(); got != "" {
t.Fatalf("零值计划的 expiresAt() = %q, want 空串", got)
}
// 反证:同一瞬间换一种布局,文本序就与时间序脱钩(读侧判据随之失效)。
sqliteLayout := at.Format("2006-01-02 15:04:05")
if formatTime(at) == sqliteLayout {
t.Fatal("两种布局不该相同 —— 那这条反证就没有意义了")
}
if !(formatTime(at) > sqliteLayout) {
t.Fatalf("定宽布局应恒大于空格分隔布局('T' > ' '),实得 %q vs %q", formatTime(at), sqliteLayout)
}
if sqliteLayout[0:4] != formatTime(at)[0:4] {
t.Fatal("两种布局的日期前缀应当相同,否则说明取的时间不是同一瞬间")
}
}
// TestIngestRuleVersionIsD33 钉住任务书验收点④:改判定规则必升版
// (design/23 §18.4),否则新旧留痕无法区分。
func TestIngestRuleVersionIsD33(t *testing.T) {
t.Parallel()
if IngestRuleVersion != "d3.3" {
t.Fatalf("IngestRuleVersion = %q, want d3.3(第五态改变了判定闭集)", IngestRuleVersion)
}
}
// TestApplyReviewTuningTempTTL 钉住验收点③:TTL 的三档优先级与**坏值绝不放行**。
//
// 三档优先级在 TTL 这一键上实际是「**面板 > 代码默认**」:`.env` 已冻结
// (design/13 §2.1),本键在那里**从来就没有出处** —— 中间那一档在结构上是空的,
// 这不是漏配(design/03 §9.6 写的就是「面板 > 代码默认」)。
func TestApplyReviewTuningTempTTL(t *testing.T) {
t.Parallel()
const fallbackSec = 604800 // 7 天(tempTtlSec)
cases := []struct {
name string
panel *int
wantTTL time.Duration
wantWarn bool
}{
{"键没写 ⇒ 代码默认", nil, 7 * 24 * time.Hour, false},
{"面板写正数 ⇒ 用面板值", ttlPanelPtr(3600), time.Hour, false},
{"面板写 0 ⇒ 坏值回落默认 + WARN", ttlPanelPtr(0), 7 * 24 * time.Hour, true},
{"面板写负数 ⇒ 坏值回落默认 + WARN", ttlPanelPtr(-3600), 7 * 24 * time.Hour, true},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
log := slog.New(slog.NewTextHandler(&buf, nil))
got := normalizeReviewConfig(ApplyReviewTuning(
ReviewConfig{}, runtimeconfig.MemoryTidyTuning{TempTtlSec: tc.panel}, log))
if got.TempTTL != tc.wantTTL {
t.Fatalf("TempTTL = %v, want %v", got.TempTTL, tc.wantTTL)
}
hasWarn := strings.Contains(buf.String(), "临时记忆 TTL 不可用")
if hasWarn != tc.wantWarn {
t.Fatalf("WARN 出现 = %t, want %t(日志:%s)", hasWarn, tc.wantWarn, buf.String())
}
if got.TempTTL <= 0 {
t.Fatal("归一化后仍是非正 TTL —— 那会让每条 defer 记忆落库即过期")
}
})
}
// 代码默认值本身就是 design/03 §9.6 点名的 604800 秒。
if normalizeReviewConfig(ReviewConfig{}).TempTTL != time.Duration(fallbackSec)*time.Second {
t.Fatalf("代码默认 TTL = %v, want %ds", normalizeReviewConfig(ReviewConfig{}).TempTTL, fallbackSec)
}
if tempTtlSec != fallbackSec {
t.Fatalf("tempTtlSec = %d, want %d", tempTtlSec, fallbackSec)
}
}
// TestNewPreInsertReviewerNormalizesBadTempTTL 钉住「坏值不靠调用方自觉」:
// 任何绕过解析器、直接把 ≤0 塞进 ReviewConfig 的路径,也在构造期被归一化掉
// (与 T1 限流两项同一教训,design/12 §N C.5 G3)。
func TestNewPreInsertReviewerNormalizesBadTempTTL(t *testing.T) {
t.Parallel()
store := newTestStore(t)
for _, ttl := range []time.Duration{0, -time.Hour} {
r, err := NewPreInsertReviewer(ReviewConfig{Enabled: true, TempTTL: ttl}, ReviewDeps{
Store: store, Log: testLogger(),
})
if err != nil {
t.Fatalf("NewPreInsertReviewer: %v", err)
}
if got := r.cfg.TempTTL; got != 7*24*time.Hour {
t.Fatalf("ReviewConfig.TempTTL = %v 未回落默认,实得 %v", ttl, got)
}
}
}
// TestIngestRuleVersionIsNotInTheTidyIdempotencyKey 是**防漂移缝断言**,回答一条
// 跨机制的实体问题:`IngestRuleVersion` 由 d3.2 升到 d3.3 之后,**既有 pending 整理
// 候选会不会凭幂等键复制出重复行**?
//
// 答案:**不会**,因为这是两个独立的常量 ——
//
// IngestRuleVersion ("d3.3") 入库审查(本切片)→ 只进 memory_ingest_reviews.rule_version
// TidyRuleVersion ("t0.1") 整理(design/23 §17.6)→ **进整理候选的幂等键**
//
// 幂等键的算式是 `tidyIdempotencyKey`(tidy_candidate.go):hash(persona_id, scope,
// project_id, sorted(source_ids), action, **c.RuleVersion**),而候选的 RuleVersion 由
// 构造器写成 TidyRuleVersion。生产库实测(2026-10-13,只读):`memory_tidy_candidates`
// 的 64 条 pending 全部 `rule_version='t0.1'`,`memory_ingest_reviews` 里则是
// `d3.1` / `d3.2` —— 两张表**从不共享**这一个值。
//
// design/23 §17.6 记录过的那次「升版 ⇒ 既有候选凭新键复制一份重复行」是
// **TidyRuleVersion** 的语义(那也是它当时刻意不升的原因),不是这一个。
// 若将来有人把 IngestRuleVersion 接进那个键(或把两个常量合并),本用例会红。
func TestIngestRuleVersionIsNotInTheTidyIdempotencyKey(t *testing.T) {
t.Parallel()
if IngestRuleVersion == TidyRuleVersion {
t.Fatalf("两个版本号不该相等(都是 %q):它们分属入库审查与整理两条机制",
IngestRuleVersion)
}
src, err := os.ReadFile("tidy_candidate.go")
if err != nil {
t.Fatalf("读 tidy_candidate.go: %v", err)
}
text := string(src)
if !strings.Contains(text, "string(c.Action), c.RuleVersion") {
t.Fatal("整理候选幂等键的算式变了 —— 请复核 IngestRuleVersion 是否意外进了键")
}
if strings.Contains(text, "IngestRuleVersion") {
t.Fatal("tidy_candidate.go 里出现了 IngestRuleVersion:两个版本号开始互相污染")
}
if strings.Count(text, "TidyRuleVersion") < 2 {
t.Fatal("整理候选的 RuleVersion 不再由 TidyRuleVersion 赋值 —— 幂等键的口径变了")
}
}
+80
View File
@@ -53,6 +53,11 @@ const (
// gateTraceAbsorbed 是闸真的做了决定(落 merged)的留痕原因码。
gateTraceAbsorbed = "gate:absorbed"
// gateTraceDeferred 是**第五态**在闸侧做决定的留痕原因码(`expires_at` 已写)。
// 与 gateTraceAbsorbed 分开:前者写的是 status/merged_into,后者只写
// expires_at 一列 —— 「闸当场折回了多少重复」与「闸当场放进了多少临时记忆」
// 是两条不同的观测线,加成一个数会让面板读不出分型。
gateTraceDeferred = "gate:deferred"
// gateTraceBudgetExceeded 是闸被墙钟预算截断的 fail-open 原因码。
gateTraceBudgetExceeded = "gate:budget_exceeded"
// gateTraceUnavailable 是候选源不可用(读取失败)的 fail-open 原因码。
@@ -72,6 +77,14 @@ const (
// ingestPlanAbsorbOld 是确定性判据命中且**既有行是子集**(modify):新行正常
// insert(它承载更全的内容),既有行置为被合并(design §15 D3-6 的「改写」)。
ingestPlanAbsorbOld ingestPlanAction = "absorb_old"
// ingestPlanDefer 是**第五态**(design/23 §18.1):真新、但不值得长期留存 ⇒
// 行**照常 active 落库**(一个字不丢),只是紧接着把 `expires_at` 写成
// 「现在 + 临时 TTL」,使它落进临时层 —— 到期由读侧时效闸隐藏
// (design/03 §9.6)、再由后台清扫物理删除(design/23 §19)。
//
// 它**不投异步审查队列**(Async=false):defer 已经是这条记忆的**终局判断**
// (「不值得长期留」),再送语义审查只会把它并进一条长期记忆、把临时性抹掉。
ingestPlanDefer ingestPlanAction = "defer"
)
// ingestGatePlan 是同步入库闸的落库计划。零值(Action="")不存在:planIngest
@@ -97,6 +110,23 @@ type ingestGatePlan struct {
// Elapsed 是闸在写路径上花掉的墙钟。它只进日志与留痕,**不参与任何判定**
// (否则同一批数据会有两个结论,幂等性就没了)。
Elapsed time.Duration
// ExpiresAt 是第五态(defer)要写进新行 `expires_at` 的时刻:**零值 = 不设
// 过期**(长期 / 日常记忆,落库为空串,四条列表读路径恒可见)。
//
// 它是**时刻**而不是「TTL 秒数」:判定的那一刻就把 TTL 换算掉,落库侧因此
// 不需要认识任何配置,也不会出现「写的时候又算一次 TTL」的双口径。
ExpiresAt time.Time
}
// expiresAt 返回新行落库时的 `expires_at` 文本,与 `insertStatus()` /
// `absorbedID()` 是同族:把「计划 → 要写进 INSERT 的值」这一步收在计划自己身上。
//
// 🔴 **编码必须走 expiryText → formatTime**(定宽 30 字节 UTC),不许另写一种
// 布局:读侧时效闸是**纯文本比较**(timegate.go 的 timeGateClause,不碰 SQLite
// 时间函数),布局一旦分叉就会**静默判错**(本仓已有历史事故,timegate_test.go
// 钉着那条反证)。零值 ⇒ 空串 ⇒ 该行恒可见。
func (p ingestGatePlan) expiresAt() string {
return expiryText(p.ExpiresAt)
}
// absorbedID 返回应该写进新行 `merged_into` 的存活行 id。
@@ -181,6 +211,13 @@ func (r *PreInsertReviewer) planFromVerdict(item MemoryItem, v ingestVerdict) in
plan.Survivor = item
plan.Absorbed = v.Existing
plan.Trace = gateTraceAbsorbed
case IngestDefer:
// 第五态(design/23 §18.1):行照常 active 落库(一个字不丢、可 revert),
// 只把 expires_at 写成「现在 + 临时 TTL」;**不投异步队列**(defer 已是
// 终局判断,再送语义审查只会把临时性抹掉)。
plan.Action = ingestPlanDefer
plan.ExpiresAt = r.deferDeadline(r.now().UTC())
plan.Trace = gateTraceDeferred
default:
// 真新 / 拿不准:正常 insert,并投既有异步队列(用语义召回再判一次)。
plan.Action = ingestPlanInsert
@@ -189,6 +226,20 @@ func (r *PreInsertReviewer) planFromVerdict(item MemoryItem, v ingestVerdict) in
return plan
}
// deferDeadline 把「现在」换算成第五态要写进 `expires_at` 的时刻(现在 + 临时
// TTL,design/03 §9.6)。
//
// TTL 由 `cfg.TempTTL` 决定,**它已经过 normalize**(`NewPreInsertReviewer` 里
// normalizeReviewConfig 先跑),因此这里拿到的恒为正 —— 「≤0 的 TTL 让每条记忆
// 落库即过期」这条坏配置形态在构造期就被回落掉了(normalizeReviewConfig 兜底 +
// ApplyReviewTuning 的 WARN)。
//
// 闸(同步)与异步审查层**共用这一个函数**:同一份配置、同一种时刻语义,因此
// 同一条记忆无论被哪一层判成 defer,拿到的有效期都来自同一个算式。
func (r *PreInsertReviewer) deferDeadline(now time.Time) time.Time {
return now.UTC().Add(r.cfg.TempTTL)
}
// failOpenPlan 组装 fail-open 计划:照常插入(active)+ 投既有队列 + 留痕。
// 「绝不静默丢记忆」不是一句承诺,而是这条路径的结构性结果。
func (r *PreInsertReviewer) failOpenPlan(reason string, start time.Time) ingestGatePlan {
@@ -298,6 +349,35 @@ func (s *Store) finishIngest(ctx context.Context, item MemoryItem, plan ingestGa
if plan.Action == ingestPlanInsert {
return
}
if plan.Action == ingestPlanDefer {
// 第五态(design/23 §18.1):新行已经在 Add 里照常插好了(active、正文
// 一字不差),这里只把 `expires_at` 补成计划给出的时刻 —— 与
// absorb_new / absorb_old 是**同一范式**:INSERT 落行、finishIngest 落
// 「那一行的派生列」。
//
// 为什么不是「INSERT 直接带上 expires_at」:store.go 的 INSERT 读的是
// `expiryText(item.ExpiresAt)`(由**调用方**置位),而 defer 的判断发生在
// 闸里(`planIngest` 拿的是 item 的**副本**),计划的值够不到那条 INSERT。
//
// 🔴 **代价与短暂窗口(如实登记,不静默)**:这一态因此多出一笔按主键的
// UPDATE(微秒级),且 INSERT 与这笔补写之间有一个**极短的窗口** ——
// 若进程恰在此刻中断,该行会以 `expires_at=''`(**永不过期**)留在库里。
// 方向是刻意的:它正是 fail-safe 的「**宁可多留,不可误弃**」
// (design/23 §18.3)—— 停在长期层只是多占一条记忆,而反向失败
// (把该留的判成临时)会丢信息。写失败同样只记 WARN、**不让 Add 失败**。
//
// 非 defer 的三态**一笔都不多写**:absorb_new / absorb_old 本来就有它们的
// 第二笔(合并登记 / 置为被合并),insert 在函数开头就返回了。
if err := s.setIngestExpiry(ctx, item.ID, plan.expiresAt()); err != nil {
s.log.Warn("memory ingest gate defer failed, item left long-lived",
"id", item.ID, "scope", string(item.Scope), "error", err)
return
}
if r := s.ingestReviewer(); r != nil {
r.bump(func(st *ReviewStats) { st.GateDeferred++ })
}
return
}
now := time.Now().UTC()
var err error
switch plan.Action {
@@ -16,6 +16,11 @@ package memory
// `ingestVerdict.Gray`,交**异步审查层**的 T1 再问一次
// (preinsert_t1.go,design/23 §15.1 D3-2)。**本文件恒为零模型调用**。
//
// 另外在**候选比对之前**插一道**提取留存规则闸**(extract_retention.go,
// design/03 §10.5 的 N1~N5):新条目自己就「显然不值得长期留存」时直接给第五态
// `defer`(`reason=t0_low_value`),**不进候选比对、不进 T1**。它是纯规则、零模型
// 调用,因此不改变上面那句「本文件恒为零模型调用」。
//
// 两条不可走样:
// - **相似度只用来召回候选**,永不参与判定(design/23 §1.2「单阈值双向失效」);
// - **规则否决一票否决**,且**绝不跨作用域/跨角色/跨项目**(§5.2-7/8/9 是硬门,
@@ -68,6 +73,18 @@ func judgeIngest(item MemoryItem, cands []ingestCandidate) ingestVerdict {
if v, ok := judgeExactDuplicate(item, cands); ok {
return v
}
// 提取留存规则闸(design/03 §10.5 的 N1~N5):新条目**自己**就「显然不值得
// 长期留存」时直接给第五态 `defer`,**不进候选比对、不进 T1**
// (design/23 §10.6:规则闸拦下的不进 T1,避免「模型推翻规则」)。
//
// 它排在**精确重复之后**是刻意的:已有等价条目时 `skip` 只更新既有行、
// **一行都不新增**,比 `defer`(新增一行临时记忆)更省也更干净。
if rule := judgeExtractionRetention(item.Content); rule != retentionNone {
return ingestVerdict{
Decision: IngestDefer, Reason: retentionLowValueReason, Mode: mode,
Veto: VetoNone, Gray: nil,
}
}
vetoed, vetoScore := VetoNone, 0.0
// 灰区惰性收集:只有真的出现「互不包含且未被否决」的候选才分配,
// 同步写入路径上的常见情形(无候选 / 一眼真新)因此零额外分配。
+19 -4
View File
@@ -17,6 +17,10 @@ type ReviewStats struct {
Merged int
Modified int
Created int
// Deferred 是**异步审查层**判出第五态 defer 的条数(T0 规则闸 / T1 同一次
// 调用都可能给出它)。它与 Created 分列:两者都留下新行,但只有 defer 带
// 短 `expires_at`,它是「临时层新增」的观测量。
Deferred int
Dropped int
Failed int
// GateChecked 是**同步入库闸**实际做了一次判定的条数(跳过闸的条目不计)。
@@ -26,6 +30,11 @@ type ReviewStats struct {
GateAbsorbed int
// GateFailed 是闸因预算/候选源不可用而 fail-open 的次数(照常插入 + 留痕)。
GateFailed int
// GateDeferred 是闸当场判定为**第五态 defer** 并**真的写成了** `expires_at`
// 的次数(design/23 §18.1)。它与 GateAbsorbed 分列:「当场折回重复」与
// 「当场放进临时层」是两条不同的观测线,加成一个数面板就读不出分型。
// 只统计写成功的(写失败时那行停在长期层,不是临时记忆)。
GateDeferred int
// T1Called 是 T1 实际发出过**模型调用**的次数。每次审查至多 +1
// (defaultT1MaxGray 只是把多条灰区候选打包进同一次 prompt,不多发请求)。
T1Called int
@@ -58,6 +67,10 @@ func (r *PreInsertReviewer) recordDecision(rev IngestReview) {
s.Merged++
case IngestModify:
s.Modified++
case IngestDefer:
// 第五态单独一档:它与 create 都「留下了新行」,但一个有 TTL、
// 一个没有 —— 混进 Created 就看不出「有多少记忆是临时的」。
s.Deferred++
default:
s.Created++
}
@@ -79,8 +92,10 @@ func (r *PreInsertReviewer) auditWith(verb string, rev IngestReview) {
// reviewStatsLabel 把计数渲染成一条稳定日志串。
func reviewStatsLabel(s ReviewStats) string {
return fmt.Sprintf("reviewed=%d skip=%d merge=%d modify=%d create=%d dropped=%d failed=%d "+
"gate_checked=%d gate_absorbed=%d gate_failed=%d t1_called=%d t1_decided=%d t1_throttled=%d",
s.Reviewed, s.Skipped, s.Merged, s.Modified, s.Created, s.Dropped, s.Failed,
s.GateChecked, s.GateAbsorbed, s.GateFailed, s.T1Called, s.T1Decided, s.T1Throttled)
return fmt.Sprintf("reviewed=%d skip=%d merge=%d modify=%d create=%d defer=%d dropped=%d failed=%d "+
"gate_checked=%d gate_absorbed=%d gate_deferred=%d gate_failed=%d "+
"t1_called=%d t1_decided=%d t1_throttled=%d",
s.Reviewed, s.Skipped, s.Merged, s.Modified, s.Created, s.Deferred, s.Dropped, s.Failed,
s.GateChecked, s.GateAbsorbed, s.GateDeferred, s.GateFailed,
s.T1Called, s.T1Decided, s.T1Throttled)
}
@@ -23,6 +23,11 @@ import (
// ingestStatusMerged 是「已被合并」的行状态(design/23 §10 的 status 列)。
const ingestStatusMerged = "merged"
// 🔴 这条内部召回 SQL **刻意只过 status 闸、不过时效闸**(design/03 §11.2 第 3 条,
// 先例见 design/23 §10.1 ①;主会话 2026-10-13 裁定「保持不过时效闸」):它比对的是
// **正文**,与可见性无关 —— 加闸会让过期但未清扫的临时行退出比对,同一事实遂以
// 「过期行 + 新行」并存(正是主人报的「反复新建」)。过期行由后台清扫物理删除
// (design/23 §19),**不会复活**。姊妹注释在 tidy_store.go 的 listTidyPartitionItemsSQL。
const listReviewCandidatesSQL = `SELECT id, persona_id, role_id, project_id, scope, kind,
content, tags, confidence, source_conversation, created_at, updated_at,
decay_weight FROM memory_items
@@ -46,6 +51,12 @@ const selectMergedIntoSQL = `SELECT merged_into FROM memory_items
const selectItemStatusSQL = `SELECT status FROM memory_items WHERE id = ?`
// setIngestExpirySQL 是第五态(design/23 §18.1)的落点:**只写 expires_at 一列**。
// 不碰 status / merged_* / content,因此它天然是「软动作」——回滚 = 把该列清空
// (§18.4);行一行不删(临时层的物理删除是 design/23 §19 的另一条有审计的
// 清扫路径,不在这里)。
const setIngestExpirySQL = `UPDATE memory_items SET expires_at = ? WHERE id = ?`
const insertIngestReviewSQL = `INSERT INTO memory_ingest_reviews
(id, item_id, persona_id, scope, project_id, decision, reason, veto, target_id,
merged_ids, score, recall_mode, degraded, rule_version, session_id, created_at)
@@ -115,6 +126,26 @@ func (s *Store) listReviewCandidates(ctx context.Context, p tidyPartition, exclu
return scanItems(rows, "review candidate")
}
// setIngestExpiry 把一条记忆的 `expires_at` 写成 expiryText(**定宽 30 字节 UTC**),
// 空白串表示「不设过期」。它是第五态 defer 的**唯一**写口,两处调用:
//
// - 同步入库闸(preinsert_gate.go 的 finishIngest):INSERT 之后补这一列;
// - 异步审查层(preinsert.go 的 applyVerdict):T1 判 defer 时改这一列。
//
// 🔴 **编码必须与读侧同源**:读侧时效闸是纯文本比较(timegate.go),因此这里
// 只许走 expiryText —— 写成别的布局(RFC3339 带 `+08:00`、秒精度、空格分隔)
// 会让闸**静默**判错(早八小时的列值可能被判成「大于当下」,B 仓历史事故,
// timegate_test.go 钉着那条反证)。
func (s *Store) setIngestExpiry(ctx context.Context, id, expiresAt string) error {
if strings.TrimSpace(id) == "" {
return ErrNotFound
}
if _, err := s.db.ExecContext(ctx, setIngestExpirySQL, expiresAt, id); err != nil {
return fmt.Errorf("memory: set ingest expiry: %w", err)
}
return nil
}
// absorbIngestItem 把 absorbed 软合并进 survivor:一个事务里做「标记被吸收 +
// 刷新存活行的合并元信息」。两条 UPDATE 都只碰 status/merged_*/merge_count/
// confidence/updated_at,**绝不改 content**(design §8 的可回滚口径)。
+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 块会被剥离,前后夹带
// 的解说文字也被忽略(只取第一个 `{` 到最后一个 `}`)—— 容忍格式噪声,但**不猜**:
@@ -0,0 +1,70 @@
package memory
import (
"log/slog"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
)
// 本文件是**入库前审查调优面**的解析点:把 `$DATA_DIR/config/memory-tidy.json`
// 的面板旋钮叠到 ReviewConfig 上 —— 与 tidy_tuning.go 对 TidyConfig 做的事同源、
// 同一条口径(主人 2026-10-07:「**`.env` 只做兜底用** —— 后端自动创建独立的面板
// 配置文件之后就走面板配置文件,这些键优先从面板配置上取」)。
//
// 为什么解析放在 memory 而不是 runtimeconfig:runtimeconfig 只负责**文件形状与
// 读取**(它的头注释把这条边界写死了),而「缺位/坏值怎么回落」属于 ReviewConfig
// 的归一化语义 —— 与 normalizeReviewConfig 同一层,因此两者可以一起被单测覆盖。
//
// 为什么不是「读盘时逐键容错」:整份文件解不开(含某键类型写错)时**全部键一起**
// 回落,这是本文件家族既有的粒度(见 runtimeconfig.LoadMemoryTidyTuning 与它的
// 用例③);本文件处理的是**类型正确但值不可用**(非正数)那一档。
// ApplyReviewTuning 把面板调优旋钮叠到 ReviewConfig 上,返回叠加后的配置
// (**未归一化**:调用方随后交给 NewPreInsertReviewer / normalizeReviewConfig
// 填代码默认值,与组合根对 TidyConfig 的既有顺序一致)。
//
// 目前只有一键,逐键独立,三档优先级是:
//
// 键 面板(memory-tidy.json) 下一档(.env) 代码默认
// tempTtlSec tempTtlSec **无此键**(结构性为空) 604800(7 天)
//
// 🔴 **中档(`.env`)在结构上是空的**,这是**刻意**的、不是漏配:`.env` 已冻结
// (design/13 §2.1「不加、不改、不删」),而「多久算临时」是本次新增的能力,
// 它在 `.env` 里**从来就没有键**。因此实际生效链是「面板 > 代码默认」——报回报时
// 不许把它写成「面板 > .env > 代码默认」而让读者以为 `.env` 那档还有东西可调。
//
// 坏值(非正秒数)**绝不写进 cfg**:回落代码默认 + 记一条 WARN。一个 ≤0 的 TTL
// 会让每条 defer 记忆**落库即过期**(读侧立刻隐藏、后台清扫下一轮就删)——那是
// 坏配置而不是「不过期」,但也不许因此让启动失败(design/03 §9.6)。
func ApplyReviewTuning(cfg ReviewConfig, panel runtimeconfig.MemoryTidyTuning, log *slog.Logger) ReviewConfig {
log = logger.WithSource(baseTidyLog(log), "memory/review")
if sec := panelPositiveTTLSec(panel.TempTtlSec, "tempTtlSec", log); sec > 0 {
cfg.TempTTL = time.Duration(sec) * time.Second
}
return cfg
}
// panelPositiveTTLSec 解析**临时记忆 TTL** 旋钮,返回 0 表示「不覆盖」(调用方原样
// 留着上一档的值,最终由 normalizeReviewConfig 落到代码默认 7 天)。
//
// 三种情形分得很清,因为它们该有不同的观测形状(与 tidy_tuning.go 的
// panelPositiveSec 逐字同构):
//
// - `nil`(键没写)⇒ 0,**静默**:没配不是错,正是「代码默认兜底」那一档;
// - 非正数(写了 0 或负数)⇒ 0 + **WARN**:它会让每条临时记忆落库即过期
// (与「不过期」恰好相反),属于坏配置,必须留下痕迹;
// - 正数 ⇒ 原值。
func panelPositiveTTLSec(v *int, key string, log *slog.Logger) int {
if v == nil {
return 0
}
if *v <= 0 {
log.Warn("memory-tidy.json 的临时记忆 TTL 不可用,回落代码默认(非正 TTL 会让临时记忆落库即过期)",
"key", key, "value", *v, "fallback_sec", tempTtlSec)
return 0
}
return *v
}
+14 -8
View File
@@ -4,7 +4,7 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
)
// 本文件是「启动播种」的**值来源**:把 `$DATA_DIR/config/memory-tidy.json` 十键的
// 本文件是「启动播种」的**值来源**:把 `$DATA_DIR/config/memory-tidy.json` 十一键的
// **当前生效值**拍成一份完整快照,交给 runtimeconfig.SeedMemoryTidy 落盘。
//
// 它回应主人 2026-10-07 的两条口径:
@@ -24,12 +24,12 @@ import (
// 的配置,只是侥幸等价;取归一化后的值,等价性才是结构性的(见
// TestTidyTuningSnapshot_PanelRoundTripKeepsEffectiveConfig)。
//
// 为什么只有一处实现:十键的默认值在 memory 侧各只有一处定义
// 为什么只有一处实现:十一键的默认值在 memory 侧各只有一处定义
// (tidy_config.go / preinsert_config.go 的 default* 常量),快照直接复用那两个
// normalize,**不另写一份默认值** —— 否则播种写下去的就是第二份「差不多」的默认值,
// 会与运行期悄悄分叉。
//
// 十键与它们的生效来源:
// 十一键与它们的生效来源:
//
// 键 生效来源(无面板文件时) 快照值
// autoApply DisableAutoApply 的反向(代码默认开) true
@@ -38,12 +38,13 @@ import (
// t1Judge DisableT1 的反向(默认开) true
// t1PerWindow defaultT1PerWindow 120
// t1WindowSec defaultT1Window 3600
// tempTtlSec ReviewConfig.TempTTL(默认 7 天 = 604800) 归一化值
// intervalSec TidyConfig.IntervalSec(env 或 86400) 归一化值
// quietIntervalSec 缺省 = intervalSec 归一化值
// quietStartHour TidyConfig.QuietStartHour(env 或 23) 归一化值
// quietEndHour TidyConfig.QuietEndHour(env 或 7) 归一化值
// TidyTuningSnapshot 拍下十键的**当前生效值**,返回一份可直接落盘的面板快照。
// TidyTuningSnapshot 拍下十一键的**当前生效值**,返回一份可直接落盘的面板快照。
//
// 入参是**已经叠过面板层的**两个配置(组合根的次序:TidyConfigFromConfig →
// ApplyTidyScheduleTuning → 本函数;审查侧同理),本函数只做最后一步归一化并翻成
@@ -51,10 +52,11 @@ import (
//
// - tidy 侧四键(intervalSec / quietIntervalSec / quietStartHour / quietEndHour)
// 与自动档两键(autoApply / autoApplyPerRound);
// - review 侧四键(ingestGate / t1Judge / t1PerWindow / t1WindowSec):
// 这四项在 `.env` 里**没有出处**(`.env` 已冻结),只由面板文件与代码默认决定,
// 而播种只发生在**面板文件不存在**时 —— 那正是「零值 + normalizeReviewConfig」
// 这一条路径,与运行期(wireMemoryReview 的三个 helper)逐字一致。
// - review 侧五键(ingestGate / t1Judge / t1PerWindow / t1WindowSec /
// tempTtlSec):这五项在 `.env` 里**没有出处**(`.env` 已冻结),只由面板文件
// 与代码默认决定,而播种只发生在**面板文件不存在**时 —— 那正是「零值 +
// normalizeReviewConfig」这一条路径,与运行期(wireMemoryReview 的四个 helper)
// 逐字一致。
//
// 两个开关是**反向**字段(DisableX),快照要把它们翻回正向的 JSON 键:文件里的
// `"autoApply": true` 才是「没配时到底开不开」的如实陈述。
@@ -69,6 +71,9 @@ func TidyTuningSnapshot(tidy TidyConfig, review ReviewConfig) runtimeconfig.Memo
quietIntervalSec := tidy.QuietIntervalSec
quietStartHour := tidy.QuietStartHour
quietEndHour := tidy.QuietEndHour
// 临时记忆 TTL(design/03 §9.6):normalize 之后恒为正(7 天 = 604800),
// 因此快照写下去的**必然是有效值**,不会播出一份自己读回都要 WARN 的配置。
tempTtlSec := int(review.TempTTL.Seconds())
return runtimeconfig.MemoryTidyTuning{
AutoApply: &autoApply,
@@ -77,6 +82,7 @@ func TidyTuningSnapshot(tidy TidyConfig, review ReviewConfig) runtimeconfig.Memo
T1Judge: &t1Judge,
T1PerWindow: review.T1PerWindow,
T1WindowSec: int(review.T1Window.Seconds()),
TempTtlSec: &tempTtlSec,
IntervalSec: &intervalSec,
QuietIntervalSec: &quietIntervalSec,
QuietStartHour: &quietStartHour,