feat(domain): rule groups — MP rule-string parser/serializer, builtin keywords, chain evaluation wired into slot
- rules/: RuleExpr tree, '>' layers with ! & | and parens, position-aware errors (unknown atom with suggestions, unsupported music atoms, parens, empty layer) - builtin keyword table aligned with MP BUILTIN_RULE_SET; field-based judgement where parsed fields exist, MP regex otherwise; custom atoms (MP 7 fields) - chain evaluation: hard excludes, ladder pri 100-k, groups AND-ed, pri from last laddered group, readable per-atom trace - slot: SlotSpec.rules, rule.hard_exclude / rule.no_layer_matched violations, pri basis switches to priority when a rule ladder is present - differential: 66 rule chains x 32 torrents vs MP python path (parse 64/64, pri 2090/2112, 22 registered mp_bug) Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
22856d0447
commit
e994a7bcfa
13 files changed
+23919
-10
No files matched your search
@@ -1,4 +1,6 @@
|
||||
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 type {
|
||||
EvaluationContext,
|
||||
HardField,
|
||||
@@ -288,6 +290,10 @@ export function describeViolation(v: Violation): string {
|
||||
return `非免费(下载系数 ${a})`;
|
||||
case "tier.no_tier_matched":
|
||||
return "不命中优先级阶梯的任何一层";
|
||||
case "rule.hard_exclude":
|
||||
return `命中规则组「${e}」的硬排除 ${fmtList(a)}`;
|
||||
case "rule.no_layer_matched":
|
||||
return `不命中规则组「${e}」的任何一层`;
|
||||
case "upgrade.upgrade_disabled":
|
||||
return "已有版本且未开启升级";
|
||||
case "upgrade.at_ceiling":
|
||||
@@ -366,8 +372,47 @@ export function priorityLadder(spec: SlotSpec, r: ParsedRelease, ctx: Evaluation
|
||||
/** 当前资产的阶梯 pri:入库时记下的优先;否则按交付特征重算(关键词条件对已入库资产视为不命中) */
|
||||
export function currentPri(spec: SlotSpec, current: QualityFacts): number {
|
||||
if (typeof current.pri === "number") return current.pri;
|
||||
const pseudo = { raw: "", hdr: [], isEncode: false, ...current } as unknown as ParsedRelease;
|
||||
return priorityLadder(spec, pseudo).pri;
|
||||
const pseudo = { raw: "", hdr: [], audio: [], isEncode: false, ...current } as unknown as ParsedRelease;
|
||||
return effectiveLadder(spec, pseudo, {}, ruleChainOf(spec, pseudo, {})).pri;
|
||||
}
|
||||
|
||||
/** 版本目标引用的规则组链求值;没有规则组时为 null */
|
||||
export function ruleChainOf(spec: SlotSpec, r: ParsedRelease, ctx: EvaluationContext = {}): RuleChainResult | null {
|
||||
if (!spec.rules?.groups.length) return null;
|
||||
return evaluateRuleChain(spec.rules.groups, r, ctx, catalogOf(spec.rules.atoms));
|
||||
}
|
||||
|
||||
/**
|
||||
* 实际生效的阶梯:版本目标自身 soft.tiers 视为排在最前的匿名组,规则组链里有带层的组时 pri 取最后一个带层的组。
|
||||
*/
|
||||
export function effectiveLadder(
|
||||
spec: SlotSpec,
|
||||
r: ParsedRelease,
|
||||
ctx: EvaluationContext,
|
||||
chain: RuleChainResult | null,
|
||||
): LadderResult {
|
||||
const own = priorityLadder(spec, r, ctx);
|
||||
if (!chain) return own;
|
||||
if (chain.pri === null || chain.decidingGroup === null) return { ...own, trace: [...own.trace, ...chain.trace] };
|
||||
const g = chain.groups[chain.decidingGroup]!;
|
||||
return {
|
||||
pri: chain.pri,
|
||||
level: g.level,
|
||||
levelName: g.level === null ? null : `${g.name}·第${g.level + 1}层`,
|
||||
trace: [...own.trace, ...chain.trace],
|
||||
};
|
||||
}
|
||||
|
||||
function chainViolations(chain: RuleChainResult | null, spec: SlotSpec): Violation[] {
|
||||
if (!chain) return [];
|
||||
const out: Violation[] = [];
|
||||
chain.groups.forEach((g, i) => {
|
||||
if (g.excludedBy.length) out.push({ field: "rule", code: "hard_exclude", expected: g.name, actual: g.excludedBy });
|
||||
const required = spec.rules!.groups[i]!.ladderRequired !== false;
|
||||
if (g.pri !== null && g.level === null && required)
|
||||
out.push({ field: "rule", code: "no_layer_matched", expected: g.name });
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function prefIndexScore<T>(pref: readonly T[] | undefined, have: readonly T[]): number {
|
||||
@@ -420,21 +465,43 @@ export function evaluateCandidate(
|
||||
current?: QualityFacts | null,
|
||||
ctx: EvaluationContext = {},
|
||||
): SlotEvaluation {
|
||||
const { violations, missing, accepted } = checkHardConditions(spec, r, ctx);
|
||||
const ladder = priorityLadder(spec, r, ctx);
|
||||
const hard = checkHardConditions(spec, r, ctx);
|
||||
const { missing, accepted } = hard;
|
||||
const chain = ruleChainOf(spec, r, ctx);
|
||||
const withRules = chain ? { rules: chain } : {};
|
||||
const violations = [...hard.violations, ...chainViolations(chain, spec)];
|
||||
const ladder = effectiveLadder(spec, r, ctx, chain);
|
||||
if (violations.length > 0)
|
||||
return { decision: "reject", violations, trace: [...violations.map(describeViolation), ...ladder.trace], ladder };
|
||||
return {
|
||||
decision: "reject",
|
||||
violations,
|
||||
trace: [...violations.map(describeViolation), ...ladder.trace],
|
||||
ladder,
|
||||
...withRules,
|
||||
};
|
||||
if (missing.length > 0)
|
||||
return { decision: "unknown_field", missing, trace: missing.map((f) => `字段未知,需要补充:${f}`) };
|
||||
if (spec.soft?.tiers?.length && spec.soft.tiersRequired && ladder.level === null) {
|
||||
const v: Violation = { field: "tier", code: "no_tier_matched" };
|
||||
return { decision: "reject", violations: [v], trace: [describeViolation(v), ...ladder.trace], ladder };
|
||||
return {
|
||||
decision: "reject",
|
||||
violations: [v],
|
||||
trace: [describeViolation(v), ...ladder.trace],
|
||||
ladder,
|
||||
...withRules,
|
||||
};
|
||||
}
|
||||
const tier = qualityTier(r, spec);
|
||||
const rank = softRank(spec, r);
|
||||
const reasons = accepted.map((f) => `unknown_${f}_accepted_by_policy`);
|
||||
const tiered = !!spec.soft?.tiers?.length;
|
||||
const priReasons = tiered ? [`pri:${ladder.pri}`, ...(ladder.levelName ? [`tier:${ladder.levelName}`] : [])] : [];
|
||||
const tiered = !!spec.soft?.tiers?.length || (chain?.pri ?? null) !== null;
|
||||
const ruleReasons = (chain?.groups ?? []).flatMap((g) =>
|
||||
g.groupId ? [`rule_group:${g.groupId}${g.revision !== undefined ? `@${g.revision}` : ""}`] : [],
|
||||
);
|
||||
const priReasons = [
|
||||
...(tiered ? [`pri:${ladder.pri}`, ...(ladder.levelName ? [`tier:${ladder.levelName}`] : [])] : []),
|
||||
...ruleReasons,
|
||||
];
|
||||
if (!current)
|
||||
return {
|
||||
decision: "accept",
|
||||
@@ -444,6 +511,7 @@ export function evaluateCandidate(
|
||||
pri: ladder.pri,
|
||||
ladder,
|
||||
trace: ["硬条件全部满足", ...ladder.trace],
|
||||
...withRules,
|
||||
};
|
||||
|
||||
const reject = (v: Violation): SlotEvaluation => ({
|
||||
@@ -451,6 +519,7 @@ export function evaluateCandidate(
|
||||
violations: [v],
|
||||
trace: [describeViolation(v), ...ladder.trace],
|
||||
ladder,
|
||||
...withRules,
|
||||
});
|
||||
const up = spec.upgrade;
|
||||
if (!up?.enabled) return reject({ field: "upgrade", code: "upgrade_disabled" });
|
||||
@@ -473,6 +542,7 @@ export function evaluateCandidate(
|
||||
fromPri,
|
||||
ladder,
|
||||
trace: [`阶梯 pri ${fromPri} → ${ladder.pri}`, ...ladder.trace],
|
||||
...withRules,
|
||||
};
|
||||
}
|
||||
if (!current.source)
|
||||
@@ -509,6 +579,7 @@ export function evaluateCandidate(
|
||||
pri: ladder.pri,
|
||||
ladder,
|
||||
trace: [bySource ? "来源等级提升" : "分辨率提升", ...ladder.trace],
|
||||
...withRules,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ export {
|
||||
currentPri,
|
||||
DEFAULT_SOURCE_TIERS,
|
||||
describeViolation,
|
||||
effectiveLadder,
|
||||
evaluateCandidate,
|
||||
improvableUnits,
|
||||
priorityLadder,
|
||||
@@ -25,6 +26,7 @@ export {
|
||||
type RankedEvaluation,
|
||||
RESOLUTION_RANK,
|
||||
rankForSlot,
|
||||
ruleChainOf,
|
||||
softRank,
|
||||
unitCountOf,
|
||||
validateKeywordPattern,
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
Resolution,
|
||||
VideoCodec,
|
||||
} from "../release-parser/types.ts";
|
||||
import type { CustomAtom, RuleChainResult, RuleGroupSpec } from "../rules/types.ts";
|
||||
|
||||
/** 可判定的硬条件字段 */
|
||||
export type HardField =
|
||||
@@ -115,10 +116,18 @@ export interface SlotUpgradePolicy {
|
||||
|
||||
export type SlotCompletionSpec = { kind: "movie"; cuts?: (Cut | "default")[] } | { kind: "units" };
|
||||
|
||||
/** 版本目标引用的规则组链(已按 scope 过滤、按引用顺序排列)与自定义条件 */
|
||||
export interface SlotRuleChain {
|
||||
groups: RuleGroupSpec[];
|
||||
atoms?: CustomAtom[];
|
||||
}
|
||||
|
||||
export interface SlotSpec {
|
||||
id?: string;
|
||||
name: string;
|
||||
hard: SlotHardConditions;
|
||||
/** 规则组:硬排除 → 拒绝;各组须命中某层(ladderRequired);pri 取最后一个带层的组(覆盖 soft.tiers 的 pri) */
|
||||
rules?: SlotRuleChain;
|
||||
unknownPolicy?: UnknownPolicy | Partial<Record<HardField, UnknownPolicy>>;
|
||||
soft?: SlotSoftPreferences;
|
||||
upgrade?: SlotUpgradePolicy;
|
||||
@@ -151,6 +160,10 @@ export interface EvaluationContext {
|
||||
downloadVolumeFactor?: number;
|
||||
/** 候选覆盖的内容单元数(每集体积折算);未给时按解析出的集范围/总集数推断 */
|
||||
unitCount?: number;
|
||||
/** 作品原语言(TMDB original_language),规则组 CNSUB/CNVOI 据此放行 */
|
||||
originalLanguage?: string;
|
||||
/** 发布后经过的分钟数(自定义条件 publishDelayMinutes) */
|
||||
publishedMinutesAgo?: number;
|
||||
}
|
||||
|
||||
/** 阶梯判定结果:pri 0–100 与逐层说明 */
|
||||
@@ -169,7 +182,7 @@ export interface QualityTier {
|
||||
}
|
||||
|
||||
export interface Violation {
|
||||
field: HardField | "upgrade" | "tier";
|
||||
field: HardField | "upgrade" | "tier" | "rule";
|
||||
code: string;
|
||||
expected?: unknown;
|
||||
actual?: unknown;
|
||||
@@ -184,6 +197,7 @@ export type SlotEvaluation =
|
||||
pri?: number;
|
||||
ladder?: LadderResult;
|
||||
trace?: string[];
|
||||
rules?: RuleChainResult;
|
||||
}
|
||||
| {
|
||||
decision: "upgrade";
|
||||
@@ -195,6 +209,7 @@ export type SlotEvaluation =
|
||||
fromPri?: number;
|
||||
ladder?: LadderResult;
|
||||
trace?: string[];
|
||||
rules?: RuleChainResult;
|
||||
}
|
||||
| { decision: "reject"; violations: Violation[]; trace?: string[]; ladder?: LadderResult }
|
||||
| { decision: "reject"; violations: Violation[]; trace?: string[]; ladder?: LadderResult; rules?: RuleChainResult }
|
||||
| { decision: "unknown_field"; missing: HardField[] | ["current_asset"]; trace?: string[] };
|
||||
Reference in new issue
Block a user