全量修复:预测系统正确性、安全性与部署问题
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,227 @@
|
||||
"""回归测试: multi-agent 预测路径 backtest cutoff / provider / model 透传。
|
||||
|
||||
验证:
|
||||
1. predict_match_multi 正确计算并传递 cutoff
|
||||
2. cutoff 贯穿到所有 5 个专家切片
|
||||
3. prediction_cutoff_at 记录的是真正的 cutoff,而非 match_dt
|
||||
4. backtest=True 时「赛后才 available 的 xG」不会出现在切片里
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from src.llm.context_builder import MatchHeader
|
||||
|
||||
|
||||
def _make_header(match_dt=None) -> MatchHeader:
|
||||
from datetime import datetime, timezone
|
||||
if match_dt is None:
|
||||
match_dt = datetime(2026, 1, 15, 20, 0, tzinfo=timezone.utc)
|
||||
return MatchHeader(
|
||||
match_id=999, home_name="利物浦", away_name="阿森纳",
|
||||
league_name="英超", season="2025-2026",
|
||||
match_date="2026-01-15 20:00 UTC",
|
||||
match_dt=match_dt, stage=None,
|
||||
home_team_id=1, away_team_id=2, league_id=1,
|
||||
)
|
||||
|
||||
|
||||
class TestMultiAgentCutoffPropagation:
|
||||
"""验证 cutoff 在 multi-agent 路径中正确计算和传递。"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_backtest_computes_cutoff_from_match_dt_minus_1_day(self):
|
||||
"""backtest=True → cutoff = match_dt - 1 天,传给所有切片。"""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import src.llm.agents.orchestrator as orch
|
||||
|
||||
match_dt = datetime(2026, 1, 15, 20, 0, tzinfo=timezone.utc)
|
||||
header = _make_header(match_dt)
|
||||
|
||||
captured_before = []
|
||||
orig_run_specialists = orch.run_specialists
|
||||
|
||||
async def mock_run_specialists(header, *, version, before=None):
|
||||
captured_before.append(before)
|
||||
return []
|
||||
|
||||
orch.run_specialists = mock_run_specialists
|
||||
orch.load_match_header = lambda mid, db=None: header
|
||||
orch._agent_provider = lambda agent_id, **kw: MagicMock(model="test")
|
||||
|
||||
try:
|
||||
try:
|
||||
await orch.predict_match_multi(999, backtest=True)
|
||||
except Exception:
|
||||
pass # 后续 aggregator 调用会因 mock 不全而失败,不影响 cutoff 测试
|
||||
|
||||
assert len(captured_before) == 1
|
||||
expected_cutoff = match_dt - timedelta(days=1)
|
||||
assert captured_before[0] == expected_cutoff, (
|
||||
f"backtest cutoff 应为 {expected_cutoff},实际 {captured_before[0]}"
|
||||
)
|
||||
finally:
|
||||
orch.run_specialists = orig_run_specialists
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_explicit_cutoff_at_overrides_backtest(self):
|
||||
"""显式 cutoff_at 优先于 backtest 自动计算。"""
|
||||
from datetime import datetime, timezone
|
||||
import src.llm.agents.orchestrator as orch
|
||||
|
||||
match_dt = datetime(2026, 1, 15, 20, 0, tzinfo=timezone.utc)
|
||||
explicit_cutoff = datetime(2026, 1, 10, 12, 0, tzinfo=timezone.utc)
|
||||
header = _make_header(match_dt)
|
||||
|
||||
captured_before = []
|
||||
orig_run_specialists = orch.run_specialists
|
||||
|
||||
async def mock_run_specialists(header, *, version, before=None):
|
||||
captured_before.append(before)
|
||||
return []
|
||||
|
||||
orch.run_specialists = mock_run_specialists
|
||||
orch.load_match_header = lambda mid, db=None: header
|
||||
orch._agent_provider = lambda agent_id, **kw: MagicMock(model="test")
|
||||
|
||||
try:
|
||||
try:
|
||||
await orch.predict_match_multi(999, backtest=True, cutoff_at=explicit_cutoff)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
assert captured_before[0] == explicit_cutoff
|
||||
finally:
|
||||
orch.run_specialists = orig_run_specialists
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_normal_mode_cutoff_is_match_dt(self):
|
||||
"""非回测模式,无显式 cutoff → cutoff = match_dt。"""
|
||||
from datetime import datetime, timezone
|
||||
import src.llm.agents.orchestrator as orch
|
||||
|
||||
match_dt = datetime(2026, 1, 15, 20, 0, tzinfo=timezone.utc)
|
||||
header = _make_header(match_dt)
|
||||
|
||||
captured_before = []
|
||||
orig_run_specialists = orch.run_specialists
|
||||
|
||||
async def mock_run_specialists(header, *, version, before=None):
|
||||
captured_before.append(before)
|
||||
return []
|
||||
|
||||
orch.run_specialists = mock_run_specialists
|
||||
orch.load_match_header = lambda mid, db=None: header
|
||||
orch._agent_provider = lambda agent_id, **kw: MagicMock(model="test")
|
||||
|
||||
try:
|
||||
try:
|
||||
await orch.predict_match_multi(999, backtest=False)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
assert captured_before[0] == match_dt
|
||||
finally:
|
||||
orch.run_specialists = orig_run_specialists
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prediction_cutoff_at_stored_not_match_dt(self):
|
||||
"""Prediction 写入时 prediction_cutoff_at = 真正 cutoff,非 match_dt。"""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from src.llm.predict import _predict_single, PredictResult
|
||||
|
||||
match_dt = datetime(2026, 1, 15, 20, 0, tzinfo=timezone.utc)
|
||||
expected_cutoff = match_dt - timedelta(days=1)
|
||||
|
||||
# Mock build_context to return a context with cutoff
|
||||
import src.llm.predict as pred
|
||||
orig_build = pred.build_context
|
||||
|
||||
class FakeContext:
|
||||
text = "fake"
|
||||
match_dt = match_dt
|
||||
cutoff = expected_cutoff
|
||||
|
||||
async def fake_build(match_id, **kw):
|
||||
return FakeContext()
|
||||
|
||||
pred.build_context = fake_build
|
||||
pred._upsert_prediction = lambda session, **kw: MagicMock(id=1, **kw.get("values", {}))
|
||||
|
||||
try:
|
||||
# 此处只验证 cutoff 参数传递,实际 LLM 调用会被 mock 阻断
|
||||
# 重点: build_context 被调用时传入 backtest=True 和正确的 cutoff
|
||||
call_args = {}
|
||||
async def tracking_build(match_id, **kw):
|
||||
call_args.update(kw)
|
||||
return FakeContext()
|
||||
pred.build_context = tracking_build
|
||||
|
||||
try:
|
||||
await _predict_single(999, backtest=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
assert call_args.get("backtest") is True, "backtest=True 应传递给 build_context"
|
||||
finally:
|
||||
pred.build_context = orig_build
|
||||
|
||||
|
||||
class TestBacktestXgNotVisible:
|
||||
"""P0-3 延伸:回测时赛后才 available 的统计数据不应出现在切片。"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stats_slice_respects_cutoff_for_xg_availability(self):
|
||||
"""available_at > cutoff 的 xG 数据不应被切片使用。"""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
cutoff = datetime(2026, 1, 13, 20, 0, tzinfo=timezone.utc) # match_date - 2天
|
||||
match_dt = datetime(2026, 1, 15, 20, 0, tzinfo=timezone.utc)
|
||||
|
||||
# 创建一场历史比赛,其 xG 在 match_date 之后才 available
|
||||
hist_match = MagicMock()
|
||||
hist_match.id = 500
|
||||
hist_match.home_team_id = 1 # 利物浦主场
|
||||
hist_match.away_team_id = 3
|
||||
hist_match.home_goals = 2
|
||||
hist_match.away_goals = 0
|
||||
hist_match.home_team = MagicMock(id=1, name="利物浦", name_zh=None)
|
||||
hist_match.away_team = MagicMock(id=3, name="诺维奇", name_zh=None)
|
||||
|
||||
# xG: available_at 在比赛日之后(1月16日),cutoff(1月13日)看不到
|
||||
stats = MagicMock()
|
||||
stats.home_xg = 2.5
|
||||
stats.away_xg = 0.3
|
||||
stats.home_shots = 15
|
||||
stats.away_shots = 4
|
||||
stats.home_shots_on_target = 6
|
||||
stats.away_shots_on_target = 1
|
||||
stats.home_possession = 65.0
|
||||
stats.available_at = datetime(2026, 1, 16, 10, 0, tzinfo=timezone.utc) # 赛后才有
|
||||
hist_match.stats = stats
|
||||
|
||||
header = _make_header(match_dt)
|
||||
|
||||
import src.llm.context_builder as cb
|
||||
orig_get_form = cb._get_form
|
||||
|
||||
async def mock_get_form(db, team_id, before, *, limit=10):
|
||||
# before=cutoff(1月13日),比赛在1月15日,满足 before 条件
|
||||
if before is not None and before < match_dt:
|
||||
return [hist_match]
|
||||
return []
|
||||
|
||||
cb._get_form = mock_get_form
|
||||
|
||||
try:
|
||||
result = await cb.stats_slice(header, limit=10, before=cutoff)
|
||||
text = str(result)
|
||||
# xG 在 cutoff 之后才 available,不应出现在切片
|
||||
assert "2.50" not in text, f"xG 2.50 不应在切片中(available_at > cutoff):\n{text}"
|
||||
# 但无比分时仍应显示进球数据
|
||||
assert "无比分数据" in text or "场均进球" in text, f"无比分时仍应显示基本数据:\n{text}"
|
||||
finally:
|
||||
cb._get_form = orig_get_form
|
||||
Reference in New Issue
Block a user