package gateway import ( "encoding/json" "errors" "fmt" "sort" "strings" "git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig" ) // 本文件是记忆整理「面板调优」的**键规格与三态校验**层(文件读写与原子落盘在 // `internal/config/runtimeconfig` 家族里,见 memorytidy_save.go —— 落点是那一族的 // 职责,网关只负责 API 契约这一侧的取值规则)。 // // 它管的是 `$DATA_DIR/config/memory-tidy.json` 里主人能调的 7 个旋钮(design/13 §2.1: // 调优/门槛/上限/开关类参数走 `$DATA_DIR/config/**` + 代码默认值,不进 `.env`)。 // // 三态与清除哨兵(与契约里 `plugin.saveProvider` 同族语义): // - 键**没出现** ⇒ 不动它(`changed` 里也不出现); // - 值为 `null` ⇒ 从面板文件里**删掉**这个键(回到下一档兜底:`.env` → 代码默认); // - 值合法 ⇒ 采用; // - 值非法(类型不对 / 超界 / 不是 JSON 对象 / 有不认识的键)⇒ 一律**在解析阶段** // 被拒(调用方回 400 且盘上一个字节都不动),**绝不静默忽略** —— 静默忽略会让 // 主人以为改了而实际没改(把 `quietEndHour` 拼成 `quietEnd` 就正是这种形态)。 const ( // tuningKindInt / tuningKindBool 是本切片管得到的两种键型。 tuningKindInt = "int" tuningKindBool = "bool" // tuningUnitSec / tuningUnitHour 是 int 键的单位(面板据此写「秒 / 点」)。 tuningUnitSec = "sec" tuningUnitHour = "hour" // tuningSecMin 是三个秒键的下界:**必须为正**(≤0 的周期不是「没配」,而是一个 // 会让整理退化成空转的值;运行期口径同样把非正数当坏值回落,见 // memory.ApplyTidyScheduleTuning 与 runtimeconfig.MemoryTidyTuning 的注释)。 tuningSecMin = 1 // tuningHourMin / tuningHourMax 是钟点键的取值域(本地钟点 0..23)。 tuningHourMin = 0 tuningHourMax = 23 // tuningCodeDefault 是「缺位时落到代码默认」这一类兜底来源的标识(面板文案用)。 // 它**只说来源、不带数值**:数值的唯一真源在 memory 侧(normalizeTidyConfig / // normalizeReviewConfig),网关不另抄一份默认值 —— 抄一份就是第二个真源。 tuningCodeDefault = "code-default" ) // memoryTidyTuningSpec 是一个可写键的**取值规则**(唯一真源)。 // // 它既驱动写侧校验,也随 GET 下发给面板(`specs`):面板据此渲染表单与就地报错, // 因此不会出现「面板允许、后端 400」的两套规则(与 /admin/llm 下发 // reasoningKinds / personaProviderOptions 同一手法)。 type memoryTidyTuningSpec struct { Key string `json:"key"` // Kind ∈ int | bool;面板据此选输入控件类型。 Kind string `json:"kind"` // Unit ∈ sec | hour(仅 int 键):面板据此写单位,不自己猜。 Unit string `json:"unit,omitempty"` // Min / Max 是闭区间上下界(缺省 = 该端无约束)。 Min *int `json:"min,omitempty"` Max *int `json:"max,omitempty"` // Fallback 报告这个键**缺位(或面板 `null` 清除)时**生效值从哪来: // - `env:MEMORY_TIDY_*` ⇒ 回落 `.env` 的同名键(进程生效值见 // GET /admin/config-facts 的同名事实行); // - `panel:` ⇒ 回落面板文件里另一个键; // - `code-default` ⇒ 回落代码默认。 // 它只给**来源**,不给数值:数值的真源在各自那一档(见 tuningCodeDefault)。 Fallback string `json:"fallback"` } // memoryTidyTuningSpecs 是面板可写 7 键的**唯一清单**(顺序 = 面板显示顺序)。 // // 为什么是这 7 个:主人要能调**整理频率**(白天短、静默期长)与**入库审查的两个 // 开关/时长**,而 `.env` 已冻结(design/13 §2.1)⇒ 这些旋钮只能落面板配置文件, // 面板必须能**写**它。 // // 刻意**不在**这里的两类键: // - `t1Judge` / `t1PerWindow` / `t1WindowSec` / `autoApplyPerRound`:同文件的其它 // 旋钮,本切片的面板卡片不呈现,写端点也不接受它们(未知键一律 400,免得主人 // 以为改了、其实没动)。它们仍**原样保留**在文件里(补丁语义)。 func memoryTidyTuningSpecs() []memoryTidyTuningSpec { secMin := tuningSecMin hourMin := tuningHourMin hourMax := tuningHourMax return []memoryTidyTuningSpec{ { Key: "intervalSec", Kind: tuningKindInt, Unit: tuningUnitSec, Min: &secMin, Fallback: "env:MEMORY_TIDY_INTERVAL_SEC", }, { Key: "quietIntervalSec", Kind: tuningKindInt, Unit: tuningUnitSec, Min: &secMin, Fallback: "panel:intervalSec", }, { Key: "quietStartHour", Kind: tuningKindInt, Unit: tuningUnitHour, Min: &hourMin, Max: &hourMax, Fallback: "env:MEMORY_TIDY_QUIET_START_HOUR", }, { Key: "quietEndHour", Kind: tuningKindInt, Unit: tuningUnitHour, Min: &hourMin, Max: &hourMax, Fallback: "env:MEMORY_TIDY_QUIET_END_HOUR", }, {Key: "ingestGate", Kind: tuningKindBool, Fallback: tuningCodeDefault}, {Key: "autoApply", Kind: tuningKindBool, Fallback: tuningCodeDefault}, {Key: "tempTtlSec", Kind: tuningKindInt, Unit: tuningUnitSec, Min: &secMin, Fallback: tuningCodeDefault}, } } // memoryTidyTuningManagedKeys 返回可写键的键名(specs 顺序),供错误文案与审计用。 func memoryTidyTuningManagedKeys() []string { specs := memoryTidyTuningSpecs() out := make([]string, 0, len(specs)) for _, s := range specs { out = append(out, s.Key) } return out } // memoryTidyTuningPatch 是一次写请求**已校验**的补丁。 // // set / clear 是交给 runtimeconfig.PatchMemoryTidyTuning 的动作(clear = 删掉该键 // ⇒ 回到下一档兜底);changed 是本次真正出现的键(specs 顺序,含被清除的),空表示 // 一个键都没提交(此时**不写盘**,文件字节保持不变)。 type memoryTidyTuningPatch struct { set map[string]json.RawMessage clear []string changed []string } // empty 报告这次请求没有提交任何受管的键。 func (p memoryTidyTuningPatch) empty() bool { return len(p.changed) == 0 } // parseMemoryTidyTuningPatch 把请求体解析成一次已校验的补丁(三态的唯一实现点)。 // // 未知键也归入「非法」:本端点**不静默忽略**任何提交上来的键 —— 静默忽略会让主人 // 以为改了而实际没改。 func parseMemoryTidyTuningPatch(body []byte) (memoryTidyTuningPatch, error) { var raw map[string]json.RawMessage if err := json.Unmarshal(body, &raw); err != nil { return memoryTidyTuningPatch{}, fmt.Errorf("请求体必须是一个 JSON 对象:%w", err) } if raw == nil { // JSON 的 `null` 解到 map 上是 nil 且**不报错**:它不是一个「提交了 0 个键」 // 的请求,而是一个形状不对的请求,如实拒掉(空补丁请用 `{}`)。 return memoryTidyTuningPatch{}, errors.New("请求体必须是一个 JSON 对象(收到 null)") } specs := memoryTidyTuningSpecs() known := make(map[string]memoryTidyTuningSpec, len(specs)) for _, s := range specs { known[s.Key] = s } if err := rejectUnknownTuningKeys(raw, known); err != nil { return memoryTidyTuningPatch{}, err } patch := memoryTidyTuningPatch{set: map[string]json.RawMessage{}} for _, s := range specs { value, ok := raw[s.Key] if !ok { continue } patch.changed = append(patch.changed, s.Key) if isJSONNull(value) { patch.clear = append(patch.clear, s.Key) continue } if err := validateTuningValue(s, value); err != nil { return memoryTidyTuningPatch{}, err } patch.set[s.Key] = value } return patch, nil } // rejectUnknownTuningKeys 拒绝请求体里任何不受管的键(含拼错的键)。 func rejectUnknownTuningKeys(raw map[string]json.RawMessage, known map[string]memoryTidyTuningSpec) error { unknown := make([]string, 0) for k := range raw { if _, ok := known[k]; !ok { unknown = append(unknown, k) } } if len(unknown) == 0 { return nil } sort.Strings(unknown) return fmt.Errorf("不认识的键:%s(本端点只写 %s;同文件其它旋钮请人工编辑)", strings.Join(unknown, "、"), strings.Join(memoryTidyTuningManagedKeys(), "、")) } // validateTuningValue 校验一个键的值(类型 + 取值域)。错误文案直接回给面板显示, // 因此带上**收到的原始值**,让主人一眼看出该改什么。 func validateTuningValue(spec memoryTidyTuningSpec, raw json.RawMessage) error { switch spec.Kind { case tuningKindBool: var b bool if err := json.Unmarshal(raw, &b); err != nil { return fmt.Errorf("%s 必须是 true 或 false(收到 %s)", spec.Key, clipJSON(raw)) } return nil case tuningKindInt: var n int if err := json.Unmarshal(raw, &n); err != nil { return fmt.Errorf("%s 必须是整数(收到 %s)", spec.Key, clipJSON(raw)) } if spec.Min != nil && n < *spec.Min { return fmt.Errorf("%s 必须 ≥ %d(%s),收到 %d", spec.Key, *spec.Min, tuningUnitLabel(spec), n) } if spec.Max != nil && n > *spec.Max { return fmt.Errorf("%s 必须 ≤ %d(%s),收到 %d", spec.Key, *spec.Max, tuningUnitLabel(spec), n) } return nil default: // 规格表自己写错了型别(编程错误,不是客户端错):如实报出来,不猜一种校验。 return fmt.Errorf("%s 的键规格未定义(kind=%q)", spec.Key, spec.Kind) } } // tuningUnitLabel 返回单位的中文说明(错误文案用)。 func tuningUnitLabel(spec memoryTidyTuningSpec) string { switch spec.Unit { case tuningUnitSec: return "秒" case tuningUnitHour: return "本地钟点 0-23" default: return "整数" } } // tuningValueClipRunes 是错误文案里回显「收到的值」的 rune 上限。 const tuningValueClipRunes = 40 // clipJSON 把收到的原始 JSON 片段截断成可安全回显的短串(错值可能很长)。 // // 🔴 按 **rune** 截断,**不按字节**:值里完全可能是中文(主人就爱填中文),按字节切 // 会把一个多字节字符切成两半 —— 回显里因此出现半截 UTF-8(JSON 编码后变成 U+FFFD // 替换符),而这条文案的用途正是让人**看清自己填错了什么**。手法与同包的 // `clipRunes`(notifications.go)同源,直接复用而不是再写一份。 func clipJSON(raw json.RawMessage) string { return clipRunes(strings.TrimSpace(string(raw)), tuningValueClipRunes) } // isJSONNull 报告这段原始值是不是 JSON 的 `null`(清除哨兵)。 func isJSONNull(raw json.RawMessage) bool { return strings.TrimSpace(string(raw)) == "null" } // memoryTidyTuningValues 把盘上的调优结构投影成面板要的 7 键值表:**键缺位 ⇒ 整个 // 键不出现**(面板据此渲染「未设置、跟随兜底」),而不是回一个编出来的 0 —— // 「0 秒」与「没配」在面板上必须看得出区别。 // // 它刻意**不**回显同文件的其它键(t1Judge 等):那些不在本卡片的编辑范围,混进来 // 只会让人以为这张卡能改它们。 func memoryTidyTuningValues(t runtimeconfig.MemoryTidyTuning) map[string]any { out := map[string]any{} if t.IntervalSec != nil { out["intervalSec"] = *t.IntervalSec } if t.QuietIntervalSec != nil { out["quietIntervalSec"] = *t.QuietIntervalSec } if t.QuietStartHour != nil { out["quietStartHour"] = *t.QuietStartHour } if t.QuietEndHour != nil { out["quietEndHour"] = *t.QuietEndHour } if t.IngestGate != nil { out["ingestGate"] = *t.IngestGate } if t.AutoApply != nil { out["autoApply"] = *t.AutoApply } if t.TempTtlSec != nil { out["tempTtlSec"] = *t.TempTtlSec } return out }