全量修复:预测系统正确性、安全性与部署问题
P0 严重问题修复: - 修复 form_slice/stats_slice 主客身份反转(历史比赛视角错误) - 修复 understat.py httpx 未导入导致的 NameError - 修复 LLM 解析失败时静默产生假成功预测(0-0 平局+置信度0.5) 预测路径修复: - multi-agent 路径增加 backtest cutoff 透传,回测防泄漏生效 - H2H 切片汇总统计改为从当前主队视角计数 - 预测唯一约束增加 mode+run_type 维度,防止回测覆盖实盘预测 伤停管线修复: - IntegrityError 后不再整批回滚丢数据(改用逐条 flush) - return_date 正确解析并写入 - retrieved_at 比较统一用 date() 避免当天数据不可见 - 唯一索引改为 partial unique index(排除 NULL 重复) - HTTP 缓存 TTL 从 7 天改为 6 小时 安全与连接管理: - /api/v1/predict 增加内存滑动窗口限流(10次/分钟/IP) - 预测路由改用短 session 模式,LLM 调用期间不持有 DB 连接 Docker 部署修复: - 修复 .dockerignore 排除 *.md 导致 COPY README.md 失败 - 容器内 DATABASE_URL 使用 postgres 服务名(非 localhost) - 启动时自动执行 alembic upgrade head - 前端改用多阶段构建(Dockerfile.frontend) 新增测试(5个文件,24+用例): - test_p0_home_away.py: 主客身份反转回归测试 - test_p0_parse_failure.py: LLM 解析失败回归测试 - test_multi_agent_cutoff.py: multi-agent cutoff 透传测试 - test_h2h_perspective.py: H2H 视角测试 - test_injuries_pipeline.py: 伤停管线 5 项修复测试 - test_predict_protection.py: 限流+短 session 测试 - test_prediction_unique_constraint.py: 唯一约束测试 迁移: - 0012_injuries_partial_unique_and_return_date.py - 0013_predictions_unique_constraint_mode_run_type.py
This commit is contained in:
+29
-7
@@ -112,11 +112,13 @@ async def _upsert_prediction(
|
||||
provider_name: str,
|
||||
model: str,
|
||||
mode: str,
|
||||
run_type: str,
|
||||
values: dict,
|
||||
) -> Prediction:
|
||||
"""按 (match, provider, model) 唯一约束写入预测。
|
||||
"""按 (match, provider, model, mode, run_type) 唯一约束写入预测。
|
||||
|
||||
已存在且未结算 → 覆盖更新(重新预测语义);已结算 → 拒绝(保护评估数据)。
|
||||
run_type 区分 live/backtest,避免回测覆盖实盘预测。
|
||||
"""
|
||||
existing = (
|
||||
await session.execute(
|
||||
@@ -124,6 +126,8 @@ async def _upsert_prediction(
|
||||
Prediction.match_id == match_id,
|
||||
Prediction.provider == provider_name,
|
||||
Prediction.model == model,
|
||||
Prediction.mode == mode,
|
||||
Prediction.run_type == run_type,
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
@@ -134,6 +138,7 @@ async def _upsert_prediction(
|
||||
match_id=match_id, provider=provider_name, model=model,
|
||||
)
|
||||
pred.mode = mode
|
||||
pred.run_type = run_type
|
||||
for k, v in values.items():
|
||||
setattr(pred, k, v)
|
||||
if existing is None:
|
||||
@@ -151,6 +156,7 @@ async def predict_match(
|
||||
mode: str = "multi",
|
||||
use_cache: bool = True,
|
||||
backtest: bool = False,
|
||||
cutoff_at=None,
|
||||
) -> "PredictResult | MultiPredictResult":
|
||||
"""预测入口。mode=multi(默认)走多 agent;mode=single 走单次调用。
|
||||
|
||||
@@ -158,7 +164,8 @@ async def predict_match(
|
||||
use_cache: 是否允许返回进程内缓存结果。回测必须传 False——
|
||||
缓存命中不会新建 prediction 行,调用方会对同一个 prediction_id
|
||||
反复 settle,把不同比赛的真实比分覆盖到同一条记录上。
|
||||
backtest: 是否回测模式。True 时 build_context 使用 match_date-1天 作为 cutoff。
|
||||
backtest: 是否回测模式。True 时 cutoff 自动设为 match_date-1天。
|
||||
cutoff_at: 显式截止时间,优先级高于 backtest 自动计算。
|
||||
"""
|
||||
if mode == "single":
|
||||
return await _predict_single(
|
||||
@@ -168,10 +175,18 @@ async def predict_match(
|
||||
prompt_version=prompt_version,
|
||||
use_cache=use_cache,
|
||||
backtest=backtest,
|
||||
cutoff_at=cutoff_at,
|
||||
)
|
||||
from src.llm.agents.orchestrator import predict_match_multi
|
||||
|
||||
return await predict_match_multi(match_id, provider=provider, version=(prompt_version or "v1").removeprefix("multi_"))
|
||||
# 回测参数完整传递到 multi-agent 路径
|
||||
return await predict_match_multi(
|
||||
match_id,
|
||||
provider=provider,
|
||||
version=(prompt_version or "v1").removeprefix("multi_"),
|
||||
backtest=backtest,
|
||||
cutoff_at=cutoff_at,
|
||||
)
|
||||
|
||||
|
||||
async def _predict_single(
|
||||
@@ -182,6 +197,7 @@ async def _predict_single(
|
||||
prompt_version: str | None = None,
|
||||
use_cache: bool = True,
|
||||
backtest: bool = False,
|
||||
cutoff_at=None,
|
||||
) -> PredictResult:
|
||||
"""单次调用路径(原有实现)。"""
|
||||
if provider is None:
|
||||
@@ -198,13 +214,14 @@ async def _predict_single(
|
||||
logger.debug("predict cache hit match=%s", match_id)
|
||||
return cached
|
||||
|
||||
# 1. 拼上下文(P2-6: backtest 时使用 match_date-1天 作为 cutoff)
|
||||
ctx = await build_context(match_id, backtest=backtest)
|
||||
# 1. 拼上下文(backtest/cutoff 防泄漏)
|
||||
ctx = await build_context(match_id, backtest=backtest, cutoff_at=cutoff_at)
|
||||
|
||||
# 1.5 计算快照元数据(用于可复现性)
|
||||
now = datetime.now(timezone.utc)
|
||||
match_kickoff_at = ctx.match_dt
|
||||
prediction_cutoff_at = ctx.match_dt # 默认:比赛时间作为数据截止
|
||||
# 使用上下文实际计算的 cutoff(回测时可能为 match_dt-1天),而非开球时间
|
||||
prediction_cutoff_at = ctx.cutoff if ctx.cutoff is not None else ctx.match_dt
|
||||
input_hash = hashlib.sha256(ctx.text.encode("utf-8")).hexdigest()
|
||||
|
||||
# 2. 拼 prompt(指定版本)
|
||||
@@ -223,7 +240,11 @@ async def _predict_single(
|
||||
if resp.error:
|
||||
raise RuntimeError(f"LLM error: {resp.error}")
|
||||
|
||||
parsed = resp.parsed or {}
|
||||
# P0-3: json_mode 下 parsed 为 None 说明 JSON 解析失败,不能 fallback 到 {}
|
||||
if resp.parsed is None:
|
||||
raise RuntimeError("LLM 输出 JSON 解析失败,parsed=None")
|
||||
|
||||
parsed = resp.parsed
|
||||
|
||||
# 3.5 严格校验 LLM 输出
|
||||
from src.llm.validation import validate_prediction_output
|
||||
@@ -245,6 +266,7 @@ async def _predict_single(
|
||||
provider_name=settings.LLM_PROVIDER,
|
||||
model=provider.model,
|
||||
mode="single",
|
||||
run_type="backtest" if backtest else "live",
|
||||
values={
|
||||
"prompt_version": version,
|
||||
"prompt_tokens": resp.prompt_tokens,
|
||||
|
||||
Reference in New Issue
Block a user