Files
Cyrene_v2/internal/config/runtimeconfig/memorytidy_test.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

334 lines
13 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 runtimeconfig
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
// 本文件锁定 `$DATA_DIR/config/memory-tidy.json`(memorytidy.go)的读取口径:
//
// ① 文件缺失 / 空白 ⇒ 零值 + nil(「还没配过」不是错误);
// ② `autoApply` **没写** ⇒ 不覆盖(AutoApplyOff=false)—— 这是指针存在的理由:
// 「没配」与「显式配成 false」必须可区分,否则只写上限的文件会静默关掉自动档;
// ③ `autoApply: false` ⇒ AutoApplyOff=true(一键回到只产建议);
// ④ 文件存在但解不开 ⇒ 分类错误(调用方据此记 WARN 并回落代码默认值);
// ⑤ 落点常量与 backthink 那一族同一目录、不同文件名(不共用常量,避免静默搬家)。
func TestLoadMemoryTidyTuning_MissingIsNotAnError(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "config", "memory-tidy.json")
got, err := LoadMemoryTidyTuning(path)
if err != nil {
t.Fatalf("缺失文件的 err = %v, want nil", err)
}
if got.AutoApplyOff() || got.IngestGateOff() || got.AutoApplyPerRound != 0 {
t.Fatalf("缺失文件应当给零值:%+v", got)
}
}
func TestLoadMemoryTidyTuning_BlankIsNotAnError(t *testing.T) {
t.Parallel()
path := writeMemoryTidyFile(t, " \n\t")
got, err := LoadMemoryTidyTuning(path)
if err != nil {
t.Fatalf("空白文件的 err = %v, want nil", err)
}
if got.AutoApplyOff() {
t.Fatal("空白文件不该被读成「关掉自动档」")
}
}
func TestLoadMemoryTidyTuning_AbsentAutoApplyKeepsDefault(t *testing.T) {
t.Parallel()
path := writeMemoryTidyFile(t, `{"autoApplyPerRound": 7}`)
got, err := LoadMemoryTidyTuning(path)
if err != nil {
t.Fatalf("LoadMemoryTidyTuning: %v", err)
}
if got.AutoApply != nil {
t.Fatalf("没写 autoApply ⇒ 指针应为 nil,实际 %v", *got.AutoApply)
}
if got.AutoApplyOff() {
t.Fatal("只写了上限的文件不得把自动档关掉(代码默认是开)")
}
if got.AutoApplyPerRound != 7 {
t.Fatalf("autoApplyPerRound = %d, want 7", got.AutoApplyPerRound)
}
}
func TestLoadMemoryTidyTuning_ExplicitFalseTurnsAutoApplyOff(t *testing.T) {
t.Parallel()
path := writeMemoryTidyFile(t, `{"autoApply": false}`)
got, err := LoadMemoryTidyTuning(path)
if err != nil {
t.Fatalf("LoadMemoryTidyTuning: %v", err)
}
if !got.AutoApplyOff() {
t.Fatal("显式 false 必须被读成「关掉自动档」")
}
on := writeMemoryTidyFile(t, `{"autoApply": true, "autoApplyPerRound": 3}`)
got, err = LoadMemoryTidyTuning(on)
if err != nil {
t.Fatalf("LoadMemoryTidyTuning(on): %v", err)
}
if got.AutoApplyOff() || got.AutoApplyPerRound != 3 {
t.Fatalf("显式 true 的回读 = %+v", got)
}
}
func TestLoadMemoryTidyTuning_BrokenIsClassifiedError(t *testing.T) {
t.Parallel()
path := writeMemoryTidyFile(t, `{"autoApply": "yes"}`)
if _, err := LoadMemoryTidyTuning(path); !errors.Is(err, ErrInvalidMemoryTidy) {
t.Fatalf("坏配置的 err = %v, want ErrInvalidMemoryTidy", err)
}
}
// TestLoadMemoryTidyTuning_IngestGate 钉住**同步入库闸**的开关口径
// (2026-10-07 主人裁定):它与自动档同一个文件、同一个「指针 = 没配」语义,
// 且**零新 env 键**(`.env` 已冻结,design/13 §2.1)。
func TestLoadMemoryTidyTuning_IngestGate(t *testing.T) {
t.Parallel()
// ① 没写 ingestGate ⇒ 不覆盖(代码默认是「开」)。
absent := writeMemoryTidyFile(t, `{"autoApplyPerRound": 3}`)
got, err := LoadMemoryTidyTuning(absent)
if err != nil {
t.Fatalf("LoadMemoryTidyTuning: %v", err)
}
if got.IngestGate != nil || got.IngestGateOff() {
t.Fatalf("没写 ingestGate 不得把闸关掉:%+v", got)
}
// ② 显式 false ⇒ 一键回到「只插不审」。
off := writeMemoryTidyFile(t, `{"ingestGate": false}`)
got, err = LoadMemoryTidyTuning(off)
if err != nil {
t.Fatalf("LoadMemoryTidyTuning(off): %v", err)
}
if !got.IngestGateOff() {
t.Fatal("显式 false 必须被读成「关掉入库闸」")
}
// 闸的开关与自动档的开关互不影响:一个键不能顺手改另一件事。
if got.AutoApplyOff() {
t.Fatal("写 ingestGate 不得顺手把自动档也关掉")
}
// ③ 显式 true ⇒ 与默认同义(写它只是为了自解释)。
on := writeMemoryTidyFile(t, `{"ingestGate": true, "autoApply": false}`)
got, err = LoadMemoryTidyTuning(on)
if err != nil {
t.Fatalf("LoadMemoryTidyTuning(on): %v", err)
}
if got.IngestGateOff() || !got.AutoApplyOff() {
t.Fatalf("两个开关必须各自独立:%+v", got)
}
// ④ 类型错 ⇒ 分类错误(调用方据此记 WARN 并回落代码默认值,绝不静默)。
broken := writeMemoryTidyFile(t, `{"ingestGate": "nope"}`)
if _, err := LoadMemoryTidyTuning(broken); !errors.Is(err, ErrInvalidMemoryTidy) {
t.Fatalf("坏 ingestGate 的 err = %v, want ErrInvalidMemoryTidy", err)
}
}
// TestLoadMemoryTidyTuning_T1RateLimit 钉住 **T1 全局限速**的两个数值键
// (design/23 §15.4,主人 2026-10-07 裁定):它们与 `t1Judge` 同一个文件、
// **零新 env 键**,且**零值只能是「没配」**——绝不能是「无上限」。
func TestLoadMemoryTidyTuning_T1RateLimit(t *testing.T) {
t.Parallel()
// ① 没写 ⇒ 保持零值(调用方据此回落代码默认额,不是「不限流」)。
absent := writeMemoryTidyFile(t, `{"t1Judge": true}`)
got, err := LoadMemoryTidyTuning(absent)
if err != nil {
t.Fatalf("LoadMemoryTidyTuning: %v", err)
}
if got.T1PerWindow != 0 || got.T1WindowSec != 0 {
t.Fatalf("没写限速键应当给零值:%+v", got)
}
// ② 写了 ⇒ 如实回读,且不连带改动别的键(一个键不能顺手关掉另一件事)。
set := writeMemoryTidyFile(t, `{"t1PerWindow": 240, "t1WindowSec": 1800}`)
got, err = LoadMemoryTidyTuning(set)
if err != nil {
t.Fatalf("LoadMemoryTidyTuning(set): %v", err)
}
if got.T1PerWindow != 240 || got.T1WindowSec != 1800 {
t.Fatalf("限速键回读 = %d/%d, want 240/1800", got.T1PerWindow, got.T1WindowSec)
}
if got.T1JudgeOff() || got.IngestGateOff() || got.AutoApplyOff() {
t.Fatalf("限速键顺手改了别的开关:%+v", got)
}
// ③ 显式 0 ⇒ 仍是零值(「没法用 0 关掉限流」是刻意的:那会是一处静默的失效面)。
zero := writeMemoryTidyFile(t, `{"t1PerWindow": 0, "t1WindowSec": 0}`)
got, err = LoadMemoryTidyTuning(zero)
if err != nil {
t.Fatalf("LoadMemoryTidyTuning(zero): %v", err)
}
if got.T1PerWindow != 0 || got.T1WindowSec != 0 {
t.Fatalf("显式 0 的回读 = %+v", got)
}
// ④ 类型错 ⇒ 分类错误(调用方据此记 WARN 并回落代码默认额,绝不静默不限流)。
for _, body := range []string{`{"t1PerWindow": "many"}`, `{"t1WindowSec": "1h"}`} {
broken := writeMemoryTidyFile(t, body)
if _, err := LoadMemoryTidyTuning(broken); !errors.Is(err, ErrInvalidMemoryTidy) {
t.Fatalf("坏配置 %s 的 err = %v, want ErrInvalidMemoryTidy", body, err)
}
}
}
func TestDefaultMemoryTidyPathIsDataDirConfig(t *testing.T) {
t.Parallel()
got := DefaultMemoryTidyPath(filepath.Join("root", "data"))
want := filepath.Join("root", "data", "config", "memory-tidy.json")
if got != want {
t.Fatalf("落点 = %q, want %q", got, want)
}
if MemoryTidyConfigRelPath != "config/memory-tidy.json" {
t.Fatalf("相对路径 = %q, want config/memory-tidy.json", MemoryTidyConfigRelPath)
}
// 与 backthink 那一族同目录、不同文件:共用常量会在某一族搬家时静默带走另一族。
if filepath.Dir(MemoryTidyConfigRelPath) != filepath.Dir(BackthinkConfigRelPath) {
t.Fatalf("两族应当同目录:%q vs %q", MemoryTidyConfigRelPath, BackthinkConfigRelPath)
}
}
// writeMemoryTidyFile 在临时目录里写一份 memory-tidy.json 并返回路径。
func writeMemoryTidyFile(t *testing.T, body string) string {
t.Helper()
dir := filepath.Join(t.TempDir(), "config")
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatalf("mkdir: %v", err)
}
path := filepath.Join(dir, memoryTidyFileName)
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
return path
}
// TestLoadMemoryTidyTuning_ScheduleKeys 钉住 **整理频率**四项
// (intervalSec / quietIntervalSec / quietStartHour / quietEndHour)的读取口径。
//
// 为什么钟点必须能区分「显式 0」与「没写」:0 是**合法钟点**(0-7 的凌晨窗口、
// 23-0 到午夜的窗口),用 int 承载会让只写一端的文件静默改掉另一端。因此这里
// 断言的是「指针是否非 nil」,而不是「值是否非 0」。
//
// 值的**可用性**(非正数 ⇒ 回落)不在这里判:那是 memory 侧
// ApplyTidyScheduleTuning 的职责,本包只管文件形状与读取。
func TestLoadMemoryTidyTuning_ScheduleKeys(t *testing.T) {
t.Parallel()
// ① 文件不存在 ⇒ 四个键都是 nil(= 不覆盖,由 `.env` / 代码默认兜底)。
absent, err := LoadMemoryTidyTuning(filepath.Join(t.TempDir(), memoryTidyFileName))
if err != nil {
t.Fatalf("LoadMemoryTidyTuning(absent): %v", err)
}
if absent.IntervalSec != nil || absent.QuietIntervalSec != nil ||
absent.QuietStartHour != nil || absent.QuietEndHour != nil {
t.Fatalf("没配过时四个调度键都该是 nil,得到 %+v", absent)
}
// ② 四个键都写了 ⇒ 逐键读出来(与 env 无关,本层不比对)。
got, err := LoadMemoryTidyTuning(writeMemoryTidyFile(t,
`{"intervalSec": 3600, "quietIntervalSec": 10800, "quietStartHour": 1, "quietEndHour": 5}`))
if err != nil {
t.Fatalf("LoadMemoryTidyTuning(set): %v", err)
}
if got.IntervalSec == nil || *got.IntervalSec != 3600 {
t.Fatalf("intervalSec = %v, want 3600", got.IntervalSec)
}
if got.QuietIntervalSec == nil || *got.QuietIntervalSec != 10800 {
t.Fatalf("quietIntervalSec = %v, want 10800", got.QuietIntervalSec)
}
if got.QuietStartHour == nil || *got.QuietStartHour != 1 ||
got.QuietEndHour == nil || *got.QuietEndHour != 5 {
t.Fatalf("安静时段 = %v-%v, want 01:00-05:00", got.QuietStartHour, got.QuietEndHour)
}
// ③ 钟点显式写 0 ⇒ **是值**(不是「没配」):0-7 的凌晨窗口是合法配置。
zero, err := LoadMemoryTidyTuning(writeMemoryTidyFile(t, `{"quietStartHour": 0, "quietEndHour": 7}`))
if err != nil {
t.Fatalf("LoadMemoryTidyTuning(zero): %v", err)
}
if zero.QuietStartHour == nil || *zero.QuietStartHour != 0 {
t.Fatalf("显式写 0 的钟点必须原样保留,得到 %v", zero.QuietStartHour)
}
if zero.QuietEndHour == nil || *zero.QuietEndHour != 7 {
t.Fatalf("quietEndHour = %v, want 7", zero.QuietEndHour)
}
// ④ 秒数写 0 ⇒ 仍是「有值」(0 由 memory 侧判为坏值并回落 + WARN,本层不拦)。
explicitZero, err := LoadMemoryTidyTuning(writeMemoryTidyFile(t, `{"intervalSec": 0}`))
if err != nil {
t.Fatalf("LoadMemoryTidyTuning(explicit zero): %v", err)
}
if explicitZero.IntervalSec == nil || *explicitZero.IntervalSec != 0 {
t.Fatalf("显式写 0 的 intervalSec 应当被读出来(好坏由上层判),得到 %v", explicitZero.IntervalSec)
}
// ⑤ 类型写错(字符串)⇒ 整份文件解不开,分类错误 + 不启动失败。
// 这是**既有粒度**:一个键类型写错会让全部键一起回落 `.env`。
if _, err := LoadMemoryTidyTuning(writeMemoryTidyFile(t, `{"intervalSec": "3600"}`)); !errors.Is(err, ErrInvalidMemoryTidy) {
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)
}
}