feat(agent): 集数待确认的智能体兜底——查看上下文 / 模拟 / 写规则三个能力,写入前强制模拟,接后台调查与授权内恢复

能力 ID 的域名允许单个下划线分词(episode_mapping)。

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-03 20:08:20 +08:00
1 parent 45a114263b
commit 7793c7b3bc
27 files changed
+1747 -48

No files matched your search

@@ -2,7 +2,7 @@ import type { Decision, EpisodeMappingPreview } from "@umt/contracts";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { EpisodeDecisionBody, episodeDecisionView, simulationText } from "./decision.tsx";
import { agentSimulationText, EpisodeDecisionBody, episodeDecisionView, simulationText } from "./decision.tsx";
const ENTITY = "7d3f7f5e-8a8c-4b8e-9f0a-1b2c3d4e5f60";
const decision = (over: Partial<Decision> = {}): Decision =>
@@ -94,4 +94,60 @@ describe("待处理「集数待确认」", () => {
expect(simulationText(p(["pass", "fail"]))).toBe("套用后 1 条对得上,1 条仍对不上");
expect(simulationText(p([]))).toBeNull();
});
const agentSuggestion = {
rule: { season: 1, releaseGroup: "Lilith-Raws", kind: "expression", expression: "EP-30", targetSeason: 2 },
reasons: ["40、41 的发布时间对上第 2 季第 10、11 集的播出日期"],
simulation: { samples: 2, resolved: 2, passed: 2, missing: 0, regressions: 0, changed: 2, scanned: 5, ok: true },
samples: [
{
title: "[Lilith-Raws] X - 40",
source: { season: 1, episodes: { start: 40, end: 40 } },
after: { method: "rule", canonical: { season: 2, episodes: { start: 10, end: 10 } }, special: null },
},
],
runId: null,
at: "2026-10-03T00:00:00Z",
};
const withAgent = () =>
decision({
evidence: { ...decision().evidence, agentSuggestion },
options: [
{
key: "confirm_agent_suggestion",
label: "确认智能体建议",
description: "按 EP-30 换算到第 2 季,建规则前按当前资源再模拟一次,通过才写入并重算",
},
...decision().options,
],
});
it("智能体建议:取规则、理由、模拟结果与「确认智能体建议」", () => {
const v = episodeDecisionView(withAgent());
expect(v.agentSuggestion?.rule.expression).toBe("EP-30");
expect(v.agentSuggestion?.reasons).toHaveLength(1);
expect(v.confirmAgent?.key).toBe("confirm_agent_suggestion");
expect(v.confirm?.key).toBe("confirm_suggestion");
// 没有建议的事项不出这一块
expect(episodeDecisionView(decision())).toMatchObject({ agentSuggestion: null, confirmAgent: null });
});
it("渲染智能体建议:规则、作用域、理由、样本套用后的季集与模拟结果", () => {
const html = renderToStaticMarkup(
createElement(EpisodeDecisionBody, { view: episodeDecisionView(withAgent()), simulation: null }),
);
expect(html).toContain("智能体建议");
expect(html).toContain("偏移表达式");
expect(html).toContain("EP-30 → 第 2 季");
expect(html).toContain("对上第 2 季第 10、11 集的播出日期");
expect(html).toContain("S01E40 → S02E10");
expect(html).toContain("模拟:这 2 条都对得上,不影响已对上的资源");
});
it("智能体建议的模拟结果一句话", () => {
const s = agentSuggestion.simulation;
expect(agentSimulationText({ ...s, resolved: 1, regressions: 2 })).toBe(
"模拟:2 条里 1 条对得上,会影响 2 条已对上的资源",
);
});
});
@@ -1,5 +1,13 @@
import type { Decision, DecisionOption, EpisodeMappingPreview, EpisodeResolutionView } from "@umt/contracts";
import { BellOff, Check, PencilLine } from "lucide-react";
import type {
Decision,
DecisionOption,
EpisodeMappingPreview,
EpisodeMappingRuleSpec,
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";
@@ -10,14 +18,24 @@ 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 { episodeCode, mappingHref, scopeText, sourceText } from "./model.ts";
import { canonicalText, episodeCode, mappingHref, RULE_KIND_LABEL, ruleText, 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 {
rule: EpisodeMappingRuleSpec;
reasons: string[];
simulation: EpisodeSimulationSummary;
samples: { title: string; source: EpisodeResolutionView["source"]; after: EpisodeResultBrief }[];
}
interface Suggestion {
expression: string;
targetSeason: number;
@@ -31,7 +49,9 @@ export interface EpisodeDecisionView {
releases: { id: string; title: string; source: string }[];
count: number;
suggestion: Suggestion | null;
agentSuggestion: AgentSuggestion | null;
confirm: DecisionOption | null;
confirmAgent: DecisionOption | null;
ignoreRule: DecisionOption | null;
}
@@ -51,6 +71,7 @@ export function episodeDecisionView(d: Decision): EpisodeDecisionView {
}))
: [];
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,
@@ -58,7 +79,16 @@ export function episodeDecisionView(d: Decision): EpisodeDecisionView {
releases,
count: typeof e.count === "number" ? e.count : releases.length,
suggestion: s && typeof s.expression === "string" ? s : null,
agentSuggestion:
a?.rule && a.simulation
? {
...a,
reasons: Array.isArray(a.reasons) ? a.reasons : [],
samples: Array.isArray(a.samples) ? a.samples : [],
}
: null,
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,
};
}
@@ -72,6 +102,41 @@ export function simulationText(p: EpisodeMappingPreview | undefined): string | n
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>
<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>
{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);
@@ -93,6 +158,14 @@ export function EpisodeDecisionBody({ view, simulation }: { view: EpisodeDecisio
) : 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 ? (
@@ -191,10 +264,32 @@ export function EpisodeDecision({ d, open }: { d: Decision; open: boolean }) {
<EpisodeDecisionBody view={view} simulation={sug ? simulationText(preview.data) : null} />
{open ? (
<div className="flex flex-wrap items-center gap-2">
{view.confirm ? (
{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>
@@ -23,6 +23,7 @@ import { Textarea } from "~/components/ui/textarea.tsx";
import { errorMessage } from "~/lib/api.ts";
import { actorText } from "~/lib/labels.ts";
import {
agentRuleInfo,
disabledReasonText,
draftFromSearch,
draftOfRule,
@@ -81,6 +82,7 @@ function RuleRow({
write: ReturnType<typeof useEpisodeMappingWrite>;
}) {
const reason = disabledReasonText(rule);
const agent = agentRuleInfo(rule);
const run = (vars: Parameters<typeof write.mutateAsync>[0], verb: string) =>
write.mutateAsync(vars).then(
(r) => toast.success(reresolveToast(r.reresolve, verb)),
@@ -97,13 +99,21 @@ function RuleRow({
<StatusBadge tone={rule.status === "active" ? "success" : "neutral"}>
{rule.status === "active" ? "启用" : "停用"}
</StatusBadge>
{agent ? <Chip>{agent.label}</Chip> : null}
{editing ? <span className="text-meta text-muted-foreground">修改中</span> : null}
</div>
<div className="mt-0.5 text-meta text-muted-foreground">
{RULE_KIND_LABEL[rule.kind]}:<span className="font-mono text-foreground">{ruleText(rule)}</span>
{" · "}
{actorText(rule.createdById ? `${rule.createdByType}:${rule.createdById}` : rule.createdByType)}
{agent ? null : (
<>
{" · "}
{actorText(rule.createdById ? `${rule.createdByType}:${rule.createdById}` : rule.createdByType)}
</>
)}
</div>
{agent?.reasons.length ? (
<div className="mt-0.5 text-meta text-muted-foreground">依据:{agent.reasons.join(";")}</div>
) : null}
{reason ? <div className="mt-0.5 text-meta text-muted-foreground">{reason}</div> : null}
</div>
<div className="flex flex-wrap items-center gap-1.5">
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
import { EXCLUDE_REASON } from "../organize-wizard/model.ts";
import {
absoluteGroups,
agentRuleInfo,
canonicalText,
disabledReasonText,
draftFromSearch,
@@ -167,3 +168,16 @@ describe("应用后的提示", () => {
expect(reresolveToast({ releases: 3, changed: 0, resolved: 0 }, "已停用")).toBe("已停用:重算 3 条资源");
});
});
describe("智能体写的规则", () => {
it("标出智能体来源与证据等级,带写规则时的理由;其它来源不标", () => {
const rule = (over: Partial<EpisodeMappingRuleRow>) =>
({ createdByType: "agent", evidenceLevel: "multi_evidence", evidence: {}, ...over }) as EpisodeMappingRuleRow;
expect(agentRuleInfo(rule({ evidence: { reasons: ["发布时间对上播出日期", 3] } }))).toEqual({
label: "智能体 · 多证据",
reasons: ["发布时间对上播出日期"],
});
expect(agentRuleInfo(rule({ evidenceLevel: "needs_review" }))?.label).toBe("智能体 · 待复核");
expect(agentRuleInfo(rule({ createdByType: "user", evidenceLevel: "manual" }))).toBeNull();
});
});
@@ -10,6 +10,7 @@ import type {
EpisodeValidationState,
} from "@umt/contracts";
import type { StatusTone } from "~/components/console/status-badge.tsx";
import { EVIDENCE_LEVEL } from "~/components/library/labels.ts";
// 集数映射的界面文案与纯函数(docs/plan/episode-resolution.md 批次 4):方式名称、季集写法、规则摘要、
// 表格规则的文本格式、作品页「集数映射」分区的地址。
@@ -104,6 +105,18 @@ export function ruleText(
return r.special ? `映射为${SPECIAL_LABEL[r.special]}` : "未选去向";
}
/** 智能体写的规则:来源与证据等级(「智能体 · 多证据」)、写规则时的理由;其它来源返回 null */
export function agentRuleInfo(
rule: Pick<EpisodeMappingRuleRow, "createdByType" | "evidenceLevel" | "evidence">,
): { label: string; reasons: string[] } | null {
if (rule.createdByType !== "agent") return null;
const raw = (rule.evidence as { reasons?: unknown }).reasons;
return {
label: `智能体 · ${EVIDENCE_LEVEL[rule.evidenceLevel] ?? rule.evidenceLevel}`,
reasons: Array.isArray(raw) ? raw.filter((x): x is string => typeof x === "string") : [],
};
}
/** 停用原因:手动停用 / 规则失效(带当时的来源季集) */
export function disabledReasonText(rule: Pick<EpisodeMappingRuleRow, "status" | "disabledReason">): string | null {
if (rule.status !== "disabled") return null;