主人 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 通过。
332 lines
16 KiB
Go
332 lines
16 KiB
Go
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))
|
||
}
|