fix(classification): remove source defaults and clarify policy docs

This commit is contained in:
jxxghp
2026-09-04 22:38:03 +08:00
parent e3641bbfea
commit 0a44a0019c
13 changed files with 64 additions and 143 deletions
+2 -8
View File
@@ -511,15 +511,9 @@ def _build_impact_analysis(
elif "rule_id" in changed_fields:
rule_changed_only_count += 1
proposed_selection = proposed.effective or proposed.recommended
if proposed_selection and proposed_selection.source in {
"fallback",
"source_fallback",
}:
if proposed_selection and proposed_selection.source == "fallback":
previous_selection = previous.effective or previous.recommended
if not previous_selection or previous_selection.source not in {
"fallback",
"source_fallback",
}:
if not previous_selection or previous_selection.source != "fallback":
became_fallback_count += 1
group_key = (facts.media.type, facts.identity.media_source)
group = group_counts.setdefault(group_key, [0, 0, 0])
+8 -9
View File
@@ -183,7 +183,7 @@ def migrate_legacy_category_config(
config: Union[CategoryConfig, Mapping[str, object]],
) -> LegacyClassificationMigrationResult:
"""
把内存中的旧分类配置转换为来源受限的新版策略草稿
把内存中的旧分类配置转换为按媒体类型匹配的新版策略草稿
:param config: 已校验的 CategoryConfig 或保持 YAML 顺序的映射
:return: 包含策略、动态字段声明和结构化诊断的纯迁移结果
@@ -193,7 +193,7 @@ def migrate_legacy_category_config(
_diagnose_unknown_top_level_keys(root, context)
categories: list[ClassificationCategory] = []
rules: list[ClassificationRule] = []
source_fallbacks: dict[str, dict[ClassificationMediaType, str]] = {}
fallbacks = dict(_COMMON_FALLBACKS)
for media_key, media_type in _MEDIA_TYPES.items():
_migrate_media_categories(
@@ -202,7 +202,7 @@ def migrate_legacy_category_config(
raw_categories=root.get(media_key),
categories=categories,
rules=rules,
source_fallbacks=source_fallbacks,
fallbacks=fallbacks,
context=context,
)
@@ -213,8 +213,7 @@ def migrate_legacy_category_config(
"mode": "first_match",
"categories": categories,
"rules": rules,
"fallbacks": dict(_COMMON_FALLBACKS),
"source_fallbacks": source_fallbacks,
"fallbacks": fallbacks,
"field_aliases": {field_id: aliases for field_id, aliases in context.field_aliases.items() if aliases},
}
policy = ClassificationPolicy.model_validate(policy_payload)
@@ -277,10 +276,10 @@ def _migrate_media_categories(
raw_categories: object,
categories: list[ClassificationCategory],
rules: list[ClassificationRule],
source_fallbacks: dict[str, dict[ClassificationMediaType, str]],
fallbacks: dict[ClassificationMediaType, str],
context: _MigrationContext,
) -> None:
"""按单个旧媒体类型的原始顺序迁移分类、规则和来源兜底。"""
"""按单个旧媒体类型的原始顺序迁移分类、规则和全局兜底。"""
if raw_categories is None:
return
if not isinstance(raw_categories, Mapping):
@@ -320,7 +319,7 @@ def _migrate_media_categories(
rule_mapping = _legacy_rule_mapping(raw_rule)
if _is_legacy_fallback(raw_rule, rule_mapping):
if not fallback_seen:
source_fallbacks.setdefault(_TMDB_SOURCE, {})[media_type] = category_id
fallbacks[media_type] = category_id
fallback_seen = True
if rule_mapping is not None:
rules.append(
@@ -737,7 +736,7 @@ def _fallback_metadata_rule(
archived: bool,
context: _MigrationContext,
) -> ClassificationRule:
"""用禁用规则保留全空字段映射,运行时仍只通过来源兜底命中"""
"""用禁用规则保留全空字段映射,运行时由全局兜底处理"""
nodes: list[ClassificationConditionNode] = []
for raw_field in rule_mapping:
field_path = [*path, str(raw_field)]
+2 -12
View File
@@ -25,7 +25,6 @@ from app.schemas.category import (
ClassificationFactScalar,
ClassificationFactValue,
ClassificationFieldDefinition,
ClassificationMediaType,
ClassificationPolicy,
ClassificationRule,
)
@@ -55,13 +54,12 @@ def project_policy_to_legacy_category_projection(
"""
把新版策略尽可能投影为旧 CategoryConfig
本迁移器生成的分类、规则和来源兜底可精确恢复;新版独有结构会保留可表达部分并返回警告。
本迁移器生成的分类、规则和全局兜底可精确恢复;新版独有结构会保留可表达部分并返回警告。
:param policy: 待兼容投影的新版策略
:return: 旧配置和无法精确表达的结构化诊断
"""
diagnostics: list[LegacyClassificationDiagnostic] = []
source_fallbacks = _policy_source_fallbacks(policy)
rules_by_category = _category_rules(policy)
projected: dict[LegacyMediaKey, dict[str, Optional[CategoryRule]]] = {
"movie": {},
@@ -73,7 +71,7 @@ def project_policy_to_legacy_category_projection(
if media_key is None or not category.id.startswith(f"legacy.{media_key}."):
continue
category_path: list[LegacyDiagnosticPathPart] = ["categories", category_index]
if source_fallbacks.get(_TMDB_SOURCE, {}).get(category.media_type) == category.id:
if policy.fallbacks.get(category.media_type) == category.id:
rule = rules_by_category.get(category.id)
if rule is not None and rule.id.endswith(".fallback"):
projected[media_key][category.name] = CategoryRule.model_validate(_project_fallback_metadata(rule))
@@ -158,14 +156,6 @@ def resolve_legacy_tmdb_category(
return _resolve_legacy_category_mapping(categories, tmdb_info)
def _policy_source_fallbacks(
policy: ClassificationPolicy,
) -> Mapping[str, Mapping[ClassificationMediaType, str]]:
"""读取新版来源专属兜底映射,并兼容并行 schema 合入前的空状态。"""
value = getattr(policy, "source_fallbacks", {})
return value if isinstance(value, Mapping) else {}
def _category_rules(policy: ClassificationPolicy) -> dict[str, ClassificationRule]:
"""按目标分类 ID 索引 TMDB 主分类规则,保持首条规则优先。"""
result: dict[str, ClassificationRule] = {}
+21 -1
View File
@@ -32,6 +32,24 @@ DirectoryConfigurationNormalizer = Callable[
]
"""使用事务内活动策略规范化目录配置的纯函数。"""
def discard_removed_source_fallbacks(value: Any) -> Any:
"""读取持久化策略时丢弃已删除的来源级默认分类字段,不再恢复其行为。"""
if not isinstance(value, Mapping):
return value
state = copy.deepcopy(dict(value))
policies = []
active = state.get("active")
if isinstance(active, Mapping):
policies.append(active)
history = state.get("history")
if isinstance(history, list):
policies.extend(item for item in history if isinstance(item, Mapping))
for policy in policies:
policy.pop("source_fallbacks", None)
return state
_CONFIGURATION_LOCK_KEYS = (
SystemConfigKey.MediaClassificationPolicy.value,
SystemConfigKey.Directories.value,
@@ -142,7 +160,9 @@ class SystemConfigClassificationPolicyStore:
try:
return cast(
ClassificationPolicyState,
ClassificationPolicyState.model_validate(value),
ClassificationPolicyState.model_validate(
discard_removed_source_fallbacks(value)
),
)
except ValidationError as error:
raise ClassificationPolicyStateCorruptError(
+2 -9
View File
@@ -135,17 +135,10 @@ class ClassificationEvaluator:
selection_source = "automatic"
if not selected_category_id:
source_fallbacks = policy.source_fallbacks.get(normalized_media_source, {})
selected_category_id = source_fallbacks.get(
selected_category_id = policy.fallbacks.get(
cast(ClassificationMediaType, normalized_media_type)
)
if selected_category_id:
selection_source = "source_fallback"
else:
selected_category_id = policy.fallbacks.get(
cast(ClassificationMediaType, normalized_media_type)
)
selection_source = "fallback"
selection_source = "fallback"
category = categories.get(selected_category_id or "")
if category:
+1 -30
View File
@@ -596,7 +596,7 @@ class ClassificationPolicyValidator:
categories: Mapping[str, Any],
collector: _ValidationCollector,
) -> None:
"""确保通用和来源级兜底引用同类型的可用分类。"""
"""确保每种媒体类型的全局兜底引用同类型的可用分类。"""
for media_type in ALL_MEDIA_TYPES:
category_id = policy.fallbacks.get(cast(ClassificationMediaType, media_type))
path: list[str | int] = ["fallbacks", media_type]
@@ -626,35 +626,6 @@ class ClassificationPolicyValidator:
f"兜底分类 {category_id} 已禁用",
path,
)
for source, source_fallbacks in policy.source_fallbacks.items():
source_path: list[str | int] = ["source_fallbacks", source]
if not _SOURCE_ID_PATTERN.fullmatch(source):
collector.error(
"invalid_fallback_source",
f"来源级兜底的数据源 {source} 不是合法标识",
source_path,
)
for media_type, category_id in source_fallbacks.items():
path = [*source_path, media_type]
category = categories.get(category_id)
if not category:
collector.error(
"unknown_source_fallback_category",
f"来源级兜底分类 {category_id} 不存在",
path,
)
elif category.media_type != media_type:
collector.error(
"source_fallback_media_type_mismatch",
f"来源级兜底分类 {category_id} 不属于媒体类型 {media_type}",
path,
)
elif not category.enabled:
collector.error(
"disabled_source_fallback_category",
f"来源级兜底分类 {category_id} 已禁用",
path,
)
@classmethod
def _validate_aliases(
+1 -5
View File
@@ -198,10 +198,6 @@ class ClassificationPolicy(_ClassificationModel):
categories: list[ClassificationCategory] = Field(default_factory=list, description="稳定分类定义列表")
rules: list[ClassificationRule] = Field(default_factory=list, description="全局有序规则列表")
fallbacks: dict[ClassificationMediaType, str] = Field(default_factory=dict, description="各媒体类型的兜底分类 ID")
source_fallbacks: dict[str, dict[ClassificationMediaType, str]] = Field(
default_factory=dict,
description="按数据源覆盖的媒体类型兜底分类 ID",
)
field_aliases: dict[str, dict[str, str]] = Field(default_factory=dict, description="字段值别名到规范值的映射")
updated_at: Optional[datetime] = Field(default=None, description="策略最后发布时间")
@@ -641,7 +637,7 @@ class ClassificationImpactAnalysis(_ClassificationModel):
category_changed_count: int = Field(ge=0, description="稳定分类 ID 发生变化的样本数量")
path_only_changed_count: int = Field(ge=0, description="分类 ID 不变但路径变化的样本数量")
rule_changed_only_count: int = Field(ge=0, description="分类与路径不变但命中规则变化的样本数量")
became_fallback_count: int = Field(ge=0, description="候选策略改为通用或来源级兜底的样本数量")
became_fallback_count: int = Field(ge=0, description="候选策略改为媒体类型默认分类的样本数量")
partial_count: int = Field(ge=0, description="任一策略因事实缺失产生 partial 的样本数量")
degraded_count: int = Field(
ge=0,
+7 -2
View File
@@ -34,7 +34,10 @@ from app.application.classification.reference import (
)
from app.application.classification.runtime import ClassificationRuntime
from app.application.database import AsyncDatabaseExecutor
from app.db.adapters.classification import SystemConfigClassificationPolicyStore
from app.db.adapters.classification import (
SystemConfigClassificationPolicyStore,
discard_removed_source_fallbacks,
)
from app.db.oper.systemconfig import SystemConfigOper
from app.db.session import SessionFactory
from app.runtime.config import Settings
@@ -104,7 +107,9 @@ async def compose_classification(
existing_issue: tuple[ClassificationValidationIssue, ...] = ()
if policy_key in values:
try:
stored_state = ClassificationPolicyState.model_validate(stored_value)
stored_state = ClassificationPolicyState.model_validate(
discard_removed_source_fallbacks(stored_value)
)
extra_fields = legacy_extension_fields_from_policy(stored_state.active)
except ValidationError:
existing_issue = (
@@ -227,7 +227,6 @@ TheMovieDb 模块内部的专用能力:
"categories": [],
"rules": [],
"fallbacks": {},
"source_fallbacks": {},
"field_aliases": {},
"updated_at": "2026-09-02T12:00:00+08:00"
}
@@ -243,7 +242,6 @@ TheMovieDb 模块内部的专用能力:
| `categories` | 稳定分类定义 |
| `rules` | 全局有序的主分类规则和附加标签规则 |
| `fallbacks` | 每种媒体类型的兜底分类 ID |
| `source_fallbacks` | 可选的数据源级媒体类型兜底;优先于通用兜底,用于兼容来源特有的历史语义 |
| `field_aliases` | 可选值别名,例如音乐流派同义词,不改变字段 ID |
### 5.2 分类定义
@@ -552,8 +550,7 @@ UI 根据用户选择的媒体类型和来源过滤字段,并显示覆盖提
2. 订阅、下载历史或人工整理持久化的分类覆盖。
3. 用户显式选定目录时,该目录绑定的固定分类。
4. 当前策略自动推荐分类。
5. 当前数据源声明的媒体类型兜底分类。
6. 媒体类型通用兜底分类。
5. 媒体类型默认分类。
来源 `metadata_category` 永远不自动升级为 `library_category`。用户确实希望按 `Live``Album``Rock`
分类时,应通过显式规则引用 `music.secondary_types``music.album_type``music.genres`
@@ -725,15 +722,15 @@ API 常规读取只返回 `active`,历史接口按需读取 `history`。选择
### 11.1 信息架构
分类策略从“设置 -> 目录”中的“自动分类策略”入口进入,点击后打开全屏、可滚动的编辑窗口;分类编辑器在窗口内工作。
窗口内按职责分为“分类树、规则、来源、检查与发布”个工作区标签;最后一个工作区再分为“结果预览、
影响分析、版本发布与历史”三个标签。移动端保持同样的分层顺序,不把所有表单压缩到同一屏:
窗口内按职责分为“分类树、规则、检查与发布”个工作区标签;最后一个工作区再分为“结果预览、影响分析、
版本发布与历史”三个标签。移动端保持同样的分层顺序,不把所有表单压缩到同一屏:
```text
+------------------+------------------+------------------+----------------------+
| 分类树 | 规则 | 来源 | 检查与发布 |
| 电影 / 电视剧 | 有序规则列表 | 来源默认分类 | 结果预览 |
| 音乐 | 条件和输出 | 影视 / 音乐 | 影响分析 / 版本历史 |
+------------------+------------------+------------------+----------------------+
+------------------+------------------+----------------------+
| 分类树 | 规则 | 检查与发布 |
| 电影 / 电视剧 | 有序规则列表 | 结果预览 |
| 音乐 | 条件和输出 | 影响分析 / 版本历史 |
+------------------+------------------+----------------------+
```
不得继续在每条规则中堆叠固定的 TMDB 表单控件。
@@ -934,9 +931,8 @@ API 常规读取只返回 `active`,历史接口按需读取 `history`。选择
类型转换或假值处理可能改变旧结果,必须保留为受控 TMDB 扩展事实,不能为了使用标准字段牺牲等价性。
7. 其它 TMDB 一级字段转换到受控 `extensions.themoviedb.*` 字段;无法登记的字段阻止自动发布,保留
legacy 运行并提示管理员处理。
8. 首个空规则分类转换为 `source_fallbacks.themoviedb` 下该媒体类型的来源级兜底,禁止污染豆瓣、
IMDb 等其它来源;其后的 legacy 项在旧实现中本就不可达,迁移时保持禁用并向管理员报告
9. 新策略仍为电影、电视剧和音乐配置通用兜底,用于没有来源级兜底或来源级规则未命中的情况。
8. 首个空规则分类转换为该媒体类型的全局 `fallbacks`,其后的 legacy 项在旧实现中本就不可达,迁移时保持禁用并向管理员报告。
9. 新策略只按媒体类型配置通用兜底;数据源只能作为规则的筛选条件,不能单独决定默认分类
10. 保存新策略 revision 1,并保留原 YAML 文件只读备份,不再继续写入。
### 13.2 行为兼容
+5 -5
View File
@@ -105,20 +105,20 @@ V3 将通用媒体身份统一为:
V2 自动分类主要由 TMDB 详情和 `category.yaml` 驱动,只覆盖电影、电视剧。V3 将自动分类独立为统一能力,不再属于某一个元数据来源:
- 电影、电视剧和音乐在“设置 → 目录 → 自动分类策略”打开的全屏窗口中维护分类树和规则。
- 规则可以使用媒体类型、年份、国家、类型、音乐实体、专辑类型、来源范围等标准事实
- 规则可以使用媒体类型、年份、国家、类型、音乐实体、专辑类型和数据源范围等媒体信息
- TMDB、豆瓣、Bangumi、AniList、IMDb、TVDB、MusicBrainz、TheAudioDB、豆瓣音乐和已登记插件来源都可以进入同一分类流程。
- 来源专用字段由宿主或插件在受控命名空间中声明,规则编辑器根据后端字段目录动态生成,不再把 TMDB 字段硬编码到前端。
- 默认只使用主来源已经取得的事实;管理员显式开启“补充缺失事实”后,宿主才会在身份可证明一致的前提下向其它来源补充缺失字段
- 默认只使用主来源已经取得的媒体信息;管理员显式开启“补充缺失信息”后,宿主才会在确认是同一媒体的前提下向其它来源补充缺失内容
分类策略按 revision 版本化保存。发布前必须先通过服务端校验,并可以使用近期历史样本执行有界影响分析;发布后可以查看历史版本和回滚。并发编辑会提示 revision 冲突,不会静默覆盖另一位管理员的修改。
使用时不需要手工填写分类事实:在“结果预览”中输入关键词并选择媒体,系统直接使用搜索结果中的标题、年份、风格、
使用时不需要手工填写条件信息:在“结果预览”中输入关键词并选择媒体,系统直接使用搜索结果中的标题、年份、风格、
国家/地区、分级以及音乐流派、标签和艺术家等信息。影响分析会读取近期下载和整理记录,再按记录中的数据源和编号
重新获取完整媒体详情;无法获取详情的记录会单独统计,不会被当成“没有变化”。
目录、订阅、下载和整理历史不再只保存易变的分类名称,而是同时记录稳定 `category_id` 和当时的路径快照。分类改名或调整路径后,既有目录和订阅仍能解析到同一个分类;已经建立的整理计划会继续使用计划创建时冻结的分类目标,不会被后续策略修改。
目录、订阅、下载和整理历史不再只保存易变的分类名称,而是同时记录内部分类编号 `category_id` 和当时的路径快照。分类改名或调整路径后,既有目录和订阅仍能到同一个分类;已经建立的整理计划会继续使用计划创建时保存的分类目标,不会被后续策略修改。
升级时,如果尚未存在 V3 分类策略,系统会读取现有 `category.yaml` 并自动迁移;旧 TMDB 规则的顺序、排除条件、年份范围和兜底语义会保留。迁移完成后不再继续写入 YAML。旧 `GET /api/v1/media/category``GET /api/v1/media/category/config` 暂时保留为只读投影,旧 `POST /api/v1/media/category/config` 已移除,所有新写入都通过带 revision 校验的策略接口完成。
升级时,如果尚未存在 V3 分类策略,系统会读取现有 `category.yaml` 并自动迁移;旧 TMDB 规则的顺序、排除条件、年份范围和“没有规则命中时使用的默认分类”会保留。迁移完成后不再继续写入 YAML。旧 `GET /api/v1/media/category``GET /api/v1/media/category/config` 暂时保留为只读投影,旧 `POST /api/v1/media/category/config` 已移除,所有新写入都通过带版本号校验的策略接口完成。
旧分类名称中用于表示目录层级的 `/` 会在迁移时转换为多个安全路径段,分类名称和目录显示结果保持不变;已经完成迁移的策略会在数据库升级时自动修复。
-40
View File
@@ -349,28 +349,6 @@ def test_media_type_fallback_is_used_when_no_category_rule_matches() -> None:
assert evaluation.result.effective.source == "fallback"
def test_source_fallback_overrides_only_its_declared_media_source() -> None:
payload = _base_policy_payload()
payload["source_fallbacks"] = {
"themoviedb": {"电影": "movie.hit"},
}
policy = ClassificationPolicy.model_validate(payload)
tmdb_evaluation = _evaluate(
policy,
_facts(media_source="themoviedb"),
)
douban_evaluation = _evaluate(
policy,
_facts(media_source="douban"),
)
assert tmdb_evaluation.result.effective.category_id == "movie.hit"
assert tmdb_evaluation.result.effective.source == "source_fallback"
assert douban_evaluation.result.effective.category_id == "movie.fallback"
assert douban_evaluation.result.effective.source == "fallback"
@pytest.mark.parametrize( # type: ignore[misc]
("media_type", "media_source", "expected_category"),
[
@@ -619,24 +597,6 @@ def test_every_enabled_media_type_requires_a_fallback() -> None:
_assert_validation_error(payload, "missing_fallback")
@pytest.mark.parametrize( # type: ignore[misc]
("source_fallbacks", "error_code"),
[
({"TheMovieDB": {"电影": "movie.hit"}}, "invalid_fallback_source"),
({"themoviedb": {"电影": "missing"}}, "unknown_source_fallback_category"),
({"themoviedb": {"电影": "tv.fallback"}}, "source_fallback_media_type_mismatch"),
],
)
def test_source_fallbacks_must_reference_valid_same_type_categories(
source_fallbacks: dict[str, dict[str, str]],
error_code: str,
) -> None:
payload = _base_policy_payload()
payload["source_fallbacks"] = source_fallbacks
_assert_validation_error(payload, error_code)
def test_extension_field_namespace_must_match_the_restricted_source() -> None:
standard_fields = list(get_standard_classification_fields())
field_model = type(standard_fields[0])
+5 -7
View File
@@ -208,8 +208,8 @@ def test_default_style_config_preserves_order_and_uses_safe_standard_fields() ->
assert origin_country.replacement_field == "media.countries"
def test_first_empty_rule_becomes_source_fallback_and_later_entries_are_disabled() -> None:
"""首个全空项应成为 TMDB 兜底,后续分类和规则保留但永远禁用。"""
def test_first_empty_rule_becomes_global_fallback_and_later_entries_are_disabled() -> None:
"""首个全空项应成为媒体类型全局兜底,后续分类和规则保留但永远禁用。"""
result = migrate_legacy_category_config(
{
"movie": {
@@ -222,7 +222,7 @@ def test_first_empty_rule_becomes_source_fallback_and_later_entries_are_disabled
)
legacy_categories = [category for category in result.policy.categories if category.id.startswith("legacy.movie.")]
assert result.policy.source_fallbacks["themoviedb"]["电影"] == legacy_categories[0].id
assert result.policy.fallbacks["电影"] == legacy_categories[0].id
assert [category.enabled for category in legacy_categories] == [True, False, False]
assert [rule.enabled for rule in result.policy.rules] == [False, False, False]
assert [issue.code for issue in result.issues].count("unreachable_legacy_category") == 2
@@ -433,8 +433,8 @@ def test_tmdb_fixtures_match_category_helper_directory_classification(
assert current_category == legacy_category
def test_non_tmdb_source_uses_common_fallback_instead_of_legacy_source_fallback() -> None:
"""非 TMDB 身份不得进入只为旧 category.yaml 保留的来源级兜底"""
def test_non_tmdb_source_uses_the_same_media_type_fallback() -> None:
"""媒体类型全局兜底对不同数据源保持一致"""
result = migrate_legacy_category_config(_legacy_config())
facts = ClassificationFacts.model_validate(
{
@@ -447,7 +447,6 @@ def test_non_tmdb_source_uses_common_fallback_instead_of_legacy_source_fallback(
evaluation = ClassificationEvaluator.evaluate(result.policy, facts)
assert evaluation.result.recommended.category_id == result.policy.fallbacks["电影"]
assert evaluation.result.recommended.category_id != result.policy.source_fallbacks["themoviedb"]["电影"]
assert evaluation.result.recommended.source == "fallback"
@@ -463,7 +462,6 @@ def test_config_without_empty_entry_remains_valid_and_uses_common_fallback() ->
evaluation = ClassificationEvaluator.evaluate(result.policy, facts)
assert result.valid
assert result.policy.source_fallbacks == {}
assert ClassificationPolicyValidator.validate(result.policy, result.extra_fields).valid
assert evaluation.result.recommended.category_id == result.policy.fallbacks["电影"]
assert evaluation.result.recommended.source == "fallback"
@@ -63,7 +63,6 @@ def _policy_state_payload() -> dict[str, object]:
"categories": [legacy_category, unchanged_category],
"rules": [],
"fallbacks": {},
"source_fallbacks": {},
"field_aliases": {},
}
history = {