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

344 lines
14 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 (
"bytes"
"encoding/json"
"errors"
"log/slog"
"os"
"path/filepath"
"reflect"
"sort"
"strings"
"testing"
)
// 本文件锁定**启动播种**(memorytidy_seed.go;主人 2026-10-07:「**`.env` 只做兜底
// 用** …… 后端**自动创建**独立的面板配置文件」)的四条纪律:
//
// ① 文件不存在 ⇒ 按调用方给的「当前生效值」播种(**十键齐全**,不是只播老六键);
// ② 文件已存在 ⇒ **一个字节都不动**(也不留 `.tmp`);
// ③ 文件损坏 ⇒ **不写盘**(WARN + 按 `.env` 跑),绝不「修好它」;
// ④ 幂等:连跑两次只在第一次写盘。
//
// 用例全部落在**临时目录**(`t.TempDir()`):生产那份 `data/config/memory-tidy.json`
// 是主人手工建的,播种逻辑只在文件缺失时触发,用例不许依赖它存在与否。
// completeSeedTuning 造一份「十一键齐全」的播种内容。值刻意都**不是**代码默认值
// (autoApply=false / 3600 / 10800 / tempTtlSec=259200),这样「播的是调用方给的
// 快照」与「播了一份硬编码的默认值」在断言里一眼可分。
//
// 它必须给出**全部** `memoryTidySeedKeys`(含 tempTtlSec):该键带 `omitempty`,
// nil 时会被整键省略 —— 那正是「没配 ⇒ 用代码默认」的正常形态,但本用例要验的是
// 「键齐全时逐一落盘」,因此这里显式给值(见 TestSeedMemoryTidy_SeedsMissingFile
// 的键集合断言)。
func completeSeedTuning() MemoryTidyTuning {
return MemoryTidyTuning{
AutoApply: boolSeedPtr(false),
AutoApplyPerRound: 7,
IngestGate: boolSeedPtr(true),
T1Judge: boolSeedPtr(false),
T1PerWindow: 240,
T1WindowSec: 1800,
IntervalSec: intSeedPtr(3600),
QuietIntervalSec: intSeedPtr(10800),
QuietStartHour: intSeedPtr(23),
QuietEndHour: intSeedPtr(7),
TempTtlSec: intSeedPtr(259200),
}
}
// boolSeedPtr / intSeedPtr 造「快照里显式写了这个键」的指针。
func boolSeedPtr(v bool) *bool { return &v }
func intSeedPtr(v int) *int { return &v }
// seedPath 返回一个**尚不存在**的落点(含尚不存在的 `config/` 目录)。
func seedPath(t *testing.T) string {
t.Helper()
return filepath.Join(t.TempDir(), "config", memoryTidyFileName)
}
// decodeSeed 把盘上的文件解成键集合,供「十键齐全」的断言(比结构体更贴近主人看到
// 的那份文件)。
func decodeSeed(t *testing.T, path string) map[string]json.RawMessage {
t.Helper()
var keys map[string]json.RawMessage
if err := json.Unmarshal([]byte(readRaw(t, path)), &keys); err != nil {
t.Fatalf("解 %s 的键集合: %v", path, err)
}
return keys
}
// assertNoTempLeft 断言播种没留下临时文件(原子写的中间态不该留在盘上)。
func assertNoTempLeft(t *testing.T, path string) {
t.Helper()
if _, err := os.Stat(path + ".tmp"); !os.IsNotExist(err) {
t.Fatalf("留下了临时文件 %s.tmp", path)
}
}
// TestSeedMemoryTidy_SeedsMissingFile 是本任务的主用例:文件不存在 ⇒ 播种,且**内容
// 就是调用方给的快照**(十键齐全、含四项频率键),目录也一并建出来。
func TestSeedMemoryTidy_SeedsMissingFile(t *testing.T) {
t.Parallel()
path := seedPath(t) // 连 config/ 目录都不存在
seed := completeSeedTuning()
res, err := SeedMemoryTidy(path, seed, discardLog())
if err != nil {
t.Fatalf("SeedMemoryTidy: %v", err)
}
if !res.Written || res.Existed || res.Warn != nil {
t.Fatalf("结果 = %+v, want Written 且非 Existed 且无 Warn", res)
}
assertNoTempLeft(t, path)
// 十键齐全(含新增的四项频率键)——「别只播老六键」的断言就在这里。
keys := decodeSeed(t, path)
for _, key := range memoryTidySeedKeys {
if _, ok := keys[key]; !ok {
t.Fatalf("播种文件缺键 %q:%s", key, readRaw(t, path))
}
}
if len(keys) != len(memoryTidySeedKeys) {
t.Fatalf("播种文件的键数 = %d, want %d:%s", len(keys), len(memoryTidySeedKeys), readRaw(t, path))
}
// 回读走的是**运行期同一条**读取路径(LoadMemoryTidyTuning),因此这里同时钉住
// 「写下去的东西读回来就是那份快照」。
got, err := LoadMemoryTidyTuning(path)
if err != nil {
t.Fatalf("LoadMemoryTidyTuning: %v", err)
}
if !reflect.DeepEqual(got, seed) {
t.Fatalf("回读 = %+v, want %+v(播种内容必须逐键等于调用方给的生效值)", got, seed)
}
}
// TestSeedMemoryTidy_NeverTouchesExistingFile 钉住第一条铁律:文件在 ⇒ 一个字节都不动。
// 覆盖四种盘上形态(面板值 / 空对象 / null / 手写带未知键),四种都必须原样保留 ——
// 尤其 `null`:它解得出零值(语义上等于没配),但它是「盘上有一个文件」这个事实本身。
func TestSeedMemoryTidy_NeverTouchesExistingFile(t *testing.T) {
t.Parallel()
bodies := []string{
`{"autoApply": false}`,
`{}`,
`null`,
"{\n \"intervalSec\": 42,\n \"unknownKey\": \"keep me\"\n}\n",
}
for _, body := range bodies {
path := writeMemoryTidyFile(t, body)
before := readRaw(t, path)
res, err := SeedMemoryTidy(path, completeSeedTuning(), discardLog())
if err != nil {
t.Fatalf("%q: SeedMemoryTidy: %v", body, err)
}
if res.Written || !res.Existed || res.Warn != nil {
t.Fatalf("%q: 结果 = %+v, want 跳过(Existed 且非 Written 且无 Warn)", body, res)
}
if after := readRaw(t, path); after != before {
t.Fatalf("%q: 已存在的文件被改了字节:\n before=%s\n after =%s", body, before, after)
}
assertNoTempLeft(t, path)
}
}
// TestSeedMemoryTidy_BrokenFileIsNotRepaired 钉住第二条纪律:损坏 ⇒ WARN + 按 `.env`
// 跑 + **一个字都不写盘**。损坏的文件可以人工救回,程序盖掉它就丢了那份人工意图。
func TestSeedMemoryTidy_BrokenFileIsNotRepaired(t *testing.T) {
t.Parallel()
bodies := []string{
`{"autoApply": "yes"}`,
`{"intervalSec": "3600"}`,
`not json at all`,
`[1,2,3]`,
}
for _, body := range bodies {
path := writeMemoryTidyFile(t, body)
before := readRaw(t, path)
res, err := SeedMemoryTidy(path, completeSeedTuning(), discardLog())
if err != nil {
// 坏文件不是「该写却没写成」:它压根不该被写,因此不是 error。
t.Fatalf("%q: 坏文件不该让播种返回错误(它只是不写盘):%v", body, err)
}
if res.Written || !res.Existed {
t.Fatalf("%q: 结果 = %+v, want 既不写也不当新文件", body, res)
}
if !errors.Is(res.Warn, ErrInvalidMemoryTidy) {
t.Fatalf("%q: Warn = %v, want ErrInvalidMemoryTidy(分类错误必须与读取侧同一个)", body, res.Warn)
}
if after := readRaw(t, path); after != before {
t.Fatalf("%q: 损坏的文件被覆盖/「修好」了:\n before=%s\n after =%s", body, before, after)
}
assertNoTempLeft(t, path)
}
}
// TestSeedMemoryTidy_IsIdempotent 钉住幂等:连跑两次,只有第一次写盘,第二次连字节
// 都不改(这是「连续启动多次只写一次」的可验证版本)。
func TestSeedMemoryTidy_IsIdempotent(t *testing.T) {
t.Parallel()
path := seedPath(t)
first, err := SeedMemoryTidy(path, completeSeedTuning(), discardLog())
if err != nil || !first.Written {
t.Fatalf("第一次: Written=%v err=%v, want 落盘", first.Written, err)
}
afterFirst := readRaw(t, path)
second, err := SeedMemoryTidy(path, completeSeedTuning(), discardLog())
if err != nil {
t.Fatalf("第二次: %v", err)
}
if second.Written || !second.Existed {
t.Fatalf("第二次 = %+v, want 跳过(文件已在)", second)
}
if afterSecond := readRaw(t, path); afterSecond != afterFirst {
t.Fatalf("第二次改动了文件:\n first=%s\n second=%s", afterFirst, afterSecond)
}
}
// TestSeedMemoryTidy_SeedsBlankFile 钉住「全空白」的处置:它与「缺失」同义是既有口径
// (LoadMemoryTidyTuning 把两者都读成「还没配过」),因此也播种 —— 那一刻的生效值
// 一个字都不变。
func TestSeedMemoryTidy_SeedsBlankFile(t *testing.T) {
t.Parallel()
path := writeMemoryTidyFile(t, " \n\t")
res, err := SeedMemoryTidy(path, completeSeedTuning(), discardLog())
if err != nil {
t.Fatalf("SeedMemoryTidy: %v", err)
}
if !res.Written || res.Existed {
t.Fatalf("结果 = %+v, want 按「还没配过」播种", res)
}
if len(decodeSeed(t, path)) != len(memoryTidySeedKeys) {
t.Fatalf("空白文件被播种后应当十键齐全:%s", readRaw(t, path))
}
}
// TestSeedMemoryTidy_RefusesIncompleteSeed 钉住种子的完整性契约:残缺的快照
// (缺指针 / 非正周期 / 钟点越界)宁可 WARN + 不落盘,也不写出一份自己读回都要
// WARN、或满是 null 的文件。
func TestSeedMemoryTidy_RefusesIncompleteSeed(t *testing.T) {
t.Parallel()
cases := []struct {
name string
seed MemoryTidyTuning
}{
{"缺 autoApply", func() MemoryTidyTuning { s := completeSeedTuning(); s.AutoApply = nil; return s }()},
{"缺 intervalSec", func() MemoryTidyTuning { s := completeSeedTuning(); s.IntervalSec = nil; return s }()},
{"缺 quietStartHour", func() MemoryTidyTuning { s := completeSeedTuning(); s.QuietStartHour = nil; return s }()},
{"intervalSec 为 0", func() MemoryTidyTuning { s := completeSeedTuning(); s.IntervalSec = intSeedPtr(0); return s }()},
{"quietIntervalSec 为负", func() MemoryTidyTuning { s := completeSeedTuning(); s.QuietIntervalSec = intSeedPtr(-1); return s }()},
{"autoApplyPerRound 为 0", func() MemoryTidyTuning { s := completeSeedTuning(); s.AutoApplyPerRound = 0; return s }()},
{"t1PerWindow 为 0", func() MemoryTidyTuning { s := completeSeedTuning(); s.T1PerWindow = 0; return s }()},
{"t1WindowSec 为负", func() MemoryTidyTuning { s := completeSeedTuning(); s.T1WindowSec = -60; return s }()},
{"quietStartHour 越界", func() MemoryTidyTuning { s := completeSeedTuning(); s.QuietStartHour = intSeedPtr(24); return s }()},
{"quietEndHour 越界", func() MemoryTidyTuning { s := completeSeedTuning(); s.QuietEndHour = intSeedPtr(-1); return s }()},
{"完全零值", MemoryTidyTuning{}},
}
for _, tc := range cases {
path := seedPath(t)
if _, err := SeedMemoryTidy(path, tc.seed, discardLog()); err == nil {
t.Fatalf("%s: err = nil, want 拒绝残缺种子", tc.name)
}
if _, statErr := os.Stat(path); !os.IsNotExist(statErr) {
t.Fatalf("%s: 残缺种子不得落盘:%s", tc.name, readRaw(t, path))
}
assertNoTempLeft(t, path)
}
}
// TestSeedMemoryTidy_LogsPathAndReasonWithoutValues 钉住「可观测」:写盘与跳过都有
// 日志(带路径与原因、带 source),且**不打任何配置值**(本仓配置日志的既有口径)。
func TestSeedMemoryTidy_LogsPathAndReasonWithoutValues(t *testing.T) {
t.Parallel()
// ① 播种:Info 日志带路径、原因、source 与键名,但没有任何值。
// 日志级别用默认的 Info(不是 Debug):主人要在**默认日志级别**下就能看到这件事
// 发生了 —— 可观测是这条口径的一部分,不是「把级别调高才看得见」。
var buf bytes.Buffer
log := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo}))
path := seedPath(t)
seed := completeSeedTuning()
seed.IntervalSec = intSeedPtr(4242) // 一眼可辨的值:它**不许**出现在日志里
if _, err := SeedMemoryTidy(path, seed, log); err != nil {
t.Fatalf("SeedMemoryTidy: %v", err)
}
out := buf.String()
if !strings.Contains(out, memoryTidyFileName) {
t.Fatalf("播种日志没带路径:%s", out)
}
if !strings.Contains(out, memoryTidySeedReasonMissing) {
t.Fatalf("播种日志没带原因:%s", out)
}
if !strings.Contains(out, "source=config/runtimeconfig") {
t.Fatalf("播种日志没标来源(AGENTS §10 禁止无来源裸日志):%s", out)
}
if !strings.Contains(out, "已按当时生效值播种落盘") {
t.Fatalf("播种日志没说明做了什么:%s", out)
}
for _, value := range []string{"4242", "10800", "1800"} {
if strings.Contains(out, value) {
t.Fatalf("日志里出现了配置值 %s(配置日志只许打键名):%s", value, out)
}
}
// ② 再次调用(文件已在):跳过也要有日志(**默认 Info 级别**可见,含路径与原因),
// 且同样不打值。
buf.Reset()
if _, err := SeedMemoryTidy(path, seed, log); err != nil {
t.Fatalf("第二次 SeedMemoryTidy: %v", err)
}
skipped := buf.String()
if !strings.Contains(skipped, "跳过播种") || !strings.Contains(skipped, memoryTidyFileName) {
t.Fatalf("跳过播种的日志缺路径或说明:%s", skipped)
}
if !strings.Contains(skipped, "绝不覆盖") {
t.Fatalf("跳过播种的日志没说明理由(绝不覆盖):%s", skipped)
}
if !strings.Contains(skipped, memoryTidySeedReasonExists) {
t.Fatalf("跳过播种的日志没带 reason 字段:%s", skipped)
}
for _, value := range []string{"4242", "10800", "1800"} {
if strings.Contains(skipped, value) {
t.Fatalf("跳过日志里出现了配置值 %s:%s", value, skipped)
}
}
// ③ 损坏:WARN 日志带路径与分类错误(主人据此知道「这份配置没生效、也没被改」)。
buf.Reset()
broken := writeMemoryTidyFile(t, `{"autoApply": "yes"}`)
if _, err := SeedMemoryTidy(broken, seed, log); err != nil {
t.Fatalf("坏文件的 SeedMemoryTidy: %v", err)
}
warned := buf.String()
if !strings.Contains(warned, "level=WARN") || !strings.Contains(warned, "无法解析") {
t.Fatalf("损坏文件必须留一条 WARN:%s", warned)
}
if !strings.Contains(warned, memoryTidyFileName) {
t.Fatalf("WARN 没带路径:%s", warned)
}
}
// TestMemoryTidySeedKeysMatchStruct 是**防漂移**断言:播种日志里列的键名与
// MemoryTidyTuning 的 json tag 必须一一对应。加了新键却忘了更新 memoryTidySeedKeys,
// 用例就会红 —— 否则「播了哪些键」的日志会安静地漏报。
func TestMemoryTidySeedKeysMatchStruct(t *testing.T) {
t.Parallel()
typ := reflect.TypeOf(MemoryTidyTuning{})
var tags []string
for i := 0; i < typ.NumField(); i++ {
tag, ok := typ.Field(i).Tag.Lookup("json")
if !ok || tag == "" {
t.Fatalf("字段 %s 没有 json tag", typ.Field(i).Name)
}
tags = append(tags, strings.Split(tag, ",")[0])
}
want := append([]string(nil), memoryTidySeedKeys...)
sort.Strings(tags)
sort.Strings(want)
if !reflect.DeepEqual(tags, want) {
t.Fatalf("结构体的键 %v 与 memoryTidySeedKeys %v 不一致(加了新键就要同步那张表)", tags, want)
}
}