_upsert_prediction 改为 _insert_or_find_by_fingerprint: - 同 input_hash → 返回已有行(绝不 UPDATE pred_/reasoning/agent_outputs) - 不同 input_hash → INSERT 新行 input_hash 升级为规范 JSON SHA-256,捕获:match_id, cutoff, prompt_version, prompt_hash, system_prompt_hash, provider, model, mode, run_type, temperature, context_hash, agent_ids。移除旧 (match, provider, model, mode, run_type) 唯一约束, 改为 partial unique index(WHERE input_hash IS NOT NULL,兼容旧 NULL 数据)。 三条路径(single/multi/baseline)统一传足指纹字段。 迁移 0024 + 测试 test_p0_prediction_fingerprint(10/10);全量 295 通过。
158 lines
4.7 KiB
Python
158 lines
4.7 KiB
Python
"""测试极简基线预测:不调用 LLM,基于主客场场均进球估计,写入 prediction 表。
|
|
|
|
运行(需先 pip-sync requirements-dev.txt):
|
|
pytest tests/test_baseline.py -v
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from types import SimpleNamespace
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from src.llm.baseline import _avg_goals, predict_baseline
|
|
|
|
|
|
class _FakeUoW:
|
|
"""P3-2:baseline 在服务层落库,测试需 mock get_uow。"""
|
|
|
|
async def __aenter__(self):
|
|
return SimpleNamespace(
|
|
execute=lambda *a, **k: SimpleNamespace(scalar_one_or_none=lambda: None),
|
|
add=lambda *a, **k: None,
|
|
flush=lambda *a, **k: None,
|
|
)
|
|
|
|
async def __aexit__(self, *a):
|
|
return None
|
|
|
|
|
|
async def _fake_upsert(session, **kw):
|
|
return SimpleNamespace(id=1)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_avg_goals_no_data_returns_zero():
|
|
"""无历史数据时场均进球为 0(不抛异常)。"""
|
|
class FakeRow:
|
|
avg_goals = None
|
|
cnt = 0
|
|
|
|
class FakeResult:
|
|
def one(self):
|
|
return FakeRow()
|
|
|
|
class FakeSession:
|
|
async def execute(self, stmt):
|
|
return FakeResult()
|
|
|
|
avg = await _avg_goals(FakeSession(), team_id=1, side="home", league_id=1, before=None)
|
|
assert avg == 0.0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_avg_goals_with_data():
|
|
"""有数据时返回正确均值。"""
|
|
class FakeRow:
|
|
avg_goals = 1.5
|
|
cnt = 10
|
|
|
|
class FakeResult:
|
|
def one(self):
|
|
return FakeRow()
|
|
|
|
class FakeSession:
|
|
async def execute(self, stmt):
|
|
return FakeResult()
|
|
|
|
avg = await _avg_goals(FakeSession(), team_id=1, side="home", league_id=1, before=None)
|
|
assert avg == 1.5
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_predict_baseline_no_llm():
|
|
"""基线预测不调用 LLM(provider=model=baseline),latency_ms=0。"""
|
|
captured = {}
|
|
|
|
async def fake_avg(db, *, team_id, side, league_id, before):
|
|
captured[f"{side}_{team_id}"] = True
|
|
return 2.4 if side == "home" else 1.6
|
|
|
|
class FakeMatch:
|
|
id = 1
|
|
match_id = 1
|
|
home_team_id = 10
|
|
away_team_id = 20
|
|
league_id = 1
|
|
match_status = "scheduled"
|
|
|
|
with patch("src.llm.baseline._avg_goals", fake_avg), \
|
|
patch("src.llm.baseline.AsyncSessionLocal") as SLC, \
|
|
patch("src.db.unit_of_work.get_uow", _FakeUoW), \
|
|
patch("src.llm.baseline._insert_or_find_by_fingerprint", _fake_upsert):
|
|
class FakeSession:
|
|
async def get(self, cls, mid):
|
|
return FakeMatch()
|
|
class FakeCM:
|
|
async def __aenter__(self):
|
|
return FakeSession()
|
|
async def __aexit__(self, *a):
|
|
return None
|
|
SLC.return_value = FakeCM()
|
|
|
|
result = await predict_baseline(1)
|
|
|
|
assert result.provider == "baseline"
|
|
assert result.model == "baseline"
|
|
assert result.mode == "baseline"
|
|
assert result.latency_ms == 0
|
|
assert result.prompt_tokens == 0
|
|
assert result.completion_tokens == 0
|
|
# 2.4 → round = 2, 1.6 → round = 2 → 平局 X
|
|
assert result.pred_home_goals == 2.0
|
|
assert result.pred_away_goals == 2.0
|
|
assert result.pred_1x2 == "X"
|
|
assert result.subjective_confidence == 0.5
|
|
assert "非投注建议" in result.reasoning
|
|
# P3-2:服务层落库,回填真实 prediction_id
|
|
assert result.prediction_id == 1
|
|
# 确认未调用任何 LLM 相关模块
|
|
assert "home_10" in captured and "away_20" in captured
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_predict_baseline_clamps_to_range():
|
|
"""预测进球数裁剪到 [0, 10]。"""
|
|
async def fake_avg(db, *, team_id, side, league_id, before):
|
|
return 15.0 if side == "home" else -3.0
|
|
|
|
class FakeMatch:
|
|
id = 2
|
|
match_id = 2
|
|
home_team_id = 10
|
|
away_team_id = 20
|
|
league_id = 1
|
|
match_status = "scheduled"
|
|
|
|
with patch("src.llm.baseline._avg_goals", fake_avg), \
|
|
patch("src.llm.baseline.AsyncSessionLocal") as SLC, \
|
|
patch("src.db.unit_of_work.get_uow", _FakeUoW), \
|
|
patch("src.llm.baseline._insert_or_find_by_fingerprint", _fake_upsert):
|
|
class FakeSession:
|
|
async def get(self, cls, mid):
|
|
return FakeMatch()
|
|
class FakeCM:
|
|
async def __aenter__(self):
|
|
return FakeSession()
|
|
async def __aexit__(self, *a):
|
|
return None
|
|
SLC.return_value = FakeCM()
|
|
|
|
result = await predict_baseline(2)
|
|
|
|
assert result.pred_home_goals == 10.0 # clamped
|
|
assert result.pred_away_goals == 0.0 # clamped
|
|
assert result.pred_1x2 == "1" # 10:0 主胜
|
|
# P3-2:服务层落库,回填真实 prediction_id
|
|
assert result.prediction_id == 1
|