from __future__ import annotations import pytest from app.features.personalized_reco.content_repository.types import ContentProfileDTO from app.features.personalized_reco.rerank_freqcap.rerank import rerank_and_freqcap from app.features.personalized_reco.rerank_freqcap.types import ScoredCandidate def _c( *, content_id: int, score: float, author_id: str | None = None, template_id: str | None = None, stage: str = "general", need_key: str = "emotional_support", context_key: str = "family", ) -> ScoredCandidate: cp = ContentProfileDTO( content_id=content_id, text="t", stage=stage, # type: ignore[arg-type] emotion_score=None, context_suitability={context_key: 1.0}, need_suitability={need_key: 1.0}, personalization_power=0.0, risk_flags=[], author_id=author_id, template_id=template_id, ) return ScoredCandidate( content_id=content_id, final_score=score, author_id=author_id, template_id=template_id, content_profile=cp, ) def test_dedup_normalizes_str_int_ids() -> None: cands = [_c(content_id=1, score=0.9), _c(content_id=2, score=0.8), _c(content_id=3, score=0.7)] r = rerank_and_freqcap( scene="push", scored_candidates=cands, already_recommended_ids=["1", "bad"], touched_or_viewed_ids=[2], k=10, ) got_ids = [x.content_id for x in r.ranked_items] assert got_ids == [3] assert r.meta.candidate_pool_size_after_dedup == 1 assert r.meta.freqcap_filtered_counts["sentence"] == 2 def test_freqcap_missing_recent_author_template_is_recorded() -> None: cands = [ _c(content_id=1, score=0.9, author_id="a1", template_id="t1"), _c(content_id=2, score=0.8, author_id="a2", template_id="t2"), ] r = rerank_and_freqcap( scene="push", scored_candidates=cands, already_recommended_ids=[], touched_or_viewed_ids=[], k=10, recent_author_ids=None, recent_template_ids=None, ) assert r.meta.missing_history_fields == ["author", "template"] assert "author" not in r.meta.freqcap_filtered_counts # 未提供则不执行该维度 assert "template" not in r.meta.freqcap_filtered_counts def test_freqcap_filters_by_recent_author_when_provided() -> None: cands = [ _c(content_id=1, score=0.9, author_id="a1", template_id="t1"), _c(content_id=2, score=0.8, author_id="a2", template_id="t2"), ] r = rerank_and_freqcap( scene="widget", scored_candidates=cands, already_recommended_ids=[], touched_or_viewed_ids=[], k=10, recent_author_ids=["a1"], recent_template_ids=None, ) got_ids = [x.content_id for x in r.ranked_items] assert got_ids == [2] assert r.meta.missing_history_fields == ["template"] assert r.meta.freqcap_filtered_counts["author"] == 1 def test_feed_mmr_picks_diverse_second_item() -> None: # 构造:Top1 是 a1;c2 分数略高但同作者;c3 分数略低但不同作者/阶段 c1 = _c(content_id=1, score=1.0, author_id="a1", template_id="t1", stage="general") c2 = _c(content_id=2, score=0.99, author_id="a1", template_id="t2", stage="general") c3 = _c(content_id=3, score=0.95, author_id="a2", template_id="t3", stage="expecting") r = rerank_and_freqcap( scene="feed", scored_candidates=[c1, c2, c3], already_recommended_ids=[], touched_or_viewed_ids=[], k=2, ) got_ids = [x.content_id for x in r.ranked_items] assert got_ids[0] == 1 assert got_ids[1] == 3 def test_push_topk_sorted_by_score_after_filters() -> None: cands = [ _c(content_id=1, score=0.1), _c(content_id=2, score=0.9), _c(content_id=3, score=0.8), ] r = rerank_and_freqcap( scene="push", scored_candidates=cands, already_recommended_ids=[], touched_or_viewed_ids=[], k=2, recent_author_ids=[], recent_template_ids=[], ) got_ids = [x.content_id for x in r.ranked_items] assert got_ids == [2, 3]