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

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
+29 -7
View File
@@ -112,11 +112,13 @@ async def _upsert_prediction(
provider_name: str,
model: str,
mode: str,
run_type: str,
values: dict,
) -> Prediction:
"""按 (match, provider, model) 唯一约束写入预测。
"""按 (match, provider, model, mode, run_type) 唯一约束写入预测。
已存在且未结算 → 覆盖更新(重新预测语义);已结算 → 拒绝(保护评估数据)。
run_type 区分 live/backtest,避免回测覆盖实盘预测。
"""
existing = (
await session.execute(
@@ -124,6 +126,8 @@ async def _upsert_prediction(
Prediction.match_id == match_id,
Prediction.provider == provider_name,
Prediction.model == model,
Prediction.mode == mode,
Prediction.run_type == run_type,
)
)
).scalar_one_or_none()
@@ -134,6 +138,7 @@ async def _upsert_prediction(
match_id=match_id, provider=provider_name, model=model,
)
pred.mode = mode
pred.run_type = run_type
for k, v in values.items():
setattr(pred, k, v)
if existing is None:
@@ -151,6 +156,7 @@ async def predict_match(
mode: str = "multi",
use_cache: bool = True,
backtest: bool = False,
cutoff_at=None,
) -> "PredictResult | MultiPredictResult":
"""预测入口。mode=multi(默认)走多 agent;mode=single 走单次调用。
@@ -158,7 +164,8 @@ async def predict_match(
use_cache: 是否允许返回进程内缓存结果。回测必须传 False——
缓存命中不会新建 prediction 行,调用方会对同一个 prediction_id
反复 settle,把不同比赛的真实比分覆盖到同一条记录上。
backtest: 是否回测模式。True 时 build_context 使用 match_date-1天 作为 cutoff
backtest: 是否回测模式。True 时 cutoff 自动设为 match_date-1天。
cutoff_at: 显式截止时间,优先级高于 backtest 自动计算。
"""
if mode == "single":
return await _predict_single(
@@ -168,10 +175,18 @@ async def predict_match(
prompt_version=prompt_version,
use_cache=use_cache,
backtest=backtest,
cutoff_at=cutoff_at,
)
from src.llm.agents.orchestrator import predict_match_multi
return await predict_match_multi(match_id, provider=provider, version=(prompt_version or "v1").removeprefix("multi_"))
# 回测参数完整传递到 multi-agent 路径
return await predict_match_multi(
match_id,
provider=provider,
version=(prompt_version or "v1").removeprefix("multi_"),
backtest=backtest,
cutoff_at=cutoff_at,
)
async def _predict_single(
@@ -182,6 +197,7 @@ async def _predict_single(
prompt_version: str | None = None,
use_cache: bool = True,
backtest: bool = False,
cutoff_at=None,
) -> PredictResult:
"""单次调用路径(原有实现)。"""
if provider is None:
@@ -198,13 +214,14 @@ async def _predict_single(
logger.debug("predict cache hit match=%s", match_id)
return cached
# 1. 拼上下文(P2-6: backtest 时使用 match_date-1天 作为 cutoff)
ctx = await build_context(match_id, backtest=backtest)
# 1. 拼上下文(backtest/cutoff 防泄漏)
ctx = await build_context(match_id, backtest=backtest, cutoff_at=cutoff_at)
# 1.5 计算快照元数据(用于可复现性)
now = datetime.now(timezone.utc)
match_kickoff_at = ctx.match_dt
prediction_cutoff_at = ctx.match_dt # 默认:比赛时间作为数据截止
# 使用上下文实际计算的 cutoff(回测时可能为 match_dt-1天),而非开球时间
prediction_cutoff_at = ctx.cutoff if ctx.cutoff is not None else ctx.match_dt
input_hash = hashlib.sha256(ctx.text.encode("utf-8")).hexdigest()
# 2. 拼 prompt(指定版本)
@@ -223,7 +240,11 @@ async def _predict_single(
if resp.error:
raise RuntimeError(f"LLM error: {resp.error}")
parsed = resp.parsed or {}
# P0-3: json_mode 下 parsed 为 None 说明 JSON 解析失败,不能 fallback 到 {}
if resp.parsed is None:
raise RuntimeError("LLM 输出 JSON 解析失败,parsed=None")
parsed = resp.parsed
# 3.5 严格校验 LLM 输出
from src.llm.validation import validate_prediction_output
@@ -245,6 +266,7 @@ async def _predict_single(
provider_name=settings.LLM_PROVIDER,
model=provider.model,
mode="single",
run_type="backtest" if backtest else "live",
values={
"prompt_version": version,
"prompt_tokens": resp.prompt_tokens,