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
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-08 17:03:00 +08:00
1 parent 12f8fc59c3
commit 63e94b308e
90 files changed
+6366 -2131

No files matched your search

@@ -2,7 +2,6 @@ import type {
Decision,
DecisionOption,
EpisodeMappingPreview,
EpisodeMappingRuleSpec,
EpisodeResolutionView,
EpisodeResultBrief,
EpisodeSimulationSummary,
@@ -12,25 +11,25 @@ import { useId, useState } from "react";
import { Link } from "react-router";
import { toast } from "sonner";
import { useResolveDecision } from "~/api/decisions.ts";
import { useEpisodeMappingPreview } from "~/api/episode-mapping.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, RULE_KIND_LABEL, ruleText, scopeText, sourceText } from "./model.ts";
import { canonicalText, episodeCode, mappingHref, scopeText, sourceText } from "./model.ts";
// 作品页集数映射里的「集数待确认」(episode_mapping 内部事项):来源季集、原因、建议规则与它的模拟结果;
// 操作「确认」(建建议规则并重算)、「修改」(跳作品页集数映射并预填草稿)、「忽略」(可同时建忽略规则)。
// 智能体建议(批次 5):后台调查没有写规则的授权时,把模拟通过的规则和理由留在事项上,「确认智能体建议」一键建规则
// (服务端建规则前按当前资源再模拟一次)。
// 作品页集数映射里的「集数待确认」(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 {
rule: EpisodeMappingRuleSpec;
line: string;
reasons: string[];
simulation: EpisodeSimulationSummary;
samples: { title: string; source: EpisodeResolutionView["source"]; after: EpisodeResultBrief }[];
@@ -49,6 +48,8 @@ export interface EpisodeDecisionView {
releases: { id: string; title: string; source: string }[];
count: number;
suggestion: Suggestion | null;
/** 采用建议规则时写进作品识别词的那一行 */
suggestionLine: string | null;
agentSuggestion: AgentSuggestion | null;
confirm: DecisionOption | null;
confirmAgent: DecisionOption | null;
@@ -80,19 +81,25 @@ export function episodeDecisionView(d: Decision): EpisodeDecisionView {
count: typeof e.count === "number" ? e.count : releases.length,
suggestion: s && typeof s.expression === "string" ? s : null,
agentSuggestion:
a?.rule && a.simulation
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;
@@ -113,11 +120,7 @@ export function agentSimulationText(s: EpisodeSimulationSummary): string {
export function AgentSuggestionBody({ s }: { s: AgentSuggestion }) {
return (
<div className="space-y-1">
<div>
<span className="text-muted-foreground">{RULE_KIND_LABEL[s.rule.kind]}:</span>
<span className="font-mono">{ruleText(s.rule)}</span>
<span className="ml-2 text-meta text-muted-foreground">{scopeText(s.rule.season, s.rule.releaseGroup)}</span>
</div>
<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) => (
@@ -170,7 +173,9 @@ export function EpisodeDecisionBody({ view, simulation }: { view: EpisodeDecisio
<dd className="min-w-0 text-foreground">
{view.suggestion ? (
<>
<span className="font-mono">{view.suggestion.expression}</span> → 第 {view.suggestion.targetSeason} 季
<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">
@@ -183,7 +188,7 @@ export function EpisodeDecisionBody({ view, simulation }: { view: EpisodeDecisio
{simulation ? <div className="mt-0.5 text-meta text-muted-foreground">{simulation}</div> : null}
</>
) : (
<span className="text-muted-foreground">样本不够推出唯一的规则,到作品页手动建</span>
<span className="text-muted-foreground">样本不够推出唯一的规则,在上面的作品识别词里手动写</span>
)}
</dd>
</dl>
@@ -211,7 +216,7 @@ function IgnoreButton({ d, view }: { d: Decision; view: EpisodeDecisionView }) {
withRule && view.ignoreRule ? { id: d.id, optionKey: view.ignoreRule.key } : { id: d.id, dismiss: true },
)
.then(
() => toast.success(withRule ? "已忽略,并建了忽略规则" : "已忽略"),
() => toast.success(withRule ? "已忽略,并写了忽略规则" : "已忽略"),
(err) => {
toast.error(errorMessage(err, "忽略失败"));
throw err;
@@ -222,43 +227,37 @@ function IgnoreButton({ d, view }: { d: Decision; view: EpisodeDecisionView }) {
{view.ignoreRule ? (
<label htmlFor={id} className="flex items-center gap-2 text-caption text-foreground">
<Switch id={id} checked={withRule} onCheckedChange={setWithRule} />
同时建「忽略」规则:{scopeText(view.season, view.releaseGroup)}的资源以后不参与集数解析
同时写一行「{optionLine(view.ignoreRule) ?? "忽略"}」:这些资源以后不参与集数换算
</label>
) : null}
</ConfirmDialog>
);
}
/** 集数待确认事项的证据与操作 */
export function EpisodeDecision({ d, open }: { d: Decision; open: boolean }) {
/**
* 集数待确认事项的证据与操作。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,
sug
line
? {
season: view.season,
releaseGroup: view.releaseGroup,
draft: {
season: view.season,
releaseGroup: view.releaseGroup,
kind: "expression",
expression: sug.expression,
targetSeason: sug.targetSeason,
},
words: current ? `${current}\n${line}\n` : `${line}\n`,
limit: 50,
}
: {},
open && !!sug,
open && !!line && words.isSuccess,
);
const modifyHref = view.entityId
? mappingHref(consolePath("library", view.entityId), {
season: view.season,
releaseGroup: view.releaseGroup,
...(sug ? { expression: sug.expression, targetSeason: sug.targetSeason } : {}),
})
: null;
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} />
@@ -272,12 +271,12 @@ export function EpisodeDecision({ d, open }: { d: Decision; open: boolean }) {
采用智能体建议
</Button>
}
title="按智能体建议建规则?"
description={`${view.confirmAgent.description ?? ""}。规则在作品页的集数映射里可以再改。`}
confirmLabel="建规则并重算"
title="采用智能体建议?"
description={`${view.confirmAgent.description ?? ""}。写进去之后可以在作品识别词里再改。`}
confirmLabel="写入并重算"
onConfirm={() =>
resolve.mutateAsync({ id: d.id, optionKey: view.confirmAgent!.key }).then(
() => toast.success("已建规则并重算"),
() => toast.success("已写进作品识别词并重算"),
(err) => {
toast.error(errorMessage(err, "处理失败"));
throw err;
@@ -294,12 +293,12 @@ export function EpisodeDecision({ d, open }: { d: Decision; open: boolean }) {
采用建议规则
</Button>
}
title="按建议建规则?"
description={`${view.confirm.description ?? ""}。规则在作品页的集数映射里可以再改。`}
confirmLabel="建规则并重算"
title="采用建议规则?"
description={`${view.confirm.description ?? ""}。写进去之后可以在作品识别词里再改。`}
confirmLabel="写入并重算"
onConfirm={() =>
resolve.mutateAsync({ id: d.id, optionKey: view.confirm!.key }).then(
() => toast.success("已建规则并重算"),
() => toast.success("已写进作品识别词并重算"),
(err) => {
toast.error(errorMessage(err, "处理失败"));
throw err;
@@ -308,7 +307,12 @@ export function EpisodeDecision({ d, open }: { d: Decision; open: boolean }) {
}
/>
) : null}
{modifyHref ? (
{onEdit && editLine ? (
<Button variant="outline" size="sm" onClick={() => onEdit(editLine)}>
<PencilLine />
修改
</Button>
) : modifyHref ? (
<Button asChild variant="outline" size="sm">
<Link to={modifyHref}>
<PencilLine />