chore(P3): baseline 落库下沉 + MatchPredictPanel 拆分 + 多 worker/CSRF 文档
P3-2 baseline 落库从路由下沉到服务层(predict_baseline 内直接落库),
删除路由层 _persist_baseline,三种模式统一 result.prediction_id,对外 JSON 不变。
P3-1 MatchPredictPanel.PredictionPanel 拆为 OutcomePanel/AgentsPanel/ReasoningPanel
三个子组件,本文件保留 PredictModal/PredictProgress/Spinner,对外导出路径不变。
P3-3 docs 加 ⚠️ 多 worker 陷阱红字 + STRICT_SINGLE_WORKER 环境变量(启动期强制拒绝多 worker)。
P3-4 docs 新增「同站部署 vs 跨站 CSRF」节。
This commit is contained in:
+29
-2
@@ -5,6 +5,7 @@
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
@@ -12,6 +13,24 @@ 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(不抛异常)。"""
|
||||
@@ -68,7 +87,9 @@ async def test_predict_baseline_no_llm():
|
||||
match_status = "scheduled"
|
||||
|
||||
with patch("src.llm.baseline._avg_goals", fake_avg), \
|
||||
patch("src.llm.baseline.AsyncSessionLocal") as SLC:
|
||||
patch("src.llm.baseline.AsyncSessionLocal") as SLC, \
|
||||
patch("src.db.unit_of_work.get_uow", _FakeUoW), \
|
||||
patch("src.llm.baseline._upsert_prediction", _fake_upsert):
|
||||
class FakeSession:
|
||||
async def get(self, cls, mid):
|
||||
return FakeMatch()
|
||||
@@ -93,6 +114,8 @@ async def test_predict_baseline_no_llm():
|
||||
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
|
||||
|
||||
@@ -112,7 +135,9 @@ async def test_predict_baseline_clamps_to_range():
|
||||
match_status = "scheduled"
|
||||
|
||||
with patch("src.llm.baseline._avg_goals", fake_avg), \
|
||||
patch("src.llm.baseline.AsyncSessionLocal") as SLC:
|
||||
patch("src.llm.baseline.AsyncSessionLocal") as SLC, \
|
||||
patch("src.db.unit_of_work.get_uow", _FakeUoW), \
|
||||
patch("src.llm.baseline._upsert_prediction", _fake_upsert):
|
||||
class FakeSession:
|
||||
async def get(self, cls, mid):
|
||||
return FakeMatch()
|
||||
@@ -128,3 +153,5 @@ async def test_predict_baseline_clamps_to_range():
|
||||
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
|
||||
|
||||
@@ -62,12 +62,35 @@ async def test_predict_baseline_returns_predict_result():
|
||||
async def __aexit__(self, *a):
|
||||
return None
|
||||
|
||||
# P3-2:baseline 在服务层落库(get_uow + _upsert_prediction),需 mock 掉。
|
||||
class FakeUoW:
|
||||
async def __aenter__(self):
|
||||
return _make_session()
|
||||
|
||||
async def __aexit__(self, *a):
|
||||
return None
|
||||
|
||||
captured = {}
|
||||
|
||||
async def fake_upsert(session, **kw):
|
||||
captured.update(kw)
|
||||
return SimpleNamespace(id=77)
|
||||
|
||||
# baseline.py 内部 from-import get_uow / _upsert_prediction,需 patch 真实来源模块。
|
||||
with patch("src.llm.baseline._avg_goals", fake_avg), \
|
||||
patch("src.llm.baseline.AsyncSessionLocal") as SLC:
|
||||
patch("src.llm.baseline.AsyncSessionLocal") as SLC, \
|
||||
patch("src.db.unit_of_work.get_uow", FakeUoW), \
|
||||
patch("src.llm.baseline._upsert_prediction", fake_upsert):
|
||||
SLC.return_value = FakeCM()
|
||||
|
||||
result = await predict_baseline(1)
|
||||
|
||||
# P3-2:验证服务层落库被调用且属性映射正确
|
||||
assert captured["match_id"] == 1
|
||||
assert captured["provider_name"] == "baseline"
|
||||
assert captured["run_type"] == "live"
|
||||
assert captured["values"]["pred_home_goals"] == 2.0
|
||||
|
||||
assert isinstance(result, PredictResult)
|
||||
assert result.mode == "baseline"
|
||||
assert result.provider == "baseline"
|
||||
@@ -144,15 +167,31 @@ def test_predict_route_has_no_dict_branch():
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 4. _persist_baseline 属性映射(baseline 落库语义不变)
|
||||
# 4. P3-2:baseline 服务层落库属性映射(落库已从路由移到 baseline.py)
|
||||
# ============================================================
|
||||
|
||||
|
||||
class _FakeResult:
|
||||
"""支持 .scalar_one_or_none() 的最小假结果集。"""
|
||||
|
||||
def __init__(self, items):
|
||||
self._items = list(items)
|
||||
|
||||
def scalars(self):
|
||||
return self
|
||||
|
||||
def all(self):
|
||||
return self._items
|
||||
|
||||
def scalar_one_or_none(self):
|
||||
return self._items[0] if self._items else None
|
||||
|
||||
|
||||
class _FakeUoW:
|
||||
"""替代 get_uow 的最小上下文管理器。"""
|
||||
"""替代 get_uow 的最小上下文管理器(session.execute 是 async 的)。"""
|
||||
|
||||
def __init__(self):
|
||||
self.session = SimpleNamespace()
|
||||
self.session = _make_session()
|
||||
|
||||
async def __aenter__(self):
|
||||
return self.session
|
||||
@@ -160,44 +199,67 @@ class _FakeUoW:
|
||||
async def __aexit__(self, *a):
|
||||
return None
|
||||
|
||||
def __call__(self):
|
||||
return self
|
||||
|
||||
|
||||
def _make_session(existing=None):
|
||||
"""构造带 async execute / add / flush 的假 session。"""
|
||||
sess = SimpleNamespace()
|
||||
|
||||
async def execute(*a, **k):
|
||||
return _FakeResult(existing or [])
|
||||
|
||||
sess.execute = execute
|
||||
sess.add = lambda *a, **k: None
|
||||
|
||||
async def flush(*a, **k):
|
||||
return None
|
||||
|
||||
sess.flush = flush
|
||||
return sess
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_persist_baseline_maps_attributes(monkeypatch):
|
||||
async def test_baseline_service_persists_with_correct_attributes(monkeypatch):
|
||||
"""P3-2:baseline 在服务层(predict_baseline)落库,属性映射与路由旧版一致。"""
|
||||
captured = {}
|
||||
|
||||
async def fake_upsert(session, **kwargs):
|
||||
captured.update(kwargs)
|
||||
return SimpleNamespace(id=77)
|
||||
|
||||
class FakeMatch:
|
||||
id = 1
|
||||
home_team_id = 10
|
||||
away_team_id = 20
|
||||
league_id = 1
|
||||
match_status = "scheduled"
|
||||
|
||||
class FakeSession:
|
||||
async def get(self, cls, mid):
|
||||
return FakeMatch()
|
||||
|
||||
class FakeSLC:
|
||||
async def __aenter__(self):
|
||||
return FakeSession()
|
||||
|
||||
async def __aexit__(self, *a):
|
||||
return None
|
||||
|
||||
async def fake_avg(db, *, team_id, side, league_id, before):
|
||||
return 2.0 if side == "home" else 1.0
|
||||
|
||||
monkeypatch.setattr("src.llm.baseline._avg_goals", fake_avg)
|
||||
monkeypatch.setattr("src.llm.baseline.AsyncSessionLocal", FakeSLC)
|
||||
monkeypatch.setattr("src.db.unit_of_work.get_uow", lambda: _FakeUoW())
|
||||
monkeypatch.setattr("src.llm.predict._upsert_prediction", fake_upsert)
|
||||
# baseline.py 模块级 import _upsert_prediction(第 15 行),需 patch baseline 模块属性
|
||||
monkeypatch.setattr("src.llm.baseline._upsert_prediction", fake_upsert)
|
||||
|
||||
from src.api.routes.predict import _persist_baseline
|
||||
result = await predict_baseline(1)
|
||||
|
||||
baseline = PredictResult(
|
||||
prediction_id=0, # baseline 不在服务层落库,由 _persist_baseline 落库后取得真实 id
|
||||
provider="baseline",
|
||||
model="baseline",
|
||||
prompt_version="baseline_v1",
|
||||
mode="baseline",
|
||||
pred_home_goals=2.0,
|
||||
pred_away_goals=1.0,
|
||||
alt_pred_home_goals=None,
|
||||
alt_pred_away_goals=None,
|
||||
pred_1x2="1",
|
||||
subjective_confidence=0.5,
|
||||
reasoning="r",
|
||||
context="",
|
||||
status="success",
|
||||
latency_ms=0,
|
||||
prompt_tokens=0,
|
||||
completion_tokens=0,
|
||||
raw={"home_avg": 2.1, "away_avg": 1.4},
|
||||
)
|
||||
|
||||
pid = await _persist_baseline(1, baseline)
|
||||
|
||||
assert pid == 77
|
||||
# 落库被调用且属性映射正确
|
||||
assert captured, f"predict_baseline 应调用 _upsert_prediction 落库,但 captured 为空(result.prediction_id={result.prediction_id!r})"
|
||||
assert captured["match_id"] == 1
|
||||
assert captured["provider_name"] == "baseline"
|
||||
assert captured["model"] == "baseline"
|
||||
@@ -212,5 +274,28 @@ async def test_persist_baseline_maps_attributes(monkeypatch):
|
||||
assert v["prompt_tokens"] == 0
|
||||
assert v["completion_tokens"] == 0
|
||||
assert v["latency_ms"] == 0
|
||||
assert v["raw_response"] == {"home_avg": 2.1, "away_avg": 1.4}
|
||||
assert v["raw_response"] == {"home_avg": 2.0, "away_avg": 1.0}
|
||||
assert v["status"] == "success"
|
||||
|
||||
# 回填真实 prediction_id(服务层落库后取得)
|
||||
assert result.prediction_id == 77
|
||||
assert result.pred_1x2 == "1"
|
||||
assert captured["match_id"] == 1
|
||||
assert captured["provider_name"] == "baseline"
|
||||
assert captured["model"] == "baseline"
|
||||
assert captured["mode"] == "baseline"
|
||||
assert captured["run_type"] == "live"
|
||||
v = captured["values"]
|
||||
assert v["prompt_version"] == "baseline_v1"
|
||||
assert v["pred_home_goals"] == 2.0
|
||||
assert v["pred_away_goals"] == 1.0
|
||||
assert v["pred_1x2"] == "1"
|
||||
assert v["subjective_confidence"] == 0.5
|
||||
assert v["prompt_tokens"] == 0
|
||||
assert v["completion_tokens"] == 0
|
||||
assert v["latency_ms"] == 0
|
||||
assert v["raw_response"] == {"home_avg": 2.0, "away_avg": 1.0}
|
||||
assert v["status"] == "success"
|
||||
|
||||
# 回填真实 prediction_id(服务层落库后取得)
|
||||
assert result.prediction_id == 77
|
||||
|
||||
Reference in New Issue
Block a user