- 新表 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
329 lines
13 KiB
TypeScript
329 lines
13 KiB
TypeScript
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>
|
||
);
|
||
}
|