Files
UHD_Media_Tool/apps/web/app/components/episode-mapping/decision.tsx
T
rootandClaude Opus 5.5 63e94b308e feat(identify-words): 识别词——全局识别词(MP 语法)+ 作品识别词(季集换算),规则页「识别词」页签
- 新表 identify_words(0094):全局一块 + 每部作品一块原文,带版本与审计;episode_mapping_rule 转成文本行后删表
- 全局识别词移植 MP WordsMatcher(屏蔽 / 替换 / 集数偏移 / 替换+偏移 / {[tmdbid=…]}),所有按标题 / 文件名识别的地方解析前套用,命中的行记在解析结果上;保存后后台按改动行重新识别
- 作品识别词:S02 >> S01 EP+24 / S01E13 >> S00E01 / S01 @组 >> 忽略,解析器 + 打印器 + 编译;发布组繁简视为同一个
- 去掉规则自动停用:算出来对不上季表只待确认,要停用就注释掉那一行
- 集数待确认的采用 / 忽略与智能体 apply_rule 改为往作品识别词追加一行(带来历注释)
- 编辑器可视化 / 文本两种模式改同一份原文,保存前试算;作品页「集数映射」用同一个编辑器

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
2026-10-08 17:03:00 +08:00

329 lines
13 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.
import type {
Decision,
DecisionOption,
EpisodeMappingPreview,
EpisodeResolutionView,
EpisodeResultBrief,
EpisodeSimulationSummary,
} from "@umt/contracts";
import { BellOff, Bot, Check, PencilLine } from "lucide-react";
import { useId, useState } from "react";
import { Link } from "react-router";
import { toast } from "sonner";
import { useResolveDecision } from "~/api/decisions.ts";
import { useEntityWords, useEpisodeMappingPreview } from "~/api/episode-mapping.ts";
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
import { consolePath } from "~/components/console/nav.ts";
import { Button } from "~/components/ui/button.tsx";
import { Switch } from "~/components/ui/switch.tsx";
import { errorMessage } from "~/lib/api.ts";
import { canonicalText, episodeCode, mappingHref, scopeText, sourceText } from "./model.ts";
// 作品页集数映射里的「集数待确认」(episode_mapping 内部事项):来源季集、原因、建议的那一行作品识别词与它的模拟结果;
// 操作「采用」(把这一行写进作品识别词并重算)、「修改」(把这一行放进编辑器草稿)、「忽略」(可同时写一行忽略)。
// 智能体建议(批次 5):后台调查没有写规则的授权时,把模拟通过的那一行和理由留在事项上,「采用智能体建议」写进去
// (服务端写之前按当前资源再模拟一次)。
export const EPISODE_CONFIRM_KEY = "confirm_suggestion";
export const EPISODE_CONFIRM_AGENT_KEY = "confirm_agent_suggestion";
export const EPISODE_IGNORE_RULE_KEY = "ignore_rule";
export interface AgentSuggestion {
line: string;
reasons: string[];
simulation: EpisodeSimulationSummary;
samples: { title: string; source: EpisodeResolutionView["source"]; after: EpisodeResultBrief }[];
}
interface Suggestion {
expression: string;
targetSeason: number;
samples: { source: { start: number; end: number }; target: { season: number; episode: number }; airDate: string }[];
}
export interface EpisodeDecisionView {
entityId: string | null;
season: number | null;
releaseGroup: string | null;
releases: { id: string; title: string; source: string }[];
count: number;
suggestion: Suggestion | null;
/** 采用建议规则时写进作品识别词的那一行 */
suggestionLine: string | null;
agentSuggestion: AgentSuggestion | null;
confirm: DecisionOption | null;
confirmAgent: DecisionOption | null;
ignoreRule: DecisionOption | null;
}
const UUID = /^[0-9a-f-]{36}$/;
/** 从事项证据里取界面要的字段(后端 writeEpisodeDecision 写入的结构) */
export function episodeDecisionView(d: Decision): EpisodeDecisionView {
const e = d.evidence as Record<string, unknown>;
const entity = typeof e.mediaEntityId === "string" && UUID.test(e.mediaEntityId) ? e.mediaEntityId : null;
const releases = Array.isArray(e.releases)
? (e.releases as { sourceReleaseId?: string; title?: string; source?: EpisodeResolutionView["source"] | null }[])
.filter((r) => r.sourceReleaseId)
.map((r) => ({
id: r.sourceReleaseId!,
title: r.title ?? "",
source: r.source ? sourceText({ source: r.source }) : "—",
}))
: [];
const s = e.suggestion as Suggestion | null | undefined;
const a = e.agentSuggestion as AgentSuggestion | null | undefined;
return {
entityId: entity ?? (d.subjectType === "media_entity" ? d.subjectId : null),
season: typeof e.season === "number" ? e.season : null,
releaseGroup: typeof e.releaseGroup === "string" ? e.releaseGroup : null,
releases,
count: typeof e.count === "number" ? e.count : releases.length,
suggestion: s && typeof s.expression === "string" ? s : null,
agentSuggestion:
typeof a?.line === "string" && a.simulation
? {
...a,
reasons: Array.isArray(a.reasons) ? a.reasons : [],
samples: Array.isArray(a.samples) ? a.samples : [],
}
: null,
suggestionLine: optionLine(d.options.find((o) => o.key === EPISODE_CONFIRM_KEY)),
confirm: d.options.find((o) => o.key === EPISODE_CONFIRM_KEY) ?? null,
confirmAgent: d.options.find((o) => o.key === EPISODE_CONFIRM_AGENT_KEY) ?? null,
ignoreRule: d.options.find((o) => o.key === EPISODE_IGNORE_RULE_KEY) ?? null,
};
}
const optionLine = (o: DecisionOption | undefined): string | null => {
const line = (o?.input as { line?: unknown } | undefined)?.line;
return typeof line === "string" ? line : null;
};
/** 建议规则套用到同季同组近期资源的结果一句话 */
export function simulationText(p: EpisodeMappingPreview | undefined): string | null {
if (!p) return null;
const total = p.items.length;
if (!total) return null;
const fail = p.items.filter((i) => i.validation === "fail").length;
return fail ? `套用后 ${total - fail} 条对得上,${fail} 条仍对不上` : `套用后 ${total} 条都对得上`;
}
/** 智能体建议的模拟结果一句话 */
export function agentSimulationText(s: EpisodeSimulationSummary): string {
const head =
s.resolved === s.samples ? `模拟:这 ${s.samples} 条都对得上` : `模拟:${s.samples} 条里 ${s.resolved} 条对得上`;
return `${head}${s.regressions ? `,会影响 ${s.regressions} 条已对上的资源` : ",不影响已对上的资源"}`;
}
/** 智能体建议:规则、理由、模拟结果与样本套用后的季集 */
export function AgentSuggestionBody({ s }: { s: AgentSuggestion }) {
return (
<div className="space-y-1">
<div className="font-mono break-all">{s.line}</div>
{s.reasons.length ? (
<ul className="list-disc space-y-0.5 pl-4 text-meta text-muted-foreground">
{s.reasons.map((r) => (
<li key={r}>{r}</li>
))}
</ul>
) : null}
<ul className="space-y-0.5 text-meta text-muted-foreground">
{s.samples.slice(0, 3).map((x) => (
<li key={x.title} className="font-mono tabular-nums">
{sourceText({ source: x.source })} → {canonicalText({ ...x.after, special: x.after.special ?? undefined })}
</li>
))}
</ul>
<div className="text-meta text-muted-foreground">{agentSimulationText(s.simulation)}</div>
</div>
);
}
/** 展示部分(纯渲染,单测直接渲染) */
export function EpisodeDecisionBody({ view, simulation }: { view: EpisodeDecisionView; simulation: string | null }) {
const shown = view.releases.slice(-5);
return (
<dl className="grid gap-1.5 text-caption sm:grid-cols-[5rem_1fr]">
<dt className="text-muted-foreground">范围</dt>
<dd className="text-foreground">{scopeText(view.season, view.releaseGroup)}</dd>
<dt className="text-muted-foreground">来源季集</dt>
<dd className="min-w-0 text-foreground">
<ul className="space-y-0.5">
{shown.map((r) => (
<li key={r.id} className="break-all">
<span className="font-mono tabular-nums">{r.source}</span>
<span className="ml-2 text-meta text-muted-foreground">{r.title}</span>
</li>
))}
{view.count > shown.length ? (
<li className="text-meta text-muted-foreground">另有 {view.count - shown.length} 条</li>
) : null}
</ul>
</dd>
{view.agentSuggestion ? (
<>
<dt className="text-muted-foreground">智能体建议</dt>
<dd className="min-w-0 text-foreground">
<AgentSuggestionBody s={view.agentSuggestion} />
</dd>
</>
) : null}
<dt className="text-muted-foreground">建议规则</dt>
<dd className="min-w-0 text-foreground">
{view.suggestion ? (
<>
<span className="font-mono break-all">
{view.suggestionLine ?? `${view.suggestion.expression} → 第 ${view.suggestion.targetSeason} 季`}
</span>
<ul className="mt-0.5 space-y-0.5 text-meta text-muted-foreground">
{view.suggestion.samples.slice(0, 3).map((s) => (
<li key={`${s.source.start}-${s.target.episode}`} className="font-mono tabular-nums">
E{String(s.source.start).padStart(2, "0")} →{" "}
{episodeCode(s.target.season, { start: s.target.episode, end: s.target.episode })}
(播出 {s.airDate})
</li>
))}
</ul>
{simulation ? <div className="mt-0.5 text-meta text-muted-foreground">{simulation}</div> : null}
</>
) : (
<span className="text-muted-foreground">样本不够推出唯一的规则,在上面的作品识别词里手动写</span>
)}
</dd>
</dl>
);
}
function IgnoreButton({ d, view }: { d: Decision; view: EpisodeDecisionView }) {
const resolve = useResolveDecision();
const id = useId();
const [withRule, setWithRule] = useState(false);
return (
<ConfirmDialog
trigger={
<Button variant="ghost" size="sm">
<BellOff />
忽略
</Button>
}
title="忽略这条事项?"
description="只关闭提醒,这些资源仍是集数待确认,不会下载和整理;同组出现新的待确认资源时会重新提出。"
confirmLabel="忽略"
onConfirm={() =>
resolve
.mutateAsync(
withRule && view.ignoreRule ? { id: d.id, optionKey: view.ignoreRule.key } : { id: d.id, dismiss: true },
)
.then(
() => toast.success(withRule ? "已忽略,并写了忽略规则" : "已忽略"),
(err) => {
toast.error(errorMessage(err, "忽略失败"));
throw err;
},
)
}
>
{view.ignoreRule ? (
<label htmlFor={id} className="flex items-center gap-2 text-caption text-foreground">
<Switch id={id} checked={withRule} onCheckedChange={setWithRule} />
同时写一行「{optionLine(view.ignoreRule) ?? "忽略"}」:这些资源以后不参与集数换算
</label>
) : null}
</ConfirmDialog>
);
}
/**
* 集数待确认事项的证据与操作。onEdit 给了(作品页集数映射里)时「修改」把这一行放进编辑器草稿,否则跳到作品页。
*/
export function EpisodeDecision({ d, open, onEdit }: { d: Decision; open: boolean; onEdit?: (line: string) => void }) {
const view = episodeDecisionView(d);
const resolve = useResolveDecision();
const sug = view.suggestion;
const line = view.suggestionLine;
const words = useEntityWords(open && line ? view.entityId : null);
const current = words.data?.block.text.replace(/\n*$/, "") ?? "";
const preview = useEpisodeMappingPreview(
view.entityId,
line
? {
season: view.season,
releaseGroup: view.releaseGroup,
words: current ? `${current}\n${line}\n` : `${line}\n`,
limit: 50,
}
: {},
open && !!line && words.isSuccess,
);
const editLine = line ?? optionLine(view.ignoreRule ?? undefined);
const modifyHref = view.entityId ? mappingHref(consolePath("library", view.entityId), editLine ?? undefined) : null;
return (
<div className="space-y-3">
<EpisodeDecisionBody view={view} simulation={sug ? simulationText(preview.data) : null} />
{open ? (
<div className="flex flex-wrap items-center gap-2">
{view.confirmAgent && view.agentSuggestion ? (
<ConfirmDialog
trigger={
<Button size="sm">
<Bot />
采用智能体建议
</Button>
}
title="采用智能体建议?"
description={`${view.confirmAgent.description ?? ""}。写进去之后可以在作品识别词里再改。`}
confirmLabel="写入并重算"
onConfirm={() =>
resolve.mutateAsync({ id: d.id, optionKey: view.confirmAgent!.key }).then(
() => toast.success("已写进作品识别词并重算"),
(err) => {
toast.error(errorMessage(err, "处理失败"));
throw err;
},
)
}
/>
) : null}
{view.confirm ? (
<ConfirmDialog
trigger={
<Button size="sm" variant={view.confirmAgent && view.agentSuggestion ? "outline" : "default"}>
<Check />
采用建议规则
</Button>
}
title="采用建议规则?"
description={`${view.confirm.description ?? ""}。写进去之后可以在作品识别词里再改。`}
confirmLabel="写入并重算"
onConfirm={() =>
resolve.mutateAsync({ id: d.id, optionKey: view.confirm!.key }).then(
() => toast.success("已写进作品识别词并重算"),
(err) => {
toast.error(errorMessage(err, "处理失败"));
throw err;
},
)
}
/>
) : null}
{onEdit && editLine ? (
<Button variant="outline" size="sm" onClick={() => onEdit(editLine)}>
<PencilLine />
修改
</Button>
) : modifyHref ? (
<Button asChild variant="outline" size="sm">
<Link to={modifyHref}>
<PencilLine />
修改
</Link>
</Button>
) : null}
<IgnoreButton d={d} view={view} />
</div>
) : null}
</div>
);
}