Files
jxxghp 6d4ce83837 fix(types): clear host mypy debt and prevent typing regressions
严格检查全部宿主源码,清零类型错误预算并移除按模块类型豁免。
按原因、所属方法、错误码及语法指纹锁定必要兼容例外和站点类型桩,
CI 阻断新增忽略、检查范围退化、本地桩替代真实接口及新的类型错误。

修复空值、异步和供应方类型边界,保持插件与 API 兼容合同,
并补充存储、订阅、通知、Agent 和工作流边界的回归覆盖。
2026-10-21 22:03:18 +08:00

786 lines
26 KiB
Python

"""
分类策略初始化、发布、历史和回滚应用服务。
"""
from __future__ import annotations
import threading
from collections.abc import Callable, Iterable
from datetime import datetime, timezone
from functools import partial
from typing import Union, cast
from app.application.classification.contract import (
ClassificationPolicyConflictError,
ClassificationPolicyReferenceValidator,
ClassificationPolicyReferenceViolationError,
ClassificationPolicyStore,
)
from app.application.database import AsyncDatabaseExecutor
from app.domain.classification.fields import merge_field_definitions
from app.domain.classification.validation import ClassificationPolicyValidator
from app.schemas.category import (
ClassificationCategory,
ClassificationCondition,
ClassificationConditionGroup,
ClassificationFactScalar,
ClassificationFieldDefinition,
ClassificationMediaType,
ClassificationOperator,
ClassificationPolicy,
ClassificationPolicyState,
ClassificationRule,
ClassificationTarget,
ClassificationValidationResult,
)
CLASSIFICATION_POLICY_HISTORY_LIMIT = 10
_DEFAULT_MUSIC_CATEGORIES = (
("music.album", "Album", ("Album",)),
(
"music.compilation",
"Album / Compilation",
("Album", "Compilation"),
),
("music.ep", "EP", ("EP",)),
("music.single", "Single", ("Single",)),
)
_DEFAULT_MEDIA_CATEGORIES: tuple[tuple[str, ClassificationMediaType, str, tuple[str, ...]], ...] = (
("movie.animation", "电影", "动画电影", ("动画电影",)),
("movie.chinese", "电影", "华语电影", ("华语电影",)),
("movie.foreign", "电影", "外语电影", ("外语电影",)),
("movie.uncategorized", "电影", "未分类", ("未分类",)),
("tv.dongman.cn", "电视剧", "国漫", ("国漫",)),
("tv.dongman.jp", "电视剧", "日番", ("日番",)),
("tv.animation.other", "电视剧", "其他动画", ("其他动画",)),
("tv.documentary", "电视剧", "纪录片", ("纪录片",)),
("tv.kids", "电视剧", "儿童", ("儿童",)),
("tv.variety", "电视剧", "综艺", ("综艺",)),
("tv.chinese", "电视剧", "国产剧", ("国产剧",)),
("tv.western", "电视剧", "欧美剧", ("欧美剧",)),
("tv.asian", "电视剧", "亚洲剧", ("亚洲剧",)),
("tv.uncategorized", "电视剧", "未分类", ("未分类",)),
("music.uncategorized", "音乐", "未分类", ("未分类",)),
)
def _build_builtin_media_categories() -> list[ClassificationCategory]:
"""
构造内置媒体分类目录。
"""
categories = [
ClassificationCategory(
id=category_id,
media_type=media_type,
name=name,
path=list(path),
)
for category_id, media_type, name, path in _DEFAULT_MEDIA_CATEGORIES
]
return categories
def _build_builtin_movie_rules() -> list[ClassificationRule]:
"""
构造内置电影分类规则。
"""
return [
ClassificationRule(
id="movie.animation.default",
name="动画电影",
kind="category",
priority=0,
media_types=["电影"],
when=ClassificationCondition(
field="media.genre_keys",
operator="contains_any",
value=["animation"],
),
target=ClassificationTarget(category_id="movie.animation"),
),
ClassificationRule(
id="movie.chinese.default",
name="华语电影",
kind="category",
priority=1,
media_types=["电影"],
when=ClassificationCondition(
field="media.language",
operator="in",
value=["zh", "cn", "bo", "za"],
),
target=ClassificationTarget(category_id="movie.chinese"),
),
ClassificationRule(
id="movie.foreign.default",
name="外语电影",
kind="category",
priority=2,
media_types=["电影"],
when=ClassificationCondition(
field="media.language",
operator="not_in",
value=["zh", "cn", "bo", "za"],
),
target=ClassificationTarget(category_id="movie.foreign"),
),
]
def _build_builtin_tv_rules() -> list[ClassificationRule]:
"""
构造内置电视剧分类规则。
"""
return [
ClassificationRule(
id="tv.dongman.cn.default",
name="国漫",
kind="category",
priority=2,
media_types=["电视剧"],
when=ClassificationConditionGroup(
all=[
ClassificationCondition(
field="media.genre_keys",
operator="contains_any",
value=["animation"],
),
ClassificationCondition(
field="media.countries",
operator="contains_any",
value=["CN", "TW", "HK"],
),
]
),
target=ClassificationTarget(category_id="tv.dongman.cn"),
),
ClassificationRule(
id="tv.dongman.jp.default",
name="日番",
kind="category",
priority=3,
media_types=["电视剧"],
when=ClassificationConditionGroup(
all=[
ClassificationCondition(
field="media.genre_keys",
operator="contains_any",
value=["animation"],
),
ClassificationCondition(
field="media.countries",
operator="contains_any",
value=["JP"],
),
]
),
target=ClassificationTarget(category_id="tv.dongman.jp"),
),
ClassificationRule(
id="tv.animation.other.default",
name="其他动画",
kind="category",
priority=4,
media_types=["电视剧"],
when=ClassificationCondition(
field="media.genre_keys",
operator="contains_any",
value=["animation"],
),
target=ClassificationTarget(category_id="tv.animation.other"),
),
ClassificationRule(
id="tv.documentary.default",
name="纪录片",
kind="category",
priority=4,
media_types=["电视剧"],
when=ClassificationCondition(
field="media.genre_keys",
operator="contains_any",
value=["documentary"],
),
target=ClassificationTarget(category_id="tv.documentary"),
),
ClassificationRule(
id="tv.kids.default",
name="儿童",
kind="category",
priority=5,
media_types=["电视剧"],
when=ClassificationCondition(
field="media.genre_keys",
operator="contains_any",
value=["kids"],
),
target=ClassificationTarget(category_id="tv.kids"),
),
ClassificationRule(
id="tv.variety.default",
name="综艺",
kind="category",
priority=6,
media_types=["电视剧"],
when=ClassificationCondition(
field="media.genre_keys",
operator="contains_any",
value=["reality", "talk"],
),
target=ClassificationTarget(category_id="tv.variety"),
),
ClassificationRule(
id="tv.chinese.default",
name="国产剧",
kind="category",
priority=7,
media_types=["电视剧"],
when=ClassificationCondition(
field="media.countries",
operator="contains_any",
value=["CN", "TW", "HK"],
),
target=ClassificationTarget(category_id="tv.chinese"),
),
ClassificationRule(
id="tv.western.default",
name="欧美剧",
kind="category",
priority=8,
media_types=["电视剧"],
when=ClassificationCondition(
field="media.countries",
operator="contains_any",
value=[
"US", "CA", "FR", "GB", "DE", "ES", "IT", "NL", "PT", "RU",
"IE", "BE", "CH", "AT", "SE", "NO", "DK", "FI", "IS", "PL",
"CZ", "HU", "GR", "RO", "UA",
],
),
target=ClassificationTarget(category_id="tv.western"),
),
ClassificationRule(
id="tv.asian.default",
name="亚洲剧",
kind="category",
priority=9,
media_types=["电视剧"],
when=ClassificationCondition(
field="media.countries",
operator="contains_any",
value=["JP", "KP", "KR", "TH", "IN", "SG"],
),
target=ClassificationTarget(category_id="tv.asian"),
),
]
def build_builtin_classification_policy() -> ClassificationPolicy:
"""
构造显式区分外语与未知信息的默认模板,不改写已保存的旧策略。
"""
rules = [*_build_builtin_movie_rules(), *_build_builtin_tv_rules()]
for priority, rule in enumerate(rules):
rule.priority = priority
return with_default_music_classification(
ClassificationPolicy(
categories=_build_builtin_media_categories(),
rules=rules,
fallbacks={
"电影": "movie.uncategorized",
"电视剧": "tv.uncategorized",
"音乐": "music.uncategorized",
},
)
)
class ClassificationPolicyNotInitializedError(RuntimeError):
"""
表示分类策略服务尚未加载或初始化活动快照。
"""
class ClassificationPolicyRevisionNotFoundError(LookupError):
"""
表示请求回滚的历史 revision 不在有界历史中。
"""
class ClassificationPolicyValidationError(ValueError):
"""
表示策略包含阻止发布的结构化校验错误。
"""
def __init__(self, result: ClassificationValidationResult) -> None:
"""
保存完整校验结果,供 API 层返回字段级错误。
"""
self.result = result.model_copy(deep=True)
super().__init__("分类策略校验失败")
def _build_uncategorized_classification_policy() -> ClassificationPolicy:
"""
构造旧版仅包含媒体类型兜底分类的初始草稿。
"""
categories = [
ClassificationCategory(
id="movie.uncategorized",
media_type="电影",
name="未分类",
path=["未分类"],
),
ClassificationCategory(
id="tv.uncategorized",
media_type="电视剧",
name="未分类",
path=["未分类"],
),
ClassificationCategory(
id="music.uncategorized",
media_type="音乐",
name="未分类",
path=["未分类"],
),
]
return ClassificationPolicy(
categories=categories,
fallbacks={
"电影": "movie.uncategorized",
"电视剧": "tv.uncategorized",
"音乐": "music.uncategorized",
},
)
def with_default_music_classification(policy: ClassificationPolicy) -> ClassificationPolicy:
"""
为尚未配置音乐分类的策略追加安全、结构化的常用专辑分类。
"""
if not needs_default_music_classification(policy):
return policy.model_copy(deep=True)
categories = [
*(item.model_copy(deep=True) for item in policy.categories),
*(
ClassificationCategory(
id=category_id,
media_type="音乐",
name=name,
path=list(path),
)
for category_id, name, path in _DEFAULT_MUSIC_CATEGORIES
),
]
priority = max((item.priority for item in policy.rules), default=-1) + 1
rules = [*(item.model_copy(deep=True) for item in policy.rules)]
def append_rule(
*,
rule_id: str,
name: str,
field: str,
operator: ClassificationOperator,
value: Union[str, list[str]],
category_id: str,
) -> None:
"""
按既有规则之后的顺序追加单个音乐规则。
"""
nonlocal priority
rules.append(
ClassificationRule(
id=rule_id,
name=name,
kind="category",
priority=priority,
media_types=["音乐"],
when=ClassificationCondition(
field=field,
operator=operator,
value=list[ClassificationFactScalar](value) if isinstance(value, list) else value,
),
target=ClassificationTarget(category_id=category_id),
)
)
priority += 1
# 精选集同时具有 Album 主类型,必须在普通 Album 之前匹配。
append_rule(
rule_id="music.compilation.default",
name="音乐精选集",
field="music.secondary_types",
operator="contains_any",
value=["Compilation"],
category_id="music.compilation",
)
for album_type, suffix, category_id in (
("EP", "ep", "music.ep"),
("Single", "single", "music.single"),
("Album", "album", "music.album"),
):
append_rule(
rule_id=f"music.{suffix}.default",
name=f"音乐{album_type}",
field="music.album_type",
operator="equals",
value=album_type,
category_id=category_id,
)
return policy.model_copy(
deep=True,
update={"categories": categories, "rules": rules},
)
def needs_default_music_classification(policy: ClassificationPolicy) -> bool:
"""
判断音乐侧是否仍为旧版原始兜底,未包含任何用户分类。
"""
music_rules = [item for item in policy.rules if "音乐" in item.media_types]
music_categories = [
item for item in policy.categories if item.media_type == "音乐"
]
if music_rules or len(music_categories) != 1:
return False
category = music_categories[0]
return bool(
category.id == "music.uncategorized"
and category.name == "未分类"
and category.path == ["未分类"]
and category.enabled
and not category.labels
and policy.fallbacks.get("音乐") == category.id
)
def build_default_classification_policy() -> ClassificationPolicy:
"""
构造带稳定兜底和常用音乐专辑分类的初始草稿。
"""
return build_builtin_classification_policy()
class ClassificationPolicyConfigurationService:
"""
维护分类策略的进程内完整快照和数据库 CAS 发布语义。
"""
def __init__(
self,
store: ClassificationPolicyStore,
*,
extra_fields: Iterable[ClassificationFieldDefinition] = (),
extra_fields_provider: Callable[
[], Iterable[ClassificationFieldDefinition]
] | None = None,
clock: Callable[[], datetime] | None = None,
async_executor: AsyncDatabaseExecutor | None = None,
reference_validator: ClassificationPolicyReferenceValidator | None = None,
) -> None:
"""
注入状态仓储、字段目录、时钟、异步端口和外部引用校验器。
"""
self._store = store
self._extra_fields = tuple(
field.model_copy(deep=True)
for field in merge_field_definitions(extra_fields)
if field.id.startswith("extensions.")
)
self._extra_fields_provider = extra_fields_provider
self._clock = clock or (lambda: datetime.now(timezone.utc))
self._async_executor = async_executor
self._reference_validator = reference_validator
self._lock = threading.RLock()
self._state: ClassificationPolicyState | None = None
def initialize(
self,
initial_policy: ClassificationPolicy | None = None,
) -> ClassificationPolicy:
"""
加载已发布状态;配置不存在时以指定草稿或默认策略发布 revision 1。
"""
with self._lock:
stored = self._store.load()
if stored is not None:
self._publish_memory_snapshot(stored)
return self.active()
draft = initial_policy or build_default_classification_policy()
return self._publish_locked(
draft=draft,
expected_revision=0,
current_state=None,
)
def reload(self) -> ClassificationPolicy:
"""
从持久化事实源重新加载完整状态包并替换进程内快照。
"""
with self._lock:
stored = self._store.load()
if stored is None:
raise ClassificationPolicyNotInitializedError("分类策略尚未初始化")
self._publish_memory_snapshot(stored)
return self.active()
def active(self) -> ClassificationPolicy:
"""
返回与内部活动引用隔离的当前策略副本。
"""
with self._lock:
return self._require_state().active.model_copy(deep=True)
def state(self) -> ClassificationPolicyState:
"""
返回与内部活动引用隔离的完整状态包副本。
"""
with self._lock:
return self._require_state().model_copy(deep=True)
def history(self) -> tuple[ClassificationPolicy, ...]:
"""
按 revision 从新到旧返回隔离的有界历史快照。
"""
with self._lock:
return tuple(
policy.model_copy(deep=True)
for policy in self._require_state().history
)
def validate(self, draft: ClassificationPolicy) -> ClassificationValidationResult:
"""
使用发布时相同字段和外部引用约束校验草稿,但不修改状态。
"""
with self._lock:
policy_result = ClassificationPolicyValidator.validate(
draft,
extra_fields=self._current_extra_fields(),
)
if self._reference_validator is None:
return policy_result
reference_result = self._reference_validator.validate(draft)
issues = [
*(issue.model_copy(deep=True) for issue in policy_result.issues),
*(issue.model_copy(deep=True) for issue in reference_result.issues),
]
return ClassificationValidationResult(
valid=not any(issue.severity == "error" for issue in issues),
issues=issues,
)
def register_extra_fields(
self,
extra_fields: Iterable[ClassificationFieldDefinition],
) -> tuple[ClassificationFieldDefinition, ...]:
"""
合并动态来源字段目录并返回隔离后的当前扩展字段快照。
"""
with self._lock:
merged = merge_field_definitions((*self._extra_fields, *tuple(extra_fields)))
self._extra_fields = tuple(
field.model_copy(deep=True)
for field in merged
if field.id.startswith("extensions.")
)
return tuple(field.model_copy(deep=True) for field in self._extra_fields)
def extra_fields(self) -> tuple[ClassificationFieldDefinition, ...]:
"""
返回与内部字段目录隔离的动态来源字段定义。
"""
with self._lock:
return tuple(
field.model_copy(deep=True)
for field in self._current_extra_fields()
)
def _current_extra_fields(self) -> tuple[ClassificationFieldDefinition, ...]:
"""
合并静态兼容字段和插件运行时当前快照,不缓存可卸载声明。
"""
try:
dynamic = (
tuple(self._extra_fields_provider())
if self._extra_fields_provider is not None
else ()
)
# 可选插件目录不可阻断静态策略管理
except Exception: # noqa: BLE001
dynamic = ()
return tuple(
field.model_copy(deep=True)
for field in merge_field_definitions((*self._extra_fields, *dynamic))
if field.id.startswith("extensions.")
)
def publish(
self,
draft: ClassificationPolicy,
*,
expected_revision: int,
) -> ClassificationPolicy:
"""
校验草稿并以 CAS 发布下一个 revision。
"""
with self._lock:
current_state = self._require_state()
return self._publish_locked(
draft=draft,
expected_revision=expected_revision,
current_state=current_state,
)
def rollback(
self,
target_revision: int,
*,
expected_revision: int,
) -> ClassificationPolicy:
"""
选择历史内容并以新的单调 revision 发布,不复用旧版本号。
"""
with self._lock:
current_state = self._require_state()
target = next(
(
policy
for policy in current_state.history
if policy.revision == target_revision
),
None,
)
if target is None:
raise ClassificationPolicyRevisionNotFoundError(
f"分类策略历史 revision {target_revision} 不存在"
)
return self._publish_locked(
draft=target,
expected_revision=expected_revision,
current_state=current_state,
)
async def async_initialize(
self,
initial_policy: ClassificationPolicy | None = None,
) -> ClassificationPolicy:
"""
在线程执行端口中初始化分类策略,避免阻塞异步启动流程。
"""
return await self._run_async(partial(self.initialize, initial_policy))
async def async_reload(self) -> ClassificationPolicy:
"""
在线程执行端口中重新加载分类策略。
"""
return await self._run_async(self.reload)
async def async_publish(
self,
draft: ClassificationPolicy,
*,
expected_revision: int,
) -> ClassificationPolicy:
"""
在线程执行端口中校验并原子发布分类策略。
"""
return await self._run_async(
partial(self.publish, draft, expected_revision=expected_revision)
)
async def async_rollback(
self,
target_revision: int,
*,
expected_revision: int,
) -> ClassificationPolicy:
"""
在线程执行端口中把历史内容发布为新的 revision。
"""
return await self._run_async(
partial(
self.rollback,
target_revision,
expected_revision=expected_revision,
)
)
def _publish_locked(
self,
*,
draft: ClassificationPolicy,
expected_revision: int,
current_state: ClassificationPolicyState | None,
) -> ClassificationPolicy:
"""
在进程锁内构造、校验、CAS 持久化并发布完整状态包。
"""
current_revision = current_state.active.revision if current_state else 0
if expected_revision != current_revision:
raise ClassificationPolicyConflictError(
expected_revision=expected_revision,
current_revision=current_revision,
)
candidate = draft.model_copy(
deep=True,
update={
"revision": current_revision + 1,
"updated_at": self._clock(),
},
)
validation = self.validate(candidate)
if not validation.valid:
raise ClassificationPolicyValidationError(validation)
history = [] if current_state is None else [
current_state.active.model_copy(deep=True),
*(policy.model_copy(deep=True) for policy in current_state.history),
]
desired = ClassificationPolicyState(
active=candidate,
history=history[:CLASSIFICATION_POLICY_HISTORY_LIMIT],
)
try:
self._store.compare_and_set(
expected_revision=expected_revision,
state=desired,
)
except ClassificationPolicyReferenceViolationError as error:
raise ClassificationPolicyValidationError(error.result) from error
except Exception:
stored = self._store.load()
if stored is not None:
self._publish_memory_snapshot(stored)
raise
self._publish_memory_snapshot(desired)
return desired.active.model_copy(deep=True)
def _publish_memory_snapshot(self, state: ClassificationPolicyState) -> None:
"""
以深拷贝整体替换内部快照,禁止暴露持久化对象引用。
"""
self._state = state.model_copy(deep=True)
def _require_state(self) -> ClassificationPolicyState:
"""
返回内部活动状态;未初始化时明确失败。
"""
if self._state is None:
raise ClassificationPolicyNotInitializedError("分类策略尚未初始化")
return self._state
async def _run_async(
self,
operation: Callable[[], ClassificationPolicy],
) -> ClassificationPolicy:
"""
通过注入的数据库执行端口运行一个同步短事务操作。
"""
if self._async_executor is None:
raise RuntimeError("分类策略异步数据库执行端口尚未配置")
return cast(
ClassificationPolicy,
await self._async_executor.run(operation),
)