diff --git a/docs/runbook/rules.md b/docs/runbook/rules.md new file mode 100644 index 00000000..94ac1ec8 --- /dev/null +++ b/docs/runbook/rules.md @@ -0,0 +1,93 @@ +# 规则运行手册(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:@`;`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` 下)。 diff --git a/packages/domain/README.md b/packages/domain/README.md index 39ba8c3d..9c69b3ca 100644 --- a/packages/domain/README.md +++ b/packages/domain/README.md @@ -13,6 +13,7 @@ | `release-parser/` | `parseRelease(title, opts)`、`parseReleasePath(path \| segments, opts)`、`mergeParsed`、`scanCnMarkers`、`evalEpisodeOffset`、`cnToInt`、`fileKindOf`、`expandEpisodes`、`RELEASE_GROUP_SEED`、`PARSER_VERSION` | 标题/文件名/目录名 → `ParsedRelease`。行为对照 MP MetaInfo/MetaInfoPath,偏离点见下 | | `identity/` | `scoreCandidate`、`rankCandidates`、`resolveIdentity`、`normalizeTitle`、`stripSeasonSuffix`、`toSimplified` | 候选(TMDB 或本地别名召回,统一为 `IdentityCandidate`)按证据等级排序;年份容差 ±1;只给等级不给概率 | | `slot/` | `evaluateCandidate(spec, parsed, current?, ctx?)`、`rankForSlot`、`checkHardConditions`、`softRank`、`qualityTier`、`priorityLadder`/`currentPri`、`improvableUnits`、`describeViolation`、`validateKeywordPattern`、`transition`/`canTransition`、`transitionWithInflight`、`movieCompletion`、`unitCompletion`、`checkDistinctAssets`、`resolveEpisodeTotal`、`computeUnitGaps`/`seasonProgress`/`unitsOverlap` | 硬条件(区分缺字段与不满足;含排除来源、HDR/编码集合、每集体积、关键词正则、站点/做种/免费,站点事实经 `EvaluationContext`)、软偏好排序、优先级阶梯(MP `>` 分层 → pri 0–100 + 逐层 trace)、升级阈值与洗版模式(movie/per_unit/full_pack)、状态机(含 Slot×单元多在途聚合)、完成条件、季总集数账本(只升、回退不低于已覆盖最高集)、缺集 = 单元 ∩ 范围 − 已覆盖 − 在途 | +| `rules/` | `parseRuleString`/`parseExpr`、`serializeLayers`/`serializeExpr`、`normalizeExpr`、`BUILTIN_ATOMS`、`customAtomFromMp`、`evaluateRuleGroup`/`evaluateRuleChain`、`testAtom` | MP 规则串 ↔ 结构化层(`>` 分层,层内 `! & \|` 与括号,错误带位置);内置关键字对齐 MP(能用解析字段的用字段,其余沿用 MP 正则,音乐 13 条不支持);多组串联取交集、pri 取最后一个带层的组;经 `SlotSpec.rules` 接入 `evaluateCandidate`。差分见 `test/differential/rules.test.ts` 与 `docs/runbook/rules.md` | | `acquisition/` | `determineMode`、`sortByPriority`/`priorityOf`、`checkBudget`、`evaluateCacheVolume` | 新作品/追更补缺/升级判定;即时>追更>新作品>升级(可配置);每日预算;缓存卷硬阈值与恢复水位 | | `path/` | `resolveTargetDirectory`、`renderMediaFileName`、`renderTemplate`/`compileTemplate`、`buildNamingContext`、`sanitizeSegment`/`sanitizeFileName`、`detectPathConflicts`、`toHostPath`/`toDownloaderPath`、`validatePathMappings` | 路径绑定(已有优先、不可变)、受限模板命名、非法字符、冲突结构、下载器↔宿主路径映射 | | `organize/` | `planOrganize(input) → OrganizePlan`、`sidecarStem`、`sidecarLanguageSuffix` | 主视频 + 随行字幕/音轨、样例排除、目标存在策略、跨卷标记、H&R 前置条件 |