refactor(core): migrate to Bun and converge acquisition policies and Agent boundaries
This commit is contained in:
1 parent
3f1c6d02f6
commit
27c0f27434
268 files changed
+7468
-7852
No files matched your search
@@ -25,6 +25,7 @@ export {
|
||||
resolveAutoFill,
|
||||
} from "./fill.ts";
|
||||
export { type AcquisitionMode, determineMode, type ModeDecision, type ModeInput } from "./mode.ts";
|
||||
export { type ScopedAcquisitionPolicy, selectAcquisitionPolicy } from "./policy.ts";
|
||||
export {
|
||||
DEFAULT_PRIORITY_ORDER,
|
||||
type PriorityClass,
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { AcquisitionPolicyScope } from "@umt/contracts";
|
||||
|
||||
export interface ScopedAcquisitionPolicy {
|
||||
id: string;
|
||||
scope: AcquisitionPolicyScope;
|
||||
}
|
||||
|
||||
/** 输入已按发布时间排序;分类明确命中优先于类型默认,显式选择不回退到别的策略。 */
|
||||
export function selectAcquisitionPolicy<T extends ScopedAcquisitionPolicy>(
|
||||
policies: readonly T[],
|
||||
input: { explicitId?: string | null; mediaKind: "movie" | "series" | "variety"; category: string | null },
|
||||
): T | undefined {
|
||||
if (input.explicitId) return policies.find((p) => p.id === input.explicitId);
|
||||
const type = input.mediaKind === "movie" ? "movie" : "tv";
|
||||
let selected: T | undefined;
|
||||
let specificity = -1;
|
||||
for (const p of policies) {
|
||||
if (p.scope.mediaKinds?.length && !p.scope.mediaKinds.includes(input.mediaKind)) continue;
|
||||
const categories = p.scope.categories;
|
||||
if (categories && Object.values(categories).some((v) => v?.length)) {
|
||||
if (!input.category || !categories[type]?.includes(input.category)) continue;
|
||||
}
|
||||
const score = categories?.[type]?.length ? 2 : p.scope.mediaKinds?.length ? 1 : 0;
|
||||
if (score > specificity) {
|
||||
selected = p;
|
||||
specificity = score;
|
||||
}
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ContentType } from "@umt/contracts";
|
||||
import type { CompiledGlobalWord } from "../identify-words/apply.ts";
|
||||
|
||||
/** 解析器版本:规则变化时递增,缓存/识别结果按此失效 */
|
||||
@@ -35,15 +36,8 @@ export type Cut =
|
||||
| "Ultimate"
|
||||
| "Special Edition";
|
||||
|
||||
/** 综艺内容类型(REQ §5.1);v0.1 只识别标记,不做分段建模 */
|
||||
export type ContentType =
|
||||
| "unknown"
|
||||
| "main"
|
||||
| "extra"
|
||||
| "behind_the_scenes"
|
||||
| "performance_only"
|
||||
| "preview"
|
||||
| "reaction";
|
||||
// 内容类型在 contracts 定义一次;领域与接口使用相同语义。
|
||||
export type { ContentType };
|
||||
|
||||
export type Segment = "full" | "upper" | "middle" | "lower";
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { ParsedRelease } from "../release-parser/types.ts";
|
||||
import type { HardField } from "../slot/types.ts";
|
||||
import { type AtomCatalog, testAtom } from "./evaluate.ts";
|
||||
import type { RuleContext, RuleExpr, RuleGroupSpec } from "./types.ts";
|
||||
|
||||
// 只对有明确事实字段的条件判断未知;正则文本缺词仍是已知不命中。
|
||||
const fields: Record<string, HardField> = {
|
||||
"4K": "resolution",
|
||||
"1080P": "resolution",
|
||||
"720P": "resolution",
|
||||
BLU: "source",
|
||||
BLURAY: "source",
|
||||
REMUX: "source",
|
||||
WEBDL: "source",
|
||||
H265: "codec",
|
||||
H264: "codec",
|
||||
FREE: "promotion",
|
||||
};
|
||||
type PartialResult = { hit: boolean | null; missing: HardField[] };
|
||||
|
||||
function partial(e: RuleExpr, r: ParsedRelease, ctx: RuleContext, catalog: AtomCatalog): PartialResult {
|
||||
if (e.op === "atom") {
|
||||
const id = e.id.toUpperCase();
|
||||
const f = fields[id];
|
||||
const value =
|
||||
f === "resolution"
|
||||
? r.resolution
|
||||
: f === "source"
|
||||
? r.source
|
||||
: f === "codec"
|
||||
? r.videoCodec
|
||||
: ctx.downloadVolumeFactor;
|
||||
if (f && value === undefined) return { hit: null, missing: [f] };
|
||||
const custom = catalog.get(id);
|
||||
const missing: HardField[] = [];
|
||||
if (custom?.minSeeders !== undefined && ctx.seeders === undefined) missing.push("seeders");
|
||||
if (custom?.free && ctx.downloadVolumeFactor === undefined) missing.push("promotion");
|
||||
if (custom?.unitSizeMB && r.sizeBytes === undefined) missing.push("size");
|
||||
const input = { r, ctx, text: [r.raw ?? "", ctx.subtitle ?? "", ...(ctx.labels ?? [])].join(" ") };
|
||||
// 已知的正则条件失败时,其他缺失字段不会改变结论。
|
||||
if (custom && missing.length) {
|
||||
const known = { ...custom, minSeeders: undefined, free: undefined, unitSizeMB: undefined };
|
||||
const knownCatalog = new Map(catalog).set(id, known);
|
||||
if (!testAtom(id, input, knownCatalog).hit) return { hit: false, missing: [] };
|
||||
return { hit: null, missing };
|
||||
}
|
||||
return { hit: testAtom(id, input, catalog).hit, missing: [] };
|
||||
}
|
||||
if (e.op === "not") {
|
||||
const result = partial(e.arg, r, ctx, catalog);
|
||||
return { ...result, hit: result.hit === null ? null : !result.hit };
|
||||
}
|
||||
const results = e.args.map((a) => partial(a, r, ctx, catalog));
|
||||
const decisive = e.op !== "and";
|
||||
if (results.some((x) => x.hit === decisive)) return { hit: decisive, missing: [] };
|
||||
const unknown = results.filter((x) => x.hit === null);
|
||||
return unknown.length ? { hit: null, missing: unknown.flatMap((x) => x.missing) } : { hit: !decisive, missing: [] };
|
||||
}
|
||||
|
||||
/** 未知的高档不能被当成不命中而落入低档,NOT 也不能把未知当成事实。 */
|
||||
export function unknownRuleFields(
|
||||
groups: readonly RuleGroupSpec[],
|
||||
r: ParsedRelease,
|
||||
ctx: RuleContext,
|
||||
catalog: AtomCatalog,
|
||||
): HardField[] {
|
||||
const missing: HardField[] = [];
|
||||
for (const group of groups) {
|
||||
for (const expr of group.hardExcludes ?? []) missing.push(...partial(expr, r, ctx, catalog).missing);
|
||||
for (const layer of group.layers) {
|
||||
const result = partial(layer.expr, r, ctx, catalog);
|
||||
missing.push(...result.missing);
|
||||
if (result.hit === true) break;
|
||||
}
|
||||
}
|
||||
return [...new Set(missing)];
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { ParsedRelease, ReleaseSource, Resolution } from "../release-parser/types.ts";
|
||||
import { catalogOf, evaluateRuleChain } from "../rules/evaluate.ts";
|
||||
import type { RuleChainResult } from "../rules/types.ts";
|
||||
import { unknownRuleFields } from "../rules/unknown.ts";
|
||||
import { seasonReleaseKey, upgradeSpec } from "./season-release.ts";
|
||||
import type {
|
||||
EvaluationContext,
|
||||
HardField,
|
||||
@@ -310,6 +312,8 @@ export function describeViolation(v: Violation): string {
|
||||
return `优先级没有提高(现有 ${JSON.stringify(e)},这个资源 ${JSON.stringify(a)})`;
|
||||
case "upgrade.not_full_pack":
|
||||
return "全集洗版只接受整季包";
|
||||
case "upgrade.different_season_release":
|
||||
return "与当季选定的成套版本不同,等待同套资源";
|
||||
case "upgrade.no_improvable_units":
|
||||
return "这个资源包含的季集都没有可升级的";
|
||||
default:
|
||||
@@ -348,6 +352,37 @@ function tierFailures(rule: SlotTierRule, r: ParsedRelease, ctx: EvaluationConte
|
||||
return out;
|
||||
}
|
||||
|
||||
function unknownTierFields(spec: SlotSpec, r: ParsedRelease, ctx: EvaluationContext): HardField[] {
|
||||
for (const tier of spec.soft?.tiers ?? []) {
|
||||
if (!tierFailures(tier, r, ctx).length) break;
|
||||
const missing: HardField[] = [];
|
||||
const filled = { ...r };
|
||||
if (!r.resolution && tier.resolutions?.length) {
|
||||
missing.push("resolution");
|
||||
filled.resolution = tier.resolutions[0];
|
||||
}
|
||||
if (!r.source && (tier.sources?.length || tier.excludeSources?.length || tier.requireEncode)) {
|
||||
missing.push("source");
|
||||
filled.source =
|
||||
tier.sources?.[0] ??
|
||||
(tier.requireEncode
|
||||
? "BluRay"
|
||||
: (Object.keys(DEFAULT_SOURCE_TIERS) as ReleaseSource[]).find((s) => !tier.excludeSources?.includes(s)));
|
||||
filled.isEncode = tier.requireEncode ?? filled.source === "BluRay";
|
||||
}
|
||||
if (!r.videoCodec && tier.codecs?.length) {
|
||||
missing.push("codec");
|
||||
filled.videoCodec = tier.codecs[0];
|
||||
}
|
||||
if (!r.releaseGroup && tier.groups?.length) {
|
||||
missing.push("group");
|
||||
filled.releaseGroup = { name: tier.groups.join("@"), source: "known" };
|
||||
}
|
||||
if (missing.length && !tierFailures(tier, filled, ctx).length) return missing;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 优先级阶梯(对应 MP 规则组 `A > B > C`):逐层匹配,第 k 层(0 起)命中 → pri = 100 - k;
|
||||
* 未配置阶梯 → pri = 100、level = null(不参与排序差异);配置了但都不命中 → pri = 0。
|
||||
@@ -465,9 +500,21 @@ export function evaluateCandidate(
|
||||
current?: QualityFacts | null,
|
||||
ctx: EvaluationContext = {},
|
||||
): SlotEvaluation {
|
||||
if (current) spec = upgradeSpec(spec);
|
||||
const hard = checkHardConditions(spec, r, ctx);
|
||||
const { missing, accepted } = hard;
|
||||
const chain = ruleChainOf(spec, r, ctx);
|
||||
if (hard.violations.length === 0) {
|
||||
const unknown = spec.rules ? unknownRuleFields(spec.rules.groups, r, ctx, catalogOf(spec.rules.atoms)) : [];
|
||||
if (chain?.decidingGroup === undefined || chain.decidingGroup === null)
|
||||
unknown.push(...unknownTierFields(spec, r, ctx));
|
||||
if (unknown.length)
|
||||
return {
|
||||
decision: "unknown_field",
|
||||
missing: [...new Set([...hard.missing, ...unknown])],
|
||||
trace: unknown.map((f) => `组合规则需要补充:${f}`),
|
||||
};
|
||||
}
|
||||
const { missing, accepted } = hard;
|
||||
const withRules = chain ? { rules: chain } : {};
|
||||
const violations = [...hard.violations, ...chainViolations(chain, spec)];
|
||||
const ladder = effectiveLadder(spec, r, ctx, chain);
|
||||
@@ -524,6 +571,53 @@ export function evaluateCandidate(
|
||||
const up = spec.upgrade;
|
||||
if (!up?.enabled) return reject({ field: "upgrade", code: "upgrade_disabled" });
|
||||
if (up.mode === "full_pack" && (r.episodes || r.airDate)) return reject({ field: "upgrade", code: "not_full_pack" });
|
||||
if (r.isEncode && up.encodeGroupAllowlist?.length) {
|
||||
if (!r.releaseGroup) return { decision: "unknown_field", missing: ["group"], trace: ["合格压制组需要查证发布组"] };
|
||||
const groups = r.releaseGroup ? r.releaseGroup.name.split("@").map(lc) : [];
|
||||
if (!up.encodeGroupAllowlist.some((g) => groups.includes(lc(g))))
|
||||
return reject({
|
||||
field: "upgrade",
|
||||
code: "encode_not_qualified",
|
||||
expected: up.encodeGroupAllowlist,
|
||||
actual: r.releaseGroup?.name,
|
||||
});
|
||||
}
|
||||
if (up.consistency === "season_release") {
|
||||
const key = seasonReleaseKey(r);
|
||||
const targetKey = ctx.seasonReleaseTarget ? seasonReleaseKey(ctx.seasonReleaseTarget) : null;
|
||||
if (key && !targetKey)
|
||||
return { decision: "unknown_field", missing: ["current_asset"], trace: ["当季成套目标的规格未知"] };
|
||||
if (!key)
|
||||
return {
|
||||
decision: "unknown_field",
|
||||
missing: [
|
||||
...(!r.resolution ? ["resolution" as const] : []),
|
||||
...(!r.source ? ["source" as const] : []),
|
||||
...(!r.videoCodec ? ["codec" as const] : []),
|
||||
...(!r.releaseGroup?.name ? ["group" as const] : []),
|
||||
],
|
||||
trace: ["成套洗版需要完整版本规格"],
|
||||
};
|
||||
if (key !== targetKey) return reject({ field: "upgrade", code: "different_season_release" });
|
||||
const fromPri = currentPri(spec, current);
|
||||
if (key !== seasonReleaseKey(current) && ladder.pri === fromPri) {
|
||||
const from = qualityTier(current, spec);
|
||||
if (tier.source < from.source || tier.resolution < from.resolution)
|
||||
return reject({ field: "upgrade", code: "lower_tier", expected: from, actual: tier });
|
||||
return {
|
||||
decision: "upgrade",
|
||||
tier,
|
||||
fromTier: from,
|
||||
softRank: rank,
|
||||
reasons: [...reasons, "season_release_aligned", ...priReasons],
|
||||
pri: ladder.pri,
|
||||
fromPri,
|
||||
ladder,
|
||||
trace: ["保持优先级,向当季同套版本对齐", ...ladder.trace],
|
||||
...withRules,
|
||||
};
|
||||
}
|
||||
}
|
||||
const basis = up.basis ?? (tiered ? "priority" : "tier");
|
||||
if (basis === "priority") {
|
||||
const fromPri = currentPri(spec, current);
|
||||
@@ -559,16 +653,6 @@ export function evaluateCandidate(
|
||||
dSource === 0 && dRes === 0 ? "same_tier" : dSource < 0 || dRes < 0 ? "lower_tier" : "below_min_improvement";
|
||||
return reject({ field: "upgrade", code, expected: { minTierSteps: minSteps, from }, actual: tier });
|
||||
}
|
||||
if (r.isEncode && up.encodeGroupAllowlist?.length) {
|
||||
const groups = r.releaseGroup ? r.releaseGroup.name.split("@").map(lc) : [];
|
||||
if (!up.encodeGroupAllowlist.some((g) => groups.includes(lc(g))))
|
||||
return reject({
|
||||
field: "upgrade",
|
||||
code: "encode_not_qualified",
|
||||
expected: up.encodeGroupAllowlist,
|
||||
actual: r.releaseGroup?.name,
|
||||
});
|
||||
}
|
||||
return {
|
||||
decision: "upgrade",
|
||||
tier,
|
||||
|
||||
@@ -59,6 +59,7 @@ export {
|
||||
type SubscriptionGroupResult,
|
||||
subscriptionGroup,
|
||||
} from "./overview.ts";
|
||||
export { seasonReleaseKey, selectSeasonRelease, upgradeSpec } from "./season-release.ts";
|
||||
export {
|
||||
canTransition,
|
||||
type SlotEvent,
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { ParsedRelease } from "../release-parser/types.ts";
|
||||
import { compareRank, currentPri, qualityTier, softRank } from "./evaluate.ts";
|
||||
import type { QualityFacts, SlotSpec } from "./types.ts";
|
||||
|
||||
/** 缺少关键规格时不能声称同套;集号、文件名、种子 ID 不属于版本身份。 */
|
||||
export function seasonReleaseKey(q: QualityFacts): string | null {
|
||||
if (!q.resolution || !q.source || !q.videoCodec || !q.releaseGroup?.name) return null;
|
||||
return JSON.stringify([
|
||||
q.resolution,
|
||||
q.source,
|
||||
q.videoCodec,
|
||||
q.releaseGroup.name
|
||||
.toLowerCase()
|
||||
.split("@")
|
||||
.map((g) => g.trim())
|
||||
.sort(),
|
||||
[...(q.hdr ?? [])].sort(),
|
||||
q.cut ?? "default",
|
||||
]);
|
||||
}
|
||||
|
||||
export function upgradeSpec(spec: SlotSpec): SlotSpec {
|
||||
return {
|
||||
...spec,
|
||||
...(spec.upgradeRules ? { rules: spec.upgradeRules } : {}),
|
||||
...(spec.upgradeHard ? { hard: spec.upgradeHard } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** 优先级、软偏好、规范化规格形成稳定顺序;同档对齐不允许质量降档。 */
|
||||
export function selectSeasonRelease(spec: SlotSpec, versions: readonly QualityFacts[]): QualityFacts | undefined {
|
||||
let target: QualityFacts | undefined;
|
||||
const rank = (q: QualityFacts) => {
|
||||
const tier = qualityTier(q, spec);
|
||||
return [
|
||||
currentPri(spec, q),
|
||||
tier.source,
|
||||
tier.resolution,
|
||||
...softRank(spec, { raw: "", hdr: [], audio: [], ...q } as unknown as ParsedRelease),
|
||||
];
|
||||
};
|
||||
for (const q of versions) {
|
||||
const key = seasonReleaseKey(q);
|
||||
if (!key) continue;
|
||||
const compared = target ? compareRank(rank(q), rank(target)) : 1;
|
||||
if (compared > 0 || (compared === 0 && key < seasonReleaseKey(target!)!)) target = q;
|
||||
}
|
||||
return target;
|
||||
}
|
||||
@@ -91,6 +91,10 @@ export interface SlotSoftPreferences {
|
||||
|
||||
export interface SlotUpgradePolicy {
|
||||
enabled: boolean;
|
||||
/** 洗版阶段显式规则组;未设时沿用既有洗版默认链。 */
|
||||
ruleGroupIds?: string[];
|
||||
/** 追更可混合,洗版按季向同组、同来源和编码的版本收敛。 */
|
||||
consistency?: "season_release";
|
||||
/** 最小改进:来源等级至少提升几级,默认 1(同等级不反复更换) */
|
||||
minTierSteps?: number;
|
||||
/** 来源等级表;默认 REMUX/原盘/蓝光二压=3,WEB-DL=2,WEBRip/HDTV/HDRip=1,DVD=0 */
|
||||
@@ -128,6 +132,9 @@ export interface SlotSpec {
|
||||
hard: SlotHardConditions;
|
||||
/** 规则组:硬排除 → 拒绝;各组须命中某层(ladderRequired);pri 取最后一个带层的组(覆盖 soft.tiers 的 pri) */
|
||||
rules?: SlotRuleChain;
|
||||
/** 服务入口解析的洗版规则,不参与首次补齐。 */
|
||||
upgradeRules?: SlotRuleChain;
|
||||
upgradeHard?: SlotHardConditions;
|
||||
unknownPolicy?: UnknownPolicy | Partial<Record<HardField, UnknownPolicy>>;
|
||||
soft?: SlotSoftPreferences;
|
||||
upgrade?: SlotUpgradePolicy;
|
||||
@@ -151,6 +158,8 @@ export type QualityFacts = Pick<
|
||||
|
||||
/** 评估上下文:站点字段与折算口径(不在标题解析里的事实由调用方给) */
|
||||
export interface EvaluationContext {
|
||||
/** 季级成套目标,由同一收录版本的有效资产及在途计划确定。 */
|
||||
seasonReleaseTarget?: QualityFacts;
|
||||
siteId?: string;
|
||||
/** 副标题/标签等参与关键词匹配的文本 */
|
||||
subtitle?: string;
|
||||
|
||||
Reference in new issue
Block a user