Files
Cyrene_v2/internal/runtime/memory/preinsert_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

101 lines
5.6 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 memory
import (
"log/slog"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
)
// 本文件是**入库前审查调优面**的解析点:把 `$DATA_DIR/config/memory-tidy.json`
// 的面板旋钮叠到 ReviewConfig 上 —— 与 tidy_tuning.go 对 TidyConfig 做的事同源、
// 同一条口径(主人 2026-10-07:「**`.env` 只做兜底用** —— 后端自动创建独立的面板
// 配置文件之后就走面板配置文件,这些键优先从面板配置上取」)。
//
// 为什么解析放在 memory 而不是 runtimeconfig:runtimeconfig 只负责**文件形状与
// 读取**(它的头注释把这条边界写死了),而「缺位/坏值怎么回落」属于 ReviewConfig
// 的归一化语义 —— 与 normalizeReviewConfig 同一层,因此两者可以一起被单测覆盖。
//
// 为什么不是「读盘时逐键容错」:整份文件解不开(含某键类型写错)时**全部键一起**
// 回落,这是本文件家族既有的粒度(见 runtimeconfig.LoadMemoryTidyTuning 与它的
// 用例③);本文件处理的是**类型正确但值不可用**(非正数)那一档。
// ApplyReviewTuning 把面板调优旋钮叠到 ReviewConfig 上,返回叠加后的配置
// (**未归一化**:调用方随后交给 NewPreInsertReviewer / normalizeReviewConfig
// 填代码默认值,与组合根对 TidyConfig 的既有顺序一致)。
//
// 目前只有一键,逐键独立,三档优先级是:
//
// 键 面板(memory-tidy.json) 下一档(.env) 代码默认
// tempTtlSec tempTtlSec **无此键**(结构性为空) 604800(7 天)
//
// 🔴 **中档(`.env`)在结构上是空的**,这是**刻意**的、不是漏配:`.env` 已冻结
// (design/13 §2.1「不加、不改、不删」),而「多久算临时」是本次新增的能力,
// 它在 `.env` 里**从来就没有键**。因此实际生效链是「面板 > 代码默认」——报回报时
// 不许把它写成「面板 > .env > 代码默认」而让读者以为 `.env` 那档还有东西可调。
//
// 坏值(非正秒数)**绝不写进 cfg**:回落代码默认 + 记一条 WARN。一个 ≤0 的 TTL
// 会让每条 defer 记忆**落库即过期**(读侧立刻隐藏、后台清扫下一轮就删)——那是
// 坏配置而不是「不过期」,但也不许因此让启动失败(design/03 §9.6)。
func ApplyReviewTuning(cfg ReviewConfig, panel runtimeconfig.MemoryTidyTuning, log *slog.Logger) ReviewConfig {
log = logger.WithSource(baseTidyLog(log), "memory/review")
if sec := panelPositiveTTLSec(panel.TempTtlSec, "tempTtlSec", log); sec > 0 {
cfg.TempTTL = time.Duration(sec) * time.Second
}
return cfg
}
// panelPositiveTTLSec 解析**临时记忆 TTL** 旋钮,返回 0 表示「不覆盖」(调用方原样
// 留着上一档的值,最终由 normalizeReviewConfig 落到代码默认 7 天)。
//
// 三种情形分得很清,因为它们该有不同的观测形状(与 tidy_tuning.go 的
// panelPositiveSec 逐字同构):
//
// - `nil`(键没写)⇒ 0,**静默**:没配不是错,正是「代码默认兜底」那一档;
// - 非正数(写了 0 或负数)⇒ 0 + **WARN**:它会让每条临时记忆落库即过期
// (与「不过期」恰好相反),属于坏配置,必须留下痕迹;
// - 正数 ⇒ 原值。
func panelPositiveTTLSec(v *int, key string, log *slog.Logger) int {
if v == nil {
return 0
}
if *v <= 0 {
log.Warn("memory-tidy.json 的临时记忆 TTL 不可用,回落代码默认(非正 TTL 会让临时记忆落库即过期)",
"key", key, "value", *v, "fallback_sec", tempTtlSec)
return 0
}
return *v
}
// ApplyPanelTuning 把面板值**当场**叠到已经在跑的审查服务上(切片 MEMPANEL / M4)。
//
// 它只碰面板管得到、且**在 `.env` 里结构性没有出处**的两个键(`ingestGate` /
// `tempTtlSec`)—— 因此重算起点就是这两个字段的**零值**(中档为空),不需要一份
// 「`.env` 快照」,也不会因为重算而误伤别的键:`Enabled` / `QueueSize` / `ScanLimit` /
// `Timeout` / `Threshold` / T1 四件套一律**原样不动**(它们来自部署配置,不是面板旋钮;
// 面板端点也拒收它们)。
//
// 口径与装配期逐字一致(`wireMemoryReview` → `NewPreInsertReviewer`):面板写了就用,
// 缺位/坏值由 `ApplyReviewTuning`(WARN + 不覆盖)与 `normalizeReviewConfig`(代码
// 默认)接住 —— 这两处仍是**唯一**实现点,本方法不另写一套回落规则。
//
// 🔴 并发边界(如实登记):写 `r.cfg` 的两处在 `r.mu` 下,但 `preinsert.go` /
// `preinsert_gate.go` 读 `r.cfg` 的几处**没有**取 `r.mu`(本切片 scope 不含那两个
// 文件,故未一并收口)。这两个字段分别是一个 bool 与一个 int64,在 64 位平台上单字长
// 写入不会撕裂,但严格意义上这仍是「写方持锁、读方不持锁」的窄窗口。把 cfg 读改成
// 访问器(或换成原子快照)是后续应做的一次收口,已在回报里登记。
func (r *PreInsertReviewer) ApplyPanelTuning(panel runtimeconfig.MemoryTidyTuning) error {
next := normalizeReviewConfig(ApplyReviewTuning(ReviewConfig{}, panel, r.log))
r.mu.Lock()
r.cfg.DisableIngestGate = panel.IngestGateOff()
r.cfg.TempTTL = next.TempTTL
gateOn := !r.cfg.DisableIngestGate
ttl := r.cfg.TempTTL
r.mu.Unlock()
r.log.Info("memory review panel tuning applied",
"ingest_gate", gateOn, "temp_ttl", ttl.String(), "source", "admin panel")
return nil
}