主人原话:「收窄提取器,确实要给他一个主动式的记忆留存规则,不能想到什么都 往记忆里写」「.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)。
232 lines
12 KiB
Go
232 lines
12 KiB
Go
// memorytidy_seed.go 落地主人 2026-10-07 那句「**`.env` 只做兜底用** —— 后端**自动
|
||
// 创建**独立的面板配置文件**要做**」里的「自动创建」:进程启动时若
|
||
// `$DATA_DIR/config/memory-tidy.json` **不存在**,就按**当时生效的值**播种一份,
|
||
// 使这个家族从此有一份自足的一等来源,而不是每次都靠 `.env` 兜。
|
||
//
|
||
// 与 memorytidy.go(读取)的边界同源:本文件只管**文件形状与落盘**,不认识
|
||
// TidyConfig / ReviewConfig,也不知道任何一个默认值是多少 —— 那属于 memory 侧
|
||
// normalize 的职责。播种内容由调用方给出(`memory.TidyTuningSnapshot` 拍出的
|
||
// 「当前生效值」快照),与 materialize.go 拿到的是 `.env` 合成组同一手法。
|
||
//
|
||
// 四条纪律,前三条逐字对齐 materialize.go(那是本仓「`.env` → 面板文件」播种的既有
|
||
// 施工口径,本文件是同一套搬到 memory-tidy 家族):
|
||
//
|
||
// 1. **绝不覆盖已存在的文件**:文件在(无论内容是什么、无论解不解得开)⇒ 一个字节
|
||
// 都不动。这条是铁律 —— 主人手改过的文件被程序盖回去,就是「面板改一次、重启
|
||
// 一次被打回」那个失败形态。
|
||
// 2. **损坏不是要「修好」的状态**:解不开时只记 WARN、按 `.env` 跑、**不写盘**。
|
||
// 人工写坏的配置可以人工救回,程序盖掉它就丢了那份人工意图。
|
||
// 3. **幂等**:只有「文件不存在(或全空白)」这一次会落盘;连续启动多次,第二次起
|
||
// 是空操作(文件已存在 ⇒ 走第 1 条)。
|
||
// 4. **不阻断启动**:返回的 error 只在「该写却没写成」时非 nil(写盘失败 / 种子
|
||
// 残缺);调用方记一条 WARN 即可 —— 播种失败时运行期仍按 `.env` + 代码默认跑,
|
||
// 行为与播种前逐字相同。
|
||
//
|
||
// 日志**只打路径、原因与键名,不打值**:这十键今天都不是凭据,但「配置日志不打值」
|
||
// 是本仓既有口径(materialize.go 的「绝不打 apiKey」),这里从一开始就照做,免得
|
||
// 日后加一个敏感键时要回头改日志。
|
||
package runtimeconfig
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"log/slog"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||
)
|
||
|
||
const (
|
||
// memoryTidySeedReasonMissing / ...Blank / ...Exists 是播种日志的 reason 字段值
|
||
// (与 materialize.go 的 reason 口径一致:让人一眼看出是「没有」「空」还是
|
||
// 「已在、没动它」)。
|
||
memoryTidySeedReasonMissing = "面板配置不存在"
|
||
memoryTidySeedReasonBlank = "面板配置为空文件"
|
||
memoryTidySeedReasonExists = "面板配置已存在,未改动"
|
||
// memoryTidySeedFileMode 是播种文件的权限。0600 与 SaveModels / SavePlugins
|
||
// 同口径:配置目录可能放凭据,一律不给组/其他读权限。
|
||
memoryTidySeedFileMode = 0o600
|
||
// memoryTidySeedDirMode 是新建 `$DATA_DIR/config/` 时的目录权限,与 SaveModels
|
||
// 的 mkdir 口径一致。
|
||
memoryTidySeedDirMode = 0o755
|
||
)
|
||
|
||
// memoryTidySeedKeys 是播种文件应有的键名全集(顺序即 JSON 顺序)。它用于播种日志
|
||
// 的「播了哪些键」,并与结构体的 json tag 相互钉住(见
|
||
// TestMemoryTidySeedKeysMatchStruct):加了新键却忘了更新它,用例会红。
|
||
var memoryTidySeedKeys = []string{
|
||
"autoApply", "autoApplyPerRound", "ingestGate", "t1Judge",
|
||
"t1PerWindow", "t1WindowSec", "intervalSec", "quietIntervalSec",
|
||
"quietStartHour", "quietEndHour", "tempTtlSec",
|
||
}
|
||
|
||
// MemoryTidySeed 是一次播种的可诊断产物(口径对齐 materialize.go 的 Result:
|
||
// Written / Warn 的含义逐字相同,Existed 用来区分「跳过」的起因)。
|
||
type MemoryTidySeed struct {
|
||
// Written 为 true 表示本次真的落盘(只在文件缺失/全空白时可能为 true)。
|
||
Written bool
|
||
// Existed 为 true 表示文件已存在(含解不开的),本次一个字节都没动。
|
||
// 它与 Written 互斥。
|
||
Existed bool
|
||
// Warn 是非致命异常:文件存在但解不开(按 `.env` 跑,且不写盘)。
|
||
Warn error
|
||
}
|
||
|
||
// SeedMemoryTidy 在 path 处播种一份面板配置 —— **仅当它缺失或全空白**。
|
||
//
|
||
// 「全空白」与「缺失」同义是既有口径(LoadMemoryTidyTuning 把两者都读成「还没配过」
|
||
// ⇒ 零值 ⇒ 走 `.env` 兜底),因此对空白文件播种,那一刻的生效值一个字都不变 ——
|
||
// 与 materialize.go 对空文件的处置一致。
|
||
//
|
||
// `null` **不算空白**:它解得出零值,但它是「盘上有一个文件」这个事实本身,按铁律
|
||
// 不动它(主人的口径是「不存在才自动创建」,不是「内容看着空就重建」)。
|
||
//
|
||
// log 由调用方给出(组合根一律传带 source 的日志器):本函数会直接往它上面写日志,
|
||
// 因此**不要传 nil**。
|
||
func SeedMemoryTidy(path string, seed MemoryTidyTuning, log *slog.Logger) (MemoryTidySeed, error) {
|
||
log = logger.WithSource(log, "config/runtimeconfig")
|
||
raw, readErr := os.ReadFile(path)
|
||
if readErr == nil {
|
||
return seedMemoryTidyExisting(log, path, raw, seed)
|
||
}
|
||
if !os.IsNotExist(readErr) {
|
||
log.Warn("memory-tidy.json 不可读,本次跳过播种(按 .env 兜底运行)", "path", path, "error", readErr)
|
||
return MemoryTidySeed{}, fmt.Errorf("runtimeconfig: 读取 %s: %w", path, readErr)
|
||
}
|
||
return writeMemoryTidySeed(log, path, seed, memoryTidySeedReasonMissing)
|
||
}
|
||
|
||
// seedMemoryTidyExisting 处理「文件已存在」的三种形态,三种处置都**不覆盖**:
|
||
//
|
||
// - 解不开 ⇒ WARN + 按 `.env` 跑(绝不当场「修好」它,见文件头第 2 条);
|
||
// - 全空白 ⇒ 按「还没配过」播种(唯一会在文件已存在时写盘的形态,理由见
|
||
// SeedMemoryTidy 的说明);
|
||
// - 其余(含 `null`)⇒ 跳过。
|
||
func seedMemoryTidyExisting(log *slog.Logger, path string, raw []byte, seed MemoryTidyTuning) (MemoryTidySeed, error) {
|
||
if strings.TrimSpace(string(raw)) == "" {
|
||
log.Info("memory-tidy.json 是空文件(按「还没配过」处理),准备播种", "path", path)
|
||
return writeMemoryTidySeed(log, path, seed, memoryTidySeedReasonBlank)
|
||
}
|
||
if _, err := decodeMemoryTidy(raw, path); err != nil {
|
||
// 解不开 ≠ 可以盖掉:只留 WARN,一个字都不写盘。这条 WARN 是主人可查的
|
||
// 唯一线索 —— 「这份配置没生效、也没被程序改过」。
|
||
log.Warn("memory-tidy.json 无法解析,本次不播种也不覆盖原文件(按 .env 兜底运行)",
|
||
"path", path, "error", err)
|
||
return MemoryTidySeed{Existed: true, Warn: err}, nil
|
||
}
|
||
// 跳过也留痕(Info 而不是 Debug:这是**每次启动一行**的交接回执 —— 主人能从日志
|
||
// 确认「后端认得我手写的那份文件、没有动它」,而不是只有提升日志级别才看得见)。
|
||
log.Info("memory-tidy.json 已存在,跳过播种(面板配置优先,绝不覆盖原文件)",
|
||
"path", path, "reason", memoryTidySeedReasonExists)
|
||
return MemoryTidySeed{Existed: true}, nil
|
||
}
|
||
|
||
// writeMemoryTidySeed 真正落盘:先把 `$DATA_DIR/config/` 建出来(不存在时),再按
|
||
// 「临时文件 + rename」原子写(同 SaveModels / SaveWebSearch:并发读者绝不会看到
|
||
// 半个 JSON),权限 0600。
|
||
//
|
||
// 落盘前先验种子**完整**(见 memoryTidySeedComplete):一份缺键的快照写下去会变成
|
||
// `"intervalSec": null` —— 读回来语义上等于「没配」,但主人打开文件会看到一堆 null,
|
||
// 且「播种 = 当时的生效值」这句话就不再成立。残缺的种子是调用方的编程错误,宁可
|
||
// WARN + 不落盘,也不留一份自相矛盾的文件。
|
||
func writeMemoryTidySeed(log *slog.Logger, path string, seed MemoryTidyTuning, reason string) (MemoryTidySeed, error) {
|
||
if err := memoryTidySeedComplete(seed); err != nil {
|
||
log.Warn("memory-tidy.json 播种内容不完整,本次不落盘(.env 继续兜底)", "path", path, "error", err)
|
||
return MemoryTidySeed{}, err
|
||
}
|
||
raw, err := json.MarshalIndent(seed, "", " ")
|
||
if err != nil {
|
||
return MemoryTidySeed{}, fmt.Errorf("runtimeconfig: marshal memory tidy seed: %w", err)
|
||
}
|
||
if dir := filepath.Dir(path); dir != "" && dir != "." {
|
||
if err := os.MkdirAll(dir, memoryTidySeedDirMode); err != nil {
|
||
return MemoryTidySeed{}, fmt.Errorf("runtimeconfig: mkdir %s: %w", dir, err)
|
||
}
|
||
}
|
||
tmp := path + ".tmp"
|
||
if err := os.WriteFile(tmp, raw, memoryTidySeedFileMode); err != nil {
|
||
return MemoryTidySeed{}, fmt.Errorf("runtimeconfig: write %s: %w", tmp, err)
|
||
}
|
||
if err := os.Rename(tmp, path); err != nil {
|
||
return MemoryTidySeed{}, fmt.Errorf("runtimeconfig: rename %s: %w", path, err)
|
||
}
|
||
log.Info("memory-tidy.json 已按当时生效值播种落盘(此后以面板配置为准,.env 只兜底)",
|
||
"path", path, "reason", reason, "keys", memoryTidySeedKeys)
|
||
return MemoryTidySeed{Written: true}, nil
|
||
}
|
||
|
||
// memoryTidySeedComplete 报告种子是否「十键齐全且值可用」。
|
||
//
|
||
// 为什么要「齐全」而不是「非 nil 就放行」:MemoryTidyTuning 的指针语义是「nil = 没配」,
|
||
// 一份缺键的种子写下去虽然与「不写」在读取端等价,但它会让文件里出现 null —— 这份文件
|
||
// 的主人(主人 / 面板)看到的东西必须与「生效值」逐字一致。
|
||
//
|
||
// 数值键要求为**正**:`intervalSec: 0` 在 memory 侧是**坏值**(非正周期让心跳空转,
|
||
// 见 ApplyTidyScheduleTuning),播种出一份自己读回都要 WARN 的配置是自相矛盾的;
|
||
// 钟点则要求落在 0..23(越界会让 time.Date 静默滚动到别的一天)。
|
||
func memoryTidySeedComplete(seed MemoryTidyTuning) error {
|
||
missing := make([]string, 0, len(memoryTidySeedKeys))
|
||
if seed.AutoApply == nil {
|
||
missing = append(missing, "autoApply")
|
||
}
|
||
if seed.IngestGate == nil {
|
||
missing = append(missing, "ingestGate")
|
||
}
|
||
if seed.T1Judge == nil {
|
||
missing = append(missing, "t1Judge")
|
||
}
|
||
if seed.IntervalSec == nil {
|
||
missing = append(missing, "intervalSec")
|
||
}
|
||
if seed.QuietIntervalSec == nil {
|
||
missing = append(missing, "quietIntervalSec")
|
||
}
|
||
if seed.QuietStartHour == nil {
|
||
missing = append(missing, "quietStartHour")
|
||
}
|
||
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, ", "))
|
||
}
|
||
if seed.AutoApplyPerRound <= 0 {
|
||
return fmt.Errorf("runtimeconfig: memory tidy seed autoApplyPerRound = %d (want > 0)", seed.AutoApplyPerRound)
|
||
}
|
||
if seed.T1PerWindow <= 0 {
|
||
return fmt.Errorf("runtimeconfig: memory tidy seed t1PerWindow = %d (want > 0)", seed.T1PerWindow)
|
||
}
|
||
if seed.T1WindowSec <= 0 {
|
||
return fmt.Errorf("runtimeconfig: memory tidy seed t1WindowSec = %d (want > 0)", seed.T1WindowSec)
|
||
}
|
||
if *seed.IntervalSec <= 0 {
|
||
return fmt.Errorf("runtimeconfig: memory tidy seed intervalSec = %d (want > 0)", *seed.IntervalSec)
|
||
}
|
||
if *seed.QuietIntervalSec <= 0 {
|
||
return fmt.Errorf("runtimeconfig: memory tidy seed quietIntervalSec = %d (want > 0)", *seed.QuietIntervalSec)
|
||
}
|
||
if *seed.QuietStartHour < 0 || *seed.QuietStartHour > 23 {
|
||
return fmt.Errorf("runtimeconfig: memory tidy seed quietStartHour = %d (want 0..23)", *seed.QuietStartHour)
|
||
}
|
||
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
|
||
}
|