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