fix(organize): 按真实文件样本验证并保存季集映射
This commit is contained in:
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([]);
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
/** 是否是事项内样本 */
|
||||
|
||||
Reference in new issue
Block a user