feat(agent): 集数待确认的智能体兜底——查看上下文 / 模拟 / 写规则三个能力,写入前强制模拟,接后台调查与授权内恢复
能力 ID 的域名允许单个下划线分词(episode_mapping)。 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
1 parent
45a114263b
commit
7793c7b3bc
27 files changed
+1747
-48
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user