feat: 足球 LLM 预测服务初始提交
Profeto — 给 LLM 提供数据,让 LLM 预测足球比分。 核心模块: - FastAPI 后端 + PostgreSQL (SQLAlchemy async) - 多 Agent LLM 预测 (5 专家 + 终裁) - 数据采集 (bzzoiro / understat / injuries) - React 前端 (Vite + Tailwind) 包含: - 数据源抽象 (DataSource 协议 + 注册表) - Alembic 数据库迁移 - Prompt 模板 (单/多 Agent) - 核心路径单元测试
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
"""FastAPI 应用工厂。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from src.core.config import settings
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
from src.db.base import init_db
|
||||
from src.core.http_client import close_client
|
||||
await init_db()
|
||||
yield
|
||||
await close_client()
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
app = FastAPI(
|
||||
title="Profeto API",
|
||||
description="足球数据 + LLM 预测服务",
|
||||
version="0.1.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
origins = [o.strip() for o in settings.CORS_ORIGINS.split(",") if o.strip()]
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
from src.api.routes.matches import router as matches_router
|
||||
from src.api.routes.predict import router as predict_router
|
||||
from src.api.routes.ingest import router as ingest_router
|
||||
from src.api.routes.eval import router as eval_router
|
||||
|
||||
app.include_router(matches_router)
|
||||
app.include_router(predict_router)
|
||||
app.include_router(ingest_router)
|
||||
app.include_router(eval_router)
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "healthy", "service": "profeto"}
|
||||
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
@@ -0,0 +1,26 @@
|
||||
"""评估路由。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from src.api.schemas import EvalSummaryOut, SettleRequest
|
||||
from src.db.base import AsyncSession, get_db, get_db_read
|
||||
from src.llm.eval import get_eval_summary, settle_prediction
|
||||
|
||||
router = APIRouter(prefix="/api/v1", tags=["eval"])
|
||||
|
||||
|
||||
@router.post("/eval/settle")
|
||||
async def settle(req: SettleRequest, db: AsyncSession = Depends(get_db)):
|
||||
"""回填实际结果。"""
|
||||
try:
|
||||
pred = await settle_prediction(req.prediction_id, req.home_goals, req.away_goals)
|
||||
return {"id": pred.id, "settled": pred.settled}
|
||||
except ValueError as e:
|
||||
raise HTTPException(404, str(e))
|
||||
|
||||
|
||||
@router.get("/eval/summary", response_model=EvalSummaryOut)
|
||||
async def eval_summary():
|
||||
"""提供商/模型准确率对比。"""
|
||||
return await get_eval_summary()
|
||||
@@ -0,0 +1,56 @@
|
||||
"""采集路由。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from src.api.schemas import IngestBzzoiroRequest, IngestResponse, IngestUnderstatRequest, IngestInjuriesRequest, IngestSimpleResponse
|
||||
from src.data.sources import get_source
|
||||
from src.data.injuries import ingest_injuries
|
||||
from src.db.base import AsyncSessionLocal
|
||||
|
||||
router = APIRouter(prefix="/api/v1", tags=["ingest"])
|
||||
|
||||
|
||||
@router.post("/ingest/bzzoiro", response_model=IngestResponse)
|
||||
async def ingest_bzzoiro_route(req: IngestBzzoiroRequest):
|
||||
"""触发 bzzoiro 采集。"""
|
||||
source = get_source("bzzoiro")
|
||||
async with AsyncSessionLocal() as db:
|
||||
try:
|
||||
result = await source.ingest(
|
||||
db,
|
||||
leagues=req.leagues,
|
||||
date_from=req.date_from,
|
||||
date_to=req.date_to,
|
||||
status=req.status,
|
||||
)
|
||||
await db.commit()
|
||||
return IngestResponse(**result)
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
raise HTTPException(500, str(e))
|
||||
|
||||
|
||||
@router.post("/ingest/understat", response_model=IngestSimpleResponse)
|
||||
async def ingest_understat_route(req: IngestUnderstatRequest):
|
||||
"""触发 understat xG 回填。"""
|
||||
source = get_source("understat")
|
||||
async with AsyncSessionLocal() as db:
|
||||
try:
|
||||
result = await source.ingest(db, league=req.league, season=req.season)
|
||||
return IngestSimpleResponse(**result)
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
raise HTTPException(500, str(e))
|
||||
|
||||
|
||||
@router.post("/ingest/injuries", response_model=IngestSimpleResponse)
|
||||
async def ingest_injuries_route(req: IngestInjuriesRequest):
|
||||
"""触发伤停采集。"""
|
||||
async with AsyncSessionLocal() as db:
|
||||
try:
|
||||
result = await ingest_injuries(db, date=req.date)
|
||||
return IngestSimpleResponse(**result)
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
raise HTTPException(500, str(e))
|
||||
@@ -0,0 +1,123 @@
|
||||
"""比赛/联赛查询路由。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from src.api.schemas import MatchListOut, MatchOut
|
||||
from src.db.base import AsyncSession, get_db_read
|
||||
from src.db.models import League, Match
|
||||
|
||||
router = APIRouter(prefix="/api/v1", tags=["data"])
|
||||
|
||||
|
||||
@router.get("/leagues", response_model=list[dict])
|
||||
async def list_leagues(db: AsyncSession = Depends(get_db_read)):
|
||||
stmt = select(League).order_by(League.name)
|
||||
result = await db.execute(stmt)
|
||||
leagues = result.scalars().all()
|
||||
return [{"id": l.id, "code": l.code, "name": l.name, "country": l.country} for l in leagues]
|
||||
|
||||
|
||||
@router.get("/matches", response_model=MatchListOut)
|
||||
async def list_matches(
|
||||
league: str | None = None,
|
||||
status: str | None = None,
|
||||
date: str | None = None,
|
||||
cursor: str | None = None,
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db_read),
|
||||
):
|
||||
"""比赛列表(游标分页)。"""
|
||||
q = select(Match).options(selectinload(Match.league), selectinload(Match.home_team), selectinload(Match.away_team))
|
||||
|
||||
if cursor:
|
||||
try:
|
||||
# 用 | 分隔,避免 isoformat 含 _ 时解析失败
|
||||
last_date_str, last_id_str = cursor.split("|", 1)
|
||||
last_date = datetime.fromisoformat(last_date_str)
|
||||
last_id = int(last_id_str)
|
||||
q = q.where(
|
||||
(Match.match_date < last_date) |
|
||||
((Match.match_date == last_date) & (Match.id < last_id))
|
||||
)
|
||||
except (ValueError, AttributeError):
|
||||
pass
|
||||
|
||||
if league:
|
||||
stmt = select(League.id).where(League.code == league)
|
||||
league_id = (await db.execute(stmt)).scalar_one_or_none()
|
||||
if league_id is None:
|
||||
return MatchListOut(items=[], next_cursor=None, has_more=False)
|
||||
q = q.where(Match.league_id == league_id)
|
||||
|
||||
if status:
|
||||
q = q.where(Match.match_status == status)
|
||||
|
||||
if date:
|
||||
try:
|
||||
d = datetime.strptime(date, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
raise HTTPException(400, "date 格式应为 YYYY-MM-DD")
|
||||
q = q.where(Match.match_date >= d, Match.match_date < d + timedelta(days=1))
|
||||
|
||||
rows = (await db.execute(q.order_by(Match.match_date.desc(), Match.id.desc()).limit(limit + 1))).scalars().all()
|
||||
has_more = len(rows) > limit
|
||||
rows = rows[:limit]
|
||||
|
||||
items = []
|
||||
for m in rows:
|
||||
items.append(MatchOut(
|
||||
id=m.id,
|
||||
league_code=m.league.code if m.league else None,
|
||||
season=m.season,
|
||||
home_team=m.home_team.name if m.home_team else "?",
|
||||
away_team=m.away_team.name if m.away_team else "?",
|
||||
home_team_zh=m.home_team.name_zh if m.home_team else None,
|
||||
away_team_zh=m.away_team.name_zh if m.away_team else None,
|
||||
match_date=m.match_date,
|
||||
match_status=m.match_status,
|
||||
home_goals=m.home_goals,
|
||||
away_goals=m.away_goals,
|
||||
match_stage=m.match_stage,
|
||||
home_xg=m.stats.home_xg if m.stats else None,
|
||||
away_xg=m.stats.away_xg if m.stats else None,
|
||||
))
|
||||
|
||||
next_cursor = None
|
||||
if has_more and items:
|
||||
last = rows[-1]
|
||||
next_cursor = f"{last.match_date.isoformat()}|{last.id}"
|
||||
|
||||
return MatchListOut(items=items, next_cursor=next_cursor, has_more=has_more)
|
||||
|
||||
|
||||
@router.get("/matches/{match_id}", response_model=MatchOut)
|
||||
async def get_match(match_id: int, db: AsyncSession = Depends(get_db_read)):
|
||||
stmt = (
|
||||
select(Match)
|
||||
.options(selectinload(Match.league), selectinload(Match.home_team), selectinload(Match.away_team))
|
||||
.where(Match.id == match_id)
|
||||
)
|
||||
m = (await db.execute(stmt)).scalar_one_or_none()
|
||||
if m is None:
|
||||
raise HTTPException(404, "match not found")
|
||||
return MatchOut(
|
||||
id=m.id,
|
||||
league_code=m.league.code if m.league else None,
|
||||
season=m.season,
|
||||
home_team=m.home_team.name if m.home_team else "?",
|
||||
away_team=m.away_team.name if m.away_team else "?",
|
||||
home_team_zh=m.home_team.name_zh if m.home_team else None,
|
||||
away_team_zh=m.away_team.name_zh if m.away_team else None,
|
||||
match_date=m.match_date,
|
||||
match_status=m.match_status,
|
||||
home_goals=m.home_goals,
|
||||
away_goals=m.away_goals,
|
||||
match_stage=m.match_stage,
|
||||
home_xg=m.stats.home_xg if m.stats else None,
|
||||
away_xg=m.stats.away_xg if m.stats else None,
|
||||
)
|
||||
@@ -0,0 +1,106 @@
|
||||
"""预测路由。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from src.api.schemas import PredictOut, PredictRequest, PredictionOut
|
||||
from src.db.base import AsyncSession, get_db, get_db_read
|
||||
from src.db.models import Prediction
|
||||
from src.llm.predict import predict_match, PredictResult
|
||||
|
||||
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。"""
|
||||
try:
|
||||
result = await predict_match(
|
||||
req.match_id,
|
||||
model=req.model,
|
||||
prompt_version=req.prompt_version,
|
||||
mode=req.mode,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(404, str(e))
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(502, str(e))
|
||||
|
||||
# single / multi 两种结果统一映射
|
||||
return PredictOut(
|
||||
prediction_id=result.prediction_id,
|
||||
provider=result.provider,
|
||||
model=result.model,
|
||||
prompt_version=getattr(result, "prompt_version", None),
|
||||
mode=getattr(result, "mode", "single"),
|
||||
pred_home_goals=result.pred_home_goals,
|
||||
pred_away_goals=result.pred_away_goals,
|
||||
pred_1x2=result.pred_1x2,
|
||||
confidence=result.confidence,
|
||||
reasoning=result.reasoning,
|
||||
agent_outputs=getattr(result, "agent_outputs", None),
|
||||
agent_weights=getattr(result, "agent_weights", None),
|
||||
context=result.context,
|
||||
latency_ms=result.latency_ms,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/predictions", response_model=list[PredictionOut])
|
||||
async def list_predictions(
|
||||
match_id: int | None = None,
|
||||
limit: int = 50,
|
||||
db: AsyncSession = Depends(get_db_read),
|
||||
):
|
||||
stmt = select(Prediction).options(selectinload(Prediction.match))
|
||||
if match_id:
|
||||
stmt = stmt.where(Prediction.match_id == match_id)
|
||||
stmt = stmt.order_by(Prediction.created_at.desc()).limit(limit)
|
||||
rows = (await db.execute(stmt)).scalars().all()
|
||||
return [
|
||||
PredictionOut(
|
||||
id=p.id,
|
||||
match_id=p.match_id,
|
||||
provider=p.provider,
|
||||
model=p.model,
|
||||
prompt_version=p.prompt_version,
|
||||
mode=p.mode or "single",
|
||||
pred_home_goals=p.pred_home_goals,
|
||||
pred_away_goals=p.pred_away_goals,
|
||||
pred_1x2=p.pred_1x2,
|
||||
confidence=p.confidence,
|
||||
reasoning=p.reasoning,
|
||||
agent_outputs=p.agent_outputs,
|
||||
created_at=p.created_at,
|
||||
actual_home_goals=p.actual_home_goals,
|
||||
actual_away_goals=p.actual_away_goals,
|
||||
settled=p.settled,
|
||||
)
|
||||
for p in rows
|
||||
]
|
||||
|
||||
|
||||
@router.get("/predictions/{prediction_id}", response_model=PredictionOut)
|
||||
async def get_prediction(prediction_id: int, db: AsyncSession = Depends(get_db_read)):
|
||||
p = await db.get(Prediction, prediction_id)
|
||||
if p is None:
|
||||
raise HTTPException(404, "prediction not found")
|
||||
return PredictionOut(
|
||||
id=p.id,
|
||||
match_id=p.match_id,
|
||||
provider=p.provider,
|
||||
model=p.model,
|
||||
prompt_version=p.prompt_version,
|
||||
mode=p.mode or "single",
|
||||
pred_home_goals=p.pred_home_goals,
|
||||
pred_away_goals=p.pred_away_goals,
|
||||
pred_1x2=p.pred_1x2,
|
||||
confidence=p.confidence,
|
||||
reasoning=p.reasoning,
|
||||
agent_outputs=p.agent_outputs,
|
||||
created_at=p.created_at,
|
||||
actual_home_goals=p.actual_home_goals,
|
||||
actual_away_goals=p.actual_away_goals,
|
||||
settled=p.settled,
|
||||
)
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Pydantic schemas。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class LeagueOut(BaseModel):
|
||||
id: int
|
||||
code: str
|
||||
name: str
|
||||
country: str | None
|
||||
|
||||
|
||||
class MatchOut(BaseModel):
|
||||
id: int
|
||||
league_code: str | None
|
||||
season: str | None
|
||||
home_team: str
|
||||
away_team: str
|
||||
home_team_zh: str | None
|
||||
away_team_zh: str | None
|
||||
match_date: datetime
|
||||
match_status: str
|
||||
home_goals: int | None
|
||||
away_goals: int | None
|
||||
match_stage: str | None
|
||||
home_xg: float | None = None
|
||||
away_xg: float | None = None
|
||||
|
||||
|
||||
class MatchListOut(BaseModel):
|
||||
items: list[MatchOut]
|
||||
next_cursor: str | None
|
||||
has_more: bool
|
||||
|
||||
|
||||
class PredictRequest(BaseModel):
|
||||
match_id: int
|
||||
provider: str | None = None
|
||||
model: str | None = None
|
||||
prompt_version: str | None = None
|
||||
mode: str = "multi" # multi(默认, 5专家+终裁) | single(单次调用)
|
||||
|
||||
|
||||
class PredictOut(BaseModel):
|
||||
prediction_id: int
|
||||
provider: str
|
||||
model: str
|
||||
prompt_version: str | None = None
|
||||
mode: str = "single"
|
||||
pred_home_goals: float | None
|
||||
pred_away_goals: float | None
|
||||
pred_1x2: str | None
|
||||
confidence: float | None
|
||||
reasoning: str | None
|
||||
agent_outputs: list[dict] | None = None
|
||||
agent_weights: dict | None = None
|
||||
context: str
|
||||
latency_ms: int | None
|
||||
|
||||
|
||||
class PredictionOut(BaseModel):
|
||||
id: int
|
||||
match_id: int
|
||||
provider: str
|
||||
model: str
|
||||
prompt_version: str
|
||||
mode: str = "single"
|
||||
pred_home_goals: float | None
|
||||
pred_away_goals: float | None
|
||||
pred_1x2: str | None
|
||||
confidence: float | None
|
||||
reasoning: str | None
|
||||
agent_outputs: list[dict] | None = None
|
||||
created_at: datetime
|
||||
actual_home_goals: int | None
|
||||
actual_away_goals: int | None
|
||||
settled: bool
|
||||
|
||||
|
||||
class IngestBzzoiroRequest(BaseModel):
|
||||
leagues: list[str] = Field(..., description="联赛代码列表,如 ['E0','SP1']")
|
||||
date_from: str | None = None
|
||||
date_to: str | None = None
|
||||
status: str = "finished"
|
||||
|
||||
|
||||
class IngestResponse(BaseModel):
|
||||
leagues: dict
|
||||
total_inserted: int
|
||||
total_updated: int
|
||||
errors: list[str] = []
|
||||
|
||||
|
||||
class IngestUnderstatRequest(BaseModel):
|
||||
league: str = Field(..., description="联赛代码,如 'E0'")
|
||||
season: int = Field(..., description="赛季起始年,如 2025 表示 2025-2026 赛季")
|
||||
|
||||
|
||||
class IngestInjuriesRequest(BaseModel):
|
||||
date: str | None = Field(None, description="日期 YYYY-MM-DD,为空则采集当天")
|
||||
|
||||
|
||||
class IngestSimpleResponse(BaseModel):
|
||||
count: int = 0
|
||||
updated: int = 0
|
||||
skipped: int = 0
|
||||
unmatched: int = 0
|
||||
errors: list[str] = []
|
||||
|
||||
|
||||
class SettleRequest(BaseModel):
|
||||
prediction_id: int
|
||||
home_goals: int = Field(ge=0, le=30)
|
||||
away_goals: int = Field(ge=0, le=30)
|
||||
|
||||
|
||||
class EvalSummaryOut(BaseModel):
|
||||
summary: list[dict[str, Any]]
|
||||
@@ -0,0 +1,37 @@
|
||||
"""pydantic-settings 配置。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||
|
||||
# --- app ---
|
||||
APP_ENV: str = "development"
|
||||
LOG_LEVEL: str = "INFO"
|
||||
|
||||
# --- database ---
|
||||
DATABASE_URL: str = "postgresql+asyncpg://football:football@localhost:5432/football"
|
||||
|
||||
# --- LLM (OpenAI-compatible) ---
|
||||
LLM_PROVIDER: str = "openai"
|
||||
LLM_API_KEY: str = ""
|
||||
LLM_BASE_URL: str = "https://api.openai.com/v1"
|
||||
LLM_MODEL: str = "gpt-4o"
|
||||
LLM_TIMEOUT: int = 60
|
||||
# multi-agent 分档: 专家用便宜快模型,终裁用强模型;空则回落 LLM_MODEL
|
||||
LLM_SPECIALIST_MODEL: str = ""
|
||||
LLM_AGGREGATOR_MODEL: str = ""
|
||||
|
||||
# --- data sources ---
|
||||
BZZOIRO_KEY: str = ""
|
||||
BZZOIRO_BASE: str = "https://sports.bzzoiro.com/api/v2"
|
||||
API_FOOTBALL_KEY: str = ""
|
||||
|
||||
# --- CORS ---
|
||||
CORS_ORIGINS: str = "http://localhost:5173,http://localhost:3000"
|
||||
|
||||
|
||||
settings = Settings()
|
||||
@@ -0,0 +1,31 @@
|
||||
"""共享 httpx 异步客户端(连接池复用 + 生命周期管理)。
|
||||
|
||||
使用方:
|
||||
- src/llm/provider.py: LLM 调用
|
||||
- src/data/understat.py: xG 抓取
|
||||
- src/data/injuries.py: 伤停抓取
|
||||
|
||||
生命周期由 FastAPI lifespan 管理(关闭时 aclose)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
|
||||
_shared_client: httpx.AsyncClient | None = None
|
||||
_default_timeout = 30
|
||||
|
||||
|
||||
def get_client() -> httpx.AsyncClient:
|
||||
"""获取共享客户端(懒初始化)。"""
|
||||
global _shared_client
|
||||
if _shared_client is None or _shared_client.is_closed:
|
||||
_shared_client = httpx.AsyncClient(timeout=_default_timeout)
|
||||
return _shared_client
|
||||
|
||||
|
||||
async def close_client() -> None:
|
||||
"""关闭共享客户端(在 FastAPI shutdown 时调用)。"""
|
||||
global _shared_client
|
||||
if _shared_client is not None and not _shared_client.is_closed:
|
||||
await _shared_client.aclose()
|
||||
_shared_client = None
|
||||
@@ -0,0 +1,226 @@
|
||||
"""Bzzoiro 数据源:抓取 + 入库。
|
||||
|
||||
迁移自旧项目 app/data/sources/bzzoiro/,改成 async + 简化入库。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json as _json
|
||||
import logging
|
||||
import time as _time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from collections.abc import Iterable
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from src.core.config import settings
|
||||
from src.data.config import BZZOIRO_LEAGUE_IDS, LEAGUE_COUNTRIES, LEAGUE_NAMES, REQUEST_INTERVAL
|
||||
from src.data.match_lookup import find_existing_match, get_or_create_team
|
||||
from src.data.normalize import normalize_bzzoiro
|
||||
from src.data.sources import register
|
||||
from src.db.models import League, Match, MatchStats
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _fetch_json_sync(path: str, params: dict | None = None, max_retries: int = 3) -> dict | list:
|
||||
"""同步 HTTP(bzzoiro 客户端保持同步,在 async 函数里 run_in_executor)。"""
|
||||
base = settings.BZZOIRO_BASE.rstrip("/")
|
||||
url = f"{base}/{path.lstrip('/')}"
|
||||
if params:
|
||||
url += "?" + urllib.parse.urlencode(params)
|
||||
key = settings.BZZOIRO_KEY
|
||||
if not key:
|
||||
raise RuntimeError("BZZOIRO_KEY 未设置")
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
req = urllib.request.Request(url)
|
||||
req.add_header("Authorization", f"Token {key}")
|
||||
req.add_header("Accept", "application/json")
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
return _json.loads(resp.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 429:
|
||||
logger.warning("bzzoiro 429, retry %d", attempt + 1)
|
||||
_time.sleep(1)
|
||||
continue
|
||||
raise
|
||||
raise RuntimeError("bzzoiro rate limit exceeded")
|
||||
|
||||
|
||||
async def fetch_bzzoiro_events(
|
||||
league_code: str,
|
||||
*,
|
||||
status: str = "finished",
|
||||
date_from: str | None = None,
|
||||
date_to: str | None = None,
|
||||
limit: int = 200,
|
||||
) -> list[dict]:
|
||||
"""抓取 bzzoiro 原始事件(异步包装)。"""
|
||||
league_id = BZZOIRO_LEAGUE_IDS.get(league_code)
|
||||
if league_id is None:
|
||||
raise ValueError(f"未知联赛代码: {league_code}")
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
rows: list[dict] = []
|
||||
offset = 0
|
||||
payload: dict | list = {}
|
||||
while True:
|
||||
params: dict = {
|
||||
"league_id": league_id,
|
||||
"status": status,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
}
|
||||
if date_from:
|
||||
params["date_from"] = str(date_from)[:10]
|
||||
if date_to:
|
||||
params["date_to"] = str(date_to)[:10]
|
||||
# 显式位置参数,避免 lambda 闭包捕获循环变量
|
||||
payload = await loop.run_in_executor(None, _fetch_json_sync, "/events/", params)
|
||||
batch = payload.get("results") or []
|
||||
if not batch:
|
||||
break
|
||||
rows.extend(batch)
|
||||
total = payload.get("total")
|
||||
offset += limit
|
||||
if total is not None and offset >= total:
|
||||
break
|
||||
if len(batch) < limit:
|
||||
break
|
||||
await asyncio.sleep(REQUEST_INTERVAL)
|
||||
return rows
|
||||
|
||||
|
||||
@register
|
||||
class BzzoiroSource:
|
||||
"""bzzoiro 数据源(实现 DataSource 协议)。"""
|
||||
|
||||
name = "bzzoiro"
|
||||
|
||||
async def ingest(
|
||||
self,
|
||||
db,
|
||||
*,
|
||||
leagues: Iterable[str],
|
||||
date_from: str | None = None,
|
||||
date_to: str | None = None,
|
||||
status: str = "finished",
|
||||
) -> dict:
|
||||
"""采集 bzzoiro → 入库。返回统计。"""
|
||||
result: dict = {"leagues": {}, "total_inserted": 0, "total_updated": 0, "errors": []}
|
||||
|
||||
for code in leagues:
|
||||
league_r: dict = {"inserted": 0, "updated": 0, "errors": []}
|
||||
try:
|
||||
raw_events = await fetch_bzzoiro_events(code, status=status, date_from=date_from, date_to=date_to)
|
||||
except Exception as e:
|
||||
logger.exception("bzzoiro fetch failed for %s", code)
|
||||
league_r["errors"].append(f"fetch failed: {e}")
|
||||
result["leagues"][code] = league_r
|
||||
continue
|
||||
|
||||
# 获取或创建联赛
|
||||
stmt = select(League).where(League.code == code)
|
||||
league = (await db.execute(stmt)).scalar_one_or_none()
|
||||
if league is None:
|
||||
league = League(code=code, name=LEAGUE_NAMES.get(code, code), country=LEAGUE_COUNTRIES.get(code))
|
||||
db.add(league)
|
||||
await db.flush()
|
||||
|
||||
for raw in raw_events:
|
||||
try:
|
||||
nm = normalize_bzzoiro(raw, code)
|
||||
if nm is None:
|
||||
continue
|
||||
nm.validate()
|
||||
except Exception as e:
|
||||
logger.debug("normalize skip: %s", e)
|
||||
league_r["errors"].append(f"normalize: {e}")
|
||||
continue
|
||||
|
||||
# 球队
|
||||
home_team = await get_or_create_team(db, nm.home_team)
|
||||
away_team = await get_or_create_team(db, nm.away_team)
|
||||
|
||||
# 查找已有比赛(天级匹配)
|
||||
existing = await find_existing_match(db, league.id, nm.home_team, nm.away_team, nm.date)
|
||||
|
||||
if existing is None:
|
||||
m = Match(
|
||||
league_id=league.id,
|
||||
season=nm.season_label or None,
|
||||
home_team_id=home_team.id,
|
||||
away_team_id=away_team.id,
|
||||
match_date=nm.date,
|
||||
match_date_date=nm.date.date() if hasattr(nm.date, "date") else nm.date,
|
||||
match_status=nm.match_status,
|
||||
home_goals=nm.home_goals,
|
||||
away_goals=nm.away_goals,
|
||||
home_ht_goals=nm.home_ht_goals,
|
||||
away_ht_goals=nm.away_ht_goals,
|
||||
match_stage=nm.match_stage,
|
||||
)
|
||||
db.add(m)
|
||||
await db.flush()
|
||||
if nm.home_xg is not None or nm.away_xg is not None:
|
||||
stats = MatchStats(
|
||||
match_id=m.id,
|
||||
home_xg=nm.home_xg,
|
||||
away_xg=nm.away_xg,
|
||||
home_shots=nm.home_shots,
|
||||
away_shots=nm.away_shots,
|
||||
home_shots_on_target=nm.home_shots_on_target,
|
||||
away_shots_on_target=nm.away_shots_on_target,
|
||||
home_corners=nm.home_corners,
|
||||
away_corners=nm.away_corners,
|
||||
home_possession=nm.home_possession,
|
||||
home_yellow_cards=nm.home_yellow_cards,
|
||||
away_yellow_cards=nm.away_yellow_cards,
|
||||
home_red_cards=nm.home_red_cards,
|
||||
away_red_cards=nm.away_red_cards,
|
||||
)
|
||||
db.add(stats)
|
||||
league_r["inserted"] += 1
|
||||
else:
|
||||
# 更新(只补空 / 状态升级)
|
||||
changed = False
|
||||
if existing.match_status != nm.match_status and nm.match_status == "finished":
|
||||
existing.match_status = nm.match_status
|
||||
changed = True
|
||||
if existing.home_goals is None and nm.home_goals is not None:
|
||||
existing.home_goals = nm.home_goals
|
||||
existing.away_goals = nm.away_goals
|
||||
existing.home_ht_goals = nm.home_ht_goals
|
||||
existing.away_ht_goals = nm.away_ht_goals
|
||||
changed = True
|
||||
if existing.match_stage is None and nm.match_stage:
|
||||
existing.match_stage = nm.match_stage
|
||||
changed = True
|
||||
# stats 只补空
|
||||
if existing.stats is None and (nm.home_xg is not None or nm.away_xg is not None):
|
||||
existing.stats = MatchStats(match_id=existing.id)
|
||||
db.add(existing.stats)
|
||||
await db.flush()
|
||||
if existing.stats is not None:
|
||||
for fld in ("home_xg", "away_xg", "home_shots", "away_shots",
|
||||
"home_shots_on_target", "away_shots_on_target",
|
||||
"home_corners", "away_corners", "home_possession",
|
||||
"home_yellow_cards", "away_yellow_cards",
|
||||
"home_red_cards", "away_red_cards"):
|
||||
if getattr(existing.stats, fld, None) is None:
|
||||
v = getattr(nm, fld, None)
|
||||
if v is not None:
|
||||
setattr(existing.stats, fld, v)
|
||||
changed = True
|
||||
if changed:
|
||||
league_r["updated"] += 1
|
||||
|
||||
await db.commit()
|
||||
result["leagues"][code] = league_r
|
||||
result["total_inserted"] += league_r["inserted"]
|
||||
result["total_updated"] += league_r["updated"]
|
||||
return result
|
||||
@@ -0,0 +1,46 @@
|
||||
"""数据源配置常量(联赛映射)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
# fdco 风格代码 → bzzoiro league_id
|
||||
BZZOIRO_LEAGUE_IDS: dict[str, int] = {
|
||||
"E0": 1, # Premier League
|
||||
"SP1": 3, # La Liga
|
||||
"D1": 5, # Bundesliga
|
||||
"I1": 4, # Serie A
|
||||
"F1": 6, # Ligue 1
|
||||
"CL": 7, # Champions League
|
||||
"EL": 8, # Europa League
|
||||
}
|
||||
|
||||
# fdco 代码 → understat 联赛代码
|
||||
FDCO_TO_UNDERSTAT: dict[str, str] = {
|
||||
"E0": "EPL",
|
||||
"SP1": "La_liga",
|
||||
"D1": "Bundesliga",
|
||||
"I1": "Serie_A",
|
||||
"F1": "Ligue_1",
|
||||
}
|
||||
|
||||
# fdco 代码 → 显示名
|
||||
LEAGUE_NAMES: dict[str, str] = {
|
||||
"E0": "Premier League",
|
||||
"SP1": "La Liga",
|
||||
"D1": "Bundesliga",
|
||||
"I1": "Serie A",
|
||||
"F1": "Ligue 1",
|
||||
"CL": "Champions League",
|
||||
"EL": "Europa League",
|
||||
}
|
||||
|
||||
# fdco 代码 → 国家
|
||||
LEAGUE_COUNTRIES: dict[str, str] = {
|
||||
"E0": "England",
|
||||
"SP1": "Spain",
|
||||
"D1": "Germany",
|
||||
"I1": "Italy",
|
||||
"F1": "France",
|
||||
"CL": "Europe",
|
||||
"EL": "Europe",
|
||||
}
|
||||
|
||||
REQUEST_INTERVAL = 1.2 # bzzoiro 限速(秒)
|
||||
@@ -0,0 +1,176 @@
|
||||
"""伤停数据采集器(api-football / api-sports.io)。
|
||||
|
||||
采集伤停数据并入库(injuries 表),供 injuries agent 使用。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from src.core.config import settings
|
||||
from src.core.http_client import get_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
API_BASE = "https://v3.football.api-sports.io"
|
||||
DEFAULT_HOST = "v3.football.api-sports.io"
|
||||
|
||||
# 缓存目录
|
||||
_CACHE_DIR = Path(__file__).resolve().parent.parent.parent / "data" / "injuries_cache"
|
||||
|
||||
|
||||
async def fetch_injuries(*, date: str | None = None, fixture_id: int | None = None, league_id: int | None = None) -> list[dict]:
|
||||
"""采集伤停数据。
|
||||
|
||||
Args:
|
||||
date: 日期 (YYYY-MM-DD),返当天全部伤停
|
||||
fixture_id: 指定比赛 ID
|
||||
league_id: 指定联赛 ID
|
||||
|
||||
Returns:
|
||||
伤停记录列表
|
||||
"""
|
||||
api_key = settings.API_FOOTBALL_KEY
|
||||
if not api_key:
|
||||
raise RuntimeError("API_FOOTBALL_KEY 未设置")
|
||||
|
||||
cache_dir = _CACHE_DIR
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 缓存命中
|
||||
cache_key = f"injuries_{date}_{fixture_id}_{league_id}.json"
|
||||
cache_file = cache_dir / cache_key
|
||||
if cache_file.exists():
|
||||
logger.debug("injuries cache hit: %s", cache_key)
|
||||
with open(cache_file, encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
headers = {
|
||||
"x-apisports-key": api_key,
|
||||
"x-rapidapi-host": DEFAULT_HOST,
|
||||
}
|
||||
params: dict[str, Any] = {}
|
||||
if date:
|
||||
params["date"] = date
|
||||
if fixture_id:
|
||||
params["fixture"] = fixture_id
|
||||
if league_id:
|
||||
params["league"] = league_id
|
||||
|
||||
url = f"{API_BASE}/injuries"
|
||||
client = get_client()
|
||||
resp = await client.get(url, headers=headers, params=params)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
injuries = data.get("response", [])
|
||||
|
||||
# 写缓存
|
||||
with open(cache_file, "w", encoding="utf-8") as f:
|
||||
json.dump(injuries, ensure_ascii=False, default=str, fp=f)
|
||||
|
||||
return injuries
|
||||
|
||||
|
||||
async def ingest_injuries(db, *, date: str | None = None) -> dict:
|
||||
"""采集伤停数据并入库(injuries 表)。"""
|
||||
from sqlalchemy import select
|
||||
|
||||
from src.data.team_names import normalize as normalize_name
|
||||
from src.db.models import Injury, Team
|
||||
|
||||
result = {"count": 0, "inserted": 0, "errors": []}
|
||||
|
||||
try:
|
||||
raw_injuries = await fetch_injuries(date=date)
|
||||
except Exception as e:
|
||||
logger.exception("injuries fetch failed")
|
||||
result["errors"].append(f"fetch failed: {e}")
|
||||
return result
|
||||
|
||||
result["count"] = len(raw_injuries)
|
||||
|
||||
# 预加载所有球队(用于按名匹配)
|
||||
teams = (await db.execute(select(Team))).scalars().all()
|
||||
team_by_name = {t.name: t.id for t in teams}
|
||||
|
||||
for raw in raw_injuries:
|
||||
try:
|
||||
player = raw.get("player", {}) or {}
|
||||
team = raw.get("team", {}) or {}
|
||||
fixture = raw.get("fixture", {}) or {}
|
||||
|
||||
player_name = player.get("name", "")
|
||||
team_name = normalize_name(team.get("name", ""))
|
||||
team_id = team_by_name.get(team_name)
|
||||
|
||||
# 解析日期
|
||||
fixture_date = fixture.get("date")
|
||||
injury_date = None
|
||||
if fixture_date:
|
||||
try:
|
||||
dt = datetime.fromisoformat(fixture_date.replace("Z", "+00:00"))
|
||||
injury_date = dt.date()
|
||||
except (ValueError, AttributeError):
|
||||
pass
|
||||
|
||||
player_id = player.get("id")
|
||||
fixture_id = fixture.get("id")
|
||||
|
||||
# 幂等: 已存在则跳过
|
||||
existing = (
|
||||
await db.execute(
|
||||
select(Injury).where(
|
||||
Injury.player_id == player_id,
|
||||
Injury.fixture_id == fixture_id,
|
||||
Injury.injury_type == player.get("type"),
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
if existing is not None:
|
||||
continue
|
||||
|
||||
injury = Injury(
|
||||
player_id=player_id,
|
||||
player_name=player_name,
|
||||
team_id=team_id,
|
||||
fixture_id=fixture_id,
|
||||
league_id=(raw.get("league") or {}).get("id"),
|
||||
injury_type=player.get("type"),
|
||||
reason=player.get("reason"),
|
||||
injury_date=injury_date,
|
||||
)
|
||||
db.add(injury)
|
||||
result["inserted"] += 1
|
||||
except Exception as e:
|
||||
result["errors"].append(f"parse error: {e}")
|
||||
|
||||
await db.commit()
|
||||
logger.info("injuries: fetched %d, inserted %d for %s", result["count"], result["inserted"], date)
|
||||
return result
|
||||
|
||||
|
||||
async def get_injuries_for_match(db, team_id: int, match_date) -> list[Injury]:
|
||||
"""查询某场比赛前某队的伤停名单(比赛日仍缺阵的)。"""
|
||||
from sqlalchemy import and_, or_, select
|
||||
|
||||
from src.db.models import Injury
|
||||
|
||||
if hasattr(match_date, "date"):
|
||||
match_date = match_date.date()
|
||||
|
||||
stmt = (
|
||||
select(Injury)
|
||||
.where(Injury.team_id == team_id)
|
||||
.where(Injury.injury_date <= match_date)
|
||||
.where(or_(Injury.return_date.is_(None), Injury.return_date >= match_date))
|
||||
.order_by(Injury.injury_date.desc())
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
@@ -0,0 +1,40 @@
|
||||
"""比赛匹配辅助函数(多数据源共用)。
|
||||
|
||||
bzzoiro / understat 等数据源在入库时都需要:
|
||||
- 按队名获取或创建球队(get_or_create_team)
|
||||
- 按联赛+主队+客队+日期找已有比赛(find_existing_match)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from src.db.models import Match, Team
|
||||
|
||||
|
||||
async def get_or_create_team(db, name: str) -> Team:
|
||||
"""按名获取球队,不存在则创建。"""
|
||||
stmt = select(Team).where(Team.name == name)
|
||||
team = (await db.execute(stmt)).scalar_one_or_none()
|
||||
if team is None:
|
||||
team = Team(name=name)
|
||||
db.add(team)
|
||||
await db.flush()
|
||||
return team
|
||||
|
||||
|
||||
async def find_existing_match(db, league_id: int, home_name: str, away_name: str, date) -> Match | None:
|
||||
"""按联赛+主队+客队+日期找已有比赛(天级匹配,避免时间精度差异)。"""
|
||||
home_team = (await db.execute(select(Team).where(Team.name == home_name))).scalar_one_or_none()
|
||||
away_team = (await db.execute(select(Team).where(Team.name == away_name))).scalar_one_or_none()
|
||||
if home_team is None or away_team is None:
|
||||
return None
|
||||
|
||||
date_only = date.date() if hasattr(date, "date") else date
|
||||
stmt = (
|
||||
select(Match)
|
||||
.where(Match.league_id == league_id)
|
||||
.where(Match.home_team_id == home_team.id)
|
||||
.where(Match.away_team_id == away_team.id)
|
||||
.where(func.date(Match.match_date) == date_only)
|
||||
)
|
||||
return (await db.execute(stmt)).scalar_one_or_none()
|
||||
@@ -0,0 +1,209 @@
|
||||
"""数据规范化:任意数据源原始记录 → NormalizedMatch。
|
||||
|
||||
迁移自旧项目 app/data/normalize.py,简化:
|
||||
- 去掉 XGBackfill 双轨(不再需要独立回填)
|
||||
- 去掉 PIT 时间契约(无训练集要防泄漏)
|
||||
- 保留核心清洗契约(队名归一、日期解析、数值范围)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
VALID_STATUS = {"finished", "scheduled", "in_play", "paused", "postponed", "cancelled", "suspended"}
|
||||
|
||||
STATUS_MAP = {
|
||||
"finished": "finished", "completed": "finished", "done": "finished", "awarded": "finished",
|
||||
"scheduled": "scheduled", "upcoming": "scheduled",
|
||||
"in_play": "in_play", "live": "in_play",
|
||||
"paused": "paused", "postponed": "postponed",
|
||||
"cancelled": "cancelled", "canceled": "cancelled", "abandoned": "cancelled",
|
||||
"suspended": "suspended",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class NormalizedMatch:
|
||||
"""清洗后的统一比赛记录(入库中间格式)。"""
|
||||
|
||||
league_type: str
|
||||
date: datetime
|
||||
home_team: str
|
||||
away_team: str
|
||||
match_status: str = "finished"
|
||||
home_goals: int | None = None
|
||||
away_goals: int | None = None
|
||||
season_label: str = ""
|
||||
home_xg: float | None = None
|
||||
away_xg: float | None = None
|
||||
home_shots: int | None = None
|
||||
away_shots: int | None = None
|
||||
home_shots_on_target: int | None = None
|
||||
away_shots_on_target: int | None = None
|
||||
home_corners: int | None = None
|
||||
away_corners: int | None = None
|
||||
home_possession: float | None = None
|
||||
home_yellow_cards: int | None = None
|
||||
away_yellow_cards: int | None = None
|
||||
home_red_cards: int | None = None
|
||||
away_red_cards: int | None = None
|
||||
home_ht_goals: int | None = None
|
||||
away_ht_goals: int | None = None
|
||||
match_stage: str | None = None
|
||||
|
||||
def validate(self) -> None:
|
||||
"""完整数据契约校验。"""
|
||||
if self.match_status == "finished" and (self.home_goals is None or self.away_goals is None):
|
||||
raise ValueError(f"Finished match must have score: {self.home_team} vs {self.away_team}")
|
||||
|
||||
def _finite(n, v):
|
||||
if v is not None and isinstance(v, float) and not math.isfinite(v):
|
||||
raise ValueError(f"{n} must be finite, got {v}")
|
||||
|
||||
def _range(n, v, lo, hi):
|
||||
if v is not None and not (lo <= v <= hi):
|
||||
raise ValueError(f"{n} out of range [{lo}, {hi}]: {v}")
|
||||
|
||||
for side in ("home", "away"):
|
||||
_finite(f"{side}_goals", getattr(self, f"{side}_goals"))
|
||||
_range(f"{side}_goals", getattr(self, f"{side}_goals"), 0, 30)
|
||||
_finite(f"{side}_xg", getattr(self, f"{side}_xg"))
|
||||
_range(f"{side}_xg", getattr(self, f"{side}_xg"), 0, 20)
|
||||
for fld in ("shots", "shots_on_target", "corners"):
|
||||
_range(f"{side}_{fld}", getattr(self, f"{side}_{fld}"), 0, 100)
|
||||
for fld in ("yellow_cards", "red_cards"):
|
||||
_range(f"{side}_{fld}", getattr(self, f"{side}_{fld}"), 0, 20)
|
||||
_range("home_possession", self.home_possession, 0, 100)
|
||||
if self.home_ht_goals is not None and self.home_goals is not None and self.home_ht_goals > self.home_goals:
|
||||
raise ValueError(f"home_ht_goals({self.home_ht_goals}) > home_goals({self.home_goals})")
|
||||
if self.away_ht_goals is not None and self.away_goals is not None and self.away_ht_goals > self.away_goals:
|
||||
raise ValueError(f"away_ht_goals({self.away_ht_goals}) > away_goals({self.away_goals})")
|
||||
|
||||
|
||||
def derive_season_label(date: datetime) -> str:
|
||||
y = date.year
|
||||
return f"{y}-{y + 1}" if date.month >= 8 else f"{y - 1}-{y}"
|
||||
|
||||
|
||||
def _parse_date(value) -> datetime | None:
|
||||
"""日期解析 → UTC datetime(带 tzinfo)。"""
|
||||
if value in (None, ""):
|
||||
return None
|
||||
if isinstance(value, (int, float)):
|
||||
return datetime.fromtimestamp(value, tz=timezone.utc)
|
||||
s = str(value).strip()
|
||||
if not s:
|
||||
return None
|
||||
iso_s = s[:-1] + "+00:00" if s.endswith("Z") else s
|
||||
try:
|
||||
dt = datetime.fromisoformat(iso_s)
|
||||
if dt.tzinfo is not None:
|
||||
return dt.astimezone(timezone.utc)
|
||||
return dt.replace(tzinfo=timezone.utc)
|
||||
except ValueError:
|
||||
pass
|
||||
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y-%m-%d", "%d/%m/%Y", "%d/%m/%y"):
|
||||
try:
|
||||
return datetime.strptime(s[:19], fmt).replace(tzinfo=timezone.utc)
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _to_int(v) -> int | None:
|
||||
if v is None or (isinstance(v, str) and v.strip() in ("", "-")):
|
||||
return None
|
||||
if isinstance(v, bool):
|
||||
return None
|
||||
if isinstance(v, int):
|
||||
return v
|
||||
try:
|
||||
f = float(str(v).strip())
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if not f.is_integer():
|
||||
return None
|
||||
return int(f)
|
||||
|
||||
|
||||
def _to_float(v) -> float | None:
|
||||
if v is None or (isinstance(v, str) and v.strip() in ("", "-")):
|
||||
return None
|
||||
try:
|
||||
return float(str(v).strip())
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def normalize_bzzoiro(raw: dict, league_type: str) -> NormalizedMatch | None:
|
||||
"""bzzoiro event → NormalizedMatch。"""
|
||||
from src.data.team_names import normalize as normalize_name
|
||||
|
||||
date = _parse_date(raw.get("event_date"))
|
||||
if date is None:
|
||||
return None
|
||||
raw_status = str(raw.get("status", "")).lower()
|
||||
status = STATUS_MAP.get(raw_status)
|
||||
if status is None:
|
||||
return None
|
||||
home = normalize_name(raw.get("home_team", ""))
|
||||
away = normalize_name(raw.get("away_team", ""))
|
||||
if not home or not away or home == away:
|
||||
return None
|
||||
m = NormalizedMatch(
|
||||
league_type=league_type,
|
||||
date=date,
|
||||
home_team=home,
|
||||
away_team=away,
|
||||
match_status=status,
|
||||
season_label=derive_season_label(date),
|
||||
)
|
||||
m.home_goals = _to_int(raw.get("home_score", raw.get("home_goals")))
|
||||
m.away_goals = _to_int(raw.get("away_score", raw.get("away_goals")))
|
||||
m.home_ht_goals = _to_int(raw.get("home_score_ht", raw.get("home_ht_goals")))
|
||||
m.away_ht_goals = _to_int(raw.get("away_score_ht", raw.get("away_ht_goals")))
|
||||
_rn = _to_int(raw.get("round_number"))
|
||||
_rn_name = str(raw.get("round_name") or "").strip()
|
||||
if _rn_name:
|
||||
m.match_stage = _rn_name
|
||||
elif _rn:
|
||||
m.match_stage = f"第 {_rn} 轮"
|
||||
if m.match_status == "finished" and m.home_goals is None:
|
||||
m.match_status = "scheduled"
|
||||
return m
|
||||
|
||||
|
||||
def normalize_understat(raw: dict, league_type: str) -> NormalizedMatch | None:
|
||||
"""understat 单场 → NormalizedMatch(仅 xG)。"""
|
||||
from src.data.team_names import normalize as normalize_name
|
||||
|
||||
dt_str = raw.get("datetime") or raw.get("date")
|
||||
if not dt_str:
|
||||
return None
|
||||
dt = _parse_date(dt_str)
|
||||
if dt is None:
|
||||
return None
|
||||
home_info = raw.get("h", {})
|
||||
away_info = raw.get("a", {})
|
||||
home_name = home_info.get("title", "") if isinstance(home_info, dict) else ""
|
||||
away_name = away_info.get("title", "") if isinstance(away_info, dict) else ""
|
||||
home = normalize_name(home_name)
|
||||
away = normalize_name(away_name)
|
||||
if not home or not away or home == away:
|
||||
return None
|
||||
home_xg = raw.get("xG", {}).get("h") if isinstance(raw.get("xG"), dict) else None
|
||||
away_xg = raw.get("xG", {}).get("a") if isinstance(raw.get("xG"), dict) else None
|
||||
return NormalizedMatch(
|
||||
league_type=league_type,
|
||||
date=dt,
|
||||
home_team=home,
|
||||
away_team=away,
|
||||
match_status="finished",
|
||||
season_label=derive_season_label(dt),
|
||||
home_xg=_to_float(home_xg),
|
||||
away_xg=_to_float(away_xg),
|
||||
)
|
||||
@@ -0,0 +1,52 @@
|
||||
"""数据源协议 + 注册表。
|
||||
|
||||
定义 DataSource 契约,并提供全局注册表供路由层分发。
|
||||
每个比赛数据源实现该协议,注册后即可通过统一入口调度。
|
||||
|
||||
注: injuries 是球员级独立领域(写 Injury 表),不遵循此协议。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol
|
||||
|
||||
from src.db.base import AsyncSession
|
||||
|
||||
|
||||
class DataSource(Protocol):
|
||||
"""比赛数据源契约:抓取 → 规范化 → 入库。"""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""数据源标识名(用于路由/日志)。"""
|
||||
...
|
||||
|
||||
async def ingest(self, db: AsyncSession, **kwargs) -> dict:
|
||||
"""执行完整采集流程,返回统计。"""
|
||||
...
|
||||
|
||||
|
||||
# ── 注册表 ──
|
||||
_SOURCES: dict[str, DataSource] = {}
|
||||
|
||||
|
||||
def register(source: DataSource) -> DataSource:
|
||||
"""装饰器:将数据源注册到全局注册表。"""
|
||||
_SOURCES[source.name] = source
|
||||
return source
|
||||
|
||||
|
||||
def get_source(name: str) -> DataSource:
|
||||
"""按名获取数据源。"""
|
||||
if name not in _SOURCES:
|
||||
raise ValueError(f"未知数据源: {name}")
|
||||
return _SOURCES[name]
|
||||
|
||||
|
||||
def list_sources() -> list[str]:
|
||||
"""列出所有已注册数据源名。"""
|
||||
return list(_SOURCES.keys())
|
||||
|
||||
|
||||
# ── 导入数据源触发 @register ──
|
||||
from src.data.bzzoiro import BzzoiroSource # noqa: E402, F401
|
||||
from src.data.understat import UnderstatSource # noqa: E402, F401
|
||||
@@ -0,0 +1,137 @@
|
||||
"""队名归一化:各源队名 → 统一规范名。
|
||||
|
||||
迁移自旧项目 app/data/team_names.py。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import unicodedata
|
||||
|
||||
NORMALIZE_MAP = {
|
||||
# ---- 英超 ----
|
||||
"Man City": "Manchester City",
|
||||
"Man United": "Manchester United",
|
||||
"Newcastle": "Newcastle United",
|
||||
"Nott'm Forest": "Nottingham Forest",
|
||||
"Wolves": "Wolverhampton Wanderers",
|
||||
"West Ham": "West Ham United",
|
||||
"Tottenham": "Tottenham Hotspur",
|
||||
"Spurs": "Tottenham Hotspur",
|
||||
"Brighton": "Brighton and Hove Albion",
|
||||
"West Brom": "West Bromwich Albion",
|
||||
"Stoke": "Stoke City",
|
||||
"Huddersfield": "Huddersfield Town",
|
||||
"Swansea": "Swansea City",
|
||||
"Hull": "Hull City",
|
||||
"Cardiff": "Cardiff City",
|
||||
"Luton": "Luton Town",
|
||||
"Norwich": "Norwich City",
|
||||
"Bournemouth": "AFC Bournemouth",
|
||||
"Ipswich": "Ipswich Town",
|
||||
"Leicester": "Leicester City",
|
||||
"Leeds": "Leeds United",
|
||||
"Sheffield United": "Sheffield United",
|
||||
"Southampton": "Southampton",
|
||||
"Arsenal": "Arsenal",
|
||||
"Aston Villa": "Aston Villa",
|
||||
"Brentford": "Brentford",
|
||||
"Chelsea": "Chelsea",
|
||||
"Crystal Palace": "Crystal Palace",
|
||||
"Everton": "Everton",
|
||||
"Fulham": "Fulham",
|
||||
"Liverpool": "Liverpool",
|
||||
# ---- 西甲 ----
|
||||
"Atletico Madrid": "Atlético Madrid",
|
||||
"Athletic Club": "Athletic Club",
|
||||
"Real Betis": "Real Betis",
|
||||
"Celta Vigo": "Celta Vigo",
|
||||
"Deportivo Alaves": "Deportivo Alavés",
|
||||
"Girona": "Girona",
|
||||
"Las Palmas": "Las Palmas",
|
||||
"Leganes": "Leganés",
|
||||
"Mallorca": "Mallorca",
|
||||
"Osasuna": "Osasuna",
|
||||
"Rayo Vallecano": "Rayo Vallecano",
|
||||
"Real Sociedad": "Real Sociedad",
|
||||
"Sevilla": "Sevilla",
|
||||
"Valencia": "Valencia",
|
||||
"Villarreal": "Villarreal",
|
||||
"Espanyol": "Espanyol",
|
||||
"Getafe": "Getafe",
|
||||
"Real Madrid": "Real Madrid",
|
||||
"Barcelona": "Barcelona",
|
||||
# ---- 德甲 ----
|
||||
"Bayern Munich": "Bayern München",
|
||||
"FC Koln": "FC Köln",
|
||||
"RB Leipzig": "RB Leipzig",
|
||||
"Borussia Dortmund": "Borussia Dortmund",
|
||||
"Borussia M'gladbach": "Borussia Mönchengladbach",
|
||||
"Bayer Leverkusen": "Bayer Leverkusen",
|
||||
"Eintracht Frankfurt": "Eintracht Frankfurt",
|
||||
"VfB Stuttgart": "VfB Stuttgart",
|
||||
"VfL Wolfsburg": "VfL Wolfsburg",
|
||||
"Werder Bremen": "Werder Bremen",
|
||||
"TSG Hoffenheim": "TSG Hoffenheim",
|
||||
"SC Freiburg": "SC Freiburg",
|
||||
"Union Berlin": "Union Berlin",
|
||||
"Mainz": "Mainz 05",
|
||||
"Augsburg": "FC Augsburg",
|
||||
"Bochum": "VfL Bochum",
|
||||
"Heidenheim": "1. FC Heidenheim",
|
||||
"St. Pauli": "FC St. Pauli",
|
||||
"Holstein Kiel": "Holstein Kiel",
|
||||
# ---- 意甲 ----
|
||||
"AC Milan": "AC Milan",
|
||||
"Inter": "Inter Milan",
|
||||
"Inter Milan": "Inter Milan",
|
||||
"Juventus": "Juventus",
|
||||
"Napoli": "SSC Napoli",
|
||||
"Roma": "AS Roma",
|
||||
"Lazio": "Lazio",
|
||||
"Atalanta": "Atalanta",
|
||||
"Fiorentina": "ACF Fiorentina",
|
||||
"Bologna": "Bologna",
|
||||
"Torino": "Torino",
|
||||
"Monza": "AC Monza",
|
||||
"Udinese": "Udinese",
|
||||
"Sassuolo": "Sassuolo",
|
||||
"Empoli": "Empoli",
|
||||
"Cagliari": "Cagliari",
|
||||
"Genoa": "Genoa",
|
||||
"Lecce": "Lecce",
|
||||
"Hellas Verona": "Hellas Verona",
|
||||
"Parma": "Parma",
|
||||
"Como": "Como",
|
||||
"Venezia": "Venezia",
|
||||
# ---- 法甲 ----
|
||||
"PSG": "Paris Saint-Germain",
|
||||
"Paris Saint-Germain": "Paris Saint-Germain",
|
||||
"Marseille": "Olympique Marseille",
|
||||
"Lyon": "Olympique Lyonnais",
|
||||
"Monaco": "AS Monaco",
|
||||
"Lille": "Lille OSC",
|
||||
"Nice": "OGC Nice",
|
||||
"Rennes": "Stade Rennais",
|
||||
"Lens": "RC Lens",
|
||||
"Strasbourg": "RC Strasbourg",
|
||||
"Brest": "Stade Brestois",
|
||||
"Nantes": "FC Nantes",
|
||||
"Reims": "Stade de Reims",
|
||||
"Toulouse": "Toulouse FC",
|
||||
"Montpellier": "Montpellier HSC",
|
||||
"Le Havre": "Le Havre AC",
|
||||
"Lorient": "FC Lorient",
|
||||
"Saint-Etienne": "AS Saint-Étienne",
|
||||
"Angers": "Angers SCO",
|
||||
"Auxerre": "AJ Auxerre",
|
||||
"Leganes": "Leganés",
|
||||
}
|
||||
|
||||
|
||||
def normalize(name: str) -> str:
|
||||
if not name:
|
||||
return ""
|
||||
# unicode 归一(重音)
|
||||
n = unicodedata.normalize("NFKD", name)
|
||||
n = "".join(c for c in n if not unicodedata.combining(c))
|
||||
n = n.strip()
|
||||
return NORMALIZE_MAP.get(n, n)
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Understat xG 数据源。
|
||||
|
||||
迁移自旧项目 app/data/sources/understat.py,改成 async。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
|
||||
from src.core.http_client import get_client
|
||||
from src.data.config import FDCO_TO_UNDERSTAT, LEAGUE_NAMES
|
||||
from src.data.match_lookup import find_existing_match
|
||||
from src.data.normalize import normalize_understat
|
||||
from src.data.sources import register
|
||||
from src.db.models import League, Match, MatchStats
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
UNDERSTAT_BASE = "https://understat.com/getLeagueData/{league}/{season}"
|
||||
|
||||
|
||||
async def fetch_understat(league_code: str, season: int) -> list[dict]:
|
||||
"""抓取 understat 单赛季 xG 数据。
|
||||
|
||||
Args:
|
||||
league_code: fdco 风格代码,如 'E0'
|
||||
season: 赛季起始年,如 2025 表示 2025-2026 赛季
|
||||
|
||||
Returns:
|
||||
比赛数组,每项含 datetime/h/a/xG
|
||||
"""
|
||||
understat_league = FDCO_TO_UNDERSTAT.get(league_code)
|
||||
if understat_league is None:
|
||||
raise ValueError(f"未知联赛代码: {league_code}")
|
||||
|
||||
url = UNDERSTAT_BASE.format(league=understat_league, season=season)
|
||||
headers = {
|
||||
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36",
|
||||
"X-Requested-With": "XMLHttpRequest",
|
||||
"Referer": f"https://understat.com/league/{understat_league}/{season}",
|
||||
}
|
||||
|
||||
client = get_client()
|
||||
resp = await client.get(url, headers=headers)
|
||||
resp.raise_for_status()
|
||||
# understat 返回 JS 对象,需要提取 JSON
|
||||
text = resp.text
|
||||
# 匹配 var datesData = JSON.parse('...');
|
||||
match = re.search(r"var\s+datesData\s*=\s*JSON\.parse\('([^']+)'\)", text)
|
||||
if not match:
|
||||
logger.warning("understat 响应格式不符: %s...", text[:200])
|
||||
return []
|
||||
decoded = match.group(1).encode().decode("unicode_escape")
|
||||
data = json.loads(decoded)
|
||||
return data
|
||||
|
||||
|
||||
@register
|
||||
class UnderstatSource:
|
||||
"""understat xG 数据源(实现 DataSource 协议)。"""
|
||||
|
||||
name = "understat"
|
||||
|
||||
async def ingest(self, db, *, league: str, season: int) -> dict:
|
||||
"""采集 understat xG → 回填到现有 Match。只回填 xG 字段,不创建新 Match。"""
|
||||
from sqlalchemy import select
|
||||
|
||||
result = {"updated": 0, "skipped": 0, "unmatched": 0, "errors": []}
|
||||
|
||||
try:
|
||||
raw_matches = await fetch_understat(league, season)
|
||||
except Exception as e:
|
||||
logger.exception("understat fetch failed for %s %s", league, season)
|
||||
result["errors"].append(f"fetch failed: {e}")
|
||||
return result
|
||||
|
||||
# 查联赛
|
||||
stmt = select(League).where(League.code == league)
|
||||
league_obj = (await db.execute(stmt)).scalar_one_or_none()
|
||||
if league_obj is None:
|
||||
result["errors"].append(f"league {league} not found in DB")
|
||||
return result
|
||||
|
||||
for raw in raw_matches:
|
||||
if not raw.get("isResult"):
|
||||
continue
|
||||
try:
|
||||
nm = normalize_understat(raw, league)
|
||||
if nm is None:
|
||||
result["skipped"] += 1
|
||||
continue
|
||||
except Exception as e:
|
||||
result["errors"].append(f"normalize: {e}")
|
||||
continue
|
||||
|
||||
# 匹配已有 Match(天级)
|
||||
existing = await find_existing_match(db, league_obj.id, nm.home_team, nm.away_team, nm.date)
|
||||
if existing is None:
|
||||
result["unmatched"] += 1
|
||||
continue
|
||||
|
||||
# 回填 xG
|
||||
if existing.stats is None and (nm.home_xg is not None or nm.away_xg is not None):
|
||||
existing.stats = MatchStats(match_id=existing.id)
|
||||
db.add(existing.stats)
|
||||
await db.flush()
|
||||
if existing.stats is not None:
|
||||
if existing.stats.home_xg is None and nm.home_xg is not None:
|
||||
existing.stats.home_xg = nm.home_xg
|
||||
result["updated"] += 1
|
||||
if existing.stats.away_xg is None and nm.away_xg is not None:
|
||||
existing.stats.away_xg = nm.away_xg
|
||||
|
||||
await db.commit()
|
||||
return result
|
||||
@@ -0,0 +1,58 @@
|
||||
"""SQLAlchemy async engine + session。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from src.core.config import settings
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
engine = create_async_engine(
|
||||
settings.DATABASE_URL,
|
||||
echo=False,
|
||||
pool_pre_ping=True,
|
||||
pool_size=10,
|
||||
max_overflow=20,
|
||||
)
|
||||
|
||||
AsyncSessionLocal = async_sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
autocommit=False,
|
||||
autoflush=False,
|
||||
)
|
||||
|
||||
|
||||
async def get_db() -> AsyncIterator[AsyncSession]:
|
||||
"""写路由用: 退出时自动 commit。"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
async def get_db_read() -> AsyncIterator[AsyncSession]:
|
||||
"""读路由用: 不 commit(只读)。"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
async def init_db() -> None:
|
||||
"""开发/测试用:建表。生产建议用 alembic。"""
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
@@ -0,0 +1,175 @@
|
||||
"""5 张表 ORM: leagues / teams / matches / match_stats / predictions。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
Date,
|
||||
DateTime,
|
||||
Float,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from src.db.base import Base
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class League(Base):
|
||||
__tablename__ = "leagues"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
code: Mapped[str] = mapped_column(String(20), unique=True, nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
country: Mapped[str | None] = mapped_column(String(50))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
|
||||
|
||||
matches: Mapped[list["Match"]] = relationship(back_populates="league")
|
||||
|
||||
|
||||
class Team(Base):
|
||||
__tablename__ = "teams"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(120), unique=True, nullable=False)
|
||||
name_zh: Mapped[str | None] = mapped_column(String(60))
|
||||
team_type: Mapped[str] = mapped_column(String(20), default="club")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
|
||||
|
||||
home_matches: Mapped[list["Match"]] = relationship(foreign_keys="Match.home_team_id", back_populates="home_team")
|
||||
away_matches: Mapped[list["Match"]] = relationship(foreign_keys="Match.away_team_id", back_populates="away_team")
|
||||
|
||||
|
||||
class Match(Base):
|
||||
__tablename__ = "matches"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
league_id: Mapped[int] = mapped_column(ForeignKey("leagues.id"), nullable=False)
|
||||
season: Mapped[str | None] = mapped_column(String(12))
|
||||
home_team_id: Mapped[int] = mapped_column(ForeignKey("teams.id"), nullable=False)
|
||||
away_team_id: Mapped[int] = mapped_column(ForeignKey("teams.id"), nullable=False)
|
||||
match_date: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
match_date_date: Mapped[date] = mapped_column(
|
||||
"match_date_date",
|
||||
Date,
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
match_status: Mapped[str] = mapped_column(String(20), default="scheduled")
|
||||
home_goals: Mapped[int | None] = mapped_column(Integer)
|
||||
away_goals: Mapped[int | None] = mapped_column(Integer)
|
||||
home_ht_goals: Mapped[int | None] = mapped_column(Integer)
|
||||
away_ht_goals: Mapped[int | None] = mapped_column(Integer)
|
||||
match_stage: Mapped[str | None] = mapped_column(String(100))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, onupdate=_utcnow)
|
||||
|
||||
league: Mapped[League] = relationship(back_populates="matches")
|
||||
home_team: Mapped[Team] = relationship(foreign_keys=[home_team_id], back_populates="home_matches")
|
||||
away_team: Mapped[Team] = relationship(foreign_keys=[away_team_id], back_populates="away_matches")
|
||||
stats: Mapped["MatchStats | None"] = relationship(back_populates="match", cascade="all, delete-orphan")
|
||||
predictions: Mapped[list["Prediction"]] = relationship(back_populates="match", cascade="all, delete-orphan")
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_matches_league_date", "league_id", match_date.desc()),
|
||||
Index("ix_matches_home_date", "home_team_id", match_date.desc()),
|
||||
Index("ix_matches_away_date", "away_team_id", match_date.desc()),
|
||||
Index("ix_matches_status_date", "match_status", match_date.desc()),
|
||||
Index(
|
||||
"ix_matches_unique",
|
||||
"league_id",
|
||||
"home_team_id",
|
||||
"away_team_id",
|
||||
"match_date_date",
|
||||
unique=True,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class MatchStats(Base):
|
||||
__tablename__ = "match_stats"
|
||||
|
||||
match_id: Mapped[int] = mapped_column(ForeignKey("matches.id", ondelete="CASCADE"), primary_key=True)
|
||||
home_xg: Mapped[float | None] = mapped_column(Float)
|
||||
away_xg: Mapped[float | None] = mapped_column(Float)
|
||||
home_shots: Mapped[int | None] = mapped_column(Integer)
|
||||
away_shots: Mapped[int | None] = mapped_column(Integer)
|
||||
home_shots_on_target: Mapped[int | None] = mapped_column(Integer)
|
||||
away_shots_on_target: Mapped[int | None] = mapped_column(Integer)
|
||||
home_corners: Mapped[int | None] = mapped_column(Integer)
|
||||
away_corners: Mapped[int | None] = mapped_column(Integer)
|
||||
home_possession: Mapped[float | None] = mapped_column(Float)
|
||||
home_yellow_cards: Mapped[int | None] = mapped_column(Integer)
|
||||
away_yellow_cards: Mapped[int | None] = mapped_column(Integer)
|
||||
home_red_cards: Mapped[int | None] = mapped_column(Integer)
|
||||
away_red_cards: Mapped[int | None] = mapped_column(Integer)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, onupdate=_utcnow)
|
||||
|
||||
match: Mapped[Match] = relationship(back_populates="stats")
|
||||
|
||||
|
||||
class Injury(Base):
|
||||
"""球员伤停记录(api-football 数据源)。"""
|
||||
__tablename__ = "injuries"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
player_id: Mapped[int | None] = mapped_column(Integer, index=True)
|
||||
player_name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
team_id: Mapped[int | None] = mapped_column(ForeignKey("teams.id"), index=True)
|
||||
fixture_id: Mapped[int | None] = mapped_column(Integer)
|
||||
league_id: Mapped[int | None] = mapped_column(Integer)
|
||||
injury_type: Mapped[str | None] = mapped_column(String(50)) # Missing Fixture / Suspended
|
||||
reason: Mapped[str | None] = mapped_column(String(200))
|
||||
injury_date: Mapped[date | None] = mapped_column(Date, index=True)
|
||||
return_date: Mapped[date | None] = mapped_column(Date)
|
||||
retrieved_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
|
||||
|
||||
team: Mapped["Team | None"] = relationship()
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_injuries_player_fixture", "player_id", "fixture_id", "injury_type", unique=True),
|
||||
Index("ix_injuries_team_date", "team_id", "injury_date"),
|
||||
)
|
||||
|
||||
|
||||
class Prediction(Base):
|
||||
__tablename__ = "predictions"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
match_id: Mapped[int] = mapped_column(ForeignKey("matches.id"), nullable=False)
|
||||
provider: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||
model: Mapped[str] = mapped_column(String(80), nullable=False)
|
||||
prompt_version: Mapped[str] = mapped_column(String(20), nullable=False, default="v1")
|
||||
prompt_tokens: Mapped[int | None] = mapped_column(Integer)
|
||||
completion_tokens: Mapped[int | None] = mapped_column(Integer)
|
||||
latency_ms: Mapped[int | None] = mapped_column(Integer)
|
||||
pred_home_goals: Mapped[float | None] = mapped_column(Float)
|
||||
pred_away_goals: Mapped[float | None] = mapped_column(Float)
|
||||
pred_1x2: Mapped[str | None] = mapped_column(String(3))
|
||||
confidence: Mapped[float | None] = mapped_column(Float)
|
||||
reasoning: Mapped[str | None] = mapped_column(Text)
|
||||
raw_response: Mapped[dict | None] = mapped_column(JSONB)
|
||||
# multi-agent 模式: 各专家报告
|
||||
mode: Mapped[str] = mapped_column(String(20), nullable=False, default="single")
|
||||
agent_outputs: Mapped[dict | None] = mapped_column(JSONB)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
|
||||
actual_home_goals: Mapped[int | None] = mapped_column(Integer)
|
||||
actual_away_goals: Mapped[int | None] = mapped_column(Integer)
|
||||
settled: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
match: Mapped[Match] = relationship(back_populates="predictions")
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_predictions_match", "match_id"),
|
||||
Index("ix_predictions_provider_model", "provider", "model"),
|
||||
)
|
||||
@@ -0,0 +1,11 @@
|
||||
"""多 agent 预测层。"""
|
||||
from src.llm.agents.base import AgentReport, AgentSpec, run_agent
|
||||
from src.llm.agents.orchestrator import MultiPredictResult, predict_match_multi
|
||||
|
||||
__all__ = [
|
||||
"AgentReport",
|
||||
"AgentSpec",
|
||||
"run_agent",
|
||||
"MultiPredictResult",
|
||||
"predict_match_multi",
|
||||
]
|
||||
@@ -0,0 +1,183 @@
|
||||
"""Agent 基础设施: spec 定义 + 执行器。
|
||||
|
||||
执行语义:
|
||||
1. 数据切片为空 / 明确 no_data → 跳过 LLM, 直接返回 stub(省 token 防幻觉)
|
||||
2. LLM 调用失败 → fail-open, 报告标记 status=error, 不阻断整体
|
||||
3. 解析失败(LLM 没输出合法 JSON) → status=parse_error
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from src.llm.context_builder import MatchHeader
|
||||
from src.llm.provider import LLMProvider, LLMResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_PROMPT_DIR = Path(__file__).resolve().parent.parent / "prompts" / "agents"
|
||||
|
||||
NO_DATA_SENTINELS = ("无数据", "no data", "no_data")
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=16)
|
||||
def load_agent_prompt(name: str, version: str = "v1") -> str:
|
||||
"""缓存加载 agent prompt 模板。"""
|
||||
path = _PROMPT_DIR / f"{name}_{version}.md"
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"agent prompt 不存在: {path}")
|
||||
with open(path, encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentSpec:
|
||||
"""领域专家 agent 定义。"""
|
||||
name: str # h2h / form / standings / injuries / xg
|
||||
system_prompt: str # system message
|
||||
slice_fn: object # async (header, before) -> str 切片函数
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentReport:
|
||||
"""专家 agent 统一输出契约。"""
|
||||
agent: str
|
||||
status: str = "ok" # ok | no_data | error | parse_error
|
||||
data_sufficiency: str = "medium" # high | medium | low | none
|
||||
analysis: str = ""
|
||||
home_edge: float | None = None # -1.0 ~ 1.0, 正=利主队
|
||||
confidence: float | None = None # 0.0 ~ 1.0
|
||||
key_evidence: list[str] = field(default_factory=list)
|
||||
# xg agent 专属
|
||||
exp_home_goals: float | None = None
|
||||
exp_away_goals: float | None = None
|
||||
probable_score: str | None = None
|
||||
# 元信息
|
||||
model: str = ""
|
||||
latency_ms: int | None = None
|
||||
prompt_tokens: int | None = None
|
||||
completion_tokens: int | None = None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"agent": self.agent,
|
||||
"status": self.status,
|
||||
"data_sufficiency": self.data_sufficiency,
|
||||
"analysis": self.analysis,
|
||||
"home_edge": self.home_edge,
|
||||
"confidence": self.confidence,
|
||||
"key_evidence": self.key_evidence,
|
||||
"exp_home_goals": self.exp_home_goals,
|
||||
"exp_away_goals": self.exp_away_goals,
|
||||
"probable_score": self.probable_score,
|
||||
"model": self.model,
|
||||
"latency_ms": self.latency_ms,
|
||||
"prompt_tokens": self.prompt_tokens,
|
||||
"completion_tokens": self.completion_tokens,
|
||||
}
|
||||
|
||||
|
||||
def _is_no_data(slice_text: str) -> bool:
|
||||
"""切片是否全无数据(除了标题行全是无数据)。"""
|
||||
body = [ln.strip() for ln in slice_text.splitlines() if ln.strip()]
|
||||
# 去掉标题行(── 开头)
|
||||
content = [ln for ln in body if not ln.startswith("──")]
|
||||
if not content:
|
||||
return True
|
||||
return all(any(s in ln for s in NO_DATA_SENTINELS) for ln in content)
|
||||
|
||||
|
||||
def _stub_no_data(agent: str) -> AgentReport:
|
||||
return AgentReport(
|
||||
agent=agent,
|
||||
status="no_data",
|
||||
data_sufficiency="none",
|
||||
analysis="该维度无数据,跳过分析。",
|
||||
)
|
||||
|
||||
|
||||
def _parse_report(agent: str, parsed: dict, resp: LLMResponse, model: str) -> AgentReport:
|
||||
"""把 LLM JSON 输出解析为 AgentReport,字段宽容处理。"""
|
||||
def _f(v, default=None):
|
||||
try:
|
||||
return float(v) if v is not None else default
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
suff = str(parsed.get("data_sufficiency", "medium")).lower()
|
||||
if suff not in ("high", "medium", "low", "none"):
|
||||
suff = "medium"
|
||||
|
||||
evidence = parsed.get("key_evidence") or []
|
||||
if isinstance(evidence, str):
|
||||
evidence = [evidence]
|
||||
|
||||
score = parsed.get("probable_score")
|
||||
if isinstance(score, dict):
|
||||
score = f"{score.get('home', '?')}-{score.get('away', '?')}"
|
||||
|
||||
return AgentReport(
|
||||
agent=agent,
|
||||
status="ok",
|
||||
data_sufficiency=suff,
|
||||
analysis=str(parsed.get("analysis", ""))[:600],
|
||||
home_edge=_f(parsed.get("home_edge")),
|
||||
confidence=_f(parsed.get("confidence")),
|
||||
key_evidence=[str(e)[:120] for e in evidence[:5]],
|
||||
exp_home_goals=_f(parsed.get("exp_home_goals")),
|
||||
exp_away_goals=_f(parsed.get("exp_away_goals")),
|
||||
probable_score=score if isinstance(score, str) else None,
|
||||
model=model,
|
||||
latency_ms=resp.latency_ms,
|
||||
prompt_tokens=resp.prompt_tokens,
|
||||
completion_tokens=resp.completion_tokens,
|
||||
)
|
||||
|
||||
|
||||
async def run_agent(
|
||||
spec: AgentSpec,
|
||||
header: MatchHeader,
|
||||
provider: LLMProvider,
|
||||
*,
|
||||
before=None,
|
||||
version: str = "v1",
|
||||
) -> AgentReport:
|
||||
"""执行单个专家 agent: 切片 → no_data 门控 → 调 LLM → 解析报告。"""
|
||||
# 1. 数据切片
|
||||
try:
|
||||
slice_text = await spec.slice_fn(header, before=before)
|
||||
except Exception as e:
|
||||
logger.exception("agent %s slice failed", spec.name)
|
||||
return AgentReport(agent=spec.name, status="error", analysis=f"数据切片失败: {e}")
|
||||
|
||||
# 2. no_data 门控: 切片无数据 → 不调 LLM
|
||||
if _is_no_data(slice_text):
|
||||
logger.debug("agent %s: slice is no_data, skipping LLM", spec.name)
|
||||
return _stub_no_data(spec.name)
|
||||
|
||||
# 3. 拼 prompt(模板中 {{context}} 为切片占位)
|
||||
template = load_agent_prompt(spec.name, version)
|
||||
user_prompt = template.replace("{{context}}", slice_text)
|
||||
|
||||
# 4. 调 LLM
|
||||
resp = await provider.chat(
|
||||
system=spec.system_prompt,
|
||||
user=user_prompt,
|
||||
json_mode=True,
|
||||
temperature=0.2,
|
||||
max_tokens=600,
|
||||
)
|
||||
if resp.error:
|
||||
logger.warning("agent %s LLM failed: %s", spec.name, resp.error)
|
||||
return AgentReport(agent=spec.name, status="error", analysis=f"LLM 调用失败: {resp.error}")
|
||||
|
||||
# 5. 解析
|
||||
if not resp.parsed:
|
||||
return AgentReport(
|
||||
agent=spec.name,
|
||||
status="parse_error",
|
||||
analysis=f"LLM 输出无法解析为 JSON: {resp.content[:200]}",
|
||||
)
|
||||
return _parse_report(spec.name, resp.parsed, resp, provider.model)
|
||||
@@ -0,0 +1,224 @@
|
||||
"""多 agent 预测编排: 并行专家 → 终裁 → 存库。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
from src.core.config import settings
|
||||
from src.db.base import AsyncSessionLocal
|
||||
from src.db.models import Match, Prediction
|
||||
from src.llm.agents.base import AgentReport, AgentSpec, load_agent_prompt
|
||||
from src.llm.context_builder import (
|
||||
MatchHeader,
|
||||
form_slice,
|
||||
h2h_slice,
|
||||
header_text,
|
||||
home_away_slice,
|
||||
injuries_slice,
|
||||
load_match_header,
|
||||
stats_slice,
|
||||
)
|
||||
from src.llm.provider import LLMProvider, get_default_provider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── 5 个专家 agent 定义 ──
|
||||
# A=近期状态 B=攻防数据 C=主客因素 D=阵容完整性 E=历史交锋
|
||||
SPECIALIST_SPECS: list[AgentSpec] = [
|
||||
AgentSpec(
|
||||
name="form",
|
||||
system_prompt="你是足球近期状态分析专家。分析比分与关键事件,输出近期走势判断。只输出 JSON。",
|
||||
slice_fn=form_slice,
|
||||
),
|
||||
AgentSpec(
|
||||
name="stats",
|
||||
system_prompt="你是足球攻防数据分析专家。评估进球、射门与控球,输出攻防强度。只输出 JSON。",
|
||||
slice_fn=stats_slice,
|
||||
),
|
||||
AgentSpec(
|
||||
name="home_away",
|
||||
system_prompt="你是足球主客因素分析专家。对比主场与客场表现,评估地理优势影响。只输出 JSON。",
|
||||
slice_fn=home_away_slice,
|
||||
),
|
||||
AgentSpec(
|
||||
name="injuries",
|
||||
system_prompt="你是足球阵容完整性分析专家。汇总伤停与停赛名单,输出战力缺失程度。只输出 JSON。",
|
||||
slice_fn=injuries_slice,
|
||||
),
|
||||
AgentSpec(
|
||||
name="h2h",
|
||||
system_prompt="你是足球历史交锋分析专家。分析过去数年以及近期的交手数据,提取交手规律。只输出 JSON。",
|
||||
slice_fn=h2h_slice,
|
||||
),
|
||||
]
|
||||
|
||||
AGGREGATOR_SYSTEM = "你是足球预测终裁专家。综合各领域报告输出最终预测。只输出 JSON。"
|
||||
|
||||
|
||||
@dataclass
|
||||
class MultiPredictResult:
|
||||
prediction_id: int
|
||||
provider: str
|
||||
model: str
|
||||
prompt_version: str
|
||||
mode: str
|
||||
pred_home_goals: float | None
|
||||
pred_away_goals: float | None
|
||||
pred_1x2: str | None
|
||||
confidence: float | None
|
||||
reasoning: str | None
|
||||
agent_outputs: list[dict]
|
||||
agent_weights: dict | None
|
||||
context: str
|
||||
latency_ms: int | None
|
||||
raw: dict | None
|
||||
|
||||
|
||||
def _get_specialist_provider() -> LLMProvider:
|
||||
"""专家模型: LLM_SPECIALIST_MODEL 回落 LLM_MODEL。"""
|
||||
p = get_default_provider()
|
||||
if settings.LLM_SPECIALIST_MODEL:
|
||||
p.model = settings.LLM_SPECIALIST_MODEL
|
||||
return p
|
||||
|
||||
|
||||
def _get_aggregator_provider() -> LLMProvider:
|
||||
"""终裁模型: LLM_AGGREGATOR_MODEL 回落 LLM_MODEL。"""
|
||||
p = get_default_provider()
|
||||
if settings.LLM_AGGREGATOR_MODEL:
|
||||
p.model = settings.LLM_AGGREGATOR_MODEL
|
||||
return p
|
||||
|
||||
|
||||
async def run_specialists(
|
||||
header: MatchHeader,
|
||||
*,
|
||||
provider: LLMProvider,
|
||||
version: str = "v1",
|
||||
) -> list[AgentReport]:
|
||||
"""并行执行 5 个专家 agent。fail-open: 单个失败不影响其他。"""
|
||||
tasks = [
|
||||
_run_one(spec, header, provider, version=version)
|
||||
for spec in SPECIALIST_SPECS
|
||||
]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
reports: list[AgentReport] = []
|
||||
for spec, r in zip(SPECIALIST_SPECS, results):
|
||||
if isinstance(r, Exception):
|
||||
logger.warning("agent %s raised: %s", spec.name, r)
|
||||
reports.append(AgentReport(agent=spec.name, status="error", analysis=str(r)[:200]))
|
||||
else:
|
||||
reports.append(r)
|
||||
return reports
|
||||
|
||||
|
||||
async def _run_one(spec, header, provider, *, version) -> AgentReport:
|
||||
from src.llm.agents.base import run_agent
|
||||
|
||||
return await run_agent(spec, header, provider, before=header.match_dt, version=version)
|
||||
|
||||
|
||||
def _reports_to_json(reports: list[AgentReport]) -> str:
|
||||
return json.dumps([r.to_dict() for r in reports], ensure_ascii=False, indent=1)
|
||||
|
||||
|
||||
async def run_aggregator(
|
||||
header: MatchHeader,
|
||||
reports: list[AgentReport],
|
||||
*,
|
||||
provider: LLMProvider,
|
||||
version: str = "v1",
|
||||
) -> tuple[dict, int, int]:
|
||||
"""终裁: 汇总报告 → 最终 JSON。返回 (解析结果, prompt_tokens, completion_tokens)。"""
|
||||
template = load_agent_prompt("aggregator", version)
|
||||
user_prompt = (
|
||||
template
|
||||
.replace("{{match_header}}", header_text(header))
|
||||
.replace("{{agent_reports}}", _reports_to_json(reports))
|
||||
)
|
||||
resp = await provider.chat(
|
||||
system=AGGREGATOR_SYSTEM,
|
||||
user=user_prompt,
|
||||
json_mode=True,
|
||||
temperature=0.2,
|
||||
max_tokens=1000,
|
||||
)
|
||||
if resp.error:
|
||||
raise RuntimeError(f"aggregator LLM error: {resp.error}")
|
||||
if not resp.parsed:
|
||||
raise RuntimeError(f"aggregator 输出无法解析: {resp.content[:200]}")
|
||||
return resp.parsed, resp.prompt_tokens or 0, resp.completion_tokens or 0
|
||||
|
||||
|
||||
async def predict_match_multi(
|
||||
match_id: int,
|
||||
*,
|
||||
provider: LLMProvider | None = None,
|
||||
version: str = "v1",
|
||||
) -> MultiPredictResult:
|
||||
"""多 agent 端到端预测: 切片 → 并行专家 → 终裁 → 存库。"""
|
||||
start = time.perf_counter()
|
||||
|
||||
# 1. 比赛头(各 agent 共享;不存在则 404)
|
||||
header = await load_match_header(match_id)
|
||||
|
||||
# 2. 并行专家
|
||||
specialist_provider = _get_specialist_provider()
|
||||
reports = await run_specialists(header, provider=specialist_provider, version=version)
|
||||
|
||||
# 3. 终裁
|
||||
aggregator_provider = _get_aggregator_provider()
|
||||
final, agg_prompt_tokens, agg_completion_tokens = await run_aggregator(
|
||||
header, reports, provider=aggregator_provider, version=version
|
||||
)
|
||||
|
||||
latency_ms = int((time.perf_counter() - start) * 1000)
|
||||
|
||||
# 4. 存库
|
||||
async with AsyncSessionLocal() as db:
|
||||
m = await db.get(Match, match_id)
|
||||
if m is None:
|
||||
raise ValueError(f"match {match_id} not found")
|
||||
|
||||
agent_weights = final.get("agent_weights")
|
||||
pred = Prediction(
|
||||
match_id=match_id,
|
||||
provider=settings.LLM_PROVIDER,
|
||||
model=aggregator_provider.model,
|
||||
prompt_version=f"multi_{version}",
|
||||
mode="multi",
|
||||
prompt_tokens=sum(r.prompt_tokens or 0 for r in reports) + agg_prompt_tokens,
|
||||
completion_tokens=sum(r.completion_tokens or 0 for r in reports) + agg_completion_tokens,
|
||||
latency_ms=latency_ms,
|
||||
pred_home_goals=final.get("pred_home_goals"),
|
||||
pred_away_goals=final.get("pred_away_goals"),
|
||||
pred_1x2=final.get("1x2"),
|
||||
confidence=final.get("confidence"),
|
||||
reasoning=final.get("reasoning"),
|
||||
raw_response=final,
|
||||
agent_outputs=[r.to_dict() for r in reports],
|
||||
)
|
||||
db.add(pred)
|
||||
await db.commit()
|
||||
await db.refresh(pred)
|
||||
|
||||
return MultiPredictResult(
|
||||
prediction_id=pred.id,
|
||||
provider=pred.provider,
|
||||
model=pred.model,
|
||||
prompt_version=pred.prompt_version,
|
||||
mode="multi",
|
||||
pred_home_goals=pred.pred_home_goals,
|
||||
pred_away_goals=pred.pred_away_goals,
|
||||
pred_1x2=pred.pred_1x2,
|
||||
confidence=pred.confidence,
|
||||
reasoning=pred.reasoning,
|
||||
agent_outputs=pred.agent_outputs,
|
||||
agent_weights=agent_weights,
|
||||
context=_reports_to_json(reports),
|
||||
latency_ms=latency_ms,
|
||||
raw=final,
|
||||
)
|
||||
@@ -0,0 +1,365 @@
|
||||
"""上下文构建器:数据切片 + 拼接。
|
||||
|
||||
架构:
|
||||
- match_header: 比赛基础信息(对阵双方/联赛/时间)
|
||||
- 切片函数: 每个领域 agent 一个数据切片(h2h / form / standings / injuries / xg)
|
||||
- build_context: 单 agent 路径,拼接全部切片(行为与旧版一致)
|
||||
|
||||
multi-agent 路径由 agents/orchestrator.py 调用切片函数,每个专家只拿自己的切片。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from src.db.base import AsyncSessionLocal
|
||||
from src.db.models import Match
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _outcome(home_goals: int, away_goals: int, side: str) -> str:
|
||||
"""从某队视角看赛果: W/D/L。"""
|
||||
if home_goals is None or away_goals is None:
|
||||
return "?"
|
||||
if side == "home":
|
||||
return "W" if home_goals > away_goals else ("D" if home_goals == away_goals else "L")
|
||||
return "W" if away_goals > home_goals else ("D" if away_goals == home_goals else "L")
|
||||
|
||||
|
||||
@dataclass
|
||||
class MatchContext:
|
||||
match_id: int
|
||||
text: str
|
||||
has_stats: bool
|
||||
has_injuries: bool
|
||||
|
||||
|
||||
@dataclass
|
||||
class MatchHeader:
|
||||
"""比赛基础信息(所有 agent 共享)。"""
|
||||
match_id: int
|
||||
home_name: str
|
||||
away_name: str
|
||||
league_name: str
|
||||
season: str | None
|
||||
match_date: str
|
||||
match_dt: object # 原始 datetime,回测防泄漏用
|
||||
stage: str | None
|
||||
home_team_id: int
|
||||
away_team_id: int
|
||||
league_id: int
|
||||
|
||||
|
||||
async def load_match_header(match_id: int) -> MatchHeader:
|
||||
"""加载比赛头信息(各 agent 共用)。"""
|
||||
async with AsyncSessionLocal() as db:
|
||||
m = await _load_match(db, match_id)
|
||||
return _to_header(m)
|
||||
|
||||
|
||||
def _to_header(m: Match) -> MatchHeader:
|
||||
return MatchHeader(
|
||||
match_id=m.id,
|
||||
home_name=m.home_team.name_zh or m.home_team.name,
|
||||
away_name=m.away_team.name_zh or m.away_team.name,
|
||||
league_name=m.league.name if m.league else "?",
|
||||
season=m.season,
|
||||
match_date=m.match_date.strftime("%Y-%m-%d %H:%M UTC") if m.match_date else "?",
|
||||
match_dt=m.match_date,
|
||||
stage=m.match_stage,
|
||||
home_team_id=m.home_team_id,
|
||||
away_team_id=m.away_team_id,
|
||||
league_id=m.league_id,
|
||||
)
|
||||
|
||||
|
||||
def header_text(h: MatchHeader) -> str:
|
||||
stage = f" {h.stage}" if h.stage else ""
|
||||
return (
|
||||
f"对阵: {h.home_name} vs {h.away_name} | {h.league_name} {h.season or '?'}{stage} | {h.match_date}"
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 切片函数: 每个领域 agent 一个
|
||||
# ============================================================
|
||||
|
||||
async def h2h_slice(header: MatchHeader, *, limit: int = 8, before=None) -> str:
|
||||
"""E - 历史交锋切片: 过去数年 + 近期交手数据,提取交手规律。before=match_date 用于回测。"""
|
||||
async with AsyncSessionLocal() as db:
|
||||
h2h = await _get_h2h(db, header.home_team_id, header.away_team_id, before=before, limit=limit)
|
||||
lines = [f"── 历史交锋(近 {limit} 次) ──"]
|
||||
if h2h:
|
||||
home_wins = draws = away_wins = 0
|
||||
for hm in h2h:
|
||||
d = hm.match_date.strftime("%Y-%m") if hm.match_date else "?"
|
||||
if hm.home_goals is not None:
|
||||
if hm.home_goals > hm.away_goals: home_wins += 1
|
||||
elif hm.home_goals == hm.away_goals: draws += 1
|
||||
else: away_wins += 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
|
||||
if total:
|
||||
lines.append(f" 总计 {total} 场: 主队 {home_wins}胜 {draws}平 {away_wins}负")
|
||||
else:
|
||||
lines.append(" 无数据")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
async def form_slice(header: MatchHeader, *, limit: int = 5, before=None) -> str:
|
||||
"""A - 近期状态切片: 两队近 N 场赛果、关键事件、走势判断。before=match_date 用于回测。"""
|
||||
async with AsyncSessionLocal() as db:
|
||||
home_form = await _get_form(db, header.home_team_id, before=before, limit=limit)
|
||||
away_form = await _get_form(db, header.away_team_id, before=before, limit=limit)
|
||||
lines = []
|
||||
for label, name, form, side in (
|
||||
("主队", header.home_name, home_form, "home"),
|
||||
("客队", header.away_name, away_form, "away"),
|
||||
):
|
||||
lines.append(f"── {label}近况({name},近 {limit} 场) ──")
|
||||
if form:
|
||||
wins = draws = losses = 0
|
||||
for fm in form:
|
||||
o = _outcome(fm.home_goals, fm.away_goals, side)
|
||||
if o == "W": wins += 1
|
||||
elif o == "D": draws += 1
|
||||
else: losses += 1
|
||||
score = f"{fm.home_goals}-{fm.away_goals}" if fm.home_goals is not None else "vs"
|
||||
xg = ""
|
||||
if fm.stats and fm.stats.home_xg is not None:
|
||||
own = fm.stats.home_xg if side == "home" else fm.stats.away_xg
|
||||
xg = f" (xG {own:.1f})"
|
||||
opp = fm.away_team.name if side == "home" else fm.home_team.name
|
||||
lines.append(f" {o} {score} vs {opp}{xg}")
|
||||
lines.append(f" 近 {len(form)} 场: {wins}胜 {draws}平 {losses}负")
|
||||
else:
|
||||
lines.append(" 无数据")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
async def stats_slice(header: MatchHeader, *, limit: int = 10, before=None) -> str:
|
||||
"""B - 攻防数据切片: 进球、射门、控球,评估攻防强度。before=match_date 用于回测。"""
|
||||
async with AsyncSessionLocal() as db:
|
||||
home_form = await _get_form(db, header.home_team_id, before=before, limit=limit)
|
||||
away_form = await _get_form(db, header.away_team_id, before=before, limit=limit)
|
||||
lines = [f"── 攻防数据(近 {limit} 场) ──"]
|
||||
for label, name, form, side in (
|
||||
("主队", header.home_name, home_form, "home"),
|
||||
("客队", header.away_name, away_form, "away"),
|
||||
):
|
||||
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
|
||||
n += 1
|
||||
if fm.stats:
|
||||
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
|
||||
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)
|
||||
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
|
||||
n_xg += 1
|
||||
if n > 0:
|
||||
lines.append(f" {label} {name}:")
|
||||
lines.append(f" 场均进球 {gf/n:.2f}, 场均失球 {ga/n:.2f}")
|
||||
if n_shots: lines.append(f" 场均射门 {shots/n_shots:.1f}, 射正 {sot/n_shots:.1f}")
|
||||
if n_poss: lines.append(f" 平均控球 {poss/n_poss:.1f}%")
|
||||
if n_xg: lines.append(f" 场均 xG {xg/n_xg:.2f}, 场均被 xG {xga/n_xg:.2f}")
|
||||
else:
|
||||
lines.append(f" {label} {name}: 无比分数据")
|
||||
else:
|
||||
lines.append(f" {label} {name}: 无数据")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
async def home_away_slice(header: MatchHeader, *, limit: int = 10, before=None) -> str:
|
||||
"""C - 主客因素切片: 主场战绩 vs 客场战绩,评估地理优势影响。before=match_date 用于回测。"""
|
||||
async with AsyncSessionLocal() as db:
|
||||
home_home = await _get_home_away(db, header.home_team_id, "home", before=before, limit=limit)
|
||||
away_away = await _get_home_away(db, header.away_team_id, "away", before=before, limit=limit)
|
||||
lines = ["── 主客因素 ──"]
|
||||
for label, name, matches, side in (
|
||||
("主队主场", header.home_name, home_home, "home"),
|
||||
("客队客场", header.away_name, away_away, "away"),
|
||||
):
|
||||
if matches:
|
||||
wins = draws = losses = gf = ga = 0
|
||||
for m in matches:
|
||||
if m.home_goals is None: continue
|
||||
o = _outcome(m.home_goals, m.away_goals, side)
|
||||
if o == "W": wins += 1
|
||||
elif o == "D": draws += 1
|
||||
else: losses += 1
|
||||
gf += m.home_goals if side == "home" else m.away_goals
|
||||
ga += m.away_goals if side == "home" else m.home_goals
|
||||
n = wins + draws + losses
|
||||
if n > 0:
|
||||
pct = wins / n * 100
|
||||
lines.append(f" {label} {name}(近 {n} 场): {wins}胜 {draws}平 {losses}负, 胜率 {pct:.0f}%")
|
||||
lines.append(f" 场均进球 {gf/n:.2f}, 场均失球 {ga/n:.2f}")
|
||||
else:
|
||||
lines.append(f" {label} {name}: 无比分数据")
|
||||
else:
|
||||
lines.append(f" {label} {name}: 无数据")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
async def injuries_slice(header: MatchHeader, *, before=None) -> str:
|
||||
"""D - 阵容完整性切片: 伤停与停赛名单,评估战力缺失程度。"""
|
||||
from src.data.injuries import get_injuries_for_match
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
home_injuries = await get_injuries_for_match(db, header.home_team_id, before or header.match_dt)
|
||||
away_injuries = await get_injuries_for_match(db, header.away_team_id, before or header.match_dt)
|
||||
|
||||
lines = ["── 阵容完整性 ──"]
|
||||
has_data = False
|
||||
for label, injuries in (("主队", home_injuries), ("客队", away_injuries)):
|
||||
if injuries:
|
||||
has_data = True
|
||||
lines.append(f" {label}伤停({len(injuries)}人):")
|
||||
for inj in injuries[:8]: # 最多显示 8 条
|
||||
reason = inj.reason or inj.injury_type or "未知"
|
||||
lines.append(f" - {inj.player_name}: {reason}")
|
||||
if len(injuries) > 8:
|
||||
lines.append(f" ...及其他 {len(injuries) - 8} 人")
|
||||
else:
|
||||
lines.append(f" {label}: 无伤停数据")
|
||||
|
||||
if not has_data:
|
||||
return "── 阵容完整性 ──\n 无数据"
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 单 agent 路径: 拼接全部切片(行为与旧版一致)
|
||||
# ============================================================
|
||||
|
||||
async def build_context(match_id: int, *, form_last: int = 5, h2h_last: int = 5) -> MatchContext:
|
||||
"""单 agent 路径的完整上下文: 拼接全部切片(before=比赛时间,防未来信息)。"""
|
||||
header = await load_match_header(match_id)
|
||||
parts = [header_text(header), ""]
|
||||
has_stats = False
|
||||
has_injuries = False
|
||||
|
||||
form_text = await form_slice(header, limit=form_last, before=header.match_dt)
|
||||
if "无数据" not in form_text:
|
||||
has_stats = True
|
||||
parts.append(form_text)
|
||||
parts.append("")
|
||||
|
||||
h2h_text = await h2h_slice(header, limit=h2h_last, before=header.match_dt)
|
||||
parts.append(h2h_text)
|
||||
parts.append("")
|
||||
|
||||
stats_text = await stats_slice(header, before=header.match_dt)
|
||||
if "无数据" not in stats_text:
|
||||
has_stats = True
|
||||
parts.append(stats_text)
|
||||
parts.append("")
|
||||
|
||||
home_away_text = await home_away_slice(header, before=header.match_dt)
|
||||
parts.append(home_away_text)
|
||||
parts.append("")
|
||||
|
||||
injuries_text = await injuries_slice(header, before=header.match_dt)
|
||||
if "无数据" not in injuries_text:
|
||||
has_injuries = True
|
||||
parts.append(injuries_text)
|
||||
|
||||
return MatchContext(
|
||||
match_id=match_id,
|
||||
text="\n".join(parts),
|
||||
has_stats=has_stats,
|
||||
has_injuries=has_injuries,
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 底层查询(切片函数共用)
|
||||
# ============================================================
|
||||
|
||||
async def _load_match(db, match_id: int) -> Match:
|
||||
stmt = (
|
||||
select(Match)
|
||||
.where(Match.id == match_id)
|
||||
.options(
|
||||
selectinload(Match.league),
|
||||
selectinload(Match.home_team),
|
||||
selectinload(Match.away_team),
|
||||
selectinload(Match.stats),
|
||||
)
|
||||
)
|
||||
m = (await db.execute(stmt)).scalar_one_or_none()
|
||||
if m is None:
|
||||
raise ValueError(f"match {match_id} not found")
|
||||
return m
|
||||
|
||||
|
||||
async def _get_form(db, team_id: int, before, *, limit: int = 5) -> list[Match]:
|
||||
"""某队近 N 场(已完赛)。before=None 表示不限制(预测赛前的场景由调用方保证)。"""
|
||||
stmt = (
|
||||
select(Match)
|
||||
.where(Match.match_status == "finished")
|
||||
.where(Match.home_goals.is_not(None))
|
||||
.where((Match.home_team_id == team_id) | (Match.away_team_id == team_id))
|
||||
.order_by(Match.match_date.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
if before is not None:
|
||||
stmt = stmt.where(Match.match_date < before)
|
||||
result = await db.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def _get_h2h(db, home_id: int, away_id: int, before, *, limit: int = 5) -> list[Match]:
|
||||
"""两队交锋史。"""
|
||||
stmt = (
|
||||
select(Match)
|
||||
.where(Match.match_status == "finished")
|
||||
.where(Match.home_goals.is_not(None))
|
||||
.where(
|
||||
((Match.home_team_id == home_id) & (Match.away_team_id == away_id))
|
||||
| ((Match.home_team_id == away_id) & (Match.away_team_id == home_id))
|
||||
)
|
||||
.order_by(Match.match_date.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
if before is not None:
|
||||
stmt = stmt.where(Match.match_date < before)
|
||||
result = await db.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def _get_home_away(db, team_id: int, side: str, before, *, limit: int = 10) -> list[Match]:
|
||||
"""某队主场/客场近 N 场。side='home' 取主场,'away' 取客场。"""
|
||||
stmt = (
|
||||
select(Match)
|
||||
.where(Match.match_status == "finished")
|
||||
.where(Match.home_goals.is_not(None))
|
||||
.order_by(Match.match_date.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
if side == "home":
|
||||
stmt = stmt.where(Match.home_team_id == team_id)
|
||||
else:
|
||||
stmt = stmt.where(Match.away_team_id == team_id)
|
||||
if before is not None:
|
||||
stmt = stmt.where(Match.match_date < before)
|
||||
result = await db.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
@@ -0,0 +1,81 @@
|
||||
"""评估:赛后回填 + 统计。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from src.db.base import AsyncSessionLocal
|
||||
from src.db.models import Prediction
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def settle_prediction(prediction_id: int, home_goals: int, away_goals: int) -> Prediction:
|
||||
"""回填实际结果。"""
|
||||
async with AsyncSessionLocal() as db:
|
||||
pred = await db.get(Prediction, prediction_id)
|
||||
if pred is None:
|
||||
raise ValueError(f"prediction {prediction_id} not found")
|
||||
pred.actual_home_goals = home_goals
|
||||
pred.actual_away_goals = away_goals
|
||||
pred.settled = True
|
||||
await db.commit()
|
||||
await db.refresh(pred)
|
||||
return pred
|
||||
|
||||
|
||||
def _actual_1x2(home: int, away: int) -> str:
|
||||
"""根据实际比分返胜平负。"""
|
||||
if home > away:
|
||||
return "1"
|
||||
if home == away:
|
||||
return "X"
|
||||
return "2"
|
||||
|
||||
|
||||
async def get_eval_summary() -> dict:
|
||||
"""按 provider × 模型聚合评估。"""
|
||||
async with AsyncSessionLocal() as db:
|
||||
stmt = (
|
||||
select(Prediction)
|
||||
.where(Prediction.settled == True)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
rows = list(result.scalars().all())
|
||||
|
||||
from collections import defaultdict
|
||||
buckets: dict[tuple[str, str], dict] = defaultdict(lambda: {
|
||||
"total": 0, "correct_1x2": 0, "score_errors": [], "conf_sum": 0.0, "conf_count": 0,
|
||||
})
|
||||
for p in rows:
|
||||
key = (p.provider, p.model)
|
||||
b = buckets[key]
|
||||
b["total"] += 1
|
||||
if p.actual_home_goals is None or p.actual_away_goals is None:
|
||||
continue
|
||||
actual = _actual_1x2(p.actual_home_goals, p.actual_away_goals)
|
||||
if p.pred_1x2 == actual:
|
||||
b["correct_1x2"] += 1
|
||||
if p.pred_home_goals is not None and p.pred_away_goals is not None:
|
||||
err = ((p.pred_home_goals - p.actual_home_goals) ** 2 +
|
||||
(p.pred_away_goals - p.actual_away_goals) ** 2) ** 0.5
|
||||
b["score_errors"].append(err)
|
||||
if p.confidence is not None:
|
||||
b["conf_sum"] += p.confidence
|
||||
b["conf_count"] += 1
|
||||
|
||||
summary = []
|
||||
for (prov, model), b in sorted(buckets.items()):
|
||||
acc = (b["correct_1x2"] / b["total"] * 100) if b["total"] else 0
|
||||
avg_err = (sum(b["score_errors"]) / len(b["score_errors"])) if b["score_errors"] else None
|
||||
avg_conf = (b["conf_sum"] / b["conf_count"]) if b["conf_count"] else None
|
||||
summary.append({
|
||||
"provider": prov,
|
||||
"model": model,
|
||||
"total": b["total"],
|
||||
"accuracy_1x2": round(acc, 1),
|
||||
"avg_score_rmse": round(avg_err, 2) if avg_err is not None else None,
|
||||
"avg_confidence": round(avg_conf, 2) if avg_conf is not None else None,
|
||||
})
|
||||
return {"summary": summary}
|
||||
@@ -0,0 +1,176 @@
|
||||
"""预测服务:拼上下文 → 调 LLM → 存预测。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from threading import Lock
|
||||
|
||||
from src.core.config import settings
|
||||
from src.db.base import AsyncSessionLocal
|
||||
from src.db.models import Match, Prediction
|
||||
from src.llm.context_builder import build_context
|
||||
from src.llm.provider import LLMProvider, get_default_provider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_PROMPT_DIR = Path(__file__).resolve().parent / "prompts"
|
||||
|
||||
# ── LLM 响应缓存(match+provider+model+version → 结果) ──
|
||||
_CACHE_TTL_SEC = 300 # 5 分钟
|
||||
_cache: dict[str, tuple[float, PredictResult]] = {}
|
||||
_cache_lock = Lock()
|
||||
|
||||
|
||||
def _cache_key(match_id: int, provider: str, model: str, version: str) -> str:
|
||||
return f"{match_id}:{provider}:{model}:{version}"
|
||||
|
||||
|
||||
def _get_cached(match_id: int, provider: str, model: str, version: str) -> PredictResult | None:
|
||||
key = _cache_key(match_id, provider, model, version)
|
||||
with _cache_lock:
|
||||
if key in _cache:
|
||||
ts, result = _cache[key]
|
||||
if time.time() - ts < _CACHE_TTL_SEC:
|
||||
return result
|
||||
del _cache[key]
|
||||
return None
|
||||
|
||||
|
||||
def _set_cached(match_id: int, provider: str, model: str, version: str, result: PredictResult) -> None:
|
||||
key = _cache_key(match_id, provider, model, version)
|
||||
with _cache_lock:
|
||||
_cache[key] = (time.time(), result)
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=8)
|
||||
def _load_prompt_template(version: str = "v1") -> str:
|
||||
"""缓存 prompt 模板(进程生命周期内每个版本只读一次)。"""
|
||||
path = _PROMPT_DIR / f"match_prediction_{version}.md"
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"prompt 模板不存在: {path}")
|
||||
with open(path, encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
@dataclass
|
||||
class PredictResult:
|
||||
prediction_id: int
|
||||
provider: str
|
||||
model: str
|
||||
prompt_version: str
|
||||
pred_home_goals: float | None
|
||||
pred_away_goals: float | None
|
||||
pred_1x2: str | None
|
||||
confidence: float | None
|
||||
reasoning: str | None
|
||||
context: str
|
||||
latency_ms: int | None
|
||||
raw: dict | None
|
||||
|
||||
|
||||
async def predict_match(
|
||||
match_id: int,
|
||||
*,
|
||||
provider: LLMProvider | None = None,
|
||||
model: str | None = None,
|
||||
prompt_version: str | None = None,
|
||||
mode: str = "multi",
|
||||
) -> "PredictResult | MultiPredictResult":
|
||||
"""预测入口。mode=multi(默认)走多 agent;mode=single 走单次调用。"""
|
||||
if mode == "single":
|
||||
return await _predict_single(
|
||||
match_id, provider=provider, model=model, prompt_version=prompt_version
|
||||
)
|
||||
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_"))
|
||||
|
||||
|
||||
async def _predict_single(
|
||||
match_id: int,
|
||||
*,
|
||||
provider: LLMProvider | None = None,
|
||||
model: str | None = None,
|
||||
prompt_version: str | None = None,
|
||||
) -> PredictResult:
|
||||
"""单次调用路径(原有实现)。"""
|
||||
if provider is None:
|
||||
provider = get_default_provider()
|
||||
if model:
|
||||
provider.model = model
|
||||
version = prompt_version or "v1"
|
||||
|
||||
# 0. 查缓存(同 match+provider+model+version 5 分钟内直接返)
|
||||
cached = _get_cached(match_id, settings.LLM_PROVIDER, provider.model, version)
|
||||
if cached is not None:
|
||||
logger.debug("predict cache hit match=%s", match_id)
|
||||
return cached
|
||||
|
||||
# 1. 拼上下文
|
||||
ctx = await build_context(match_id)
|
||||
|
||||
# 2. 拼 prompt(指定版本)
|
||||
template = _load_prompt_template(version)
|
||||
user_prompt = template.replace("{{context}}", ctx.text)
|
||||
|
||||
# 3. 调 LLM
|
||||
resp = await provider.chat(
|
||||
system="你是一个严谨的足球预测专家。只输出 JSON。",
|
||||
user=user_prompt,
|
||||
json_mode=True,
|
||||
temperature=0.3,
|
||||
max_tokens=800,
|
||||
)
|
||||
|
||||
if resp.error:
|
||||
raise RuntimeError(f"LLM error: {resp.error}")
|
||||
|
||||
parsed = resp.parsed or {}
|
||||
|
||||
# 4. 存预测(独立 session,因为 context 用的是自己的 session)
|
||||
async with AsyncSessionLocal() as db:
|
||||
# 验证 match 存在
|
||||
m = await db.get(Match, match_id)
|
||||
if m is None:
|
||||
raise ValueError(f"match {match_id} not found")
|
||||
|
||||
pred = Prediction(
|
||||
match_id=match_id,
|
||||
provider=settings.LLM_PROVIDER,
|
||||
model=provider.model,
|
||||
prompt_version=version,
|
||||
prompt_tokens=resp.prompt_tokens,
|
||||
completion_tokens=resp.completion_tokens,
|
||||
latency_ms=resp.latency_ms,
|
||||
pred_home_goals=parsed.get("pred_home_goals"),
|
||||
pred_away_goals=parsed.get("pred_away_goals"),
|
||||
pred_1x2=parsed.get("1x2"),
|
||||
confidence=parsed.get("confidence"),
|
||||
reasoning=parsed.get("reasoning"),
|
||||
raw_response=resp.raw,
|
||||
)
|
||||
db.add(pred)
|
||||
await db.commit()
|
||||
await db.refresh(pred)
|
||||
|
||||
result = PredictResult(
|
||||
prediction_id=pred.id,
|
||||
provider=pred.provider,
|
||||
model=pred.model,
|
||||
prompt_version=version,
|
||||
pred_home_goals=pred.pred_home_goals,
|
||||
pred_away_goals=pred.pred_away_goals,
|
||||
pred_1x2=pred.pred_1x2,
|
||||
confidence=pred.confidence,
|
||||
reasoning=pred.reasoning,
|
||||
context=ctx.text,
|
||||
latency_ms=resp.latency_ms,
|
||||
raw=resp.raw,
|
||||
)
|
||||
|
||||
# 5. 写入缓存
|
||||
_set_cached(match_id, settings.LLM_PROVIDER, provider.model, version, result)
|
||||
return result
|
||||
@@ -0,0 +1,27 @@
|
||||
你是足球预测终裁专家。以下是 5 位领域专家对同一场比赛的分析报告(JSON),以及比赛基本信息。
|
||||
|
||||
比赛: {{match_header}}
|
||||
|
||||
专家报告:
|
||||
{{agent_reports}}
|
||||
|
||||
你的任务: 综合权衡各报告,输出最终预测。
|
||||
|
||||
裁决规则:
|
||||
- 各报告的 confidence 和 data_sufficiency 是采信依据: no_data/error 状态的报告必须忽略,不得编造
|
||||
- 5 个专家维度: form(近期状态) / stats(攻防数据) / home_away(主客因素) / injuries(阵容完整性) / h2h(历史交锋)
|
||||
- home_edge 是各专家的方向性判断(-1~1),冲突时给出你的权衡理由
|
||||
- agent_weights 体现你对各报告的采信度(0-1,总和无须为 1)
|
||||
- reasoning 需引用具体报告的证据
|
||||
|
||||
严格按此 JSON 输出,不要其他内容:
|
||||
```json
|
||||
{
|
||||
"pred_home_goals": <float, 预测主队进球>,
|
||||
"pred_away_goals": <float, 预测客队进球>,
|
||||
"1x2": "<'1'|'X'|'2'>",
|
||||
"confidence": <0.0-1.0>,
|
||||
"reasoning": "<250 字内推理,引用各报告证据>",
|
||||
"agent_weights": {"form": <0-1>, "stats": <0-1>, "home_away": <0-1>, "injuries": <0-1>, "h2h": <0-1>}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,21 @@
|
||||
你是足球近期状态分析专家。分析以下两队近期比赛数据,判断当前状态走势。
|
||||
|
||||
{{context}}
|
||||
|
||||
分析要点:
|
||||
- 近期 W/D/L 序列与趋势(连胜/连败/起伏)
|
||||
- 关键事件:大胜/惨败/逆转等标志性比分
|
||||
- 进攻火力与防守稳固度
|
||||
- 动量:最近 2-3 场 vs 更早的表现变化
|
||||
- 综合判断:哪支球队近期状态更好,走势向上还是向下
|
||||
|
||||
严格按此 JSON 输出,不要其他内容:
|
||||
```json
|
||||
{
|
||||
"data_sufficiency": "high|medium|low|none",
|
||||
"analysis": "<150 字内分析,含关键事件与走势判断>",
|
||||
"home_edge": <-1.0 到 1.0, 正数=主队状态更好/走势更向上>,
|
||||
"confidence": <0.0-1.0>,
|
||||
"key_evidence": ["<证据1>", "<证据2>"]
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,22 @@
|
||||
你是足球历史交锋分析专家。分析以下两队过去数年以及近期的交手数据,提取交手规律。
|
||||
|
||||
{{context}}
|
||||
|
||||
分析要点:
|
||||
- 总体交锋倾向:谁赢的多,胜率差距
|
||||
- 主客场交锋差异:有些球队只在主场赢/输
|
||||
- 比分模式:大球还是小球,常见比分
|
||||
- 近期 vs 远期的变化:交锋格局是否发生逆转
|
||||
- 样本量评估:1-2 次交锋的参考价值低
|
||||
- 综合判断:历史交锋揭示的规律与心理优势
|
||||
|
||||
严格按此 JSON 输出,不要其他内容:
|
||||
```json
|
||||
{
|
||||
"data_sufficiency": "high|medium|low|none",
|
||||
"analysis": "<150 字内分析,提取交手规律>",
|
||||
"home_edge": <-1.0 到 1.0, 正数=主队交锋占优>,
|
||||
"confidence": <0.0-1.0>,
|
||||
"key_evidence": ["<证据1>", "<证据2>"]
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,21 @@
|
||||
你是足球主客因素分析专家。分析以下两队的主客场表现差异,评估地理优势影响。
|
||||
|
||||
{{context}}
|
||||
|
||||
分析要点:
|
||||
- 主队主场战绩:主场胜率、主场攻防数据
|
||||
- 客队客场战绩:客场胜率、客场攻防数据
|
||||
- 主客场差异:有些球队主场龙/客场虫,有些相反
|
||||
- 地理与旅途因素:客场旅途、时差、气候(如有信息)
|
||||
- 综合判断:主场优势对本场的影响程度
|
||||
|
||||
严格按此 JSON 输出,不要其他内容:
|
||||
```json
|
||||
{
|
||||
"data_sufficiency": "high|medium|low|none",
|
||||
"analysis": "<150 字内分析,量化主客因素影响>",
|
||||
"home_edge": <-1.0 到 1.0, 正数=主场优势明显>,
|
||||
"confidence": <0.0-1.0>,
|
||||
"key_evidence": ["<证据1>", "<证据2>"]
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,21 @@
|
||||
你是足球阵容完整性分析专家。分析以下两队的伤停与停赛信息,评估战力缺失程度。
|
||||
|
||||
{{context}}
|
||||
|
||||
分析要点:
|
||||
- 核心球员缺阵影响(射手/组织核心/主力门将/后防中坚)
|
||||
- 缺阵人数与位置分布(前场/中场/后场)
|
||||
- 替补深度:缺阵是否有人可替
|
||||
- 无数据时如实标注 data_sufficiency=none,不猜测
|
||||
- 综合判断:哪支球队战力受损更严重
|
||||
|
||||
严格按此 JSON 输出,不要其他内容:
|
||||
```json
|
||||
{
|
||||
"data_sufficiency": "high|medium|low|none",
|
||||
"analysis": "<150 字内分析,量化战力缺失程度>",
|
||||
"home_edge": <-1.0 到 1.0, 正数=客队伤停更严重(利主队)>,
|
||||
"confidence": <0.0-1.0>,
|
||||
"key_evidence": ["<证据1>", "<证据2>"]
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,22 @@
|
||||
你是足球攻防数据分析专家。分析以下两队的进球、射门、控球数据,评估攻防强度。
|
||||
|
||||
{{context}}
|
||||
|
||||
分析要点:
|
||||
- 场均进球:进攻火力强弱
|
||||
- 场均失球:防守稳固度
|
||||
- 场均射门/射正:进攻威胁与效率
|
||||
- 控球率:场面控制力
|
||||
- xG(期望进球):进攻质量 vs 实际进球的转化效率
|
||||
- 综合判断:哪支球队攻防更均衡,哪端(攻/防)是优势端
|
||||
|
||||
严格按此 JSON 输出,不要其他内容:
|
||||
```json
|
||||
{
|
||||
"data_sufficiency": "high|medium|low|none",
|
||||
"analysis": "<150 字内分析,量化攻防强度>",
|
||||
"home_edge": <-1.0 到 1.0, 正数=主队攻防占优>,
|
||||
"confidence": <0.0-1.0>,
|
||||
"key_evidence": ["<证据1>", "<证据2>"]
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,15 @@
|
||||
你是足球分析专家。根据以下数据预测比赛结果。只输出 JSON,不要解释。
|
||||
|
||||
{{context}}
|
||||
|
||||
严格按此 JSON 输出:
|
||||
```json
|
||||
{
|
||||
"pred_home_goals": "<float, 预测主队进球>",
|
||||
"pred_away_goals": "<float, 预测客队进球>",
|
||||
"1x2": "<'1'|'X'|'2'>",
|
||||
"confidence": "<0.0-1.0>",
|
||||
"score_probable": {"home": "<int>", "away": "<int>", "prob": "<float>"},
|
||||
"reasoning": "<200 字内推理>"
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,21 @@
|
||||
你是足球分析专家。根据以下数据预测比赛结果。
|
||||
|
||||
{{context}}
|
||||
|
||||
请分析:
|
||||
1. 主客队近期状态差异
|
||||
2. 主客场因素
|
||||
3. 历史交锋心理优势
|
||||
4. 联赛排名差距
|
||||
|
||||
严格按此 JSON 输出,不要其他内容:
|
||||
```json
|
||||
{
|
||||
"pred_home_goals": "<float, 预测主队进球>",
|
||||
"pred_away_goals": "<float, 预测客队进球>",
|
||||
"1x2": "<'1'|'X'|'2'>",
|
||||
"confidence": "<0.0-1.0>",
|
||||
"score_probable": {"home": "<int>", "away": "<int>", "prob": "<float>"},
|
||||
"reasoning": "<200 字内推理,需引用具体数据>"
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,114 @@
|
||||
"""多提供商 LLM 抽象(OpenAI-compatible 接口)。
|
||||
|
||||
支持: OpenAI / Deepseek / Ollama / 任何 OpenAI-compatible 网关。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from src.core.config import settings
|
||||
from src.core.http_client import get_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LLMResponse:
|
||||
content: str
|
||||
parsed: dict | None = None
|
||||
prompt_tokens: int | None = None
|
||||
completion_tokens: int | None = None
|
||||
latency_ms: int | None = None
|
||||
raw: dict | None = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class LLMProvider:
|
||||
"""OpenAI-compatible async provider。"""
|
||||
|
||||
api_key: str = ""
|
||||
base_url: str = "https://api.openai.com/v1"
|
||||
model: str = "gpt-4o"
|
||||
timeout: int = 60
|
||||
extra_headers: dict = field(default_factory=dict)
|
||||
|
||||
async def chat(
|
||||
self,
|
||||
system: str,
|
||||
user: str,
|
||||
*,
|
||||
json_mode: bool = True,
|
||||
temperature: float = 0.3,
|
||||
max_tokens: int = 1000,
|
||||
) -> LLMResponse:
|
||||
"""发请求,返回结构化响应。"""
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
**self.extra_headers,
|
||||
}
|
||||
payload: dict[str, Any] = {
|
||||
"model": self.model,
|
||||
"messages": [
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": user},
|
||||
],
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_tokens,
|
||||
}
|
||||
if json_mode:
|
||||
payload["response_format"] = {"type": "json_object"}
|
||||
|
||||
start = time.perf_counter()
|
||||
try:
|
||||
client = get_client()
|
||||
resp = await client.post(
|
||||
f"{self.base_url}/chat/completions",
|
||||
headers=headers,
|
||||
json=payload,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
latency = int((time.perf_counter() - start) * 1000)
|
||||
usage = data.get("usage", {})
|
||||
content = data["choices"][0]["message"]["content"]
|
||||
parsed = None
|
||||
if json_mode:
|
||||
try:
|
||||
parsed = json.loads(content)
|
||||
except json.JSONDecodeError:
|
||||
# 尝试从代码块提取
|
||||
import re
|
||||
m = re.search(r"```(?:json)?\s*([\s\S]*?)\s*```", content)
|
||||
if m:
|
||||
try:
|
||||
parsed = json.loads(m.group(1))
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return LLMResponse(
|
||||
content=content,
|
||||
parsed=parsed,
|
||||
prompt_tokens=usage.get("prompt_tokens"),
|
||||
completion_tokens=usage.get("completion_tokens"),
|
||||
latency_ms=latency,
|
||||
raw=data,
|
||||
)
|
||||
except Exception as e:
|
||||
latency = int((time.perf_counter() - start) * 1000)
|
||||
logger.error("LLM request failed: %s", e)
|
||||
return LLMResponse(content="", error=str(e), latency_ms=latency)
|
||||
|
||||
|
||||
def get_default_provider() -> LLMProvider:
|
||||
return LLMProvider(
|
||||
api_key=settings.LLM_API_KEY,
|
||||
base_url=settings.LLM_BASE_URL,
|
||||
model=settings.LLM_MODEL,
|
||||
timeout=settings.LLM_TIMEOUT,
|
||||
)
|
||||
Reference in New Issue
Block a user