新功能:个性化推荐算法
This commit is contained in:
133
server/tests/test_scoring.py
Normal file
133
server/tests/test_scoring.py
Normal file
@@ -0,0 +1,133 @@
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user