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