from __future__ import annotations from datetime import datetime, timezone import pytest from app.features.personalized_reco.observability.builder import RecoMetaBuilder from app.features.personalized_reco.observability.utils import compute_empty_reason, compute_missing_fields from app.features.user_profile_scoring.types import ProfileAnswered, UserProfileV1_2, UserStageOneHot def _u(*, need: dict | None = None, context: dict | None = None, emotion_score=None, conf_u: float = 0.9) -> UserProfileV1_2: now = datetime.now(tz=timezone.utc) return UserProfileV1_2( profile_generated_at=now, profile_confidence=conf_u, profile_answered=ProfileAnswered(stage=True, emotion=True, context=True, need=True), stage=UserStageOneHot(unknown=1), emotion_score=emotion_score, context=context or {}, need=need or {}, ) def test_compute_missing_fields() -> None: u1 = _u(need={}, context={}, emotion_score=None) m1 = compute_missing_fields(u1) assert m1.need is True assert m1.context is True assert m1.emotion is True u2 = _u(need={"x": 1}, context={"y": 1}, emotion_score=0.6) m2 = compute_missing_fields(u2) assert m2.need is False assert m2.context is False assert m2.emotion is False def test_compute_empty_reason_branches() -> None: assert ( compute_empty_reason( served_k=1, candidate_pool_size_raw=0, candidate_pool_size_after_hard_filter=0, candidate_pool_size_after_freqcap=0, ) is None ) assert ( compute_empty_reason( served_k=0, candidate_pool_size_raw=0, candidate_pool_size_after_hard_filter=0, candidate_pool_size_after_freqcap=0, ) == "pool_empty" ) assert ( compute_empty_reason( served_k=0, candidate_pool_size_raw=10, candidate_pool_size_after_hard_filter=0, candidate_pool_size_after_freqcap=0, ) == "hard_filter_all" ) assert ( compute_empty_reason( served_k=0, candidate_pool_size_raw=10, candidate_pool_size_after_hard_filter=5, candidate_pool_size_after_freqcap=0, ) == "freqcap_all" ) assert ( compute_empty_reason( served_k=0, candidate_pool_size_raw=10, candidate_pool_size_after_hard_filter=5, candidate_pool_size_after_freqcap=3, ) == "unknown" ) def test_builder_outputs_stable_fields_and_monotonic_counts() -> None: u = _u(need={"emotional_support": 1}, context={}, emotion_score=None, conf_u=0.2) # 故意设置“非单调”的输入,验证 builder 的防御修正 meta = ( RecoMetaBuilder(scene="feed", user_profile=u, k=30) .set_candidate_pool_size_raw(10) .set_after_hard_filter(12) # 非法:大于 raw .set_after_dedup(20) # 非法:大于 after_hard .set_after_freqcap(15) # 非法:大于 after_dedup(修正后会与 after_dedup 对齐) .set_served_k(99) # 非法:大于 after_freqcap .set_fallback_level_final(1, reason="freqcap_all") .build() ) d = meta.model_dump() for k in [ "scene", "candidate_pool_size_raw", "candidate_pool_size_after_hard_filter", "candidate_pool_size_after_dedup", "candidate_pool_size_after_freqcap", "fallback_level_final", "served_k", "empty_reason", "conf_U", "missing_fields", ]: assert k in d assert meta.candidate_pool_size_raw == 10 assert meta.candidate_pool_size_after_hard_filter == 10 assert meta.candidate_pool_size_after_dedup == 10 assert meta.candidate_pool_size_after_freqcap == 10 assert meta.served_k == 10 assert meta.conf_U == pytest.approx(0.2) assert meta.missing_fields.context is True assert meta.missing_fields.emotion is True