全量修复:预测系统正确性、安全性与部署问题
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,114 @@
|
||||
"""回归测试: P0-3 — LLM 解析失败不能产生假成功预测。
|
||||
|
||||
验证链路:
|
||||
1. provider.py: JSON 解析失败时必须设置 error
|
||||
2. predict.py: resp.parsed is None 时必须抛错,不能 fallback 到 {}
|
||||
3. validation.py: 必填字段缺失时必须失败,不能静默给默认值
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from src.llm.provider import LLMProvider, LLMResponse
|
||||
from src.llm.validation import validate_prediction_output
|
||||
|
||||
|
||||
class TestProviderJsonParseError:
|
||||
"""P0-3 Part 1: provider.py JSON 解析失败必须设置 error。"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_json_sets_error(self, monkeypatch):
|
||||
"""LLM 返回非 JSON 内容时,error 必须非空。"""
|
||||
async def fake_post(*args, **kwargs):
|
||||
class FakeResp:
|
||||
status_code = 200
|
||||
def raise_for_status(self): pass
|
||||
def json(self):
|
||||
return {
|
||||
"choices": [{"message": {"content": "我不确定,可能是平局"}}],
|
||||
"usage": {"prompt_tokens": 10, "completion_tokens": 5},
|
||||
}
|
||||
return FakeResp()
|
||||
|
||||
import httpx
|
||||
monkeypatch.setattr(httpx.AsyncClient, "post", fake_post)
|
||||
|
||||
p = LLMProvider(api_key="test", model="gpt-4o")
|
||||
resp = await p.chat("sys", "user", json_mode=True)
|
||||
# P0-3: JSON 解析失败必须设置 error
|
||||
assert resp.error is not None, "JSON 解析失败应设置 error"
|
||||
assert resp.parsed is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_code_block_json_works(self, monkeypatch):
|
||||
"""LLM 返回 ```json {...}}``` 时应成功解析。"""
|
||||
async def fake_post(*args, **kwargs):
|
||||
class FakeResp:
|
||||
status_code = 200
|
||||
def raise_for_status(self): pass
|
||||
def json(self):
|
||||
return {
|
||||
"choices": [{"message": {"content": '```json\n{"pred_home_goals": 1.5, "pred_away_goals": 1.0, "pred_1x2": "1", "subjective_confidence": 0.7}\n```'}}],
|
||||
"usage": {"prompt_tokens": 10, "completion_tokens": 5},
|
||||
}
|
||||
return FakeResp()
|
||||
|
||||
import httpx
|
||||
monkeypatch.setattr(httpx.AsyncClient, "post", fake_post)
|
||||
|
||||
p = LLMProvider(api_key="test", model="gpt-4o")
|
||||
resp = await p.chat("sys", "user", json_mode=True)
|
||||
assert resp.error is None
|
||||
assert resp.parsed is not None
|
||||
assert resp.parsed["pred_1x2"] == "1"
|
||||
|
||||
|
||||
class TestValidationNoSilentDefaults:
|
||||
"""P0-3 Part 3: validation.py 必填字段缺失时必须失败。"""
|
||||
|
||||
def test_missing_pred_home_goals_raises(self):
|
||||
"""缺少 pred_home_goals 必须报错,不能默认为 0。"""
|
||||
with pytest.raises((ValueError, KeyError)):
|
||||
validate_prediction_output({
|
||||
"pred_away_goals": 1,
|
||||
"pred_1x2": "1",
|
||||
"subjective_confidence": 0.7,
|
||||
})
|
||||
|
||||
def test_missing_pred_1x2_raises(self):
|
||||
"""缺少 pred_1x2 必须报错,不能默认为 X。"""
|
||||
with pytest.raises(ValueError, match="Missing required field: pred_1x2"):
|
||||
validate_prediction_output({
|
||||
"pred_home_goals": 1,
|
||||
"pred_away_goals": 0,
|
||||
"subjective_confidence": 0.7,
|
||||
})
|
||||
|
||||
def test_missing_confidence_raises(self):
|
||||
"""缺少 subjective_confidence 必须报错,不能默认为 0.5。"""
|
||||
with pytest.raises(ValueError, match="Missing required field: subjective_confidence"):
|
||||
validate_prediction_output({
|
||||
"pred_home_goals": 1,
|
||||
"pred_away_goals": 0,
|
||||
"pred_1x2": "1",
|
||||
})
|
||||
|
||||
def test_empty_dict_raises(self):
|
||||
"""空 dict 必须报错(不能产生 0-0 X 0.5 的假预测)。"""
|
||||
with pytest.raises((ValueError, KeyError)):
|
||||
validate_prediction_output({})
|
||||
|
||||
def test_valid_input_passes(self):
|
||||
"""完整的合法输入应通过。"""
|
||||
result = validate_prediction_output({
|
||||
"pred_home_goals": 1.5,
|
||||
"pred_away_goals": 1.0,
|
||||
"pred_1x2": "1",
|
||||
"subjective_confidence": 0.7,
|
||||
})
|
||||
assert result.pred_home_goals == 2 # 1.5 → round → 2
|
||||
assert result.pred_away_goals == 1
|
||||
assert result.pred_1x2 == "1"
|
||||
assert result.subjective_confidence == 0.7
|
||||
Reference in New Issue
Block a user