157 lines
4.9 KiB
Python
157 lines
4.9 KiB
Python
from __future__ import annotations
|
||
|
||
from datetime import datetime, timezone
|
||
from typing import Any, Optional
|
||
|
||
from fastapi import APIRouter, Depends, Header
|
||
from pydantic import BaseModel, Field
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.api.limits import rate_limit_reco_by_ip
|
||
from app.db.session import get_db
|
||
from app.features.personalized_reco.content_repository.interface import ContentRepository
|
||
from app.features.personalized_reco.content_repository.sqlalchemy_repo import SqlAlchemyContentRepository
|
||
from app.features.personalized_reco.reco_engine import recommend
|
||
from app.features.personalized_reco.reco_engine.types import RecoConstraints, RecoEngineResult
|
||
from app.features.user_profile_scoring.types import UserProfileV1_2
|
||
|
||
router = APIRouter(
|
||
prefix="/v1/reco",
|
||
tags=["reco"],
|
||
dependencies=[Depends(rate_limit_reco_by_ip)],
|
||
)
|
||
|
||
|
||
class RecoRequest(BaseModel):
|
||
k: Optional[int] = None
|
||
user_profile: UserProfileV1_2
|
||
already_recommended_ids: list[Any] = Field(default_factory=list)
|
||
touched_or_viewed_ids: list[Any] = Field(default_factory=list)
|
||
now: Optional[datetime] = None
|
||
|
||
|
||
def _parse_now_from_header(x_now: Optional[str]) -> Optional[datetime]:
|
||
if not x_now:
|
||
return None
|
||
raw = str(x_now).strip()
|
||
if not raw:
|
||
return None
|
||
# 支持 Z
|
||
if raw.endswith("Z"):
|
||
raw = raw[:-1] + "+00:00"
|
||
try:
|
||
dt = datetime.fromisoformat(raw)
|
||
except Exception:
|
||
return None
|
||
if dt.tzinfo is None:
|
||
dt = dt.replace(tzinfo=timezone.utc)
|
||
return dt
|
||
|
||
|
||
def _pick_now(*, header_now: Optional[str], body_now: Optional[datetime]) -> datetime:
|
||
dt = _parse_now_from_header(header_now)
|
||
if dt is not None:
|
||
return dt
|
||
if body_now is not None:
|
||
if body_now.tzinfo is None:
|
||
return body_now.replace(tzinfo=timezone.utc)
|
||
return body_now
|
||
return datetime.now(timezone.utc)
|
||
|
||
|
||
def _pick_locale_from_accept_language(accept_language: Optional[str]) -> str:
|
||
"""
|
||
从 Accept-Language 映射 locale:
|
||
- 缺失/空 -> en
|
||
- 含 zh-TW/zh-HK/tc -> tc
|
||
- 其他 -> en
|
||
"""
|
||
|
||
raw = (accept_language or "").strip().lower()
|
||
if not raw:
|
||
return "en"
|
||
if "zh-tw" in raw or "zh-hk" in raw or "tc" in raw:
|
||
return "tc"
|
||
return "en"
|
||
|
||
|
||
async def get_reco_repo(db: AsyncSession = Depends(get_db)) -> ContentRepository:
|
||
"""
|
||
构造推荐 repo(可在测试中 override,避免依赖真实 DB)。
|
||
"""
|
||
|
||
return SqlAlchemyContentRepository(db)
|
||
|
||
|
||
@router.post("/feed", response_model=RecoEngineResult)
|
||
async def reco_feed(
|
||
req: RecoRequest,
|
||
repo: ContentRepository = Depends(get_reco_repo),
|
||
x_now: Optional[str] = Header(default=None, alias="X-Now"),
|
||
accept_language: Optional[str] = Header(default=None, alias="Accept-Language"),
|
||
) -> RecoEngineResult:
|
||
k_i = 30 if req.k is None else int(req.k)
|
||
now = _pick_now(header_now=x_now, body_now=req.now)
|
||
locale = _pick_locale_from_accept_language(accept_language)
|
||
|
||
return await recommend(
|
||
repo=repo,
|
||
scene="feed",
|
||
user_profile=req.user_profile,
|
||
already_recommended_ids=list(req.already_recommended_ids or []),
|
||
touched_or_viewed_ids=list(req.touched_or_viewed_ids or []),
|
||
k=k_i,
|
||
now=now,
|
||
locale=locale,
|
||
constraints=RecoConstraints(),
|
||
)
|
||
|
||
|
||
@router.post("/push", response_model=RecoEngineResult)
|
||
async def reco_push(
|
||
req: RecoRequest,
|
||
repo: ContentRepository = Depends(get_reco_repo),
|
||
x_now: Optional[str] = Header(default=None, alias="X-Now"),
|
||
accept_language: Optional[str] = Header(default=None, alias="Accept-Language"),
|
||
) -> RecoEngineResult:
|
||
k_i = 1 if req.k is None else int(req.k)
|
||
now = _pick_now(header_now=x_now, body_now=req.now)
|
||
locale = _pick_locale_from_accept_language(accept_language)
|
||
|
||
return await recommend(
|
||
repo=repo,
|
||
scene="push",
|
||
user_profile=req.user_profile,
|
||
already_recommended_ids=list(req.already_recommended_ids or []),
|
||
touched_or_viewed_ids=list(req.touched_or_viewed_ids or []),
|
||
k=k_i,
|
||
now=now,
|
||
locale=locale,
|
||
constraints=RecoConstraints(),
|
||
)
|
||
|
||
|
||
@router.post("/widget", response_model=RecoEngineResult)
|
||
async def reco_widget(
|
||
req: RecoRequest,
|
||
repo: ContentRepository = Depends(get_reco_repo),
|
||
x_now: Optional[str] = Header(default=None, alias="X-Now"),
|
||
accept_language: Optional[str] = Header(default=None, alias="Accept-Language"),
|
||
) -> RecoEngineResult:
|
||
k_i = 1 if req.k is None else int(req.k)
|
||
now = _pick_now(header_now=x_now, body_now=req.now)
|
||
locale = _pick_locale_from_accept_language(accept_language)
|
||
|
||
return await recommend(
|
||
repo=repo,
|
||
scene="widget",
|
||
user_profile=req.user_profile,
|
||
already_recommended_ids=list(req.already_recommended_ids or []),
|
||
touched_or_viewed_ids=list(req.touched_or_viewed_ids or []),
|
||
k=k_i,
|
||
now=now,
|
||
locale=locale,
|
||
constraints=RecoConstraints(),
|
||
)
|
||
|