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(), ) async def run_reco_payload_async( *, scene: Scene, user_profile: UserProfileV1_2, already_recommended_ids: Optional[list[Any]] = None, touched_or_viewed_ids: Optional[list[Any]] = None, k: Optional[int] = None, now: Optional[datetime] = None, locale: Optional[str] = None, ) -> dict[str, Any]: """ 在“已有事件循环”内运行推荐并返回 payload。 用途: - 供 Push 等 async 任务内部调用,避免 `asyncio.run()` 嵌套导致 RuntimeError - 也便于未来在 API/任务间复用 """ effective_now = _ensure_now(now) effective_locale = _ensure_locale(locale) # k 默认按场景(与 generate 保持一致) if k is None: k_i = 30 if scene == "feed" else 1 else: k_i = int(k) result = await _run_reco_async( 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_i), now=effective_now, locale=effective_locale, ) return result.model_dump() 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