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

214 lines
9.7 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 (
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
)
// 本文件锁定**运行期调优**(切片 MEMPANEL / M4:面板保存 ⇒ 当场生效,
// 不要求重启后端)的两条判据:
// ① 整理档:面板值按「面板 > `.env` > 代码默认」**重算**(不是叠在现值上)——
// 把键清掉(nil)必须回到 `base` 那一档,而不是留着上一次的面板值;心跳
// 在跑时保存会被**重排**(新周期立刻生效),没跑时是空操作。
// ② 入库审查档:`ingestGate` / `tempTtlSec` 两个键同样重算(中档结构性为空 ⇒
// 清掉即回代码默认),且经 `Store.ApplyIngestPanelTuning` 转调可达。
// tuningInt 返回面板调优用的 *int。
func tuningInt(v int) *int { return &v }
// tuningBool 返回面板调优用的 *bool。
func tuningBool(v bool) *bool { return &v }
// TestTidyServiceApplyPanelTuning_RecomputesFromEnvBase 面板 > .env > 代码默认,
// 且**重算而不是叠加**:清掉一个键就回到 base 那一档。
func TestTidyServiceApplyPanelTuning_RecomputesFromEnvBase(t *testing.T) {
t.Parallel()
base := TidyConfig{Enabled: true, IntervalSec: 86400, QuietStartHour: 23, QuietEndHour: 7}
svc := newTidyServiceForTest(t, newTestStore(t), base, nil, time.Now, nil)
// 面板给了三个键 ⇒ 三个都按面板走,没给的键保持 base(.env)档。
panel := runtimeconfig.MemoryTidyTuning{
IntervalSec: tuningInt(10800),
QuietIntervalSec: tuningInt(36000),
AutoApply: tuningBool(false),
}
if err := svc.ApplyPanelTuning(base, panel); err != nil {
t.Fatalf("ApplyPanelTuning: %v", err)
}
if svc.cfg.IntervalSec != 10800 || svc.cfg.Interval != 3*time.Hour {
t.Fatalf("interval = %d / %s, want 10800 / 3h", svc.cfg.IntervalSec, svc.cfg.Interval)
}
if svc.cfg.QuietIntervalSec != 36000 || svc.cfg.QuietInterval != 10*time.Hour {
t.Fatalf("quiet interval = %d / %s, want 36000 / 10h",
svc.cfg.QuietIntervalSec, svc.cfg.QuietInterval)
}
if !svc.cfg.DisableAutoApply {
t.Fatal("autoApply:false 应当落到 DisableAutoApply")
}
if svc.cfg.QuietStartHour != 23 || svc.cfg.QuietEndHour != 7 {
t.Fatalf("没写 quietStartHour/quietEndHour 时应当保持 base:%+v", svc.cfg)
}
// 两个持有者都要写:调度读 s.cfg,每一轮读 tidier.cfg。
if svc.tidier.cfg.IntervalSec != 10800 || !svc.tidier.cfg.DisableAutoApply {
t.Fatalf("tidier 配置没跟上:%+v", svc.tidier.cfg)
}
// 🔴 清掉 intervalSec(模拟面板 null 清除)⇒ 必须回到 base 的 86400,
// 而不是留在上一次的 10800。
cleared := runtimeconfig.MemoryTidyTuning{QuietStartHour: tuningInt(1), QuietEndHour: tuningInt(5)}
if err := svc.ApplyPanelTuning(base, cleared); err != nil {
t.Fatalf("ApplyPanelTuning(cleared): %v", err)
}
if svc.cfg.IntervalSec != 86400 {
t.Fatalf("清掉面板键后应回到 .env 档 86400,得到 %d(叠加而非重算)", svc.cfg.IntervalSec)
}
if svc.cfg.QuietIntervalSec != 86400 {
t.Fatalf("清掉 quietIntervalSec 后应回到 intervalSec 档,得到 %d", svc.cfg.QuietIntervalSec)
}
if svc.cfg.DisableAutoApply {
t.Fatal("清掉 autoApply 后应回到代码默认(开)")
}
if svc.cfg.QuietStartHour != 1 || svc.cfg.QuietEndHour != 5 {
t.Fatalf("quietStartHour/quietEndHour 应当采用面板值:%+v", svc.cfg)
}
}
// TestTidyServiceApplyPanelTuning_ReschedulesRunningHeartbeat 心跳在跑时保存 ⇒ 心跳
// 被重排(仍活着,且下一次等待按**新**周期算)。没跑时是空操作。
func TestTidyServiceApplyPanelTuning_ReschedulesRunningHeartbeat(t *testing.T) {
t.Parallel()
at := time.Date(2026, 10, 13, 15, 0, 0, 0, time.Local)
base := TidyConfig{Enabled: true, IntervalSec: 86400, QuietStartHour: 23, QuietEndHour: 7}
svc := newTidyServiceForTest(t, newTestStore(t), base, nil, fixedTidyClock(at), nil)
// 未启动:保存只改配置,不碰心跳状态(用一个不含 intervalSec 的补丁,免得
// 影响下面「旧周期」的断言)。
idlePanel := runtimeconfig.MemoryTidyTuning{QuietStartHour: tuningInt(22), QuietEndHour: tuningInt(6)}
if err := svc.ApplyPanelTuning(base, idlePanel); err != nil {
t.Fatalf("ApplyPanelTuning(idle): %v", err)
}
svc.mu.Lock()
running := svc.running
svc.mu.Unlock()
if running {
t.Fatal("未 Start 时不该因为保存而启动心跳")
}
// 启动后:造出「上次跑过」的历史,于是下一拍 = 上次 + 当时时段的周期。
// 15:00、上次 14:30、周期 24h ⇒ 下一拍还要等 23.5 小时(旧周期)。
svc.Start(nil)
t.Cleanup(func() { _ = svc.Stop(nil) })
svc.mu.Lock()
svc.lastRun = at.Add(-30 * time.Minute)
svc.mu.Unlock()
if got := svc.nextDelay(); got != 23*time.Hour+30*time.Minute {
t.Fatalf("启动后 nextDelay = %s, want 23h30m(旧周期)", got)
}
// 把周期改短并**重排**:新的下一拍应当按 1 小时的周期算(= 上次 + 1h ⇒ 还有
// 30 分钟),而不是继续睡原来那 23.5 小时。
if err := svc.ApplyPanelTuning(base, runtimeconfig.MemoryTidyTuning{IntervalSec: tuningInt(3600)}); err != nil {
t.Fatalf("ApplyPanelTuning(running): %v", err)
}
svc.mu.Lock()
running = svc.running
svc.mu.Unlock()
if !running {
t.Fatal("重排后心跳必须仍在跑(否则定时整理会悄悄停摆)")
}
if got := svc.nextDelay(); got != 30*time.Minute {
t.Fatalf("重排后的 nextDelay = %s, want 30m(仍按旧周期说明没重排)", got)
}
}
// TestTidyServiceApplyPanelTuning_UnchangedConfigKeepsHeartbeat 只改别的键(配置没变)
// 时不打扰心跳:那一次保存不该白白停一次心跳。
func TestTidyServiceApplyPanelTuning_UnchangedConfigKeepsHeartbeat(t *testing.T) {
t.Parallel()
at := time.Date(2026, 10, 13, 15, 0, 0, 0, time.Local)
base := TidyConfig{Enabled: true, IntervalSec: 86400, QuietStartHour: 23, QuietEndHour: 7}
svc := newTidyServiceForTest(t, newTestStore(t), base, nil, fixedTidyClock(at), nil)
svc.Start(nil)
t.Cleanup(func() { _ = svc.Stop(nil) })
same := normalizeTidyConfig(base)
if svc.cfg != same {
t.Fatalf("前置条件不成立:%+v vs %+v", svc.cfg, same)
}
if err := svc.ApplyPanelTuning(base, runtimeconfig.MemoryTidyTuning{}); err != nil {
t.Fatalf("ApplyPanelTuning(noop): %v", err)
}
if got := svc.nextDelay(); got != 8*time.Hour {
t.Fatalf("配置没变时不该重排心跳(无历史时仍锚到低峰起点 8h):nextDelay = %s", got)
}
}
// TestPreInsertReviewerApplyPanelTuning_RecomputesIngestKeys 审查侧的两个键同样
// **重算**:清掉即回代码默认(中档结构性为空)。
func TestPreInsertReviewerApplyPanelTuning_RecomputesIngestKeys(t *testing.T) {
t.Parallel()
store := newTestStore(t)
reviewer, err := NewPreInsertReviewer(ReviewConfig{Enabled: true}, ReviewDeps{Store: store, Log: testLogger()})
if err != nil {
t.Fatalf("NewPreInsertReviewer: %v", err)
}
// 面板关闸 + 1 小时 TTL。
panel := runtimeconfig.MemoryTidyTuning{IngestGate: tuningBool(false), TempTtlSec: tuningInt(3600)}
if err := reviewer.ApplyPanelTuning(panel); err != nil {
t.Fatalf("ApplyPanelTuning: %v", err)
}
if !reviewer.cfg.DisableIngestGate {
t.Fatal("ingestGate:false 应当当场关掉同步入库闸")
}
if reviewer.cfg.TempTTL != time.Hour {
t.Fatalf("tempTtlSec 未生效:%s", reviewer.cfg.TempTTL)
}
// 闸的判定面也真的变了(gateEnabled 直接读这份 cfg)。
if reviewer.gateEnabled() {
t.Fatal("关闸后 gateEnabled 仍为 true")
}
// 清掉两个键 ⇒ 回到代码默认(闸开、TTL 7 天)。
if err := reviewer.ApplyPanelTuning(runtimeconfig.MemoryTidyTuning{}); err != nil {
t.Fatalf("ApplyPanelTuning(cleared): %v", err)
}
if reviewer.cfg.DisableIngestGate || !reviewer.gateEnabled() {
t.Fatal("清掉 ingestGate 后应当回到默认(开)")
}
if want := time.Duration(tempTtlSec) * time.Second; reviewer.cfg.TempTTL != want {
t.Fatalf("清掉 tempTtlSec 后 TTL = %s, want %s(代码默认)", reviewer.cfg.TempTTL, want)
}
// 坏值(0 秒)不得落进 cfg:回落代码默认,而不是「不过期」也不是「落库即过期」。
if err := reviewer.ApplyPanelTuning(runtimeconfig.MemoryTidyTuning{TempTtlSec: tuningInt(0)}); err != nil {
t.Fatalf("ApplyPanelTuning(bad ttl): %v", err)
}
if want := time.Duration(tempTtlSec) * time.Second; reviewer.cfg.TempTTL != want {
t.Fatalf("坏 TTL 应当回落默认 %s,得到 %s", want, reviewer.cfg.TempTTL)
}
// 审查本身的其它旋钮**一个都不动**(它们不是面板键)。
if !reviewer.cfg.Enabled || reviewer.cfg.QueueSize == 0 || reviewer.cfg.ScanLimit == 0 {
t.Fatalf("面板调优误伤了别的旋钮:%+v", reviewer.cfg)
}
}
// TestStoreApplyIngestPanelTuning_DelegatesToAttachedReviewer 网关只能通过记忆存储
// 触达审查侧(deps.Memories),因此这条转调就是那条通道:接了审查 ⇒ 转调生效;
// 没接(审查关闭)⇒ 空操作 + nil(那时两个键本来就不生效)。
func TestStoreApplyIngestPanelTuning_DelegatesToAttachedReviewer(t *testing.T) {
t.Parallel()
store := newTestStore(t)
if err := store.ApplyIngestPanelTuning(runtimeconfig.MemoryTidyTuning{IngestGate: tuningBool(false)}); err != nil {
t.Fatalf("未接审查时应当空操作 + nil,得到 %v", err)
}
reviewer, err := NewPreInsertReviewer(ReviewConfig{Enabled: true}, ReviewDeps{Store: store, Log: testLogger()})
if err != nil {
t.Fatalf("NewPreInsertReviewer: %v", err)
}
store.AttachIngestReviewer(reviewer)
if err := store.ApplyIngestPanelTuning(runtimeconfig.MemoryTidyTuning{IngestGate: tuningBool(false)}); err != nil {
t.Fatalf("Store.ApplyIngestPanelTuning: %v", err)
}
if !reviewer.cfg.DisableIngestGate {
t.Fatal("经 Store 的转调没有落到审查服务上")
}
}