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

232 lines
12 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.
// 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
}