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:
@@ -2,6 +2,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
@@ -40,6 +41,24 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
"多 worker 部署请将限流前置到 Nginx/网关,或以单 worker 运行"
|
||||
)
|
||||
|
||||
# P3-3:STRICT_SINGLE_WORKER 启动期强制校验,拒绝多 worker 静默配额漂移。
|
||||
# uvicorn 通过 --workers 传入;此处以环境变量 UVICORN_WORKERS 或启动参数判定。
|
||||
# 为避免耦合 uvicorn 内部,仅校验一个显式传入的标记:当 STRICT_SINGLE_WORKER=True 时,
|
||||
# 要求环境变量 UVICORN_WORKERS 不为空且 <=1,否则拒绝启动。
|
||||
if settings.STRICT_SINGLE_WORKER:
|
||||
workers = os.environ.get("UVICORN_WORKERS", "1")
|
||||
try:
|
||||
n_workers = int(workers)
|
||||
except ValueError:
|
||||
n_workers = 1
|
||||
if n_workers > 1:
|
||||
raise RuntimeError(
|
||||
f"STRICT_SINGLE_WORKER=True 但以 {n_workers} worker 启动会被拒绝 "
|
||||
f"(应用内限流/KeyRing 多 worker 下各自独立计数,配额放大 {n_workers} 倍)。"
|
||||
f"请前置 Nginx/网关全局限流后再启用多 worker,或保持单 worker。"
|
||||
)
|
||||
logger.info("STRICT_SINGLE_WORKER=True:已确认单 worker 启动,限流配额不会漂移")
|
||||
|
||||
# 注册默认定时任务(如果数据库中没有)
|
||||
from src.db.base import AsyncSessionLocal
|
||||
from sqlalchemy import select
|
||||
|
||||
@@ -64,11 +64,9 @@ async def predict(req: PredictRequest, request: Request):
|
||||
raise HTTPException(500, "预测失败,请查看服务器日志")
|
||||
|
||||
# D2: 三种模式统一返回 PredictResult —— 字段映射单一化,无 dict 分支。
|
||||
# 仅 baseline 的 prediction_id 需要在此落库补齐(服务层不落库)。
|
||||
if req.mode == "baseline":
|
||||
prediction_id = await _persist_baseline(req.match_id, result)
|
||||
else:
|
||||
prediction_id = result.prediction_id
|
||||
# P3-2:baseline 已在服务层(predict_baseline)落库并回填真实 prediction_id,
|
||||
# 路由层不再需要特殊的 _persist_baseline,与 single/multi 路径统一。
|
||||
prediction_id = result.prediction_id
|
||||
|
||||
# 3. 结果映射(无 DB 访问)
|
||||
logger.info(
|
||||
@@ -101,36 +99,6 @@ async def predict(req: PredictRequest, request: Request):
|
||||
)
|
||||
|
||||
|
||||
async def _persist_baseline(match_id: int, baseline: PredictResult) -> int:
|
||||
"""将基线预测结果写入 prediction 表,复用 upsert 语义。"""
|
||||
from src.db.unit_of_work import get_uow
|
||||
from src.llm.predict import _upsert_prediction
|
||||
|
||||
async with get_uow() as session:
|
||||
pred = await _upsert_prediction(
|
||||
session,
|
||||
match_id=match_id,
|
||||
provider_name="baseline",
|
||||
model="baseline",
|
||||
mode="baseline",
|
||||
run_type="live", # baseline 是 live 预测的变体,符合 ck_run_type_enum
|
||||
values={
|
||||
"prompt_version": baseline.prompt_version,
|
||||
"prompt_tokens": baseline.prompt_tokens or 0,
|
||||
"completion_tokens": baseline.completion_tokens or 0,
|
||||
"latency_ms": baseline.latency_ms or 0,
|
||||
"pred_home_goals": baseline.pred_home_goals,
|
||||
"pred_away_goals": baseline.pred_away_goals,
|
||||
"pred_1x2": baseline.pred_1x2,
|
||||
"subjective_confidence": baseline.subjective_confidence,
|
||||
"reasoning": baseline.reasoning,
|
||||
"raw_response": baseline.raw,
|
||||
"status": "success",
|
||||
},
|
||||
)
|
||||
return pred.id
|
||||
|
||||
|
||||
@router.get("/predictions", response_model=list[PredictionOut], dependencies=[Depends(require_admin)])
|
||||
async def list_predictions(
|
||||
match_id: int | None = None,
|
||||
|
||||
@@ -11,6 +11,10 @@ class Settings(BaseSettings):
|
||||
# --- app ---
|
||||
APP_ENV: str = "development"
|
||||
LOG_LEVEL: str = "INFO"
|
||||
# P3-3:多 worker 时应用内限流与 KeyRing 各自独立计数(配额放大 N 倍)。
|
||||
# 设为 True 时若以多 worker 启动 uvicorn 则拒绝启动,避免静默配额漂移。
|
||||
# 仅在你已前置 Nginx/网关做全局限流、确认不需要此守护时留空/False。
|
||||
STRICT_SINGLE_WORKER: bool = False
|
||||
# 生产环境强制要求管理鉴权配置,即使 APP_ENV=production 也生效。
|
||||
# True 时若 auth_configured() 为 False 则拒绝(503),development 保持 fail-open。
|
||||
REQUIRE_ADMIN_AUTH: bool = False
|
||||
|
||||
+42
-11
@@ -12,7 +12,7 @@ from sqlalchemy import case, func, select
|
||||
|
||||
from src.db.base import AsyncSession, AsyncSessionLocal
|
||||
from src.db.models import Match
|
||||
from src.llm.predict import PredictResult
|
||||
from src.llm.predict import PredictResult, _upsert_prediction
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -58,19 +58,23 @@ async def predict_baseline(
|
||||
|
||||
返回 PredictResult(D2 统一结果类型):
|
||||
provider=model="baseline", 不调用 LLM,latency_ms≈0。
|
||||
prediction_id 为占位 0 —— baseline 不在服务层落库,
|
||||
由路由层 _persist_baseline 落库后取得真实 id。
|
||||
|
||||
P3-2:baseline 落库下沉到服务层 —— 直接在服务层完成落库并回填真实
|
||||
prediction_id,路由层不再需要特殊的 _persist_baseline,与 single/multi
|
||||
路径统一(result.prediction_id 即可用)。对外 JSON 不变。
|
||||
"""
|
||||
from src.db.unit_of_work import get_uow
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
match = await db.get(Match, match_id)
|
||||
if match is None:
|
||||
raise ValueError(f"match {match_id} not found")
|
||||
|
||||
before = None
|
||||
if backtest and match.match_dt:
|
||||
if backtest and match.match_date:
|
||||
from datetime import timedelta
|
||||
|
||||
before = match.match_dt - timedelta(days=1)
|
||||
before = match.match_date - timedelta(days=1)
|
||||
elif cutoff_at is not None:
|
||||
before = cutoff_at
|
||||
|
||||
@@ -93,8 +97,38 @@ async def predict_baseline(
|
||||
else:
|
||||
pred_1x2 = "X"
|
||||
|
||||
values = {
|
||||
"prompt_version": "baseline_v1",
|
||||
"prompt_tokens": 0,
|
||||
"completion_tokens": 0,
|
||||
"latency_ms": 0,
|
||||
"pred_home_goals": float(pred_home),
|
||||
"pred_away_goals": float(pred_away),
|
||||
"pred_1x2": pred_1x2,
|
||||
"subjective_confidence": 0.5,
|
||||
"reasoning": (
|
||||
f"基线估计(非投注建议): 主队主场场均进球 {home_avg:.2f} → 预测 {pred_home}; "
|
||||
f"客队客场场均进球 {away_avg:.2f} → 预测 {pred_away}。"
|
||||
),
|
||||
"raw_response": {"home_avg": round(home_avg, 2), "away_avg": round(away_avg, 2)},
|
||||
"status": "success",
|
||||
}
|
||||
|
||||
# P3-2:服务层落库,回填真实 prediction_id(与 single/multi 统一)。
|
||||
async with get_uow() as session:
|
||||
pred = await _upsert_prediction(
|
||||
session,
|
||||
match_id=match_id,
|
||||
provider_name="baseline",
|
||||
model="baseline",
|
||||
mode="baseline",
|
||||
run_type="live",
|
||||
values=values,
|
||||
)
|
||||
prediction_id = pred.id
|
||||
|
||||
return PredictResult(
|
||||
prediction_id=0, # 占位:真实 id 由路由层 _persist_baseline 落库后返回
|
||||
prediction_id=prediction_id,
|
||||
provider="baseline",
|
||||
model="baseline",
|
||||
prompt_version="baseline_v1",
|
||||
@@ -105,14 +139,11 @@ async def predict_baseline(
|
||||
alt_pred_away_goals=None,
|
||||
pred_1x2=pred_1x2,
|
||||
subjective_confidence=0.5,
|
||||
reasoning=(
|
||||
f"基线估计(非投注建议): 主队主场场均进球 {home_avg:.2f} → 预测 {pred_home}; "
|
||||
f"客队客场场均进球 {away_avg:.2f} → 预测 {pred_away}。"
|
||||
),
|
||||
reasoning=values["reasoning"],
|
||||
context="", # baseline 不构建 LLM 上下文
|
||||
status="success",
|
||||
latency_ms=0,
|
||||
prompt_tokens=0,
|
||||
completion_tokens=0,
|
||||
raw={"home_avg": round(home_avg, 2), "away_avg": round(away_avg, 2)},
|
||||
raw=values["raw_response"],
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user