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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-03 20:08:20 +08:00
1 parent 45a114263b
commit 7793c7b3bc
27 files changed
+1747 -48

No files matched your search

@@ -23,6 +23,8 @@ export const GRANT_ACTIONS: Record<string, readonly string[]> = {
"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<DB>, decisionId: string): Promise<PlanBinding | null> {
if (!isUuid(decisionId)) return null;
const r = await db
.selectFrom("decision as d")
.leftJoin("media_entity as e", (j) =>
j.onRef(
"e.id",
"=",
sql<string>`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 …) */
@@ -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);
+36 -6
View File
@@ -10,10 +10,11 @@ import { startRun } from "./service.ts";
// recover 需要有覆盖恢复动作的有效持续授权(策略版本须为 published),否则降级为只读调查并在事项上留建议;
// 同一 decision 只一个 run(dedupe_key=investigate:<id>),同一根因(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<string, readonly string[]> = {
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<string, unknown>) : 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<string | null>`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";
+17 -4
View File
@@ -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")
+1 -1
View File
@@ -263,7 +263,7 @@ export async function executeResolution(ctx: ServerContext, decisionId: string)
export const agentOutboxRoutes: Record<string, OutboxRouteFn> = {
"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) => {
@@ -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),
);
}
@@ -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 }[] = [];
@@ -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));
}
@@ -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<EpisodeMa
return rows.map((r) => toApi<EpisodeMappingRuleRow>(r));
}
/** 写入一条规则(不重算,调用方在同一事务里接着重算) */
async function insertRule(
/** 写入一条规则(不重算,调用方在同一事务里接着重算);证据等级缺省 manual(人工建 / 人工确认) */
export async function insertRule(
exec: Exec,
entityId: string,
n: NormalizedRule,
actor: Actor,
evidence: Record<string, unknown>,
evidenceLevel: EpisodeMappingRuleEvidenceLevel = "manual",
): Promise<RuleDbRow> {
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<T>(fn: () => Promise<T>): Promise<T> {
export async function withConflict<T>(fn: () => Promise<T>): Promise<T> {
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<RuleSimulation> {
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<string>();
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")
@@ -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<DecisionRow> {
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<EpisodeScope> {
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<EpisodeMappingInspectOutput> {
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<typeof releaseView>[];
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<boolean> {
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<EpisodeMappingSimulateOutput> {
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<boolean> {
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<EpisodeMappingApplyRuleOutput> {
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 };
}),
);
}
@@ -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<string, unknown>;
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,
@@ -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<string, unknown>, 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<Any>;
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");
});
});
@@ -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",
@@ -2,7 +2,7 @@ import type { Decision, EpisodeMappingPreview } from "@umt/contracts";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { EpisodeDecisionBody, episodeDecisionView, simulationText } from "./decision.tsx";
import { agentSimulationText, EpisodeDecisionBody, episodeDecisionView, simulationText } from "./decision.tsx";
const ENTITY = "7d3f7f5e-8a8c-4b8e-9f0a-1b2c3d4e5f60";
const decision = (over: Partial<Decision> = {}): Decision =>
@@ -94,4 +94,60 @@ describe("待处理「集数待确认」", () => {
expect(simulationText(p(["pass", "fail"]))).toBe("套用后 1 条对得上,1 条仍对不上");
expect(simulationText(p([]))).toBeNull();
});
const agentSuggestion = {
rule: { season: 1, releaseGroup: "Lilith-Raws", kind: "expression", expression: "EP-30", targetSeason: 2 },
reasons: ["40、41 的发布时间对上第 2 季第 10、11 集的播出日期"],
simulation: { samples: 2, resolved: 2, passed: 2, missing: 0, regressions: 0, changed: 2, scanned: 5, ok: true },
samples: [
{
title: "[Lilith-Raws] X - 40",
source: { season: 1, episodes: { start: 40, end: 40 } },
after: { method: "rule", canonical: { season: 2, episodes: { start: 10, end: 10 } }, special: null },
},
],
runId: null,
at: "2026-10-03T00:00:00Z",
};
const withAgent = () =>
decision({
evidence: { ...decision().evidence, agentSuggestion },
options: [
{
key: "confirm_agent_suggestion",
label: "确认智能体建议",
description: "按 EP-30 换算到第 2 季,建规则前按当前资源再模拟一次,通过才写入并重算",
},
...decision().options,
],
});
it("智能体建议:取规则、理由、模拟结果与「确认智能体建议」", () => {
const v = episodeDecisionView(withAgent());
expect(v.agentSuggestion?.rule.expression).toBe("EP-30");
expect(v.agentSuggestion?.reasons).toHaveLength(1);
expect(v.confirmAgent?.key).toBe("confirm_agent_suggestion");
expect(v.confirm?.key).toBe("confirm_suggestion");
// 没有建议的事项不出这一块
expect(episodeDecisionView(decision())).toMatchObject({ agentSuggestion: null, confirmAgent: null });
});
it("渲染智能体建议:规则、作用域、理由、样本套用后的季集与模拟结果", () => {
const html = renderToStaticMarkup(
createElement(EpisodeDecisionBody, { view: episodeDecisionView(withAgent()), simulation: null }),
);
expect(html).toContain("智能体建议");
expect(html).toContain("偏移表达式");
expect(html).toContain("EP-30 → 第 2 季");
expect(html).toContain("对上第 2 季第 10、11 集的播出日期");
expect(html).toContain("S01E40 → S02E10");
expect(html).toContain("模拟:这 2 条都对得上,不影响已对上的资源");
});
it("智能体建议的模拟结果一句话", () => {
const s = agentSuggestion.simulation;
expect(agentSimulationText({ ...s, resolved: 1, regressions: 2 })).toBe(
"模拟:2 条里 1 条对得上,会影响 2 条已对上的资源",
);
});
});
@@ -1,5 +1,13 @@
import type { Decision, DecisionOption, EpisodeMappingPreview, EpisodeResolutionView } from "@umt/contracts";
import { BellOff, Check, PencilLine } from "lucide-react";
import type {
Decision,
DecisionOption,
EpisodeMappingPreview,
EpisodeMappingRuleSpec,
EpisodeResolutionView,
EpisodeResultBrief,
EpisodeSimulationSummary,
} from "@umt/contracts";
import { BellOff, Bot, Check, PencilLine } from "lucide-react";
import { useId, useState } from "react";
import { Link } from "react-router";
import { toast } from "sonner";
@@ -10,14 +18,24 @@ import { consolePath } from "~/components/console/nav.ts";
import { Button } from "~/components/ui/button.tsx";
import { Switch } from "~/components/ui/switch.tsx";
import { errorMessage } from "~/lib/api.ts";
import { episodeCode, mappingHref, scopeText, sourceText } from "./model.ts";
import { canonicalText, episodeCode, mappingHref, RULE_KIND_LABEL, ruleText, scopeText, sourceText } from "./model.ts";
// 待处理中心里的「集数待确认」(episode_mapping):来源季集、原因、建议规则与它的模拟结果;
// 操作「确认」(建建议规则并重算)、「修改」(跳作品页集数映射并预填草稿)、「忽略」(可同时建忽略规则)。
// 智能体建议(批次 5):后台调查没有写规则的授权时,把模拟通过的规则和理由留在事项上,「确认智能体建议」一键建规则
// (服务端建规则前按当前资源再模拟一次)。
export const EPISODE_CONFIRM_KEY = "confirm_suggestion";
export const EPISODE_CONFIRM_AGENT_KEY = "confirm_agent_suggestion";
export const EPISODE_IGNORE_RULE_KEY = "ignore_rule";
export interface AgentSuggestion {
rule: EpisodeMappingRuleSpec;
reasons: string[];
simulation: EpisodeSimulationSummary;
samples: { title: string; source: EpisodeResolutionView["source"]; after: EpisodeResultBrief }[];
}
interface Suggestion {
expression: string;
targetSeason: number;
@@ -31,7 +49,9 @@ export interface EpisodeDecisionView {
releases: { id: string; title: string; source: string }[];
count: number;
suggestion: Suggestion | null;
agentSuggestion: AgentSuggestion | null;
confirm: DecisionOption | null;
confirmAgent: DecisionOption | null;
ignoreRule: DecisionOption | null;
}
@@ -51,6 +71,7 @@ export function episodeDecisionView(d: Decision): EpisodeDecisionView {
}))
: [];
const s = e.suggestion as Suggestion | null | undefined;
const a = e.agentSuggestion as AgentSuggestion | null | undefined;
return {
entityId: entity ?? (d.subjectType === "media_entity" ? d.subjectId : null),
season: typeof e.season === "number" ? e.season : null,
@@ -58,7 +79,16 @@ export function episodeDecisionView(d: Decision): EpisodeDecisionView {
releases,
count: typeof e.count === "number" ? e.count : releases.length,
suggestion: s && typeof s.expression === "string" ? s : null,
agentSuggestion:
a?.rule && a.simulation
? {
...a,
reasons: Array.isArray(a.reasons) ? a.reasons : [],
samples: Array.isArray(a.samples) ? a.samples : [],
}
: null,
confirm: d.options.find((o) => o.key === EPISODE_CONFIRM_KEY) ?? null,
confirmAgent: d.options.find((o) => o.key === EPISODE_CONFIRM_AGENT_KEY) ?? null,
ignoreRule: d.options.find((o) => o.key === EPISODE_IGNORE_RULE_KEY) ?? null,
};
}
@@ -72,6 +102,41 @@ export function simulationText(p: EpisodeMappingPreview | undefined): string | n
return fail ? `套用后 ${total - fail} 条对得上,${fail} 条仍对不上` : `套用后 ${total} 条都对得上`;
}
/** 智能体建议的模拟结果一句话 */
export function agentSimulationText(s: EpisodeSimulationSummary): string {
const head =
s.resolved === s.samples ? `模拟:这 ${s.samples} 条都对得上` : `模拟:${s.samples} 条里 ${s.resolved} 条对得上`;
return `${head}${s.regressions ? `,会影响 ${s.regressions} 条已对上的资源` : ",不影响已对上的资源"}`;
}
/** 智能体建议:规则、理由、模拟结果与样本套用后的季集 */
export function AgentSuggestionBody({ s }: { s: AgentSuggestion }) {
return (
<div className="space-y-1">
<div>
<span className="text-muted-foreground">{RULE_KIND_LABEL[s.rule.kind]}:</span>
<span className="font-mono">{ruleText(s.rule)}</span>
<span className="ml-2 text-meta text-muted-foreground">{scopeText(s.rule.season, s.rule.releaseGroup)}</span>
</div>
{s.reasons.length ? (
<ul className="list-disc space-y-0.5 pl-4 text-meta text-muted-foreground">
{s.reasons.map((r) => (
<li key={r}>{r}</li>
))}
</ul>
) : null}
<ul className="space-y-0.5 text-meta text-muted-foreground">
{s.samples.slice(0, 3).map((x) => (
<li key={x.title} className="font-mono tabular-nums">
{sourceText({ source: x.source })} → {canonicalText({ ...x.after, special: x.after.special ?? undefined })}
</li>
))}
</ul>
<div className="text-meta text-muted-foreground">{agentSimulationText(s.simulation)}</div>
</div>
);
}
/** 展示部分(纯渲染,单测直接渲染) */
export function EpisodeDecisionBody({ view, simulation }: { view: EpisodeDecisionView; simulation: string | null }) {
const shown = view.releases.slice(-5);
@@ -93,6 +158,14 @@ export function EpisodeDecisionBody({ view, simulation }: { view: EpisodeDecisio
) : null}
</ul>
</dd>
{view.agentSuggestion ? (
<>
<dt className="text-muted-foreground">智能体建议</dt>
<dd className="min-w-0 text-foreground">
<AgentSuggestionBody s={view.agentSuggestion} />
</dd>
</>
) : null}
<dt className="text-muted-foreground">建议规则</dt>
<dd className="min-w-0 text-foreground">
{view.suggestion ? (
@@ -191,10 +264,32 @@ export function EpisodeDecision({ d, open }: { d: Decision; open: boolean }) {
<EpisodeDecisionBody view={view} simulation={sug ? simulationText(preview.data) : null} />
{open ? (
<div className="flex flex-wrap items-center gap-2">
{view.confirm ? (
{view.confirmAgent && view.agentSuggestion ? (
<ConfirmDialog
trigger={
<Button size="sm">
<Bot />
确认智能体建议
</Button>
}
title="按智能体建议的规则处理?"
description={`${view.confirmAgent.description ?? ""}。规则在作品页的集数映射里可以再改。`}
confirmLabel="确认"
onConfirm={() =>
resolve.mutateAsync({ id: d.id, optionKey: view.confirmAgent!.key }).then(
() => toast.success("已建规则并重算"),
(err) => {
toast.error(errorMessage(err, "处理失败"));
throw err;
},
)
}
/>
) : null}
{view.confirm ? (
<ConfirmDialog
trigger={
<Button size="sm" variant={view.confirmAgent && view.agentSuggestion ? "outline" : "default"}>
<Check />
确认
</Button>
@@ -23,6 +23,7 @@ import { Textarea } from "~/components/ui/textarea.tsx";
import { errorMessage } from "~/lib/api.ts";
import { actorText } from "~/lib/labels.ts";
import {
agentRuleInfo,
disabledReasonText,
draftFromSearch,
draftOfRule,
@@ -81,6 +82,7 @@ function RuleRow({
write: ReturnType<typeof useEpisodeMappingWrite>;
}) {
const reason = disabledReasonText(rule);
const agent = agentRuleInfo(rule);
const run = (vars: Parameters<typeof write.mutateAsync>[0], verb: string) =>
write.mutateAsync(vars).then(
(r) => toast.success(reresolveToast(r.reresolve, verb)),
@@ -97,13 +99,21 @@ function RuleRow({
<StatusBadge tone={rule.status === "active" ? "success" : "neutral"}>
{rule.status === "active" ? "启用" : "停用"}
</StatusBadge>
{agent ? <Chip>{agent.label}</Chip> : null}
{editing ? <span className="text-meta text-muted-foreground">修改中</span> : null}
</div>
<div className="mt-0.5 text-meta text-muted-foreground">
{RULE_KIND_LABEL[rule.kind]}:<span className="font-mono text-foreground">{ruleText(rule)}</span>
{" · "}
{actorText(rule.createdById ? `${rule.createdByType}:${rule.createdById}` : rule.createdByType)}
{agent ? null : (
<>
{" · "}
{actorText(rule.createdById ? `${rule.createdByType}:${rule.createdById}` : rule.createdByType)}
</>
)}
</div>
{agent?.reasons.length ? (
<div className="mt-0.5 text-meta text-muted-foreground">依据:{agent.reasons.join(";")}</div>
) : null}
{reason ? <div className="mt-0.5 text-meta text-muted-foreground">{reason}</div> : null}
</div>
<div className="flex flex-wrap items-center gap-1.5">
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
import { EXCLUDE_REASON } from "../organize-wizard/model.ts";
import {
absoluteGroups,
agentRuleInfo,
canonicalText,
disabledReasonText,
draftFromSearch,
@@ -167,3 +168,16 @@ describe("应用后的提示", () => {
expect(reresolveToast({ releases: 3, changed: 0, resolved: 0 }, "已停用")).toBe("已停用:重算 3 条资源");
});
});
describe("智能体写的规则", () => {
it("标出智能体来源与证据等级,带写规则时的理由;其它来源不标", () => {
const rule = (over: Partial<EpisodeMappingRuleRow>) =>
({ createdByType: "agent", evidenceLevel: "multi_evidence", evidence: {}, ...over }) as EpisodeMappingRuleRow;
expect(agentRuleInfo(rule({ evidence: { reasons: ["发布时间对上播出日期", 3] } }))).toEqual({
label: "智能体 · 多证据",
reasons: ["发布时间对上播出日期"],
});
expect(agentRuleInfo(rule({ evidenceLevel: "needs_review" }))?.label).toBe("智能体 · 待复核");
expect(agentRuleInfo(rule({ createdByType: "user", evidenceLevel: "manual" }))).toBeNull();
});
});
@@ -10,6 +10,7 @@ import type {
EpisodeValidationState,
} from "@umt/contracts";
import type { StatusTone } from "~/components/console/status-badge.tsx";
import { EVIDENCE_LEVEL } from "~/components/library/labels.ts";
// 集数映射的界面文案与纯函数(docs/plan/episode-resolution.md 批次 4):方式名称、季集写法、规则摘要、
// 表格规则的文本格式、作品页「集数映射」分区的地址。
@@ -104,6 +105,18 @@ export function ruleText(
return r.special ? `映射为${SPECIAL_LABEL[r.special]}` : "未选去向";
}
/** 智能体写的规则:来源与证据等级(「智能体 · 多证据」)、写规则时的理由;其它来源返回 null */
export function agentRuleInfo(
rule: Pick<EpisodeMappingRuleRow, "createdByType" | "evidenceLevel" | "evidence">,
): { label: string; reasons: string[] } | null {
if (rule.createdByType !== "agent") return null;
const raw = (rule.evidence as { reasons?: unknown }).reasons;
return {
label: `智能体 · ${EVIDENCE_LEVEL[rule.evidenceLevel] ?? rule.evidenceLevel}`,
reasons: Array.isArray(raw) ? raw.filter((x): x is string => typeof x === "string") : [],
};
}
/** 停用原因:手动停用 / 规则失效(带当时的来源季集) */
export function disabledReasonText(rule: Pick<EpisodeMappingRuleRow, "status" | "disabledReason">): string | null {
if (rule.status !== "disabled") return null;