diff --git a/app/application/classification/analysis.py b/app/application/classification/analysis.py index f9335c3c3..c1641c9d5 100644 --- a/app/application/classification/analysis.py +++ b/app/application/classification/analysis.py @@ -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]) diff --git a/app/application/classification/migration.py b/app/application/classification/migration.py index 88daef859..5f8d3b825 100644 --- a/app/application/classification/migration.py +++ b/app/application/classification/migration.py @@ -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)] diff --git a/app/application/classification/projection.py b/app/application/classification/projection.py index c22dcc0ad..e9639f9cf 100644 --- a/app/application/classification/projection.py +++ b/app/application/classification/projection.py @@ -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] = {} diff --git a/app/db/adapters/classification.py b/app/db/adapters/classification.py index 71974d6c1..5da8cc4bf 100644 --- a/app/db/adapters/classification.py +++ b/app/db/adapters/classification.py @@ -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( diff --git a/app/domain/classification/evaluator.py b/app/domain/classification/evaluator.py index 3c025d182..e1d437fcf 100644 --- a/app/domain/classification/evaluator.py +++ b/app/domain/classification/evaluator.py @@ -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: diff --git a/app/domain/classification/validation.py b/app/domain/classification/validation.py index b6e08d068..a79190711 100644 --- a/app/domain/classification/validation.py +++ b/app/domain/classification/validation.py @@ -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( diff --git a/app/schemas/category.py b/app/schemas/category.py index b0984bd37..3ff72efef 100644 --- a/app/schemas/category.py +++ b/app/schemas/category.py @@ -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, diff --git a/app/startup/composition/classification.py b/app/startup/composition/classification.py index 252a4ee6e..b0e2ebdd5 100644 --- a/app/startup/composition/classification.py +++ b/app/startup/composition/classification.py @@ -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 = ( diff --git a/docs/architecture/media-classification-design.md b/docs/architecture/media-classification-design.md index a595ec7a5..e9af92a44 100644 --- a/docs/architecture/media-classification-design.md +++ b/docs/architecture/media-classification-design.md @@ -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 行为兼容 diff --git a/docs/v2-to-v3-overview.md b/docs/v2-to-v3-overview.md index 50559639a..081cae557 100644 --- a/docs/v2-to-v3-overview.md +++ b/docs/v2-to-v3-overview.md @@ -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` 已移除,所有新写入都通过带版本号校验的策略接口完成。 旧分类名称中用于表示目录层级的 `/` 会在迁移时转换为多个安全路径段,分类名称和目录显示结果保持不变;已经完成迁移的策略会在数据库升级时自动修复。 diff --git a/tests/test_media_classification.py b/tests/test_media_classification.py index ff182323f..ef423e1a9 100644 --- a/tests/test_media_classification.py +++ b/tests/test_media_classification.py @@ -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]) diff --git a/tests/test_media_classification_legacy.py b/tests/test_media_classification_legacy.py index 815513d46..149018851 100644 --- a/tests/test_media_classification_legacy.py +++ b/tests/test_media_classification_legacy.py @@ -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" diff --git a/tests/test_media_classification_legacy_path_migration.py b/tests/test_media_classification_legacy_path_migration.py index 3f81c749d..317e486dc 100644 --- a/tests/test_media_classification_legacy_path_migration.py +++ b/tests/test_media_classification_legacy_path_migration.py @@ -63,7 +63,6 @@ def _policy_state_payload() -> dict[str, object]: "categories": [legacy_category, unchanged_category], "rules": [], "fallbacks": {}, - "source_fallbacks": {}, "field_aliases": {}, } history = {