全量修复:预测系统正确性、安全性与部署问题
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:
@@ -0,0 +1,191 @@
|
||||
"""回归测试: P0-1 — form_slice / stats_slice 主客身份反转。
|
||||
|
||||
用 mock Match 对象验证:当某队在历史比赛中是「客队」时,
|
||||
form_slice 必须正确识别该队当时是客场,赛果应为 L(输),
|
||||
对手名字和进球数不能反转。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from src.llm.context_builder import MatchHeader, SliceResult, form_slice, stats_slice
|
||||
|
||||
|
||||
def _make_team(team_id: int, name: str) -> MagicMock:
|
||||
t = MagicMock()
|
||||
t.id = team_id
|
||||
t.name = name
|
||||
t.name_zh = None
|
||||
return t
|
||||
|
||||
|
||||
def _make_stats(
|
||||
home_xg=1.5,
|
||||
away_xg=1.0,
|
||||
home_shots=12,
|
||||
away_shots=8,
|
||||
home_sot=4,
|
||||
away_sot=3,
|
||||
home_poss=55.0,
|
||||
available_at=None,
|
||||
) -> MagicMock:
|
||||
s = MagicMock()
|
||||
s.home_xg = home_xg
|
||||
s.away_xg = away_xg
|
||||
s.home_shots = home_shots
|
||||
s.away_shots = away_shots
|
||||
s.home_shots_on_target = home_sot
|
||||
s.away_shots_on_target = away_sot
|
||||
s.home_possession = home_poss
|
||||
s.available_at = available_at
|
||||
return s
|
||||
|
||||
|
||||
def _make_match(
|
||||
match_id: int,
|
||||
home_team_id: int,
|
||||
away_team_id: int,
|
||||
home_goals: int,
|
||||
away_goals: int,
|
||||
home_name: str = "H",
|
||||
away_name: str = "A",
|
||||
stats=None,
|
||||
) -> MagicMock:
|
||||
m = MagicMock()
|
||||
m.id = match_id
|
||||
m.home_team_id = home_team_id
|
||||
m.away_team_id = away_team_id
|
||||
m.home_goals = home_goals
|
||||
m.away_goals = away_goals
|
||||
m.stats = stats
|
||||
m.home_team = _make_team(home_team_id, home_name)
|
||||
m.away_team = _make_team(away_team_id, away_name)
|
||||
return m
|
||||
|
||||
|
||||
def _make_header(home_id=1, away_id=2, home_name="利物浦", away_name="阿森纳") -> MatchHeader:
|
||||
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 TestFormSliceHomeAwayIdentity:
|
||||
"""P0-1: form_slice 必须根据每场历史比赛的真实主客来判断赛果。"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_home_team_away_loss_shows_L(self):
|
||||
"""
|
||||
场景: 本场利物浦是主队(home_id=1),历史上一场它作为客队 1-3 输给曼城。
|
||||
正确输出: L 3-1 vs 曼城 (赛果为输,对手为曼城)
|
||||
原bug: W 3-1 vs 曼城 (把客场输球算成主场赢球)
|
||||
"""
|
||||
header = _make_header(home_id=1, away_id=2, home_name="利物浦", away_name="阿森纳")
|
||||
hist_match = _make_match(
|
||||
match_id=100,
|
||||
home_team_id=5, # 曼城主场
|
||||
away_team_id=1, # 利物浦客场
|
||||
home_goals=3,
|
||||
away_goals=1,
|
||||
home_name="曼城",
|
||||
away_name="利物浦",
|
||||
)
|
||||
import src.llm.context_builder as cb
|
||||
orig_get_form = cb._get_form
|
||||
async def mock_get_form(db, team_id, before, *, limit):
|
||||
return [hist_match] if team_id == 1 else []
|
||||
cb._get_form = mock_get_form
|
||||
try:
|
||||
result = await form_slice(header, limit=5, before=None, db=MagicMock())
|
||||
finally:
|
||||
cb._get_form = orig_get_form
|
||||
|
||||
text = str(result)
|
||||
assert "L 3-1 vs 曼城" in text, f"期望「L 3-1 vs 曼城」,实际输出:\n{text}"
|
||||
assert "W 3-1" not in text, f"不应出现 W 3-1(客场输球不能算主场赢):\n{text}"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_away_team_home_win_shows_W_for_that_team(self):
|
||||
"""
|
||||
场景: 本场阿森纳是客队(away_id=2),历史上一场它作为主队 2-0 赢了切尔西。
|
||||
从阿森纳视角: is_home=True → W 2-0 vs 切尔西。
|
||||
原bug: side 固定为 "away" → _outcome(2,0,"away") = L → 输出 L 2-0 vs 切尔西(反转!)
|
||||
"""
|
||||
header = _make_header(home_id=1, away_id=2, home_name="利物浦", away_name="阿森纳")
|
||||
hist_match = _make_match(
|
||||
match_id=101,
|
||||
home_team_id=2, # 阿森纳主场
|
||||
away_team_id=4, # 切尔西客场
|
||||
home_goals=2,
|
||||
away_goals=0,
|
||||
home_name="阿森纳",
|
||||
away_name="切尔西",
|
||||
)
|
||||
import src.llm.context_builder as cb
|
||||
orig_get_form = cb._get_form
|
||||
async def mock_get_form(db, team_id, before, *, limit):
|
||||
return [hist_match] if team_id == 2 else []
|
||||
cb._get_form = mock_get_form
|
||||
try:
|
||||
result = await form_slice(header, limit=5, before=None, db=MagicMock())
|
||||
finally:
|
||||
cb._get_form = orig_get_form
|
||||
|
||||
text = str(result)
|
||||
assert "W 2-0 vs 切尔西" in text, f"期望「W 2-0 vs 切尔西」,实际输出:\n{text}"
|
||||
assert "L 2-0 vs 切尔西" not in text, f"不应出现 L 2-0(主场赢球不能算客场输):\n{text}"
|
||||
|
||||
|
||||
class TestStatsSliceHomeAwayIdentity:
|
||||
"""P0-1: stats_slice 进球/失球/xG 必须按历史比赛真实主客取值。"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_home_team_away_match_goals_not_swapped(self):
|
||||
"""
|
||||
场景: 本场利物浦是主队,历史上一场它作为客队 1-3 输给曼城(xG 0.8 vs 2.5)。
|
||||
从利物浦视角: 进球=1(away_goals), 失球=3(home_goals), xG=0.8(away_xg)。
|
||||
原bug: side="home" → 进球=3, 失球=1, xG=2.5 —— 全部反了!
|
||||
"""
|
||||
header = _make_header(home_id=1, away_id=2, home_name="利物浦", away_name="阿森纳")
|
||||
hist_match = _make_match(
|
||||
match_id=200,
|
||||
home_team_id=5, # 曼城主场
|
||||
away_team_id=1, # 利物浦客场
|
||||
home_goals=3,
|
||||
away_goals=1,
|
||||
home_name="曼城",
|
||||
away_name="利物浦",
|
||||
stats=_make_stats(home_xg=2.5, away_xg=0.8, home_shots=15, away_shots=5,
|
||||
home_sot=6, away_sot=2, home_poss=60.0),
|
||||
)
|
||||
import src.llm.context_builder as cb
|
||||
orig_get_form = cb._get_form
|
||||
async def mock_get_form(db, team_id, before, *, limit):
|
||||
return [hist_match] if team_id == 1 else []
|
||||
cb._get_form = mock_get_form
|
||||
try:
|
||||
result = await stats_slice(header, limit=10, before=None, db=MagicMock())
|
||||
finally:
|
||||
cb._get_form = orig_get_form
|
||||
|
||||
text = str(result)
|
||||
# 利物浦客场 1-3 输: 进球 1, 失球 3
|
||||
assert "场均进球 1.00" in text, f"期望场均进球 1.00,实际输出:\n{text}"
|
||||
assert "场均失球 3.00" in text, f"期望场均失球 3.00,实际输出:\n{text}"
|
||||
# 原bug: 进球 3, 失球 1 (反了)
|
||||
assert "场均进球 3.00" not in text, f"不应出现场均进球 3.00(反转):\n{text}"
|
||||
# xG: 利物浦 away_xg=0.8
|
||||
assert "场均 xG 0.80" in text, f"期望场均 xG 0.80,实际输出:\n{text}"
|
||||
# shots: 利物浦 away_shots=5
|
||||
assert "场均射门 5.0" in text, f"期望场均射门 5.0,实际输出:\n{text}"
|
||||
Reference in New Issue
Block a user