review P1:applyTidyConfig 在运行期**整体覆写** s.cfg,而我只把调度读收进了持锁 访问器 —— 同一字段还有几处无锁读,其中两处是**活端点**: - TidyService.DryRun 的开关判定(在取 runMu **之前**)⇒ POST …/tidy/dry-run 与 ?async=1 那条会起后台 goroutine 的版本(真机 90s 级); - TidyService.ManualRun 的开关判定(同样在 runMu 之前)⇒ POST …/tidy/run; - (原本也漏的)Enabled() 与 Start 的启动日志块。 按**字段**重扫了一遍(不按文件):现在生产代码里对 s.cfg 的读**只剩**三个持锁访问器 内部(enabled / cfgSnapshot / scheduleSnapshot),写点只有 applyTidyConfig 一处; s.tidier.cfg 的读点全在持 runMu 的轮次 / 试算路径上(含异步版经 DryRun)。 新增第三条并发回归哨兵:真并发跑「试算 + 手动真跑」两个活端点 × 面板保存。 修前红/修后绿:撤回 DryRun 的同步 ⇒ 报「写 applyTidyConfig × 读 DryRun」;撤回 ManualRun 的同步 ⇒ 报「写 applyTidyConfig × 读 ManualRun」;两次变异都逐字还原 (diff 0 差异)。 顺手两条(塔指定): - 那条并发哨兵的 doc 与实现相反(还写着「正确顺序是 Stop → 写 → Start」),已改成 唤醒机制下的说法 —— 与「注释与实现不符」同类,不留旧说法。 - 非法值的回显文案原按**字节**截断(s[:40]),中文会被切成半截(非法 UTF-8,经 JSON 编码变成 U+FFFD 替换符)。改成按 **rune** 截断,直接复用同包既有的 clipRunes;用例取值刻意让截断点落在多字节字符中间(JSON 引号 + 一个 ASCII 前缀 错开对齐),并做过变异复验:改回按字节 ⇒ 用例红(切出半个汉字)。
279 lines
12 KiB
Go
279 lines
12 KiB
Go
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:<key>` ⇒ 回落面板文件里另一个键;
|
||
// - `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
|
||
}
|