主人 2026-10-07 两条口径:「.env 只做兜底用,后端自动创建独立的面板配置文件
之后就走面板配置文件,这些键优先从面板配置上取」+「频率必须调,可以调到 1 小时
或者三小时,或者动态分配(白天短、静默期长)」。
此前 $DATA_DIR/config/memory-tidy.json 只有读路径(装配期读一次 + 启动播种):
主人要改频率只能改文件 + 重启后端。本切片补上「读回来 → 改 → 落盘 → 当场生效」。
后端(读写端点,契约待塔登记 v0.92):
- GET /api/v1/admin/memories/tidy/tuning 读盘上 7 键值 + 取值规则 specs +
缺位时 .env 档的进程生效值(与 /admin/config-facts 同源)
- PUT 同路径:三态补丁(键缺位 = 不改动 / 合法值 = 采用 / 非法值 = 400 且盘上一
个字节都不动)+ null 清除哨兵(删键 ⇒ 回到下一档)+ writeAuth + 审计
memory.tidy.tuning;值一个都没变时不落盘,文件解不开时拒绝覆盖(409)
- 落盘走 runtimeconfig.PatchMemoryTidyTuning(原子写 temp+rename,与
SeedMemoryTidy/SaveWebSearch 同族同手法);补丁语义保留下受管的其它键
(t1Judge/t1PerWindow/autoApplyPerRound 原样保留)
- 热生效:保存后按「面板 > .env > 代码默认」重算并叠到运行期对象;整理档会
重排心跳(否则新周期要等旧周期那一觉睡完),审查档当场改 ingestGate/tempTtlSec。
未接线/失败时如实回 hot:false + hotError,绝不把「落盘了」说成「生效了」
前端(Miuix):
- 记忆管理页新增「记忆整理调优」卡:7 键表单(中文标签、单位、留空⇒跟随谁、
env 档当前值、坏值就地报错并拦住保存、保存后回显从磁盘读回的真值)
- 试算卡的 recallMode 按契约 v0.91 如实渲染:embedding / lexical(已降级)/
mixed(部分分区降级)/ ""(无候选可比)——空串不再渲染成空标签;并把
error.code=tidy_recall_unavailable 单列一句(「尝试过但向量源不可用」,与
「没有候选可比」显式区分)
验证:go test ./internal/{services/gateway,runtime/memory,config/runtimeconfig}
全绿;三态/清除/坏文件拒写/同值不重写/热生效接线各有用例,并逐条做过变异自证;
tsc --noEmit + vite build 通过;两个 esbuild 前端测试 39 + 34 + 41 checks 通过。
276 lines
11 KiB
Go
276 lines
11 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 "整数"
|
||
}
|
||
}
|
||
|
||
// clipJSON 把收到的原始 JSON 片段截断成可安全回显的短串(错值可能很长)。
|
||
func clipJSON(raw json.RawMessage) string {
|
||
const maxClip = 40
|
||
s := strings.TrimSpace(string(raw))
|
||
if len(s) <= maxClip {
|
||
return s
|
||
}
|
||
return s[:maxClip] + "…"
|
||
}
|
||
|
||
// 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
|
||
}
|