diff --git a/app/application/classification/execution.py b/app/application/classification/execution.py index 41dc5e672..3b6c42ae3 100644 --- a/app/application/classification/execution.py +++ b/app/application/classification/execution.py @@ -314,9 +314,12 @@ class ClassificationExecutionService: *, effective_override: ClassificationSelection | None, ) -> ClassificationSubject: - """应用纯求值结果和人工覆盖,并更新兼容目录分类。""" + """应用纯求值结果、降级保护和人工覆盖,并更新兼容目录分类。""" evaluation = evaluate_classification_facts(policy, facts) result = evaluation.result.model_copy(deep=True) + carried = _degraded_automatic_selection(finalized, policy, result) + if carried is not None: + result.effective = carried if effective_override: result.effective = effective_override.model_copy(deep=True) finalized.classification = result @@ -537,6 +540,38 @@ def _explicit_effective_override( return deepcopy(effective) +def _degraded_automatic_selection( + media: ClassificationSubject, + policy: ClassificationPolicy, + result: ClassificationResult, +) -> ClassificationSelection | None: + """事实缺失把同策略结果打成兜底时,返回可复用的既有自动分类选择。 + + 补充元数据的入口会先裁剪媒体字段再重新分类,此时缺失事实只会让结果更差, + 不会带来新的判断依据。因此仅在既有快照来自同一策略修订且命中过规则,而本次 + 求值同时出现事实缺失和兜底化时才复用;事实读齐后给出的兜底结果仍然生效, + 策略调整和真正的重新分类不会被掩盖。 + """ + if result.state != "partial": + return None + proposed = result.effective or result.recommended + if proposed is not None and proposed.source != "fallback": + return None + carried = getattr(media, "classification", None) + if not isinstance(carried, ClassificationResult): + return None + if carried.policy_revision != policy.revision: + return None + if carried.state not in {"complete", "partial"}: + return None + selection = carried.effective or carried.recommended + if selection is None or selection.source != "automatic": + return None + if not str(selection.category_id or "").strip(): + return None + return selection.model_copy(deep=True) + + def _enum_text(value: object) -> str: """把字符串枚举或普通值转换为去空白文本。""" if isinstance(value, Enum): diff --git a/tests/test_media_classification_execution.py b/tests/test_media_classification_execution.py index 1bbc517e2..a53fa31b3 100644 --- a/tests/test_media_classification_execution.py +++ b/tests/test_media_classification_execution.py @@ -334,6 +334,69 @@ def test_execution_refreshes_same_revision_after_auxiliary_facts_change() -> Non assert refreshed.classification.effective.rule_id == "rule.movie.jp" +def test_execution_preserves_automatic_category_when_supplement_drops_facts() -> None: + """裁剪过字段的媒体再次分类时,同策略已命中的自动分类不得被兜底覆盖。""" + migration = migrate_legacy_category_config( + { + "movie": {}, + "tv": { + "国产剧": {"origin_country": "CN,TW,HK"}, + "未分类": None, + }, + } + ) + domestic = next( + category.id + for category in migration.policy.categories + if category.name == "国产剧" + ) + service = ClassificationExecutionService(_Runtime(migration.policy)) + source = MediaInfo( + media_source=MediaSource.Douban, + media_id="35593344", + type=MediaType.TV, + title="测试剧", + production_countries=[{"name": "中国大陆"}], + ) + classified = service.finalize(source) + assert classified.classification.effective.category_id == domestic + assert classified.classification.state == "complete" + + classified.clear() + supplemented = service.finalize(classified) + + assert supplemented.classification is not None + assert supplemented.classification.effective.category_id == domestic + assert supplemented.classification.effective.source == "automatic" + assert supplemented.library_category == "国产剧" + # 本次求值确实缺事实,推荐结果和状态仍如实记录,供预览解释差异。 + assert supplemented.classification.state == "partial" + assert supplemented.classification.recommended.source == "fallback" + + +def test_execution_keeps_fallback_when_complete_facts_no_longer_match() -> None: + """事实读齐后不再命中规则时,兜底结果必须生效而不是复用旧自动分类。""" + service = ClassificationExecutionService(_Runtime(_policy())) + source = MediaInfo( + media_source="douban", + media_id="1", + type=MediaType.MOVIE, + origin_country=["JP"], + genres=[{"name": "动画"}], + ) + classified = service.finalize(source) + assert classified.classification.effective.category_id == "movie.jp" + + classified.origin_country = ["US"] + reclassified = service.finalize(classified) + + assert reclassified.classification is not None + assert reclassified.classification.state == "complete" + assert reclassified.classification.effective.category_id == "movie.other" + assert reclassified.classification.effective.source == "fallback" + assert reclassified.library_category == "其它电影" + + def test_execution_applies_manual_effective_override_without_losing_recommendation() -> None: """订阅或目录人工覆盖只替换 effective,仍保留自动推荐供 UI 解释。""" source = MediaInfo(