package config import ( "fmt" "reflect" ) // MemoryReviewConfig 是 D3「入库前审查」(design/23-记忆整理与去重 §15.1 D3) // 在配置层的旋钮:新记忆落库后,由独立的记忆管理会话异步判定它是 // merge / modify / create / skip。 // // 为什么用独立加载器而不是挂进 Config 根结构:Config 由 Load 统一解码,而 // 本切片刻意**不碰** Config / 记忆整理那组键(并行切片正在改它们)。这里复用 // 与 LoadLLMDefaults 同一手法(parseDotEnv + 同一份 `env:"…"` 解码器), // 因此优先级与 Load 逐字一致:进程环境变量 > `.env` > 标签默认值(TimeoutSec // 没有标签默认值,见该字段的注释 —— 未配置时它读出 0,由 memory 包定其生效值)。 // envPath 为空表示不看 `.env`(与 Load 的约定一致)。 type MemoryReviewConfig struct { // Enabled 是入库前审查总开关,默认 true。依据:主人 2026-10-05 的裁定 // 就是「入库之前先走记忆管理会话审查,而不是反复新建」,而 T0 判定是 // **纯规则、零模型调用**,默认开启是安全的增量。置 false 时组合根不建、 // 不接、不跑 —— 写入路径行为逐字回到本次改动之前。 Enabled bool `env:"MEMORY_REVIEW_ENABLED" default:"true"` // QueueSize 是有界审查队列的长度。队列满时该条**不做审查**:记忆本身 // 早已落库(绝不因队列满而丢记忆),只记一条带来源的 WARN。 QueueSize int `env:"MEMORY_REVIEW_QUEUE_SIZE" default:"256"` // ScanLimit 是每次审查在同一分区内最多看多少条既有记忆(top-K 上限, // design/23 §15 D3-4「每条待写记忆的审查有界」)。 ScanLimit int `env:"MEMORY_REVIEW_SCAN_LIMIT" default:"200"` // TimeoutSec 是单条审查的墙钟上限(秒)。超时 ⇒ **不做任何改写**, // 记忆照旧保留 + WARN(design/23 §15 D3-5 的 fail-safe)。 // // 🔴 **本字段刻意没有 `default:` 标签**(2026-10-13,切片 M16)。单条审查预算的 // **唯一真源**是 memory 包的 `defaultReviewTimeout`(90s,推导见 // internal/runtime/memory/preinsert_config.go 的文件头注释)。这里原写 // `default:"20"`,后果不是「默认值是 20」而是**默认值根本不由 memory 侧决定**: // env 层永远产出一个非零值 ⇒ memory 侧那道「≤0 才回落默认」的归一化成了**死代码** // (90s 从未生效,生产启动行 `memory ingest review started … timeout=20s` 即铁证, // 而实测召回链就要 21~23s ⇒ 每条候选满额的审查都超时、连留痕都不写)。 // // 去掉字面默认后的**生效值**(路径:本字段 → cmd/cyrene/wire_memory_review.go 的 // `ReviewConfig.Timeout` → `normalizeReviewConfig`): // // `.env` / 进程环境没写这个键 ⇒ 这里 0 ⇒ memory 侧回落 90s // 写了正数 ⇒ 原样生效(归一化只动 ≤0) // 写了 0 / 负数 ⇒ 回落 90s(与项目其它旋钮同一口径:≤0 = 用默认) // // ⚠️ 别在这里重新加回字面默认值 —— 那会让 memory 侧的 90s 再次变成死代码, // 而 memory 包的 TestReviewTimeoutEffectiveValueIsTheOnlyDefault 会立刻变红 // (它走完整组装路径取生效值,不比对常量)。 TimeoutSec int `env:"MEMORY_REVIEW_TIMEOUT_SEC"` } // LoadMemoryReviewConfig 读取入库前审查的旋钮。非法(负数)值不在这里报错, // 而是与项目其它旋钮同一口径 —— **≤0 视为「用默认」**,由 memory 包 normalize // 收敛,避免一个笔误让进程起不来。 // // 🔴 因此本函数**对 TimeoutSec 不做任何回落**(切片 M16):返回 0 就是「没配」, // 调用方必须原样交给 memory 包的 ReviewConfig(组合根今天就是这么做的), // 「0 ⇒ 多少秒」由 `normalizeReviewConfig` 在那**唯一一处**决定。调用方若在这里 // 又补一个自己的默认值,全链就会重新长出第二个真源 —— 那正是本切片修掉的形态。 func LoadMemoryReviewConfig(envPath string) (MemoryReviewConfig, error) { fromEnv := map[string]string{} if envPath != "" { m, err := parseDotEnv(envPath) if err != nil { return MemoryReviewConfig{}, fmt.Errorf("config: read %s: %w", envPath, err) } fromEnv = m } var cfg MemoryReviewConfig if err := decodeValue(reflect.ValueOf(&cfg), fromEnv); err != nil { return MemoryReviewConfig{}, err } return cfg, nil }