新功能:个性化推荐算法

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,130 @@
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 是 a1c2 分数略高但同作者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]