fix(organize): 按真实文件样本验证并保存季集映射

This commit is contained in:
root committed 2026-10-13 15:52:51 +08:00
1 parent d196235418
commit 679389f854
5 files changed
+577 -30

No files matched your search

@@ -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<RuleSimulation> {
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<string>();
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({
@@ -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);
@@ -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<EpisodeFileSample, "recordId">[] },
): Promise<void> {
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<string, { season: number | null; releaseGroup: string | null; files: EpisodeFileSample[] }>();
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<typeof r> => !!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<string, unknown>;
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<boolean>`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<void> {
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,
});
}
}
}
@@ -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<Omit<EpisodeFileSample, "recordId">> => {
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([]);
});
});
+18 -2
View File
@@ -980,6 +980,17 @@ export const EpisodeInspectRelease = z.object({
evidence: z.array(z.string()),
});
export type EpisodeInspectRelease = z.infer<typeof EpisodeInspectRelease>;
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<typeof EpisodeInspectFile>;
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(),
/** 是否是事项内样本 */