"""预测路由。 安全改进: - 限流: 每分钟 10 次 / IP(内存实现) - DB 连接: 短 session 模式,LLM 调用期间不持有连接 """ from __future__ import annotations import logging from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy import select from sqlalchemy.orm import selectinload from src.api.deps import rate_limit_predict, require_admin from src.api.schemas import PredictOut, PredictRequest, PredictionOut from src.db.base import AsyncSession, get_db_read, short_read from src.db.models import Match, Prediction from src.llm.predict import predict_match, PredictResult logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/v1", tags=["predict"]) @router.post("/predict", response_model=PredictOut, dependencies=[Depends(rate_limit_predict)]) async def predict(req: PredictRequest): """对一场比赛调 LLM 预测。mode=multi(默认,5专家+终裁)或 single。 公开接口,仅做限流保护(不要求登录)。 DB 连接优化: 1. 短 read session 检查比赛存在性/状态 2. 释放连接后调用 LLM(可能几十秒) 3. 短 write session 保存 Prediction """ # 1. 短 read session: 检查比赛(连接立即释放) async with short_read() as session: m = await session.get(Match, req.match_id) if m is None: raise HTTPException(404, "match not found") if m.match_status == "finished": raise HTTPException(400, "该比赛已完赛,不再支持预测") # 2. LLM 调用(不持有任何 DB 连接) try: result = await predict_match( req.match_id, model=req.model, prompt_version=req.prompt_version, mode=req.mode, ) except ValueError as e: msg = str(e) if "已结算" in msg: raise HTTPException(409, msg) logger.warning("predict validation error: %s", e) raise HTTPException(404, "比赛不存在") except RuntimeError as e: logger.error("predict LLM error: %s", e) raise HTTPException(502, "LLM 预测失败,请查看服务器日志") except Exception as e: logger.exception("predict unexpected error") raise HTTPException(500, "预测失败,请查看服务器日志") # 3. 结果映射(无 DB 访问) logger.info( "预测完成 match=%s mode=%s pred=%s:%s (%s)", req.match_id, req.mode, result.pred_home_goals, result.pred_away_goals, result.pred_1x2, ) return PredictOut( prediction_id=result.prediction_id, provider=result.provider, model=result.model, prompt_version=getattr(result, "prompt_version", None), mode=getattr(result, "mode", "single"), pred_home_goals=result.pred_home_goals, pred_away_goals=result.pred_away_goals, alt_pred_home_goals=result.alt_pred_home_goals, alt_pred_away_goals=result.alt_pred_away_goals, pred_1x2=result.pred_1x2, subjective_confidence=result.subjective_confidence, reasoning=result.reasoning, agent_outputs=getattr(result, "agent_outputs", None), agent_weights=getattr(result, "agent_weights", None), context=result.context, latency_ms=result.latency_ms, ) @router.get("/predictions", response_model=list[PredictionOut], dependencies=[Depends(require_admin)]) async def list_predictions( match_id: int | None = None, limit: int = Query(50, ge=1, le=200), db: AsyncSession = Depends(get_db_read), ): stmt = select(Prediction).options(selectinload(Prediction.match)) if match_id: stmt = stmt.where(Prediction.match_id == match_id) stmt = stmt.order_by(Prediction.created_at.desc()).limit(limit) rows = (await db.execute(stmt)).scalars().all() return [ PredictionOut( id=p.id, match_id=p.match_id, provider=p.provider, model=p.model, prompt_version=p.prompt_version, mode=p.mode or "single", pred_home_goals=p.pred_home_goals, pred_away_goals=p.pred_away_goals, pred_1x2=p.pred_1x2, subjective_confidence=p.subjective_confidence, reasoning=p.reasoning, agent_outputs=p.agent_outputs, created_at=p.created_at, actual_home_goals=p.actual_home_goals, actual_away_goals=p.actual_away_goals, settled=p.settled, ) for p in rows ] @router.get("/predictions/{prediction_id}", response_model=PredictionOut, dependencies=[Depends(require_admin)]) async def get_prediction(prediction_id: int, db: AsyncSession = Depends(get_db_read)): p = await db.get(Prediction, prediction_id) if p is None: raise HTTPException(404, "prediction not found") return PredictionOut( id=p.id, match_id=p.match_id, provider=p.provider, model=p.model, prompt_version=p.prompt_version, mode=p.mode or "single", pred_home_goals=p.pred_home_goals, pred_away_goals=p.pred_away_goals, pred_1x2=p.pred_1x2, subjective_confidence=p.subjective_confidence, reasoning=p.reasoning, agent_outputs=p.agent_outputs, created_at=p.created_at, actual_home_goals=p.actual_home_goals, actual_away_goals=p.actual_away_goals, settled=p.settled, )