From 679389f854fbd3d0e4d1eb98052dae9ad6919ce8 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 13 Oct 2026 15:52:05 +0800 Subject: [PATCH] =?UTF-8?q?fix(organize):=20=E6=8C=89=E7=9C=9F=E5=AE=9E?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E6=A0=B7=E6=9C=AC=E9=AA=8C=E8=AF=81=E5=B9=B6?= =?UTF-8?q?=E4=BF=9D=E5=AD=98=E5=AD=A3=E9=9B=86=E6=98=A0=E5=B0=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/episode-mapping-admin.ts | 62 +++- .../service/episode-mapping-agent.ts | 41 ++- .../service/episode-resolution.ts | 158 ++++++++- .../organize-episode-samples.test.ts | 326 ++++++++++++++++++ packages/contracts/src/capabilities-io.ts | 20 +- 5 files changed, 577 insertions(+), 30 deletions(-) create mode 100644 apps/server/test/sources-identity/organize-episode-samples.test.ts diff --git a/apps/server/src/modules/sources-identity/service/episode-mapping-admin.ts b/apps/server/src/modules/sources-identity/service/episode-mapping-admin.ts index ea923b2c..af6cbd15 100644 --- a/apps/server/src/modules/sources-identity/service/episode-mapping-admin.ts +++ b/apps/server/src/modules/sources-identity/service/episode-mapping-admin.ts @@ -31,6 +31,8 @@ import { EPISODE_DECISION_CONFIRM_AGENT, EPISODE_DECISION_IGNORE_RULE, type EpisodeDecisionEvidence, + type EpisodeFileSample, + episodeFileSamples, loadRules, loadSeasonTable, publishedAtOf, @@ -238,6 +240,7 @@ export async function simulateEpisodeRule( entityId: string, line: string, sampleIds: readonly string[] | null, + fileSamples: readonly EpisodeFileSample[] = [], ): Promise { const rules = rulesOfLine(line); const groups = [...new Set(rules.map((r) => r.releaseGroup))]; @@ -268,30 +271,62 @@ export async function simulateEpisodeRule( settled(i.current as EpisodeResolution | null) && i.preview.method === "unresolved", ); + const table = fileSamples.length ? await loadSeasonTable(exec, entityId) : null; + const entity = fileSamples.length ? await loadEpisodicEntity(exec, entityId) : null; + const currentRules = fileSamples.length ? await loadRules(exec, entityId) : []; + const combined = [...currentRules.filter((r) => !rules.some((candidate) => sameScope(r, candidate))), ...rules]; + const files = fileSamples.map((f) => { + const after = resolveEpisodes({ + parsed: f.parsed, + kind: entity!.kind, + table: table!, + rules: combined, + publishedAt: f.publishedAt, + }); + return { file: f, after, validation: validationOf(after), changed: !sameResolution(f.resolution, after) }; + }); + const resolvedFiles = files.filter((f) => f.after.method !== "unresolved" && f.validation !== "fail"); + const fileRegressions = files.filter((f) => settled(f.file.resolution) && f.after.method === "unresolved"); const missing = ids.size - samples.length; const failures: string[] = []; - if (!ids.size) failures.push("没有要解决的待确认资源"); + if (!ids.size && !files.length) 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 (files.length !== resolvedFiles.length) { + const failing = files.find((f) => !resolvedFiles.includes(f))!; + failures.push(`${files.length - resolvedFiles.length} 个文件套用后仍对不上季表(如「${failing.file.path}」)`); + } 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, + samples: ids.size + files.length, + resolved: resolved.length + resolvedFiles.length, + passed: samples.filter((i) => i.validation === "pass").length + files.filter((f) => f.validation === "pass").length, missing, - regressions: regressions.length, - changed: items.filter((i) => i.changed).length, - scanned: items.length, + regressions: regressions.length + fileRegressions.length, + changed: items.filter((i) => i.changed).length + files.filter((f) => f.changed).length, + scanned: items.length + files.length, ok: failures.length === 0, }; const shown = [...samples, ...regressions, ...items.filter((i) => i.changed && !ids.has(i.sourceReleaseId))]; const seen = new Set(); - const out: EpisodeSimulationItem[] = []; + const out: EpisodeSimulationItem[] = files.slice(0, SIMULATE_ITEMS).map((f) => ({ + sourceReleaseId: null, + organizationRecordId: f.file.recordId, + sourcePath: f.file.path, + title: f.file.path.split("/").at(-1) ?? f.file.path, + releaseGroup: f.file.parsed.releaseGroup?.name ?? null, + sample: true, + source: f.after.source, + before: briefOf(f.file.resolution), + after: briefOf(f.after)!, + validation: f.validation, + changed: f.changed, + })); for (const i of shown) { if (seen.has(i.sourceReleaseId) || out.length >= SIMULATE_ITEMS) continue; seen.add(i.sourceReleaseId); @@ -357,15 +392,20 @@ export async function resolveEpisodeDecision( if (!["open", "investigating", "awaiting_user"].includes(cur.status)) throw new ApiException("conflict", `事项已是 ${cur.status}`); let simulation: unknown = null; - if (optionKey === EPISODE_DECISION_CONFIRM_AGENT) { + if ( + optionKey === EPISODE_DECISION_CONFIRM_AGENT || + (optionKey === EPISODE_DECISION_CONFIRM && episodeFileSamples(cur.evidence).length) + ) { // 智能体建议可能是一阵子之前模拟的:写之前按当前资源再模拟一次,不通过就不写 const ids = ((cur.evidence ?? {}) as { sourceReleaseIds?: string[] }).sourceReleaseIds ?? []; - const sim = await simulateEpisodeRule(trx, entityId, line, ids); + const sim = await simulateEpisodeRule(trx, entityId, line, ids, episodeFileSamples(cur.evidence)); if (!sim.summary.ok) throw new ApiException("conflict", `智能体建议的规则现在模拟不通过:${sim.failures.join(";")}`); simulation = sim.summary; } const appended = await appendEntityWords(trx, entityId, [line], actor, comment); + // 文件证据必须在关闭前重算;纯资源事项沿用先关闭再重算的行为。 + let reresolve = episodeFileSamples(cur.evidence).length ? await reresolveEntityEpisodes(ctx, trx, entityId) : null; await trx .updateTable("decision") .set({ @@ -383,7 +423,7 @@ export async function resolveEpisodeDecision( { idempotencyKey: `decision.updated:${d.id}:${status}` }, ); await audit(trx, actor, "decision.resolved", { type: "decision", id: d.id }, { optionKey, status, line }); - const reresolve = await reresolveEntityEpisodes(ctx, trx, entityId); + reresolve ??= await reresolveEntityEpisodes(ctx, trx, entityId); return trx .updateTable("decision") .set({ diff --git a/apps/server/src/modules/sources-identity/service/episode-mapping-agent.ts b/apps/server/src/modules/sources-identity/service/episode-mapping-agent.ts index 5dfc5ce0..42b810e5 100644 --- a/apps/server/src/modules/sources-identity/service/episode-mapping-agent.ts +++ b/apps/server/src/modules/sources-identity/service/episode-mapping-agent.ts @@ -23,7 +23,9 @@ import { briefOf, type RuleSimulation, rulesOfLine, simulateEpisodeRule } from " import { type EpisodeAgentSuggestion, type EpisodeDecisionEvidence, + type EpisodeFileSample, episodeDecisionOptions, + episodeFileSamples, loadSeasonTable, publishedAtOf, reresolveEntityEpisodes, @@ -54,6 +56,7 @@ interface EpisodeScope { decision: DecisionRow | null; /** 事项内资源;没有事项时为 null(由调用方按范围取当前待确认的资源) */ sampleIds: string[] | null; + files: EpisodeFileSample[]; } const decisionKey = (entityId: string, season: number | null, group: string | null) => @@ -76,6 +79,7 @@ function scopeOfDecision(d: DecisionRow): EpisodeScope { releaseGroup: ev.releaseGroup ?? null, decision: d, sampleIds: ev.sourceReleaseIds ?? [], + files: episodeFileSamples(ev), }; } @@ -102,6 +106,7 @@ async function resolveScope( releaseGroup: group, decision: null, sampleIds: null, + files: [], }; } @@ -117,7 +122,7 @@ type ReleaseRow = { function releaseView( row: ReleaseRow, parse: (title: string, subtitle?: string | null) => ParsedRelease, -): EpisodeInspectRelease & { publishedAtMs: number } { +): EpisodeInspectRelease & { publishedAtMs: number; publishedAtKnown: boolean } { const raw = parse(row.title, row.subtitle); const res = (row.episode_resolution as EpisodeResolution | null) ?? null; const published = publishedAtOf(row); @@ -126,6 +131,7 @@ function releaseView( title: row.title, publishedAt: published.toISOString(), publishedAtMs: published.getTime(), + publishedAtKnown: row.source_published_at !== null, releaseGroup: raw.releaseGroup?.name ?? null, source: res?.source ?? { ...(raw.season !== undefined ? { season: raw.season } : {}), @@ -136,7 +142,11 @@ function releaseView( }; } -const strip = ({ publishedAtMs: _drop, ...r }: EpisodeInspectRelease & { publishedAtMs: number }) => r; +const strip = ({ + publishedAtMs: _drop, + publishedAtKnown: _known, + ...r +}: EpisodeInspectRelease & { publishedAtMs: number; publishedAtKnown: boolean }) => r; const sourceSeason = (r: EpisodeInspectRelease) => r.source.season ?? (r.source.episodes ? 1 : null); @@ -228,9 +238,14 @@ export async function inspectEpisodeMapping( // 确定性建议:事项内待确认样本按发布时间对播出日期(与待处理里的建议同一推导) const samples: SuggestSample[] = members - .filter((m) => m.current?.method === "unresolved" && m.source.episodes) + .filter((m) => m.current?.method === "unresolved" && m.source.episodes && m.publishedAtKnown) .map((m) => ({ episodes: m.source.episodes!, publishedAt: m.publishedAt })) .sort((a, b) => a.publishedAt.localeCompare(b.publishedAt)); + samples.push( + ...scope.files + .filter((f) => f.resolution.method === "unresolved" && f.resolution.source.episodes && f.publishedAt) + .map((f) => ({ episodes: f.resolution.source.episodes!, publishedAt: f.publishedAt! })), + ); const s = samples.length ? suggestRule(samples, table) : null; const suggestion: EpisodeSuggestion | null = s ? { season: scope.season, releaseGroup: scope.releaseGroup, ...s } @@ -249,13 +264,27 @@ export async function inspectEpisodeMapping( } : null, releases: members.slice(0, RELEASE_CAP).map(strip), + files: scope.files.slice(0, RELEASE_CAP).map((f) => ({ + organizationRecordId: f.recordId, + sourcePath: f.path, + title: f.path.split("/").at(-1) ?? f.path, + publishedAt: f.publishedAt, + releaseGroup: f.parsed.releaseGroup?.name ?? null, + source: f.resolution.source, + current: briefOf(f.resolution)!, + evidence: f.resolution.evidence, + })), neighbors: neighbors.slice(0, RELEASE_CAP).map(strip), seasons, latestSeason: table.latestSeason, ended: table.ended, words, suggestion, - truncated: { releases: members.length > RELEASE_CAP, neighbors: neighbors.length > RELEASE_CAP }, + truncated: { + releases: members.length > RELEASE_CAP, + neighbors: neighbors.length > RELEASE_CAP, + ...(scope.files.length > RELEASE_CAP ? { files: true } : {}), + }, }; } @@ -330,7 +359,7 @@ export async function simulateForAgent( const line = canonicalLine(input.line); const rule = rulesOfLine(line)[0]!; const scope = await resolveScope(exec, input, rule.season, rule.releaseGroup); - const sim = await simulateEpisodeRule(exec, scope.entityId, line, scope.sampleIds); + const sim = await simulateEpisodeRule(exec, scope.entityId, line, scope.sampleIds, scope.files); let suggestionRecorded = false; if (record && scope.decision && sim.summary.ok) suggestionRecorded = await recordAgentSuggestion( @@ -393,7 +422,7 @@ export async function applyAgentEpisodeRule( "conflict", "这个范围的规则在作品识别词里被注释掉了,不自动写;需要时到作品页的集数映射里改", ); - const sim = await simulateEpisodeRule(trx, scope.entityId, line, scope.sampleIds); + const sim = await simulateEpisodeRule(trx, scope.entityId, line, scope.sampleIds, scope.files); if (!sim.summary.ok) throw new ApiException("conflict", `模拟没通过,没有写规则:${sim.failures.join(";")}`); const comment = `智能体${runId ? `(会话 ${runId.slice(0, 8)})` : ""}:${input.reasons.join(";")}`; const r = await appendEntityWords(trx, scope.entityId, [line], actor, comment); diff --git a/apps/server/src/modules/sources-identity/service/episode-resolution.ts b/apps/server/src/modules/sources-identity/service/episode-resolution.ts index 85cbe3b4..f0cd7403 100644 --- a/apps/server/src/modules/sources-identity/service/episode-resolution.ts +++ b/apps/server/src/modules/sources-identity/service/episode-resolution.ts @@ -410,12 +410,88 @@ export async function settleEpisodeDecision( }); } +/** 整理发现的真实文件编号;不以资源站标题替代,不用发现时间推测发布时间。 */ +export interface EpisodeFileSample { + recordId: string; + path: string; + parsed: ParsedRelease; + resolution: EpisodeResolution; + publishedAt: string | null; +} + +export function episodeFileSamples(evidence: unknown): EpisodeFileSample[] { + const files = (evidence as { fileSamples?: EpisodeFileSample[] } | null)?.fileSamples; + return Array.isArray(files) ? files : []; +} + +/** 明确发起整理本身即关注意图,文件事项不要求作品已有订阅或下载资源。 */ +export async function settleOrganizeEpisodeDecisions( + ctx: ServerContext, + exec: Exec, + input: { recordId: string; entityId: string; files: readonly Omit[] }, +): Promise { + const existing = await exec + .selectFrom("decision") + .select(["dedupe_key", "evidence"]) + .where("kind", "=", "episode_mapping") + .where("status", "in", [...OPEN]) + .where("subject_type", "=", "media_entity") + .where("subject_id", "=", input.entityId) + .execute(); + const groups = new Map(); + for (const file of input.files) { + if (file.resolution.method !== "unresolved") continue; + const releaseGroup = file.parsed.releaseGroup?.name ?? null; + const key = episodeDecisionKey(input.entityId, file.resolution, releaseGroup); + const group = groups.get(key) ?? { season: sourceSeasonOf(file.resolution), releaseGroup, files: [] }; + const sample = { ...file, recordId: input.recordId }; + const index = group.files.findIndex((f) => f.path === file.path); + if (index >= 0) group.files[index] = sample; + else group.files.push(sample); + groups.set(key, group); + } + const keys = new Set([ + ...groups.keys(), + ...existing + .filter((d) => episodeFileSamples(d.evidence).some((f) => f.recordId === input.recordId)) + .map((d) => d.dedupe_key), + ]); + for (const key of keys) { + const previous = existing.find((d) => d.dedupe_key === key); + const ev = previous?.evidence as EpisodeDecisionEvidence | undefined; + const group = groups.get(key); + const files = [...episodeFileSamples(ev).filter((f) => f.recordId !== input.recordId), ...(group?.files ?? [])]; + const ids = ev?.sourceReleaseIds ?? []; + if (!ids.length && !files.some((f) => f.resolution.method === "unresolved")) { + await closeDecisions( + ctx, + exec, + key, + "resolved", + { by: "organize_episodes_resolved", recordId: input.recordId }, + "system", + ); + continue; + } + await writeEpisodeDecision(ctx, exec, { + key, + entityId: input.entityId, + season: group?.season ?? ev?.season ?? null, + releaseGroup: group?.releaseGroup ?? ev?.releaseGroup ?? null, + ids, + files, + latest: group?.files.at(-1)?.resolution ?? null, + }); + } +} + interface EpisodeDecisionState { key: string; entityId: string; season: number | null; releaseGroup: string | null; ids: string[]; + files?: EpisodeFileSample[]; /** 最近进来的那条资源的解析结果(摘要里的原因) */ latest: EpisodeResolution | null; } @@ -438,12 +514,6 @@ async function writeEpisodeDecision(ctx: ServerContext, exec: Exec, s: EpisodeDe .map((id) => byId.get(id)) .filter((r): r is NonNullable => !!r) .map((r) => ({ ...r, res: r.episode_resolution as EpisodeResolution | null })); - // 建议规则:同组待确认资源按发布时间对播出日期 - const samples: SuggestSample[] = releases - .filter((r) => r.res?.method === "unresolved" && r.res.source.episodes) - .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") @@ -452,15 +522,31 @@ async function writeEpisodeDecision(ctx: ServerContext, exec: Exec, s: EpisodeDe .where("status", "in", [...OPEN]) .executeTakeFirst(); const prevEv = (prev?.evidence ?? {}) as Record; + const files = s.files ?? episodeFileSamples(prevEv); + const pendingFiles = files.filter((f) => f.resolution.method === "unresolved"); + // 只拿真实来源的发布时间推建议;文件没有发布时间就保留为未知。 + const samples: SuggestSample[] = [ + ...releases + .filter((r) => r.res?.method === "unresolved" && r.res.source.episodes && r.source_published_at) + .map((r) => ({ episodes: r.res!.source.episodes!, publishedAt: r.source_published_at!.toISOString() })), + ...pendingFiles + .filter((f) => f.resolution.source.episodes && f.publishedAt) + .map((f) => ({ episodes: f.resolution.source.episodes!, publishedAt: f.publishedAt! })), + ]; + const suggestion = samples.length ? suggestRule(samples, await loadSeasonTable(exec, s.entityId)) : null; 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 latest = s.latest ?? pendingFiles.at(-1)?.resolution ?? releases[releases.length - 1]?.res ?? null; const seasonText = s.season === null ? "季未知" : `第 ${s.season} 季`; const groupText = s.releaseGroup ? ` · ${s.releaseGroup}` : ""; const title = `集数待确认:${entity?.title ?? "未知作品"} ${seasonText}${groupText}`; const summary = `${sourceText(latest)}${reasonText(latest)}${ - releases.length > 1 ? `(共 ${releases.length} 条资源)` : "" + pendingFiles.length + ? `(${pendingFiles.length} 个文件待确认${releases.length ? `、${releases.length} 条资源` : ""})` + : releases.length > 1 + ? `(共 ${releases.length} 条资源)` + : "" }`; await openDecision(ctx, exec, { kind: "episode_mapping", @@ -475,7 +561,8 @@ async function writeEpisodeDecision(ctx: ServerContext, exec: Exec, s: EpisodeDe season: s.season, releaseGroup: s.releaseGroup, sourceReleaseIds: s.ids, - count: s.ids.length, + fileSamples: files, + count: s.ids.length + pendingFiles.length, releases: releases.slice(-DECISION_LIST_LIMIT).map((r) => ({ sourceReleaseId: r.id, title: r.title, @@ -509,6 +596,8 @@ export interface EpisodeAgentSuggestion { } export interface EpisodeDecisionEvidence { + sourceReleaseIds?: string[]; + fileSamples?: EpisodeFileSample[]; mediaEntityId?: string; season?: number | null; releaseGroup?: string | null; @@ -610,7 +699,7 @@ async function detachFromEpisodeDecisions( releaseGroup?: string | null; }; const ids = (ev.sourceReleaseIds ?? []).filter((x) => x !== sourceReleaseId); - if (!ids.length) { + if (!ids.length && !episodeFileSamples(ev).some((f) => f.resolution.method === "unresolved")) { await closeDecisions(ctx, exec, d.dedupe_key, "resolved", { by: "episode_resolved", sourceReleaseId }, "system"); continue; } @@ -678,7 +767,6 @@ export async function reresolveEntityEpisodes( .where("resolution_locked", "=", false); if (opts.onlyPending) q = q.where(sql`episode_resolution ->> 'method' in ('unresolved', 'unverified')`); const rows = await q.orderBy("first_seen_at").execute(); - if (!rows.length) return out; const table = await loadSeasonTable(exec, entityId); const rules = await loadRules(exec, entityId); const parse = await titleParser(exec); @@ -737,5 +825,53 @@ export async function reresolveEntityEpisodes( ); } } + await reresolveFileSamples(ctx, exec, entityId, entity.kind, table, rules); return out; } + +/** 文件样本只重跑原始解析与现行作品规则,不读取或修改整理模块的表。 */ +async function reresolveFileSamples( + ctx: ServerContext, + exec: Exec, + entityId: string, + kind: MediaKind, + table: SeasonTable, + rules: EpisodeMappingRule[], +): Promise { + const decisions = await exec + .selectFrom("decision") + .select(["id", "dedupe_key", "evidence"]) + .where("kind", "=", "episode_mapping") + .where("status", "in", [...OPEN]) + .where("subject_type", "=", "media_entity") + .where("subject_id", "=", entityId) + .execute(); + for (const decision of decisions) { + const ev = decision.evidence as EpisodeDecisionEvidence; + const originals = episodeFileSamples(ev); + if (!originals.length) continue; + const files = originals.map((f) => ({ + ...f, + resolution: resolveEpisodes({ parsed: f.parsed, kind, table, rules, publishedAt: f.publishedAt }), + })); + // 即使关闭也保留本次重算结果,恢复和历史证据可读取真实文件编号。 + await exec + .updateTable("decision") + .set({ evidence: sql`evidence || jsonb_build_object('fileSamples', ${jsonb(files)}::jsonb)` }) + .where("id", "=", decision.id) + .execute(); + if (!ev.sourceReleaseIds?.length && !files.some((f) => f.resolution.method === "unresolved")) { + await closeDecisions(ctx, exec, decision.dedupe_key, "resolved", { by: "episode_files_resolved" }, "system"); + } else { + await writeEpisodeDecision(ctx, exec, { + key: decision.dedupe_key, + entityId, + season: ev.season ?? null, + releaseGroup: ev.releaseGroup ?? null, + ids: ev.sourceReleaseIds ?? [], + files, + latest: files.find((f) => f.resolution.method === "unresolved")?.resolution ?? null, + }); + } + } +} diff --git a/apps/server/test/sources-identity/organize-episode-samples.test.ts b/apps/server/test/sources-identity/organize-episode-samples.test.ts new file mode 100644 index 00000000..ca3abb68 --- /dev/null +++ b/apps/server/test/sources-identity/organize-episode-samples.test.ts @@ -0,0 +1,326 @@ +import { randomUUID } from "node:crypto"; +import { EpisodeMappingInspectOutput, EpisodeMappingSimulateOutput } from "@umt/contracts"; +import { parseRelease } from "@umt/domain"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { resolveEpisodeDecision } from "../../src/modules/sources-identity/service/episode-mapping-admin.ts"; +import { + applyAgentEpisodeRule, + inspectEpisodeMapping, + simulateForAgent, +} from "../../src/modules/sources-identity/service/episode-mapping-agent.ts"; +import { + clearReleaseEpisodes, + type EpisodeFileSample, + episodeFileSamples, + reresolveEntityEpisodes, + resolveReleaseEpisodes, + settleEpisodeDecision, + settleOrganizeEpisodeDecisions, +} from "../../src/modules/sources-identity/service/episode-resolution.ts"; +import { isEntityWatched } from "../../src/modules/sources-identity/service/watched.ts"; +import { setWordsText } from "../support/identify-words.ts"; +import { createHarness, type Harness, pgAvailable, seedSite } from "./harness.ts"; + +const pg = await pgAvailable(); +const ACTOR = { type: "user" as const, id: "admin" }; + +describe.skipIf(!pg)("整理真实文件的季集映射闭环", () => { + let h: Harness; + let siteId: string; + beforeAll(async () => { + h = await createHarness("organize_episode_samples"); + siteId = (await seedSite(h, { baseUrl: "https://example.test" })).site.id; + }, 120_000); + afterAll(async () => { + await h?.close(); + }); + + const series = async () => { + const entity = await h.db + .insertInto("media_entity") + .values({ title: "整理分季测试", kind: "series", metadata: JSON.stringify({ airStatus: "Ended" }) }) + .returning("id") + .executeTakeFirstOrThrow(); + await h.db + .insertInto("content_unit") + .values([ + { + media_entity_id: entity.id, + kind: "season", + season_number: 1, + episode_total: 12, + source: "tmdb", + status: "confirmed", + }, + { + media_entity_id: entity.id, + kind: "season", + season_number: 2, + episode_total: 13, + source: "tmdb", + status: "confirmed", + }, + ]) + .execute(); + return entity.id; + }; + const file = async ( + entityId: string, + episode: number, + group = "ANi", + publishedAt: string | null = null, + ): Promise> => { + const path = `/downloads/[${group}] Test S01E${episode}.mkv`; + const parsed = parseRelease(path.split("/").at(-1)!); + const mapped = await resolveReleaseEpisodes(h.db, { entityId, kind: "series", parsed, publishedAt }); + return { path, parsed, resolution: mapped.resolution, publishedAt }; + }; + const pending = (entityId: string) => + h.db + .selectFrom("decision") + .selectAll() + .where("kind", "=", "episode_mapping") + .where("subject_id", "=", entityId) + .where("status", "in", ["open", "investigating", "awaiting_user"]) + .execute(); + const release = async (entityId: string, episode: number, group = "ANi") => { + const title = `[${group}] Test S01E${episode} [1080p]`; + const parsed = parseRelease(title); + const mapped = await resolveReleaseEpisodes(h.db, { entityId, kind: "series", parsed }); + const row = await h.db + .insertInto("source_release") + .values({ + site_id: siteId, + site_release_id: randomUUID(), + title, + media_entity_id: entityId, + resolution_status: "resolved", + episode_resolution: JSON.stringify(mapped.resolution), + parsed: JSON.stringify(parsed), + }) + .returningAll() + .executeTakeFirstOrThrow(); + return { row, parsed, resolution: mapped.resolution }; + }; + + it("无订阅、无资源站资源也按真实文件归集,重复记录样本不累加,inspect保留未知发布时间", async () => { + const entityId = await series(); + expect(await isEntityWatched(h.db, entityId)).toBe(false); + const recordId = randomUUID(); + const files = [await file(entityId, 40), await file(entityId, 41), await file(entityId, 50, "Other")]; + await settleOrganizeEpisodeDecisions(h.ctx, h.db, { recordId, entityId, files }); + await settleOrganizeEpisodeDecisions(h.ctx, h.db, { recordId, entityId, files }); + const decisions = await pending(entityId); + expect(decisions).toHaveLength(2); + const ani = decisions.find((d) => d.dedupe_key.endsWith(":ani"))!; + expect(episodeFileSamples(ani.evidence)).toHaveLength(2); + expect(ani.evidence).toMatchObject({ sourceReleaseIds: [], suggestion: null, count: 2 }); + const inspect = EpisodeMappingInspectOutput.parse(await inspectEpisodeMapping(h.db, { decisionId: ani.id })); + expect(inspect.releases).toEqual([]); + expect(inspect.files).toHaveLength(2); + expect(inspect.files[0]).toMatchObject({ + organizationRecordId: recordId, + sourcePath: files[0]!.path, + publishedAt: null, + source: { season: 1, episodes: { start: 40, end: 40 } }, + }); + expect(inspect.suggestion).toBeNull(); + }); + + it("模拟同时验证每个文件,无法只靠一个正确资源标题放行整包", async () => { + const entityId = await series(); + await h.db + .insertInto("subscription") + .values({ media_entity_id: entityId, media_kind: "series", status: "active" }) + .execute(); + const r = await release(entityId, 40); + await settleEpisodeDecision(h.ctx, h.db, { + sourceReleaseId: r.row.id, + entityId, + parsed: r.parsed, + resolution: r.resolution, + }); + const files = [await file(entityId, 41), await file(entityId, 60)]; + await settleOrganizeEpisodeDecisions(h.ctx, h.db, { recordId: randomUUID(), entityId, files }); + const d = (await pending(entityId))[0]!; + const simulation = EpisodeMappingSimulateOutput.parse( + await simulateForAgent(h.db, { decisionId: d.id, line: "S01 @ANi >> S02 EP-30" }, null), + ); + expect(simulation.summary).toMatchObject({ samples: 3, resolved: 2, ok: false }); + expect(simulation.failures.join(";")).toContain(files[1]!.path); + expect(simulation.items.filter((i) => i.sourceReleaseId === null)).toHaveLength(2); + await expect( + applyAgentEpisodeRule(h.ctx, { decisionId: d.id, line: simulation.line, reasons: ["核对真实文件"] }, ACTOR, null), + ).rejects.toThrow("模拟没通过"); + expect( + await h.db.selectFrom("identify_words").select("id").where("media_entity_id", "=", entityId).execute(), + ).toEqual([]); + }); + + it("纯文件规则模拟通过后自动重算并关闭,后续同范围文件沿用作品规则", async () => { + const entityId = await series(); + const recordId = randomUUID(); + await settleOrganizeEpisodeDecisions(h.ctx, h.db, { + recordId, + entityId, + files: [await file(entityId, 40), await file(entityId, 41)], + }); + const d = (await pending(entityId))[0]!; + const sim = await simulateForAgent( + h.db, + { decisionId: d.id, line: "S01 @ANi >> S02 EP-30", reasons: ["资源站与文件对应 S02E10-11"] }, + { runId: null }, + ); + expect(sim.summary).toMatchObject({ samples: 2, resolved: 2, passed: 2, ok: true }); + expect(sim.suggestionRecorded).toBe(true); + const out = await applyAgentEpisodeRule( + h.ctx, + { decisionId: d.id, line: sim.line, reasons: ["文件编号已核对"] }, + ACTOR, + null, + ); + expect(out.decisionStatus).toBe("resolved"); + expect(await pending(entityId)).toEqual([]); + const after = await h.db.selectFrom("decision").select("evidence").where("id", "=", d.id).executeTakeFirstOrThrow(); + expect(episodeFileSamples(after.evidence).map((f) => f.resolution.canonical)).toEqual([ + { season: 2, episodes: { start: 10, end: 10 } }, + { season: 2, episodes: { start: 11, end: 11 } }, + ]); + expect((await file(entityId, 42)).resolution.canonical).toEqual({ season: 2, episodes: { start: 12, end: 12 } }); + }); + + it("普通保存作品识别词也重算纯文件事项,无资源不能提前返回", async () => { + const entityId = await series(); + await settleOrganizeEpisodeDecisions(h.ctx, h.db, { + recordId: randomUUID(), + entityId, + files: [await file(entityId, 40)], + }); + await setWordsText(h.db, entityId, "S01 @ANi >> S02 EP-30"); + expect((await reresolveEntityEpisodes(h.ctx, h.db, entityId)).releases).toBe(0); + expect(await pending(entityId)).toEqual([]); + }); + + it("摘除资源站资源不会关闭仍有未解决文件的同范围事项", async () => { + const entityId = await series(); + await h.db + .insertInto("subscription") + .values({ media_entity_id: entityId, media_kind: "series", status: "active" }) + .execute(); + const r = await release(entityId, 40); + await settleEpisodeDecision(h.ctx, h.db, { + sourceReleaseId: r.row.id, + entityId, + resolution: r.resolution, + parsed: r.parsed, + }); + await settleOrganizeEpisodeDecisions(h.ctx, h.db, { + recordId: randomUUID(), + entityId, + files: [await file(entityId, 41)], + }); + await clearReleaseEpisodes(h.ctx, h.db, r.row.id); + const d = (await pending(entityId))[0]!; + expect(d).toBeDefined(); + expect(d.evidence).toMatchObject({ sourceReleaseIds: [], count: 1 }); + expect(episodeFileSamples(d.evidence)).toHaveLength(1); + }); + + it("确认智能体建议前重新核对当前文件,成功时保存文件重算结果", async () => { + const entityId = await series(); + await settleOrganizeEpisodeDecisions(h.ctx, h.db, { + recordId: randomUUID(), + entityId, + files: [await file(entityId, 40)], + }); + const d = (await pending(entityId))[0]!; + await simulateForAgent( + h.db, + { decisionId: d.id, line: "S01 @ANi >> S02 EP-30", reasons: ["文件证据"] }, + { runId: null }, + ); + const cur = await h.db.selectFrom("decision").selectAll().where("id", "=", d.id).executeTakeFirstOrThrow(); + const result = await resolveEpisodeDecision(h.ctx, cur, "confirm_agent_suggestion", null, ACTOR); + expect(result.status).toBe("resolved"); + expect(episodeFileSamples(result.evidence)[0]!.resolution.canonical).toEqual({ + season: 2, + episodes: { start: 10, end: 10 }, + }); + }); + + it("已有同发布组资源被规则弄成待确认时拒绝写入", async () => { + const entityId = await series(); + await release(entityId, 1); + await settleOrganizeEpisodeDecisions(h.ctx, h.db, { + recordId: randomUUID(), + entityId, + files: [await file(entityId, 40)], + }); + const d = (await pending(entityId))[0]!; + const simulation = await simulateForAgent(h.db, { decisionId: d.id, line: "S01 @ANi >> S02 EP-30" }, null); + expect(simulation.summary).toMatchObject({ samples: 1, resolved: 1, regressions: 1, ok: false }); + await expect( + applyAgentEpisodeRule(h.ctx, { decisionId: d.id, line: simulation.line, reasons: ["只核对了文件"] }, ACTOR, null), + ).rejects.toThrow("已对上的资源"); + }); + + it("保存部分有效的映射保留未决文件与已解决样本,不误关闭事项", async () => { + const entityId = await series(); + const files = [await file(entityId, 40), await file(entityId, 60)]; + await settleOrganizeEpisodeDecisions(h.ctx, h.db, { recordId: randomUUID(), entityId, files }); + await setWordsText(h.db, entityId, "S01 @ANi >> S02 EP-30"); + await reresolveEntityEpisodes(h.ctx, h.db, entityId); + const d = (await pending(entityId))[0]!; + expect(episodeFileSamples(d.evidence).map((f) => f.resolution.method)).toEqual(["rule", "unresolved"]); + expect(d.evidence).toMatchObject({ count: 1 }); + expect((await inspectEpisodeMapping(h.db, { decisionId: d.id })).files).toHaveLength(2); + }); + + it("旧建议确认前必须按后来进入的文件再次模拟,失败不关闭也不写规则", async () => { + const entityId = await series(); + await settleOrganizeEpisodeDecisions(h.ctx, h.db, { + recordId: randomUUID(), + entityId, + files: [await file(entityId, 40)], + }); + const d = (await pending(entityId))[0]!; + await simulateForAgent( + h.db, + { decisionId: d.id, line: "S01 @ANi >> S02 EP-30", reasons: ["旧样本"] }, + { runId: null }, + ); + await settleOrganizeEpisodeDecisions(h.ctx, h.db, { + recordId: randomUUID(), + entityId, + files: [await file(entityId, 60)], + }); + const cur = (await pending(entityId))[0]!; + expect(cur.evidence).toHaveProperty("agentSuggestion"); + await expect(resolveEpisodeDecision(h.ctx, cur, "confirm_agent_suggestion", null, ACTOR)).rejects.toThrow("文件"); + expect((await pending(entityId))[0]!.status).toBe("open"); + expect( + await h.db.selectFrom("identify_words").select("id").where("media_entity_id", "=", entityId).execute(), + ).toEqual([]); + }); + + it("覆盖同一记录样本时移除旧范围,保留其他整理记录的未决文件", async () => { + const entityId = await series(); + const recordA = randomUUID(); + const recordB = randomUUID(); + await settleOrganizeEpisodeDecisions(h.ctx, h.db, { + recordId: recordA, + entityId, + files: [await file(entityId, 40)], + }); + await settleOrganizeEpisodeDecisions(h.ctx, h.db, { + recordId: recordB, + entityId, + files: [await file(entityId, 41)], + }); + await settleOrganizeEpisodeDecisions(h.ctx, h.db, { recordId: recordA, entityId, files: [] }); + const d = (await pending(entityId))[0]!; + expect(episodeFileSamples(d.evidence).map((f) => f.recordId)).toEqual([recordB]); + await settleOrganizeEpisodeDecisions(h.ctx, h.db, { recordId: recordB, entityId, files: [] }); + expect(await pending(entityId)).toEqual([]); + }); +}); diff --git a/packages/contracts/src/capabilities-io.ts b/packages/contracts/src/capabilities-io.ts index adbfbb9b..e8377ff4 100644 --- a/packages/contracts/src/capabilities-io.ts +++ b/packages/contracts/src/capabilities-io.ts @@ -980,6 +980,17 @@ export const EpisodeInspectRelease = z.object({ evidence: z.array(z.string()), }); export type EpisodeInspectRelease = z.infer; +export const EpisodeInspectFile = z.object({ + organizationRecordId: Id, + sourcePath: z.string(), + title: z.string(), + publishedAt: z.string().nullable(), + releaseGroup: z.string().nullable(), + source: EpisodeSourceView, + current: EpisodeResultBrief, + evidence: z.array(z.string()), +}); +export type EpisodeInspectFile = z.infer; export const EpisodeMappingInspectOutput = z.object({ entity: z.object({ id: Id, title: z.string(), kind: MediaKind, year: z.number().int().nullable() }), scope: z.object({ season: z.number().int().nullable(), releaseGroup: z.string().nullable() }), @@ -993,6 +1004,8 @@ export const EpisodeMappingInspectOutput = z.object({ .nullable(), /** 事项内资源(没有事项时 = 这个范围里当前待确认的资源),最多 50 条 */ releases: z.array(EpisodeInspectRelease), + /** 整理中无法换算的真实文件编号,最多 50 条;发布时间无来源证据时为 null */ + files: z.array(EpisodeInspectFile), /** 同作品同发布组近期的其他资源(含已对上 / 已换算的),最多 50 条 */ neighbors: z.array(EpisodeInspectRelease), /** 季表:每季总集数、已知每集播出日期与标题(每季最多 100 集) */ @@ -1021,7 +1034,7 @@ export const EpisodeMappingInspectOutput = z.object({ }), /** 确定性建议(按发布时间对播出日期,全部样本成立且唯一才有) */ suggestion: EpisodeSuggestion.nullable(), - truncated: z.object({ releases: z.boolean(), neighbors: z.boolean() }), + truncated: z.object({ releases: z.boolean(), neighbors: z.boolean(), files: z.boolean().optional() }), }); export const EpisodeMappingSimulateInput = z.object({ @@ -1035,7 +1048,10 @@ export const EpisodeMappingSimulateInput = z.object({ reasons: z.array(z.string().trim().min(1).max(300)).max(10).optional(), }); export const EpisodeSimulationItem = z.object({ - sourceReleaseId: Id, + /** 文件样本没有资源 ID */ + sourceReleaseId: Id.nullable(), + organizationRecordId: Id.optional(), + sourcePath: z.string().optional(), title: z.string(), releaseGroup: z.string().nullable(), /** 是否是事项内样本 */