Files
Cyrene_v2/frontend/web/src/admin/modules/MemoryTidyDryRunCard.render.test.ts
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

177 lines
9.6 KiB
TypeScript
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.
// 记忆整理「试算」卡的**真实渲染**测试(契约 v0.75 §4.12;切片 MT1)。
//
// 为什么需要它:这张卡要回答的是「它会建议合并哪些」,而修好之后的三条关键断言
// 都是**文案与呈现**:
// ① 有候选:动作/理由/分数/id 去向/diff 预览都要一眼读得出来;
// ② 空:必须说明「算完了但没建议」**不等于**「库里没有重复」(否则空态会被读成
// 「库很干净」);
// ③ 错误:**显示错误、绝不显示 0**(把失败画成「0 条候选」就是把「没算出来」
// 冒充成结论 —— 这正是本项目面板明令禁止的那一类)。
//
// 跑法(与 ConfigFactsCards.render.test.ts 同):
//
// cd frontend/web \
// && ./node_modules/.bin/esbuild src/admin/modules/MemoryTidyDryRunCard.render.test.ts \
// --bundle --platform=node --format=cjs \
// --outfile=/tmp/cyrene-memory-tidy-dryrun.render.test.cjs \
// && node /tmp/cyrene-memory-tidy-dryrun.render.test.cjs
//
// `tsc --noEmit` 会对本文件做类型检查,因此它只从项目内导入、不 import node:*。
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import type { AdminTidyDryRunResult } from "../../lib/types";
import {
MemoryTidyDryRunBody,
TIDY_DRY_RUN_DISCLAIMER,
TIDY_DRY_RUN_ERROR_HINT,
TIDY_RECALL_UNAVAILABLE,
TIDY_RECALL_UNAVAILABLE_HINT,
type TidyDryRunState,
} from "./MemoryTidyDryRunCard";
let checks = 0;
function ok(cond: boolean, what: string): void {
checks += 1;
if (!cond) throw new Error(`FAILED: ${what}`);
}
function has(haystack: string, needle: string): boolean {
return haystack.includes(needle);
}
function render(state: TidyDryRunState): string {
return renderToStaticMarkup(createElement(MemoryTidyDryRunBody, { state, onRun: () => {} }));
}
const RESULT: AdminTidyDryRunResult = {
dryRun: true,
recallMode: "embedding",
durationMs: 42,
ruleVersion: "t0.1",
partitions: 2,
scanned: 736,
compared: 400,
pairs: 5,
vetoed: 2,
vetoReasons: { scope: 1, temporal: 1 },
candidates: [
{
action: "merge",
sourceIds: ["mem-short", "mem-long"],
targetId: "mem-long",
personaId: "cyrene",
scope: "persona",
kind: "relationship",
proposedContent: "主人常在深夜与助手聊天,有熬夜习惯",
mergedContent: "主人常在深夜与助手聊天,有熬夜习惯,作息偏晚",
reason: "containment_a_subset_b",
ruleVersion: "t0.1",
scoreMin: 0.95,
scoreMax: 0.95,
evidence: { similarity: 0.95, recall_mode: "embedding", containment: "a_subset_b" },
},
{
action: "link",
sourceIds: ["mem-redbull", "mem-iced"],
personaId: "cyrene",
scope: "persona",
kind: "relationship",
proposedContent: "",
mergedContent: "",
reason: "similar_but_not_contained",
ruleVersion: "t0.1",
scoreMin: 0.88,
scoreMax: 0.88,
},
],
};
// ── ① 有候选:动作 / 理由 / 分数 / id 去向 / diff 预览 / n观测口径 ──────────
const doneHtml = render({ kind: "done", result: RESULT });
ok(has(doneHtml, TIDY_DRY_RUN_DISCLAIMER), "恒显示「仅试算,不会改动任何记忆」");
ok(has(doneHtml, "并入"), "merge 动作要显示中文标签「并入」");
ok(has(doneHtml, "只建链接"), "link 动作要显示「只建链接」(绝不合并)");
ok(has(doneHtml, "containment_a_subset_b"), "必须显示机器可读理由");
ok(has(doneHtml, "0.950"), "必须显示分数(区间)");
ok(has(doneHtml, "mem-short") && has(doneHtml, "保留 mem-long"), "必须显示「哪条 → 保留哪条」");
ok(has(doneHtml, "作息偏晚"), "必须显示合并后的存活条目原文(diff 预览)");
ok(has(doneHtml, "扫描 736") && has(doneHtml, "用时 42 ms"), "必须显示扫描口径与耗时");
ok(has(doneHtml, "召回来源:语义向量(embedding)"), "必须显示召回来源(lexical 是降级信号)");
ok(has(doneHtml, "scope=1"), "必须显示否决分布(它解释「为什么没有更多建议」)");
// ── 截断:必须说明「这一轮看到的比库里少 + 该调哪个键」 ─────────────────────
const truncatedHtml = render({
kind: "done",
result: { ...RESULT, truncated: ["cyrene/persona/(compare)"] },
});
ok(has(truncatedHtml, "被配额截断"), "命中护栏时必须如实说明");
ok(has(truncatedHtml, "MEMORY_TIDY_SCAN_LIMIT") && has(truncatedHtml, "MEMORY_TIDY_CANDIDATE_LIMIT"), "要指向真键名");
// ── ② 空:说清「算完了但没建议」≠「没有重复」 ──────────────────────────────
const emptyHtml = render({ kind: "done", result: { ...RESULT, candidates: [], pairs: 0, vetoed: 0, vetoReasons: {} } });
ok(has(emptyHtml, "这一轮没有产生任何候选"), "空态必须明说「没有产生候选」");
ok(has(emptyHtml, "不等于"), "空态必须说清「不等于库里没有重复」");
ok(has(emptyHtml, "召回门"), "空态要解释候选的三道门(召回门 / 包含判定 / 规则否决)");
ok(!has(emptyHtml, "建议 0 条"), "空态不得渲染成「建议 0 条」(那不是失败文案也不是空态文案)");
// ── ③ 错误:显示错误、绝不显示 0 ────────────────────────────────────────────
const errorHtml = render({ kind: "error", message: "语义召回(embedding)不可用,试算未产出候选" });
ok(has(errorHtml, "试算失败"), "错误态必须显式标出失败");
ok(has(errorHtml, "语义召回(embedding)不可用"), "必须显示后端给的原因");
ok(has(errorHtml, TIDY_DRY_RUN_ERROR_HINT), "必须说清「没算出来」≠「算过了、没有建议」");
ok(!has(errorHtml, "算完了"), "错误态绝不得渲染完成态标记(「算完了,一行都没写」)");
ok(!has(errorHtml, "扫描 "), "错误态绝不得渲染观测口径(扫描/候选数)—— 把失败画成 0 条就是把失败当结论");
ok(!has(errorHtml, "这一轮没有产生任何候选"), "错误态不得复用空态文案(两者含义不同)");
ok(has(errorHtml, "把失败画成「0 条」"), "错误态反而要解释「为什么不显示 0 条」");
// ── ④ 召回来源(契约 v0.91):四种取值**各有各的话**,空串绝不是空标签 ────────
// 起因是 M6 的诊断(DevDocs/audit/去重失效诊断-2026-10-13.md):旧实现把「全量
// 降级」静默报成 embedding,面板据此判读会把「向量不可用」读成「向量正常」。
// 因此这个字段的**全部价值**就是让人看得出这一轮到底走了哪条路。
const recallCases: Array<[string, string]> = [
["embedding", "语义向量"],
["lexical", "已降级"],
["mixed", "部分分区降级"],
["", "无候选可比"],
["unregistered_mode", "未登记取值"],
];
for (const [mode, want] of recallCases) {
const html = render({ kind: "done", result: { ...RESULT, recallMode: mode } });
ok(has(html, want), `recallMode=${JSON.stringify(mode)} 必须渲染成可读中文(含「${want}」)`);
}
// 空串绝不许渲染成「召回来源:」后面空白(那是本次要修的那个缺陷)。
const noCandidateHtml = render({ kind: "done", result: { ...RESULT, recallMode: "", candidates: [], pairs: 0 } });
ok(has(noCandidateHtml, "无候选可比"), "空串 = 本轮没做召回,必须说出来");
ok(!has(noCandidateHtml, "召回来源:<"), "空串绝不能渲染成空标签(后面什么都没有)");
ok(!has(noCandidateHtml, "召回来源:</"), "空串绝不能渲染成空标签(连标签体都空)");
ok(has(noCandidateHtml, "未做召回"), "空串要解释「这一轮压根没算过相似度」,不能含糊成「—」");
// ── ⑤ 「尝试过召回但向量源不可用」:与上面两种都不同,走**错误**路径 ─────────
// 试算的严格口径下这种失败不回响应体(前端只能从 error.code 认出来),因此这里
// 必须单独给一句说明 —— 否则主人对着一句「试算失败」分不清是没候选还是向量挂了。
const recallDownHtml = render({
kind: "error",
message: "语义召回(embedding)不可用,试算未产出候选(试算不做静默降级)",
code: TIDY_RECALL_UNAVAILABLE,
});
ok(has(recallDownHtml, TIDY_RECALL_UNAVAILABLE_HINT), "向量源不可用时要给专门的说明");
ok(has(recallDownHtml, "不等于"), "必须与「没有候选可比」显式区分");
ok(has(recallDownHtml, "没有降级到词项"), "必须说清它也没有降级到词项余弦");
ok(!has(errorHtml, TIDY_RECALL_UNAVAILABLE_HINT), "别的错误不该套用这条说明(不许乱归因)");
// ── 未跑 / 在跑 ────────────────────────────────────────────────────────────
const idleHtml = render({ kind: "idle" });
ok(has(idleHtml, "试算整理建议"), "未跑时必须给出按钮文案");
ok(has(idleHtml, "23:00"), "未跑时要解释「整理器只在低峰时段真跑」");
ok(has(idleHtml, "不会自动"), "未跑时要说明这是按需试算、不自动刷新");
const runningHtml = render({ kind: "running" });
ok(has(runningHtml, "试算中…"), "在跑时按钮显示进行态");
ok(has(runningHtml, "disabled"), "在跑时按钮必须禁用(防止重复触发)");
// eslint-disable-next-line no-console
console.log(`MemoryTidyDryRunCard.render: ${checks} checks passed`);