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:
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 />
|
||||
|
||||
Reference in new issue
Block a user