diff --git a/apps/server/src/modules/agent/authorize.ts b/apps/server/src/modules/agent/authorize.ts index 63b2376c..9e690a71 100644 --- a/apps/server/src/modules/agent/authorize.ts +++ b/apps/server/src/modules/agent/authorize.ts @@ -23,6 +23,8 @@ export const GRANT_ACTIONS: Record = { "task.retry": ["task.retry", "task.*"], "task.cancel": ["task.cancel", "task.*"], "policy.apply": ["policy.apply"], + // 集数映射兜底(批次 5):只认专门的持续授权,不随身份识别策略放开 + "episode_mapping.apply_rule": ["episode_mapping.apply_rule"], // VIS-1:坐标操作与视觉子流程和 browser.interact 同一档(操作原站页面),同一条持续授权覆盖 "browser.click_at": ["browser.click_at", "browser.interact"], "browser.type_at": ["browser.type_at", "browser.interact"], @@ -260,6 +262,10 @@ export function createAuthorizer(deps: { } } + // 集数映射:持续授权的对象范围按事项的作品核对 + if (d.id === "episode_mapping.apply_rule" && typeof inp.decisionId === "string") + target = await episodeTarget(db, inp.decisionId); + // 2) 持续授权 const covered = await coveringGrant(db, d.id, target, now()); if ("grantId" in covered) return { allowed: true, grantId: covered.grantId }; @@ -283,6 +289,27 @@ export function createAuthorizer(deps: { }; } +/** 集数待确认事项的作品(持续授权的作品 / 类型范围用) */ +async function episodeTarget(db: Kysely, decisionId: string): Promise { + if (!isUuid(decisionId)) return null; + const r = await db + .selectFrom("decision as d") + .leftJoin("media_entity as e", (j) => + j.onRef( + "e.id", + "=", + sql`case when d.evidence->>'mediaEntityId' ~ '^[0-9a-f-]{36}$' + then (d.evidence->>'mediaEntityId')::uuid end`, + ), + ) + .select(["e.id as entity_id", "e.kind"]) + .where("d.id", "=", decisionId) + .executeTakeFirst(); + return r?.entity_id + ? { payloadHash: "", revision: 0, mediaEntityId: r.entity_id, mediaKind: r.kind, bytes: null } + : null; +} + const isUuid = (s: string) => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s); /** 策略范围用订阅口径(movie / series / variety),作品用 media_entity.kind(movie / tv / variety …) */ diff --git a/apps/server/src/modules/agent/fake-scripts.ts b/apps/server/src/modules/agent/fake-scripts.ts index 3a065ed8..31436ff6 100644 --- a/apps/server/src/modules/agent/fake-scripts.ts +++ b/apps/server/src/modules/agent/fake-scripts.ts @@ -20,7 +20,7 @@ const JsonScript = z.object({ }); const JsonScripts = z.union([JsonScript, z.array(JsonScript)]); -const TEMPLATE = /^\{\{\s*([a-z][a-z0-9]*\.[a-z][a-z0-9_]*)((?:\.[A-Za-z0-9_]+)*)\s*\}\}$/; +const TEMPLATE = /^\{\{\s*([a-z][a-z0-9]*(?:_[a-z0-9]+)*\.[a-z][a-z0-9_]*)((?:\.[A-Za-z0-9_]+)*)\s*\}\}$/; function lookup(results: FakeToolResult[], capability: string, path: string[]): unknown { const hit = [...results].reverse().find((r) => r.capabilityId === capability && !r.isError); diff --git a/apps/server/src/modules/agent/investigate.ts b/apps/server/src/modules/agent/investigate.ts index 19f76224..02c44ce5 100644 --- a/apps/server/src/modules/agent/investigate.ts +++ b/apps/server/src/modules/agent/investigate.ts @@ -10,10 +10,11 @@ import { startRun } from "./service.ts"; // recover 需要有覆盖恢复动作的有效持续授权(策略版本须为 published),否则降级为只读调查并在事项上留建议; // 同一 decision 只一个 run(dedupe_key=investigate:),同一根因(decision 去重键)的恢复次数有上限。 // runAgent 结束 → recoveryActions / checkRecovery / applyRecoveryOutcome: -// 按业务事实(download_task / task / source_release 状态)核实恢复结果,核实成功才关闭事项; +// 按业务事实(download_task / task / source_release 状态;集数映射看事项内资源是否都已不是待确认)核实恢复结果, +// 核实成功才关闭事项; // 失败 / 超时 / 预算超限一律保持待处理(finalize 把事项转为 awaiting_user),不自动再试。 -const KINDS = ["identity_ambiguous", "download_failed", "download_stalled"] as const; +const KINDS = ["identity_ambiguous", "download_failed", "download_stalled", "episode_mapping"] as const; export const AUTO_ACTOR = "system:auto-investigate"; export const RECOVER_ACTOR = "system:auto-recover"; @@ -22,6 +23,7 @@ const ACTIONS_FOR: Record = { download_failed: ["download.recheck", "task.retry"], download_stalled: ["download.recheck", "task.retry"], identity_ambiguous: ["identity.resolve"], + episode_mapping: ["episode_mapping.apply_rule"], }; /** 下载任务处于这些状态即视为已恢复(qB 重新开始下载或已经完成) */ @@ -128,6 +130,8 @@ async function relatedTask(ctx: ServerContext, d: DecisionRow) { function suggestionFor(d: DecisionRow, reason: string): string { if (d.kind === "identity_ambiguous") return `${reason}。可在待处理中手动选择身份,或为 identity.resolve 发放持续授权。`; + if (d.kind === "episode_mapping") + return `${reason}。智能体模拟通过的规则会作为建议留在事项上,可在待处理中确认;或为 episode_mapping.apply_rule 发放持续授权。`; return ( `${reason}。可在待处理中选择"强制重新校验"(download.recheck),或为 download.recheck / task.retry 发放持续授权;` + `换源、删除需人工决定。` @@ -227,16 +231,25 @@ export async function autoInvestigate( .executeTakeFirst() : undefined; const taskId = await relatedTask(ctx, d); + const ep = d.kind === "episode_mapping" ? ((d.evidence ?? {}) as Record) : null; const goal = [ `调查待处理事项 ${d.id}(${d.kind}):${d.title}。`, d.summary ? `摘要:${d.summary}。` : "", d.subject_type && d.subject_id ? `对象:${d.subject_type} ${d.subject_id}。` : "", dt ? `下载任务 ${dt.id}「${dt.name ?? ""}」当前 ${dt.status}(qB 状态 ${dt.raw_state ?? "未知"})。` : "", taskId ? `关联任务 ${taskId}。` : "", - mode === "recover" - ? "在授权范围内做有界恢复:先用 task.inspect 等只读能力查证,再按需调用 download.recheck(强制重新校验 / 恢复暂停)、" + - "task.retry(重试失败任务)或 identity.resolve,每种最多一次;不换源、不删除、不清理;恢复结果由系统核实。" - : "只做只读调查:查证据,给出结论与处理建议;不提交下载,不改文件。", + ep + ? `作品 ${String(ep.mediaEntityId ?? d.subject_id ?? "")},来源季 ${ep.season ?? "未知"},发布组 ${ep.releaseGroup ?? "无"}。` + + (mode === "recover" + ? "先用 episode_mapping.inspect(decisionId)看上下文,再用 episode_mapping.simulate 试草稿规则;" + + "模拟里事项样本全部解决、且没有让已对上的资源变成待确认时,才用 episode_mapping.apply_rule 写一次规则(带理由);" + + "拿不准就不写,只把模拟通过的规则留作建议。不逐集处理,是否解决由系统核实。" + : "只做只读调查:用 episode_mapping.inspect(decisionId)看上下文,用 episode_mapping.simulate(带 decisionId 和理由)试草稿规则;" + + "模拟通过的规则会作为智能体建议留在事项上,由用户确认;不写规则,拿不准就只说明原因。") + : mode === "recover" + ? "在授权范围内做有界恢复:先用 task.inspect 等只读能力查证,再按需调用 download.recheck(强制重新校验 / 恢复暂停)、" + + "task.retry(重试失败任务)或 identity.resolve,每种最多一次;不换源、不删除、不清理;恢复结果由系统核实。" + : "只做只读调查:查证据,给出结论与处理建议;不提交下载,不改文件。", ].join(""); const { run, created } = await startRun(ctx, { goal, @@ -343,6 +356,23 @@ export async function checkRecovery(ctx: ServerContext, actions: RecoveryAction[ : undefined; facts[`source_release:${id}`] = r ? { resolutionStatus: r.resolution_status } : null; states.push(r?.resolution_status === "resolved" && r.media_entity_id ? "recovered" : "pending"); + } else if (a.capabilityId === "episode_mapping.apply_rule") { + // 事项内资源都已不是待确认(规则写入后的重算会把事项自动关闭)即视为恢复 + const id = String(a.input.decisionId ?? ""); + const d = id + ? await ctx.db.selectFrom("decision").select(["status", "evidence"]).where("id", "=", id).executeTakeFirst() + : undefined; + const ids = ((d?.evidence ?? {}) as { sourceReleaseIds?: string[] }).sourceReleaseIds ?? []; + const rows = ids.length + ? await ctx.db + .selectFrom("source_release") + .select(["id", sql`episode_resolution->>'method'`.as("method")]) + .where("id", "in", ids) + .execute() + : []; + const unresolved = rows.filter((r) => r.method === "unresolved").length; + facts[`decision:${id}`] = d ? { status: d.status, releases: ids.length, unresolved } : null; + states.push(!d ? "failed" : d.status === "resolved" || unresolved === 0 ? "recovered" : "pending"); } } const state = states.includes("failed") ? "failed" : states.every((x) => x === "recovered") ? "recovered" : "pending"; diff --git a/apps/server/src/modules/agent/runner.ts b/apps/server/src/modules/agent/runner.ts index c061cdc9..f1e00c65 100644 --- a/apps/server/src/modules/agent/runner.ts +++ b/apps/server/src/modules/agent/runner.ts @@ -76,15 +76,28 @@ export const INVESTIGATE_CAPABILITIES = [ "site.list", "site.user_data", "identity.resolve", + // 集数映射兜底(批次 5):看上下文、试草稿规则;模拟通过的规则作为智能体建议留在事项上 + "episode_mapping.inspect", + "episode_mapping.simulate", ] as const; /** * 授权内恢复(settings.agent.autoInvestigateMode = recover)的能力白名单:只读调查 + 恢复类 - * (重试失败任务、强制重新校验 / 恢复暂停)。换源(download.plan / download.submit)、删除 / 清理、 + * (重试失败任务、强制重新校验 / 恢复暂停、写集数映射规则)。换源(download.plan / download.submit)、删除 / 清理、 * 取消任务、整理执行、策略发布一律不在白名单内。实际能否执行仍由 authorize 按持续授权判定。 */ -export const RECOVER_CAPABILITIES = [...INVESTIGATE_CAPABILITIES, "task.retry", "download.recheck"] as const; +export const RECOVER_CAPABILITIES = [ + ...INVESTIGATE_CAPABILITIES, + "task.retry", + "download.recheck", + "episode_mapping.apply_rule", +] as const; /** 被视为"恢复动作"的能力(结果需系统核实) */ -export const RECOVERY_ACTIONS = ["task.retry", "download.recheck", "identity.resolve"] as const; +export const RECOVERY_ACTIONS = [ + "task.retry", + "download.recheck", + "identity.resolve", + "episode_mapping.apply_rule", +] as const; export interface RunScope { capabilities: string[]; @@ -315,7 +328,7 @@ export async function buildRunPrompt( constraints.push("你是子会话:只完成本目标,思考和汇报都用中文,完成后简要汇报结论与证据,由父会话汇总。"); if (run.trigger === "event" && run.mode === "execute") constraints.push( - "本任务由后台事件触发,只做授权内的有界恢复:先查证据,再在授权范围内执行恢复(task.retry / download.recheck / identity.resolve)," + + "本任务由后台事件触发,只做授权内的有界恢复:先查证据,再在授权范围内执行恢复(task.retry / download.recheck / identity.resolve / episode_mapping.apply_rule)," + "每种动作最多一次;不换源、不删除、不清理;是否恢复由系统按业务状态核实,不要自行宣称已恢复。", ); else if (run.trigger === "event") diff --git a/apps/server/src/modules/agent/tasks.ts b/apps/server/src/modules/agent/tasks.ts index 5663a31a..278436e4 100644 --- a/apps/server/src/modules/agent/tasks.ts +++ b/apps/server/src/modules/agent/tasks.ts @@ -263,7 +263,7 @@ export async function executeResolution(ctx: ServerContext, decisionId: string) export const agentOutboxRoutes: Record = { "decision.created": (ev) => { const p = ev.payload as EventPayload<"decision.created">; - if (!["identity_ambiguous", "download_failed", "download_stalled"].includes(p.kind)) return null; + if (!["identity_ambiguous", "download_failed", "download_stalled", "episode_mapping"].includes(p.kind)) return null; return { task: "resolveDecision", input: { decisionId: p.decisionId, trigger: "created" } }; }, "decision.updated": (ev) => { diff --git a/apps/server/src/modules/sources-identity/capabilities-episode.ts b/apps/server/src/modules/sources-identity/capabilities-episode.ts new file mode 100644 index 00000000..15782764 --- /dev/null +++ b/apps/server/src/modules/sources-identity/capabilities-episode.ts @@ -0,0 +1,37 @@ +import type { CapabilityRegistry, HandlerContext } from "@umt/agent"; +import { capabilitySpec } from "@umt/contracts"; +import type { SourcesRuntime } from "./runtime.ts"; +import type { Actor } from "./service/common.ts"; +import { applyAgentEpisodeRule, inspectEpisodeMapping, simulateForAgent } from "./service/episode-mapping-agent.ts"; + +// 集数映射的智能体能力(docs/plan/episode-resolution.md 批次 5):episode_mapping.inspect / simulate / apply_rule。 +// apply_rule 属 execute 类,授权由注册中心按持续授权 / 人工判定;处理器里还要强制模拟通过才写。 + +const isUuid = (s: string | undefined): s is string => + !!s && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s); + +/** 写规则的操作者:智能体会话记 agent(id = 会话);控制台人工 / API Key 照实记 */ +function actorOf(ctx: HandlerContext): Actor { + if (ctx.source === "agent" || isUuid(ctx.agentRunId)) + return { type: "agent", id: isUuid(ctx.agentRunId) ? ctx.agentRunId : ctx.actorId }; + if (ctx.actorId.startsWith("api_key:")) return { type: "api_key", id: ctx.actorId.slice("api_key:".length) }; + if (/^(user|admin):/.test(ctx.actorId)) return { type: "user", id: ctx.actorId.replace(/^\w+:/, "") }; + return { type: "system", id: ctx.actorId }; +} + +export function registerEpisodeMappingCapabilities(registry: CapabilityRegistry, rt: () => SourcesRuntime): void { + registry.register(capabilitySpec("episode_mapping.inspect"), (input) => inspectEpisodeMapping(rt().ctx.db, input)); + + registry.register(capabilitySpec("episode_mapping.simulate"), (input, ctx) => + simulateForAgent( + rt().ctx.db, + input, + // 只有智能体会话里的模拟才留建议(控制台 / API Key 调用纯读) + ctx.source === "agent" ? { runId: isUuid(ctx.agentRunId) ? ctx.agentRunId : null } : null, + ), + ); + + registry.register(capabilitySpec("episode_mapping.apply_rule"), (input, ctx) => + applyAgentEpisodeRule(rt().ctx, input, actorOf(ctx), isUuid(ctx.agentRunId) ? ctx.agentRunId : null), + ); +} diff --git a/apps/server/src/modules/sources-identity/capabilities.ts b/apps/server/src/modules/sources-identity/capabilities.ts index d33d8165..45fbc642 100644 --- a/apps/server/src/modules/sources-identity/capabilities.ts +++ b/apps/server/src/modules/sources-identity/capabilities.ts @@ -2,6 +2,7 @@ import type { CapabilityRegistry } from "@umt/agent"; import { capabilitySpec, type ExternalEntityType, type SourceRelease } from "@umt/contracts"; import { sql } from "@umt/db"; import { normalizeTitle } from "@umt/domain"; +import { registerEpisodeMappingCapabilities } from "./capabilities-episode.ts"; import { registerSiteCapabilities } from "./capabilities-sites.ts"; import { toApi, toApiList } from "./rows.ts"; import type { SourcesRuntime } from "./runtime.ts"; @@ -11,11 +12,12 @@ import { refreshReleaseDetail } from "./service/snapshot.ts"; import { liveSearch } from "./service/workbench.ts"; // 本模块领域的能力处理器(规格一律取 contracts):catalog.lookup / release.search / release.inspect / identity.resolve; -// 站点能力 site.* 见 capabilities-sites.ts。 +// 站点能力 site.* 见 capabilities-sites.ts;集数映射 episode_mapping.* 见 capabilities-episode.ts。 // identity.resolve 属 propose 类,授权回调由 W2-D 接入前会被注册中心拒绝(安全默认)。 export function registerSourcesCapabilities(registry: CapabilityRegistry, rt: () => SourcesRuntime): void { registerSiteCapabilities(registry, rt); + registerEpisodeMappingCapabilities(registry, rt); registry.register(capabilitySpec("catalog.lookup"), async (input, ctx) => { const { db } = rt().ctx; const local: { entity: unknown; score: number; matchedAlias: string | null }[] = []; diff --git a/apps/server/src/modules/sources-identity/routes/decisions.ts b/apps/server/src/modules/sources-identity/routes/decisions.ts index 9d8b5ac6..b4fad28a 100644 --- a/apps/server/src/modules/sources-identity/routes/decisions.ts +++ b/apps/server/src/modules/sources-identity/routes/decisions.ts @@ -11,7 +11,11 @@ import { decodeCursor, jsonb, page, toApi, toApiList } from "../rows.ts"; import type { SourcesRuntime } from "../runtime.ts"; import { audit } from "../service/common.ts"; import { resolveEpisodeDecision } from "../service/episode-mapping-admin.ts"; -import { EPISODE_DECISION_CONFIRM, EPISODE_DECISION_IGNORE_RULE } from "../service/episode-resolution.ts"; +import { + EPISODE_DECISION_CONFIRM, + EPISODE_DECISION_CONFIRM_AGENT, + EPISODE_DECISION_IGNORE_RULE, +} from "../service/episode-resolution.ts"; import { assignReleaseIdentity, dismissPoolEntry } from "../service/identity-admin.ts"; import { groupMemberIds } from "../service/identity-decisions.ts"; @@ -163,10 +167,13 @@ export function decisionRoutes(app: ModuleApp, rt: () => SourcesRuntime) { const opt = body.optionKey ? options.find((o) => o.key === body.optionKey) : undefined; if (body.optionKey && !opt) throw new ApiException("bad_request", `未知选项 ${body.optionKey}`); - // 集数待确认:确认建议规则 / 忽略并建忽略规则 = 建规则 + 关闭事项 + 按作品重算(同一事务);单纯忽略走下面的通用关闭 + // 集数待确认:确认建议规则 / 确认智能体建议 / 忽略并建忽略规则 = 建规则 + 关闭事项 + 按作品重算(同一事务); + // 单纯忽略走下面的通用关闭 if ( d.kind === "episode_mapping" && - (opt?.key === EPISODE_DECISION_CONFIRM || opt?.key === EPISODE_DECISION_IGNORE_RULE) + (opt?.key === EPISODE_DECISION_CONFIRM || + opt?.key === EPISODE_DECISION_CONFIRM_AGENT || + opt?.key === EPISODE_DECISION_IGNORE_RULE) ) { return toApi(await resolveEpisodeDecision(rt().ctx, d, opt.key, body.note ?? null, actor)); } diff --git a/apps/server/src/modules/sources-identity/service/episode-mapping-admin.ts b/apps/server/src/modules/sources-identity/service/episode-mapping-admin.ts index ee9a1852..bd81215b 100644 --- a/apps/server/src/modules/sources-identity/service/episode-mapping-admin.ts +++ b/apps/server/src/modules/sources-identity/service/episode-mapping-admin.ts @@ -9,10 +9,13 @@ import type { EpisodeMappingRuleUpdate, EpisodeMappingRuleWriteResult, EpisodeResolutionMethod, + EpisodeResultBrief, + EpisodeSimulationItem, + EpisodeSimulationSummary, EpisodeSuggestion, EpisodeValidationState, } from "@umt/contracts"; -import type { DecisionRow, EpisodeMappingRuleRow as RuleDbRow } from "@umt/db"; +import type { DecisionRow, EpisodeMappingRuleEvidenceLevel, EpisodeMappingRuleRow as RuleDbRow } from "@umt/db"; import { type EpisodeMappingRule, type EpisodeResolution, @@ -30,6 +33,7 @@ import { jsonb, toApi } from "../rows.ts"; import { type Actor, audit, type Exec } from "./common.ts"; import { EPISODE_DECISION_CONFIRM, + EPISODE_DECISION_CONFIRM_AGENT, EPISODE_DECISION_IGNORE_RULE, type EpisodeDecisionEvidence, loadRules, @@ -47,7 +51,7 @@ const PREVIEW_LIMIT = 100; /** 有季 / 组过滤时多取一些再在内存里筛(季与组要按原标题重解析才知道) */ const PREVIEW_SCAN = 1000; -interface NormalizedRule { +export interface NormalizedRule { season: number | null; release_group: string | null; kind: EpisodeMappingRuleSpec["kind"]; @@ -108,7 +112,7 @@ const scopeText = (season: number | null, group: string | null) => `${season === null ? "任意季" : `第 ${season} 季`} · ${group ?? "任意发布组"}`; /** 同作品 + 季 + 发布组(忽略大小写)已有启用的规则 → conflict */ -async function assertScopeFree( +export async function assertScopeFree( exec: Exec, entityId: string, season: number | null, @@ -151,13 +155,14 @@ export async function listRules(exec: Exec, entityId: string): Promise toApi(r)); } -/** 写入一条规则(不重算,调用方在同一事务里接着重算) */ -async function insertRule( +/** 写入一条规则(不重算,调用方在同一事务里接着重算);证据等级缺省 manual(人工建 / 人工确认) */ +export async function insertRule( exec: Exec, entityId: string, n: NormalizedRule, actor: Actor, evidence: Record, + evidenceLevel: EpisodeMappingRuleEvidenceLevel = "manual", ): Promise { await assertScopeFree(exec, entityId, n.season, n.release_group, null); const row = await exec @@ -167,7 +172,7 @@ async function insertRule( ...n, table_entries: n.table_entries ? jsonb(n.table_entries) : null, evidence: jsonb(evidence), - evidence_level: "manual", + evidence_level: evidenceLevel, created_by_type: actor.type, created_by_id: actor.id, }) @@ -186,7 +191,7 @@ async function insertRule( return row; } -async function withConflict(fn: () => Promise): Promise { +export async function withConflict(fn: () => Promise): Promise { try { return await fn(); } catch (e) { @@ -302,7 +307,7 @@ export async function deleteRule(ctx: ServerContext, id: string, actor: Actor): const DRAFT_ID = "draft"; -function domainRule(n: NormalizedRule, id: string): EpisodeMappingRule { +export function domainRule(n: NormalizedRule, id: string): EpisodeMappingRule { return { id, season: n.season, @@ -315,7 +320,7 @@ function domainRule(n: NormalizedRule, id: string): EpisodeMappingRule { }; } -const sameScope = (a: EpisodeMappingRule, b: EpisodeMappingRule) => +export const sameScope = (a: EpisodeMappingRule, b: EpisodeMappingRule) => a.season === b.season && (a.releaseGroup?.trim().toLowerCase() ?? null) === (b.releaseGroup?.trim().toLowerCase() ?? null); @@ -443,10 +448,106 @@ export async function previewEpisodeMapping( return { applicable: true, seasons, groups, items, changed: items.filter((i) => i.changed).length }; } +// ── 模拟一条草稿规则(批次 5:智能体 simulate / apply_rule、确认智能体建议共用) ── + +/** 模拟最多扫多少条近期资源(有发布组时按组筛) */ +const SIMULATE_LIMIT = 300; +/** 模拟结果里最多列多少条 */ +const SIMULATE_ITEMS = 100; + +export interface RuleSimulation { + summary: EpisodeSimulationSummary; + failures: string[]; + items: EpisodeSimulationItem[]; +} + +export const briefOf = (r: EpisodeResolution | null | undefined): EpisodeResultBrief | null => + r ? { method: r.method, canonical: r.canonical ?? null, special: r.special ?? null } : null; + +/** 当前已解决(原号 / 规则 / 换算 / 滞后放行 / 特别篇):套用后变成待确认算「回退」 */ +const settled = (r: EpisodeResolution | null) => !!r && r.method !== "unresolved" && r.method !== "skipped"; + +/** + * 用 previewEpisodeMapping 试算草稿规则(纯读):sampleIds = 事项内资源;没有事项时取范围内当前待确认的资源。 + * ok = 样本全部解决(非待确认、校验非 fail)且没有让当前已解决的资源变成待确认。 + */ +export async function simulateEpisodeRule( + exec: Exec, + entityId: string, + spec: EpisodeMappingRuleSpec, + sampleIds: readonly string[] | null, +): Promise { + const n = normalizeRuleSpec(spec); + const p = await previewEpisodeMapping(exec, entityId, { + releaseGroup: n.release_group, + draft: spec, + limit: SIMULATE_LIMIT, + }); + if (!p.applicable) throw bad("只有剧集和综艺能设置集数映射"); + const inScope = (i: EpisodeMappingPreviewItem) => { + const src = i.preview.source; + const season = src.season ?? (src.episodes ? 1 : null); + return n.season === null || season === n.season; + }; + const ids = new Set( + sampleIds ?? p.items.filter((i) => i.current?.method === "unresolved" && inScope(i)).map((i) => i.sourceReleaseId), + ); + const items = p.items as EpisodeMappingPreviewItem[]; + const samples = items.filter((i) => ids.has(i.sourceReleaseId)); + const resolved = samples.filter((i) => i.preview.method !== "unresolved" && i.validation !== "fail"); + const regressions = items.filter( + (i) => + !ids.has(i.sourceReleaseId) && + settled(i.current as EpisodeResolution | null) && + i.preview.method === "unresolved", + ); + const missing = ids.size - samples.length; + const failures: string[] = []; + if (!ids.size) failures.push("没有要解决的待确认资源"); + if (missing) failures.push(`${missing} 条样本不在最近的 ${SIMULATE_LIMIT} 条资源里,模拟不到`); + const unresolved = samples.length - resolved.length; + if (unresolved) { + const eg = samples.find((i) => !resolved.includes(i))!; + failures.push(`${unresolved} 条样本套用后仍对不上季表(如「${eg.title}」)`); + } + if (regressions.length) + failures.push(`会让 ${regressions.length} 条已对上的资源变成待确认(如「${regressions[0]!.title}」)`); + const summary: EpisodeSimulationSummary = { + samples: ids.size, + resolved: resolved.length, + passed: samples.filter((i) => i.validation === "pass").length, + missing, + regressions: regressions.length, + changed: items.filter((i) => i.changed).length, + scanned: items.length, + ok: failures.length === 0, + }; + const shown = [...samples, ...regressions, ...items.filter((i) => i.changed && !ids.has(i.sourceReleaseId))]; + const seen = new Set(); + const out: EpisodeSimulationItem[] = []; + for (const i of shown) { + if (seen.has(i.sourceReleaseId) || out.length >= SIMULATE_ITEMS) continue; + seen.add(i.sourceReleaseId); + out.push({ + sourceReleaseId: i.sourceReleaseId, + title: i.title, + releaseGroup: i.releaseGroup, + sample: ids.has(i.sourceReleaseId), + source: i.preview.source, + before: briefOf(i.current as EpisodeResolution | null), + after: briefOf(i.preview as EpisodeResolution)!, + validation: i.validation, + changed: i.changed, + }); + } + return { summary, failures, items: out }; +} + // ── 待处理 episode_mapping ── /** - * 处理 episode_mapping 事项:确认建议规则(建 expression 规则)/ 忽略并建 special=ignore 规则。 + * 处理 episode_mapping 事项:确认建议规则(建 expression 规则)/ 确认智能体建议(批次 5,建规则前再强制模拟一次)/ + * 忽略并建 special=ignore 规则。 * 同一事务里:建规则 → 按用户的选择关闭事项 → 按作品重算(资源解决后不会再挂到已关闭的事项上; * 仍待确认的资源会另开一条新事项)。 */ @@ -470,6 +571,9 @@ export async function resolveEpisodeDecision( expression: ev.suggestion.expression, targetSeason: ev.suggestion.targetSeason, }; + } else if (optionKey === EPISODE_DECISION_CONFIRM_AGENT) { + if (!ev.agentSuggestion?.rule) throw new ApiException("conflict", "这条事项没有智能体建议"); + spec = ev.agentSuggestion.rule; } else if (optionKey === EPISODE_DECISION_IGNORE_RULE) { spec = { ...scope, kind: "special", special: "ignore" }; } else { @@ -488,9 +592,24 @@ export async function resolveEpisodeDecision( .executeTakeFirstOrThrow(); if (!["open", "investigating", "awaiting_user"].includes(cur.status)) throw new ApiException("conflict", `事项已是 ${cur.status}`); + let simulation: unknown = null; + if (optionKey === EPISODE_DECISION_CONFIRM_AGENT) { + // 智能体建议可能是一阵子之前模拟的:建规则前按当前资源再模拟一次,不通过就不写 + const ids = ((cur.evidence ?? {}) as { sourceReleaseIds?: string[] }).sourceReleaseIds ?? []; + const sim = await simulateEpisodeRule(trx, entityId, spec, ids); + if (!sim.summary.ok) + throw new ApiException("conflict", `智能体建议的规则现在模拟不通过:${sim.failures.join(";")}`); + simulation = sim.summary; + } const rule = await insertRule(trx, entityId, n, actor, { decisionId: d.id, ...(optionKey === EPISODE_DECISION_CONFIRM ? { suggestion: ev.suggestion } : {}), + ...(optionKey === EPISODE_DECISION_CONFIRM_AGENT + ? { + agentSuggestion: { reasons: ev.agentSuggestion?.reasons ?? [], runId: ev.agentSuggestion?.runId ?? null }, + simulation, + } + : {}), }); await trx .updateTable("decision") diff --git a/apps/server/src/modules/sources-identity/service/episode-mapping-agent.ts b/apps/server/src/modules/sources-identity/service/episode-mapping-agent.ts new file mode 100644 index 00000000..b08cd4b0 --- /dev/null +++ b/apps/server/src/modules/sources-identity/service/episode-mapping-agent.ts @@ -0,0 +1,436 @@ +import type { + DecisionOption, + EpisodeInspectRelease, + EpisodeMappingApplyRuleInput, + EpisodeMappingApplyRuleOutput, + EpisodeMappingInspectInput, + EpisodeMappingInspectOutput, + EpisodeMappingRuleSpec, + EpisodeMappingSimulateInput, + EpisodeMappingSimulateOutput, + EpisodeSuggestion, +} from "@umt/contracts"; +import { type DecisionRow, sql } from "@umt/db"; +import { type EpisodeResolution, parseRelease, type SuggestSample, suggestRule } from "@umt/domain"; +import { ApiException } from "../../../http/errors.ts"; +import type { ServerContext } from "../../types.ts"; +import { jsonb } from "../rows.ts"; +import type { Actor, Exec } from "./common.ts"; +import { + briefOf, + insertRule, + normalizeRuleSpec, + type RuleSimulation, + simulateEpisodeRule, + withConflict, +} from "./episode-mapping-admin.ts"; +import { + type EpisodeAgentSuggestion, + type EpisodeDecisionEvidence, + episodeDecisionOptions, + loadSeasonTable, + publishedAtOf, + reresolveEntityEpisodes, +} from "./episode-resolution.ts"; + +// 集数映射的智能体兜底(docs/plan/episode-resolution.md 批次 5):确定性流程都失败、开了 episode_mapping 待处理后, +// 智能体 inspect(上下文包)→ simulate(试草稿规则)→ apply_rule(服务端强制再模拟,通过才写规则并按作品重算)。 +// 规则写入后同类资源由规则自动处理,智能体不逐集调用。没有写规则授权时,模拟通过的规则作为「智能体建议」留在事项上。 + +const OPEN = ["open", "investigating", "awaiting_user"] as const; +const EPISODIC = new Set(["series", "variety"]); +/** 上下文包上限:事项内资源 / 相邻资源各 50 条,季表每季 100 集;相邻资源最多扫这么多条近期资源 */ +const RELEASE_CAP = 50; +const EPISODE_CAP = 100; +const NEIGHBOR_SCAN = 1000; +/** 建议里带多少条样本 */ +const SUGGESTION_SAMPLES = 10; + +const bad = (msg: string) => new ApiException("bad_request", msg); + +interface EpisodeScope { + entityId: string; + season: number | null; + releaseGroup: string | null; + decision: DecisionRow | null; + /** 事项内资源;没有事项时为 null(由调用方按范围取当前待确认的资源) */ + sampleIds: string[] | null; +} + +const decisionKey = (entityId: string, season: number | null, group: string | null) => + `episode_mapping:${entityId}:${season ?? "-"}:${group?.trim().toLowerCase() || "-"}`; + +async function loadDecision(exec: Exec, id: string): Promise { + const d = await exec.selectFrom("decision").selectAll().where("id", "=", id).executeTakeFirst(); + if (!d) throw new ApiException("not_found", "待处理事项不存在"); + if (d.kind !== "episode_mapping") throw bad("这条事项不是集数待确认"); + return d; +} + +function scopeOfDecision(d: DecisionRow): EpisodeScope { + const ev = (d.evidence ?? {}) as EpisodeDecisionEvidence & { sourceReleaseIds?: string[] }; + const entityId = ev.mediaEntityId ?? (d.subject_type === "media_entity" ? d.subject_id : null); + if (!entityId) throw bad("事项缺少作品"); + return { + entityId, + season: ev.season ?? null, + releaseGroup: ev.releaseGroup ?? null, + decision: d, + sampleIds: ev.sourceReleaseIds ?? [], + }; +} + +/** decisionId 优先;否则作品 + 来源季 + 发布组,并找同范围未关闭的事项 */ +async function resolveScope( + exec: Exec, + input: { decisionId?: string | undefined; mediaEntityId?: string | undefined }, + season: number | null | undefined, + releaseGroup: string | null | undefined, +): Promise { + if (input.decisionId) return scopeOfDecision(await loadDecision(exec, input.decisionId)); + if (!input.mediaEntityId) throw bad("要给 decisionId 或 mediaEntityId"); + const group = releaseGroup?.trim() || null; + const d = await exec + .selectFrom("decision") + .selectAll() + .where("dedupe_key", "=", decisionKey(input.mediaEntityId, season ?? null, group)) + .where("status", "in", [...OPEN]) + .executeTakeFirst(); + if (d) return scopeOfDecision(d); + return { + entityId: input.mediaEntityId, + season: season ?? null, + releaseGroup: group, + decision: null, + sampleIds: null, + }; +} + +type ReleaseRow = { + id: string; + title: string; + subtitle: string | null; + source_published_at: Date | null; + first_seen_at: Date; + episode_resolution: unknown; +}; + +function releaseView(row: ReleaseRow): EpisodeInspectRelease & { publishedAtMs: number } { + const raw = parseRelease(row.title, row.subtitle ? { subtitle: row.subtitle } : {}); + const res = (row.episode_resolution as EpisodeResolution | null) ?? null; + const published = publishedAtOf(row); + return { + sourceReleaseId: row.id, + title: row.title, + publishedAt: published.toISOString(), + publishedAtMs: published.getTime(), + releaseGroup: raw.releaseGroup?.name ?? null, + source: res?.source ?? { + ...(raw.season !== undefined ? { season: raw.season } : {}), + ...(raw.episodes ? { episodes: raw.episodes } : {}), + }, + current: briefOf(res), + evidence: res?.evidence ?? [], + }; +} + +const strip = ({ publishedAtMs: _drop, ...r }: EpisodeInspectRelease & { publishedAtMs: number }) => r; + +const sourceSeason = (r: EpisodeInspectRelease) => r.source.season ?? (r.source.episodes ? 1 : null); + +/** episode_mapping.inspect:给智能体判断用的上下文包(只读,有上限) */ +export async function inspectEpisodeMapping( + exec: Exec, + input: EpisodeMappingInspectInput, +): Promise { + const scope = await resolveScope(exec, input, input.season, input.releaseGroup); + const entity = await exec + .selectFrom("media_entity") + .select(["id", "title", "kind", "year"]) + .where("id", "=", scope.entityId) + .executeTakeFirst(); + if (!entity) throw new ApiException("not_found", "作品不存在"); + if (!EPISODIC.has(entity.kind)) throw bad("只有剧集和综艺参与集数映射"); + + const cols = ["id", "title", "subtitle", "source_published_at", "first_seen_at", "episode_resolution"] as const; + const recent = await exec + .selectFrom("source_release") + .select([...cols]) + .where("media_entity_id", "=", scope.entityId) + .where("resolution_status", "=", "resolved") + .orderBy((eb) => eb.fn.coalesce("source_published_at", "first_seen_at"), "desc") + .orderBy("id") + .limit(NEIGHBOR_SCAN) + .execute(); + const group = scope.releaseGroup?.toLowerCase() ?? null; + const views = recent.map(releaseView).filter((v) => (v.releaseGroup?.toLowerCase() ?? null) === group); + + let members: ReturnType[]; + if (scope.sampleIds) { + const ids = scope.sampleIds; + const rows = ids.length + ? await exec + .selectFrom("source_release") + .select([...cols]) + .where("id", "in", ids) + .execute() + : []; + members = rows.map(releaseView); + } else { + // 没有事项:这个范围里当前待确认的资源 + members = views.filter( + (v) => v.current?.method === "unresolved" && (scope.season === null || sourceSeason(v) === scope.season), + ); + } + members.sort((a, b) => b.publishedAtMs - a.publishedAtMs); + const memberIds = new Set(members.map((m) => m.sourceReleaseId)); + const neighbors = views.filter((v) => !memberIds.has(v.sourceReleaseId)); + + const table = await loadSeasonTable(exec, scope.entityId); + const titled = await exec + .selectFrom("content_unit") + .select(["season_number", "episode_number", "title"]) + .where("media_entity_id", "=", scope.entityId) + .where("kind", "=", "episode") + .where("status", "<>", "retired") + .where("title", "is not", null) + .execute(); + const titleOf = new Map(titled.map((u) => [`${u.season_number}:${u.episode_number}`, u.title])); + const seasons = table.seasons.map((s) => ({ + season: s.season, + total: s.total, + episodes: (s.episodes ?? []).slice(0, EPISODE_CAP).map((e) => ({ + episode: e.episode, + airDate: e.airDate ?? null, + title: titleOf.get(`${s.season}:${e.episode}`) ?? null, + })), + })); + + const ruleRows = await exec + .selectFrom("episode_mapping_rule") + .selectAll() + .where("media_entity_id", "=", scope.entityId) + .orderBy("status") + .orderBy("created_at", "desc") + .execute(); + const rules = ruleRows.map((r) => { + const ev = (r.evidence ?? {}) as { reasons?: unknown }; + return { + id: r.id, + season: r.season, + releaseGroup: r.release_group, + kind: r.kind, + expression: r.expression, + targetSeason: r.target_season, + tableEntries: (r.table_entries as EpisodeMappingRuleSpec["tableEntries"]) ?? null, + special: r.special, + status: r.status, + disabledReason: r.disabled_reason, + evidenceLevel: r.evidence_level, + byAgent: r.created_by_type === "agent", + reasons: Array.isArray(ev.reasons) ? ev.reasons.filter((x): x is string => typeof x === "string") : [], + }; + }); + + // 确定性建议:事项内待确认样本按发布时间对播出日期(与待处理里的建议同一推导) + const samples: SuggestSample[] = members + .filter((m) => m.current?.method === "unresolved" && m.source.episodes) + .map((m) => ({ episodes: m.source.episodes!, publishedAt: m.publishedAt })) + .sort((a, b) => a.publishedAt.localeCompare(b.publishedAt)); + const s = samples.length ? suggestRule(samples, table) : null; + const suggestion: EpisodeSuggestion | null = s + ? { season: scope.season, releaseGroup: scope.releaseGroup, ...s } + : null; + + const d = scope.decision; + return { + entity: { id: entity.id, title: entity.title, kind: entity.kind, year: entity.year }, + scope: { season: scope.season, releaseGroup: scope.releaseGroup }, + decision: d + ? { + id: d.id, + status: d.status, + title: d.title, + summary: d.summary, + failedRules: ((d.evidence ?? {}) as { failedRules?: string[] }).failedRules ?? [], + } + : null, + releases: members.slice(0, RELEASE_CAP).map(strip), + neighbors: neighbors.slice(0, RELEASE_CAP).map(strip), + seasons, + latestSeason: table.latestSeason, + ended: table.ended, + rules, + suggestion, + truncated: { releases: members.length > RELEASE_CAP, neighbors: neighbors.length > RELEASE_CAP }, + }; +} + +/** 草稿规则的季 / 发布组没给(undefined)时用事项的;显式 null 表示任意 */ +function withScope(rule: EpisodeMappingRuleSpec, scope: EpisodeScope): EpisodeMappingRuleSpec { + return { + ...rule, + season: rule.season === undefined ? scope.season : rule.season, + releaseGroup: rule.releaseGroup === undefined ? scope.releaseGroup : rule.releaseGroup, + }; +} + +/** 规范化后的规则内容(回给调用方 / 存进建议) */ +function specOf(rule: EpisodeMappingRuleSpec): EpisodeMappingRuleSpec { + const n = normalizeRuleSpec(rule); + return { + season: n.season, + releaseGroup: n.release_group, + kind: n.kind, + expression: n.expression, + targetSeason: n.target_season, + tableEntries: n.table_entries, + special: n.special, + }; +} + +/** 模拟通过的规则作为智能体建议写进事项证据,并补「确认智能体建议」选项(不写规则) */ +async function recordAgentSuggestion( + exec: Exec, + d: DecisionRow, + spec: EpisodeMappingRuleSpec, + reasons: string[], + sim: RuleSimulation, + runId: string | null, +): Promise { + const suggestion: EpisodeAgentSuggestion = { + rule: spec, + reasons, + simulation: sim.summary, + samples: sim.items + .filter((i) => i.sample) + .slice(0, SUGGESTION_SAMPLES) + .map((i) => ({ title: i.title, source: i.source, after: i.after })), + runId, + at: new Date().toISOString(), + }; + const ev = (d.evidence ?? {}) as EpisodeDecisionEvidence; + const options: DecisionOption[] = episodeDecisionOptions({ + season: ev.season ?? null, + releaseGroup: ev.releaseGroup ?? null, + suggestion: ev.suggestion ?? null, + agentSuggestion: suggestion, + }); + // 事项上其它来源的选项(如有)保留 + const known = new Set(options.map((o) => o.key)); + const rest = ((d.options ?? []) as DecisionOption[]).filter((o) => !known.has(o.key)); + const r = await exec + .updateTable("decision") + .set({ + evidence: sql`evidence || jsonb_build_object('agentSuggestion', ${jsonb(suggestion)}::jsonb)`, + options: jsonb([...options, ...rest]), + }) + .where("id", "=", d.id) + .where("status", "in", [...OPEN]) + .returning("id") + .executeTakeFirst(); + return !!r; +} + +/** + * episode_mapping.simulate:试草稿规则(复用模拟预览)。recordFor 给了(智能体会话 + 有事项)且模拟通过时, + * 规则与理由作为智能体建议留在事项上。 + */ +export async function simulateForAgent( + exec: Exec, + input: EpisodeMappingSimulateInput, + record: { runId: string | null } | null, +): Promise { + const scope = await resolveScope(exec, input, input.rule.season, input.rule.releaseGroup); + const spec = specOf(withScope(input.rule, scope)); + const sim = await simulateEpisodeRule(exec, scope.entityId, spec, scope.sampleIds); + let suggestionRecorded = false; + if (record && scope.decision && sim.summary.ok) + suggestionRecorded = await recordAgentSuggestion( + exec, + scope.decision, + spec, + input.reasons ?? [], + sim, + record.runId, + ); + return { rule: spec, summary: sim.summary, failures: sim.failures, items: sim.items, suggestionRecorded }; +} + +async function manuallyDisabled(exec: Exec, entityId: string, spec: EpisodeMappingRuleSpec): Promise { + let q = exec + .selectFrom("episode_mapping_rule") + .select(["release_group", "disabled_reason"]) + .where("media_entity_id", "=", entityId) + .where("status", "=", "disabled"); + q = spec.season == null ? q.where("season", "is", null) : q.where("season", "=", spec.season); + const group = spec.releaseGroup?.trim().toLowerCase() || null; + return (await q.execute()).some((r) => { + if ((r.release_group?.trim().toLowerCase() || null) !== group) return false; + try { + return (JSON.parse(r.disabled_reason ?? "{}") as { manual?: boolean }).manual === true; + } catch { + return false; + } + }); +} + +/** + * episode_mapping.apply_rule:同一事务里锁事项 → 强制模拟(样本全部解决、不让已解决的变待确认,否则拒绝不写)→ + * 写规则(created_by_type = agent;样本 ≥ 3 且全部「对得上」记 multi_evidence,否则 needs_review)→ 按作品重算。 + * 资源都解决时事项由重算自动关闭(批次 2 的摘除逻辑)。 + */ +export async function applyAgentEpisodeRule( + ctx: ServerContext, + input: EpisodeMappingApplyRuleInput, + actor: Actor, + runId: string | null, +): Promise { + return withConflict(() => + ctx.db.transaction().execute(async (trx) => { + const locked = await trx + .selectFrom("decision") + .selectAll() + .where("id", "=", input.decisionId) + .forUpdate() + .executeTakeFirst(); + if (!locked) throw new ApiException("not_found", "待处理事项不存在"); + if (locked.kind !== "episode_mapping") throw bad("这条事项不是集数待确认"); + if (!(OPEN as readonly string[]).includes(locked.status)) + throw new ApiException("conflict", `事项已是 ${locked.status},不用再写规则`); + const scope = scopeOfDecision(locked); + const spec = specOf(withScope(input.rule, scope)); + // 用户手动停用过同作用域的规则 = 撤销过智能体(或别人)的判断:不再自动写,留给人 + if (await manuallyDisabled(trx, scope.entityId, spec)) + throw new ApiException( + "conflict", + "这个范围的规则被手动停用过,不自动写规则;需要时到作品页的集数映射里手动建", + ); + const sim = await simulateEpisodeRule(trx, scope.entityId, spec, scope.sampleIds); + if (!sim.summary.ok) throw new ApiException("conflict", `模拟没通过,没有写规则:${sim.failures.join(";")}`); + const level = + sim.summary.samples >= 3 && sim.summary.passed === sim.summary.samples ? "multi_evidence" : "needs_review"; + const rule = await insertRule( + trx, + scope.entityId, + normalizeRuleSpec(spec), + actor, + { + decisionId: locked.id, + reasons: input.reasons, + samples: sim.summary.samples, + simulation: sim.summary, + runId, + }, + level, + ); + const reresolve = await reresolveEntityEpisodes(ctx, trx, scope.entityId); + const after = await trx + .selectFrom("decision") + .select("status") + .where("id", "=", locked.id) + .executeTakeFirstOrThrow(); + return { ruleId: rule.id, evidenceLevel: level, summary: sim.summary, reresolve, decisionStatus: after.status }; + }), + ); +} diff --git a/apps/server/src/modules/sources-identity/service/episode-resolution.ts b/apps/server/src/modules/sources-identity/service/episode-resolution.ts index d98d85a8..0b346edd 100644 --- a/apps/server/src/modules/sources-identity/service/episode-resolution.ts +++ b/apps/server/src/modules/sources-identity/service/episode-resolution.ts @@ -1,4 +1,11 @@ -import type { DecisionOption, EvidenceLevel, MediaKind } from "@umt/contracts"; +import type { + DecisionOption, + EpisodeMappingRuleSpec, + EpisodeResultBrief, + EpisodeSimulationSummary, + EvidenceLevel, + MediaKind, +} from "@umt/contracts"; import { type SourceReleaseRow, sql } from "@umt/db"; import { type EpisodeMappingRule, @@ -313,6 +320,17 @@ async function writeEpisodeDecision(ctx: ServerContext, exec: Exec, s: EpisodeDe .map((r) => ({ episodes: r.res!.source.episodes!, publishedAt: publishedAtOf(r).toISOString() })); const suggestion = samples.length ? suggestRule(samples, await loadSeasonTable(exec, s.entityId)) : null; + // 刷新事项时保留智能体建议与自动调查记录(openDecision 会整体替换证据) + const prev = await exec + .selectFrom("decision") + .select(["evidence"]) + .where("dedupe_key", "=", s.key) + .where("status", "in", [...OPEN]) + .executeTakeFirst(); + const prevEv = (prev?.evidence ?? {}) as Record; + const kept = Object.fromEntries(KEEP_EVIDENCE.filter((k) => prevEv[k] !== undefined).map((k) => [k, prevEv[k]])); + const agentSuggestion = (prevEv.agentSuggestion as EpisodeDecisionEvidence["agentSuggestion"]) ?? null; + const latest = s.latest ?? releases[releases.length - 1]?.res ?? null; const seasonText = s.season === null ? "季未知" : `第 ${s.season} 季`; const groupText = s.releaseGroup ? ` · ${s.releaseGroup}` : ""; @@ -342,26 +360,59 @@ async function writeEpisodeDecision(ctx: ServerContext, exec: Exec, s: EpisodeDe })), ...(s.failedRules.length ? { failedRules: s.failedRules } : {}), suggestion, + ...kept, }, - options: episodeDecisionOptions({ season: s.season, releaseGroup: s.releaseGroup, suggestion }), + options: episodeDecisionOptions({ season: s.season, releaseGroup: s.releaseGroup, suggestion, agentSuggestion }), }); } -/** 待处理 episode_mapping 的处理选项键(批次 4,处理逻辑在 episode-mapping-admin.ts) */ +/** 待处理 episode_mapping 的处理选项键(批次 4 / 5,处理逻辑在 episode-mapping-admin.ts) */ export const EPISODE_DECISION_CONFIRM = "confirm_suggestion"; +export const EPISODE_DECISION_CONFIRM_AGENT = "confirm_agent_suggestion"; export const EPISODE_DECISION_IGNORE_RULE = "ignore_rule"; +/** 刷新事项时从旧证据里保留的字段 */ +const KEEP_EVIDENCE = ["agentSuggestion", "autoRecover", "agentInvestigation"] as const; + +/** 智能体建议(批次 5):模拟通过的草稿规则 + 理由,由用户在待处理里一键确认 */ +export interface EpisodeAgentSuggestion { + rule: EpisodeMappingRuleSpec; + reasons: string[]; + simulation: EpisodeSimulationSummary; + /** 样本套用前后(最多 10 条) */ + samples: { title: string; source: EpisodeResolution["source"]; after: EpisodeResultBrief }[]; + runId: string | null; + at: string; +} + export interface EpisodeDecisionEvidence { mediaEntityId?: string; season?: number | null; releaseGroup?: string | null; suggestion?: { expression: string; targetSeason: number; samples?: unknown[] } | null; + agentSuggestion?: EpisodeAgentSuggestion | null; +} + +/** 规则内容一句话(选项说明用) */ +export function ruleSpecText(r: EpisodeMappingRuleSpec): string { + if (r.kind === "expression") + return `按 ${r.expression ?? ""}${r.targetSeason != null ? ` 换算到第 ${r.targetSeason} 季` : ""}`; + if (r.kind === "table") return `按对照表(${r.tableEntries?.length ?? 0} 行)`; + return `映射为${r.special === "special" ? "特别篇" : r.special === "extra" ? "花絮" : "忽略"}`; } /** 开 / 刷新 episode_mapping 待处理时附的选项(批次 2 的 writeEpisodeDecision 调用) */ export function episodeDecisionOptions(ev: EpisodeDecisionEvidence): DecisionOption[] { const scope = { season: ev.season ?? null, releaseGroup: ev.releaseGroup ?? null }; const out: DecisionOption[] = []; + if (ev.agentSuggestion) { + out.push({ + key: EPISODE_DECISION_CONFIRM_AGENT, + label: "确认智能体建议", + description: `${ruleSpecText(ev.agentSuggestion.rule)},建规则前按当前资源再模拟一次,通过才写入并重算`, + input: { ...ev.agentSuggestion.rule }, + }); + } if (ev.suggestion) { out.push({ key: EPISODE_DECISION_CONFIRM, diff --git a/apps/server/test/sources-identity/episode-mapping-agent.test.ts b/apps/server/test/sources-identity/episode-mapping-agent.test.ts new file mode 100644 index 00000000..7be8bfd7 --- /dev/null +++ b/apps/server/test/sources-identity/episode-mapping-agent.test.ts @@ -0,0 +1,460 @@ +import { randomUUID } from "node:crypto"; +import { createMockTmdb, type MockTmdb, TMDB_FIXTURES, type TmdbFixture, type TmdbFixtureEpisode } from "@umt/testkit"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { createCapabilityRegistry } from "../../src/modules/agent/capability-registry.ts"; +import { + applyRecoveryOutcome, + autoInvestigate, + checkRecovery, + recoveryActions, +} from "../../src/modules/agent/investigate.ts"; +import { INVESTIGATE_CAPABILITIES, RECOVER_CAPABILITIES } from "../../src/modules/agent/runner.ts"; +import { registerSourcesCapabilities } from "../../src/modules/sources-identity/capabilities.ts"; +import { refreshMetadata } from "../../src/modules/sources-identity/service/metadata.ts"; +import { resolveRelease } from "../../src/modules/sources-identity/service/resolve.ts"; +import { createHarness, type Harness, json, pgAvailable, seedSite } from "./harness.ts"; + +// 集数映射的智能体兜底(docs/plan/episode-resolution.md 批次 5):episode_mapping.inspect 的上下文包与上限、 +// simulate、apply_rule 强制模拟(不通过 / 会让已对上的资源变待确认都拒绝且不写库;通过则写规则 + 重算 + 事项关闭)、 +// 后台调查接入(没有持续授权降级只读并留智能体建议 → 待处理一键确认;有授权时恢复动作按业务事实核实)。 +// 不调真实模型:按假模型会走的调用顺序直接经注册中心调用能力。 + +const pg = await pgAvailable(); +// biome-ignore lint/suspicious/noExplicitAny: 能力输出按 schema 校验过,测试里直接取字段 +type Any = any; + +const weekly = (n: number, start: string): TmdbFixtureEpisode[] => + Array.from({ length: n }, (_, k) => ({ + episode_number: k + 1, + air_date: new Date(Date.parse(start) + k * 7 * 86_400_000).toISOString().slice(0, 10), + names: { "zh-CN": `第 ${k + 1} 集`, "en-US": `Episode ${k + 1}` }, + })); + +// 已完结:S1 = 12(2023-10-22 起)、S2 = 13(2025-01-10 起,每周一集;E10 = 03-14、E11 = 03-21、E12 = 03-28) +const KUSURIYA: TmdbFixture = { + type: "tv", + id: 990102, + original_title: "Kusuriya no Hitorigoto", + original_language: "ja", + origin_country: ["JP"], + date: "2023-10-22", + titles: { "zh-CN": "药屋少女的呢喃", "en-US": "Kusuriya no Hitorigoto" }, + popularity: 30, + alternative_titles: [], + external_ids: {}, + rich: { status: "Ended" }, + seasons: [ + { season_number: 1, air_date: "2023-10-22", names: { "zh-CN": "第 1 季" }, episodes: weekly(12, "2023-10-22") }, + { season_number: 2, air_date: "2025-01-10", names: { "zh-CN": "第 2 季" }, episodes: weekly(13, "2025-01-10") }, + ], +} as TmdbFixture; + +const AIR = { 40: "2025-03-14T15:00:00Z", 41: "2025-03-21T15:00:00Z", 42: "2025-03-28T15:00:00Z" } as const; +const EP30 = { kind: "expression", expression: "EP-30", targetSeason: 2 } as const; + +describe.skipIf(!pg)("集数映射的智能体兜底", () => { + let h: Harness; + let tmdb: MockTmdb; + let siteId: string; + let n = 0; + let kId: string; + + beforeAll(async () => { + tmdb = await createMockTmdb({ fixtures: [...TMDB_FIXTURES, KUSURIYA] }); + h = await createHarness("episode_mapping_agent", { tmdbUrl: tmdb.url }); + const registry = createCapabilityRegistry({ logger: h.ctx.logger, db: h.db, settings: h.ctx.settings }); + registerSourcesCapabilities(registry, () => h.rt); + (h.ctx as { capabilities: unknown }).capabilities = registry; + siteId = (await seedSite(h, { baseUrl: "https://acgrip.example/" })).site.id; + await h.db + .insertInto("settings") + .values({ + key: "agent", + value: JSON.stringify({ autoInvestigate: true, autoInvestigateMode: "recover", investigateMaxAttempts: 3 }), + }) + .execute(); + }, 120_000); + afterAll(async () => { + await h?.close(); + await tmdb?.close(); + }); + + const resolve = async (title: string, publishedAt: string | null = null) => { + const r = await h.db + .insertInto("source_release") + .values({ + site_id: siteId, + site_release_id: String(820000 + ++n), + title, + subtitle: "药屋少女的呢喃", + source_published_at: publishedAt ? new Date(publishedAt) : null, + }) + .returningAll() + .executeTakeFirstOrThrow(); + const res = await resolveRelease(h.rt, { sourceReleaseId: r.id, trigger: "manual" }); + expect(res.outcome, JSON.stringify(res)).toBe("auto"); + return { id: r.id, entityId: res.mediaEntityId! }; + }; + /** 某发布组的连续编号 40 / 41 / 42(发布时间贴着 S02E10-12 的播出日期) */ + const continuous = async (group: string, eps: (40 | 41 | 42)[] = [40, 41, 42]) => { + const ids: string[] = []; + for (const e of eps) + ids.push((await resolve(`[${group}] Kusuriya no Hitorigoto - ${e} [WEB-DL][1080p]`, AIR[e])).id); + return ids; + }; + const decisionOf = async (group: string) => + h.db + .selectFrom("decision") + .selectAll() + .where("kind", "=", "episode_mapping") + .where("dedupe_key", "=", `episode_mapping:${kId}:1:${group.toLowerCase()}`) + .orderBy("created_at", "desc") + .executeTakeFirstOrThrow(); + const rulesOf = (group: string) => + h.db.selectFrom("episode_mapping_rule").selectAll().where("release_group", "=", group).execute(); + const methods = async (ids: string[]) => + (await h.db.selectFrom("source_release").select(["id", "episode_resolution"]).where("id", "in", ids).execute()).map( + (r) => (r.episode_resolution as { method: string }).method, + ); + const invoke = (id: string, input: Record, runId?: string) => + h.ctx.capabilities.invoke(id, input, { + actorId: runId ? "system:auto-investigate" : "user:admin", + taskId: randomUUID(), + scopeIds: [], + signal: new AbortController().signal, + source: runId ? "agent" : "web", + ...(runId ? { agentRunId: runId, invocationId: randomUUID() } : {}), + }) as Promise; + + let lilith: string[]; + it("准备:季表就绪,Lilith-Raws 连续编号 40-42 待确认、开一条待处理;同组 S02E09 原号已对上", async () => { + kId = (await resolve("[ANi] Kusuriya no Hitorigoto - 01 [1080P][Baha][WEB-DL][AAC AVC][CHT][MP4]")).entityId; + expect((await refreshMetadata(h.rt, kId, { full: true })).status).toBe("refreshed"); + lilith = await continuous("Lilith-Raws"); + await resolve("[Lilith-Raws] Kusuriya no Hitorigoto S02E09 [WEB-DL][1080p]", "2025-03-07T15:00:00Z"); + expect(await methods(lilith)).toEqual(["unresolved", "unresolved", "unresolved"]); + const d = await decisionOf("Lilith-Raws"); + expect(d.status).toBe("open"); + expect((d.evidence as Any).sourceReleaseIds.sort()).toEqual([...lilith].sort()); + }); + + it("inspect:事项内资源、同组相邻资源、季表(播出日期与标题)、规则、确定性建议", async () => { + const d = await decisionOf("Lilith-Raws"); + const out = await invoke("episode_mapping.inspect", { decisionId: d.id }); + expect(out.entity).toMatchObject({ id: kId, kind: "series" }); + expect(out.scope).toEqual({ season: 1, releaseGroup: "Lilith-Raws" }); + expect(out.decision).toMatchObject({ id: d.id, status: "open", failedRules: [] }); + expect(out.releases).toHaveLength(3); + expect(out.releases[0]).toMatchObject({ + releaseGroup: "Lilith-Raws", + source: { season: 1, episodes: { start: 42, end: 42 } }, + current: { method: "unresolved", canonical: null }, + }); + expect(out.releases[0].publishedAt).toBe("2025-03-28T15:00:00.000Z"); + // 相邻资源:同组已对上的 S02E09;别的发布组(ANi)不在其中 + expect(out.neighbors).toHaveLength(1); + expect(out.neighbors[0]).toMatchObject({ + source: { season: 2, episodes: { start: 9, end: 9 } }, + current: { method: "raw", canonical: { season: 2, episodes: { start: 9, end: 9 } } }, + }); + const s2 = out.seasons.find((s: Any) => s.season === 2); + expect(s2.total).toBe(13); + expect(s2.episodes[9]).toMatchObject({ episode: 10, airDate: "2025-03-14", title: "第 10 集" }); + expect(out).toMatchObject({ latestSeason: 2, ended: true, rules: [] }); + expect(out.suggestion).toMatchObject({ + season: 1, + releaseGroup: "Lilith-Raws", + expression: "EP-30", + targetSeason: 2, + }); + expect(out.truncated).toEqual({ releases: false, neighbors: false }); + }); + + it("inspect 有上限:相邻资源最多 50 条并标出截断;不给事项时按作品 + 发布组取", async () => { + const rows = Array.from({ length: 55 }, (_, k) => ({ + site_id: siteId, + site_release_id: `cap-${k}`, + title: `[Cap-Group] Kusuriya no Hitorigoto - ${String((k % 12) + 1).padStart(2, "0")} [${k}]`, + media_entity_id: kId, + resolution_status: "resolved" as const, + episode_resolution: JSON.stringify({ + method: "raw", + source: { season: 1, episodes: { start: (k % 12) + 1, end: (k % 12) + 1 } }, + canonical: { season: 1, episodes: { start: (k % 12) + 1, end: (k % 12) + 1 } }, + evidence: ["validate:pass"], + }), + })); + await h.db.insertInto("source_release").values(rows).execute(); + const out = await invoke("episode_mapping.inspect", { mediaEntityId: kId, season: 1, releaseGroup: "cap-group" }); + expect(out.decision).toBeNull(); + expect(out.releases).toEqual([]); + expect(out.neighbors).toHaveLength(50); + expect(out.truncated).toEqual({ releases: false, neighbors: true }); + // 每条只带判断用的字段 + expect(Object.keys(out.neighbors[0]).sort()).toEqual( + ["current", "evidence", "publishedAt", "releaseGroup", "source", "sourceReleaseId", "title"].sort(), + ); + }); + + it("simulate:草稿越界 → 没通过并写明原因;EP-30 → 样本全部解决;纯读,控制台调用不留建议", async () => { + const d = await decisionOf("Lilith-Raws"); + const bad = await invoke("episode_mapping.simulate", { decisionId: d.id, rule: { ...EP30, expression: "EP-20" } }); + expect(bad.summary).toMatchObject({ samples: 3, resolved: 0, ok: false }); + expect(bad.failures.join("")).toContain("仍对不上季表"); + const ok = await invoke("episode_mapping.simulate", { + decisionId: d.id, + rule: EP30, + reasons: ["发布时间对上播出日期"], + }); + expect(ok.rule).toMatchObject({ season: 1, releaseGroup: "Lilith-Raws", expression: "EP-30", targetSeason: 2 }); + expect(ok.summary).toMatchObject({ samples: 3, resolved: 3, passed: 3, regressions: 0, missing: 0, ok: true }); + expect(ok.failures).toEqual([]); + const i40 = ok.items.find((i: Any) => i.sourceReleaseId === lilith[0]); + expect(i40).toMatchObject({ + sample: true, + before: { method: "unresolved" }, + after: { method: "rule", canonical: { season: 2, episodes: { start: 10, end: 10 } } }, + validation: "pass", + }); + expect(ok.suggestionRecorded).toBe(false); + expect(await rulesOf("Lilith-Raws")).toHaveLength(0); + expect((await decisionOf("Lilith-Raws")).evidence).not.toHaveProperty("agentSuggestion"); + }); + + it("apply_rule:模拟不通过被拒,不写规则、资源与事项不变", async () => { + const d = await decisionOf("Lilith-Raws"); + await expect( + invoke("episode_mapping.apply_rule", { + decisionId: d.id, + rule: { ...EP30, expression: "EP-20" }, + reasons: ["试试"], + }), + ).rejects.toThrow(/模拟没通过/); + expect(await rulesOf("Lilith-Raws")).toHaveLength(0); + expect(await methods(lilith)).toEqual(["unresolved", "unresolved", "unresolved"]); + expect((await decisionOf("Lilith-Raws")).status).toBe("open"); + }); + + it("apply_rule:会让已对上的资源变成待确认的规则被拒(同组 S01E05 原号已对上)", async () => { + await resolve("[Sakurato] Kusuriya no Hitorigoto - 05 [WEB-DL][1080p]", "2023-11-19T15:00:00Z"); + const ids = await continuous("Sakurato", [40, 41]); + const d = await decisionOf("Sakurato"); + const sim = await invoke("episode_mapping.simulate", { decisionId: d.id, rule: EP30 }); + expect(sim.summary).toMatchObject({ samples: 2, resolved: 2, regressions: 1, ok: false }); + expect(sim.failures.join("")).toContain("已对上的资源变成待确认"); + await expect( + invoke("episode_mapping.apply_rule", { decisionId: d.id, rule: EP30, reasons: ["连续编号"] }), + ).rejects.toThrow(/已对上的资源变成待确认/); + expect(await rulesOf("Sakurato")).toHaveLength(0); + expect(await methods(ids)).toEqual(["unresolved", "unresolved"]); + }); + + it("后台调查没有持续授权:降级只读,模拟通过的规则留作智能体建议;写规则被拒;待处理一键确认", async () => { + const d = await decisionOf("Lilith-Raws"); + const r = await autoInvestigate(h.ctx, d.id); + expect(r).toMatchObject({ created: true, mode: "investigate" }); + const run = await h.db.selectFrom("agent_run").selectAll().where("id", "=", r.runId!).executeTakeFirstOrThrow(); + expect(run).toMatchObject({ trigger: "event", mode: "plan_only" }); + expect(run.scope).toMatchObject({ capabilities: [...INVESTIGATE_CAPABILITIES] }); + expect((run.scope as Any).capabilities).toEqual( + expect.arrayContaining(["episode_mapping.inspect", "episode_mapping.simulate"]), + ); + expect((run.scope as Any).capabilities).not.toContain("episode_mapping.apply_rule"); + expect(run.goal).toContain("episode_mapping.inspect"); + const inv = await decisionOf("Lilith-Raws"); + expect(inv.status).toBe("investigating"); + expect((inv.evidence as Any).autoRecover).toMatchObject({ mode: "investigate", requestedMode: "recover" }); + expect((inv.evidence as Any).autoRecover.degraded.reason).toContain("episode_mapping.apply_rule"); + + // 假模型:inspect → simulate(带理由)→ 试图写规则 + await invoke("episode_mapping.inspect", { decisionId: d.id }, run.id); + const sim = await invoke( + "episode_mapping.simulate", + { decisionId: d.id, rule: EP30, reasons: ["40-42 的发布时间分别对上第 2 季第 10-12 集的播出日期"] }, + run.id, + ); + expect(sim).toMatchObject({ suggestionRecorded: true, summary: { ok: true } }); + await expect( + invoke("episode_mapping.apply_rule", { decisionId: d.id, rule: EP30, reasons: ["对上播出日期"] }, run.id), + ).rejects.toThrow(/授权/); + expect(await rulesOf("Lilith-Raws")).toHaveLength(0); + + const withSug = await decisionOf("Lilith-Raws"); + expect((withSug.evidence as Any).agentSuggestion).toMatchObject({ + rule: { season: 1, releaseGroup: "Lilith-Raws", kind: "expression", expression: "EP-30", targetSeason: 2 }, + reasons: ["40-42 的发布时间分别对上第 2 季第 10-12 集的播出日期"], + simulation: { samples: 3, resolved: 3, ok: true }, + runId: run.id, + }); + expect((withSug.evidence as Any).agentSuggestion.samples).toHaveLength(3); + expect((withSug.options as Any[]).map((o) => o.key)).toEqual([ + "confirm_agent_suggestion", + "confirm_suggestion", + "ignore_rule", + ]); + + // 新资源进来刷新事项:建议与自动调查记录保留 + const more = await resolve("[Lilith-Raws] Kusuriya no Hitorigoto - 43 [WEB-DL][1080p]", "2025-04-04T15:00:00Z"); + const refreshed = await decisionOf("Lilith-Raws"); + expect(refreshed.id).toBe(d.id); + expect((refreshed.evidence as Any).sourceReleaseIds).toContain(more.id); + expect((refreshed.evidence as Any).agentSuggestion).toMatchObject({ runId: run.id }); + expect((refreshed.evidence as Any).autoRecover).toMatchObject({ mode: "investigate" }); + expect((refreshed.options as Any[])[0].key).toBe("confirm_agent_suggestion"); + + // 待处理「确认智能体建议」:按当前资源再模拟(43 → S02E13 也对得上)→ 建规则 + 重算 + 事项关闭 + const body = await json( + await h.req(`/decisions/${d.id}/resolve`, { body: { optionKey: "confirm_agent_suggestion" } }), + ); + expect(body.status, JSON.stringify(body.body)).toBe(200); + expect(body.body.data).toMatchObject({ + status: "resolved", + resolution: { optionKey: "confirm_agent_suggestion", execution: { reresolve: { resolved: 4 } } }, + }); + const rules = await rulesOf("Lilith-Raws"); + expect(rules).toHaveLength(1); + expect(rules[0]).toMatchObject({ expression: "EP-30", created_by_type: "user", evidence_level: "manual" }); + expect(rules[0]!.evidence).toMatchObject({ + decisionId: d.id, + agentSuggestion: { runId: run.id }, + simulation: { samples: 4, ok: true }, + }); + expect(await methods([...lilith, more.id])).toEqual(["rule", "rule", "rule", "rule"]); + }); + + it("确认智能体建议前再模拟:现在不通过就拒绝,不写规则", async () => { + const ids = await continuous("Kirara", [40, 41]); + const d = await decisionOf("Kirara"); + // 直接写一条现在模拟不通过的建议(如季表变了之后) + await h.db + .updateTable("decision") + .set({ + evidence: JSON.stringify({ + ...(d.evidence as object), + agentSuggestion: { + rule: { season: 1, releaseGroup: "Kirara", kind: "expression", expression: "EP-20", targetSeason: 2 }, + reasons: ["旧建议"], + simulation: { + samples: 2, + resolved: 2, + passed: 2, + missing: 0, + regressions: 0, + changed: 2, + scanned: 2, + ok: true, + }, + samples: [], + runId: null, + at: new Date().toISOString(), + }, + }), + options: JSON.stringify([{ key: "confirm_agent_suggestion", label: "确认智能体建议" }]), + }) + .where("id", "=", d.id) + .execute(); + const r = await json( + await h.req(`/decisions/${d.id}/resolve`, { body: { optionKey: "confirm_agent_suggestion" } }), + ); + expect(r.status).toBe(409); + expect(r.body.error.message).toContain("模拟不通过"); + expect(await rulesOf("Kirara")).toHaveLength(0); + expect(await methods(ids)).toEqual(["unresolved", "unresolved"]); + expect((await decisionOf("Kirara")).status).toBe("open"); + }); + + it("后台调查有持续授权:inspect → simulate → apply_rule 写规则(智能体 / 多证据)+ 重算 + 事项关闭,系统核实已恢复", async () => { + await h.db + .insertInto("automation_grant") + .values({ action: "episode_mapping.apply_rule", granted_by: "admin:test", reason: "集数映射兜底" }) + .execute(); + const ids = await continuous("Nekomoe"); + const d = await decisionOf("Nekomoe"); + const r = await autoInvestigate(h.ctx, d.id); + expect(r).toMatchObject({ created: true, mode: "recover" }); + const run = await h.db.selectFrom("agent_run").selectAll().where("id", "=", r.runId!).executeTakeFirstOrThrow(); + expect(run).toMatchObject({ mode: "execute", created_by: "system:auto-recover" }); + expect(run.scope).toMatchObject({ capabilities: [...RECOVER_CAPABILITIES] }); + expect((run.scope as Any).capabilities).toContain("episode_mapping.apply_rule"); + expect(run.goal).toContain("episode_mapping.apply_rule"); + + await invoke("episode_mapping.inspect", { decisionId: d.id }, run.id); + expect((await invoke("episode_mapping.simulate", { decisionId: d.id, rule: EP30 }, run.id)).summary.ok).toBe(true); + const out = await invoke( + "episode_mapping.apply_rule", + { decisionId: d.id, rule: EP30, reasons: ["发布时间对上第 2 季第 10-12 集播出日期", "同组 S02E09 已按原号对上"] }, + run.id, + ); + expect(out).toMatchObject({ + evidenceLevel: "multi_evidence", + summary: { samples: 3, resolved: 3, ok: true }, + reresolve: { resolved: 3 }, + decisionStatus: "resolved", + }); + const rules = await rulesOf("Nekomoe"); + expect(rules).toHaveLength(1); + expect(rules[0]).toMatchObject({ + season: 1, + kind: "expression", + expression: "EP-30", + target_season: 2, + status: "active", + created_by_type: "agent", + created_by_id: run.id, + evidence_level: "multi_evidence", + }); + expect(rules[0]!.evidence).toMatchObject({ + decisionId: d.id, + reasons: ["发布时间对上第 2 季第 10-12 集播出日期", "同组 S02E09 已按原号对上"], + samples: 3, + simulation: { ok: true }, + runId: run.id, + }); + expect(await methods(ids)).toEqual(["rule", "rule", "rule"]); + + // 系统核实:事项内资源都已不是待确认 + const acts = await recoveryActions(h.ctx, run.id); + expect(acts.actions).toEqual([ + expect.objectContaining({ capabilityId: "episode_mapping.apply_rule", status: "succeeded" }), + ]); + const check = await checkRecovery(h.ctx, acts.actions); + expect(check).toMatchObject({ state: "recovered", facts: { [`decision:${d.id}`]: { unresolved: 0 } } }); + const rec = await applyRecoveryOutcome( + h.ctx, + run.id, + { status: "completed", reason: null, budget: null, message: null, finalText: "已写入规则。" }, + acts, + check, + ); + expect(rec).toMatchObject({ decisionId: d.id, outcome: "recovered" }); + expect(rec!.summary).toMatch(/^已恢复(系统核实)/); + const closed = await decisionOf("Nekomoe"); + expect(closed.status).toBe("resolved"); + expect((closed.evidence as Any).autoRecover).toMatchObject({ outcome: "recovered", recoverAttempted: true }); + }); + + it("用户手动停用智能体写的规则后:资源回到待确认,同作用域不再自动写", async () => { + const rule = (await rulesOf("Nekomoe"))[0]!; + const off = await json( + await h.req(`/episode-mapping-rules/${rule.id}`, { method: "PATCH", body: { status: "disabled" } }), + ); + expect(off.status, JSON.stringify(off.body)).toBe(200); + const d = await decisionOf("Nekomoe"); + expect(d.status).toBe("open"); + await expect( + invoke("episode_mapping.apply_rule", { decisionId: d.id, rule: EP30, reasons: ["再写一次"] }), + ).rejects.toThrow(/手动停用/); + expect((await rulesOf("Nekomoe")).map((r) => r.status)).toEqual(["disabled"]); + }); + + it("恢复核实:写规则没解决事项内资源时不算恢复", async () => { + const check = await checkRecovery(h.ctx, [ + { + capabilityId: "episode_mapping.apply_rule", + input: { decisionId: (await decisionOf("Sakurato")).id }, + status: "succeeded", + }, + ]); + expect(check.state).toBe("pending"); + }); +}); diff --git a/apps/server/test/sources-identity/site-capabilities.test.ts b/apps/server/test/sources-identity/site-capabilities.test.ts index d6015137..81a0ae7f 100644 --- a/apps/server/test/sources-identity/site-capabilities.test.ts +++ b/apps/server/test/sources-identity/site-capabilities.test.ts @@ -15,8 +15,15 @@ const TiB = 1024 ** 4; const SITE_IDS = ["site.list", "site.health_check", "site.refresh_login", "site.user_data"] as const; describe("站点能力规格", () => { - it("进入能力目录(41 个已有能力 + 整理元数据补全 + 起草巡检);类别 / 风险 / 重放按约定", () => { - expect(CAPABILITY_IDS).toHaveLength(43); + it("进入能力目录(41 个已有能力 + 整理元数据补全 + 起草巡检 + 集数映射 3 个);类别 / 风险 / 重放按约定", () => { + expect(CAPABILITY_IDS).toHaveLength(46); + expect(capabilitySpec("episode_mapping.inspect")).toMatchObject({ kind: "read", replay: "safe" }); + expect(capabilitySpec("episode_mapping.simulate")).toMatchObject({ kind: "preview", replay: "safe" }); + expect(capabilitySpec("episode_mapping.apply_rule")).toMatchObject({ + kind: "execute", + riskLevel: "medium", + replay: "never", + }); expect(capabilitySpec("metadata.resolve_organize")).toMatchObject({ kind: "execute", replay: "never" }); expect(capabilitySpec("schedule.draft_check")).toMatchObject({ kind: "propose", diff --git a/apps/web/app/components/episode-mapping/decision.test.ts b/apps/web/app/components/episode-mapping/decision.test.ts index a21b72e5..38a202ad 100644 --- a/apps/web/app/components/episode-mapping/decision.test.ts +++ b/apps/web/app/components/episode-mapping/decision.test.ts @@ -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 => @@ -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 条已对上的资源", + ); + }); }); diff --git a/apps/web/app/components/episode-mapping/decision.tsx b/apps/web/app/components/episode-mapping/decision.tsx index 1c8c0244..51ba6585 100644 --- a/apps/web/app/components/episode-mapping/decision.tsx +++ b/apps/web/app/components/episode-mapping/decision.tsx @@ -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 ( +
+
+ {RULE_KIND_LABEL[s.rule.kind]}: + {ruleText(s.rule)} + {scopeText(s.rule.season, s.rule.releaseGroup)} +
+ {s.reasons.length ? ( +
    + {s.reasons.map((r) => ( +
  • {r}
  • + ))} +
+ ) : null} +
    + {s.samples.slice(0, 3).map((x) => ( +
  • + {sourceText({ source: x.source })} → {canonicalText({ ...x.after, special: x.after.special ?? undefined })} +
  • + ))} +
+
{agentSimulationText(s.simulation)}
+
+ ); +} + /** 展示部分(纯渲染,单测直接渲染) */ 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} + {view.agentSuggestion ? ( + <> +
智能体建议
+
+ +
+ + ) : null}
建议规则
{view.suggestion ? ( @@ -191,10 +264,32 @@ export function EpisodeDecision({ d, open }: { d: Decision; open: boolean }) { {open ? (
- {view.confirm ? ( + {view.confirmAgent && view.agentSuggestion ? ( + + 确认智能体建议 + + } + 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 ? ( + 确认 diff --git a/apps/web/app/components/episode-mapping/mapping-panel.tsx b/apps/web/app/components/episode-mapping/mapping-panel.tsx index 504de01e..99cd777d 100644 --- a/apps/web/app/components/episode-mapping/mapping-panel.tsx +++ b/apps/web/app/components/episode-mapping/mapping-panel.tsx @@ -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; }) { const reason = disabledReasonText(rule); + const agent = agentRuleInfo(rule); const run = (vars: Parameters[0], verb: string) => write.mutateAsync(vars).then( (r) => toast.success(reresolveToast(r.reresolve, verb)), @@ -97,13 +99,21 @@ function RuleRow({ {rule.status === "active" ? "启用" : "停用"} + {agent ? {agent.label} : null} {editing ? 修改中 : null}
{RULE_KIND_LABEL[rule.kind]}:{ruleText(rule)} - {" · "} - {actorText(rule.createdById ? `${rule.createdByType}:${rule.createdById}` : rule.createdByType)} + {agent ? null : ( + <> + {" · "} + {actorText(rule.createdById ? `${rule.createdByType}:${rule.createdById}` : rule.createdByType)} + + )}
+ {agent?.reasons.length ? ( +
依据:{agent.reasons.join(";")}
+ ) : null} {reason ?
{reason}
: null}
diff --git a/apps/web/app/components/episode-mapping/model.test.ts b/apps/web/app/components/episode-mapping/model.test.ts index c62de1cb..c6350228 100644 --- a/apps/web/app/components/episode-mapping/model.test.ts +++ b/apps/web/app/components/episode-mapping/model.test.ts @@ -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) => + ({ 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(); + }); +}); diff --git a/apps/web/app/components/episode-mapping/model.ts b/apps/web/app/components/episode-mapping/model.ts index b8fbe016..f6a556f3 100644 --- a/apps/web/app/components/episode-mapping/model.ts +++ b/apps/web/app/components/episode-mapping/model.ts @@ -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, +): { 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): string | null { if (rule.status !== "disabled") return null; diff --git a/docs/plan/episode-resolution.md b/docs/plan/episode-resolution.md index a1aa29b7..6d6b5a8e 100644 --- a/docs/plan/episode-resolution.md +++ b/docs/plan/episode-resolution.md @@ -46,7 +46,7 @@ ANI-RSS(GPL-2.0,只借思路不抄代码)摸排结论:一条订阅 = 一 | 2 存储与识别接入 | 迁移(`episode_mapping_rule` 表、`source_release.episode_resolution`);季表加载;识别(`resolve.ts` 及其他 `ensureUnits` 调用方)接入;`parsedOf` 叠加;订阅评估跳过待确认;待处理 `episode_mapping`;规则失效;按作品重算 | server pg 测试:ANi 连续编号进库即为 S02E01 并能被订阅覆盖;规则失效开待处理;滞后放行不卡追更 | | 3 文件侧接入 | 整理计划、季包选文件、接管、文件管理识别统一套用同一作品的映射 | 下载按 S02E01 计划、文件名 ` - 13` 整理后命名为 S02E01;季包按缺集选对文件 | | 4 接口与界面 | contracts 端点:规则增删改、模拟预览(给定作品 / 季 / 发布组 / 草稿规则,列近期资源的原始 → 规范与校验结果);待处理 `episode_mapping` 的确认 / 修改 / 忽略;作品页与订阅详情「集数映射」面板 | 契约测试、web 单测、冒烟;文案过 `check:copy` | -| 5 Agent 兜底 | 能力 `episode_mapping.propose`(只读上下文包 → 结构化规则建议)与 `episode_mapping.apply`(经授权写规则,必须先模拟全部样本通过);登记到 Skill 与能力地图;`episode_mapping` 待处理接后台自动调查 | agent 测试;模拟不通过的建议不能写入 | +| 5 Agent 兜底 | 能力 `episode_mapping.inspect`(只读上下文包)、`episode_mapping.simulate`(试草稿规则)与 `episode_mapping.apply_rule`(经授权写规则,服务端强制先模拟全部样本通过、不误伤已对上的资源);Skill「集数映射」与能力地图;`episode_mapping` 待处理接后台自动调查,没有授权时留「智能体建议」一键确认 | agent 测试;模拟不通过的建议不能写入 | 以后再做:TMDB Episode Group、Bangumi `sort / ep` 对照、综艺会员加更等变体细分。 @@ -56,4 +56,4 @@ ANI-RSS(GPL-2.0,只借思路不抄代码)摸排结论:一条订阅 = 一 - [x] 批次 2 - [x] 批次 3 - [x] 批次 4 -- [ ] 批次 5 +- [x] 批次 5 diff --git a/docs/runbook/identity.md b/docs/runbook/identity.md index a4ee26b1..068ff281 100644 --- a/docs/runbook/identity.md +++ b/docs/runbook/identity.md @@ -156,7 +156,16 @@ select key, value->>'title' title, value->>'year' y, value->>'source' src from k - 修改:跳到作品页集数映射并预填草稿(`?mapSeason=&mapGroup=&mapExpr=&mapTarget=#episode-mapping`),在那里改完再应用。 - 忽略:只关闭提醒,资源仍待确认;勾选「同时建忽略规则」时走选项 `ignore_rule`,建 special=ignore 规则,这一季这个发布组的资源以后不参与集数解析。 -处理走 `POST /decisions/{id}/resolve` 的选项,由 sources-identity 直接执行(与身份类事项同一做法),没有新增智能体能力;智能体自动提规则在批次 5。订阅详情有该作品 + 季的方式统计与待确认数;新建 / 编辑订阅时如果近期资源里有发布组用绝对集号,表单里提示一句。 +处理走 `POST /decisions/{id}/resolve` 的选项,由 sources-identity 直接执行(与身份类事项同一做法)。订阅详情有该作品 + 季的方式统计与待确认数;新建 / 编辑订阅时如果近期资源里有发布组用绝对集号,表单里提示一句。 + +**智能体兜底**(批次 5):确定性流程(规则 → 原号校验 → 换算)都失败、开了「集数待确认」后,智能体在完整上下文里判断一次,产出一条结构化规则;写入后同类资源由规则自动处理,智能体不逐集调用。 + +- 何时触发:设置 → 智能体打开「自动调查」时,新开的 `episode_mapping` 事项和下载失败一样进后台调查(`decision.created` → `resolveDecision` → `autoInvestigate`),同一事项只跑一次。对话里也可以直接说「《X》第二季集数对不上」,走同样三个能力(Skill「集数映射」)。 +- 三个能力:`episode_mapping.inspect`(只读:事项内资源、同组相邻资源各最多 50 条,季表每季最多 100 集含播出日期与集标题,现有规则含停用原因,确定性建议)→ `episode_mapping.simulate`(只读:复用模拟预览,汇总事项内样本是否全部解决、有没有让已对上的资源变成待确认)→ `episode_mapping.apply_rule`(写)。 +- `apply_rule` 服务端强制再模拟:样本有一条没解决、或会让当前已对上 / 已换算的资源变成待确认,直接拒绝不写库。通过才写规则:`created_by_type = agent`,样本 ≥ 3 且全部对得上记「多证据」(`multi_evidence`),否则「待复核」(`needs_review`);证据存理由、样本数、模拟摘要与会话 ID;随后按作品重算,资源都解决时事项自动关闭,系统据此核实恢复。 +- 需要什么授权:后台自动调查的模式要是「授权内恢复」(`agent.autoInvestigateMode = recover`),且有一条覆盖 `episode_mapping.apply_rule` 的有效持续授权(action 写 `episode_mapping.apply_rule` 或 `*`;可用 `scope.mediaEntityIds / mediaKinds` 限定作品,按事项的作品核对;不随身份识别策略放开)。对话里没有授权时同样被拒,智能体只会说「需要你批准」。 +- 没有授权(或模式是只读调查)时降级只读:智能体在会话里用 `simulate`(带事项 ID 和理由)试出的通过规则,会作为「智能体建议」写进事项证据(`evidence.agentSuggestion`,事项刷新时保留),待处理卡片显示规则、理由、样本套用后的季集与模拟结果,点「确认智能体建议」(选项 `confirm_agent_suggestion`)即建规则 + 重算;建之前按当前资源再模拟一次,不通过返回 409 不写。人工确认的规则记操作者为管理员、证据等级「人工确认」。 +- 怎么撤销:作品页「集数映射」里智能体写的规则带「智能体 · 多证据 / 待复核」标记和依据,点「停用」:立即按作品重算(资源回到待确认并重新开事项),且同作用域有手动停用的规则时 `apply_rule` 一律拒绝,智能体不会再自动写回(仍可留建议给人确认)。直接删除不留这个记号,新开的事项可能再被自动写一次(受同根因自动恢复次数上限 `agent.investigateMaxAttempts` 约束)。要彻底停掉自动写规则,撤销那条持续授权或把自动调查模式改回只读调查。 ## 池的治理 diff --git a/packages/agent/skills/episode-mapping/SKILL.md b/packages/agent/skills/episode-mapping/SKILL.md new file mode 100644 index 00000000..4ea5bee2 --- /dev/null +++ b/packages/agent/skills/episode-mapping/SKILL.md @@ -0,0 +1,56 @@ +--- +name: episode-mapping +title: 集数映射 +description: 资源的集数对不上作品季表(绝对集号、连续编号、特别篇混在正片里)时,看清上下文、试一条结构化的映射规则,模拟全部通过才写入,写入后同类资源由规则自动处理。集数待确认事项的后台调查,或用户说「某部剧集数不对」「集数待确认」时用。 +triggers: + - 集数(待确认|对不上|不对|错了?|映射|乱了) + - (绝对|连续)(集号|编号)|集号(不对|对不上|错) + - 映射规则 + - 认成.{0,8}第.{0,4}集|第.{0,4}集.{0,8}(认成|变成|对不上) +capabilities: + - episode_mapping.inspect + - episode_mapping.simulate + - episode_mapping.apply_rule + - decision.list + - catalog.lookup +sessions: [chat, investigate, child] +--- + +## 适用意图 + +- 后台调查「集数待确认」事项:规则、原号校验、绝对集号 / 播出日期换算都没对上,要判断这个发布组这一季该怎么映射。 +- 用户在对话里说「《药屋少女》第二季集数不对」「Lilith 的连续编号帮我对一下」「这部剧集数待确认怎么处理」。 + +## 步骤 + +1. 定位:后台调查直接用事项 ID;对话里先 decision.list 找这部作品的集数待确认事项,没有事项时用 catalog.lookup 拿作品 ID,再按作品 + 来源季 + 发布组看。 +2. 先 episode_mapping.inspect:看事项内资源的原标题、发布时间、原始季集,同组相邻资源已经怎么对上的,季表每季总集数与每集播出日期,现有规则(停用的看停用原因),以及确定性建议。 +3. 想规则,按优先级: + - 首选 expression(偏移表达式):连续编号、整体差一个固定数,如 `EP-12` 换到第 2 季、`EP*2-1`;确定性建议给了就从它开始。 + - 其次 table(对照表):编号不规则、只有少数几集要对,逐行写 `来源集号 → 第几季第几集`。 + - 特别篇、总集篇、花絮、PV 用 special(特别篇 / 花絮 / 忽略),不要硬塞成某一集。 + - 作用域默认用事项的来源季和发布组;只有确信所有发布组都这么编号时才放宽到任意发布组。 +4. 再 episode_mapping.simulate(带事项 ID 和一两句理由):看事项内样本是否全部解决、有没有让已对上的资源变成待确认。没通过就按失败原因改规则再试,最多试三次。 +5. 通过且有把握时,episode_mapping.apply_rule 写一次(带理由:依据哪些播出日期 / 标题 / 相邻资源)。服务端会再模拟一次,不通过会拒绝。写入后同类资源由规则自动处理,不要逐集再调用。 +6. 拿不准(样本太少、几种规则都说得通、季表缺集)就不写:只读调查里模拟通过的规则会自动作为「智能体建议」留在事项上,让用户确认;说清楚还差什么证据。 + +## 需要批准 + +- episode_mapping.apply_rule(写映射规则)需要持续授权;没有授权时写一句「没做:写集数映射规则,需要你批准」,模拟通过的规则已留在待处理里可一键确认。 +- episode_mapping.inspect、episode_mapping.simulate 只读,不需要批准。 + +## 汇报模板 + +> 《药屋少女的呢喃》第 1 季 · Lilith-Raws 的 40、41 集是连续编号,已按「减 30,换到第 2 季」写入规则,两条资源分别对上第 2 季第 10、11 集,播出日期一致。 + +> 《药屋少女的呢喃》第 1 季 · Lilith-Raws 的集数还没定:模拟通过的规则是「减 30,换到第 2 季」,已作为智能体建议留在待处理里,确认后生效。 + +- 第一句写结论(写了什么规则 / 为什么没写),第二句写依据(播出日期、标题、相邻资源),需要用户做的放最后。 + +## 不要做 + +- 不要绕过模拟:没跑过 episode_mapping.simulate 或模拟没通过,不调用 episode_mapping.apply_rule。 +- 不要逐集处理,也不要为每条资源各写一条规则:一条规则覆盖这个发布组这一季。 +- 不要把特别篇、总集篇硬算成正片集号。 +- 不要改别的发布组、别的季已经对上的资源;模拟显示会让已对上的资源变成待确认就换规则。 +- 汇报里不写规则 ID、资源 ID 和能力名。 diff --git a/packages/agent/src/capability/tool-name.ts b/packages/agent/src/capability/tool-name.ts index 70967c10..de9c9e13 100644 --- a/packages/agent/src/capability/tool-name.ts +++ b/packages/agent/src/capability/tool-name.ts @@ -4,7 +4,7 @@ import { CapabilityId } from "@umt/contracts"; * 能力 ID ↔ 模型工具名 的唯一映射。 * 上游(Anthropic/OpenAI)工具名只允许 ^[a-zA-Z0-9_-]{1,64}$,不能带点, * 所以 `download.submit` 对 Pi 暴露为 `download__submit`。 - * 域名部分不含下划线,反向映射按第一个 `__` 切分即可无歧义。 + * 域名部分最多含单个下划线分词(episode_mapping),不含连续下划线,反向映射按第一个 `__` 切分即可无歧义。 */ const TOOL_NAME_SEPARATOR = "__"; const TOOL_NAME_PATTERN = /^[a-zA-Z0-9_-]{1,64}$/; diff --git a/packages/agent/src/prompt/layers/capabilities.ts b/packages/agent/src/prompt/layers/capabilities.ts index c98556a7..7e9f17f5 100644 --- a/packages/agent/src/prompt/layers/capabilities.ts +++ b/packages/agent/src/prompt/layers/capabilities.ts @@ -67,6 +67,17 @@ export const INTENT_ROUTES: readonly IntentRoute[] = [ { cap: "organize.execute", note: "执行整理" }, ], }, + { + intent: "集数对不上、集数待确认(绝对集号、连续编号、特别篇)", + steps: [ + { cap: "episode_mapping.inspect", note: "事项内资源、同组相邻资源、季表、现有规则与确定性建议" }, + { cap: "episode_mapping.simulate", note: "试草稿规则,看样本是否全部解决、有没有误伤已对上的资源" }, + { cap: "episode_mapping.apply_rule", note: "模拟通过才写规则,写入后同类资源自动处理" }, + ], + tips: [ + "优先 expression(偏移表达式),其次 table(对照表),特别篇 / 花絮用 special;拿不准就不写,只把模拟通过的规则留作建议。", + ], + }, { intent: "问订阅、追更、缺集", steps: [ @@ -171,7 +182,7 @@ export function renderRoutes(available: ReadonlySet): string[] { const chain = steps.map((s) => `${s.cap}(${s.note}${needsApproval(s.cap) ? ",需要批准" : ""})`).join(" → "); out.push(`- ${r.intent}:${chain}`); for (const t of r.tips ?? []) { - const caps = t.match(/\b[a-z]+\.[a-z_]+\b/g) ?? []; + const caps = t.match(/\b[a-z][a-z_]*\.[a-z_]+\b/g) ?? []; if (caps.every((c) => !(c in CAPABILITY_CATALOG) || available.has(c))) out.push(` - ${t}`); } } diff --git a/packages/agent/test/registry.test.ts b/packages/agent/test/registry.test.ts index 001a54bf..d5714091 100644 --- a/packages/agent/test/registry.test.ts +++ b/packages/agent/test/registry.test.ts @@ -16,7 +16,12 @@ describe("能力 ID ↔ 工具名", () => { expect(capabilityIdToToolName("task.retry_later")).toBe("task__retry_later"); expect(toolNameToCapabilityId("download__submit")).toBe("download.submit"); expect(toolNameToCapabilityId("task__retry_later")).toBe("task.retry_later"); - for (const id of ["catalog.lookup", "policy.apply", "organize.execute"]) { + // 域名可用单个下划线分词:按第一个 `__` 切分仍无歧义 + expect(capabilityIdToToolName("episode_mapping.apply_rule")).toBe("episode_mapping__apply_rule"); + expect(toolNameToCapabilityId("episode_mapping__apply_rule")).toBe("episode_mapping.apply_rule"); + expect(() => capabilityIdToToolName("episode__mapping.inspect")).toThrow(); + expect(() => capabilityIdToToolName("episode_.inspect")).toThrow(); + for (const id of ["catalog.lookup", "policy.apply", "organize.execute", "episode_mapping.inspect"]) { expect(toolNameToCapabilityId(capabilityIdToToolName(id))).toBe(id); } expect(toolNameToCapabilityId("bash")).toBeUndefined(); diff --git a/packages/agent/test/skills.test.ts b/packages/agent/test/skills.test.ts index 99e39a08..9dbaa21a 100644 --- a/packages/agent/test/skills.test.ts +++ b/packages/agent/test/skills.test.ts @@ -50,7 +50,7 @@ describe("加载(Pi harness loadSkills)", () => { it("正文提到的能力都在 frontmatter 的 capabilities 里,且都是已知能力", () => { for (const s of skills) { - const mentioned = new Set(s.content.match(/\b[a-z]+\.[a-z_]+\b/g) ?? []); + const mentioned = new Set(s.content.match(/\b[a-z][a-z_]*\.[a-z_]+\b/g) ?? []); for (const m of mentioned) { if (!(CAPABILITY_IDS as readonly string[]).includes(m)) continue; expect(s.capabilities, `${s.name} 正文用了 ${m}`).toContain(m); @@ -99,6 +99,8 @@ const ROUTING: [string, string][] = [ ["下载进度怎么样了", "download-diagnose"], ["帮我建个巡检,每天早上看看有没有失败的下载", "patrol-draft"], ["每隔 6 小时检查一下站点登录", "patrol-draft"], + ["药屋少女第二季的集数对不上", "episode-mapping"], + ["Lilith 的连续编号帮我对一下", "episode-mapping"], ]; describe("按意图选择", () => { @@ -107,6 +109,15 @@ describe("按意图选择", () => { expect(picked[0]?.name).toBe(name); }); + it("集数待确认事项的后台调查载入「集数映射」", () => { + const goal = "调查待处理事项 x(episode_mapping):集数待确认:药屋少女的呢喃 第 1 季 · Lilith-Raws。"; + const picked = selectSkills(skills, goal, { + kind: "investigate", + capabilities: ["episode_mapping.inspect", "episode_mapping.simulate", "decision.list"], + }); + expect(picked[0]?.name).toBe("episode-mapping"); + }); + it("闲聊不载入 Skill;最多载入 2 个", () => { expect(selectSkills(skills, "你好", { kind: "chat" })).toEqual([]); expect(selectSkills(skills, "巡检一下下载失败和订阅追更,再清理空间", { kind: "chat" }).length).toBe(2); diff --git a/packages/contracts/src/capabilities-io.ts b/packages/contracts/src/capabilities-io.ts index c81c72a7..edc5b774 100644 --- a/packages/contracts/src/capabilities-io.ts +++ b/packages/contracts/src/capabilities-io.ts @@ -6,8 +6,25 @@ import { BrowserController, BrowserDownloadVerification, BrowserSnapshot } from import { CAPABILITY_CATALOG, type CapabilitySpec, type KnownCapabilityId } from "./capability.ts"; import { Id, JsonObject, NonNegInt } from "./common.ts"; import { MediaEntity, MediaKind } from "./content.ts"; +import { + EpisodeMappingRuleSpec, + EpisodeMappingRuleStatus, + EpisodeMappingTableEntry, + EpisodeReresolveResult, + EpisodeResolutionMethod, + EpisodeSpecialKind, + EpisodeSuggestion, + EpisodeValidationState, +} from "./episode-mapping.ts"; import { AcquisitionPlan, DownloadStatus, DownloadTask, Task, TaskStatus } from "./execution.ts"; -import { EnrichmentStatus, EnrichSuggestion, EnrichUsage, ExternalEntityType, ExternalIdentity } from "./identity.ts"; +import { + EnrichmentStatus, + EnrichSuggestion, + EnrichUsage, + EvidenceLevel, + ExternalEntityType, + ExternalIdentity, +} from "./identity.ts"; import { OrganizeMetadataInput, OrganizeMetadataOutput } from "./local-metadata.ts"; import { ServerMapping } from "./ops.ts"; import { OrganizationAction, OrganizationRecord } from "./organization.ts"; @@ -909,6 +926,168 @@ export const WebSearchTestResult = z.object({ }); export type WebSearchTestResult = z.infer; +// ── episode_mapping.*:集数映射的智能体兜底(docs/plan/episode-resolution.md 批次 5) ── +// inspect 给上下文包(有上限),simulate 试草稿规则,apply_rule 服务端强制先模拟、通过才写规则并重算。 + +const EpisodeRange = z.object({ start: z.number().int(), end: z.number().int() }); +const EpisodeSourceView = z.object({ + season: z.number().int().optional(), + episodes: EpisodeRange.optional(), + airDate: z.string().optional(), +}); +/** 解析结果摘要:方式、规范季集、特别篇去向 */ +export const EpisodeResultBrief = z.object({ + method: EpisodeResolutionMethod, + canonical: z.object({ season: z.number().int(), episodes: EpisodeRange }).nullable(), + special: EpisodeSpecialKind.nullable(), +}); +export type EpisodeResultBrief = z.infer; + +export const EpisodeMappingInspectInput = z.object({ + /** 集数待确认事项 ID;给了就按事项的作品 / 来源季 / 发布组取上下文 */ + decisionId: Id.optional(), + /** 不给 decisionId 时必填 */ + mediaEntityId: Id.optional(), + /** 来源季;不填 = 任意季 */ + season: z.number().int().min(0).max(999).nullable().optional(), + /** 发布组;不填 = 没有发布组的资源 */ + releaseGroup: z.string().trim().max(200).nullable().optional(), +}); +export const EpisodeInspectRelease = z.object({ + sourceReleaseId: Id, + title: z.string(), + publishedAt: z.string(), + releaseGroup: z.string().nullable(), + /** 发布者声称的季集 */ + source: EpisodeSourceView, + /** 当前结果与依据 */ + current: EpisodeResultBrief.nullable(), + evidence: z.array(z.string()), +}); +export type EpisodeInspectRelease = z.infer; +export const EpisodeMappingInspectOutput = z.object({ + entity: z.object({ id: Id, title: z.string(), kind: MediaKind, year: z.number().int().nullable() }), + scope: z.object({ season: z.number().int().nullable(), releaseGroup: z.string().nullable() }), + decision: z + .object({ + id: Id, + status: DecisionStatus, + title: z.string(), + summary: z.string().nullable(), + /** 算出越界被停用的规则 */ + failedRules: z.array(z.string()), + }) + .nullable(), + /** 事项内资源(没有事项时 = 这个范围里当前待确认的资源),最多 50 条 */ + releases: z.array(EpisodeInspectRelease), + /** 同作品同发布组近期的其他资源(含已对上 / 已换算的),最多 50 条 */ + neighbors: z.array(EpisodeInspectRelease), + /** 季表:每季总集数、已知每集播出日期与标题(每季最多 100 集) */ + seasons: z.array( + z.object({ + season: z.number().int(), + total: z.number().int().nullable(), + episodes: z.array( + z.object({ episode: z.number().int(), airDate: z.string().nullable(), title: z.string().nullable() }), + ), + }), + ), + latestSeason: z.number().int().nullable(), + ended: z.boolean(), + /** 这部作品的映射规则(含停用及原因);byAgent = 智能体写的 */ + rules: z.array( + z.object({ + id: Id, + season: z.number().int().nullable(), + releaseGroup: z.string().nullable(), + kind: z.enum(["expression", "table", "special"]), + expression: z.string().nullable(), + targetSeason: z.number().int().nullable(), + tableEntries: z.array(EpisodeMappingTableEntry).nullable(), + special: EpisodeSpecialKind.nullable(), + status: EpisodeMappingRuleStatus, + disabledReason: z.string().nullable(), + evidenceLevel: EvidenceLevel, + byAgent: z.boolean(), + /** 智能体写规则时的理由 */ + reasons: z.array(z.string()), + }), + ), + /** 确定性建议(按发布时间对播出日期,全部样本成立且唯一才有) */ + suggestion: EpisodeSuggestion.nullable(), + truncated: z.object({ releases: z.boolean(), neighbors: z.boolean() }), +}); + +export const EpisodeMappingSimulateInput = z.object({ + /** 集数待确认事项 ID:样本取事项内资源;规则的季 / 发布组不填时用事项的 */ + decisionId: Id.optional(), + /** 不给 decisionId 时必填 */ + mediaEntityId: Id.optional(), + /** 草稿规则(expression / table / special + 作用域) */ + rule: EpisodeMappingRuleSpec, + /** 理由:模拟通过时作为智能体建议留在事项上(后台调查没有写规则授权时) */ + reasons: z.array(z.string().trim().min(1).max(300)).max(10).optional(), +}); +export const EpisodeSimulationItem = z.object({ + sourceReleaseId: Id, + title: z.string(), + releaseGroup: z.string().nullable(), + /** 是否是事项内样本 */ + sample: z.boolean(), + source: EpisodeSourceView, + before: EpisodeResultBrief.nullable(), + after: EpisodeResultBrief, + validation: EpisodeValidationState, + changed: z.boolean(), +}); +export type EpisodeSimulationItem = z.infer; +export const EpisodeSimulationSummary = z.object({ + /** 事项内样本数 */ + samples: NonNegInt, + /** 其中套用后解决的(非待确认、校验非 fail) */ + resolved: NonNegInt, + /** 其中校验结果为「对得上」的 */ + passed: NonNegInt, + /** 样本不在模拟范围内(太旧)的条数 */ + missing: NonNegInt, + /** 当前已对上 / 已换算、套用后变成待确认的条数 */ + regressions: NonNegInt, + /** 套用后结果有变化的条数 */ + changed: NonNegInt, + /** 参与模拟的资源数 */ + scanned: NonNegInt, + /** 事项内样本全部解决且没有让已对上的资源变成待确认 */ + ok: z.boolean(), +}); +export type EpisodeSimulationSummary = z.infer; +export const EpisodeMappingSimulateOutput = z.object({ + rule: EpisodeMappingRuleSpec, + summary: EpisodeSimulationSummary, + /** 没通过的原因(通过时为空) */ + failures: z.array(z.string()), + /** 样本 + 有变化的资源,最多 100 条 */ + items: z.array(EpisodeSimulationItem), + /** 已作为智能体建议留在事项上 */ + suggestionRecorded: z.boolean(), +}); + +export const EpisodeMappingApplyRuleInput = z.object({ + /** 集数待确认事项 ID */ + decisionId: Id, + /** 草稿规则;季 / 发布组不填时用事项的 */ + rule: EpisodeMappingRuleSpec, + /** 为什么这样映射(依据),至少一条 */ + reasons: z.array(z.string().trim().min(1).max(300)).min(1).max(10), +}); +export const EpisodeMappingApplyRuleOutput = z.object({ + ruleId: Id, + evidenceLevel: EvidenceLevel, + summary: EpisodeSimulationSummary, + reresolve: EpisodeReresolveResult, + /** 写入后事项的状态(资源都解决时已自动关闭) */ + decisionStatus: DecisionStatus, +}); + /** 能力 ID → 输入/输出 schema(server 注册处理器、agent 生成工具定义时共用) */ // ── schedule.draft_check:按自然语言需求起草巡检(建成未启用,用户在计划页检查后启用) ── @@ -985,6 +1164,9 @@ export const CAPABILITY_IO = { "metadata.resolve_organize": { input: OrganizeMetadataInput, output: OrganizeMetadataOutput }, "metadata.enrich": { input: MetadataEnrichInput, output: MetadataEnrichOutput }, "schedule.draft_check": { input: ScheduleDraftCheckInput, output: ScheduleDraftCheckOutput }, + "episode_mapping.inspect": { input: EpisodeMappingInspectInput, output: EpisodeMappingInspectOutput }, + "episode_mapping.simulate": { input: EpisodeMappingSimulateInput, output: EpisodeMappingSimulateOutput }, + "episode_mapping.apply_rule": { input: EpisodeMappingApplyRuleInput, output: EpisodeMappingApplyRuleOutput }, } as const satisfies Record; type CapabilityIo = typeof CAPABILITY_IO; @@ -1076,3 +1258,9 @@ export type WebFetchInput = z.infer; export type WebFetchOutput = z.infer; export type MetadataEnrichInput = z.infer; export type MetadataEnrichOutput = z.infer; +export type EpisodeMappingInspectInput = z.infer; +export type EpisodeMappingInspectOutput = z.infer; +export type EpisodeMappingSimulateInput = z.infer; +export type EpisodeMappingSimulateOutput = z.infer; +export type EpisodeMappingApplyRuleInput = z.infer; +export type EpisodeMappingApplyRuleOutput = z.infer; diff --git a/packages/contracts/src/capability.ts b/packages/contracts/src/capability.ts index acf98fd9..c1763ad2 100644 --- a/packages/contracts/src/capability.ts +++ b/packages/contracts/src/capability.ts @@ -24,8 +24,11 @@ export const MutationPlan = z.object({ }); export type MutationPlan = z.infer; -/** 能力 ID:`域.动作`,如 download.submit */ -export const CapabilityId = z.string().regex(/^[a-z][a-z0-9]*\.[a-z][a-z0-9_]*$/); +/** + * 能力 ID:`域.动作`,如 download.submit;域名可用单个下划线分词(episode_mapping.inspect), + * 不以下划线结尾、不含连续下划线,工具名(`域__动作`)按第一个 `__` 反向切分仍无歧义 + */ +export const CapabilityId = z.string().regex(/^[a-z][a-z0-9]*(?:_[a-z0-9]+)*\.[a-z][a-z0-9_]*$/); export type CapabilityId = z.infer; // ── 能力描述符与首批能力(任务书 §3.1-9;REQ §10.3) ── @@ -94,6 +97,9 @@ export const CAPABILITY_IDS = [ "metadata.enrich", "metadata.resolve_organize", "schedule.draft_check", + "episode_mapping.inspect", + "episode_mapping.simulate", + "episode_mapping.apply_rule", ] as const; export const KnownCapabilityId = z.enum(CAPABILITY_IDS); export type KnownCapabilityId = z.infer; @@ -629,6 +635,39 @@ export const CAPABILITY_CATALOG: Record false, "safe", ), + "episode_mapping.inspect": d( + "episode_mapping.inspect", + "看集数映射上下文", + "集数待确认时用:按事项(或作品 + 来源季 + 发布组)取上下文——事项内资源的原标题、发布时间、原始季集与当前结果,同组近期其他资源,季表(每季总集数、每集播出日期与标题),现有规则(含停用原因),确定性建议规则。写规则前先看这个", + "read", + "EpisodeMappingInspect", + "read", + "low", + true, + false, + ), + "episode_mapping.simulate": d( + "episode_mapping.simulate", + "模拟映射规则", + "试一条草稿映射规则(expression 偏移表达式 / table 对照表 / special 特别篇·花絮·忽略),逐条给出套用后的季集与校验,并汇总事项内样本是否全部解决、有没有让已对上的资源变成待确认。不写规则;智能体会话里带 decisionId 且模拟通过时,规则和理由会作为智能体建议留在事项上,由用户确认", + "preview", + "EpisodeMappingSimulate", + "read", + "low", + true, + false, + ), + "episode_mapping.apply_rule": d( + "episode_mapping.apply_rule", + "写映射规则", + "把草稿映射规则写进作品并按作品重算资源的集数。服务端先强制模拟:事项内样本必须全部解决、已对上的资源不能变成待确认,否则拒绝不写。先用 episode_mapping.simulate 确认通过再调用,每个事项只写一次;拿不准就不要写", + "execute", + "EpisodeMappingApplyRule", + "organize.execute", + "medium", + false, + true, + ), }; /** @@ -691,6 +730,9 @@ export const CAPABILITY_ACTIVITY: Record "web.fetch": act("读", "网页", "个"), "metadata.resolve_organize": act("补全", "整理元数据"), "metadata.enrich": act("补全", "元数据"), + "episode_mapping.inspect": act("查", "集数映射"), + "episode_mapping.simulate": act("模拟", "映射规则", "次"), + "episode_mapping.apply_rule": act("写入", "映射规则", "条"), }; function d(