安全加固 + 数据管线修复 + 质量改进(第2批)
安全加固: - /api/v1/predict 限流改用 get_client_ip 防 X-Forwarded-For 伪造 - 新增 TRUST_PROXY_HEADERS/REQUIRE_ADMIN_AUTH 配置,默认 fail-closed - 生产环境管理接口未配置鉴权时拒绝(503),不再放行 数据管线修复: - injuries IntegrityError 改用 begin_nested(SAVEPOINT)隔离批次 - injuries 空名单 vs 未配置语义区分(has_data 精确标记) - bzzoiro 统计字段映射(shots/possession/cards) + 入库条件放宽 - available_at 语义收紧(回测防泄漏) - team_names NFKD 去变音双重查找 + 补齐变体键 预测系统改进: - multi-agent 全专家失败时 status=degraded 跳过终裁 - agent_weights 独立持久化到 predictions 表 新增迁移: - 0014_predictions_agent_weights.py 新增测试(8个文件): - test_ip_spoofing.py: 限流防伪造 - test_require_admin_fail_closed.py: 生产 fail-closed - test_injuries_integrity_rollback.py: SAVEPOINT 隔离 - test_injuries_slice_semantic.py: 空名单 vs 未配置 - test_available_at.py: 回测防泄漏 - test_bzzoirot_stats.py: 统计字段映射 - test_team_names_normalize.py: NFKD 变体 - test_multi_agent_degraded.py: 全失败 degraded - test_agent_weights_persist.py: 权重持久化
This commit is contained in:
@@ -0,0 +1,246 @@
|
||||
"""回归测试: multi-agent 全专家失败/无数据时 status=degraded。
|
||||
|
||||
验证:
|
||||
1. 5 个专家全 no_data/error → status="degraded",不调终裁
|
||||
2. 至少 1 个专家 ok → status="success",正常走终裁
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.llm.agents.base import AgentReport
|
||||
from src.llm.agents import orchestrator as orch_mod
|
||||
|
||||
|
||||
def _make_header():
|
||||
from src.llm.context_builder import MatchHeader
|
||||
return MatchHeader(
|
||||
match_id=999, home_name="A", away_name="B",
|
||||
league_name="X", season=None, match_date="?",
|
||||
match_dt=None, stage=None,
|
||||
home_team_id=1, away_team_id=2, league_id=1,
|
||||
)
|
||||
|
||||
|
||||
def _all_error_reports():
|
||||
"""5 个专家全 error."""
|
||||
return [
|
||||
AgentReport(agent="form", status="error", analysis="slice failed"),
|
||||
AgentReport(agent="stats", status="error", analysis="slice failed"),
|
||||
AgentReport(agent="home_away", status="error", analysis="slice failed"),
|
||||
AgentReport(agent="injuries", status="error", analysis="slice failed"),
|
||||
AgentReport(agent="h2h", status="error", analysis="slice failed"),
|
||||
]
|
||||
|
||||
|
||||
def _all_no_data_reports():
|
||||
"""5 个专家全 no_data."""
|
||||
return [
|
||||
AgentReport(agent="form", status="no_data", analysis="无数据"),
|
||||
AgentReport(agent="stats", status="no_data", analysis="无数据"),
|
||||
AgentReport(agent="home_away", status="no_data", analysis="无数据"),
|
||||
AgentReport(agent="injuries", status="no_data", analysis="无数据"),
|
||||
AgentReport(agent="h2h", status="no_data", analysis="无数据"),
|
||||
]
|
||||
|
||||
|
||||
def _mixed_reports():
|
||||
"""1 个 ok,4 个 error."""
|
||||
return [
|
||||
AgentReport(agent="form", status="ok", analysis="good"),
|
||||
AgentReport(agent="stats", status="error", analysis="failed"),
|
||||
AgentReport(agent="home_away", status="no_data", analysis="无数据"),
|
||||
AgentReport(agent="injuries", status="error", analysis="failed"),
|
||||
AgentReport(agent="h2h", status="no_data", analysis="无数据"),
|
||||
]
|
||||
|
||||
|
||||
class TestAllExpertsFailed:
|
||||
"""全部专家失败/无数据时,status 应为 degraded。"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_all_error_reports_yields_degraded(self):
|
||||
"""5 个专家全 error → status=degraded,不调终裁。"""
|
||||
header = _make_header()
|
||||
|
||||
# Mock run_specialists 返回全 error
|
||||
async def mock_run_specialists(header, *, version, before):
|
||||
return _all_error_reports()
|
||||
|
||||
# Mock _agent_provider
|
||||
async def mock_agent_provider(agent_id, *, tier):
|
||||
return MagicMock(model="test-model")
|
||||
|
||||
# Mock load_match_header
|
||||
async def mock_load_header(mid, db=None):
|
||||
return header
|
||||
|
||||
# Mock _upsert_prediction — 捕获写入的 status
|
||||
captured_status = {}
|
||||
|
||||
async def mock_upsert(session, **kw):
|
||||
captured_status.update(kw.get("values", {}))
|
||||
mock_pred = MagicMock()
|
||||
mock_pred.id = 1
|
||||
mock_pred.provider = "test"
|
||||
mock_pred.model = "test"
|
||||
mock_pred.prompt_version = "v1"
|
||||
mock_pred.pred_home_goals = None
|
||||
mock_pred.pred_away_goals = None
|
||||
mock_pred.alt_pred_home_goals = None
|
||||
mock_pred.alt_pred_away_goals = None
|
||||
mock_pred.pred_1x2 = None
|
||||
mock_pred.subjective_confidence = None
|
||||
mock_pred.reasoning = kw["values"].get("reasoning")
|
||||
mock_pred.agent_outputs = []
|
||||
return mock_pred
|
||||
|
||||
# Mock get_uow
|
||||
class FakeUow:
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
async def __aexit__(self, *a):
|
||||
pass
|
||||
async def get(self, cls, id):
|
||||
return MagicMock() # match exists
|
||||
|
||||
with patch.object(orch_mod, "run_specialists", mock_run_specialists), \
|
||||
patch.object(orch_mod, "_agent_provider", mock_agent_provider), \
|
||||
patch.object(orch_mod, "load_match_header", mock_load_header), \
|
||||
patch.object(orch_mod, "_upsert_prediction", mock_upsert), \
|
||||
patch.object(orch_mod, "get_uow", FakeUow):
|
||||
|
||||
result = await orch_mod.predict_match_multi(999)
|
||||
|
||||
# 断言:status 是 degraded,不是 success
|
||||
assert captured_status.get("status") == "degraded", \
|
||||
f"期望 status=degraded,实际 {captured_status.get('status')}"
|
||||
# 断言:reasoning 包含说明
|
||||
assert "专家" in captured_status.get("reasoning", ""), \
|
||||
f"reasoning 应说明原因,实际 {captured_status.get('reasoning')}"
|
||||
# 断言:pred_* 全为 None
|
||||
assert captured_status.get("pred_home_goals") is None
|
||||
assert captured_status.get("pred_1x2") is None
|
||||
print(f"PASS: 全 error → status={captured_status.get('status')}")
|
||||
print(f" reasoning={captured_status.get('reasoning')[:50]}...")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_all_no_data_reports_yields_degraded(self):
|
||||
"""5 个专家全 no_data → status=degraded,不调终裁。"""
|
||||
header = _make_header()
|
||||
|
||||
async def mock_run_specialists(header, *, version, before):
|
||||
return _all_no_data_reports()
|
||||
|
||||
async def mock_agent_provider(agent_id, *, tier):
|
||||
return MagicMock(model="test-model")
|
||||
|
||||
async def mock_load_header(mid, db=None):
|
||||
return header
|
||||
|
||||
captured_status = {}
|
||||
|
||||
async def mock_upsert(session, **kw):
|
||||
captured_status.update(kw.get("values", {}))
|
||||
mock_pred = MagicMock()
|
||||
mock_pred.id = 1
|
||||
mock_pred.provider = "test"
|
||||
mock_pred.model = "test"
|
||||
mock_pred.prompt_version = "v1"
|
||||
mock_pred.pred_home_goals = None
|
||||
mock_pred.pred_away_goals = None
|
||||
mock_pred.alt_pred_home_goals = None
|
||||
mock_pred.alt_pred_away_goals = None
|
||||
mock_pred.pred_1x2 = None
|
||||
mock_pred.subjective_confidence = None
|
||||
mock_pred.reasoning = kw["values"].get("reasoning")
|
||||
mock_pred.agent_outputs = []
|
||||
return mock_pred
|
||||
|
||||
class FakeUow:
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
async def __aexit__(self, *a):
|
||||
pass
|
||||
async def get(self, cls, id):
|
||||
return MagicMock()
|
||||
|
||||
with patch.object(orch_mod, "run_specialists", mock_run_specialists), \
|
||||
patch.object(orch_mod, "_agent_provider", mock_agent_provider), \
|
||||
patch.object(orch_mod, "load_match_header", mock_load_header), \
|
||||
patch.object(orch_mod, "_upsert_prediction", mock_upsert), \
|
||||
patch.object(orch_mod, "get_uow", FakeUow):
|
||||
|
||||
result = await orch_mod.predict_match_multi(999)
|
||||
|
||||
assert captured_status.get("status") == "degraded", \
|
||||
f"期望 status=degraded,实际 {captured_status.get('status')}"
|
||||
print(f"PASS: 全 no_data → status={captured_status.get('status')}")
|
||||
|
||||
|
||||
class TestPartialExpertsOk:
|
||||
"""部分专家 ok 时,status 仍可为 success。"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_one_ok_report_allows_success(self):
|
||||
"""1 个 ok + 4 个 error → status=success(走终裁)。"""
|
||||
header = _make_header()
|
||||
|
||||
async def mock_run_specialists(header, *, version, before):
|
||||
return _mixed_reports()
|
||||
|
||||
async def mock_agent_provider(agent_id, *, tier):
|
||||
return MagicMock(model="test-model")
|
||||
|
||||
async def mock_load_header(mid, db=None):
|
||||
return header
|
||||
|
||||
captured_status = {}
|
||||
|
||||
async def mock_aggregator(header, reports, *, provider, version):
|
||||
# 终裁返回合法 JSON
|
||||
return {
|
||||
"pred_home_goals": 2,
|
||||
"pred_away_goals": 1,
|
||||
"pred_1x2": "1",
|
||||
"subjective_confidence": 0.7,
|
||||
"reasoning": "test prediction",
|
||||
"agent_weights": {"form": 0.5},
|
||||
}, 100, 50
|
||||
|
||||
async def mock_upsert(session, **kw):
|
||||
captured_status.update(kw.get("values", {}))
|
||||
mock_pred = MagicMock()
|
||||
mock_pred.id = 1
|
||||
mock_pred.provider = "test"
|
||||
mock_pred.model = "test"
|
||||
mock_pred.prompt_version = "v1"
|
||||
mock_pred.pred_home_goals = 2
|
||||
mock_pred.pred_away_goals = 1
|
||||
mock_pred.pred_1x2 = "1"
|
||||
mock_pred.subjective_confidence = 0.7
|
||||
mock_pred.reasoning = "test"
|
||||
return mock_pred
|
||||
|
||||
class FakeUow:
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
async def __aexit__(self, *a):
|
||||
pass
|
||||
async def get(self, cls, id):
|
||||
return MagicMock()
|
||||
|
||||
with patch.object(orch_mod, "run_specialists", mock_run_specialists), \
|
||||
patch.object(orch_mod, "_agent_provider", mock_agent_provider), \
|
||||
patch.object(orch_mod, "load_match_header", mock_load_header), \
|
||||
patch.object(orch_mod, "_upsert_prediction", mock_upsert), \
|
||||
patch.object(orch_mod, "run_aggregator", mock_aggregator), \
|
||||
patch.object(orch_mod, "get_uow", FakeUow):
|
||||
|
||||
result = await orch_mod.predict_match_multi(999)
|
||||
|
||||
assert captured_status.get("status") == "success", \
|
||||
f"期望 status=success,实际 {captured_status.get('status')}"
|
||||
print(f"PASS: 1 ok + 4 error → status={captured_status.get('status')}")
|
||||
Reference in New Issue
Block a user