Files
mindfulness/server/tests/test_scoring.py
2026-02-02 16:47:37 +08:00

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