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
+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]]