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:
shangfangjian
2026-09-21 23:29:09 +08:00
parent 45497d2112
commit 4b0d6ee58a
11 changed files with 627 additions and 420 deletions
+117 -32
View File
@@ -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