Files
rootandClaude Opus 5.5 03de030371 test(identity): MoviePilot differential on 113 HHClub titles (ID-2)
MP MetaInfo + TheMovieDbModule name matching vs parse/recall/resolveIdentity,
TMDB responses replayed offline; includes the category-alias misbinding
regression from the user stack.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-29 17:41:22 +08:00

239 lines
10 KiB
TypeScript

/**
* 身份匹配差分(ID-2):憨憨真实标题 → parseRelease → TMDB 候选召回(recallTmdbCandidates)→ resolveIdentity,
* 与 MoviePilot(MetaInfo + TheMovieDbModule 名称识别链,见 gen-identity-mp.py)逐条比对。
*
* 数据在 packages/domain/test/differential/identity/:
* samples.json 输入标题(用户栈只读导出)
* mp.json MP 侧期望(gen-identity-mp.py 生成)
* tmdb-fixtures.json 我们侧用到的 TMDB 响应(脱敏裁剪,测试离线回放)
* deviations.json 逐条归因(mp_defect / intentional / known_gap)
* poisoned-local.json 真机误识别:含分类词别名的本地作品(回归「电视剧」别名把 15 条资源并入一部剧)
* 重新录制:identity-differential.run.ts(走本地 TMDB 缓存代理)。
*/
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { recallTmdbCandidates, TmdbClient } from "@umt/adapters";
import { type IdentityCandidate, type IdentityResolution, parseRelease, resolveIdentity } from "@umt/domain";
import { localIdentityCandidate } from "@umt/mapping";
import { tmdbRecallInput, tmdbToCandidate } from "../../src/modules/sources-identity/service/tmdb-candidate.ts";
export const DATA_DIR = join(
dirname(fileURLToPath(import.meta.url)),
"../../../../packages/domain/test/differential/identity",
);
export interface Sample {
id: string;
work: string;
kind: string;
title: string;
subtitle: string | null;
}
export interface MpResult {
meta?: Record<string, unknown>;
names?: string[];
match: { tmdbId: number; type: "movie" | "tv"; title: string; year: number | null; by: string } | null;
error?: string;
}
export type Verdict = "consistent" | "ours_missed" | "ours_wrong" | "ours_extra";
export interface Deviation {
kind: "mp_defect" | "intentional" | "known_gap";
expect?: string | null;
note: string;
}
export interface Row {
id: string;
title: string;
parsed: { titleCn?: string; titleEn?: string; altTitles?: string[]; year?: number; season?: number };
mp: string | null;
ours: string | null;
status: IdentityResolution["status"];
reason: string;
best: string | null;
bestEvidence: string[];
verdict: Verdict;
deviation?: Deviation;
}
const readJson = <T>(f: string): T => JSON.parse(readFileSync(join(DATA_DIR, f), "utf8")) as T;
export function loadData() {
return {
samples: readJson<{ samples: Sample[] }>("samples.json").samples,
mp: readJson<{ results: Record<string, MpResult> }>("mp.json").results,
deviations: readJson<{ deviations: Record<string, Deviation> }>("deviations.json").deviations,
};
}
/** TMDB 请求的缓存键:路径 + 排序后的参数(去掉凭证) */
export function fixtureKey(url: string): string {
const u = new URL(url);
const params = [...u.searchParams.entries()].filter(([k]) => k !== "api_key").sort(([a], [b]) => a.localeCompare(b));
return `${u.pathname.replace(/^\/3/, "")}?${new URLSearchParams(params).toString()}`;
}
const pick = (o: Record<string, unknown>, keys: string[]) =>
Object.fromEntries(keys.filter((k) => o[k] !== undefined && o[k] !== null).map((k) => [k, o[k]]));
const BRIEF = [
"id",
"media_type",
"title",
"name",
"original_title",
"original_name",
"original_language",
"release_date",
"first_air_date",
"popularity",
"origin_country",
];
/** 只留识别用到的字段(标题、日期、别名、译名标题、外部 ID、季列表) */
export function trimTmdb(body: unknown): unknown {
const b = body as Record<string, unknown>;
if (Array.isArray(b.results)) {
return { results: (b.results as Record<string, unknown>[]).map((r) => pick(r, BRIEF)) };
}
const out = pick(b, BRIEF);
const alt = b.alternative_titles as Record<string, unknown[]> | undefined;
if (alt) {
const list = (alt.titles ?? alt.results ?? []) as Record<string, unknown>[];
out.alternative_titles = { [alt.titles ? "titles" : "results"]: list.map((a) => pick(a, ["title", "iso_3166_1"])) };
}
const tr = (b.translations as { translations?: Record<string, unknown>[] } | undefined)?.translations;
if (tr) {
out.translations = {
translations: tr
.map((t) => ({
iso_639_1: t.iso_639_1,
iso_3166_1: t.iso_3166_1,
data: pick((t.data ?? {}) as Record<string, unknown>, ["title", "name"]),
}))
.filter((t) => Object.keys(t.data).length),
};
}
const ext = b.external_ids as Record<string, unknown> | undefined;
if (ext) out.external_ids = pick(ext, ["imdb_id", "tvdb_id"]);
if (Array.isArray(b.seasons))
out.seasons = (b.seasons as Record<string, unknown>[]).map((s) =>
pick(s, ["season_number", "air_date", "episode_count", "name"]),
);
return out;
}
/** 离线回放:按键取录制的响应;没录到的请求直接报错(说明召回逻辑变了,需要重新录制) */
export function fixtureFetch(fixtures: Record<string, unknown>): typeof fetch {
return (async (input: string | URL | Request) => {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
const key = fixtureKey(url);
if (!(key in fixtures)) throw new Error(`TMDB 回放缺少请求 ${key}(召回逻辑变化,需重新录制)`);
const body = fixtures[key] as { __status?: number };
const status = body.__status ?? 200;
return new Response(JSON.stringify(status === 200 ? body : { status_message: "not found" }), {
status,
headers: { "content-type": "application/json" },
});
}) as typeof fetch;
}
export function tmdbFor(fetchImpl: typeof fetch, baseUrl = "http://tmdb.fixture/3"): TmdbClient {
return new TmdbClient({ apiKey: "x", baseUrl, fetch: fetchImpl, retries: 0 });
}
const keyOf = (c: { entityType: string; externalId: string } | undefined) =>
c ? `${c.entityType}:${c.externalId}` : null;
/** 单条:解析 → 召回 → 打分(local = 额外的本地候选,例如真机误识别场景) */
export async function resolveSample(tmdb: TmdbClient, s: Sample, local: IdentityCandidate[] = []) {
const parsed = parseRelease(s.title, s.subtitle ? { subtitle: s.subtitle } : {});
const remote = await recallTmdbCandidates(tmdb, tmdbRecallInput(parsed));
const resolution = resolveIdentity(parsed, [...local, ...remote.map(tmdbToCandidate)]);
return { parsed, resolution };
}
export async function runDifferential(tmdb: TmdbClient): Promise<Row[]> {
const { samples, mp, deviations } = loadData();
const rows: Row[] = [];
for (const s of samples) {
const { parsed, resolution } = await resolveSample(tmdb, s);
const m = mp[s.id]?.match ?? null;
const mpKey = m ? `${m.type}:${m.tmdbId}` : null;
const best = resolution.best?.candidate;
const ours = resolution.status === "auto" ? keyOf(best) : null;
const verdict: Verdict =
mpKey === ours ? "consistent" : !ours ? "ours_missed" : !mpKey ? "ours_extra" : "ours_wrong";
rows.push({
id: s.id,
title: s.title,
parsed: {
...(parsed.titleCn ? { titleCn: parsed.titleCn } : {}),
...(parsed.titleEn ? { titleEn: parsed.titleEn } : {}),
...(parsed.altTitles ? { altTitles: parsed.altTitles } : {}),
...(parsed.year !== undefined ? { year: parsed.year } : {}),
...(parsed.season !== undefined ? { season: parsed.season } : {}),
},
mp: mpKey,
ours,
status: resolution.status,
reason: resolution.reason,
best: best ? `${keyOf(best)} ${best.titles[0] ?? ""} ${best.year ?? ""}`.trim() : null,
bestEvidence: resolution.best ? [`level:${resolution.best.level}`, ...resolution.best.evidence] : [],
verdict,
...(deviations[s.id] ? { deviation: deviations[s.id] } : {}),
});
}
return rows;
}
/** 汇总:非故意偏离项的一致率(MP 缺陷且我们正确的计为一致) */
export function summarize(rows: Row[]) {
const scored = rows.filter((r) => r.deviation?.kind !== "intentional");
const ok = scored.filter(
(r) => r.verdict === "consistent" || (r.deviation?.kind === "mp_defect" && r.ours === (r.deviation.expect ?? null)),
);
const count = (v: Verdict) => rows.filter((r) => r.verdict === v).length;
return {
samples: rows.length,
works: new Set(loadData().samples.map((s) => s.work)).size,
consistent: count("consistent"),
oursMissed: count("ours_missed"),
oursWrong: count("ours_wrong"),
oursExtra: count("ours_extra"),
intentional: rows.length - scored.length,
mpDefect: rows.filter((r) => r.deviation?.kind === "mp_defect").length,
rate: scored.length ? ok.length / scored.length : 1,
};
}
export interface PoisonedCase {
title: string;
subtitle: string | null;
/** 正确的 TMDB 身份 type:id,或 not_local = 不得自动并入被污染的本地作品 */
expect: string;
}
/** 真机误识别回归:带「电视剧」等错误站点别名的本地作品 + 被误并入的资源,重新识别的结果 */
export async function runPoisoned(tmdb: TmdbClient) {
const d = readJson<{
entity: { id: string; kind: "series"; title: string; originalTitle: string; year: number };
aliases: string[];
identities: { provider: string; externalId: string; status: "accepted" }[];
cases: PoisonedCase[];
}>("poisoned-local.json");
const local = localIdentityCandidate(d.entity, d.aliases, d.identities)!;
const out: { c: PoisonedCase; status: string; best: string | null; local: boolean; ok: boolean }[] = [];
for (const c of d.cases) {
const s: Sample = { id: "poisoned", work: "", kind: "", title: c.title, subtitle: c.subtitle };
const { resolution } = await resolveSample(tmdb, s, [local]);
const best = resolution.best?.candidate;
const auto = resolution.status === "auto";
// 本地作品已映射 tmdb:312027:自动并入本地作品等价于识别为该 TMDB 身份
const key = !auto || !best ? null : best.provider === "local" ? "local" : keyOf(best);
const effective = key === "local" ? "tv:312027" : key;
const ok = c.expect === "not_local" ? key !== "local" : effective === c.expect;
out.push({ c, status: resolution.status, best: key, local: key === "local", ok });
}
return out;
}