130 lines
4.0 KiB
Python
130 lines
4.0 KiB
Python
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
|
||
|