from __future__ import annotations from datetime import datetime, timezone import pytest from app.features.personalized_reco.content_repository.types import ContentProfileDTO from app.features.personalized_reco.scoring.defaults import get_default_config from app.features.personalized_reco.scoring.score import score_content from app.features.personalized_reco.scoring.types import ExternalTerms, ScoreConfig from app.features.user_profile_scoring.types import ProfileAnswered, UserProfileV1_2, UserStageOneHot def _u( *, stage: str = "unknown", emotion_score: float | None = None, need: dict[str, int] | None = None, context: dict[str, int] | None = None, profile_confidence: float = 1.0, ) -> UserProfileV1_2: now = datetime.now(tz=timezone.utc) if stage == "expecting": s = UserStageOneHot(expecting=1, parenting=0, unknown=0) elif stage == "parenting": s = UserStageOneHot(expecting=0, parenting=1, unknown=0) else: s = UserStageOneHot(unknown=1) return UserProfileV1_2( profile_generated_at=now, profile_confidence=profile_confidence, profile_answered=ProfileAnswered(stage=True, emotion=True, context=True, need=True), stage=s, emotion_score=emotion_score, context=context or {}, need=need or {}, ) def _c( *, stage: str = "general", emotion_score: float | None = None, need_key: str = "emotional_support", context_key: str = "family", need_value: float = 1.0, context_value: float = 1.0, personalization_power: float = 1.0, review_confidence: float = 0.7, ) -> ContentProfileDTO: return ContentProfileDTO( content_id=1, text="hello", stage=stage, # type: ignore[arg-type] emotion_score=emotion_score, context_suitability={context_key: context_value}, need_suitability={need_key: need_value}, personalization_power=personalization_power, risk_flags=[], review_confidence=review_confidence, ) def test_missing_fields_defaults_are_applied() -> None: u = _u(emotion_score=None, need={}, context={}) c = _c(stage="general", emotion_score=None) r = score_content(scene="feed", user_profile=u, content_profile=c) assert r.breakdown.S_need == pytest.approx(0.5) assert r.breakdown.S_context == pytest.approx(0.5) assert r.breakdown.S_emotion == pytest.approx(0.8) assert set(r.breakdown.missing_fields) == {"need", "context", "emotion"} def test_uncertainty_penalty_enabled_for_push_by_default_and_can_be_disabled() -> None: u = _u(stage="unknown", emotion_score=0.6, need={"emotional_support": 1}, context={"family": 1}, profile_confidence=0.2) c = _c(stage="general", emotion_score=0.6, personalization_power=1.0, review_confidence=0.2) r_on = score_content(scene="push", user_profile=u, content_profile=c) assert r_on.breakdown.P_uncertainty > 0 cfg_off = ScoreConfig.model_validate(get_default_config("push").model_dump() | {"enable_uncertainty_penalty": False}) r_off = score_content(scene="push", user_profile=u, content_profile=c, config=cfg_off) assert r_off.breakdown.P_uncertainty == pytest.approx(0.0) assert r_off.final_score > r_on.final_score def test_widget_emotion_soft_penalty_is_applied_outside_range() -> None: u = _u(stage="unknown", emotion_score=0.6, need={"emotional_support": 1}, context={"family": 1}) cfg = get_default_config("widget") assert cfg.widget_emotion_soft_range == (0.4, 0.8) assert cfg.widget_emotion_penalty_gamma == pytest.approx(0.25) c_in = _c(stage="general", emotion_score=0.6, personalization_power=0.0) r_in = score_content(scene="widget", user_profile=u, content_profile=c_in, config=cfg) assert r_in.breakdown.P_widget_emotion_out_of_range == pytest.approx(0.0) c_out = _c(stage="general", emotion_score=0.0, personalization_power=0.0) r_out = score_content(scene="widget", user_profile=u, content_profile=c_out, config=cfg) assert r_out.breakdown.P_widget_emotion_out_of_range == pytest.approx(0.25) assert r_out.final_score < r_in.final_score def test_pass_false_forces_final_score_zero_but_breakdown_is_present() -> None: u = _u(stage="unknown", emotion_score=0.6, need={"emotional_support": 1}, context={"family": 1}) c = _c(stage="general", emotion_score=0.6, personalization_power=1.0) r = score_content(scene="feed", user_profile=u, content_profile=c, pass_filters=False, external_terms=ExternalTerms()) assert r.final_score == pytest.approx(0.0) assert r.breakdown.passed is False # breakdown 字段集合稳定(至少包含关键分解项) d = r.breakdown.model_dump(by_alias=True) for k in [ "scene", "pass", "S_need", "S_context", "S_stage", "S_emotion", "S_core", "S_personal", "S_fresh", "P_fatigue", "P_repeat", "P_risk", "P_uncertainty", "P_widget_emotion_out_of_range", "missing_fields", ]: assert k in d