新功能:个性化推荐算法
This commit is contained in:
168
server/app/tasks/reco.py
Normal file
168
server/app/tasks/reco.py
Normal file
@@ -0,0 +1,168 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
from celery import shared_task
|
||||
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.features.personalized_reco.content_repository.sqlalchemy_repo import SqlAlchemyContentRepository
|
||||
from app.features.personalized_reco.content_repository.types import normalize_locale
|
||||
from app.features.personalized_reco.reco_engine import recommend
|
||||
from app.features.personalized_reco.reco_engine.types import RecoConstraints, RecoEngineResult, Scene
|
||||
from app.features.user_profile_scoring.types import UserProfileV1_2
|
||||
|
||||
|
||||
def _ensure_now(now: Optional[datetime]) -> datetime:
|
||||
if now is None:
|
||||
return datetime.now(timezone.utc)
|
||||
if now.tzinfo is None:
|
||||
return now.replace(tzinfo=timezone.utc)
|
||||
return now
|
||||
|
||||
|
||||
def _ensure_locale(locale: Optional[str]) -> str:
|
||||
raw = (locale or "").strip() or "en"
|
||||
# 严格校验只支持 en/tc(允许 en-US 等在 normalize_locale 内归一化)
|
||||
return str(normalize_locale(raw))
|
||||
|
||||
|
||||
async def _run_reco_async(
|
||||
*,
|
||||
scene: Scene,
|
||||
user_profile: UserProfileV1_2,
|
||||
already_recommended_ids: list[Any],
|
||||
touched_or_viewed_ids: list[Any],
|
||||
k: int,
|
||||
now: datetime,
|
||||
locale: str,
|
||||
) -> RecoEngineResult:
|
||||
async with AsyncSessionLocal() as session:
|
||||
repo = SqlAlchemyContentRepository(session)
|
||||
return await recommend(
|
||||
repo=repo,
|
||||
scene=scene,
|
||||
user_profile=user_profile,
|
||||
already_recommended_ids=list(already_recommended_ids or []),
|
||||
touched_or_viewed_ids=list(touched_or_viewed_ids or []),
|
||||
k=int(k),
|
||||
now=now,
|
||||
locale=locale,
|
||||
constraints=RecoConstraints(),
|
||||
)
|
||||
|
||||
|
||||
def _run_reco_sync(
|
||||
*,
|
||||
scene: Scene,
|
||||
user_profile: UserProfileV1_2,
|
||||
already_recommended_ids: list[Any],
|
||||
touched_or_viewed_ids: list[Any],
|
||||
k: int,
|
||||
now: Optional[datetime],
|
||||
locale: Optional[str],
|
||||
) -> dict[str, Any]:
|
||||
effective_now = _ensure_now(now)
|
||||
effective_locale = _ensure_locale(locale)
|
||||
result = asyncio.run(
|
||||
_run_reco_async(
|
||||
scene=scene,
|
||||
user_profile=user_profile,
|
||||
already_recommended_ids=already_recommended_ids,
|
||||
touched_or_viewed_ids=touched_or_viewed_ids,
|
||||
k=int(k),
|
||||
now=effective_now,
|
||||
locale=effective_locale,
|
||||
)
|
||||
)
|
||||
# 默认不存结果,但返回值可用于开发调试(worker 通常 ignore_result)
|
||||
return result.model_dump()
|
||||
|
||||
|
||||
@shared_task(name="tasks.reco.generate")
|
||||
def generate(
|
||||
*,
|
||||
scene: Scene,
|
||||
user_profile: dict[str, Any],
|
||||
already_recommended_ids: Optional[list[Any]] = None,
|
||||
touched_or_viewed_ids: Optional[list[Any]] = None,
|
||||
k: Optional[int] = None,
|
||||
now: Optional[str] = None,
|
||||
locale: Optional[str] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
推荐生成任务(通用入口)。
|
||||
|
||||
说明:
|
||||
- 入参尽量保持小(避免 Redis 队列膨胀)
|
||||
- 默认 worker 配置为 ignore_result,但这里仍返回结构,便于本地调试
|
||||
"""
|
||||
|
||||
# 解析 user_profile(严格按 V1.2)
|
||||
u = UserProfileV1_2.model_validate(user_profile or {})
|
||||
|
||||
# k 默认按场景(与 API 一致)
|
||||
if k is None:
|
||||
k_i = 30 if scene == "feed" else 1
|
||||
else:
|
||||
k_i = int(k)
|
||||
|
||||
# now 支持 ISO 字符串
|
||||
dt: Optional[datetime]
|
||||
if not now:
|
||||
dt = None
|
||||
else:
|
||||
raw = str(now).strip()
|
||||
if raw.endswith("Z"):
|
||||
raw = raw[:-1] + "+00:00"
|
||||
try:
|
||||
dt = datetime.fromisoformat(raw)
|
||||
except Exception:
|
||||
dt = None
|
||||
|
||||
return _run_reco_sync(
|
||||
scene=scene,
|
||||
user_profile=u,
|
||||
already_recommended_ids=list(already_recommended_ids or []),
|
||||
touched_or_viewed_ids=list(touched_or_viewed_ids or []),
|
||||
k=k_i,
|
||||
now=dt,
|
||||
locale=locale,
|
||||
)
|
||||
|
||||
|
||||
def _deliver_push_placeholder(payload: dict[str, Any]) -> None:
|
||||
"""
|
||||
Push 下游写入占位函数(V1 不接真实推送系统)。
|
||||
"""
|
||||
|
||||
_ = payload
|
||||
return None
|
||||
|
||||
|
||||
@shared_task(name="tasks.reco.push_once")
|
||||
def push_once(
|
||||
*,
|
||||
user_profile: dict[str, Any],
|
||||
already_recommended_ids: Optional[list[Any]] = None,
|
||||
touched_or_viewed_ids: Optional[list[Any]] = None,
|
||||
now: Optional[str] = None,
|
||||
locale: Optional[str] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
单次 Push 生成(占位任务)。
|
||||
"""
|
||||
|
||||
payload = generate(
|
||||
scene="push",
|
||||
user_profile=user_profile,
|
||||
already_recommended_ids=already_recommended_ids,
|
||||
touched_or_viewed_ids=touched_or_viewed_ids,
|
||||
k=1,
|
||||
now=now,
|
||||
locale=locale,
|
||||
)
|
||||
_deliver_push_placeholder(payload)
|
||||
return payload
|
||||
|
||||
Reference in New Issue
Block a user