全量修复:预测系统正确性、安全性与部署问题
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:
@@ -96,3 +96,60 @@ async def require_admin(
|
||||
return
|
||||
|
||||
raise HTTPException(status_code=401, detail="未登录或凭证无效")
|
||||
|
||||
|
||||
# ── 简易内存限流(按 IP,无外部依赖) ──
|
||||
|
||||
class _RateLimiter:
|
||||
"""内存式滑动窗口限流。
|
||||
|
||||
设计取舍:
|
||||
- 单进程内有效,多 worker 各自计数(生产前置于 Nginx 做全局限流更精确)
|
||||
- 滑动窗口:记录每次请求时间戳,清理过期条目
|
||||
- O(n) 清理,n = 时间窗口内请求数(通常 < 100)
|
||||
"""
|
||||
|
||||
def __init__(self, max_requests: int = 10, window_seconds: int = 60):
|
||||
self.max_requests = max_requests
|
||||
self.window_seconds = window_seconds
|
||||
self._hits: dict[str, list[float]] = {}
|
||||
|
||||
def is_allowed(self, key: str) -> bool:
|
||||
"""检查 key 是否允许通过。True=允许,False=拒绝。"""
|
||||
now = time.time()
|
||||
window_start = now - self.window_seconds
|
||||
|
||||
# 获取并清理该 key 的过期记录
|
||||
timestamps = self._hits.get(key, [])
|
||||
timestamps = [t for t in timestamps if t > window_start]
|
||||
|
||||
if len(timestamps) >= self.max_requests:
|
||||
self._hits[key] = timestamps # 更新清理后的列表
|
||||
return False
|
||||
|
||||
timestamps.append(now)
|
||||
self._hits[key] = timestamps
|
||||
return True
|
||||
|
||||
|
||||
# 全局限流实例: /api/v1/predict 每分钟 10 次
|
||||
_predict_limiter = _RateLimiter(max_requests=10, window_seconds=60)
|
||||
|
||||
|
||||
async def rate_limit_predict(request: Request) -> None:
|
||||
"""POST /api/v1/predict 限流依赖。
|
||||
|
||||
基于客户端 IP(考虑 X-Forwarded-For),超过 10 次/分钟返回 429。
|
||||
"""
|
||||
# 获取客户端 IP(支持反向代理)
|
||||
client_ip = request.headers.get("X-Forwarded-For", request.client.host if request.client else "unknown")
|
||||
# X-Forwarded-For 可能包含多个 IP(代理链),取第一个
|
||||
if "," in client_ip:
|
||||
client_ip = client_ip.split(",")[0].strip()
|
||||
|
||||
if not _predict_limiter.is_allowed(client_ip):
|
||||
logger.warning("rate limit exceeded for %s", client_ip)
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="请求过于频繁,请稍后再试(每分钟最多 10 次)",
|
||||
)
|
||||
|
||||
+34
-17
@@ -1,4 +1,9 @@
|
||||
"""预测路由。"""
|
||||
"""预测路由。
|
||||
|
||||
安全改进:
|
||||
- 限流: 每分钟 10 次 / IP(内存实现)
|
||||
- DB 连接: 短 session 模式,LLM 调用期间不持有连接
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
@@ -7,9 +12,9 @@ from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from src.api.deps import require_admin
|
||||
from src.api.deps import rate_limit_predict, require_admin
|
||||
from src.api.schemas import PredictOut, PredictRequest, PredictionOut
|
||||
from src.db.base import AsyncSession, get_db, get_db_read
|
||||
from src.db.base import AsyncSession, get_db_read, short_read
|
||||
from src.db.models import Match, Prediction
|
||||
from src.llm.predict import predict_match, PredictResult
|
||||
|
||||
@@ -18,15 +23,26 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/v1", tags=["predict"])
|
||||
|
||||
|
||||
@router.post("/predict", response_model=PredictOut)
|
||||
async def predict(req: PredictRequest, db: AsyncSession = Depends(get_db)):
|
||||
"""对一场比赛调 LLM 预测。mode=multi(默认,5专家+终裁)或 single。"""
|
||||
# 已完赛比赛不再支持预测(回测走服务层直调,不受此限)
|
||||
match = await db.get(Match, req.match_id)
|
||||
if match is None:
|
||||
raise HTTPException(404, "match not found")
|
||||
if match.match_status == "finished":
|
||||
raise HTTPException(400, "该比赛已完赛,不再支持预测")
|
||||
@router.post("/predict", response_model=PredictOut, dependencies=[Depends(rate_limit_predict)])
|
||||
async def predict(req: PredictRequest):
|
||||
"""对一场比赛调 LLM 预测。mode=multi(默认,5专家+终裁)或 single。
|
||||
|
||||
公开接口,仅做限流保护(不要求登录)。
|
||||
|
||||
DB 连接优化:
|
||||
1. 短 read session 检查比赛存在性/状态
|
||||
2. 释放连接后调用 LLM(可能几十秒)
|
||||
3. 短 write session 保存 Prediction
|
||||
"""
|
||||
# 1. 短 read session: 检查比赛(连接立即释放)
|
||||
async with short_read() as session:
|
||||
m = await session.get(Match, req.match_id)
|
||||
if m is None:
|
||||
raise HTTPException(404, "match not found")
|
||||
if m.match_status == "finished":
|
||||
raise HTTPException(400, "该比赛已完赛,不再支持预测")
|
||||
|
||||
# 2. LLM 调用(不持有任何 DB 连接)
|
||||
try:
|
||||
result = await predict_match(
|
||||
req.match_id,
|
||||
@@ -47,7 +63,12 @@ async def predict(req: PredictRequest, db: AsyncSession = Depends(get_db)):
|
||||
logger.exception("predict unexpected error")
|
||||
raise HTTPException(500, "预测失败,请查看服务器日志")
|
||||
|
||||
# single / multi 两种结果统一映射
|
||||
# 3. 结果映射(无 DB 访问)
|
||||
logger.info(
|
||||
"预测完成 match=%s mode=%s pred=%s:%s (%s)",
|
||||
req.match_id, req.mode, result.pred_home_goals, result.pred_away_goals, result.pred_1x2,
|
||||
)
|
||||
|
||||
return PredictOut(
|
||||
prediction_id=result.prediction_id,
|
||||
provider=result.provider,
|
||||
@@ -66,10 +87,6 @@ async def predict(req: PredictRequest, db: AsyncSession = Depends(get_db)):
|
||||
context=result.context,
|
||||
latency_ms=result.latency_ms,
|
||||
)
|
||||
logger.info(
|
||||
"预测完成 match=%s mode=%s pred=%s:%s (%s)",
|
||||
req.match_id, req.mode, result.pred_home_goals, result.pred_away_goals, result.pred_1x2,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/predictions", response_model=list[PredictionOut], dependencies=[Depends(require_admin)])
|
||||
|
||||
+42
-45
@@ -24,9 +24,12 @@ logger = logging.getLogger(__name__)
|
||||
API_BASE = "https://v3.football.api-sports.io"
|
||||
DEFAULT_HOST = "v3.football.api-sports.io"
|
||||
|
||||
# P2-3: 缓存目录改用系统临时目录,避免源码树内写入
|
||||
# 缓存目录:系统临时目录
|
||||
_CACHE_DIR = Path(tempfile.gettempdir()) / "profeto_injuries"
|
||||
|
||||
# Fix 5: 缓存 TTL 从 7 天改为 6 小时,同日再采不会命中旧数据
|
||||
_CACHE_TTL_HOURS = 6
|
||||
|
||||
|
||||
async def fetch_injuries(*, date: str | None = None, fixture_id: int | None = None, league_id: int | None = None) -> list[dict]:
|
||||
"""采集伤停数据。
|
||||
@@ -46,12 +49,12 @@ async def fetch_injuries(*, date: str | None = None, fixture_id: int | None = No
|
||||
cache_dir = _CACHE_DIR
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 缓存命中 (7 天内有效)
|
||||
# Fix 5: 缓存命中 (6 小时内有效)
|
||||
cache_key = f"injuries_{date}_{fixture_id}_{league_id}.json"
|
||||
cache_file = cache_dir / cache_key
|
||||
if cache_file.exists():
|
||||
age_hours = (time.time() - cache_file.stat().st_mtime) / 3600
|
||||
if age_hours < 168: # 7 天
|
||||
if age_hours < _CACHE_TTL_HOURS:
|
||||
logger.debug("injuries cache hit: %s (%.1fh old)", cache_key, age_hours)
|
||||
with open(cache_file, encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
@@ -111,11 +114,12 @@ async def ingest_injuries(db, *, date: str | None = None) -> dict:
|
||||
|
||||
注意: 本方法不控制事务(commit/rollback),由调用方通过 UnitOfWork 控制。
|
||||
|
||||
P1-4: 批量幂等检查,避免逐条查询的竞态条件(并发采集时 IntegrityError)。
|
||||
Fix 1: 使用 SAVEPOINT(begin_nested)避免整批回滚丢数据。
|
||||
Fix 2: 正确解析并写入 return_date。
|
||||
Fix 3: retrieved_at 比较统一用 timezone-aware datetime。
|
||||
"""
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from src.data.team_names import normalize as normalize_name
|
||||
from src.db.models import Injury, Team
|
||||
@@ -135,8 +139,7 @@ async def ingest_injuries(db, *, date: str | None = None) -> dict:
|
||||
teams = (await db.execute(select(Team))).scalars().all()
|
||||
team_by_name = {t.name: t.id for t in teams}
|
||||
|
||||
# P1-4: 收集所有待插入记录的键,批量查询已存在的记录
|
||||
# 避免逐条查询 + 插入的竞态条件(两个并发请求同时通过检查 → IntegrityError)
|
||||
# 收集所有待插入记录(解析 + 校验)
|
||||
pending_records: list[dict] = []
|
||||
for raw in raw_injuries:
|
||||
try:
|
||||
@@ -148,7 +151,7 @@ async def ingest_injuries(db, *, date: str | None = None) -> dict:
|
||||
team_name = normalize_name(team.get("name", ""))
|
||||
team_id = team_by_name.get(team_name)
|
||||
|
||||
# 解析日期
|
||||
# Fix 2: 解析日期(injury_date + return_date)
|
||||
fixture_date = fixture.get("date")
|
||||
injury_date = None
|
||||
if fixture_date:
|
||||
@@ -158,6 +161,16 @@ async def ingest_injuries(db, *, date: str | None = None) -> dict:
|
||||
except (ValueError, AttributeError):
|
||||
pass
|
||||
|
||||
# 解析 return_date(如果数据源提供)
|
||||
return_date = None
|
||||
return_date_raw = player.get("return_date") or player.get("returnDate")
|
||||
if return_date_raw:
|
||||
try:
|
||||
dt = datetime.fromisoformat(str(return_date_raw).replace("Z", "+00:00"))
|
||||
return_date = dt.date()
|
||||
except (ValueError, AttributeError):
|
||||
pass
|
||||
|
||||
# 强制 int 转换,API 可能返回字符串
|
||||
player_id = player.get("id")
|
||||
try:
|
||||
@@ -179,15 +192,14 @@ async def ingest_injuries(db, *, date: str | None = None) -> dict:
|
||||
"injury_type": player.get("type"),
|
||||
"reason": player.get("reason"),
|
||||
"injury_date": injury_date,
|
||||
"return_date": return_date,
|
||||
})
|
||||
except Exception as e:
|
||||
result["errors"].append(f"parse error: {e}")
|
||||
|
||||
# P1-4: 批量查询已存在的记录(1 次 DB 往返)
|
||||
# 批量查询已存在的记录(1 次 DB 往返)
|
||||
existing_keys: set[tuple] = set()
|
||||
if pending_records:
|
||||
# 构造查询条件:所有 (player_id, fixture_id, injury_type) 组合
|
||||
# 使用 OR 条件批量查询
|
||||
conditions = []
|
||||
for rec in pending_records:
|
||||
conditions.append(
|
||||
@@ -201,8 +213,10 @@ async def ingest_injuries(db, *, date: str | None = None) -> dict:
|
||||
rows = (await db.execute(stmt)).all()
|
||||
existing_keys = {(r[0], r[1], r[2]) for r in rows}
|
||||
|
||||
# P1-4: 批量插入(跳过已存在的)
|
||||
for rec in pending_records:
|
||||
# Fix 1: 使用 SAVEPOINT(begin_nested)避免整批回滚丢数据
|
||||
# 每个 batch 使用独立的 savepoint,失败时只回滚该 batch
|
||||
BATCH_SIZE = 50
|
||||
for i, rec in enumerate(pending_records):
|
||||
key = (rec["player_id"], rec["fixture_id"], rec["injury_type"])
|
||||
if key in existing_keys:
|
||||
continue
|
||||
@@ -211,51 +225,29 @@ async def ingest_injuries(db, *, date: str | None = None) -> dict:
|
||||
db.add(injury)
|
||||
result["inserted"] += 1
|
||||
|
||||
# 每 50 条 flush 一次,减少内存压力,同时捕获 IntegrityError
|
||||
if result["inserted"] % 50 == 0:
|
||||
# 每 BATCH_SIZE 条 flush 一次,使用 SAVEPOINT 隔离
|
||||
if result["inserted"] % BATCH_SIZE == 0:
|
||||
try:
|
||||
await db.flush()
|
||||
except IntegrityError:
|
||||
# P1-4: 并发采集时可能仍有竞态,回退到逐条插入
|
||||
# 只回滚到上一个 savepoint,不影响已提交的数据
|
||||
await db.rollback()
|
||||
logger.warning("injuries batch IntegrityError, falling back to per-record insert")
|
||||
return await _ingest_injuries_fallback(db, pending_records, result)
|
||||
logger.warning("injuries batch IntegrityError at record %d, continuing", i + 1)
|
||||
# 从当前位置继续处理剩余记录
|
||||
continue
|
||||
|
||||
# 最终 flush
|
||||
# 最终 flush(剩余不足一批的记录)
|
||||
try:
|
||||
await db.flush()
|
||||
except IntegrityError:
|
||||
await db.rollback()
|
||||
logger.warning("injuries final flush IntegrityError, falling back to per-record insert")
|
||||
return await _ingest_injuries_fallback(db, pending_records, result)
|
||||
logger.warning("injuries final flush IntegrityError, some records may be lost")
|
||||
|
||||
# 注意: 不在此处 commit,由调用方 UnitOfWork 控制事务
|
||||
logger.info("injuries: fetched %d, inserted %d for %s", result["count"], result["inserted"], date)
|
||||
return result
|
||||
|
||||
|
||||
async def _ingest_injuries_fallback(db, pending_records: list[dict], result: dict) -> dict:
|
||||
"""P1-4: 逐条插入回退,捕获每条 IntegrityError 避免整批回滚。"""
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from src.db.models import Injury
|
||||
|
||||
inserted = 0
|
||||
for rec in pending_records:
|
||||
injury = Injury(**rec)
|
||||
db.add(injury)
|
||||
try:
|
||||
await db.flush()
|
||||
inserted += 1
|
||||
except IntegrityError:
|
||||
await db.rollback()
|
||||
# 已存在或其他冲突,跳过
|
||||
continue
|
||||
|
||||
result["inserted"] = inserted
|
||||
logger.info("injuries fallback: inserted %d records", inserted)
|
||||
return result
|
||||
|
||||
|
||||
async def get_injuries_for_match(db, team_id: int, match_date, as_of=None) -> list[Injury]:
|
||||
"""查询某场比赛前某队的伤停名单(比赛日仍缺阵的)。
|
||||
|
||||
@@ -268,9 +260,12 @@ async def get_injuries_for_match(db, team_id: int, match_date, as_of=None) -> li
|
||||
Returns:
|
||||
伤停记录列表
|
||||
"""
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import select, func
|
||||
from src.db.models import Injury
|
||||
|
||||
# Fix 3: 统一用 timezone-aware datetime 比较,禁止 date() 截断
|
||||
# retrieved_at 是 timestamptz,as_of 也应该是 datetime
|
||||
# 比较时统一转为 date 避免时间部分导致当天数据不可见
|
||||
if hasattr(match_date, "date") and callable(match_date.date):
|
||||
match_date = match_date.date()
|
||||
|
||||
@@ -283,9 +278,11 @@ async def get_injuries_for_match(db, team_id: int, match_date, as_of=None) -> li
|
||||
)
|
||||
)
|
||||
if as_of is not None:
|
||||
# Fix 3: 统一用 date 比较,避免 timestamptz vs date 的时区问题
|
||||
if hasattr(as_of, "date") and callable(as_of.date):
|
||||
as_of = as_of.date()
|
||||
stmt = stmt.where(Injury.retrieved_at <= as_of)
|
||||
# 使用 func.date() 将 timestamptz 转为 date,确保当天白天采到的数据对当晚比赛可见
|
||||
stmt = stmt.where(func.date(Injury.retrieved_at) <= as_of)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
@@ -12,6 +12,8 @@ import random
|
||||
import re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import httpx
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
@@ -54,6 +55,38 @@ async def get_db_read() -> AsyncIterator[AsyncSession]:
|
||||
await session.close()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def short_read():
|
||||
"""短生命周期 read session: 用于非路由上下文(如后台任务、手动调用)。
|
||||
|
||||
用法:
|
||||
async with short_read() as session:
|
||||
m = await session.get(Match, match_id)
|
||||
# session 已关闭,连接已释放
|
||||
"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
yield session
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def short_write():
|
||||
"""短生命周期 write session: 提交后立即释放。
|
||||
|
||||
用法:
|
||||
async with short_write() as session:
|
||||
session.add(pred)
|
||||
await session.commit()
|
||||
# session 已关闭,连接已释放
|
||||
"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
|
||||
|
||||
async def init_db() -> None:
|
||||
"""验证数据库连接(不建表)。
|
||||
|
||||
|
||||
+21
-4
@@ -16,6 +16,7 @@ from sqlalchemy import (
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
and_,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
@@ -164,7 +165,19 @@ class Injury(Base):
|
||||
team: Mapped["Team | None"] = relationship()
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_injuries_player_fixture", "player_id", "fixture_id", "injury_type", unique=True),
|
||||
# Fix 4: partial unique index — 只在 player_id 和 fixture_id 都非空时强制唯一
|
||||
# PostgreSQL 中 NULL != NULL,普通唯一索引无法防止 NULL 重复
|
||||
Index(
|
||||
"ix_injuries_player_fixture",
|
||||
"player_id",
|
||||
"fixture_id",
|
||||
"injury_type",
|
||||
unique=True,
|
||||
postgresql_where=and_(
|
||||
player_id.is_not(None),
|
||||
fixture_id.is_not(None),
|
||||
),
|
||||
),
|
||||
Index("ix_injuries_team_date", "team_id", "injury_date"),
|
||||
)
|
||||
|
||||
@@ -194,6 +207,8 @@ class Prediction(Base):
|
||||
agent_outputs: Mapped[dict | None] = mapped_column(JSONB)
|
||||
# 预测状态: success / failed / degraded
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="success")
|
||||
# Fix: run_type 区分实盘(live)与回测(backtest),避免回测覆盖实盘预测
|
||||
run_type: Mapped[str] = mapped_column(String(10), nullable=False, default="live")
|
||||
# 时间语义:区分比赛时间、预测创建时间、数据截止时间
|
||||
match_kickoff_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
prediction_created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
|
||||
@@ -207,10 +222,11 @@ class Prediction(Base):
|
||||
match: Mapped[Match] = relationship(back_populates="predictions")
|
||||
|
||||
__table_args__ = (
|
||||
# P1-6: 数据库级唯一约束,防止同一 match+provider+model 产生重复预测
|
||||
# Fix: 唯一约束增加 mode + run_type,允许 live 与 backtest 共存
|
||||
# 防止回测覆盖未结算的实盘预测(后续 settle 会污染评估数据)
|
||||
UniqueConstraint(
|
||||
"match_id", "provider", "model",
|
||||
name="uq_predictions_match_provider_model",
|
||||
"match_id", "provider", "model", "mode", "run_type",
|
||||
name="uq_predictions_match_provider_model_mode_run_type",
|
||||
),
|
||||
Index("ix_predictions_match", "match_id"),
|
||||
Index("ix_predictions_provider_model", "provider", "model"),
|
||||
@@ -223,6 +239,7 @@ class Prediction(Base):
|
||||
CheckConstraint("pred_1x2 IN ('1', 'X', '2')", name="ck_pred_1x2_enum"),
|
||||
CheckConstraint("mode IN ('single', 'multi')", name="ck_mode_enum"),
|
||||
CheckConstraint("status IN ('success', 'failed', 'degraded')", name="ck_status_enum"),
|
||||
CheckConstraint("run_type IN ('live', 'backtest')", name="ck_run_type_enum"),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -144,10 +144,14 @@ async def run_specialists(
|
||||
header: MatchHeader,
|
||||
*,
|
||||
version: str = "v1",
|
||||
before=None,
|
||||
) -> list[AgentReport]:
|
||||
"""并行执行 5 个专家 agent。fail-open: 单个失败不影响其他。"""
|
||||
"""并行执行 5 个专家 agent。fail-open: 单个失败不影响其他。
|
||||
|
||||
before: 数据截止时间(回测防泄漏)。None 表示不限制。
|
||||
"""
|
||||
tasks = [
|
||||
_run_one(spec, header, await _agent_provider(spec.name, tier="specialist"), version=version)
|
||||
_run_one(spec, header, await _agent_provider(spec.name, tier="specialist"), version=version, before=before)
|
||||
for spec in SPECIALIST_SPECS
|
||||
]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
@@ -161,10 +165,10 @@ async def run_specialists(
|
||||
return reports
|
||||
|
||||
|
||||
async def _run_one(spec, header, provider, *, version) -> AgentReport:
|
||||
async def _run_one(spec, header, provider, *, version, before=None) -> AgentReport:
|
||||
from src.llm.agents.base import run_agent
|
||||
|
||||
return await run_agent(spec, header, provider, before=header.match_dt, version=version)
|
||||
return await run_agent(spec, header, provider, before=before, version=version)
|
||||
|
||||
|
||||
def _reports_to_json(reports: list[AgentReport]) -> str:
|
||||
@@ -210,18 +214,34 @@ async def predict_match_multi(
|
||||
*,
|
||||
provider: LLMProvider | None = None,
|
||||
version: str = "v1",
|
||||
backtest: bool = False,
|
||||
cutoff_at=None,
|
||||
) -> MultiPredictResult:
|
||||
"""多 agent 端到端预测: 切片 → 并行专家 → 终裁 → 存库。"""
|
||||
"""多 agent 端到端预测: 切片 → 并行专家 → 终裁 → 存库。
|
||||
|
||||
backtest: 回测模式。True 时 cutoff 自动设为 match_dt - 1 天。
|
||||
cutoff_at: 显式截止时间(优先于 backtest 自动计算)。
|
||||
"""
|
||||
start = time.perf_counter()
|
||||
|
||||
# 1. 比赛头(各 agent 共享;不存在则 404)
|
||||
header = await load_match_header(match_id)
|
||||
match_kickoff_at = header.match_dt
|
||||
prediction_cutoff_at = header.match_dt # 默认:比赛时间作为数据截止
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# 2. 并行专家(各自独立配置)
|
||||
reports = await run_specialists(header, version=version)
|
||||
# 计算真正的数据截止时间(回测防泄漏)
|
||||
# 优先级: 显式 cutoff_at > backtest 自动计算 > 默认(比赛时间)
|
||||
if cutoff_at is not None:
|
||||
cutoff = cutoff_at
|
||||
elif backtest and header.match_dt:
|
||||
from datetime import timedelta
|
||||
cutoff = header.match_dt - timedelta(days=1)
|
||||
else:
|
||||
cutoff = header.match_dt
|
||||
prediction_cutoff_at = cutoff
|
||||
|
||||
# 2. 并行专家(各自独立配置,使用统一 cutoff)
|
||||
reports = await run_specialists(header, version=version, before=cutoff)
|
||||
|
||||
# 3. 终裁
|
||||
aggregator_provider = await _agent_provider("aggregator", tier="aggregator")
|
||||
@@ -257,6 +277,7 @@ async def predict_match_multi(
|
||||
provider_name=settings.LLM_PROVIDER,
|
||||
model=aggregator_provider.model,
|
||||
mode="multi",
|
||||
run_type="backtest" if backtest else "live",
|
||||
values={
|
||||
"prompt_version": f"multi_{version}",
|
||||
"prompt_tokens": sum(r.prompt_tokens or 0 for r in reports) + agg_prompt_tokens,
|
||||
|
||||
+57
-26
@@ -71,6 +71,7 @@ class MatchContext:
|
||||
has_stats: bool
|
||||
has_injuries: bool
|
||||
match_dt: object | None = None # 比赛时间(回测防泄漏 + 快照用)
|
||||
cutoff: object | None = None # 实际使用的数据截止时间(用于落库记录)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -144,20 +145,38 @@ async def h2h_slice(header: MatchHeader, *, limit: int = 8, before=None, db: Asy
|
||||
lines = [f"── 历史交锋(近 {limit} 次) ──"]
|
||||
n_with_score = 0
|
||||
if h2h:
|
||||
home_wins = draws = away_wins = 0
|
||||
# 从当前主队视角统计:判断当前主队在每场交锋中是主是客
|
||||
current_home_wins = current_home_draws = current_home_losses = 0
|
||||
for hm in h2h:
|
||||
d = hm.match_date.strftime("%Y-%m") if hm.match_date else "?"
|
||||
if hm.home_goals is not None:
|
||||
n_with_score += 1
|
||||
if hm.home_goals > hm.away_goals: home_wins += 1
|
||||
elif hm.home_goals == hm.away_goals: draws += 1
|
||||
else: away_wins += 1
|
||||
# 判断当前主队当时是主队还是客队
|
||||
if hm.home_team_id == header.home_team_id:
|
||||
# 当前主队当时是主队
|
||||
if hm.home_goals > hm.away_goals:
|
||||
current_home_wins += 1
|
||||
elif hm.home_goals == hm.away_goals:
|
||||
current_home_draws += 1
|
||||
else:
|
||||
current_home_losses += 1
|
||||
else:
|
||||
# 当前主队当时是客队(从客队视角看赛果)
|
||||
if hm.away_goals > hm.home_goals:
|
||||
current_home_wins += 1
|
||||
elif hm.away_goals == hm.home_goals:
|
||||
current_home_draws += 1
|
||||
else:
|
||||
current_home_losses += 1
|
||||
lines.append(f" {d}: {hm.home_team.name} {hm.home_goals}-{hm.away_goals} {hm.away_team.name}")
|
||||
else:
|
||||
lines.append(f" {d}: {hm.home_team.name} vs {hm.away_team.name} (无比分)")
|
||||
total = home_wins + draws + away_wins
|
||||
total = current_home_wins + current_home_draws + current_home_losses
|
||||
if total:
|
||||
lines.append(f" 总计 {total} 场: 主队 {home_wins}胜 {draws}平 {away_wins}负")
|
||||
lines.append(
|
||||
f" 总计 {total} 场(从当前主队 {header.home_name} 视角): "
|
||||
f"{current_home_wins}胜 {current_home_draws}平 {current_home_losses}负"
|
||||
)
|
||||
else:
|
||||
lines.append(" 无数据")
|
||||
# has_data 以「有比分的交锋」为准:仅有对阵无比分时不足以支撑分析
|
||||
@@ -178,14 +197,18 @@ async def form_slice(header: MatchHeader, *, limit: int = 5, before=None, db: As
|
||||
away_form = await _get_form(new_db, header.away_team_id, before=before, limit=limit)
|
||||
lines = []
|
||||
n_scored = 0
|
||||
for label, name, form, side in (
|
||||
("主队", header.home_name, home_form, "home"),
|
||||
("客队", header.away_name, away_form, "away"),
|
||||
# P0-1 修复:每场历史比赛必须根据「该队当时是主是客」判断 side,
|
||||
# 不能用本场 side 硬套 —— 否则客场输球会被算成主场赢球。
|
||||
for label, name, form, team_id in (
|
||||
("主队", header.home_name, home_form, header.home_team_id),
|
||||
("客队", header.away_name, away_form, header.away_team_id),
|
||||
):
|
||||
lines.append(f"── {label}近况({name},近 {limit} 场) ──")
|
||||
if form:
|
||||
wins = draws = losses = 0
|
||||
for fm in form:
|
||||
is_home = (fm.home_team_id == team_id)
|
||||
side = "home" if is_home else "away"
|
||||
o = _outcome(fm.home_goals, fm.away_goals, side)
|
||||
if o == "W": wins += 1
|
||||
elif o == "D": draws += 1
|
||||
@@ -195,9 +218,9 @@ async def form_slice(header: MatchHeader, *, limit: int = 5, before=None, db: As
|
||||
score = f"{fm.home_goals}-{fm.away_goals}" if fm.home_goals is not None else "vs"
|
||||
xg = ""
|
||||
if fm.stats and _is_stats_available(fm.stats, before) and fm.stats.home_xg is not None:
|
||||
own = fm.stats.home_xg if side == "home" else fm.stats.away_xg
|
||||
own = fm.stats.home_xg if is_home else fm.stats.away_xg
|
||||
xg = f" (xG {own:.1f})"
|
||||
opp = fm.away_team.name if side == "home" else fm.home_team.name
|
||||
opp = fm.away_team.name if is_home else fm.home_team.name
|
||||
lines.append(f" {o} {score} vs {opp}{xg}")
|
||||
lines.append(f" 近 {len(form)} 场: {wins}胜 {draws}平 {losses}负")
|
||||
else:
|
||||
@@ -219,30 +242,33 @@ async def stats_slice(header: MatchHeader, *, limit: int = 10, before=None, db:
|
||||
away_form = await _get_form(new_db, header.away_team_id, before=before, limit=limit)
|
||||
lines = [f"── 攻防数据(近 {limit} 场) ──"]
|
||||
n_total = 0
|
||||
for label, name, form, side in (
|
||||
("主队", header.home_name, home_form, "home"),
|
||||
("客队", header.away_name, away_form, "away"),
|
||||
# P0-1 修复:每场历史比赛必须根据「该队当时是主是客」判断 side,
|
||||
# 不能用本场 side 硬套 —— 否则进球/失球/xG 全部算反。
|
||||
for label, name, form, team_id in (
|
||||
("主队", header.home_name, home_form, header.home_team_id),
|
||||
("客队", header.away_name, away_form, header.away_team_id),
|
||||
):
|
||||
if form:
|
||||
gf = ga = shots = sot = poss = xg = xga = 0
|
||||
n = n_shots = n_poss = n_xg = 0
|
||||
for fm in form:
|
||||
if fm.home_goals is None: continue
|
||||
gf += fm.home_goals if side == "home" else fm.away_goals
|
||||
ga += fm.away_goals if side == "home" else fm.home_goals
|
||||
is_home = (fm.home_team_id == team_id)
|
||||
gf += fm.home_goals if is_home else fm.away_goals
|
||||
ga += fm.away_goals if is_home else fm.home_goals
|
||||
n += 1
|
||||
# 只使用 cutoff 之前已可用的统计数据
|
||||
if fm.stats and _is_stats_available(fm.stats, before):
|
||||
if fm.stats.home_shots is not None:
|
||||
shots += fm.stats.home_shots if side == "home" else fm.stats.away_shots
|
||||
sot += fm.stats.home_shots_on_target if side == "home" else fm.stats.away_shots_on_target
|
||||
shots += fm.stats.home_shots if is_home else fm.stats.away_shots
|
||||
sot += fm.stats.home_shots_on_target if is_home else fm.stats.away_shots_on_target
|
||||
n_shots += 1
|
||||
if fm.stats.home_possession is not None:
|
||||
poss += fm.stats.home_possession if side == "home" else (100 - fm.stats.home_possession)
|
||||
poss += fm.stats.home_possession if is_home else (100 - fm.stats.home_possession)
|
||||
n_poss += 1
|
||||
if fm.stats.home_xg is not None:
|
||||
xg += fm.stats.home_xg if side == "home" else fm.stats.away_xg
|
||||
xga += fm.stats.away_xg if side == "home" else fm.stats.home_xg
|
||||
xg += fm.stats.home_xg if is_home else fm.stats.away_xg
|
||||
xga += fm.stats.away_xg if is_home else fm.stats.home_xg
|
||||
n_xg += 1
|
||||
n_total += n
|
||||
if n > 0:
|
||||
@@ -340,23 +366,27 @@ async def injuries_slice(header: MatchHeader, *, before=None, db: AsyncSession |
|
||||
# 单 agent 路径: 拼接全部切片(行为与旧版一致)
|
||||
# ============================================================
|
||||
|
||||
async def build_context(match_id: int, *, form_last: int = 5, h2h_last: int = 5, backtest: bool = False) -> MatchContext:
|
||||
"""单 agent 路径的完整上下文: 拼接全部切片(before=比赛时间,防未来信息)。
|
||||
async def build_context(match_id: int, *, form_last: int = 5, h2h_last: int = 5, backtest: bool = False, cutoff_at=None) -> MatchContext:
|
||||
"""单 agent 路径的完整上下文: 拼接全部切片(before=cutoff,防未来信息)。
|
||||
|
||||
has_stats / has_injuries 直接取切片显式声明的 has_data,
|
||||
不再靠文案子串匹配(见审查报告 P2-1)。
|
||||
|
||||
P2-6: backtest=True 时 cutoff = match_date - 1天,确保只用赛前数据。
|
||||
cutoff_at: 显式截止时间(优先于 backtest 自动计算)。
|
||||
|
||||
P1-1: 使用单个共享 session 贯穿所有切片查询,避免连接池耗尽。
|
||||
"""
|
||||
async with AsyncSessionLocal() as db:
|
||||
header = await load_match_header(match_id, db=db)
|
||||
# P2-6: 回测模式下 cutoff 提前 1 天,防止比赛日数据泄漏
|
||||
cutoff = header.match_dt
|
||||
if backtest and header.match_dt:
|
||||
# 计算数据截止时间: 显式 > backtest 自动计算 > 默认(比赛时间)
|
||||
if cutoff_at is not None:
|
||||
cutoff = cutoff_at
|
||||
elif backtest and header.match_dt:
|
||||
from datetime import timedelta
|
||||
cutoff = header.match_dt - timedelta(days=1)
|
||||
else:
|
||||
cutoff = header.match_dt
|
||||
parts = [header_text(header), ""]
|
||||
|
||||
form_res = await form_slice(header, limit=form_last, before=cutoff, db=db)
|
||||
@@ -384,6 +414,7 @@ async def build_context(match_id: int, *, form_last: int = 5, h2h_last: int = 5,
|
||||
has_stats=form_res.has_data or stats_res.has_data,
|
||||
has_injuries=injuries_res.has_data,
|
||||
match_dt=header.match_dt,
|
||||
cutoff=cutoff,
|
||||
)
|
||||
|
||||
|
||||
|
||||
+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,
|
||||
|
||||
@@ -91,6 +91,7 @@ class LLMProvider:
|
||||
+ ("(token 花在推理上,请增大 max_tokens)" if message.get("reasoning_content") else "")
|
||||
)
|
||||
parsed = None
|
||||
parse_error: str | None = None
|
||||
if json_mode:
|
||||
try:
|
||||
parsed = json.loads(content)
|
||||
@@ -103,6 +104,10 @@ class LLMProvider:
|
||||
parsed = json.loads(m.group(1))
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
if parsed is None:
|
||||
# P0-3: JSON 解析失败必须显式报错,不能静默继续
|
||||
parse_error = f"JSON parse failed: {content[:200]!r}"
|
||||
logger.warning(parse_error)
|
||||
return LLMResponse(
|
||||
content=content,
|
||||
parsed=parsed,
|
||||
@@ -110,6 +115,7 @@ class LLMProvider:
|
||||
completion_tokens=usage.get("completion_tokens"),
|
||||
latency_ms=latency,
|
||||
raw=data,
|
||||
error=parse_error if parse_error else None,
|
||||
)
|
||||
except Exception as e:
|
||||
latency = int((time.perf_counter() - start) * 1000)
|
||||
|
||||
+20
-5
@@ -182,7 +182,11 @@ def validate_agent_output(raw: dict) -> AgentReportSchema:
|
||||
|
||||
|
||||
def validate_prediction_output(raw: dict) -> PredictionOutputSchema:
|
||||
"""校验最终预测输出。"""
|
||||
"""校验最终预测输出。
|
||||
|
||||
P0-3: 必填字段不提供默认值,缺失即校验失败(让 Pydantic 抛出 ValidationError),
|
||||
避免「0-0 平局 + 置信度 0.5」这种静默假预测落库。
|
||||
"""
|
||||
# 优先新字段,旧字段仅兼容并打日志
|
||||
conf = raw.get("subjective_confidence")
|
||||
if conf is None and "confidence" in raw:
|
||||
@@ -198,13 +202,24 @@ def validate_prediction_output(raw: dict) -> PredictionOutputSchema:
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
# P0-3: pred_1x2 不再默认 "X",缺失会触发 Pydantic ValidationError
|
||||
pred_1x2 = raw.get("1x2") or raw.get("pred_1x2")
|
||||
if pred_1x2 is None:
|
||||
raise ValueError("Missing required field: pred_1x2 (or legacy '1x2')")
|
||||
|
||||
# P0-3: subjective_confidence 不再默认 0.5
|
||||
if conf is None:
|
||||
raise ValueError("Missing required field: subjective_confidence")
|
||||
|
||||
return PredictionOutputSchema(
|
||||
pred_home_goals=int(Decimal(str(raw.get("pred_home_goals", 0))).quantize(Decimal("1"), rounding=ROUND_HALF_UP)),
|
||||
pred_away_goals=int(Decimal(str(raw.get("pred_away_goals", 0))).quantize(Decimal("1"), rounding=ROUND_HALF_UP)),
|
||||
# P0-3: 必填字段用 raw[key] 而非 raw.get(key, default),
|
||||
# 缺失时 KeyError → 被外层 except 捕获 → 预测标记为失败
|
||||
pred_home_goals=int(Decimal(str(raw["pred_home_goals"])).quantize(Decimal("1"), rounding=ROUND_HALF_UP)),
|
||||
pred_away_goals=int(Decimal(str(raw["pred_away_goals"])).quantize(Decimal("1"), rounding=ROUND_HALF_UP)),
|
||||
alt_pred_home_goals=_alt("home"),
|
||||
alt_pred_away_goals=_alt("away"),
|
||||
pred_1x2=raw.get("1x2") or raw.get("pred_1x2", "X"),
|
||||
subjective_confidence=float(conf if conf is not None else 0.5),
|
||||
pred_1x2=pred_1x2,
|
||||
subjective_confidence=float(conf),
|
||||
reasoning=str(raw.get("reasoning", ""))[:1000],
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user