Files
Profeto/tests/test_multi_agent_degraded.py
shangfangjianandnew-provider/LongCat-2.0 < f05dc1ae15 refactor: 以 bzzoiro 为唯一数据源的全面重构
数据源统一为 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>>
2026-09-20 19:13:07 +08:00

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')}")