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:
shangfangjian
2026-09-09 02:10:47 +08:00
commit 0a27b18c27
74 changed files with 5667 additions and 0 deletions
View File
View File
+57
View File
@@ -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()
View File
+26
View File
@@ -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()
+56
View File
@@ -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))
+123
View File
@@ -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,
)
+106
View File
@@ -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,
)
+122
View File
@@ -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]]
View File
+37
View File
@@ -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()
+31
View File
@@ -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
View File
+226
View File
@@ -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
+46
View File
@@ -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 限速(秒)
+176
View File
@@ -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())
+40
View File
@@ -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()
+209
View File
@@ -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),
)
+52
View File
@@ -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
+137
View File
@@ -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)
+116
View File
@@ -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
View File
+58
View File
@@ -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)
+175
View File
@@ -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"),
)
View File
+11
View File
@@ -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",
]
+183
View File
@@ -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)
+224
View File
@@ -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,
)
+365
View File
@@ -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())
+81
View File
@@ -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}
+176
View File
@@ -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
+27
View File
@@ -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>}
}
```
+21
View File
@@ -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>"]
}
```
+22
View File
@@ -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>"]
}
```
+21
View File
@@ -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>"]
}
```
+21
View File
@@ -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>"]
}
```
+22
View File
@@ -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>"]
}
```
+15
View File
@@ -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 字内推理>"
}
```
+21
View File
@@ -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 字内推理,需引用具体数据>"
}
```
+114
View File
@@ -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,
)