Files
Cyrene_v2/internal/services/gateway/admin_memory_tuning_file.go
T
AskaEth eddf921229 feat(memory): 记忆整理调优面板读写(三态 + 热生效 + 面板表单)
主人 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 通过。
2026-10-13 21:59:18 +08:00

276 lines
11 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.
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
}