- subscription: status pending (TMDB not ready: searchable/evaluable, not completable), last_searched_at / next_search_at - content_unit (season): episode_total ledger with source / lock / timestamp - acquisition_plan: unit_keys (Slot × unit reservation) and file_selection; drop the one-in-flight-per-slot unique index (checked under slot row lock per unit instead) - new tables slot_deferral, slot_unit (per-unit quality ledger), subscription_history - contracts: SubscriptionParams, SubscriptionHistory, SlotDeferral, SlotUnit, SubscriptionProgress, EpisodeTotalSet, CandidateEvaluation pri/trace/unitKeys and decision deferred; endpoints subscriptions.search/progress/setEpisodeTotal and subscription-history - mapping: evaluation messages via describeViolation, pri/trace passthrough Co-Authored-By: Claude Opus 5.5 <[email protected]>
136 lines
4.6 KiB
TypeScript
136 lines
4.6 KiB
TypeScript
import type {
|
|
AcquisitionSlot,
|
|
AcquisitionSlotCreate,
|
|
SlotStatus as ContractSlotStatus,
|
|
JsonObject,
|
|
} from "@umt/contracts";
|
|
import {
|
|
type SlotStatus as DomainSlotStatus,
|
|
describeViolation,
|
|
type SlotCompletionSpec,
|
|
type SlotEvaluation,
|
|
type SlotHardConditions,
|
|
type SlotSoftPreferences,
|
|
type SlotSpec,
|
|
type SlotUpgradePolicy,
|
|
} from "@umt/domain";
|
|
|
|
// Slot:状态两侧取值相同(REQ §6.1),映射表只为让任一侧增删值时编译/测试失败。
|
|
// 规则 JSON 列(hard_conditions / soft_preferences / upgrade_threshold / completion / fallback)↔ domain SlotSpec。
|
|
|
|
export type { DomainSlotStatus };
|
|
|
|
const TO_CONTRACT: Record<DomainSlotStatus, ContractSlotStatus> = {
|
|
empty: "empty",
|
|
reserved: "reserved",
|
|
downloading: "downloading",
|
|
verifying: "verifying",
|
|
satisfied: "satisfied",
|
|
attention: "attention",
|
|
upgrade_pending: "upgrade_pending",
|
|
};
|
|
const TO_DOMAIN: Record<ContractSlotStatus, DomainSlotStatus> = {
|
|
empty: "empty",
|
|
reserved: "reserved",
|
|
downloading: "downloading",
|
|
verifying: "verifying",
|
|
satisfied: "satisfied",
|
|
upgrade_pending: "upgrade_pending",
|
|
attention: "attention",
|
|
};
|
|
|
|
export const DOMAIN_SLOT_STATUSES = Object.keys(TO_CONTRACT) as DomainSlotStatus[];
|
|
|
|
export const toContractSlotStatus = (s: DomainSlotStatus): ContractSlotStatus => TO_CONTRACT[s];
|
|
export const toDomainSlotStatus = (s: ContractSlotStatus): DomainSlotStatus => TO_DOMAIN[s];
|
|
|
|
type SlotRuleRow = Pick<
|
|
AcquisitionSlot,
|
|
"id" | "name" | "hardConditions" | "softPreferences" | "upgradeThreshold" | "completion" | "fallback"
|
|
>;
|
|
|
|
const isEmpty = (o: JsonObject | null | undefined) => !o || Object.keys(o).length === 0;
|
|
|
|
/**
|
|
* acquisition_slot 行 → domain SlotSpec。JSON 列按 domain 类型原样解释(写入时由 slotSpecToColumns 保证形状);
|
|
* fallback.unknownPolicy 对应 SlotSpec.unknownPolicy。
|
|
*/
|
|
export function slotSpecFromRow(row: SlotRuleRow): SlotSpec {
|
|
const unknownPolicy = row.fallback.unknownPolicy as SlotSpec["unknownPolicy"] | undefined;
|
|
return {
|
|
id: row.id,
|
|
name: row.name,
|
|
hard: row.hardConditions as SlotHardConditions,
|
|
...(unknownPolicy !== undefined ? { unknownPolicy } : {}),
|
|
...(isEmpty(row.softPreferences) ? {} : { soft: row.softPreferences as SlotSoftPreferences }),
|
|
...(isEmpty(row.upgradeThreshold) ? {} : { upgrade: row.upgradeThreshold as unknown as SlotUpgradePolicy }),
|
|
...(isEmpty(row.completion) ? {} : { completion: row.completion as unknown as SlotCompletionSpec }),
|
|
};
|
|
}
|
|
|
|
/** domain SlotSpec → acquisition_slot 规则列(Create/Update 可直接展开) */
|
|
export function slotSpecToColumns(
|
|
spec: SlotSpec,
|
|
): Pick<
|
|
AcquisitionSlotCreate,
|
|
"name" | "hardConditions" | "softPreferences" | "upgradeThreshold" | "completion" | "fallback"
|
|
> {
|
|
const json = (v: unknown): JsonObject => (v ? (JSON.parse(JSON.stringify(v)) as JsonObject) : {});
|
|
return {
|
|
name: spec.name,
|
|
hardConditions: json(spec.hard),
|
|
softPreferences: json(spec.soft),
|
|
upgradeThreshold: json(spec.upgrade),
|
|
completion: json(spec.completion),
|
|
fallback: spec.unknownPolicy === undefined ? {} : json({ unknownPolicy: spec.unknownPolicy }),
|
|
};
|
|
}
|
|
|
|
export interface ContractSlotEvaluation {
|
|
decision: SlotEvaluation["decision"];
|
|
/** 可以用这个候选生成计划(accept 或 upgrade) */
|
|
acceptable: boolean;
|
|
/** 与 DownloadPlanOutput.rejected 同形:硬条件不满足 / 字段未知的原因 */
|
|
rejected: { code: string; message: string }[];
|
|
reasons: string[];
|
|
/** 优先级阶梯 pri(未配置阶梯为 100;拒绝/未知为 null) */
|
|
pri: number | null;
|
|
/** 可读说明(逐条违规 + 逐层阶梯) */
|
|
trace: string[];
|
|
}
|
|
|
|
/** domain 评估结果 → 能力输出/决策记录用的扁平表示 */
|
|
export function toContractSlotEvaluation(ev: SlotEvaluation): ContractSlotEvaluation {
|
|
const trace = [...(ev.trace ?? [])];
|
|
switch (ev.decision) {
|
|
case "accept":
|
|
case "upgrade":
|
|
return {
|
|
decision: ev.decision,
|
|
acceptable: true,
|
|
rejected: [],
|
|
reasons: [...ev.reasons],
|
|
pri: ev.pri ?? null,
|
|
trace,
|
|
};
|
|
case "reject":
|
|
return {
|
|
decision: "reject",
|
|
acceptable: false,
|
|
rejected: ev.violations.map((v) => ({ code: `${v.field}.${v.code}`, message: describeViolation(v) })),
|
|
reasons: [],
|
|
pri: ev.ladder?.pri ?? null,
|
|
trace,
|
|
};
|
|
case "unknown_field":
|
|
return {
|
|
decision: "unknown_field",
|
|
acceptable: false,
|
|
rejected: ev.missing.map((m) => ({ code: `unknown_field.${m}`, message: `字段未知,需要补充: ${m}` })),
|
|
reasons: [],
|
|
pri: null,
|
|
trace,
|
|
};
|
|
}
|
|
}
|