主人 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 通过。
177 lines
9.6 KiB
TypeScript
177 lines
9.6 KiB
TypeScript
// 记忆整理「试算」卡的**真实渲染**测试(契约 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`);
|