Files
Cyrene_v2/internal/services/gateway/admin_memory_tuning.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

332 lines
16 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 (
"errors"
"fmt"
"io"
"net/http"
"os"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
)
// 本文件承载记忆整理「面板调优」的**管理读写端点**(契约待塔登记,见回报):
//
// GET /api/v1/admin/memories/tidy/tuning 读:盘上的 7 键值 + 取值规则
// PUT /api/v1/admin/memories/tidy/tuning 写:三态补丁 + 原子落盘 + 热生效
//
// 它回答的是主人的两句原话(逐字,见 M4 mission Context):
//
// 「`.env` 只做兜底用 在除了第1次部署之后 后端自动创建 独立的面板配置文件之后就走
// 面板配置文件。这些键优先从面板配置上取」
// 「频率必须调 可以调 1 小时或者三小时 或者动态分配,比如白天是间隔短一点 晚上到了
// 静默期稍微长一点」
//
// 此前 `$DATA_DIR/config/memory-tidy.json` **只有读路径**(`cmd/cyrene/wire_memory_tidy.go`
// 在装配期读一次 + 启动播种):主人要改频率只能 SSH 改文件 + 重启后端。本切片把
// 「读回来 → 改 → 落盘」这条面板路径补上,**零新 env 键**(`.env` 已冻结,
// design/13 §2.1)、**不新开文件家族**。
//
// 四条不可走样的口径:
//
// 1. **三态**(硬要求,与契约里 `plugin.saveProvider` 同族语义):键**缺位** ⇒ 不动;
// **合法值** ⇒ 采用;**非法值** ⇒ `400 invalid_payload` 且**盘上一个字节都不动**。
// `null` 是这一族的清除哨兵(= 删掉该键、回到下一档兜底)。三态的实现与
// 「非法值不落盘」的证据在 `admin_memory_tuning_file.go` 的
// `parseMemoryTidyTuningPatch`(解析阶段就拒,落盘只在解析成功之后发生)。
// 2. **写端点带审计**(动作 `memory.tidy.tuning`),且**不改** `.env`、不重启后端。
// 3. **热生效**:保存后把新值当场叠到运行期(可选能力,见 MemoryTidyTuningApplier)。
// 能力不存在或失败时,响应 `hot:false` + `hotError`,面板**如实**说「已落盘、
// 要重启后端」——绝不把「落盘了」说成「生效了」。
// 4. **诚实回话**:写成功后的响应是**重新读盘**的结果(真值回显),不是「我以为
// 写成了什么」;盘上文件解不开时读/写**都**拒绝(`409 tuning_corrupt`)并说明
// 运行期此刻正回落 `.env`/代码默认。
//
// 权限:GET 与 PUT 都走 **writeAuth(owner|admin)**(与本族其它记忆端点
// `/admin/memories`、`POST /admin/memories/{id}/revert` 同档)——它是私有记忆面的
// 运行期旋钮,不是给 viewer 的监控投影。
// memoryTuningLogSource 是本子功能的日志来源(模块/子功能可追溯,AGENTS §10)。
const memoryTuningLogSource = "gateway/memory-tuning"
// memoryTuningAuditAction 是写端点落审计的动作名(与 memory.tidy.apply /
// memory.tidy.reject 同一族命名)。
const memoryTuningAuditAction = "memory.tidy.tuning"
// memoryTuningMaxBodyBytes 是写端点请求体的墙钟上限。这个文件是给人改的小配置
// (11 个标量键),正常请求体不到 200 字节;给上限只为让畸形/超大请求不占内存,
// 不是业务上限。
const memoryTuningMaxBodyBytes = 16 << 10
// MemoryTidyTuningApplier 是「把面板值当场叠到**整理自动档**」的可选能力。
//
// 它**不是**必需依赖:组合根注入的整理服务(`deps.MemoryTidy` = `*memory.TidyService`)
// 实现了它 ⇒ 保存后立刻生效;没实现 ⇒ 值照旧落盘、但要等后端重启,响应里
// `hot:false` 如实说出来(口径 3)。
//
// base 是**`.env` 层**(由 `memory.TidyConfigFromConfig` 从进程生效配置投影而来,
// 与 wireMemoryTidy 用的是同一个唯一实现点);panel 是刚落盘的那一份。实现必须按
// 「面板 > `.env` > 代码默认」逐键重算,且**缺位键保持 base 的值** —— 也就是
// wireMemoryTidy 当初那一遍的同一口径,只是可以随时再跑一次。
type MemoryTidyTuningApplier interface {
ApplyPanelTuning(base memory.TidyConfig, panel runtimeconfig.MemoryTidyTuning) error
}
// MemoryIngestTuningApplier 同理,服务于**同步入库闸 / 入库前审查**一侧
// (`ingestGate` / `tempTtlSec`)——它的持有者是记忆存储,因此由 `deps.Memories`
// 那个出口解析(网关不为此新开依赖面)。
type MemoryIngestTuningApplier interface {
ApplyIngestPanelTuning(panel runtimeconfig.MemoryTidyTuning) error
}
// AdminMemoryTidyTuningView 是 GET / PUT `/admin/memories/tidy/tuning` 的 data。
type AdminMemoryTidyTuningView struct {
// Path 是面板配置文件的落点(**相对 `$DATA_DIR`**,如 `config/memory-tidy.json`):
// 面板据此如实告诉主人改的是哪个文件,而不必把部署绝对路径下发。
Path string `json:"path"`
// Present 报告那份文件是否已存在且有内容。false = 还没播种(后端**下次启动**
// 会自动按当时生效值创建它,见 runtimeconfig.SeedMemoryTidy)。
Present bool `json:"present"`
// Hot 报告这次结果是否**当场生效**(true)。false = 已落盘,但要等后端重启 ——
// 面板必须照实说,不得显示成「已生效」。GET 里恒为 false:只读不改变任何东西,
// 「生效」这个问题在只读路径上没有意义。
Hot bool `json:"hot"`
// HotError 在「已保存、但没能在运行期立刻生效」时给出原因(`hot:false` 时才有)。
HotError string `json:"hotError,omitempty"`
// Specs 是这 7 个键的**取值规则**(类型/单位/上下界/兜底来源),随读下发 ⇒
// 面板的表单与就地校验都由它驱动,不会出现「面板允许、后端 400」的两套规则。
Specs []memoryTidyTuningSpec `json:"specs"`
// Values 是**盘上**的值:键缺位 ⇒ 整个键不出现(= 跟随兜底),不编一个 0 ——
// 「写了 0」(坏值,运行期会 WARN 回落)与「没写」在面板上必须看得出区别。
Values map[string]any `json:"values"`
// EnvValues 是缺位时**下一档(`.env`)**的进程生效值(只读下发):面板据此写
// 「留空 ⇒ 跟随 `.env` 的 86400」,而不必自己猜一个数。与
// GET /admin/config-facts 同源(都取 `s.cfg`,网关绝不直接 fopen `.env`)。
// 某个键**连 `.env` 档也没有值**(未填 / 结构性无此键)时它不出现。
EnvValues map[string]int `json:"envValues,omitempty"`
}
// adminMemoryTidyTuningRead 是 GET 的处理函数:只读、不审计(与
// /admin/config-facts、tidy 试算同一档:读事实不改状态)。
func (s *Server) adminMemoryTidyTuningRead(w http.ResponseWriter, _ *http.Request) {
path, ok := s.memoryTidyTuningPath()
if !ok {
writeError(w, apperr.Wrap(apperr.ErrInternal, "tuning_unavailable", "数据目录未接线,面板配置文件落点无法确定"))
return
}
view, err := s.buildMemoryTidyTuningView(path)
if err != nil {
s.writeTuningCorrupt(w, err)
return
}
writeJSON(w, http.StatusOK, view)
}
// adminMemoryTidyTuningWrite 是 PUT 的处理函数:三态补丁 → 原子落盘 → 热生效 →
// 审计。顺序是刻意的:**校验全部发生在落盘之前**(口径 1),落盘之后才谈生效。
func (s *Server) adminMemoryTidyTuningWrite(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(io.LimitReader(r.Body, memoryTuningMaxBodyBytes))
if err != nil {
s.auditClaims(r, memoryTuningAuditAction, "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "读取请求体失败"))
return
}
patch, err := parseMemoryTidyTuningPatch(body)
if err != nil {
// 非法值/未知键:**盘上一个字节都不动**(解析阶段就退出,写盘在下面)。
s.auditClaims(r, memoryTuningAuditAction, "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "invalid_payload", "%v", err))
return
}
path, ok := s.memoryTidyTuningPath()
if !ok {
s.auditClaims(r, memoryTuningAuditAction, "error")
writeError(w, apperr.Wrap(apperr.ErrInternal, "tuning_unavailable", "数据目录未接线,面板配置文件落点无法确定"))
return
}
// 落盘(补丁 + 原子写)交给 runtimeconfig 家族:它同时是「盘上那份能不能被
// 运行期读走」的**唯一**判定点(读不走的文件拒绝覆盖,见 ErrInvalidMemoryTidy)。
if !patch.empty() {
if err := s.commitMemoryTidyTuningPatch(path, patch); err != nil {
s.auditClaims(r, memoryTuningAuditAction, "error")
s.writeTuningWriteFailure(w, err)
return
}
}
view, err := s.buildMemoryTidyTuningView(path)
if err != nil {
// 刚落盘就解不开:这是本端点的编程/磁盘错误,如实回 500,绝不回一个
// 「已保存」的假成功。
s.auditClaims(r, memoryTuningAuditAction, "error")
s.writeTuningCorrupt(w, err)
return
}
hot, hotErr := s.applyMemoryTidyTuningHot(path)
view.Hot = hot
if hotErr != nil {
view.HotError = hotErr.Error()
}
s.auditClaims(r, memoryTuningAuditAction, "ok")
writeJSON(w, http.StatusOK, view)
}
// commitMemoryTidyTuningPatch 把补丁交给 runtimeconfig 家族原子落盘。值一个都没变
// 时不落盘(「保存同样的值」不该在盘上留一次无意义的改写)。
func (s *Server) commitMemoryTidyTuningPatch(path string, patch memoryTidyTuningPatch) error {
_, wrote, err := runtimeconfig.PatchMemoryTidyTuning(path, patch.set, patch.clear)
if err != nil {
return err
}
log := logger.WithSource(s.log, memoryTuningLogSource)
if !wrote {
log.Info("memory tidy tuning unchanged (no bytes written)", "path", path, "keys", patch.changed)
return nil
}
log.Info("memory tidy tuning saved", "path", path, "keys", patch.changed)
return nil
}
// buildMemoryTidyTuningView 读盘并组装面板视图(口径 4:响应就是**盘上真值**)。
func (s *Server) buildMemoryTidyTuningView(path string) (AdminMemoryTidyTuningView, error) {
tuning, err := runtimeconfig.LoadMemoryTidyTuning(path)
if err != nil {
return AdminMemoryTidyTuningView{}, err
}
present, err := memoryTidyTuningPresent(path)
if err != nil {
return AdminMemoryTidyTuningView{}, err
}
return AdminMemoryTidyTuningView{
Path: runtimeconfig.MemoryTidyConfigRelPath,
Present: present,
Specs: memoryTidyTuningSpecs(),
Values: memoryTidyTuningValues(tuning),
EnvValues: s.memoryTidyTuningEnvValues(),
}, nil
}
// memoryTidyTuningPresent 报告面板配置文件是否已存在且**有内容**(全空白 = 还没配过,
// 与 runtimeconfig.LoadMemoryTidyTuning 的口径一致)。
func memoryTidyTuningPresent(path string) (bool, error) {
raw, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return false, nil
}
return false, fmt.Errorf("读取 %s:%w", path, err)
}
return len(strings.TrimSpace(string(raw))) > 0, nil
}
// memoryTidyTuningEnvValues 返回缺位时的**下一档(`.env`)**进程生效值。
//
// 只取 `s.cfg`(配置层已消化「进程环境 → `.env` → 声明默认」):网关**绝不**
// 直接 fopen `.env`,否则面板看到的是文件原文而不是运行实例在用的数。
//
// 三处刻意省略(省略 = 「这一档也没值,继续落到代码默认」,而不是编一个 0):
// - `MEMORY_TIDY_INTERVAL_SEC` ≤ 0:非正数是坏值(运行期会 WARN 并回落),
// 报出来会让人以为「留空就得到 0 秒」;
// - 安静时段两端**都为 0**:0/0 是「未填」,运行期会回落到设计默认窗口,
// 此时报 0/0 等于把「未填」谎报成一个午夜窗口。
//
// 其余四个键(`quietIntervalSec` / `ingestGate` / `autoApply` / `tempTtlSec`)在
// `.env` 里**结构性没有出处**(`.env` 已冻结,design/13 §2.1),因此不存在第二档,
// 它们的下一档就是代码默认(specs 里的 fallback 已说明)。
func (s *Server) memoryTidyTuningEnvValues() map[string]int {
out := map[string]int{}
if s.cfg == nil {
return out
}
if v := s.cfg.MemoryTidy.IntervalSec; v > 0 {
out["intervalSec"] = v
}
if start, end := s.cfg.MemoryTidy.QuietStartHour, s.cfg.MemoryTidy.QuietEndHour; start != 0 || end != 0 {
out["quietStartHour"] = start
out["quietEndHour"] = end
}
return out
}
// memoryTidyTuningPath 解析面板配置文件的绝对落点(`$DATA_DIR/config/memory-tidy.json`)。
//
// 数据目录的来源与图片上传端点同一口径:优先已解析的 `deps.DataDir`,为空时回退
// `cfg.DataDir`;两者都空(未接线)时报 false,调用方如实回 `tuning_unavailable`,
// 绝不猜一个目录去写。
func (s *Server) memoryTidyTuningPath() (string, bool) {
dataDir := s.dataDir
if dataDir == "" && s.cfg != nil {
dataDir = s.cfg.DataDir
}
if dataDir == "" {
return "", false
}
return runtimeconfig.DefaultMemoryTidyPath(dataDir), true
}
// applyMemoryTidyTuningHot 把刚落盘的面板值当场叠到运行期(口径 3)。
//
// 返回 (hot, err):hot=false 表示这次**没能在运行期生效**(能力未接线,或叠的过程
// 报错),err 说明原因。两者只用于如实回话,**不影响**「已经落盘」这个事实 ——
// 值不会因为热生效失败而丢。
//
// 能力用类型断言探测(可选能力,与 `plugin` 侧的可选接口同一手法):网关**不为此**
// 新增 Deps 字段/Server 字段 —— 整理服务本来就在 `s.memoryTidy` 里,审查器挂在
// `deps.Memories` 的实现上。这样「有没有热生效」是一件可观测的事实,而不是一句
// 靠不住的断言。
func (s *Server) applyMemoryTidyTuningHot(path string) (hot bool, hotErr error) {
tuning, err := runtimeconfig.LoadMemoryTidyTuning(path)
if err != nil {
return false, fmt.Errorf("重读面板配置失败:%v", err)
}
applied := false
if applier, ok := s.memoryTidy.(MemoryTidyTuningApplier); ok {
base := memory.TidyConfig{}
if s.cfg != nil {
base = memory.TidyConfigFromConfig(s.cfg.MemoryTidy)
}
if err := applier.ApplyPanelTuning(base, tuning); err != nil {
return false, fmt.Errorf("整理自动档未能热生效:%v", err)
}
applied = true
}
if applier, ok := s.memories.(MemoryIngestTuningApplier); ok {
if err := applier.ApplyIngestPanelTuning(tuning); err != nil {
return false, fmt.Errorf("入库审查未能热生效:%v", err)
}
applied = true
}
if !applied {
return false, fmt.Errorf("运行期没有接线可热生效的实现(deps.MemoryTidy / deps.Memories);新值已落盘,重启后端后生效")
}
return true, nil
}
// writeTuningCorrupt 把「盘上那份读不走」映射成 409 tuning_corrupt,并把**运行期的
// 实际后果**写进文案:此刻它正整体回落 `.env`/代码默认(见 runtimeconfig 的读取口径),
// 而面板**拒绝**在这个文件上覆盖 —— 坏文件请人工修复或删除。
func (s *Server) writeTuningCorrupt(w http.ResponseWriter, cause error) {
logger.WithSource(s.log, memoryTuningLogSource).Warn(
"memory tidy tuning file unusable", "path", runtimeconfig.MemoryTidyConfigRelPath, "error", cause)
writeError(w, apperr.Wrap(apperr.ErrConflict, "tuning_corrupt",
"面板配置文件 %s 解不开(运行期正回落 .env / 代码默认);面板拒绝在一个读不走的文件上覆盖,请人工修复或删除它:%v",
runtimeconfig.MemoryTidyConfigRelPath, cause))
}
// writeTuningWriteFailure 把落盘失败如实分类:**盘上那份读不走**(ErrInvalidMemoryTidy)
// 是 409(拒绝覆盖,不是故障),其余(IO/权限/重读不一致)是 500。两种都带出处,
// 绝不回一个「已保存」的假成功。
func (s *Server) writeTuningWriteFailure(w http.ResponseWriter, cause error) {
if errors.Is(cause, runtimeconfig.ErrInvalidMemoryTidy) {
s.writeTuningCorrupt(w, cause)
return
}
logger.WithSource(s.log, memoryTuningLogSource).Error(
"memory tidy tuning write failed", "path", runtimeconfig.MemoryTidyConfigRelPath, "error", cause)
writeError(w, apperr.Wrap(apperr.ErrInternal, "tuning_write_failed",
"面板配置文件 %s 落盘失败(盘上内容未改动):%v", runtimeconfig.MemoryTidyConfigRelPath, cause))
}