主人 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 通过。
294 lines
14 KiB
TypeScript
294 lines
14 KiB
TypeScript
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<string, string> = {
|
||
merge: "并入",
|
||
link: "只建链接",
|
||
archive: "归档建议",
|
||
reclassify: "改分类建议",
|
||
};
|
||
|
||
const actionTone: Record<string, "ok" | "warn" | "accent" | "neutral"> = {
|
||
merge: "warn",
|
||
link: "accent",
|
||
archive: "neutral",
|
||
reclassify: "neutral",
|
||
};
|
||
|
||
/** ActionTag 渲染候选动作标签;未知动作原样显示(不吞信息)。 */
|
||
function ActionTag({ action }: { action: string }): React.ReactNode {
|
||
return <Tag tone={actionTone[action] ?? "neutral"}>{actionLabel[action] ?? action}</Tag>;
|
||
}
|
||
|
||
/** VoteReasonLine 渲染规则否决分布(它是「没有建议」时最有用的解释)。 */
|
||
function VetoReasonLine({ reasons }: { reasons: Record<string, number> }): React.ReactNode {
|
||
const entries = Object.entries(reasons).sort((a, b) => (a[0] < b[0] ? -1 : 1));
|
||
if (entries.length === 0) return null;
|
||
return (
|
||
<span className="text-[11px] text-[var(--mu-text-mid)]">
|
||
否决分布(规则把哪些相似对挡在候选之外):
|
||
{entries.map(([reason, count]) => (
|
||
<span key={reason} className="ml-2 font-mono">
|
||
{reason}={count}
|
||
</span>
|
||
))}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
/** 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 (
|
||
<div className="space-y-1 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2">
|
||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||
<ActionTag action={candidate.action} />
|
||
<span className="font-mono text-[var(--mu-text-lo)]">{scoreLabel(candidate)}</span>
|
||
<span className="font-mono text-[11px] text-[var(--mu-text-lo)]">{candidate.reason}</span>
|
||
<Tag tone="neutral">{candidate.scope}</Tag>
|
||
</div>
|
||
<div className="font-mono text-[11px] text-[var(--mu-text-mid)]">
|
||
{candidate.sourceIds.join(", ")}
|
||
{target}
|
||
</div>
|
||
{candidate.proposedContent ? (
|
||
<div className="text-xs text-[var(--mu-text-mid)]">
|
||
<span className="text-[var(--mu-text-lo)]">被并入:</span>
|
||
{candidate.proposedContent}
|
||
</div>
|
||
) : null}
|
||
{candidate.mergedContent ? (
|
||
<div className="text-xs text-[var(--mu-text-mid)]">
|
||
<span className="text-[var(--mu-text-lo)]">存活条目:</span>
|
||
{candidate.mergedContent}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** 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 (
|
||
<div className="space-y-2">
|
||
<div className="flex flex-wrap items-center gap-3">
|
||
<Tag tone="ok">算完了,一行都没写</Tag>
|
||
<Tag tone={recallSourceTone(result.recallMode)}>{recallSourceLabel(result.recallMode)}</Tag>
|
||
<span className="text-[11px] text-[var(--mu-text-lo)]">
|
||
扫描 {result.scanned} / 比对 {result.compared} / 召回 {result.pairs} 对 / 否决 {result.vetoed} 对 · 用时{" "}
|
||
{result.durationMs} ms · 规则 {result.ruleVersion} · 分区 {result.partitions}
|
||
</span>
|
||
</div>
|
||
{result.truncated && result.truncated.length > 0 ? (
|
||
<Notice tone="warn">
|
||
本轮被配额截断:{result.truncated.join("、")} —— 这一轮看到的比库里少,调大
|
||
<span className="font-mono"> MEMORY_TIDY_SCAN_LIMIT</span> /{" "}
|
||
<span className="font-mono">MEMORY_TIDY_CANDIDATE_LIMIT</span> 后重试。
|
||
</Notice>
|
||
) : null}
|
||
<VetoReasonLine reasons={vetoReasons} />
|
||
{result.candidates.length === 0 ? (
|
||
<div className="space-y-1 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2">
|
||
<span className="text-xs text-[var(--mu-text-hi)]">这一轮没有产生任何候选。</span>
|
||
<p className="text-[11px] text-[var(--mu-text-lo)]">
|
||
这意味着「跑完了但没建议」,不等于「库里没有重复」:候选必须先过召回门(相似度只用来召回),
|
||
再通过包含判定(A 的事实集合 ⊆ B 才并入),最后还要逐条过规则否决(跨作用域 / 跨角色 / 跨项目 /
|
||
群聊来源 / 时效污染 / 数值不一致 / 否定冲突 / 低置信)。上面的否决分布就是「哪些相似对被挡下」。
|
||
</p>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-1.5">
|
||
<span className="text-xs text-[var(--mu-text-mid)]">建议 {result.candidates.length} 条(均未应用):</span>
|
||
{result.candidates.map((candidate, index) => (
|
||
<CandidateRow
|
||
key={`${candidate.action}-${candidate.sourceIds.join(",")}-${index}`}
|
||
candidate={candidate}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** StateBody 渲染「未跑 / 在跑 / 失败 / 完成」四态中的非按钮部分。 */
|
||
function StateBody({ state }: { state: TidyDryRunState }): React.ReactNode {
|
||
switch (state.kind) {
|
||
case "idle":
|
||
return (
|
||
<span className="text-xs text-[var(--mu-text-lo)]">
|
||
点一下就跑一轮「同样的整理规则」,把「它会建议合并 / 归档 / 建链接哪些」摊开给你看。整理器只在低峰
|
||
时段(默认 23:00–07:00)真跑,平时库里的候选队列是空的 —— 这是一次按需试算,不是看板,所以不会自动
|
||
刷新。
|
||
</span>
|
||
);
|
||
case "running":
|
||
return <span className="text-xs text-[var(--mu-text-lo)]">试算中…(只读计算,不会改动任何记忆)</span>;
|
||
case "error":
|
||
return (
|
||
<div className="space-y-1">
|
||
<Notice tone="err">试算失败:{state.message}</Notice>
|
||
{state.code === TIDY_RECALL_UNAVAILABLE ? (
|
||
<span className="text-[11px] text-[var(--mu-text-mid)]">{TIDY_RECALL_UNAVAILABLE_HINT}</span>
|
||
) : null}
|
||
<span className="text-[11px] text-[var(--mu-text-lo)]">
|
||
{TIDY_DRY_RUN_ERROR_HINT}。这里刻意不显示候选数 —— 把失败画成「0 条」就是把「没算出来」冒充成结论。
|
||
</span>
|
||
</div>
|
||
);
|
||
case "done":
|
||
return <ResultBody result={state.result} />;
|
||
}
|
||
}
|
||
|
||
/** MemoryTidyDryRunBody 是试算卡的纯展示体(测试直接喂状态)。 */
|
||
export function MemoryTidyDryRunBody({
|
||
state,
|
||
onRun,
|
||
}: {
|
||
state: TidyDryRunState;
|
||
onRun: () => void;
|
||
}): React.ReactNode {
|
||
return (
|
||
<Surface className="space-y-3">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<h3 className="text-sm font-semibold">记忆整理试算</h3>
|
||
<Tag tone="accent">{TIDY_DRY_RUN_DISCLAIMER}</Tag>
|
||
<span className="text-xs text-[var(--mu-text-lo)]">POST /api/v1/admin/memories/tidy/dry-run</span>
|
||
<span className="ml-auto">
|
||
<Button variant="primary" disabled={state.kind === "running"} onClick={onRun}>
|
||
{state.kind === "running" ? "试算中…" : RUN_LABEL}
|
||
</Button>
|
||
</span>
|
||
</div>
|
||
<StateBody state={state} />
|
||
</Surface>
|
||
);
|
||
}
|
||
|
||
/** MemoryTidyDryRunCard 是记忆管理页挂的试算卡(局部状态 + 点击才跑)。 */
|
||
export function MemoryTidyDryRunCard(): React.ReactNode {
|
||
const [state, setState] = useState<TidyDryRunState>({ 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 <MemoryTidyDryRunBody state={state} onRun={run} />;
|
||
}
|