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

+174
View File
@@ -0,0 +1,174 @@
#!/usr/bin/env python3
"""规则组(W9-D)差分期望:MP 规则串解析 + 多规则组串联过滤的 pri 与 trace。
用法(必须用 MP 自己的 venv):
MP_ROOT=/opt/moviepilot-plus/upstream/MoviePilot \
$MP_ROOT/.venv/bin/python scripts/mp-sync/gen-rules-differential.py
输出 packages/domain/test/differential/rules-cases.json:
- parse:每个规则串按 `>` 切层后 RuleParser.parse(layer).as_list()(pyparsing,Python 参考实现)
- cases:FilterModule.filter_torrents(Python 回退路径)对每个 (规则组链 × 种子) 的 pri_order;
trace 为 Python 路径的 debug 日志(逐条规则失败原因),rustTrace 为 filter_torrents_with_trace_fast 的调试输出
- deviations 不由本脚本生成,重跑时保留已有登记。
只读调用上游,不复制上游源码。
"""
from __future__ import annotations
import importlib.util
import json
import os
import sys
from pathlib import Path
from types import SimpleNamespace
from typing import Any, Optional
from unittest.mock import patch
HERE = Path(__file__).resolve().parent
spec = importlib.util.spec_from_file_location("gen_differential", HERE / "gen-differential.py")
gd = importlib.util.module_from_spec(spec) # type: ignore[arg-type]
assert spec and spec.loader
spec.loader.exec_module(gd) # type: ignore[union-attr]
OUT = gd.DOMAIN_DIFF / "rules-cases.json"
GiB = 1024 ** 3
EXTRA_TORRENTS: list[dict[str, Any]] = [
{"id": "t29", "title": "Infernal.Affairs.2002.1080p.BluRay.x264.DTS-HK-GRP", "description": "无间道 粤语 中字", "size": 12 * GiB, "seeders": 6},
{"id": "t30", "title": "Movie.2019.1080p.WEB-DL.SDR.H264-GRP", "description": "", "size": 5 * GiB, "seeders": 5},
{"id": "t31", "title": "Show.S01E05.2160p.WEB-DL.HDR10+.H265-GRP", "description": "官种 特效中字", "labels": ["官种"], "size": 4 * GiB, "seeders": 30, "downloadvolumefactor": 0.5},
{"id": "t32", "title": "Movie.2018.1080p.BluRay.Remux.AVC.Atmos-GRP", "description": "国配 杜比全景声", "size": 28 * GiB, "seeders": 2},
]
TORRENTS = gd.TORRENTS + EXTRA_TORRENTS
# groups:规则组链(按顺序串联取交集,pri 取最后一组);单组即单条规则串
RULES: list[dict[str, Any]] = [
*({"id": r["id"], "groups": [r["rule"]], **({"mediainfo": r["mediainfo"]} if r.get("mediainfo") else {})} for r in gd.RULES),
{"id": "X01", "groups": ["SPECSUB & CNSUB > CNSUB & 4K > 1080P & !BLU"]},
{"id": "X02", "groups": ["HKVOI"]},
{"id": "X03", "groups": ["SDR"]},
{"id": "X04", "groups": ["HDR & !DOLBY"]},
{"id": "X05", "groups": ["DOLBY | HDR"]},
{"id": "X06", "groups": ["H264 > H265"]},
{"id": "X07", "groups": ["!(BLU | REMUX) & 4K"]},
{"id": "X08", "groups": ["BLURAY"]},
{"id": "X09", "groups": ["FREE | SEED5"]},
{"id": "X10", "groups": ["UHD & REMUX > 4K"]},
{"id": "X11", "groups": ["CNVOI | HKVOI > !CNVOI"]},
{"id": "X12", "groups": ["!!4K"]},
{"id": "X13", "groups": ["4K & (CNSUB | SPECSUB) & !3D"]},
{"id": "X14", "groups": ["ATMOS & (REMUX | BLU)"]},
{"id": "X15", "groups": ["GZ > FREE > 4K"]},
{"id": "X16", "groups": ["CNVOI"], "mediainfo": {"original_language": "zh"}},
{"id": "X17", "groups": ["SIZESMALL > SIZEBIG"]},
{"id": "X18", "groups": ["1080P & CNSUB | 4K & !CNSUB"]},
{"id": "X19", "groups": ["60FPS | 3D > ATMOS"]},
{"id": "M01", "groups": ["4K | 1080P", "CNSUB > !CNSUB"]},
{"id": "M02", "groups": ["!BLU", "4K > 1080P > 720P"]},
{"id": "M03", "groups": ["4K > 1080P", "WEBDL"]},
{"id": "M04", "groups": ["CNSUB", "SIZEMOVIE", "H265 > H264"]},
{"id": "M05", "groups": ["REMUX > BLU > WEBDL", "!DOLBY"]},
]
def main() -> int:
root = gd._filter_mp_root(None)
if str(root) not in sys.path:
sys.path.insert(0, str(root))
old = os.getcwd()
os.chdir(root)
try:
from app.adapters.system import rust as rust_accel
from app.application.rules import BUILTIN_RULE_SET, RuleParser
from app.domain.context import TorrentInfo
from app.modules import filter as filter_mod
from app.modules.filter import FilterModule
finally:
os.chdir(old)
rule_set = {**BUILTIN_RULE_SET, **gd.CUSTOM_RULES}
class _RuleHelper:
def __init__(self, groups):
self._groups = groups
def get_rule_group_by_media(self, media=None, group_names=None): # noqa: ARG002
return self._groups
def mk(t: dict) -> Any:
return TorrentInfo(
site_name="S", title=t["title"], description=t.get("description") or "",
size=t["size"], seeders=t.get("seeders") or 0, labels=t.get("labels") or [],
downloadvolumefactor=t.get("downloadvolumefactor", 1.0),
)
parser = RuleParser()
parse_out: dict[str, Any] = {}
for r in RULES:
for g in r["groups"]:
if g in parse_out:
continue
with patch("app.application.rules._filter_rule_parser", None):
parse_out[g] = [parser.expr.parse_string(layer.strip()).as_list()[0] for layer in g.split(">")]
def run(r: dict, t: dict) -> tuple[Optional[int], list[str], list[str]]:
module = FilterModule()
groups = [SimpleNamespace(name=f"{r['id']}#{i}", rule_string=g) for i, g in enumerate(r["groups"])]
module.rulehelper = _RuleHelper(groups)
module.rule_set = rule_set
mi = SimpleNamespace(**r["mediainfo"]) if r.get("mediainfo") else None
logs: list[str] = []
with patch("app.modules.filter.rust_accel.is_enabled", return_value=False), \
patch.object(filter_mod.logger, "debug", side_effect=lambda m, *a, **k: logs.append(str(m))):
res = module.filter_torrents(rule_groups=[g.name for g in groups], torrent_list=[mk(t)], mediainfo=mi)
pri = res[0].pri_order if res else None
rust_trace: list[str] = []
native = getattr(rust_accel, "_moviepilot_rust", None)
if rust_accel.is_enabled() and native is not None and hasattr(native, "filter_torrents_with_trace_fast"):
try:
_m, rust_trace = native.filter_torrents_with_trace_fast(
[vars(g) for g in groups], [mk(t)], rule_set, mi, filter_mod._rust_parse_options(),
)
except BaseException: # noqa: BLE001
rust_trace = []
return pri, logs, [str(x) for x in rust_trace]
cases = []
for r in RULES:
for t in TORRENTS:
pri, logs, rtrace = run(r, t)
# 去掉每行重复的 "种子 S - <标题> " 前缀;Rust trace 与 Python 相同时不重复存
prefix = f"种子 S - {t['title']} "
strip = lambda xs: [x[len(prefix):].strip() if x.startswith(prefix) else x for x in xs] # noqa: E731
mp: dict[str, Any] = {"pri": pri, "trace": strip(logs)}
if rtrace and rtrace != logs:
mp["rustTrace"] = strip(rtrace)
cases.append({"caseId": f"{r['id']}:{t['id']}", "ruleId": r["id"], "torrentId": t["id"], "mp": mp})
deviations: dict = {}
if OUT.exists():
try:
deviations = json.loads(OUT.read_text("utf-8")).get("deviations", {})
except Exception: # noqa: BLE001
deviations = {}
doc = {
"generatedFrom": {
"repo": "MoviePilot", "commit": gd._filter_mp_commit(root),
"engine": "python-fallback (FilterModule, rust_accel disabled); parse = pyparsing RuleParser",
"generator": "scripts/mp-sync/gen-rules-differential.py",
"semantics": "groups 串联取交集,pri = 最后一组命中层的 pri_order(100 起逐层 -1);null = 被过滤",
},
"customRules": gd.CUSTOM_RULES,
"torrents": TORRENTS,
"rules": RULES,
"parse": parse_out,
"cases": cases,
"deviations": deviations,
}
OUT.write_text(json.dumps(doc, ensure_ascii=False, indent=1) + "\n", "utf-8")
print(f"rules: rules={len(RULES)} cases={len(cases)} parse={len(parse_out)}")
return 0
if __name__ == "__main__":
sys.exit(main())