Files
Profeto/src/api/routes/predict.py
T
shangfangjian 4b0d6ee58a chore(P3): baseline 落库下沉 + MatchPredictPanel 拆分 + 多 worker/CSRF 文档
P3-2 baseline 落库从路由下沉到服务层(predict_baseline 内直接落库),
删除路由层 _persist_baseline,三种模式统一 result.prediction_id,对外 JSON 不变。

P3-1 MatchPredictPanel.PredictionPanel 拆为 OutcomePanel/AgentsPanel/ReasoningPanel
三个子组件,本文件保留 PredictModal/PredictProgress/Spinner,对外导出路径不变。

P3-3 docs 加 ⚠️ 多 worker 陷阱红字 + STRICT_SINGLE_WORKER 环境变量(启动期强制拒绝多 worker)。
P3-4 docs 新增「同站部署 vs 跨站 CSRF」节。
2026-09-21 23:29:09 +08:00

187 lines
6.9 KiB
Python

"""预测路由。
安全改进:
- 限流: 每分钟 10 次 / IP(内存实现)
- DB 连接: 短 session 模式,LLM 调用期间不持有连接
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from src.api.deps import get_predict_rate_limit_remaining, 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, request: Request):
"""对一场比赛调 LLM 预测。mode=multi(默认,5专家+终裁)、single 或 baseline。
公开接口,仅做限流保护(不要求登录)。
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. 预测调用(不持有任何 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, "预测失败,请查看服务器日志")
# D2: 三种模式统一返回 PredictResult —— 字段映射单一化,无 dict 分支。
# P3-2:baseline 已在服务层(predict_baseline)落库并回填真实 prediction_id,
# 路由层不再需要特殊的 _persist_baseline,与 single/multi 路径统一。
prediction_id = result.prediction_id
# 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=prediction_id,
provider=result.provider,
model=result.model,
prompt_version=result.prompt_version,
mode=req.mode,
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,
status=result.status,
agent_outputs=result.agent_outputs,
agent_weights=result.agent_weights,
context=result.context,
latency_ms=result.latency_ms,
prompt_tokens=result.prompt_tokens,
completion_tokens=result.completion_tokens,
rate_limit_remaining=get_predict_rate_limit_remaining(request),
)
@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,
alt_pred_home_goals=p.alt_pred_home_goals,
alt_pred_away_goals=p.alt_pred_away_goals,
pred_1x2=p.pred_1x2,
subjective_confidence=p.subjective_confidence,
reasoning=p.reasoning,
status=p.status or "success",
agent_outputs=p.agent_outputs,
agent_weights=p.agent_weights,
created_at=p.created_at,
actual_home_goals=p.actual_home_goals,
actual_away_goals=p.actual_away_goals,
settled=p.settled,
match=_match_dict(p.match) if p.match else None,
)
for p in rows
]
def _match_dict(m) -> dict | None:
if m is None:
return None
return {
"id": m.id,
"league_code": m.league.code if m.league else None,
"season": m.season,
"home_team": m.home_team.name if m.home_team else "?",
"away_team": m.away_team.name if m.away_team else "?",
"home_team_zh": m.home_team.name_zh if m.home_team else None,
"away_team_zh": m.away_team.name_zh if m.away_team else None,
"match_date": m.match_date.isoformat() if m.match_date else None,
"match_status": m.match_status,
"home_goals": m.home_goals,
"away_goals": m.away_goals,
"match_stage": m.match_stage,
}
@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,
alt_pred_home_goals=p.alt_pred_home_goals,
alt_pred_away_goals=p.alt_pred_away_goals,
pred_1x2=p.pred_1x2,
subjective_confidence=p.subjective_confidence,
reasoning=p.reasoning,
status=p.status or "success",
agent_outputs=p.agent_outputs,
agent_weights=p.agent_weights,
created_at=p.created_at,
actual_home_goals=p.actual_home_goals,
actual_away_goals=p.actual_away_goals,
settled=p.settled,
)