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