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

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