134 lines
5.0 KiB
Python
134 lines
5.0 KiB
Python
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
|
|
|