feat(memory): 记忆整理「试算(dry-run)」入口 + 面板卡(契约 v0.75)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

主人要「现在就想看到在 736 条上它会建议合并哪些」,而不是等 23:00 的低峰首跑。
- 后端:Tidier.DryRun + TidyService.DryRun,**复用同一条扫描→召回→判定→构造候选路径**(抽出 tidyRoundPolicy,生产轮与试算轮只差「候选出口」与「召回降级策略」两处;禁止第二套实现)
- 🔴 零副作用:不写 memory_tidy_candidates / memory_tidy_runs,绝不改 memory_items 任何列;机械证明 = 跑前跑后 cyrene.db 与 -wal 的 SHA256 一致(测试断言「表行数不变」)
- 失败码诚实:500 tidy_unavailable / 409 tidy_disabled / 500 tidy_recall_unavailable(**embedding 不可用不降级成 0 条**)/ 503 tidy_timeout / 500 tidy_dry_run_failed;**空数组唯一含义 = 算过了没建议**
- 端点 POST /api/v1/admin/memories/tidy/dry-run(writeAuth);面板「记忆」页加试算卡(点了才跑、无轮询、三态分开、明写「仅试算,不会改动任何记忆」)
- 真机 e2e(741 条活库副本):76 ms / 15 分区 / 53 候选 / 41 skipped / 1 vetoed(low_confidence);样例 merge reason=fact_subset score=1.0000、link reason=similar_but_not_contained score=0.9248
- 变异:dry-run 顺手写库 ⇒ 两条测试红(行数 0→2);自造阈值 ⇒ 红;失败回 200 空数组 ⇒ 红
- 如实登记:Compared=688 被 400/分区护栏截断、候选上限 20/分区被打满 ⇒ 护栏值需在 741 条量级重定(非 bug)
This commit is contained in:
AskaEth committed 2026-10-05 22:06:54 +08:00
1 parent 3d8c30dc99
commit d6ed9cfa2b
18 files changed
+1631 -79

No files matched your search

@@ -0,0 +1,138 @@
// 记忆整理「试算」卡的**真实渲染**测试(契约 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,
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 条」");
// ── 未跑 / 在跑 ────────────────────────────────────────────────────────────
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`);