数据源统一为 bzzoiro,移除 Understat 与 injuries:
- 删除 src/data/understat.py / injuries.py 及相关测试
- 删除 injuries 模型与表;扩展 match_stats(xG 之外增加 big_chances/fouls)
- 新增 standings 表(联赛积分榜:位置/积分/xG/走势/分区)
- matches 表增加 source_event_id 血缘列,支撑统计回填
采集管线(bzzoiro 三条管线):
- events:赛程/比分(/events/),记录 source_event_id
- standings:积分榜快照(/leagues/{id}/standings/)
- stats:已完赛比赛详细统计回填(/events/{id}/stats/)
预测增强:
- standings_slice 替代 injuries_slice;积分榜专家替代阵容完整性专家
- AGENT_META runtime_config 同步更新
管理后台:
- ingest 路由重写为单一 bzzoiro 入口 + task 参数(events/standings/stats/all)
- 新增 /admin/data-completeness 数据完整性分析 API
- 数据源状态页简化为 bzzoiro 单源
前端:
- 采集页重构为任务驱动(比赛/积分榜/统计回填/全量)
- 新增「数据完整性」可视化页(覆盖率矩阵/字段完整率/健康摘要)
- 新增主站积分榜页(/standings)与比赛详情完整统计面板
- agent 名称同步更新(injuries→standings)
迁移 0015_bzzoiro_single_source 已在容器内验证通过,后端测试全部通过。
Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
301 lines
11 KiB
Python
301 lines
11 KiB
Python
"""回归测试: 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="standings", 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="standings", 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="standings", 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')}")
|
|
|
|
|
|
class TestNoAggregatorCallOnDegraded:
|
|
"""全专家失败时,aggregator provider 不应被调用。"""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_all_error_no_aggregator_call(self):
|
|
"""5 个专家全 error → 不应调用 _agent_provider('aggregator')。"""
|
|
header = _make_header()
|
|
aggregator_called = []
|
|
|
|
async def mock_run_specialists(header, *, version, before):
|
|
return _all_error_reports()
|
|
|
|
async def mock_agent_provider(agent_id, *, tier):
|
|
aggregator_called.append((agent_id, tier))
|
|
return MagicMock(model="test-model")
|
|
|
|
async def mock_load_header(mid, db=None):
|
|
return header
|
|
|
|
captured_values = {}
|
|
|
|
async def mock_upsert(session, **kw):
|
|
captured_values.update(kw.get("values", {}))
|
|
mock_pred = MagicMock()
|
|
mock_pred.id = 1
|
|
mock_pred.provider = "test"
|
|
mock_pred.model = kw["values"].get("model")
|
|
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):
|
|
|
|
await orch_mod.predict_match_multi(999)
|
|
|
|
# 断言:aggregator provider 未被调用
|
|
assert len(aggregator_called) == 0, \
|
|
f"全失败时不应调用 aggregator provider,实际调用: {aggregator_called}"
|
|
# 断言:model 使用 settings 默认值
|
|
assert captured_values.get("model") is not None
|
|
assert captured_values.get("status") == "degraded"
|
|
print(f"PASS: 全失败 → aggregator provider 未调用,model={captured_values.get('model')}")
|