全量修复:预测系统正确性、安全性与部署问题
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,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
|
||||
Reference in New Issue
Block a user