主人原话:「收窄提取器,确实要给他一个主动式的记忆留存规则,不能想到什么都 往记忆里写」「.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)。
334 lines
13 KiB
Go
334 lines
13 KiB
Go
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)
|
||
}
|
||
}
|