feat(mapping): centralize domain <-> contracts mappings (B5)

New @umt/mapping (depends on domain + contracts only): download status + pause reason,
evidence level / media kind, identity resolution status, slot status / spec columns /
evaluation, ParsedRelease <-> source_release.parsed, SiteRelease <-> source_release,
PathBinding, OrganizePlan -> organization_record / file results / organize.plan output.
Tables are Record<union, ...> so adding a value on either side fails typecheck or tests.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-27 19:20:30 +08:00
1 parent da8e53bbca
commit 42ecc93fa5
16 files changed
+1282 -1

No files matched your search

+106
View File
@@ -0,0 +1,106 @@
import type {
ExternalIdentity,
ExternalIdentityCreate,
IdentityResolveOutput,
IdentityStatus,
MediaEntity,
ResolutionStatus,
} from "@umt/contracts";
import type { IdentityCandidate, IdentityResolution, RankedCandidate } from "@umt/domain";
import { toContractEvidenceLevel, toDomainMediaType } from "./evidence.ts";
// 身份解析:domain.resolveIdentity(auto / ambiguous / no_match)→ contracts 的能力输出状态
// 与 source_release.resolution_status。
export type DomainResolutionStatus = IdentityResolution["status"];
export type ContractResolveStatus = IdentityResolveOutput["status"];
export interface ResolveStatusOptions {
/**
* auto 时是否可直接接受(automation 总开关/授权/人工锁都允许)。
* false 时产出 proposed:证据足够但需要确认,进待识别池。
*/
autoAccept: boolean;
}
const RESOLVE_STATUS: Record<DomainResolutionStatus, (o: ResolveStatusOptions) => ContractResolveStatus> = {
auto: (o) => (o.autoAccept ? "resolved" : "proposed"),
ambiguous: () => "ambiguous",
no_match: () => "unidentifiable",
};
export const DOMAIN_RESOLUTION_STATUSES = Object.keys(RESOLVE_STATUS) as DomainResolutionStatus[];
export function toContractResolveStatus(
status: DomainResolutionStatus,
opts: ResolveStatusOptions,
): ContractResolveStatus {
return RESOLVE_STATUS[status](opts);
}
const RELEASE_RESOLUTION: Record<ContractResolveStatus, ResolutionStatus> = {
resolved: "resolved",
// 待确认的提议与歧义一样进待识别池
proposed: "ambiguous",
ambiguous: "ambiguous",
unidentifiable: "unidentifiable",
};
/** 能力输出状态 → source_release.resolution_status */
export function toReleaseResolutionStatus(status: ContractResolveStatus): ResolutionStatus {
return RELEASE_RESOLUTION[status];
}
/**
* 排名候选 → external_identity 新建输入。本地候选(provider=local)不是外部身份,返回 null;
* 被否决的候选同样返回 null。
*/
export function rankedToIdentityCreate(
r: RankedCandidate,
target: { mediaEntityId: string } | { contentUnitId: string },
status: IdentityStatus = "proposed",
): ExternalIdentityCreate | null {
const level = toContractEvidenceLevel(r.level);
if (!level || r.candidate.provider === "local") return null;
return {
...target,
provider: r.candidate.provider,
entityType: r.candidate.entityType,
externalId: r.candidate.externalId,
status,
evidenceLevel: level,
evidence: {
titleMatch: r.titleMatch,
yearMatch: r.yearMatch,
typeMatch: r.typeMatch,
rules: r.evidence,
},
};
}
/**
* 本地作品 → domain 身份候选(本地优先召回)。externalId 用本地 UUID;
* 已接受的外部身份作为 linkedExternalIds,用于与远端候选判同。
* 媒体类型无法映射到 movie/tv(合集/其他)时返回 null。
*/
export function localIdentityCandidate(
entity: Pick<MediaEntity, "id" | "kind" | "title" | "originalTitle" | "year">,
aliases: readonly string[],
identities: readonly Pick<ExternalIdentity, "provider" | "externalId" | "status">[] = [],
): IdentityCandidate | null {
const entityType = toDomainMediaType(entity.kind);
if (!entityType) return null;
const titles = [entity.title, ...(entity.originalTitle ? [entity.originalTitle] : [])];
return {
provider: "local",
entityType,
externalId: entity.id,
titles,
...(entity.year !== null ? { year: entity.year } : {}),
aliases: [...aliases],
localEntityId: entity.id,
linkedExternalIds: identities
.filter((i) => i.status === "accepted")
.map((i) => ({ provider: i.provider, id: i.externalId })),
};
}