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

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
+134
View File
@@ -0,0 +1,134 @@
"""回归测试: H2H 切片「主队 n 胜」统计视角修复。
验证: 历史交锋汇总必须从「当前主队」视角统计胜/平/负,
而非按「场地主队」统计。
"""
from __future__ import annotations
from unittest.mock import MagicMock
import pytest
from src.llm.context_builder import MatchHeader, h2h_slice
def _make_team(tid: int, name: str) -> MagicMock:
t = MagicMock()
t.id = tid
t.name = name
return t
def _make_h2h_match(mid, home_id, away_id, home_goals, away_goals, home_name="H", away_name="A"):
m = MagicMock()
m.id = mid
m.home_team_id = home_id
m.away_team_id = away_id
m.home_goals = home_goals
m.away_goals = away_goals
m.match_date = None
m.home_team = _make_team(home_id, home_name)
m.away_team = _make_team(away_id, away_name)
return m
def _make_header(home_id=1, away_id=2, home_name="利物浦", away_name="阿森纳"):
return MatchHeader(
match_id=999,
home_name=home_name,
away_name=away_name,
league_name="英超",
season="2025-2026",
match_date="2026-01-15 20:00 UTC",
match_dt=None,
stage=None,
home_team_id=home_id,
away_team_id=away_id,
league_id=1,
)
class TestH2HCurrentHomePerspective:
"""H2H 汇总统计必须从当前主队视角出发。"""
@pytest.mark.asyncio
async def test_swapped_home_away_perspective(self):
"""
场景: 当前比赛利物浦(home_id=1) vs 阿森纳(away_id=2)。
历史交锋两场:
1. 利物浦主场 2-0 阿森纳 (home_id=1, away_id=2)
2. 阿森纳主场 3-1 利物浦 (home_id=2, away_id=1)
从利物浦视角: 1胜(2-0) 1负(1-3)。
原bug: 按场地主队统计 → "主队 1胜 0平 1负"(第二场场地主队是阿森纳,赢了),
导致「利物浦横扫」的假象。
"""
header = _make_header(home_id=1, away_id=2, home_name="利物浦", away_name="阿森纳")
matches = [
_make_h2h_match(100, home_id=1, away_id=2, home_goals=2, away_goals=0,
home_name="利物浦", away_name="阿森纳"),
_make_h2h_match(101, home_id=2, away_id=1, home_goals=3, away_goals=1,
home_name="阿森纳", away_name="利物浦"),
]
import src.llm.context_builder as cb
orig = cb._get_h2h
async def mock_get_h2h(db, home_id, away_id, before, *, limit):
return matches
cb._get_h2h = mock_get_h2h
try:
result = await h2h_slice(header, limit=8, before=None)
text = str(result)
print(text)
# 从利物浦视角: 1胜 0平 1负
assert "1胜 0平 1负" in text, f"期望「1胜 0平 1负」,实际:\n{text}"
assert "利物浦" in text, f"应标明当前主队视角:\n{text}"
# 原bug输出: "主队 1胜 0平 1负"(模糊的「主队」,实际是场地主队)
# 修复后: "从当前主队 利物浦 视角: 1胜 0平 1负"
assert "从当前主队" in text, f"应标明「从当前主队」视角:\n{text}"
finally:
cb._get_h2h = orig
@pytest.mark.asyncio
async def test_all_home_wins_from_current_perspective(self):
"""
当前主队所有交锋都是主场且全胜 → 全部计为当前主队胜。
"""
header = _make_header(home_id=1, away_id=2, home_name="曼城", away_name="诺维奇")
matches = [
_make_h2h_match(200, home_id=1, away_id=2, home_goals=3, away_goals=0,
home_name="曼城", away_name="诺维奇"),
_make_h2h_match(201, home_id=1, away_id=2, home_goals=2, away_goals=1,
home_name="曼城", away_name="诺维奇"),
]
import src.llm.context_builder as cb
orig = cb._get_h2h
cb._get_h2h = lambda db, h, a, before, **kw: matches
try:
result = await h2h_slice(header, limit=8, before=None)
text = str(result)
assert "2胜 0平 0负" in text, f"期望「2胜 0平 0负」,实际:\n{text}"
finally:
cb._get_h2h = orig
@pytest.mark.asyncio
async def test_draw_counted_correctly(self):
"""场景: 两场交锋一胜一平,验证平局也被正确计数。"""
header = _make_header(home_id=1, away_id=2, home_name="切尔西", away_name="热刺")
matches = [
_make_h2h_match(300, home_id=1, away_id=2, home_goals=1, away_goals=1,
home_name="切尔西", away_name="热刺"), # 平局
_make_h2h_match(301, home_id=2, away_id=1, home_goals=0, away_goals=2,
home_name="热刺", away_name="切尔西"), # 切尔西客场 2-0 赢
]
import src.llm.context_builder as cb
orig = cb._get_h2h
async def mock_get_h2h(db, h, a, before, *, limit):
return matches
cb._get_h2h = mock_get_h2h
try:
result = await h2h_slice(header, limit=8, before=None)
text = str(result)
# 切尔西视角: 1胜(客场2-0) 1平(主场1-1) 0负
assert "1胜 1平 0负" in text, f"期望「1胜 1平 0负」,实际:\n{text}"
finally:
cb._get_h2h = orig