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 通过。
This commit is contained in:
AskaEth committed 2026-10-13 21:59:18 +08:00
1 parent 17c41265e8
commit eddf921229
18 files changed
+2604 -5

No files matched your search

@@ -0,0 +1,167 @@
// 记忆整理「调优」卡**纯逻辑**的测试(切片 MEMPANEL / M4)。
//
// 为什么需要它:这张卡最容易写错的地方不是样式,而是**三态**(键缺位 / 合法值 /
// 非法值)与「清空 ≠ 不改」这两条 —— 错一处就会写出「主人以为改了、盘上没动」或者
// 「一个坏值把整份配置写成 0」这类事故。这些判据全在 memoryTidyTuning.ts 的纯函数里,
// 因此不需要渲染就能钉住。
//
// 跑法(与同目录 *.render.test.ts 同一手法,本文件不依赖 React):
//
// cd frontend/web \
// && ./node_modules/.bin/esbuild src/admin/modules/memoryTidyTuning.test.ts \
// --bundle --platform=node --format=cjs \
// --outfile=/tmp/cyrene-memory-tidy-tuning.test.cjs \
// && node /tmp/cyrene-memory-tidy-tuning.test.cjs
//
// `tsc --noEmit` 会对本文件做类型检查,因此它只从项目内导入、不 import node:*。
import type {
AdminMemoryTidyTuningSpec,
AdminMemoryTidyTuningView,
} from "../../lib/api/adminMemories";
import {
buildTuningPatch,
draftFromView,
fallbackLabel,
fieldError,
patchSummary,
secondsHint,
} from "./memoryTidyTuning";
let checks = 0;
function ok(cond: boolean, what: string): void {
checks += 1;
if (!cond) throw new Error(`FAILED: ${what}`);
}
function eq<T>(got: T, want: T, what: string): void {
checks += 1;
if (got !== want) throw new Error(`FAILED: ${what}:got=${JSON.stringify(got)} want=${JSON.stringify(want)}`);
}
function deepEq(got: unknown, want: unknown, what: string): void {
const g = JSON.stringify(got);
const w = JSON.stringify(want);
checks += 1;
if (g !== w) throw new Error(`FAILED: ${what}:got=${g} want=${w}`);
}
/** spec 是后端 specs 的替身(取值规则来自后端,前端只消费)。 */
function spec(key: string, kind: string, extra: Partial<AdminMemoryTidyTuningSpec> = {}): AdminMemoryTidyTuningSpec {
return { key, kind, fallback: "code-default", ...extra } as AdminMemoryTidyTuningSpec;
}
/** view 造一份 GET 响应形状的 fixture。 */
function view(values: Record<string, number | boolean>, envValues?: Record<string, number>): AdminMemoryTidyTuningView {
return {
path: "config/memory-tidy.json",
present: true,
hot: false,
hotError: "运行期没有接线可热生效的实现",
specs: [
spec("intervalSec", "int", { unit: "sec", min: 1, fallback: "env:MEMORY_TIDY_INTERVAL_SEC" }),
spec("quietIntervalSec", "int", { unit: "sec", min: 1, fallback: "panel:intervalSec" }),
spec("quietStartHour", "int", { unit: "hour", min: 0, max: 23, fallback: "env:MEMORY_TIDY_QUIET_START_HOUR" }),
spec("quietEndHour", "int", { unit: "hour", min: 0, max: 23, fallback: "env:MEMORY_TIDY_QUIET_END_HOUR" }),
spec("ingestGate", "bool", { fallback: "code-default" }),
spec("autoApply", "bool", { fallback: "code-default" }),
spec("tempTtlSec", "int", { unit: "sec", min: 1, fallback: "code-default" }),
],
values,
envValues,
};
}
// ① 草稿:键缺位 ⇒ 空串(= 跟随兜底),而不是 0 —— 「没配」与「配成 0」必须分得开。
{
const draft = draftFromView(view({ intervalSec: 10800, ingestGate: false }));
eq(draft.intervalSec, "10800", "草稿回填盘上值");
eq(draft.ingestGate, "false", "布尔键回填为字符串");
eq(draft.quietIntervalSec, "", "盘上没这个键 ⇒ 空串(跟随兜底)");
eq(draft.tempTtlSec, "", "盘上没这个键 ⇒ 空串(跟随兜底)");
}
// ② 三个态:没动过的键**不进补丁**(= 不改动);改过的进补丁;合法值按类型提交。
{
const base = view({ intervalSec: 86400, quietStartHour: 23, quietEndHour: 7, ingestGate: false });
const draft = draftFromView(base);
const untouched = buildTuningPatch(base, draft);
deepEq(untouched.patch, {}, "没动过任何键 ⇒ 空补丁(盘上逐字节不动)");
deepEq(untouched.errors, {}, "没动过任何键 ⇒ 无错误");
const changed = buildTuningPatch(base, { ...draft, intervalSec: "10800", ingestGate: "true" });
deepEq(changed.patch, { intervalSec: 10800, ingestGate: true }, "合法值 ⇒ 采用(且只含改动过的键)");
deepEq(changed.errors, {}, "合法值 ⇒ 无错误");
}
// ③ 清空哨兵:原本有值、现在清空 ⇒ `null`(删掉该键,回到下一档兜底);
// 原本就没值、现在还是空 ⇒ 什么都不发(不改动)。
{
const base = view({ intervalSec: 3600, tempTtlSec: 604800 });
const draft = draftFromView(base);
const cleared = buildTuningPatch(base, { ...draft, intervalSec: "" });
deepEq(cleared.patch, { intervalSec: null }, "清空一个有值的键 ⇒ null(回到兜底)");
const alreadyEmpty = view({ ingestGate: true });
const mix = view({ ingestGate: true });
const r2 = buildTuningPatch(mix, { ...draftFromView(alreadyEmpty), tempTtlSec: "" });
deepEq(r2.patch, {}, "原本就没值的键保持空 ⇒ 不改动(不是清除)");
}
// ④ 非法值:**就地报错且不进补丁**(界面据此拦下保存),后端的 400 仍是权威那道门。
{
const base = view({ intervalSec: 3600, quietStartHour: 23, quietEndHour: 7 });
const draft = draftFromView(base);
const cases: Array<[string, string, string]> = [
["intervalSec", "abc", "非整数"],
["intervalSec", "0", "低于下界(0 会让整理空转)"],
["intervalSec", "-60", "负数"],
["quietStartHour", "24", "钟点越上界"],
["quietEndHour", "-1", "钟点越下界"],
["tempTtlSec", "1.5", "小数不是整数"],
];
for (const [key, raw, why] of cases) {
const res = buildTuningPatch(base, { ...draft, [key]: raw });
ok(res.errors[key] !== undefined, `非法值必须就地报错(${why})`);
ok(res.patch[key] === undefined, `非法值绝不能进补丁(${why})`);
}
// 混着来:一个坏值不影响对合法键的校验,但界面整体不许保存(errors 非空)。
const mixed = buildTuningPatch(base, { ...draft, intervalSec: "0", quietStartHour: "22" });
ok(Object.keys(mixed.errors).length === 1, "只有一个坏值时只报一处");
eq(mixed.patch.quietStartHour, 22, "合法键仍照常进补丁(后端一次事务性写盘)");
// 布尔键只认三个取值。
eq(fieldError(spec("ingestGate", "bool", { fallback: "code-default" }), "yes").length > 0, true, "布尔键的坏值要报错");
eq(fieldError(spec("ingestGate", "bool", { fallback: "code-default" }), ""), "", "空 = 跟随兜底,合法");
}
// ⑤ 边界值合法(下界/上界本身可用,不是「越界」)。
{
eq(fieldError(spec("intervalSec", "int", { unit: "sec", min: 1, fallback: "x" }), "1"), "", "下界本身合法");
eq(fieldError(spec("quietStartHour", "int", { unit: "hour", min: 0, max: 23, fallback: "x" }), "0"), "", "钟点 0 合法");
eq(fieldError(spec("quietEndHour", "int", { unit: "hour", min: 0, max: 23, fallback: "x" }), "23"), "", "钟点 23 合法");
}
// ⑥ 文案:兜底来源与单位要写成人话(面板的「留空 ⇒ 跟随谁」全靠它)。
{
eq(fallbackLabel(spec("intervalSec", "int", { fallback: "env:MEMORY_TIDY_INTERVAL_SEC" })),
"跟随 .env 的 MEMORY_TIDY_INTERVAL_SEC", "env 兜底文案");
eq(fallbackLabel(spec("quietIntervalSec", "int", { fallback: "panel:intervalSec" })),
"跟随面板里的 intervalSec", "面板内兜底文案");
eq(fallbackLabel(spec("tempTtlSec", "int", { fallback: "code-default" })), "跟随代码默认", "代码默认文案");
eq(secondsHint(10800), "≈ 3 小时", "秒 → 小时提示");
eq(secondsHint(1800), "≈ 30 分钟", "秒 → 分钟提示");
eq(secondsHint(0), "", "非正数不提示");
eq(patchSummary({ intervalSec: 3600, ingestGate: false }), "同步入库闸(写入时当场审查)、平时整理间隔", "回执文案");
eq(patchSummary({}), "没有需要改的键", "空补丁回执");
}
// ⑦ 字符串数字不做隐式转换(" 12 " 这种带空白的要能通过,但 "12 3" 不行)。
{
const base = view({ intervalSec: 3600 });
const padded = buildTuningPatch(base, { ...draftFromView(base), intervalSec: " 7200 " });
deepEq(padded.patch, { intervalSec: 7200 }, "两侧空白应当被容忍");
const bad = buildTuningPatch(base, { ...draftFromView(base), intervalSec: "12 3" });
ok(bad.errors.intervalSec !== undefined, "内部空格不是合法整数");
}
console.log(`memoryTidyTuning.test.ts: ${checks} checks passed`);