Files
mindfulness/server/app/api/v1/reco.py
2026-02-02 16:47:37 +08:00

157 lines
4.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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(),
)