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

@@ -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)
}
}