import { useState } from "react"; import { ApiError, adminMemoryTidyDryRun } from "../../lib/api"; import type { AdminTidyDryRunCandidate, AdminTidyDryRunResult } from "../../lib/types"; import { Button, Surface, Tag } from "../../components/primitives"; import { Notice } from "../components"; import { useAuditStore } from "../audit"; /** * 记忆整理「试算」卡(契约 v0.75 §4.12;切片 MT1)。 * * 它回答的是「**现在就想看到整理会建议合并哪些**」,而不必等到低峰时段那一轮: * 点击才跑(**不轮询** —— 试算是一次真实规则计算,不是看板指标),跑完把候选摊在 * 这里。后端保证**零副作用**(候选直接回在响应里,一行都不写),因此卡片的文案把 * 这件事明确写出来(`仅试算,不会改动任何记忆`)。 * * 三个态的呈现**刻意分开**,因为它们的产品含义不同: * - 有候选:逐条展示「哪些条 → 建议怎么处理 + 理由 + 分数区间 + diff 预览」; * - 空:**算完了但没有建议**(要说明这不是「没有重复」,而是被召回门/包含判定/ * 规则否决挡下了); * - 错误:**显示错误,绝不显示 0** —— 把失败画成「0 条候选」等于把「没算出来」 * 冒充成「算过了,没有」,正是本项目面板明令禁止的那一类。 * * 展示与取数分层:`MemoryTidyDryRunBody` 只吃状态(可用真实渲染测试直接喂 * fixture),卡组件负责取数 + 局部状态。 */ /** 诚实文案:整张卡的核心承诺(测试钉住它必须出现)。 */ export const TIDY_DRY_RUN_DISCLAIMER = "仅试算,不会改动任何记忆"; /** 错误态的说明:区分「没算出来」与「算过了,没有」。 */ export const TIDY_DRY_RUN_ERROR_HINT = "这是「没算出来」,不是「算过了、没有建议」"; /** 尝试按钮文案。 */ const RUN_LABEL = "试算整理建议"; /** 面板状态机(idle / running / done / error)。 */ export type TidyDryRunState = | { kind: "idle" } | { kind: "running" } | { kind: "done"; result: AdminTidyDryRunResult } | { kind: "error"; message: string; code?: string }; /** TIDY_RECALL_UNAVAILABLE 是「这一轮**尝试过**向量召回但向量源不可用」的错误码。 * * 它与「没有候选可比」(成功响应 + `recallMode:""`)和「降级到词项」(`lexical`) * 是三件不同的事,必须各有各的话。试算的严格口径下这种失败**不回响应体**,因此它 * 只走错误路径 —— 面板在这里把它说出来,而不是让主人对着一句「试算失败」猜。 */ export const TIDY_RECALL_UNAVAILABLE = "tidy_recall_unavailable"; /** TIDY_RECALL_UNAVAILABLE_HINT 是上面那个错误码的补充说明。 */ export const TIDY_RECALL_UNAVAILABLE_HINT = "这一轮尝试过向量召回、但向量源不可用(试算不做静默降级):它既不等于「没有候选可比」,也没有降级到词项余弦。请检查 embedding 服务后重试。"; const actionLabel: Record = { merge: "并入", link: "只建链接", archive: "归档建议", reclassify: "改分类建议", }; const actionTone: Record = { merge: "warn", link: "accent", archive: "neutral", reclassify: "neutral", }; /** ActionTag 渲染候选动作标签;未知动作原样显示(不吞信息)。 */ function ActionTag({ action }: { action: string }): React.ReactNode { return {actionLabel[action] ?? action}; } /** VoteReasonLine 渲染规则否决分布(它是「没有建议」时最有用的解释)。 */ function VetoReasonLine({ reasons }: { reasons: Record }): React.ReactNode { const entries = Object.entries(reasons).sort((a, b) => (a[0] < b[0] ? -1 : 1)); if (entries.length === 0) return null; return ( 否决分布(规则把哪些相似对挡在候选之外): {entries.map(([reason, count]) => ( {reason}={count} ))} ); } /** scoreLabel 渲染分数区间(当前实现一对一个分数,因此多数时候两端相等)。 */ function scoreLabel(candidate: AdminTidyDryRunCandidate): string { if (candidate.scoreMin === candidate.scoreMax) return candidate.scoreMin.toFixed(3); return `${candidate.scoreMin.toFixed(3)}–${candidate.scoreMax.toFixed(3)}`; } /** CandidateRow 渲染一条候选:动作 + 理由 + 分数 + id 去向 + diff 预览。 */ function CandidateRow({ candidate }: { candidate: AdminTidyDryRunCandidate }): React.ReactNode { const target = candidate.targetId ? ` → 保留 ${candidate.targetId}` : ""; return (
{scoreLabel(candidate)} {candidate.reason} {candidate.scope}
{candidate.sourceIds.join(", ")} {target}
{candidate.proposedContent ? (
被并入: {candidate.proposedContent}
) : null} {candidate.mergedContent ? (
存活条目: {candidate.mergedContent}
) : null}
); } /** RecallSourceTag 渲染「这一轮到底走的是向量还是词项」(契约 v0.91 的 `recallMode`)。 * * 它是 M6 诊断(`DevDocs/audit/去重失效诊断-2026-10-13.md`)的落点:旧实现按「有没 * 有配 embedder」写这个字段,于是**全量降级被静默报成 `embedding`**,面板据此判读会 * 把「向量不可用」读成「向量正常工作」。取值域与读法(三种态**必须分得清**): * * - `embedding` —— 语义向量召回真的用上了(好态); * - `lexical` —— 全部走零依赖词项余弦(进程没接 embedder ⇒ 降级,命中会更少); * - `mixed` —— **部分分区降级**、其余走向量(v0.91 新增); * - `""` —— **没有候选可比**:这一轮压根没算过相似度(不是降级,也不该显示成 * 一个空标签 —— 那会让人以为字段坏了)。 * * ⚠️ 「尝试过召回但向量源不可用」**不在这里**:试算的严格口径下那种情况直接报错 * (`error.code = tidy_recall_unavailable`),响应体根本不会出现;线上也没有 * `recall_unavailable_partitions` 这个键(它只存在于后端日志行)。因此本组件不许 * 用「—」把空串含糊掉 —— 每个取值都有一句可读的中文。 */ export function recallSourceLabel(mode: string): string { switch (mode) { case "embedding": return "召回来源:语义向量(embedding)"; case "lexical": return "召回来源:词项余弦(未接向量,已降级)"; case "mixed": return "召回来源:部分分区降级(其余走向量)"; case "": return "召回来源:无候选可比(本轮未做召回)"; default: return `召回来源:${mode}(未登记取值)`; } } /** recallSourceTone 给上面那句话配色调:好态 accent、降级 warn、无候选 neutral。 */ export function recallSourceTone(mode: string): "accent" | "warn" | "neutral" { switch (mode) { case "embedding": return "accent"; case "lexical": case "mixed": return "warn"; default: return "neutral"; } } /** ResultBody 渲染一次试算的完整结果(有候选 / 空 / 观测口径)。 */ function ResultBody({ result }: { result: AdminTidyDryRunResult }): React.ReactNode { const vetoReasons = result.vetoReasons ?? {}; return (
算完了,一行都没写 {recallSourceLabel(result.recallMode)} 扫描 {result.scanned} / 比对 {result.compared} / 召回 {result.pairs} 对 / 否决 {result.vetoed} 对 · 用时{" "} {result.durationMs} ms · 规则 {result.ruleVersion} · 分区 {result.partitions}
{result.truncated && result.truncated.length > 0 ? ( 本轮被配额截断:{result.truncated.join("、")} —— 这一轮看到的比库里少,调大 MEMORY_TIDY_SCAN_LIMIT /{" "} MEMORY_TIDY_CANDIDATE_LIMIT 后重试。 ) : null} {result.candidates.length === 0 ? (
这一轮没有产生任何候选。

这意味着「跑完了但没建议」,不等于「库里没有重复」:候选必须先过召回门(相似度只用来召回), 再通过包含判定(A 的事实集合 ⊆ B 才并入),最后还要逐条过规则否决(跨作用域 / 跨角色 / 跨项目 / 群聊来源 / 时效污染 / 数值不一致 / 否定冲突 / 低置信)。上面的否决分布就是「哪些相似对被挡下」。

) : (
建议 {result.candidates.length} 条(均未应用): {result.candidates.map((candidate, index) => ( ))}
)}
); } /** StateBody 渲染「未跑 / 在跑 / 失败 / 完成」四态中的非按钮部分。 */ function StateBody({ state }: { state: TidyDryRunState }): React.ReactNode { switch (state.kind) { case "idle": return ( 点一下就跑一轮「同样的整理规则」,把「它会建议合并 / 归档 / 建链接哪些」摊开给你看。整理器只在低峰 时段(默认 23:00–07:00)真跑,平时库里的候选队列是空的 —— 这是一次按需试算,不是看板,所以不会自动 刷新。 ); case "running": return 试算中…(只读计算,不会改动任何记忆); case "error": return (
试算失败:{state.message} {state.code === TIDY_RECALL_UNAVAILABLE ? ( {TIDY_RECALL_UNAVAILABLE_HINT} ) : null} {TIDY_DRY_RUN_ERROR_HINT}。这里刻意不显示候选数 —— 把失败画成「0 条」就是把「没算出来」冒充成结论。
); case "done": return ; } } /** MemoryTidyDryRunBody 是试算卡的纯展示体(测试直接喂状态)。 */ export function MemoryTidyDryRunBody({ state, onRun, }: { state: TidyDryRunState; onRun: () => void; }): React.ReactNode { return (

记忆整理试算

{TIDY_DRY_RUN_DISCLAIMER} POST /api/v1/admin/memories/tidy/dry-run
); } /** MemoryTidyDryRunCard 是记忆管理页挂的试算卡(局部状态 + 点击才跑)。 */ export function MemoryTidyDryRunCard(): React.ReactNode { const [state, setState] = useState({ kind: "idle" }); const log = useAuditStore((s) => s.log); const run = (): void => { setState({ kind: "running" }); adminMemoryTidyDryRun() .then((result) => { log("memory.tidy.dryRun", "ok"); setState({ kind: "done", result }); }) .catch((e: unknown) => { log("memory.tidy.dryRun", "error"); setState({ kind: "error", message: e instanceof Error ? e.message : "试算失败", code: e instanceof ApiError ? e.code : undefined, }); }); }; return ; }