新功能:个性化推荐算法

This commit is contained in:
吕新雨
2026-02-02 16:47:37 +08:00
parent 936094211b
commit 6dc4e2b943
119 changed files with 7427 additions and 357 deletions

View File

@@ -0,0 +1,129 @@
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