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

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
@@ -0,0 +1,65 @@
"""修复 injuries 唯一索引允许 NULL 重复 + 添加 return_date 字段
Revision ID: 0012_injuries_partial_unique_and_return_date
Revises: 0011_prediction_alt_scores
Create Date: 2026-09-20
Fix 4: 唯一索引 (player_id, fixture_id, injury_type) 三列均可 NULL,
PostgreSQL 允许多条 NULL 重复。改为 partial unique index:
WHERE player_id IS NOT NULL AND fixture_id IS NOT NULL
Fix 2: return_date 字段已在 ORM 声明,确保数据库列存在。
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used Alembic.
revision: str = '0012_injuries_partial_unique_and_return_date'
down_revision: Union[str, None] = '0011_prediction_alt_scores'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
# 检查现有索引
indexes = {i["name"]: i for i in inspector.get_indexes("injuries")}
# Fix 4: 删除旧的全局唯一索引(允许 NULL 重复)
if "ix_injuries_player_fixture" in indexes:
op.drop_index("ix_injuries_player_fixture", table_name="injuries")
# 创建 partial unique index: 只在 player_id 和 fixture_id 都非空时强制唯一
op.execute(
"""
CREATE UNIQUE INDEX ix_injuries_player_fixture
ON injuries (player_id, fixture_id, injury_type)
WHERE player_id IS NOT NULL AND fixture_id IS NOT NULL
"""
)
# Fix 2: 确保 return_date 列存在(ORM 已声明,但早期迁移可能缺失)
columns = [c["name"] for c in inspector.get_columns("injuries")]
if "return_date" not in columns:
op.add_column(
"injuries",
sa.Column("return_date", sa.Date, nullable=True),
)
def downgrade() -> None:
# 删除 partial unique index
op.drop_index("ix_injuries_player_fixture", table_name="injuries")
# 恢复旧的全局唯一索引
op.create_index(
"ix_injuries_player_fixture",
"injuries",
["player_id", "fixture_id", "injury_type"],
unique=True,
)
@@ -0,0 +1,74 @@
"""修复预测唯一约束过粗,增加 mode + run_type 维度
Revision ID: 0013_predictions_unique_constraint_mode_run_type
Revises: 0012_injuries_partial_unique_and_return_date
Create Date: 2026-09-20
背景:
原唯一约束 (match_id, provider, model) 过粗,回测写入会覆盖未结算的实盘预测,
后续 settle 会污染评估数据。
修复:
1. 新增 run_type 列(默认 'live'),区分实盘与回测
2. 唯一约束改为 (match_id, provider, model, mode, run_type)
3. 已有数据 run_type 回填为 'live'
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '0013_predictions_unique_constraint_mode_run_type'
down_revision: Union[str, None] = '0012_injuries_partial_unique_and_return_date'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# 1. 新增 run_type 列(先 nullable,回填后再改 NOT NULL)
op.add_column(
"predictions",
sa.Column("run_type", sa.String(10), nullable=True),
)
# 2. 回填已有数据:全部标记为 'live'
op.execute("UPDATE predictions SET run_type = 'live' WHERE run_type IS NULL")
# 3. 改为 NOT NULL
op.alter_column("predictions", "run_type", nullable=False)
# 4. 删除旧唯一约束
op.drop_constraint("uq_predictions_match_provider_model", "predictions", type_="unique")
# 5. 创建新唯一约束(包含 mode + run_type)
op.create_unique_constraint(
"uq_predictions_match_provider_model_mode_run_type",
"predictions",
["match_id", "provider", "model", "mode", "run_type"],
)
# 6. 添加 check constraint
op.create_check_constraint(
"ck_run_type_enum",
"predictions",
"run_type IN ('live', 'backtest')",
)
def downgrade() -> None:
# 1. 删除 check constraint
op.drop_constraint("ck_run_type_enum", "predictions", type_="check")
# 2. 删除新唯一约束
op.drop_constraint("uq_predictions_match_provider_model_mode_run_type", "predictions", type_="unique")
# 3. 恢复旧唯一约束
op.create_unique_constraint(
"uq_predictions_match_provider_model",
"predictions",
["match_id", "provider", "model"],
)
# 4. 删除 run_type 列
op.drop_column("predictions", "run_type")