169 lines
4.7 KiB
Python
169 lines
4.7 KiB
Python
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
|
||
|