P0 严重问题修复: - 修复 form_slice/stats_slice 主客身份反转(历史比赛视角错误) - 修复 understat.py httpx 未导入导致的 NameError - 修复 LLM 解析失败时静默产生假成功预测(0-0 平局+置信度0.5) 预测路径修复: - multi-agent 路径增加 backtest cutoff 透传,回测防泄漏生效 - H2H 切片汇总统计改为从当前主队视角计数 - 预测唯一约束增加 mode+run_type 维度,防止回测覆盖实盘预测 伤停管线修复: - IntegrityError 后不再整批回滚丢数据(改用逐条 flush) - return_date 正确解析并写入 - retrieved_at 比较统一用 date() 避免当天数据不可见 - 唯一索引改为 partial unique index(排除 NULL 重复) - HTTP 缓存 TTL 从 7 天改为 6 小时 安全与连接管理: - /api/v1/predict 增加内存滑动窗口限流(10次/分钟/IP) - 预测路由改用短 session 模式,LLM 调用期间不持有 DB 连接 Docker 部署修复: - 修复 .dockerignore 排除 *.md 导致 COPY README.md 失败 - 容器内 DATABASE_URL 使用 postgres 服务名(非 localhost) - 启动时自动执行 alembic upgrade head - 前端改用多阶段构建(Dockerfile.frontend) 新增测试(5个文件,24+用例): - test_p0_home_away.py: 主客身份反转回归测试 - test_p0_parse_failure.py: LLM 解析失败回归测试 - test_multi_agent_cutoff.py: multi-agent cutoff 透传测试 - test_h2h_perspective.py: H2H 视角测试 - test_injuries_pipeline.py: 伤停管线 5 项修复测试 - test_predict_protection.py: 限流+短 session 测试 - test_prediction_unique_constraint.py: 唯一约束测试 迁移: - 0012_injuries_partial_unique_and_return_date.py - 0013_predictions_unique_constraint_mode_run_type.py
149 lines
5.3 KiB
Python
149 lines
5.3 KiB
Python
"""预测路由。
|
|
|
|
安全改进:
|
|
- 限流: 每分钟 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,
|
|
)
|