全量修复:预测系统正确性、安全性与部署问题

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
This commit is contained in:
Profeto Agent
2026-09-19 06:43:55 +00:00
parent 11efe91ce9
commit bee330f31f
27 changed files with 1666 additions and 137 deletions
+34 -17
View File
@@ -1,4 +1,9 @@
"""预测路由。"""
"""预测路由。
安全改进:
- 限流: 每分钟 10 次 / IP(内存实现)
- DB 连接: 短 session 模式,LLM 调用期间不持有连接
"""
from __future__ import annotations
import logging
@@ -7,9 +12,9 @@ from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from src.api.deps import require_admin
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, get_db_read
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
@@ -18,15 +23,26 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1", tags=["predict"])
@router.post("/predict", response_model=PredictOut)
async def predict(req: PredictRequest, db: AsyncSession = Depends(get_db)):
"""对一场比赛调 LLM 预测。mode=multi(默认,5专家+终裁)或 single。"""
# 已完赛比赛不再支持预测(回测走服务层直调,不受此限)
match = await db.get(Match, req.match_id)
if match is None:
raise HTTPException(404, "match not found")
if match.match_status == "finished":
raise HTTPException(400, "该比赛已完赛,不再支持预测")
@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,
@@ -47,7 +63,12 @@ async def predict(req: PredictRequest, db: AsyncSession = Depends(get_db)):
logger.exception("predict unexpected error")
raise HTTPException(500, "预测失败,请查看服务器日志")
# single / multi 两种结果统一映射
# 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,
@@ -66,10 +87,6 @@ async def predict(req: PredictRequest, db: AsyncSession = Depends(get_db)):
context=result.context,
latency_ms=result.latency_ms,
)
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,
)
@router.get("/predictions", response_model=list[PredictionOut], dependencies=[Depends(require_admin)])