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:
rootandClaude Opus 5.5 committed 2026-09-28 14:50:43 +08:00
1 parent 22856d0447
commit e994a7bcfa
13 files changed
+23919 -10

No files matched your search

+1
View File
@@ -7,4 +7,5 @@ export * from "./identity/index.ts";
export * from "./organize/index.ts";
export * from "./path/index.ts";
export * from "./release-parser/index.ts";
export * from "./rules/index.ts";
export * from "./slot/index.ts";
+470
View File
@@ -0,0 +1,470 @@
import type { ParsedRelease } from "../release-parser/types.ts";
import type { CustomAtom, RuleContext } from "./types.ts";
// 内置关键字表:名称与 MP BUILTIN_RULE_SET(app/application/rules.py)一一对应。
// mp 字段原样记录上游的判定口径(正则/条件),供关键字说明与差分核对;
// test 是我们的判定:能用解析字段的用字段(分辨率/来源/HDR/编码/字幕),其余沿用 MP 正则。
// 音乐 13 条不支持(解析时报 unsupported_atom)。
export interface AtomInput {
r: ParsedRelease;
ctx: RuleContext;
/** 标题 + 副标题 + 标签(对照 MP `title description labels`) */
text: string;
}
export interface AtomHit {
hit: boolean;
/** 可读依据,如 "分辨率 1080p"、"原语言 zh 放行" */
why: string;
}
export interface BuiltinAtom {
id: string;
name: string;
/** 一句话语义 */
description: string;
/** 我们的判定依据:field = 解析字段;regex = 沿用 MP 正则;site = 站点事实 */
basis: "field" | "regex" | "site";
supported: boolean;
/** MP 上游定义(只读,说明用) */
mp: {
include?: string[];
exclude?: string[];
match?: string[];
tmdb?: Record<string, string>;
downloadvolumefactor?: number;
};
test?: (i: AtomInput) => AtomHit;
}
/** MP 正则是 Python 语法:去掉内联 (?i),统一按不区分大小写编译;非法返回 null */
export function compileMpPattern(p: string): RegExp | null {
try {
return new RegExp(p.replace(/\(\?i\)/g, ""), "i");
} catch {
return null;
}
}
const reCache = new Map<string, RegExp | null>();
function re(p: string): RegExp | null {
if (!reCache.has(p)) {
if (reCache.size > 500) reCache.clear();
reCache.set(p, compileMpPattern(p));
}
return reCache.get(p)!;
}
const anyRe = (ps: readonly string[], text: string) => ps.some((p) => re(p)?.test(text) ?? false);
function regexAtom(ps: string[], label: string) {
return ({ text }: AtomInput): AtomHit =>
anyRe(ps, text) ? { hit: true, why: `含「${label}」` } : { hit: false, why: `不含「${label}」` };
}
const zhLang = (lang: string | undefined, accept: readonly string[]) =>
!!lang && accept.includes(lang.toLowerCase().split("-")[0]!);
const MP_CNSUB =
"[中国國繁简](/|\\s|\\\\|\\|)?[繁简英粤]|[英简繁](/|\\s|\\\\|\\|)?[中繁简]" +
"|繁體|简体|[中国國][字配]|国语|國語|中文|中字|简日|繁日|简繁|繁体" +
"|([\\s,.-\\[])(chs|cht)(|[\\s,.-\\]])" +
"|(?<![a-z0-9])(?<!\\d\\s)(gb|big5)(?![a-z0-9])";
const MP_CNVOI = "[国國][语語]配音|[国國]配|[国國][语語]";
const res =
(...rs: string[]) =>
(i: AtomInput): AtomHit =>
i.r.resolution && rs.includes(i.r.resolution)
? { hit: true, why: `分辨率 ${i.r.resolution}` }
: { hit: false, why: `分辨率 ${i.r.resolution ?? "未知"}` };
const src =
(...ss: string[]) =>
(i: AtomInput): AtomHit =>
i.r.source && ss.includes(i.r.source)
? { hit: true, why: `来源 ${i.r.source}` }
: { hit: false, why: `来源 ${i.r.source ?? "未知"}` };
const codec =
(c: string) =>
(i: AtomInput): AtomHit =>
i.r.videoCodec === c ? { hit: true, why: `编码 ${c}` } : { hit: false, why: `编码 ${i.r.videoCodec ?? "未知"}` };
const hdrList = (i: AtomInput) => (i.r.hdr.length ? i.r.hdr.join("/") : "无");
const MUSIC: [string, string, string][] = [
[
"HIRES",
"Hi-Res 无损",
"(?i)\\b(?:Hi[ ._-]?Res(?:olution)?|DSD(?:64|128|256|512)?)\\b|高解析|(?:24|32)\\s*(?:-?bit|位)",
],
["LOSSLESS", "无损音频", "(?i)\\b(?:Lossless|FLAC|ALAC|APE|WAV|WAVE|AIFF?|PCM|DSD|DSF|DFF)\\b|无损"],
["FLAC", "FLAC", "(?i)(?<![A-Z0-9])FLAC(?![A-Z0-9])"],
["ALAC", "ALAC", "(?i)(?<![A-Z0-9])ALAC(?![A-Z0-9])"],
["APE", "APE", "(?i)(?<![A-Z0-9])APE(?![A-Z0-9])"],
["WAV", "WAV", "(?i)(?<![A-Z0-9])WAV(?:E)?(?![A-Z0-9])"],
["DSD", "DSD", "(?i)(?<![A-Z0-9])(?:DSD(?:64|128|256|512)?|DSF|DFF)(?![A-Z0-9])"],
["MP3", "MP3", "(?i)(?<![A-Z0-9])MP3(?![A-Z0-9])"],
["AAC", "AAC", "(?i)(?<![A-Z0-9])(?:AAC|M4A)(?![A-Z0-9])"],
["OPUS", "OPUS", "(?i)(?<![A-Z0-9])OPUS(?![A-Z0-9])"],
["BITRATE320", "320k", "(?i)(?<!\\d)320\\s*k(?:bps?|b(?:it)?/?s?)?(?![a-z])"],
["BITRATE256", "256k", "(?i)(?<!\\d)256\\s*k(?:bps?|b(?:it)?/?s?)?(?![a-z])"],
["BITRATE192", "192k", "(?i)(?<!\\d)192\\s*k(?:bps?|b(?:it)?/?s?)?(?![a-z])"],
];
export const BUILTIN_ATOMS: readonly BuiltinAtom[] = [
{
id: "BLU",
name: "蓝光原盘",
description: "来源为蓝光原盘(BDMV/ISO,非 Remux、非压制)",
basis: "field",
supported: true,
mp: {
include: [
"(?i)(\\bBlu-?Ray\\b.*\\b(?:VC-?1|AVC|MPEG-?2)\\b|\\b(?:UHD|4K|2160p)\\b(?:.*Blu-?Ray)?.*\\b(?:HEVC|H\\.?265)\\b|\\bBlu-?Ray\\b.*\\b(?:UHD|4K|2160p)\\b.*\\b(?:HEVC|H\\.?265)\\b|\\b(?:COMPLETE|FULL)\\b.*\\b(?:(?:UHD|4K|2160p)\\b.*)?Blu-?Ray\\b|\\b(BD25|BD50|BD66|BD100|BDMV|MiniBD)\\b)",
],
exclude: [
"(?i)(\\b[XH]\\.?264\\b|\\b[XH]\\.?265\\b|\\bWEB-?DL\\b|\\bWEB-?RIP\\b|\\bHDTV(?:RIP)?\\b|\\bREMUX\\b|\\bBDRip\\b|\\bBRRip\\b|\\bHDRip\\b|\\bENCODE\\b|\\b(?<!WEB-|HDTV)RIP\\b)",
],
},
test: src("BluRay-Disc"),
},
{
id: "4K",
name: "4K",
description: "分辨率 2160p",
basis: "field",
supported: true,
mp: { include: ["4k|2160p|x2160"] },
test: res("2160p"),
},
{
id: "1080P",
name: "1080P",
description: "分辨率 1080p / 1080i",
basis: "field",
supported: true,
mp: { include: ["1080[pi]|x1080"] },
test: res("1080p", "1080i"),
},
{
id: "720P",
name: "720P",
description: "分辨率 720p",
basis: "field",
supported: true,
mp: { include: ["720[pi]|x720"] },
test: res("720p"),
},
{
id: "CNSUB",
name: "中字",
description: "含中文字幕(含 CHS/CHT/GB/BIG5);作品原语言为中文时直接满足",
basis: "field",
supported: true,
mp: { include: [MP_CNSUB], tmdb: { original_language: "zh,cn" } },
test: (i) => {
if (zhLang(i.ctx.originalLanguage, ["zh", "cn"]))
return { hit: true, why: `原语言 ${i.ctx.originalLanguage} 放行` };
const zh = (i.r.subtitles ?? []).filter((s) => s.startsWith("zh"));
if (zh.length) return { hit: true, why: `字幕 ${zh.join("/")}` };
return { hit: false, why: i.r.subtitles === undefined ? "字幕未知" : `字幕 ${i.r.subtitles.join("/") || "无"}` };
},
},
{
id: "GZ",
name: "官种",
description: "站点标签含 官方/官种/官组(只看标签)",
basis: "site",
supported: true,
mp: { include: ["官方", "官种", "官组"], match: ["labels"] },
test: (i) => {
const labels = (i.ctx.labels ?? []).join(" ");
return anyRe(["官方", "官种", "官组"], labels)
? { hit: true, why: "官方标签" }
: { hit: false, why: labels ? `标签 ${labels}` : "无站点标签" };
},
},
{
id: "SPECSUB",
name: "特效字幕",
description: "标题/副标题/标签含「特效」",
basis: "regex",
supported: true,
mp: { include: ["特效"] },
test: regexAtom(["特效"], "特效"),
},
{
id: "BLURAY",
name: "蓝光",
description: "蓝光来源(原盘、蓝光 Remux、蓝光压制)",
basis: "field",
supported: true,
mp: { include: ["Blu-?Ray"] },
test: (i) => {
const s = i.r.source;
if (s === "BluRay" || s === "BluRay-Disc") return { hit: true, why: `来源 ${s}` };
if (s === "REMUX" && /blu-?ray/i.test(i.r.raw)) return { hit: true, why: "蓝光 REMUX" };
return { hit: false, why: `来源 ${s ?? "未知"}` };
},
},
{
id: "UHD",
name: "UHD",
description: "标题含 UHD / UltraHD",
basis: "field",
supported: true,
mp: { include: ["UHD|UltraHD"] },
test: (i) => (i.r.uhd || /UltraHD/i.test(i.text) ? { hit: true, why: "UHD" } : { hit: false, why: "无 UHD 标记" }),
},
{
id: "H265",
name: "H265",
description: "视频编码 H.265/HEVC",
basis: "field",
supported: true,
mp: { include: ["[Hx].?265|HEVC"] },
test: codec("H.265"),
},
{
id: "H264",
name: "H264",
description: "视频编码 H.264/AVC",
basis: "field",
supported: true,
mp: { include: ["[Hx].?264|AVC"] },
test: codec("H.264"),
},
{
id: "DOLBY",
name: "杜比视界",
description: "HDR 含杜比视界(DV/DoVi)",
basis: "field",
supported: true,
mp: { include: ["Dolby[\\s.]+Vision|DOVI|[\\s.]+DV[\\s.]+|杜比视界"] },
test: (i) =>
i.r.hdr.includes("DV") || /杜比视界/.test(i.text)
? { hit: true, why: "杜比视界" }
: { hit: false, why: `HDR ${hdrList(i)}` },
},
{
id: "ATMOS",
name: "杜比全景声",
description: "音轨含 Atmos(杜比全景声)",
basis: "field",
supported: true,
mp: { include: ["Dolby[\\s.+]+Atmos|Atmos|杜比全景[声聲]"] },
test: (i) =>
i.r.audio.some((a) => /atmos/i.test(a)) || anyRe(["Atmos|杜比全景[声聲]"], i.text)
? { hit: true, why: "Atmos" }
: { hit: false, why: "无 Atmos" },
},
{
id: "HDR",
name: "HDR",
description: "HDR10 / HDR10+ / HDR Vivid(不含只有杜比视界的)",
basis: "field",
supported: true,
mp: { include: ["[\\s.]+HDR[\\s.]+|HDR10|HDR10\\+|HDRVivid"] },
test: (i) =>
i.r.hdr.some((h) => h === "HDR10" || h === "HDR10+" || h === "HDR Vivid")
? { hit: true, why: `HDR ${hdrList(i)}` }
: { hit: false, why: `HDR ${hdrList(i)}` },
},
{
id: "SDR",
name: "SDR",
description: "标题明确标 SDR",
basis: "regex",
supported: true,
mp: { include: ["[\\s.]+SDR[\\s.]+"] },
test: regexAtom(["[\\s.]+SDR[\\s.]+"], "SDR"),
},
{
id: "REMUX",
name: "Remux",
description: "来源为 Remux",
basis: "field",
supported: true,
mp: { include: ["REMUX"] },
test: src("REMUX"),
},
{
id: "WEBDL",
name: "WEB-DL",
description: "来源为 WEB-DL 或 WEBRip",
basis: "field",
supported: true,
mp: { include: ["WEB-?DL|WEB-?RIP"] },
test: src("WEB-DL", "WEBRip"),
},
{
id: "FREE",
name: "免费",
description: "站点促销为免费(下载量不计)",
basis: "site",
supported: true,
mp: { downloadvolumefactor: 0 },
test: (i) =>
i.ctx.downloadVolumeFactor === 0
? { hit: true, why: "免费" }
: {
hit: false,
why: i.ctx.downloadVolumeFactor === undefined ? "促销未知" : `下载系数 ${i.ctx.downloadVolumeFactor}`,
},
},
{
id: "CNVOI",
name: "国语配音",
description: "含国语配音;作品原语言为中文时直接满足",
basis: "regex",
supported: true,
mp: { include: [MP_CNVOI], tmdb: { original_language: "zh" } },
test: (i) =>
zhLang(i.ctx.originalLanguage, ["zh"])
? { hit: true, why: `原语言 ${i.ctx.originalLanguage} 放行` }
: regexAtom([MP_CNVOI], "国语")(i),
},
{
id: "HKVOI",
name: "粤语配音",
description: "含粤语配音",
basis: "regex",
supported: true,
mp: { include: ["粤语配音|粤语"] },
test: regexAtom(["粤语配音|粤语"], "粤语"),
},
{
id: "60FPS",
name: "60帧",
description: "帧率 ≥ 60",
basis: "field",
supported: true,
mp: { include: ["60fps|60帧"] },
test: (i) =>
(i.r.fps ?? 0) >= 60 || anyRe(["60fps|60帧"], i.text)
? { hit: true, why: `帧率 ${i.r.fps ?? 60}` }
: { hit: false, why: `帧率 ${i.r.fps ?? "未知"}` },
},
{
id: "3D",
name: "3D",
description: "标题含 3D",
basis: "regex",
supported: true,
mp: { include: ["3D"] },
test: regexAtom(["3D"], "3D"),
},
...MUSIC.map(
([id, name, p]): BuiltinAtom => ({
id,
name,
description: "音乐资源条件,暂不支持",
basis: "regex",
supported: false,
mp: { include: [p] },
}),
),
];
export const BUILTIN_ATOM_MAP: ReadonlyMap<string, BuiltinAtom> = new Map(BUILTIN_ATOMS.map((a) => [a.id, a]));
/** 自定义条件 id:大写字母开头或数字+字母(对照 MP pyparsing 原子),规范化为大写 */
export const ATOM_ID_RE = /^(?:[A-Z][A-Z0-9]*|[0-9]+[A-Z][A-Z0-9]*)$/;
const MB = 1024 * 1024;
function unitCount(i: AtomInput): number {
const { r, ctx } = i;
if (ctx.unitCount !== undefined && ctx.unitCount > 0) return ctx.unitCount;
if (r.episodes) return Math.max(1, r.episodes.end - r.episodes.start + 1);
if (r.totalEpisodes && r.totalEpisodes > 0) return r.totalEpisodes;
return 1;
}
/** 自定义条件判定(对照 MP __match_rule:包含 → 排除 → 体积(每集)→ 做种 → 促销 → 发布时间) */
export function testCustomAtom(a: CustomAtom, i: AtomInput): AtomHit {
let text = i.text;
if (a.matchFields?.length) {
const parts: string[] = [];
if (a.matchFields.includes("title")) parts.push(i.r.raw);
if (a.matchFields.includes("subtitle") && i.ctx.subtitle) parts.push(i.ctx.subtitle);
if (a.matchFields.includes("labels")) parts.push(...(i.ctx.labels ?? []));
text = parts.join(" ");
}
if (a.include?.length && !anyRe(a.include, text)) return { hit: false, why: `不含 ${a.include.join(" / ")}` };
const ex = (a.exclude ?? []).find((p) => re(p)?.test(text));
if (ex) return { hit: false, why: `含排除词 ${ex}` };
if (a.unitSizeMB && (a.unitSizeMB.min !== undefined || a.unitSizeMB.max !== undefined)) {
if (i.r.sizeBytes === undefined) return { hit: false, why: "体积未知" };
const n = unitCount(i);
const per = i.r.sizeBytes / n;
const mb = Math.round(per / MB);
const tag = n > 1 ? `每集 ${mb}MB` : `${mb}MB`;
if (a.unitSizeMB.min !== undefined && per < a.unitSizeMB.min * MB)
return { hit: false, why: `${tag} 小于 ${a.unitSizeMB.min}MB` };
if (a.unitSizeMB.max !== undefined && per > a.unitSizeMB.max * MB)
return { hit: false, why: `${tag} 大于 ${a.unitSizeMB.max}MB` };
}
if (a.minSeeders !== undefined && a.minSeeders > 0) {
if (i.ctx.seeders === undefined) return { hit: false, why: "做种数未知" };
if (i.ctx.seeders < a.minSeeders) return { hit: false, why: `做种 ${i.ctx.seeders} 小于 ${a.minSeeders}` };
}
if (a.free && i.ctx.downloadVolumeFactor !== 0)
return { hit: false, why: i.ctx.downloadVolumeFactor === undefined ? "促销未知" : "非免费" };
const pd = a.publishDelayMinutes;
if (pd && (pd.min !== undefined || pd.max !== undefined)) {
const m = i.ctx.publishedMinutesAgo;
if (m === undefined) return { hit: false, why: "发布时间未知" };
if (pd.min !== undefined && m < pd.min) return { hit: false, why: `发布 ${Math.round(m)} 分钟,不足 ${pd.min}` };
if (pd.max !== undefined && m > pd.max) return { hit: false, why: `发布 ${Math.round(m)} 分钟,超过 ${pd.max}` };
}
return { hit: true, why: "满足" };
}
/** MP 自定义规则(导入格式)→ CustomAtom;size_range 形如 "a-b" / ">a" / "<b",publish_time 形如 "n" / "a-b" */
export function customAtomFromMp(
id: string,
mp: {
name?: string;
include?: string | string[];
exclude?: string | string[];
size_range?: string;
seeders?: string | number;
publish_time?: string;
downloadvolumefactor?: number;
match?: string[];
},
): CustomAtom {
const list = (v: string | string[] | undefined) => (v === undefined || v === "" ? [] : Array.isArray(v) ? v : [v]);
const out: CustomAtom = { id: id.toUpperCase() };
if (mp.name) out.name = mp.name;
const inc = list(mp.include);
const exc = list(mp.exclude);
if (inc.length) out.include = inc;
if (exc.length) out.exclude = exc;
const sr = mp.size_range?.trim();
if (sr) {
if (sr.includes("-")) {
const [a, b] = sr.split("-").map((x) => Number(x.trim()));
out.unitSizeMB = { min: a!, max: b! };
} else if (sr.startsWith(">")) out.unitSizeMB = { min: Number(sr.slice(1).trim()) };
else if (sr.startsWith("<")) out.unitSizeMB = { max: Number(sr.slice(1).trim()) };
}
if (mp.seeders !== undefined && mp.seeders !== "") out.minSeeders = Number(mp.seeders);
if (mp.publish_time) {
const ts = mp.publish_time.split("-").map((x) => Number(x.trim()));
out.publishDelayMinutes = ts.length === 1 ? { min: ts[0]! } : { min: ts[0]!, max: ts[1]! };
}
if (mp.downloadvolumefactor === 0) out.free = true;
const fields = (mp.match ?? []).flatMap((m) =>
m === "title"
? ["title" as const]
: m === "description"
? ["subtitle" as const]
: m === "labels"
? ["labels" as const]
: [],
);
if (fields.length) out.matchFields = fields;
return out;
}
+127
View File
@@ -0,0 +1,127 @@
import type { ParsedRelease } from "../release-parser/types.ts";
import { type AtomHit, type AtomInput, BUILTIN_ATOM_MAP, testCustomAtom } from "./atoms.ts";
import { serializeExpr } from "./expr.ts";
import type { CustomAtom, RuleChainResult, RuleContext, RuleExpr, RuleGroupResult, RuleGroupSpec } from "./types.ts";
// 规则组求值(对照 MP FilterModule.__get_order / __match_group / __match_rule):
// - 组内:硬排除任一命中 → 拒绝;层按序匹配,第 k 层(0 起)命中 pri = 100 - k;一层不中且 ladderRequired → 拒绝。
// - 多组串联取交集(任一组拒绝即拒绝);pri 取最后一个带层的组。
// - 未知原子视为不匹配(MP 语义),trace 里写明。
export type AtomCatalog = ReadonlyMap<string, CustomAtom>;
export function catalogOf(atoms: readonly CustomAtom[] = []): AtomCatalog {
return new Map(atoms.map((a) => [a.id.toUpperCase(), a]));
}
function inputOf(r: ParsedRelease, ctx: RuleContext): AtomInput {
return { r, ctx, text: [r.raw ?? "", ctx.subtitle ?? "", ...(ctx.labels ?? [])].join(" ") };
}
/** 单个原子判定 */
export function testAtom(id: string, input: AtomInput, catalog: AtomCatalog = new Map()): AtomHit {
const key = id.toUpperCase();
const b = BUILTIN_ATOM_MAP.get(key);
if (b) {
if (!b.supported || !b.test) return { hit: false, why: "不支持" };
return b.test(input);
}
const c = catalog.get(key);
if (c) return testCustomAtom(c, input);
return { hit: false, why: "条件不存在" };
}
/** 表达式求值(短路,同 MP);notes 收集已求值原子的依据(按字面:`!X✗` 表示取反后不满足),供 trace */
function evalExpr(e: RuleExpr, input: AtomInput, catalog: AtomCatalog, notes: string[], neg = false): boolean {
switch (e.op) {
case "atom": {
const h = testAtom(e.id, input, catalog);
const lit = neg ? !h.hit : h.hit;
notes.push(`${neg ? "!" : ""}${e.id.toUpperCase()}${lit ? "✓" : "✗"} ${h.why}`);
return h.hit;
}
case "not":
return !evalExpr(e.arg, input, catalog, notes, !neg);
case "and":
return e.args.every((a) => evalExpr(a, input, catalog, notes, neg));
case "or":
return e.args.some((a) => evalExpr(a, input, catalog, notes, neg));
}
}
/** 表达式是否命中(不要 trace 时用) */
export function matchesExpr(e: RuleExpr, r: ParsedRelease, ctx: RuleContext = {}, catalog?: AtomCatalog): boolean {
return evalExpr(e, inputOf(r, ctx), catalog ?? new Map(), []);
}
export function evaluateRuleGroup(
g: RuleGroupSpec,
r: ParsedRelease,
ctx: RuleContext = {},
catalog: AtomCatalog = new Map(),
): RuleGroupResult {
const input = inputOf(r, ctx);
const label = `规则组「${g.name}」${g.revision !== undefined ? `@r${g.revision}` : ""}`;
const trace: string[] = [];
const excludedBy: string[] = [];
for (const x of g.hardExcludes ?? []) {
const notes: string[] = [];
if (evalExpr(x, input, catalog, notes)) {
const s = serializeExpr(x);
excludedBy.push(s);
trace.push(`${label} 硬排除 ${s} 命中(${notes.join(";")})`);
}
}
const base = {
...(g.id !== undefined ? { groupId: g.id } : {}),
name: g.name,
...(g.revision !== undefined ? { revision: g.revision } : {}),
};
if (g.layers.length === 0) {
return { ...base, excludedBy, level: null, pri: null, passed: excludedBy.length === 0, trace };
}
let level: number | null = null;
for (let k = 0; k < g.layers.length; k++) {
const layer = g.layers[k]!;
const notes: string[] = [];
const hit = evalExpr(layer.expr, input, catalog, notes);
const name = layer.name ? `第${k + 1}层「${layer.name}」` : `第${k + 1}层`;
const expr = serializeExpr(layer.expr);
if (hit) {
trace.push(`${label} ${name} ${expr}:命中,pri=${100 - k}(${notes.join(";")})`);
level = k;
break;
}
trace.push(`${label} ${name} ${expr}:未命中(${notes.join(";")})`);
}
const required = g.ladderRequired !== false;
if (level === null && required) trace.push(`${label} 不命中任何一层,过滤`);
return {
...base,
excludedBy,
level,
pri: level === null ? 0 : 100 - level,
passed: excludedBy.length === 0 && (level !== null || !required),
trace,
};
}
/** 多组串联:任一组不通过即不通过;pri 取最后一个带层的组 */
export function evaluateRuleChain(
groups: readonly RuleGroupSpec[],
r: ParsedRelease,
ctx: RuleContext = {},
catalog: AtomCatalog = new Map(),
): RuleChainResult {
const results = groups.map((g) => evaluateRuleGroup(g, r, ctx, catalog));
let deciding: number | null = null;
results.forEach((x, i) => {
if (x.pri !== null) deciding = i;
});
const passed = results.every((x) => x.passed);
const pri = deciding === null ? null : results[deciding]!.pri;
const trace = results.flatMap((x) => x.trace);
if (deciding !== null && results.filter((x) => x.pri !== null).length > 1)
trace.push(`pri 取最后一个带层的规则组「${results[deciding]!.name}」:${pri}`);
return { passed, pri, decidingGroup: deciding, groups: results, trace };
}
+278
View File
@@ -0,0 +1,278 @@
import { BUILTIN_ATOM_MAP } from "./atoms.ts";
import type { RuleExpr, RuleLayer, RuleParseError } from "./types.ts";
// MP 规则串 ↔ 结构化层。语法对齐 MP RuleParser(pyparsing infix_notation):
// 规则串 = 层 ('>' 层)*;层内优先级 ! > & > |,可加括号;原子 = 字母开头的字母数字串,或数字+字母(4K、60FPS)。
// 偏离:MP 对未知原子视为不匹配、对多余 token 静默截断;我们在解析时报错并给位置(allowUnknown 时保留未知原子)。
export interface RuleParseOptions {
/** 自定义条件 id(大写);不在内置表也不在这里的原子报 unknown_atom */
customAtoms?: Iterable<string>;
/** 保留未知原子(求值时视为不匹配,对齐 MP);缺省 false 报错 */
allowUnknown?: boolean;
}
export interface RuleParseResult {
layers: RuleLayer[];
errors: RuleParseError[];
}
type Tok = { t: "atom" | "(" | ")" | "!" | "&" | "|" | "bad"; v: string; pos: number };
function lex(s: string, base: number): Tok[] {
const out: Tok[] = [];
let i = 0;
while (i < s.length) {
const ch = s[i]!;
if (/\s/.test(ch)) {
i++;
continue;
}
if ("()!&|".includes(ch)) {
out.push({ t: ch as Tok["t"], v: ch, pos: base + i });
i++;
continue;
}
const m = /^[A-Za-z0-9]+/.exec(s.slice(i));
if (m) {
out.push({ t: "atom", v: m[0], pos: base + i });
i += m[0].length;
continue;
}
out.push({ t: "bad", v: ch, pos: base + i });
i++;
}
return out;
}
function levenshtein(a: string, b: string): number {
const dp = Array.from({ length: b.length + 1 }, (_, j) => j);
for (let i = 1; i <= a.length; i++) {
let prev = dp[0]!;
dp[0] = i;
for (let j = 1; j <= b.length; j++) {
const tmp = dp[j]!;
dp[j] = Math.min(dp[j]! + 1, dp[j - 1]! + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1));
prev = tmp;
}
}
return dp[b.length]!;
}
/** 相近原子名(给 unknown_atom 提示) */
export function suggestAtoms(id: string, custom: Iterable<string> = []): string[] {
const all = [...[...BUILTIN_ATOM_MAP.values()].filter((a) => a.supported).map((a) => a.id), ...custom];
return all
.map((x) => ({ x, d: levenshtein(id, x) }))
.filter((e) => e.d <= Math.max(1, Math.floor(id.length / 2)) || e.x.startsWith(id) || id.startsWith(e.x))
.sort((a, b) => a.d - b.d)
.slice(0, 3)
.map((e) => e.x);
}
const ATOM_SHAPE = /^(?:[A-Za-z][A-Za-z0-9]*|[0-9]+[A-Za-z][A-Za-z0-9]*)$/;
class LayerParser {
i = 0;
constructor(
readonly toks: Tok[],
readonly layer: number,
readonly errors: RuleParseError[],
readonly custom: Set<string>,
readonly opts: RuleParseOptions,
) {}
peek() {
return this.toks[this.i];
}
err(code: RuleParseError["code"], message: string, pos: number, extra: Partial<RuleParseError> = {}) {
this.errors.push({ code, message, position: pos, layer: this.layer, ...extra });
}
or(): RuleExpr | null {
const first = this.and();
if (!first) return null;
const args = [first];
while (this.peek()?.t === "|") {
this.i++;
const n = this.and();
if (!n) return null;
args.push(n);
}
return args.length === 1 ? first : flatten({ op: "or", args });
}
and(): RuleExpr | null {
const first = this.unary();
if (!first) return null;
const args = [first];
while (this.peek()?.t === "&") {
this.i++;
const n = this.unary();
if (!n) return null;
args.push(n);
}
return args.length === 1 ? first : flatten({ op: "and", args });
}
unary(): RuleExpr | null {
const t = this.peek();
if (t?.t === "!") {
this.i++;
const a = this.unary();
return a ? { op: "not", arg: a } : null;
}
return this.primary();
}
primary(): RuleExpr | null {
const t = this.peek();
if (!t) {
const last = this.toks[this.toks.length - 1];
this.err("unexpected_token", "表达式不完整,缺少条件", last ? last.pos + last.v.length : 0);
return null;
}
if (t.t === "(") {
this.i++;
const e = this.or();
if (!e) return null;
if (this.peek()?.t !== ")") {
this.err("unbalanced_paren", "括号不配对:缺少 )", t.pos);
return null;
}
this.i++;
return e;
}
if (t.t === "atom") {
this.i++;
if (!ATOM_SHAPE.test(t.v)) {
this.err("unexpected_token", `「${t.v}」不是合法条件名(字母开头,或数字加字母如 4K)`, t.pos);
return null;
}
const id = t.v.toUpperCase();
const b = BUILTIN_ATOM_MAP.get(id);
if (b && !b.supported)
this.err("unsupported_atom", `${id}(${b.name})不支持:音乐资源条件没有迁移`, t.pos, { atom: id });
else if (!b && !this.custom.has(id) && !this.opts.allowUnknown) {
const suggestions = suggestAtoms(id, this.custom);
this.err(
"unknown_atom",
`未知条件 ${id}${suggestions.length ? `,是不是 ${suggestions.join(" / ")}` : ""}`,
t.pos,
{ atom: id, suggestions },
);
}
return { op: "atom", id };
}
if (t.t === ")") this.err("unbalanced_paren", "括号不配对:多余的 )", t.pos);
else if (t.t === "bad") this.err("invalid_char", `不认识的字符「${t.v}」`, t.pos);
else this.err("unexpected_token", `「${t.v}」前缺少条件`, t.pos);
return null;
}
}
function flatten(e: RuleExpr): RuleExpr {
if (e.op !== "and" && e.op !== "or") return e;
const args: RuleExpr[] = [];
for (const a of e.args) {
const f = flatten(a);
if (f.op === e.op) args.push(...f.args);
else args.push(f);
}
return { op: e.op, args };
}
/** 规范化:同类 AND/OR 展平、原子大写(结构等价比较与指纹用) */
export function normalizeExpr(e: RuleExpr): RuleExpr {
switch (e.op) {
case "atom":
return { op: "atom", id: e.id.toUpperCase() };
case "not":
return { op: "not", arg: normalizeExpr(e.arg) };
default:
return flatten({ op: e.op, args: e.args.map(normalizeExpr) });
}
}
/** 解析一层表达式(不含 `>`);offset 用于报错位置 */
export function parseExpr(
s: string,
opts: RuleParseOptions = {},
layer = 0,
offset = 0,
): { expr: RuleExpr | null; errors: RuleParseError[] } {
const errors: RuleParseError[] = [];
const toks = lex(s, offset);
if (toks.length === 0) {
errors.push({ code: "empty_layer", message: `第 ${layer + 1} 层是空的`, position: offset, layer });
return { expr: null, errors };
}
const p = new LayerParser(
toks,
layer,
errors,
new Set([...(opts.customAtoms ?? [])].map((x) => x.toUpperCase())),
opts,
);
const expr = p.or();
if (expr && p.i < toks.length && errors.length === 0) {
const t = toks[p.i]!;
if (t.t === ")") p.err("unbalanced_paren", "括号不配对:多余的 )", t.pos);
else if (t.t === "bad") p.err("invalid_char", `不认识的字符「${t.v}」`, t.pos);
else p.err("unexpected_token", `「${t.v}」前缺少 & 或 |`, t.pos);
}
return { expr: errors.length ? null : expr, errors };
}
/** 规则串 → 结构化层(`>` 分层,第 1 层 pri=100) */
export function parseRuleString(s: string, opts: RuleParseOptions = {}): RuleParseResult {
const errors: RuleParseError[] = [];
const layers: RuleLayer[] = [];
if (!s.trim()) return { layers, errors: [{ code: "empty", message: "规则串是空的", position: 0, layer: 0 }] };
let offset = 0;
s.split(">").forEach((part, k) => {
const r = parseExpr(part, opts, k, offset);
errors.push(...r.errors);
if (r.expr) layers.push({ expr: r.expr });
offset += part.length + 1;
});
return { layers: errors.length ? [] : layers, errors };
}
const PREC = { or: 1, and: 2, not: 3, atom: 4 } as const;
/** 表达式 → 规则串(原子大写,只加必要的括号) */
export function serializeExpr(e: RuleExpr, parent = 0): string {
switch (e.op) {
case "atom":
return e.id.toUpperCase();
case "not":
return `!${serializeExpr(e.arg, PREC.not)}`;
case "and": {
const s = e.args.map((a) => serializeExpr(a, PREC.and)).join(" & ");
return parent > PREC.and ? `(${s})` : s;
}
case "or": {
const s = e.args.map((a) => serializeExpr(a, PREC.or)).join(" | ");
return parent > PREC.or ? `(${s})` : s;
}
}
}
/** 结构化层 → 规则串 */
export function serializeLayers(layers: readonly RuleLayer[]): string {
return layers.map((l) => serializeExpr(l.expr)).join(" > ");
}
/** 表达式引用到的原子 id(去重) */
export function atomsOf(e: RuleExpr, out = new Set<string>()): Set<string> {
if (e.op === "atom") out.add(e.id.toUpperCase());
else if (e.op === "not") atomsOf(e.arg, out);
else for (const a of e.args) atomsOf(a, out);
return out;
}
/** MP pyparsing as_list() 形状 → RuleExpr(差分测试用):"A" | ["not", X] | [X, "and", Y, "and", Z] */
export function fromMpParseList(v: unknown): RuleExpr {
if (typeof v === "string") return { op: "atom", id: v.toUpperCase() };
const a = v as unknown[];
if (a.length === 1) return fromMpParseList(a[0]);
if (a[0] === "not") return { op: "not", arg: fromMpParseList(a.length === 2 ? a[1] : a.slice(1)) };
const op = a[1] as "and" | "or";
return flatten({ op, args: a.filter((_, i) => i % 2 === 0).map(fromMpParseList) });
}
+32
View File
@@ -0,0 +1,32 @@
export {
ATOM_ID_RE,
type AtomHit,
type AtomInput,
BUILTIN_ATOM_MAP,
BUILTIN_ATOMS,
type BuiltinAtom,
compileMpPattern,
customAtomFromMp,
testCustomAtom,
} from "./atoms.ts";
export {
type AtomCatalog,
catalogOf,
evaluateRuleChain,
evaluateRuleGroup,
matchesExpr,
testAtom,
} from "./evaluate.ts";
export {
atomsOf,
fromMpParseList,
normalizeExpr,
parseExpr,
parseRuleString,
type RuleParseOptions,
type RuleParseResult,
serializeExpr,
serializeLayers,
suggestAtoms,
} from "./expr.ts";
export * from "./types.ts";
+100
View File
@@ -0,0 +1,100 @@
import type { EvaluationContext } from "../slot/types.ts";
// 规则组(对照 MP 规则组 `name + rule_string`):结构化层 + 硬排除。规则串只是它的一种文本表示。
/** 条件表达式:内置关键字 / 自定义条件的 AND / OR / NOT 树 */
export type RuleExpr =
| { op: "atom"; id: string }
| { op: "not"; arg: RuleExpr }
| { op: "and"; args: RuleExpr[] }
| { op: "or"; args: RuleExpr[] };
/** 优先级阶梯的一层;第 k 层(0 起)命中 → pri = 100 - k */
export interface RuleLayer {
name?: string;
expr: RuleExpr;
}
export type RuleGroupKind = "acquisition" | "upgrade" | "search";
/** 参与求值的规则组(server 从 rule_group 行编译) */
export interface RuleGroupSpec {
id?: string;
name: string;
revision?: number;
layers: RuleLayer[];
/** 任一表达式命中即拒绝 */
hardExcludes?: RuleExpr[];
/** 一层都不命中即拒绝(MP 语义);缺省 true。软偏好组为 false:不中只是 pri 低 */
ladderRequired?: boolean;
}
/** 自定义条件(对照 MP 自定义规则 7 字段;同 id 不允许覆盖内置) */
export interface CustomAtom {
id: string;
name?: string;
/** 正则(不区分大小写),至少命中一条 */
include?: string[];
/** 正则,一条都不能命中 */
exclude?: string[];
/** 匹配范围,缺省 标题+副标题+标签 */
matchFields?: ("title" | "subtitle" | "labels")[];
/** 体积区间(MB,1 MB = 1024²),剧集按每集折算 */
unitSizeMB?: { min?: number; max?: number };
minSeeders?: number;
/** 发布后经过的分钟数区间(只给 min = 至少发布这么久) */
publishDelayMinutes?: { min?: number; max?: number };
/** 只要免费 */
free?: boolean;
}
/** 规则求值上下文:站点事实 + 作品事实(tmdb 放行条件) */
export interface RuleContext extends EvaluationContext {
/** 作品原语言(TMDB original_language),CNSUB/CNVOI 据此放行 */
originalLanguage?: string;
/** 发布后经过的分钟数 */
publishedMinutesAgo?: number;
}
export interface RuleParseError {
code:
| "empty"
| "empty_layer"
| "unknown_atom"
| "unsupported_atom"
| "unbalanced_paren"
| "unexpected_token"
| "invalid_char";
message: string;
/** 在整个规则串里的字符偏移(0 起) */
position: number;
/** 第几层(0 起) */
layer: number;
atom?: string;
suggestions?: string[];
}
export interface RuleGroupResult {
groupId?: string;
name: string;
revision?: number;
/** 命中的硬排除表达式(规则串形式) */
excludedBy: string[];
/** 命中的层(0 起);null = 未命中或没有层 */
level: number | null;
/** 本组 pri;没有层时为 null(不参与"pri 取最后一组") */
pri: number | null;
/** 本组放行(未被硬排除,且有层时命中某层或 ladderRequired=false) */
passed: boolean;
trace: string[];
}
export interface RuleChainResult {
passed: boolean;
/** 最后一个带层的组的 pri;整条链都没有层时为 null */
pri: number | null;
/** 决定 pri 的组(下标) */
decidingGroup: number | null;
groups: RuleGroupResult[];
trace: string[];
}
+79 -8
View File
@@ -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,
};
}
+2
View File
@@ -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,
+17 -2
View File
@@ -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[] };