数据源统一为 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>>
142 lines
5.2 KiB
Python
142 lines
5.2 KiB
Python
"""回归测试: predictions 表 agent_weights 独立持久化。
|
|
|
|
验证:
|
|
1. ORM 模型有 agent_weights 列(JSONB, 可空)
|
|
2. 迁移文件存在且可逆
|
|
3. orchestrator 写入 agent_weights
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from src.db.models import Prediction
|
|
|
|
|
|
class TestAgentWeightsColumn:
|
|
"""验证 predictions 表有 agent_weights 列。"""
|
|
|
|
def test_orm_has_agent_weights_column(self):
|
|
"""ORM 模型应包含 agent_weights 列。"""
|
|
cols = {c.name: c for c in Prediction.__table__.columns}
|
|
assert "agent_weights" in cols, "predictions 表应有 agent_weights 列"
|
|
|
|
def test_agent_weights_is_jsonb(self):
|
|
"""agent_weights 应为 JSONB 类型。"""
|
|
col = Prediction.__table__.columns["agent_weights"]
|
|
# JSONB 类型检查
|
|
assert "JSON" in str(col.type).upper() or "JSONB" in str(col.type).upper()
|
|
|
|
def test_agent_weights_nullable(self):
|
|
"""agent_weights 应可空(旧行保持 NULL)。"""
|
|
col = Prediction.__table__.columns["agent_weights"]
|
|
assert col.nullable is True, "agent_weights 应可空"
|
|
|
|
|
|
class TestMigration:
|
|
"""验证迁移文件存在且内容正确。"""
|
|
|
|
def test_migration_exists(self):
|
|
import os
|
|
|
|
path = "/.octop/workspaces/CA7PFH/Profeto/alembic/versions/0014_predictions_agent_weights.py"
|
|
assert os.path.exists(path)
|
|
|
|
def test_migration_content(self):
|
|
path = "/.octop/workspaces/CA7PFH/Profeto/alembic/versions/0014_predictions_agent_weights.py"
|
|
content = open(path).read()
|
|
|
|
assert "agent_weights" in content
|
|
assert "upgrade" in content
|
|
assert "downgrade" in content
|
|
assert "downgrade" in content and "drop_column" in content
|
|
assert "nullable=True" in content
|
|
|
|
|
|
class TestOrchestratorWritesAgentWeights:
|
|
"""验证 orchestrator 写入 agent_weights。"""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_orchestrator_writes_agent_weights_to_upsert(self):
|
|
"""orchestrator 应将 agent_weights 传入 _upsert_prediction。"""
|
|
from src.llm.agents import orchestrator as orch_mod
|
|
from src.llm.agents.base import AgentReport
|
|
from src.llm.context_builder import MatchHeader
|
|
|
|
header = 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,
|
|
)
|
|
|
|
# 构造有效专家报告(至少 1 个 ok)
|
|
reports = [
|
|
AgentReport(agent="form", status="ok", analysis="good"),
|
|
AgentReport(agent="stats", status="error", analysis="failed"),
|
|
AgentReport(agent="home_away", status="ok", analysis="good"),
|
|
AgentReport(agent="standings", status="no_data", analysis="无数据"),
|
|
AgentReport(agent="h2h", status="error", analysis="failed"),
|
|
]
|
|
|
|
captured_values = {}
|
|
|
|
async def mock_specialists(h, *, version, before):
|
|
return reports
|
|
|
|
async def mock_provider(aid, **kw):
|
|
return MagicMock(model="test-model")
|
|
|
|
async def mock_header(mid, db=None):
|
|
return header
|
|
|
|
async def mock_aggregator(header, reports, *, provider, version):
|
|
return {
|
|
"pred_home_goals": 2,
|
|
"pred_away_goals": 1,
|
|
"pred_1x2": "1",
|
|
"subjective_confidence": 0.7,
|
|
"reasoning": "test",
|
|
"agent_weights": {"form": 0.3, "home_away": 0.5, "stats": 0.2},
|
|
}, 100, 50
|
|
|
|
async def mock_upsert(session, **kw):
|
|
captured_values.update(kw.get("values", {}))
|
|
p = MagicMock()
|
|
p.id = 1
|
|
p.provider = "test"
|
|
p.model = "test"
|
|
p.prompt_version = "v1"
|
|
p.pred_home_goals = 2
|
|
p.pred_away_goals = 1
|
|
p.pred_1x2 = "1"
|
|
p.subjective_confidence = 0.7
|
|
p.reasoning = "test"
|
|
p.agent_outputs = []
|
|
p.agent_weights = kw["values"].get("agent_weights")
|
|
return p
|
|
|
|
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_specialists), \
|
|
patch.object(orch_mod, "_agent_provider", mock_provider), \
|
|
patch.object(orch_mod, "load_match_header", mock_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)
|
|
|
|
# 断言 agent_weights 被写入
|
|
assert "agent_weights" in captured_values, "agent_weights 应传入 _upsert_prediction"
|
|
assert captured_values["agent_weights"] is not None, "agent_weights 不应为 None"
|
|
assert "form" in captured_values["agent_weights"], "agent_weights 应包含专家权重"
|
|
print(f"PASS: agent_weights = {captured_values['agent_weights']}")
|