fix(classification): 补充元数据不再把已命中的自动分类打成兜底 (#6717)

This commit is contained in:
freeman
2026-09-17 15:55:03 +08:00
committed by GitHub
parent 0c5bdd6684
commit ca47864e45
2 changed files with 99 additions and 1 deletions

View File

@@ -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):

View File

@@ -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(