refactor: fix P0/P1/P2 security and performance issues
P0 fixes: - CORS: replace wildcard methods/headers with configurable lists - deps.py: remove unsafe global _warned_unset variable P1 fixes: - http_client: read default timeout from Settings - bzzoiro: replace sync urllib with async httpx - bzzoiro: normalize validation failures use warning level only - db pool: read pool config from Settings (default 5+10) - backtest: add asyncio.Semaphore(8) for concurrent execution - predict/context_builder: add backtest parameter for cutoff buffer P2 improvements: - injuries: enforce int conversion for player_id/fixture_id - injuries: use system temp dir for cache - utils.py: extract shared actual_1x2/is_correct_1x2 - validation: downgrade 1x2 mismatch log to debug - docker-compose: use env vars for all credentials - .env.example: add POSTGRES_USER/PASSWORD/PORT, API_PORT
This commit is contained in:
+35
-43
@@ -3,9 +3,11 @@
|
||||
核心机制:
|
||||
- build_context 已内置 before=match_date,天然防未来信息泄漏
|
||||
- 对历史比赛跑预测 → 用实际比分 settle → 统计准确率
|
||||
- 并发控制: asyncio.Semaphore 限制同时 LLM 调用数
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
@@ -17,6 +19,7 @@ from src.db.models import Match
|
||||
from src.db.unit_of_work import get_uow
|
||||
from src.llm.eval import settle_prediction
|
||||
from src.llm.predict import predict_match
|
||||
from src.llm.utils import actual_1x2
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -69,15 +72,6 @@ class BacktestSummary:
|
||||
results: list[BacktestMatchResult] = field(default_factory=list)
|
||||
|
||||
|
||||
def _actual_1x2(home: int, away: int) -> str:
|
||||
"""实际比分 → 胜平负。"""
|
||||
if home > away:
|
||||
return "1"
|
||||
if home == away:
|
||||
return "X"
|
||||
return "2"
|
||||
|
||||
|
||||
async def _get_historical_matches(
|
||||
db,
|
||||
*,
|
||||
@@ -156,44 +150,42 @@ async def run_backtest(
|
||||
|
||||
summary = BacktestSummary(total=len(candidates), scored=0)
|
||||
|
||||
for c in candidates:
|
||||
try:
|
||||
# 预测 (build_context 内部已用 before=match_date 防泄漏,
|
||||
# injuries_slice 也使用 as_of=match_date 过滤 retrieved_at)
|
||||
# 回测必须禁用结果缓存: 否则命中缓存会复用同一 prediction_id,
|
||||
# 导致 settle 反复覆盖同一条记录(见 P1-3)。
|
||||
result = await predict_match(c.match_id, mode=mode, model=model, use_cache=False)
|
||||
# P1-6: 并发控制,同时最多 8 场预测(避免 LLM API 限流)
|
||||
sem = asyncio.Semaphore(8)
|
||||
|
||||
# 用实际比分 settle
|
||||
await settle_prediction(result.prediction_id, c.home_goals, c.away_goals)
|
||||
async def _one(c: BacktestCandidate) -> BacktestMatchResult | None:
|
||||
async with sem:
|
||||
try:
|
||||
result = await predict_match(c.match_id, mode=mode, model=model, use_cache=False, backtest=True)
|
||||
await settle_prediction(result.prediction_id, c.home_goals, c.away_goals)
|
||||
actual = actual_1x2(c.home_goals, c.away_goals)
|
||||
return BacktestMatchResult(
|
||||
match_id=c.match_id,
|
||||
league_code=c.league_code,
|
||||
home_team=c.home_team,
|
||||
away_team=c.away_team,
|
||||
match_date=c.match_date.strftime("%Y-%m-%d") if c.match_date else "?",
|
||||
actual_home=c.home_goals,
|
||||
actual_away=c.away_goals,
|
||||
actual_1x2=actual,
|
||||
pred_home=result.pred_home_goals,
|
||||
pred_away=result.pred_away_goals,
|
||||
pred_1x2=result.pred_1x2,
|
||||
subjective_confidence=result.subjective_confidence,
|
||||
correct_1x2=result.pred_1x2 == actual,
|
||||
prediction_id=result.prediction_id,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("backtest match %s failed", c.match_id)
|
||||
return None
|
||||
|
||||
actual = _actual_1x2(c.home_goals, c.away_goals)
|
||||
correct = result.pred_1x2 == actual
|
||||
|
||||
bt = BacktestMatchResult(
|
||||
match_id=c.match_id,
|
||||
league_code=c.league_code,
|
||||
home_team=c.home_team,
|
||||
away_team=c.away_team,
|
||||
match_date=c.match_date.strftime("%Y-%m-%d") if c.match_date else "?",
|
||||
actual_home=c.home_goals,
|
||||
actual_away=c.away_goals,
|
||||
actual_1x2=actual,
|
||||
pred_home=result.pred_home_goals,
|
||||
pred_away=result.pred_away_goals,
|
||||
pred_1x2=result.pred_1x2,
|
||||
subjective_confidence=result.subjective_confidence,
|
||||
correct_1x2=correct,
|
||||
prediction_id=result.prediction_id,
|
||||
)
|
||||
summary.results.append(bt)
|
||||
# 并行执行,保持结果顺序
|
||||
results = await asyncio.gather(*[_one(c) for c in candidates])
|
||||
for r in results:
|
||||
if r is not None:
|
||||
summary.results.append(r)
|
||||
summary.scored += 1
|
||||
|
||||
except Exception:
|
||||
# 用 exception 而非 warning:保留堆栈,否则集成层缺陷(如惰性加载
|
||||
# 在 session 外触发)会只剩一行无堆栈的 warning,极难定位。
|
||||
logger.exception("backtest match %s failed", c.match_id)
|
||||
|
||||
# 汇总统计
|
||||
if summary.scored > 0:
|
||||
correct_count = sum(1 for r in summary.results if r.correct_1x2)
|
||||
|
||||
@@ -291,32 +291,39 @@ async def injuries_slice(header: MatchHeader, *, before=None) -> SliceResult:
|
||||
# 单 agent 路径: 拼接全部切片(行为与旧版一致)
|
||||
# ============================================================
|
||||
|
||||
async def build_context(match_id: int, *, form_last: int = 5, h2h_last: int = 5) -> MatchContext:
|
||||
async def build_context(match_id: int, *, form_last: int = 5, h2h_last: int = 5, backtest: bool = False) -> MatchContext:
|
||||
"""单 agent 路径的完整上下文: 拼接全部切片(before=比赛时间,防未来信息)。
|
||||
|
||||
has_stats / has_injuries 直接取切片显式声明的 has_data,
|
||||
不再靠文案子串匹配(见审查报告 P2-1)。
|
||||
|
||||
P2-6: backtest=True 时 cutoff = match_date - 1天,确保只用赛前数据。
|
||||
"""
|
||||
header = await load_match_header(match_id)
|
||||
# P2-6: 回测模式下 cutoff 提前 1 天,防止比赛日数据泄漏
|
||||
cutoff = header.match_dt
|
||||
if backtest and header.match_dt:
|
||||
from datetime import timedelta
|
||||
cutoff = header.match_dt - timedelta(days=1)
|
||||
parts = [header_text(header), ""]
|
||||
|
||||
form_res = await form_slice(header, limit=form_last, before=header.match_dt)
|
||||
form_res = await form_slice(header, limit=form_last, before=cutoff)
|
||||
parts.append(form_res.text)
|
||||
parts.append("")
|
||||
|
||||
h2h_res = await h2h_slice(header, limit=h2h_last, before=header.match_dt)
|
||||
h2h_res = await h2h_slice(header, limit=h2h_last, before=cutoff)
|
||||
parts.append(h2h_res.text)
|
||||
parts.append("")
|
||||
|
||||
stats_res = await stats_slice(header, before=header.match_dt)
|
||||
stats_res = await stats_slice(header, before=cutoff)
|
||||
parts.append(stats_res.text)
|
||||
parts.append("")
|
||||
|
||||
home_away_res = await home_away_slice(header, before=header.match_dt)
|
||||
home_away_res = await home_away_slice(header, before=cutoff)
|
||||
parts.append(home_away_res.text)
|
||||
parts.append("")
|
||||
|
||||
injuries_res = await injuries_slice(header, before=header.match_dt)
|
||||
injuries_res = await injuries_slice(header, before=cutoff)
|
||||
parts.append(injuries_res.text)
|
||||
|
||||
return MatchContext(
|
||||
|
||||
+6
-2
@@ -103,6 +103,7 @@ async def predict_match(
|
||||
prompt_version: str | None = None,
|
||||
mode: str = "multi",
|
||||
use_cache: bool = True,
|
||||
backtest: bool = False,
|
||||
) -> "PredictResult | MultiPredictResult":
|
||||
"""预测入口。mode=multi(默认)走多 agent;mode=single 走单次调用。
|
||||
|
||||
@@ -110,6 +111,7 @@ async def predict_match(
|
||||
use_cache: 是否允许返回进程内缓存结果。回测必须传 False——
|
||||
缓存命中不会新建 prediction 行,调用方会对同一个 prediction_id
|
||||
反复 settle,把不同比赛的真实比分覆盖到同一条记录上。
|
||||
backtest: 是否回测模式。True 时 build_context 使用 match_date-1天 作为 cutoff。
|
||||
"""
|
||||
if mode == "single":
|
||||
return await _predict_single(
|
||||
@@ -118,6 +120,7 @@ async def predict_match(
|
||||
model=model,
|
||||
prompt_version=prompt_version,
|
||||
use_cache=use_cache,
|
||||
backtest=backtest,
|
||||
)
|
||||
from src.llm.agents.orchestrator import predict_match_multi
|
||||
|
||||
@@ -131,6 +134,7 @@ async def _predict_single(
|
||||
model: str | None = None,
|
||||
prompt_version: str | None = None,
|
||||
use_cache: bool = True,
|
||||
backtest: bool = False,
|
||||
) -> PredictResult:
|
||||
"""单次调用路径(原有实现)。"""
|
||||
if provider is None:
|
||||
@@ -147,8 +151,8 @@ async def _predict_single(
|
||||
logger.debug("predict cache hit match=%s", match_id)
|
||||
return cached
|
||||
|
||||
# 1. 拼上下文
|
||||
ctx = await build_context(match_id)
|
||||
# 1. 拼上下文(P2-6: backtest 时使用 match_date-1天 作为 cutoff)
|
||||
ctx = await build_context(match_id, backtest=backtest)
|
||||
|
||||
# 1.5 计算快照元数据(用于可复现性)
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
"""LLM 模块共享工具函数。"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def actual_1x2(home: int, away: int) -> str:
|
||||
"""实际比分 → 胜平负。
|
||||
|
||||
单一权威源: backtest.py 和 eval.py 共用,避免重复定义。
|
||||
"""
|
||||
if home > away:
|
||||
return "1"
|
||||
if home == away:
|
||||
return "X"
|
||||
return "2"
|
||||
|
||||
|
||||
def is_correct_1x2(pred: str | None, actual: str) -> bool:
|
||||
"""预测是否命中胜平负。"""
|
||||
return pred == actual
|
||||
@@ -76,7 +76,7 @@ class PredictionOutputSchema(BaseModel):
|
||||
"""
|
||||
expected = _score_to_1x2(self.pred_home_goals, self.pred_away_goals)
|
||||
if self.pred_1x2 != expected:
|
||||
logger.warning(
|
||||
logger.debug(
|
||||
"1x2 与比分不一致: 比分 %.1f-%.1f 推出 '%s',但 LLM 给出 '%s';以比分修正",
|
||||
self.pred_home_goals, self.pred_away_goals, expected, self.pred_1x2,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user