新功能:个性化推荐算法

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,165 @@
from __future__ import annotations
import importlib
from datetime import datetime, timezone
from typing import Any
import pytest
from fastapi.testclient import TestClient
def _set_min_env(monkeypatch: pytest.MonkeyPatch) -> None:
# 让 Settings 可构造(引擎不会在单测中真正连接 DB/Redis
monkeypatch.setenv(
"DATABASE_URL",
"mysql+aiomysql://u:p@127.0.0.1:3306/mindfulness_dev_test?charset=utf8mb4",
)
monkeypatch.setenv("REDIS_URL", "redis://127.0.0.1:6379/0")
monkeypatch.setenv("CELERY_BROKER_URL", "redis://127.0.0.1:6379/0")
def _user_profile_dict() -> dict[str, Any]:
return {
"profile_version": "v1.2",
"profile_source": "questionnaire",
"profile_generated_at": "2026-02-02T12:00:00Z",
"profile_confidence": 1.0,
"profile_answered": {"stage": True, "emotion": False, "context": False, "need": False},
"stage": {"unknown": 1},
"emotion_score": None,
"context": {},
"need": {},
}
@pytest.fixture
def client(monkeypatch: pytest.MonkeyPatch):
_set_min_env(monkeypatch)
# 清理 settings cache避免被其他测试污染
from app.core import config as config_mod
config_mod.get_settings.cache_clear()
# 重新加载 main确保使用最新 env
import app.main as main_mod
importlib.reload(main_mod)
app = main_mod.create_app()
# override repo避免依赖真实 DB
from app.api.v1 import reco as reco_mod
from app.features.personalized_reco.content_repository.types import ContentProfileDTO
class _FakeRepo:
async def fetch_candidates(self, **kwargs): # type: ignore[no-untyped-def]
# 返回一条可下发内容
return [
ContentProfileDTO(
content_id=1,
text="t1",
stage="general",
emotion_score=None,
context_suitability={},
need_suitability={},
personalization_power=0.0,
risk_flags=[],
author_id=None,
template_id=None,
review_confidence=0.7,
)
]
async def fetch_contents_by_ids(self, **kwargs): # type: ignore[no-untyped-def]
return []
async def _override_repo(): # type: ignore[no-untyped-def]
return _FakeRepo()
app.dependency_overrides[reco_mod.get_reco_repo] = _override_repo
# 清空限流计数,避免跨测试污染
import app.api.limits as limits_mod
limits_mod._reco_rate_limiter._counters.clear() # type: ignore[attr-defined]
limits_mod._reco_rate_limiter._last_gc_bucket = 0 # type: ignore[attr-defined]
return TestClient(app)
def test_accept_language_mapping_to_tc(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
# 固定时间,避免跨分钟 flake
import app.api.limits as limits_mod
monkeypatch.setattr(limits_mod.time, "time", lambda: 1738497600.0) # 2025-02-02 12:00:00Z 的某个时间戳
resp = client.post(
"/v1/reco/feed",
json={"user_profile": _user_profile_dict(), "already_recommended_ids": [], "touched_or_viewed_ids": []},
headers={"Accept-Language": "zh-TW,zh;q=0.9"},
)
assert resp.status_code == 200
data = resp.json()
assert data["meta"]["config_snapshot"]["locale"] == "tc"
def test_x_now_header_priority_over_body_now(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
import app.api.limits as limits_mod
monkeypatch.setattr(limits_mod.time, "time", lambda: 1738497600.0)
resp = client.post(
"/v1/reco/push",
json={
"user_profile": _user_profile_dict(),
"now": "2026-02-01T00:00:00Z",
"already_recommended_ids": [],
"touched_or_viewed_ids": [],
},
headers={"X-Now": "2026-02-02T12:00:00Z", "Accept-Language": "en-US"},
)
assert resp.status_code == 200
data = resp.json()
# meta.config_snapshot 里没有 now但 served_k 应该正常
assert data["meta"]["served_k"] == 1
def test_rate_limit_10_per_minute(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
import app.api.limits as limits_mod
monkeypatch.setattr(limits_mod.time, "time", lambda: 1738497600.0)
body = {"user_profile": _user_profile_dict(), "already_recommended_ids": [], "touched_or_viewed_ids": []}
for _ in range(10):
r = client.post("/v1/reco/widget", json=body)
assert r.status_code == 200
r = client.post("/v1/reco/widget", json=body)
assert r.status_code == 429
assert r.json()["detail"] == "rate_limited"
def test_celery_tasks_can_call_generate(monkeypatch: pytest.MonkeyPatch) -> None:
_set_min_env(monkeypatch)
from app.core import config as config_mod
config_mod.get_settings.cache_clear()
import app.tasks.reco as reco_tasks
# monkeypatch async runner避免依赖 DB
async def _fake_run_reco_async(**kwargs): # type: ignore[no-untyped-def]
from app.features.personalized_reco.observability.types import RecoMeta
from app.features.personalized_reco.reco_engine.types import RecoEngineResult, RecommendedItem
return RecoEngineResult(
items=[RecommendedItem(content_id=1, text="t1", final_score=1.0, fallback_level_final=0, explanations={})],
meta=RecoMeta(scene="push", served_k=1),
)
monkeypatch.setattr(reco_tasks, "_run_reco_async", _fake_run_reco_async)
out = reco_tasks.generate(scene="push", user_profile=_user_profile_dict(), k=1)
assert out["meta"]["served_k"] == 1