174 lines
5.5 KiB
Python
174 lines
5.5 KiB
Python
from __future__ import annotations
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
import pytest
|
|
|
|
from app.features.personalized_reco.content_repository.interface import ContentRepository
|
|
from app.features.personalized_reco.content_repository.types import ContentProfileDTO
|
|
from app.features.personalized_reco.reco_engine.orchestrator import recommend
|
|
from app.features.personalized_reco.reco_engine.types import RecoConstraints
|
|
from app.features.user_profile_scoring.types import ProfileAnswered, UserProfileV1_2, UserStageOneHot
|
|
|
|
|
|
class _FakeRepo(ContentRepository):
|
|
def __init__(self, candidates_by_level: dict[int, list[ContentProfileDTO]]):
|
|
self._candidates_by_level = candidates_by_level
|
|
|
|
async def fetch_candidates( # type: ignore[override]
|
|
self,
|
|
*,
|
|
scene: str,
|
|
user_profile: object,
|
|
fallback_level: int,
|
|
limit: int,
|
|
locale: str,
|
|
exclude_content_ids: list[int] | None = None,
|
|
) -> list[ContentProfileDTO]:
|
|
# 单测:简化实现,只按 level 返回,忽略 limit/locale/exclude
|
|
return list(self._candidates_by_level.get(int(fallback_level), []))[: int(limit)]
|
|
|
|
async def fetch_contents_by_ids(self, *, content_ids: list[int], locale: str) -> list[ContentProfileDTO]: # type: ignore[override]
|
|
raise NotImplementedError
|
|
|
|
|
|
def _now() -> datetime:
|
|
return datetime(2026, 2, 2, 12, 0, 0, tzinfo=timezone.utc)
|
|
|
|
|
|
def _user_profile(*, stage: str = "unknown", emotion_score: float | None = 0.5) -> UserProfileV1_2:
|
|
if stage == "expecting":
|
|
st = UserStageOneHot(expecting=1, parenting=0, unknown=0) # type: ignore[arg-type]
|
|
elif stage == "parenting":
|
|
st = UserStageOneHot(expecting=0, parenting=1, unknown=0) # type: ignore[arg-type]
|
|
else:
|
|
st = UserStageOneHot(expecting=0, parenting=0, unknown=1) # type: ignore[arg-type]
|
|
|
|
return UserProfileV1_2(
|
|
profile_generated_at=_now(),
|
|
profile_confidence=1.0,
|
|
profile_answered=ProfileAnswered(stage=True, emotion=emotion_score is not None, context=False, need=False),
|
|
stage=st,
|
|
emotion_score=emotion_score,
|
|
context={},
|
|
need={},
|
|
)
|
|
|
|
|
|
def _content(
|
|
*,
|
|
content_id: int,
|
|
text: str = "hello",
|
|
stage: str = "general",
|
|
personalization_power: float = 0.0,
|
|
risk_flags: list[str] | None = None,
|
|
author_id: str | None = None,
|
|
template_id: str | None = None,
|
|
need_suitability: dict[str, float] | None = None,
|
|
) -> ContentProfileDTO:
|
|
return ContentProfileDTO(
|
|
content_id=int(content_id),
|
|
text=text,
|
|
stage=stage, # type: ignore[arg-type]
|
|
emotion_score=None,
|
|
context_suitability={},
|
|
need_suitability=need_suitability or {},
|
|
personalization_power=float(personalization_power),
|
|
risk_flags=risk_flags or [],
|
|
author_id=author_id,
|
|
template_id=template_id,
|
|
review_confidence=0.7,
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reco_engine_k_zero_returns_empty() -> None:
|
|
repo = _FakeRepo({0: [_content(content_id=1)]})
|
|
res = await recommend(
|
|
repo=repo,
|
|
scene="feed",
|
|
user_profile=_user_profile(),
|
|
already_recommended_ids=[],
|
|
touched_or_viewed_ids=[],
|
|
k=0,
|
|
now=_now(),
|
|
locale=None,
|
|
)
|
|
assert res.items == []
|
|
assert res.meta.served_k == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reco_engine_pool_empty_sets_empty_reason() -> None:
|
|
repo = _FakeRepo({0: []})
|
|
res = await recommend(
|
|
repo=repo,
|
|
scene="feed",
|
|
user_profile=_user_profile(),
|
|
already_recommended_ids=[],
|
|
touched_or_viewed_ids=[],
|
|
k=1,
|
|
now=_now(),
|
|
locale="en",
|
|
)
|
|
assert res.items == []
|
|
assert res.meta.empty_reason == "pool_empty"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reco_engine_hard_filter_all_sets_empty_reason() -> None:
|
|
repo = _FakeRepo({0: [_content(content_id=1, risk_flags=["block_health_medical"])]})
|
|
res = await recommend(
|
|
repo=repo,
|
|
scene="push",
|
|
user_profile=_user_profile(stage="unknown"),
|
|
already_recommended_ids=[],
|
|
touched_or_viewed_ids=[],
|
|
k=1,
|
|
now=_now(),
|
|
locale="en",
|
|
)
|
|
assert res.items == []
|
|
assert res.meta.empty_reason == "hard_filter_all"
|
|
assert res.meta.risk_filtered_count_by_flag.get("block_health_medical", 0) == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reco_engine_freqcap_all_sets_empty_reason() -> None:
|
|
# Push 场景:作者冷却命中,导致 after_freqcap=0
|
|
repo = _FakeRepo({0: [_content(content_id=1, author_id="a1")]})
|
|
res = await recommend(
|
|
repo=repo,
|
|
scene="push",
|
|
user_profile=_user_profile(stage="unknown"),
|
|
already_recommended_ids=[],
|
|
touched_or_viewed_ids=[],
|
|
k=1,
|
|
now=_now(),
|
|
locale="en",
|
|
constraints=RecoConstraints(recent_author_ids=["a1"]),
|
|
)
|
|
assert res.items == []
|
|
assert res.meta.empty_reason == "freqcap_all"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reco_engine_returns_item_and_explanations_enabled() -> None:
|
|
repo = _FakeRepo({0: [_content(content_id=1, text="t1", personalization_power=0.0)]})
|
|
res = await recommend(
|
|
repo=repo,
|
|
scene="feed",
|
|
user_profile=_user_profile(stage="unknown"),
|
|
already_recommended_ids=[],
|
|
touched_or_viewed_ids=[],
|
|
k=1,
|
|
now=_now(),
|
|
locale="en",
|
|
)
|
|
assert len(res.items) == 1
|
|
assert res.items[0].content_id == 1
|
|
assert res.items[0].text == "t1"
|
|
assert res.items[0].explanations is not None
|
|
|
|
|