Files
UHD_Media_Tool/docs/runbook/rules.md
T

94 lines
8.3 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 规则运行手册(W9-D)
实现:`packages/domain/src/rules/`(规则串解析/序列化、内置关键字、规则组链求值)、`packages/domain/src/slot/evaluate.ts`(接入版本目标评估)、
`apps/server/src/modules/acquisition-organize/{routes/rule-groups.ts, routes/rules.ts, services/rules.ts, services/acquisition.ts}`、迁移 `0033_rule_groups`。
设计:`docs/design/subscriptions.md` §6;MP 对照:`docs/porting/mp-rust-logic-parity.md` §6、`docs/porting/moviepilot-subscribe.md` §6。
## 模型
**规则组**(`rule_group`)= 名称 + 类型 + 适用范围 + 结构化层 + 硬排除。规则串(`SPECSUB & CNSUB > 4K`)只是层的文本表示,存库的是结构化表达式,`rule_string` 是规范化后的缓存。
| 字段 | 含义 |
| --- | --- |
| `kind` | `acquisition` 订阅 / `upgrade` 洗版 / `search` 搜索 |
| `scope` | `mediaTypes[]`(movie/series/variety)、`entityIds[]`、`siteIds[]`、`subscriptionId`(订阅私有组);空 = 全部 |
| `layers[]` | 每层 `{name?, expr}`,`expr` 是 `{op:atom,id}` / `{op:not,arg}` / `{op:and\|or,args[]}` 树;第 k 层(0 起)命中 pri = 100 − k |
| `hardExcludes[]` | 表达式列表,任一命中即拒绝 |
| `ladderRequired` | 一层都不中即拒绝(MP 语义,缺省 true);false = 软偏好,不中只是 pri=0 |
| `source` | `manual` 手动 / `chat` 对话中用户明确说的 / `agent` Agent 推断 |
| `enabled`、`revision`、`fingerprint`、`suppressedAt`、`deletedAt` | 每次修改 revision+1 并写审计 `rule_group.*`;指纹 = hash(kind+scope+规范化层+硬排除+ladderRequired) |
| `defaultOrder` | 非 null = 属于该类默认链 |
| `legacyKind` | 由偏好标签迁来或经旧 `/preference-tags` 写入 |
**自定义条件**(`rule_atom`):对齐 MP 自定义规则 7 字段 —— `key`(大写)、`include[]`/`exclude[]`(正则,MP 语法,`(?i)` 会去掉,统一不区分大小写;过防灾难回溯白名单)、`matchFields`、`unitSizeMb {min,max}`(剧集按每集)、`minSeeders`、`publishDelayMinutes {min,max}`、`free`。**不允许与内置关键字同名**(偏离 MP 的"同 id 覆盖",避免静默改义)。
## 求值语义(对齐 MP `FilterModule`)
1. 组内:硬排除任一命中 → 拒绝(`rule.hard_exclude`);逐层匹配,首个命中层给 pri;有层且 `ladderRequired` 而一层不中 → 拒绝(`rule.no_layer_matched`)。
2. 多组串联取交集:任一组拒绝即拒绝;**pri 取链上最后一个带层的组**(没有层的组只起过滤作用)。
3. 层内 `!` > `&` > `|`,可加括号;表达式短路求值。未知条件视为不匹配(只可能出现在迁移前的旧数据里,新写入会被拒)。
4. 版本目标自身的 `hardConditions` / `softPreferences.tiers` 保留,视为链首的匿名组;规则组有层时 pri 以规则组为准,升级比较口径自动切到 pri(对照 MP 洗版 `pri_order > current_priority`)。
**引用链**(`services/rules.ts resolveRuleChain`):订阅私有组 → 版本目标 `ruleGroupIds`(非空时)|| 订阅 `params.ruleGroupIds` || 订阅默认链 → 版本目标开了升级时再叠加洗版默认链;逐个按 scope 过滤,跳过停用/已删。搜索用 `kind: "search"` 取搜索默认链(给 `release.search` / 手动搜索接入)。
候选评估记录(artifact `candidate_evaluation`)里:`pri` 为最终 pri;`reasons` 带 `rule_group:<id>@<revision>`;`trace` 逐组逐层写明命中/未命中及每个原子的依据,如
`规则组「片源」@r1 第1层 4K:未命中(4K✗ 分辨率 1080p)`、`规则组「片源」@r1 硬排除 REMUX 命中(REMUX✓ 来源 REMUX)`。
## 内置关键字
`GET /rules/keywords` 返回全表(含 MP 上游正则/条件原文与我们的判定依据 `basis`)。
| 关键字 | 我们的判定 | basis |
| --- | --- | --- |
| 4K / 1080P / 720P | 解析出的分辨率(1080P 含 1080i) | field |
| BLU / REMUX / WEBDL | 解析出的来源(原盘 / Remux / WEB-DL+WEBRip) | field |
| BLURAY | 来源属蓝光族(原盘、压制、标题带 Blu-ray 的 Remux) | field |
| UHD | 标题 UHD / UltraHD | field |
| H265 / H264 | 视频编码 | field |
| HDR / DOLBY | HDR10/HDR10+/HDR Vivid;杜比视界 | field |
| ATMOS / 60FPS | 音轨 Atmos;帧率 ≥ 60(均兼容 MP 正则) | field |
| CNSUB | 字幕含中文;作品原语言 zh/cn 直接满足(MP tmdb 条件) | field |
| CNVOI / HKVOI / SPECSUB / SDR / 3D | MP 正则;CNVOI 原语言 zh 直接满足 | regex |
| GZ / FREE | 站点标签 官方/官种/官组;下载系数 = 0 | site |
| 音乐 13 条(HIRES、FLAC、AAC…) | 不支持,规则串里出现报 `unsupported_atom` | — |
## 端点
| 端点 | 说明 |
| --- | --- |
| `GET /rules/keywords` | 内置 + 自定义条件 |
| `POST /rules/parse {ruleString}` | → `{layers, ruleString(规范化,有错为 null), errors[{code, message, position, layer, atom?, suggestions?}]}`;code:`empty` `empty_layer` `unknown_atom` `unsupported_atom` `unbalanced_paren` `unexpected_token` `invalid_char` |
| `POST /rules/serialize {layers}` | → `{ruleString}`(原子大写,只加必要括号) |
| `GET / PUT /rules/defaults` | `{acquisition[], upgrade[], search[]}`,按数组顺序串联;类型不符 400 |
| `GET/POST /rules`、`GET/PATCH/DELETE /rules/{id}` | 列表筛选 `kind source enabled includeDeleted q`;写入可给 `ruleString`(优先)或 `layers`,`hardExcludes` 元素可以是表达式或单层规则串;PATCH 可带 `expectedRevision`(不匹配 409);同名 409;条件无效 422;DELETE 被版本目标/订阅引用 409 |
| `GET /rules/{id}/revisions` | 审计回放(含迁移前的 `preference_tag.*`) |
| `POST /rules/{id}/preview` | `{limit?, sourceReleaseIds?, titles?[{title, subtitle?}], draft?}`;缺省最近 50 条已识别资源;`draft` 试算未保存的层/硬排除 → `{sampleSize, passed, rejected, priHistogram[{pri,count}], items[{title, passed, pri, level, excludedBy, reasons, trace}]}` |
| `GET/POST /rules/atoms`、`GET/PATCH/DELETE /rules/atoms/{id}` | 自定义条件;与内置同名 409;被规则组引用不能删 409 |
| `/rules/policies*` | 收录策略版本(草案→预览→发布→回滚),同 `/policy-revisions` |
| `/rules/suppressions*` | 抑制记录,同 `/preference-suppressions`;行多了 `ruleGroupId` |
| 版本目标 `ruleGroupIds` | `POST/PATCH /acquisition-slots` 可写,引用不存在的组 400;`SlotTemplate.ruleGroupIds` 建目标时写入 |
| 订阅 `params.ruleGroupIds` | `PATCH /subscriptions/{id}` 的 params 键 |
## 抑制记录(偏好标签语义保留)
- 停用(`enabled: false`)或删除规则组 → 写抑制记录(指纹 = 停用前的指纹),`suppressedAt` 置位。
- 对话/Agent 来源的组被用户改掉语义 → 旧指纹也写抑制("edited");手动组的编辑不写。
- `POST /rules` `source=agent` 命中有效抑制 → **409**;`manual`/`chat` 新建同指纹 → 解除抑制。非手动来源的同指纹重复新建 409。
## 旧端点(弃用,保留到 v0.3)
| 旧 | 新 | 备注 |
| --- | --- | --- |
| `/preference-tags*` | `/rules*` | 别名直接读写 `rule_group`(`legacy_kind` 非空的行);`rule.ruleString` 会编译:hard → 硬排除,soft → 单层软偏好;其余 rule 键只作记录、不参与评估。指纹沿用旧口径 hash(kind+scope+rule),旧抑制记录照常命中 |
| `/policy-revisions*` | `/rules/policies*` | 同一张表、同一套处理器 |
| `/preference-suppressions*` | `/rules/suppressions*` | 同上;`preferenceTagId` 对新建规则组回填为 `ruleGroupId` |
`preference_tag` 表在 0033 后不再写入(数据已按原 id 迁到 `rule_group`,抑制记录按 `preference_tag_id` 回填 `rule_group_id`),回滚迁移前保留。
## 差分测试
- 生成:`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`(登记的 deviations 重跑保留)。
- 覆盖:66 条规则链(旧 filter-cases 的 42 条 + 19 条新规则串 + 5 条多组串联)× 32 个种子 = 2112 case;64 个规则串的 pyparsing 结构。
- 结果:解析结构 64/64 一致、序列化往返一致;pri 2090/2112 一致(98.96%),22 处全是 **mp_bug**:无来源 2160p HEVC 被 BLU 正则误判原盘(t08)、副标题"国语/国配"被 CNSUB 当中字(t26/t32)、`.HDR-` 不被 HDR 正则命中(t02)。原 filter-cases 里 15 条"表达不了"的规则现在全部可表达,只剩音乐条件不支持。
- 临时看差异:`UMT_RULES_DUMP=1 deno run -A npm:vitest run test/differential/rules.test.ts`(在 `packages/domain` 下)。