Files
Profeto/tests/test_p0_home_away.py
T
Profeto Agent bee330f31f 全量修复:预测系统正确性、安全性与部署问题
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
2026-09-19 06:43:55 +00:00

192 lines
6.8 KiB
Python

"""回归测试: 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}"