Files
Profeto/tests/test_agent_weights_persist.py
shangfangjian 06e973d34d fix(concurrency): 消除 model 覆盖的模块级变量中转(并发串味)
R4 首版实现用模块级 _ACTIVE_MODEL_OVERRIDE 中转 model override,
理由是不想改动 run_specialists 的签名(既有测试会 mock 它)。

但 backtest.py:225 会 asyncio.gather 并发 8 场预测(Semaphore(8)),
每场都调用 predict_match_multi —— 模块级变量会被并发调用互相覆盖,
导致 A 场的预测用上 B 场的模型。这是静默的正确性缺陷。

改为: run_specialists 新增 model_override 形参,一路显式下传;
删除模块级变量。同步更新 6 处 mock 签名。

新增 3 个守卫并做变异验证:
  - test_r4_no_module_level_model_override_global (源码级,可判别)
  - test_r4_run_specialists_accepts_model_override_parameter
  - test_r4_dispatch_passes_override_to_specialists
变异测试: 重新引入全局变量方案 → 两个守卫变红;还原 → 全绿。

注: 曾尝试写并发行为测试,但真实 run_specialists 会访问数据库,
测试环境下不稳定(ConnectionRefusedError),会是 flaky 的假证据,
故改用清晰的源码级判别守卫,并在注释中说明原因。

240 passed / 1 skipped; tsc 0 error; vite build 成功。
2026-09-21 17:52:45 +08:00

144 lines
5.4 KiB
Python

"""回归测试: predictions 表 agent_weights 独立持久化。
验证:
1. ORM 模型有 agent_weights 列(JSONB, 可空)
2. 迁移文件存在且可逆
3. orchestrator 写入 agent_weights
"""
from __future__ import annotations
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from src.db.models import Prediction
# 仓库根目录下的 alembic 迁移目录 —— 相对本测试文件解析,
# 避免硬编码某台机器/CI 上的绝对路径(见 tests/test_regressions.py 的 _read 约定)。
REPO_ROOT = Path(__file__).resolve().parent.parent
MIGRATION_PATH = REPO_ROOT / "alembic" / "versions" / "0014_predictions_agent_weights.py"
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):
assert MIGRATION_PATH.is_file(), f"迁移文件不存在: {MIGRATION_PATH}"
def test_migration_content(self):
content = MIGRATION_PATH.read_text(encoding="utf-8")
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, model_override=None):
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']}")