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
+19
View File
@@ -0,0 +1,19 @@
# ---- 应用 ----
APP_ENV=development
LOG_LEVEL=INFO
# ---- 数据库 ----
DATABASE_URL=postgresql+asyncpg://football:football@localhost:5432/football
# ---- LLM (OpenAI-compatible,必填一个) ----
LLM_PROVIDER=openai
LLM_API_KEY=sk-xxxx
LLM_BASE_URL=https://api.openai.com/v1
LLM_MODEL=gpt-4o
# ---- 数据源 ----
BZZOIRO_KEY=
API_FOOTBALL_KEY=
# ---- CORS ----
CORS_ORIGINS=http://localhost:5173,http://localhost:3000
+7
View File
@@ -0,0 +1,7 @@
__pycache__/
*.pyc
.env
.venv/
.pytest_cache/
frontend/node_modules/
frontend/dist/
+13
View File
@@ -0,0 +1,13 @@
FROM python:3.11-slim
WORKDIR /app
RUN pip install --no-cache-dir hatchling
COPY pyproject.toml README.md ./
COPY src ./src
RUN pip install --no-cache-dir .
EXPOSE 8000
CMD ["uvicorn", "src.api.app:app", "--host", "0.0.0.0", "--port", "8000"]
+88
View File
@@ -0,0 +1,88 @@
# Profeto — 足球 LLM 预测服务
**给 LLM 提供数据,让 LLM 预测足球比分。**
与旧项目 `MatchPro`(自研统计模型预测引擎)完全不同:
- MatchPro: 160 个 Python 文件,7 套 ML 模型,OOF/校准/Promotion Gate,6 容器
- **Profeto**: ~25 个文件,5 张表,LLM 做预测,2 容器(api + postgres)
## 架构
```
前端(单页) → FastAPI → PostgreSQL
LLM (OpenAI-compatible)
bzzoiro / understat
```
## 快速开始
### 1. 环境
```bash
# 后端
pip install -e ".[dev]"
cp .env.example .env
# 编辑 .env: 填 LLM_API_KEY、BZZOIRO_KEY
# 数据库
docker compose up -d postgres
```
### 2. 启动
```bash
# 后端
uvicorn src.api.app:app --reload
# 前端(另一个终端)
cd frontend && npm install && npm run dev
```
### 3. 使用
```bash
# 采集数据
curl -X POST http://localhost:8000/api/v1/ingest/bzzoiro \
-H "Content-Type: application/json" \
-d '{"leagues":["E0","SP1"],"date_from":"2026-08-01","date_to":"2026-09-06"}'
# 查比赛
curl "http://localhost:8000/api/v1/matches?league=E0&status=scheduled"
# LLM 预测
curl -X POST http://localhost:8000/api/v1/predict \
-H "Content-Type: application/json" \
-d '{"match_id": 1}'
# 赛后回填
curl -X POST http://localhost:8000/api/v1/eval/settle \
-H "Content-Type: application/json" \
-d '{"prediction_id": 1, "home_goals": 2, "away_goals": 1}'
# 评估汇总
curl http://localhost:8000/api/v1/eval/summary
```
前端访问 `http://localhost:5173`
## 核心模块
| 文件 | 作用 |
|---|---|
| `src/llm/context_builder.py` | **最重要**: 拼 LLM 看到的上下文 |
| `src/llm/prompts/match_prediction.md` | prompt 模板(迭代最频繁) |
| `src/llm/provider.py` | 多提供商抽象 |
| `src/data/bzzoiro.py` | 数据采集(迁移自旧项目) |
| `src/data/normalize.py` | 数据清洗契约 |
## 测试
```bash
pytest
```
## License
个人研究项目,预测结果不构成投注建议。
+149
View File
@@ -0,0 +1,149 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts.
# this is typically a path given in POSIX (e.g. forward slashes)
# format, relative to the token %(here)s which refers to the location of this
# ini file
script_location = %(here)s/alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# Or organize into date-based subdirectories (requires recursive_version_locations = true)
# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory. for multiple paths, the path separator
# is defined by "path_separator" below.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the tzdata library which can be installed by adding
# `alembic[tz]` to the pip requirements.
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to <script_location>/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "path_separator"
# below.
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
# path_separator; This indicates what character is used to split lists of file
# paths, including version_locations and prepend_sys_path within configparser
# files such as alembic.ini.
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
# to provide os-dependent path splitting.
#
# Note that in order to support legacy alembic.ini files, this default does NOT
# take place if path_separator is not present in alembic.ini. If this
# option is omitted entirely, fallback logic is as follows:
#
# 1. Parsing of the version_locations option falls back to using the legacy
# "version_path_separator" key, which if absent then falls back to the legacy
# behavior of splitting on spaces and/or commas.
# 2. Parsing of the prepend_sys_path option falls back to the legacy
# behavior of splitting on spaces, commas, or colons.
#
# Valid values for path_separator are:
#
# path_separator = :
# path_separator = ;
# path_separator = space
# path_separator = newline
#
# Use os.pathsep. Default configuration used for new projects.
path_separator = os
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# database URL. This is consumed by the user-maintained env.py script only.
# other means of configuring database URLs may be customized within the env.py
# file.
sqlalchemy.url = postgresql+psycopg2://football:football@localhost:5432/football
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
# hooks = ruff
# ruff.type = module
# ruff.module = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Alternatively, use the exec runner to execute a binary found on your PATH
# hooks = ruff
# ruff.type = exec
# ruff.executable = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Logging configuration. This is also consumed by the user-maintained
# env.py script only.
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+1
View File
@@ -0,0 +1 @@
Generic single-database configuration.
+64
View File
@@ -0,0 +1,64 @@
from logging.config import fileConfig
import sys
from pathlib import Path
from sqlalchemy import engine_from_config, pool
from alembic import context
# 把项目根加入 pythonpath,让 alembic 能找到 src 包
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from src.db.base import Base # noqa: E402
from src.db.models import * # noqa: E402,F401,F403 # 导入所有模型确保注册
from src.core.config import settings # noqa: E402
# this is the Alembic Config object
config = context.config
# 用 settings 的 DATABASE_URL,但转成 sync 驱动
DB_URL = settings.DATABASE_URL
if DB_URL.startswith("postgresql+asyncpg"):
DB_URL = DB_URL.replace("postgresql+asyncpg", "postgresql+psycopg2", 1)
config.set_main_option("sqlalchemy.url", DB_URL)
# Interpret the config file for Python logging.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode."""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode."""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+28
View File
@@ -0,0 +1,28 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
"""Upgrade schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Downgrade schema."""
${downgrades if downgrades else "pass"}
+129
View File
@@ -0,0 +1,129 @@
"""initial_tables
Revision ID: 0001_initial
Revises:
Create Date: 2026-09-07 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = '0001_initial'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic ###
op.create_table('leagues',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('code', sa.String(length=20), nullable=False),
sa.Column('name', sa.String(length=100), nullable=False),
sa.Column('country', sa.String(length=50), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('code')
)
op.create_table('teams',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=120), nullable=False),
sa.Column('name_zh', sa.String(length=60), nullable=True),
sa.Column('team_type', sa.String(length=20), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name')
)
op.create_table('matches',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('league_id', sa.Integer(), nullable=False),
sa.Column('season', sa.String(length=12), nullable=True),
sa.Column('home_team_id', sa.Integer(), nullable=False),
sa.Column('away_team_id', sa.Integer(), nullable=False),
sa.Column('match_date', sa.DateTime(timezone=True), nullable=False),
sa.Column('match_date_date', sa.Date(), nullable=False),
sa.Column('match_status', sa.String(length=20), nullable=False),
sa.Column('home_goals', sa.Integer(), nullable=True),
sa.Column('away_goals', sa.Integer(), nullable=True),
sa.Column('home_ht_goals', sa.Integer(), nullable=True),
sa.Column('away_ht_goals', sa.Integer(), nullable=True),
sa.Column('match_stage', sa.String(length=100), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['away_team_id'], ['teams.id'], ),
sa.ForeignKeyConstraint(['home_team_id'], ['teams.id'], ),
sa.ForeignKeyConstraint(['league_id'], ['leagues.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index('ix_matches_away_date', 'matches', ['away_team_id', sa.text('match_date DESC')], unique=False)
op.create_index('ix_matches_home_date', 'matches', ['home_team_id', sa.text('match_date DESC')], unique=False)
op.create_index('ix_matches_league_date', 'matches', ['league_id', sa.text('match_date DESC')], unique=False)
op.create_index('ix_matches_status_date', 'matches', ['match_status', sa.text('match_date DESC')], unique=False)
op.create_index('ix_matches_unique', 'matches', ['league_id', 'home_team_id', 'away_team_id', 'match_date_date'], unique=True)
op.create_index(op.f('ix_matches_match_date_date'), 'matches', ['match_date_date'], unique=False)
op.create_table('match_stats',
sa.Column('match_id', sa.Integer(), nullable=False),
sa.Column('home_xg', sa.Float(), nullable=True),
sa.Column('away_xg', sa.Float(), nullable=True),
sa.Column('home_shots', sa.Integer(), nullable=True),
sa.Column('away_shots', sa.Integer(), nullable=True),
sa.Column('home_shots_on_target', sa.Integer(), nullable=True),
sa.Column('away_shots_on_target', sa.Integer(), nullable=True),
sa.Column('home_corners', sa.Integer(), nullable=True),
sa.Column('away_corners', sa.Integer(), nullable=True),
sa.Column('home_possession', sa.Float(), nullable=True),
sa.Column('home_yellow_cards', sa.Integer(), nullable=True),
sa.Column('away_yellow_cards', sa.Integer(), nullable=True),
sa.Column('home_red_cards', sa.Integer(), nullable=True),
sa.Column('away_red_cards', sa.Integer(), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['match_id'], ['matches.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('match_id')
)
op.create_table('predictions',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('match_id', sa.Integer(), nullable=False),
sa.Column('provider', sa.String(length=30), nullable=False),
sa.Column('model', sa.String(length=80), nullable=False),
sa.Column('prompt_version', sa.String(length=20), nullable=False),
sa.Column('prompt_tokens', sa.Integer(), nullable=True),
sa.Column('completion_tokens', sa.Integer(), nullable=True),
sa.Column('latency_ms', sa.Integer(), nullable=True),
sa.Column('pred_home_goals', sa.Float(), nullable=True),
sa.Column('pred_away_goals', sa.Float(), nullable=True),
sa.Column('pred_1x2', sa.String(length=3), nullable=True),
sa.Column('confidence', sa.Float(), nullable=True),
sa.Column('reasoning', sa.Text(), nullable=True),
sa.Column('raw_response', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('actual_home_goals', sa.Integer(), nullable=True),
sa.Column('actual_away_goals', sa.Integer(), nullable=True),
sa.Column('settled', sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(['match_id'], ['matches.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_predictions_match'), 'predictions', ['match_id'], unique=False)
op.create_index('ix_predictions_provider_model', 'predictions', ['provider', 'model'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic ###
op.drop_index('ix_predictions_provider_model', table_name='predictions')
op.drop_index(op.f('ix_predictions_match'), table_name='predictions')
op.drop_table('predictions')
op.drop_table('match_stats')
op.drop_index(op.f('ix_matches_match_date_date'), table_name='matches')
op.drop_index('ix_matches_unique', table_name='matches')
op.drop_index('ix_matches_status_date', table_name='matches')
op.drop_index('ix_matches_league_date', table_name='matches')
op.drop_index('ix_matches_home_date', table_name='matches')
op.drop_index('ix_matches_away_date', table_name='matches')
op.drop_table('matches')
op.drop_table('teams')
op.drop_table('leagues')
# ### end Alembic commands ###
+28
View File
@@ -0,0 +1,28 @@
"""add agent outputs to predictions
Revision ID: 0002_agent_outputs
Revises: 0001_initial
Create Date: 2026-09-08
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = '0002_agent_outputs'
down_revision: Union[str, None] = '0001_initial'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('predictions', sa.Column('mode', sa.String(length=20), nullable=False, server_default='single'))
op.add_column('predictions', sa.Column('agent_outputs', postgresql.JSONB(astext_type=sa.Text()), nullable=True))
def downgrade() -> None:
op.drop_column('predictions', 'agent_outputs')
op.drop_column('predictions', 'mode')
+48
View File
@@ -0,0 +1,48 @@
"""add injuries table
Revision ID: 0003_injuries
Revises: 0002_agent_outputs
Create Date: 2026-09-09
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '0003_injuries'
down_revision: Union[str, None] = '0002_agent_outputs'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table('injuries',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('player_id', sa.Integer(), nullable=True),
sa.Column('player_name', sa.String(length=120), nullable=False),
sa.Column('team_id', sa.Integer(), nullable=True),
sa.Column('fixture_id', sa.Integer(), nullable=True),
sa.Column('league_id', sa.Integer(), nullable=True),
sa.Column('injury_type', sa.String(length=50), nullable=True),
sa.Column('reason', sa.String(length=200), nullable=True),
sa.Column('injury_date', sa.Date(), nullable=True),
sa.Column('return_date', sa.Date(), nullable=True),
sa.Column('retrieved_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['team_id'], ['teams.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('player_id', 'fixture_id', 'injury_type', name='ix_injuries_player_fixture')
)
op.create_index(op.f('ix_injuries_injury_date'), 'injuries', ['injury_date'], unique=False)
op.create_index(op.f('ix_injuries_player_id'), 'injuries', ['player_id'], unique=False)
op.create_index(op.f('ix_injuries_team_date'), 'injuries', ['team_id', 'injury_date'], unique=False)
op.create_index(op.f('ix_injuries_team_id'), 'injuries', ['team_id'], unique=False)
def downgrade() -> None:
op.drop_index(op.f('ix_injuries_team_id'), table_name='injuries')
op.drop_index(op.f('ix_injuries_team_date'), table_name='injuries')
op.drop_index(op.f('ix_injuries_player_id'), table_name='injuries')
op.drop_index(op.f('ix_injuries_injury_date'), table_name='injuries')
op.drop_table('injuries')
+31
View File
@@ -0,0 +1,31 @@
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: football
POSTGRES_PASSWORD: football
POSTGRES_DB: football
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U football"]
interval: 5s
timeout: 5s
retries: 5
api:
build: .
command: uvicorn src.api.app:app --host 0.0.0.0 --port 8000 --reload
ports:
- "8000:8000"
env_file: .env
depends_on:
postgres:
condition: service_healthy
volumes:
- ./src:/app/src
volumes:
pgdata:
+107
View File
@@ -0,0 +1,107 @@
# 01 · 架构总览
## 系统架构
```
┌─────────────────────────────────────────────────────┐
│ 前端(单页 React + Vite + Tailwind) │
│ 比赛列表 → 选比赛 → LLM 预测 → 专家报告 + 最终结论 │
└──────────────────────────▲──────────────────────────┘
│ REST (/api/v1)
┌──────────────────────────┴──────────────────────────┐
│ FastAPI(单进程,全 async) │
│ │
│ 数据查询 预测编排 采集(手动/cron 触发) │
│ ┌──────┐ ┌────────────┐ ┌───────────────────┐ │
│ │matches│ │ orchestrator│ │ bzzoiro (赛果) │ │
│ │leagues│ │ ┌─ 5 专家并行(便宜模型) │ │
│ └──┬───┘ │ │ h2h / form / stats / │ │
│ │ │ │ home_away / injuries │ │
│ │ │ └─ aggregator 终裁(强模型) │ │
│ │ └────────────┘ └───────────────────┘ │
│ │ │ └ understat (xG) │
│ ┌──┴──────────────┴──┐ └ injuries (伤停) │
│ │ PostgreSQL (5 张表) │ httpx → 外部 API │
│ └────────────────────┘ │
└─────────────────────────────────────────────────────┘
```
## 核心数据流(一次多 Agent 预测)
1. `POST /predict {match_id}` → orchestrator
2. `load_match_header`: 查比赛 + 双方 + 联赛(一次 eager load)
3. **5 个专家 agent 并行**(`asyncio.gather`),每个:
- 各自的数据切片函数查库(近况/交锋/积分榜 SQL 聚合/伤停/xG)
- 切片无数据 → **跳过 LLM**,直接 `no_data` stub(省 token、防幻觉)
- 有数据 → 专属 prompt(专家模型,便宜快)→ 结构化 JSON 报告(`home_edge` 方向性评分 + 证据)
4. **终裁 agent**:5 份报告 + 比赛信息 → 权衡采信度(`agent_weights`)→ 最终预测 JSON
5.`predictions` 表(含 `agent_outputs` 全部报告)
6. 赛后 `POST /eval/settle` 回填实际比分 → `GET /eval/summary` 按 模型×prompt 版本 聚合准确率
## 关键设计决策
| 决策 | 理由 |
|---|---|
| **多专家并行而非单次大 prompt** | 每维度独立迭代 prompt;报告可归因(哪个维度分析错了);总延迟 ≈ 2 次串行调用 |
| **专家/终裁模型分档** | 专家用便宜模型快速分析,终裁用强模型汇总决策,成本与质量平衡(`LLM_SPECIALIST_MODEL` / `LLM_AGGREGATOR_MODEL`) |
| **no_data 门控** | 无数据维度(如伤停未接入)不调 LLM,终裁知道维度缺失,不编造 |
| **fail-open** | 单个专家失败只标记 `status=error`,其余照常;研究场景可用性优先 |
| **`match_date_date` 天级去重** | 不同源时间精度不同,秒级匹配会产生重复行;天级 + 数据库唯一约束 |
| **积分榜 SQL 聚合 + season 过滤** | `UNION ALL` 主客双视角 + `GROUP BY` 在库内算,只算当前赛季(修复过跨赛季 bug) |
| **单 agent 模式保留** | `mode="single"` 走旧单次路径,与 multi 形成天然 A/B(eval 按 `prompt_version` 分组) |
| **无 worker/redis/队列** | 采集是 cron 触发的短任务,单进程足够;违背简化初衷的基础设施一律不加 |
## 目录结构
```
Profeto/
├── src/
│ ├── api/
│ │ ├── app.py # FastAPI 工厂(lifespan 建表)
│ │ ├── schemas.py # Pydantic v2 请求/响应
│ │ └── routes/
│ │ ├── matches.py # 联赛/比赛查询(游标分页)
│ │ ├── predict.py # 预测 + 预测历史
│ │ ├── ingest.py # 采集触发(自管 session)
│ │ └── eval.py # 赛后回填 + 准确率汇总
│ ├── db/
│ │ ├── base.py # async engine + get_db/get_db_read
│ │ └── models.py # 5 张表 ORM
│ ├── data/
│ │ ├── bzzoiro.py # 赛果采集 + 幂等入库
│ │ ├── understat.py # xG 回填
│ │ ├── injuries.py # 伤停采集(带文件缓存)
│ │ ├── normalize.py # NormalizedMatch 清洗契约
│ │ ├── team_names.py # 队名归一映射
│ │ └── config.py # 联赛代码映射
│ ├── llm/
│ │ ├── provider.py # OpenAI-compatible 抽象(共享连接池/JSON 兜底解析)
│ │ ├── context_builder.py # 数据切片(h2h/form/stats/home_away/injuries)+ 单 agent 拼接
│ │ ├── predict.py # 预测入口(mode 分派 + 缓存)
│ │ ├── eval.py # 准确率统计
│ │ ├── agents/
│ │ │ ├── base.py # AgentSpec / AgentReport / run_agent
│ │ │ └── orchestrator.py # 并行专家 → 终裁 → 存库
│ │ └── prompts/
│ │ ├── match_prediction_v1/v2.md # 单 agent 模板
│ │ └── agents/{h2h,form,stats,home_away,injuries,aggregator}_v1.md
│ └── core/config.py # pydantic-settings
├── alembic/versions/ # 0001 建表 + 0002 agent 字段
├── frontend/src/pages/Matches.tsx # 单页(预测面板 + 专家报告折叠区)
├── tests/ # 33 项(核心 13 + agent 20)
├── docker-compose.yml # api + postgres 两容器
└── docs/ # 本文档
```
## 技术栈
| 层 | 选型 |
|---|---|
| API | FastAPI + uvicorn(全 async) |
| ORM | SQLAlchemy 2.0 async + asyncpg |
| 数据库 | PostgreSQL 16(JSONB 存 agent 报告) |
| HTTP | httpx(共享连接池)/ urllib(bzzoiro 同步限速) |
| LLM | OpenAI-compatible 接口(openai/deepseek/ollama 等任一) |
| 前端 | Vite + React 18 + TypeScript + Tailwind |
| 测试 | pytest + pytest-asyncio(33 项,自包含) |
| 部署 | Docker Compose(api + postgres) |
+129
View File
@@ -0,0 +1,129 @@
# 02 · 快速开始
## 前置条件
- Python 3.11+
- Docker Desktop(跑 PostgreSQL)
- 一个 OpenAI-compatible 的 LLM API Key(OpenAI / Deepseek / Ollama 等任一)
- bzzoiro 数据源 Key(旧项目 MatchPro 的同一 Key)
## 1. 安装
```bash
cd P:\Profeto
pip install -e ".[dev]"
```
## 2. 配置环境变量
```bash
cp .env.example .env
```
编辑 `.env`,至少填这三项:
```ini
LLM_API_KEY=sk-xxx # 必填
LLM_BASE_URL=https://api.openai.com/v1 # 换成你的提供商
LLM_MODEL=gpt-4o-mini # 默认模型
# 可选分档(推荐):
LLM_SPECIALIST_MODEL=gpt-4o-mini # 5 个专家用(便宜快)
LLM_AGGREGATOR_MODEL=gpt-4o # 终裁用(强)
BZZOIRO_KEY=xxx # 数据采集用
```
## 3. 启动数据库 + 建表
```bash
docker compose up -d postgres
alembic upgrade head
```
## 4. 启动服务
```bash
# 后端
uvicorn src.api.app:app --reload
# 前端(另开终端)
cd frontend
npm install
npm run dev
```
- 前端界面: http://localhost:5173
- API 文档(Swagger): http://localhost:8000/docs
## 5. 首次跑通全流程
### 采集历史数据(英超近两个月为例)
```bash
curl -X POST http://localhost:8000/api/v1/ingest/bzzoiro \
-H "Content-Type: application/json" \
-d '{"leagues":["E0"],"date_from":"2026-08-01","date_to":"2026-09-08"}'
```
数据量大时**直接拉整赛季**(约 380 场,含近几个赛季更好,近况/交锋/积分榜都需要历史):
```bash
curl -X POST http://localhost:8000/api/v1/ingest/bzzoiro \
-H "Content-Type: application/json" \
-d '{"leagues":["E0"],"date_from":"2025-08-01","date_to":"2026-09-08"}'
```
### 回填 xG(可选,让攻防数据 agent 有数据)
```bash
curl -X POST http://localhost:8000/api/v1/ingest/understat \
-H "Content-Type: application/json" \
-d '{"league":"E0","season":2025}'
```
### 查比赛
浏览器打开 http://localhost:5173 ,选"英超 / 未开赛";
或:
```bash
curl "http://localhost:8000/api/v1/matches?league=E0&status=scheduled"
```
### LLM 预测
页面上点"LLM 预测",预测面板会展示最终结论 + 5 个专家 agent 的折叠报告(方向性评分/证据/分析);
或:
```bash
curl -X POST http://localhost:8000/api/v1/predict \
-H "Content-Type: application/json" \
-d '{"match_id": 1}'
```
不传 `mode` 默认走多 agent;`"mode":"single"` 走单次调用旧路径(用于对比)。
### 赛后评估
比赛结束后,采集最新赛果(同一条 ingest 命令会自动把 scheduled 升级为 finished 并补比分),
然后回填预测:
```bash
curl -X POST http://localhost:8000/api/v1/eval/settle \
-H "Content-Type: application/json" \
-d '{"prediction_id": 1, "home_goals": 2, "away_goals": 1}'
# 汇总准确率(按 模型 × prompt 版本,含 multi/single 对比)
curl http://localhost:8000/api/v1/eval/summary
```
## 常见问题
| 问题 | 处理 |
|---|---|
| 连不上数据库 | `docker compose ps` 确认 postgres 健康;`.env``DATABASE_URL` 与 compose 一致 |
| predict 返回 502 | 看 uvicorn 日志的 LLM error;确认 `LLM_BASE_URL`/`LLM_API_KEY`;`response_format` 不兼容的网关会报错(改用支持 json mode 的模型) |
| 采集 0 场 | bzzoiro Key 失效或联赛代码写错;先 `GET /api/v1/leagues` 看库里有没有联赛 |
| 专家报告全是 no_data | 历史数据不够 —— 近况需要每队近 5 场、积分榜需要本赛季已完赛比赛,多拉几周数据 |
| xg agent 报无 xG 数据 | 先跑 understat 回填;注意 understat 只有五大联赛 |
+189
View File
@@ -0,0 +1,189 @@
# 03 · API 参考
Base URL: `http://localhost:8000` · 交互式文档: `/docs`(Swagger)与 `/redoc`
所有数据端点返回 JSON。错误统一为 `{"detail": "<message>"}` + 对应 HTTP 状态码。
---
## 数据查询
### `GET /api/v1/leagues`
列出已入库联赛。
```json
[{"id": 1, "code": "E0", "name": "Premier League", "country": "England"}]
```
### `GET /api/v1/matches`
比赛列表,游标分页。
| 参数 | 说明 |
|---|---|
| `league` | 联赛代码,如 `E0` / `SP1` / `D1` / `I1` / `F1` |
| `status` | `scheduled` / `finished`(不传 = 全部) |
| `date` | `YYYY-MM-DD`,当天比赛 |
| `cursor` | 上一页返回的 `next_cursor` |
| `limit` | 1100,默认 50 |
响应(倒序,含双方中文名与 xG):
```json
{
"items": [
{"id": 42, "league_code": "E0", "season": "2026-2027",
"home_team": "Arsenal", "away_team": "Manchester United",
"home_team_zh": null, "away_team_zh": null,
"match_date": "2026-09-15T19:00:00Z", "match_status": "scheduled",
"home_goals": null, "away_goals": null,
"match_stage": "第 5 轮", "home_xg": null, "away_xg": null}
],
"next_cursor": "2026-09-15T19:00:00+00:00|41",
"has_more": true
}
```
### `GET /api/v1/matches/{id}`
单场比赛详情,字段同上。
---
## 预测
### `POST /api/v1/predict`
对一场比赛做 LLM 预测。**核心端点。**
请求:
```json
{
"match_id": 42,
"mode": "multi",
"model": "gpt-4o",
"prompt_version": "v1"
}
```
| 字段 | 默认 | 说明 |
|---|---|---|
| `match_id` | 必填 | 比赛 ID |
| `mode` | `multi` | `multi` = 5 专家 + 终裁;`single` = 单次调用 |
| `model` | 配置值 | 覆盖本次模型(single 模式下生效) |
| `prompt_version` | `v1` | prompt 版本(multi 模式即 agent prompt 版本) |
响应(multi 模式):
```json
{
"prediction_id": 7,
"provider": "openai",
"model": "gpt-4o",
"prompt_version": "multi_v1",
"mode": "multi",
"pred_home_goals": 2.1,
"pred_away_goals": 1.0,
"pred_1x2": "1",
"confidence": 0.68,
"reasoning": "综合 xg 报告的进球期望 2.1-1.0 与 form 报告的三连胜势头……",
"agent_outputs": [
{"agent": "h2h", "status": "ok", "data_sufficiency": "medium",
"analysis": "近 5 次交锋主队 3 胜……", "home_edge": 0.4,
"confidence": 0.7, "key_evidence": ["近5次交锋主队3胜", "主场交锋3连胜"],
"exp_home_goals": null, "exp_away_goals": null, "probable_score": null,
"model": "gpt-4o-mini", "latency_ms": 2100,
"prompt_tokens": 380, "completion_tokens": 120},
{"agent": "injuries", "status": "no_data", "data_sufficiency": "none",
"analysis": "该维度无数据,跳过分析。", "home_edge": null, "confidence": null,
"key_evidence": [], "exp_home_goals": null, "exp_away_goals": null,
"probable_score": null, "model": "", "latency_ms": null,
"prompt_tokens": null, "completion_tokens": null}
],
"agent_weights": {"form": 0.9, "stats": 0.8, "home_away": 0.7, "injuries": 0.0, "h2h": 0.8},
"context": "[5 份报告的 JSON 串]",
"latency_ms": 9800
}
```
`agent.status` 取值:`ok` / `no_data`(维度无数据,已跳过 LLM)/ `error`(调用失败,fail-open 不阻断)/ `parse_error`
错误:404 比赛不存在;502 LLM 调用失败(终裁失败时整体失败,专家失败不会)。
### `GET /api/v1/predictions?match_id=&limit=`
预测历史(倒序),含 `settled` 与实际比分回填状态。
### `GET /api/v1/predictions/{id}`
单条预测详情(含完整 `agent_outputs`)。
---
## 数据采集
### `POST /api/v1/ingest/bzzoiro`
从 bzzoiro 采集赛果/赛程并入库(幂等,重复跑安全)。
```json
{"leagues": ["E0", "SP1"], "date_from": "2025-08-01", "date_to": "2026-09-08", "status": "finished"}
```
- `status` 还可传 `scheduled` 拉未来赛程
- 响应含每联赛 `inserted`/`updated`/`errors` 统计
### `POST /api/v1/ingest/understat`
回填 xG(只补空字段,不创建比赛):
```json
{"league": "E0", "season": 2025}
```
`season=2025` 表示 2025-2026 赛季。仅支持五大联赛。
### `POST /api/v1/ingest/injuries`
采集伤停(需 `API_FOOTBALL_KEY`,当前只返回计数,尚未接入 context):
```json
{"date": "2026-09-10"}
```
---
## 评估
### `POST /api/v1/eval/settle`
赛后回填实际比分:
```json
{"prediction_id": 7, "home_goals": 2, "away_goals": 1}
```
### `GET /api/v1/eval/summary`
`provider × model` 聚合已结算预测:
```json
{"summary": [
{"provider": "openai", "model": "gpt-4o", "total": 12,
"accuracy_1x2": 58.3, "avg_score_rmse": 1.21, "avg_confidence": 0.65}
]}
```
> 提示:multi 模式存的 `model` 是终裁模型、`prompt_version` 是 `multi_v1`,
> 因此 summary 里天然可对比 multi vs single、以及不同 prompt 版本的效果。
---
## 基础
| 端点 | 说明 |
|---|---|
| `GET /health` | 存活检查 |
| `GET /docs` | Swagger UI |
+165
View File
@@ -0,0 +1,165 @@
# 04 · 多 Agent 预测架构
Profeto 的核心预测路径是 **5 个领域专家 Agent 并行分析 + 1 个终裁 Agent 汇总决策**
每个专家只拿到自己维度的数据切片,输出结构化 JSON;终裁综合 5 份报告给出最终预测。
## Agent 一览
| Agent | 职责 | 数据切片 | 输出核心 |
|---|---|---|---|
| `form` 近期状态 | 分析比分与关键事件,判断近期走势 | 两队近 N 场赛果(含 xG) | `home_edge` + 走势判断 |
| `stats` 攻防数据 | 评估进球、射门与控球,量化攻防强度 | 近 N 场进球/射门/控球/xG 统计 | `home_edge` + 攻防强度 |
| `home_away` 主客因素 | 对比主场与客场表现,评估地理优势影响 | 主队主场战绩 + 客队客场战绩 | `home_edge` + 地理优势 |
| `injuries` 阵容完整性 | 汇总伤停与停赛名单,评估战力缺失程度 | 伤停数据(当前无源 → no_data 门控) | `home_edge``no_data` |
| `h2h` 历史交锋 | 分析过去数年以及近期的交手数据,提取交手规律 | 近 N 次交锋(含主客方向 + 总计统计) | `home_edge` + 交手规律 |
| `aggregator` 终裁 | 权衡 5 份报告 → 最终结论 | 5 份结构化报告 + 比赛头信息 | 最终预测 + 各报告采信度 |
## 数据流
```
POST /predict {match_id, mode: "multi"}
├─ load_match_header ──► MatchHeader(比赛基础信息,所有 agent 共享)
├─ asyncio.gather(并行执行 5 专家, before=match_date 防未来信息泄漏)
│ ├─ form agent ─┐
│ ├─ stats agent │ 每个 agent 拿到专属数据切片
│ ├─ home_away agent │ → no_data 门控 → 调 LLM → 输出 JSON 报告
│ ├─ injuries agent │ (无数据 → 跳过 LLM,返回 stub)
│ └─ h2h agent ─┘
├─ aggregator agent(5 份报告 + 比赛头 → 最终 JSON)
└─ 存 predictions(mode="multi", agent_outputs JSONB)
```
## 执行语义
### 1. 并行执行
5 个专家通过 `asyncio.gather` 并发,总延迟 ≈ `max(专家延迟) + 终裁延迟` ≈ 2 次串行 LLM 调用。
### 2. no_data 门控(省 token、防幻觉)
数据切片为空时(如伤停数据源未接入),**跳过 LLM 调用**,直接返回:
```json
{"agent": "injuries", "status": "no_data", "data_sufficiency": "none",
"analysis": "该维度无数据,跳过分析。"}
```
终裁 Agent 会看到这个 `no_data` 状态,不会编造伤停分析。
### 3. fail-open(单专家失败不阻断)
单个专家 LLM 调用失败 → 其报告标记 `status: error`,其余 4 份 + 终裁照常执行。
只有**终裁 Agent 失败**才会整体返回 502。
### 4. 防未来信息泄漏
所有切片查询都带 `before=match_date`,确保只用比赛**之前**的数据。
这对历史回测(对已完赛比赛跑预测)尤其重要。
## 输出契约
### 专家 Agent 统一输出(JSON)
```json
{
"agent": "h2h",
"status": "ok",
"data_sufficiency": "high",
"analysis": "近 5 次交锋主队 3 胜 1 平 1 负,主场交锋 3 连胜……",
"home_edge": 0.4,
"confidence": 0.7,
"key_evidence": ["近5次交锋主队3胜", "主场交锋3连胜"]
}
```
| 字段 | 说明 |
|---|---|
| `status` | `ok` / `no_data` / `error` / `parse_error` |
| `data_sufficiency` | `high` / `medium` / `low` / `none` |
| `home_edge` | -1.0 ~ 1.0,正数=利主队,负数=利客队 |
| `confidence` | 0.0 ~ 1.0,该专家对自己分析的信心 |
| `key_evidence` | 关键证据列表(最多 5 条) |
### 终裁 Agent 输出
在现有最终预测 schema 基础上新增 `agent_weights`:
```json
{
"pred_home_goals": 2.1,
"pred_away_goals": 1.0,
"1x2": "1",
"confidence": 0.68,
"reasoning": "综合 stats 报告的攻防强度与 form 报告的三连胜势头……",
"agent_weights": {"form": 0.9, "stats": 0.8, "home_away": 0.7, "injuries": 0.0, "h2h": 0.8}
}
```
`agent_weights` 体现终裁对各专家报告的采信度(0–1),可用于后续分析"哪个维度对预测贡献大"。
## 模型分档配置
专家用便宜快模型,终裁用强模型,各自回落 `LLM_MODEL`:
| 环境变量 | 说明 | 回落 |
|---|---|---|
| `LLM_SPECIALIST_MODEL` | 5 个专家共用模型 | `LLM_MODEL` |
| `LLM_AGGREGATOR_MODEL` | 终裁模型 | `LLM_MODEL` |
示例(专家用 gpt-4o-mini,终裁用 gpt-4o):
```bash
LLM_MODEL=gpt-4o
LLM_SPECIALIST_MODEL=gpt-4o-mini
LLM_AGGREGATOR_MODEL=gpt-4o
```
## Prompt 版本化
每个 Agent 有独立 prompt 文件,位于 `src/llm/prompts/agents/`:
```
agents/
├── form_v1.md # 近期状态专家
├── stats_v1.md # 攻防数据专家
├── home_away_v1.md # 主客因素专家
├── injuries_v1.md # 阵容完整性专家
├── h2h_v1.md # 历史交锋专家
└── aggregator_v1.md # 终裁
```
调用时传 `prompt_version: "v1"` 即加载所有 `*_v1.md`
迭代 prompt 时:复制 `h2h_v1.md``h2h_v2.md`,改内容,传 `prompt_version: "v2"`
`predictions.prompt_version` 存的是 `multi_v2`,与 single 模式的 `v1`/`v2` 天然分组,可在 eval summary 中 A/B 对比。
## 如何新增一个专家 Agent
三步:
**1. 写切片函数**(`src/llm/context_builder.py`):
```python
async def weather_slice(header: MatchHeader, *, before=None) -> str:
"""天气切片示例。"""
return "── 天气 ──\n 比赛日: 小雨 15°C"
```
**2. 注册 AgentSpec**(`src/llm/agents/orchestrator.py``SPECIALIST_SPECS`):
```python
AgentSpec(name="weather", system_prompt="你是足球天气影响分析专家。只输出 JSON。",
slice_fn=weather_slice)
```
**3. 写 prompt 文件**(`src/llm/prompts/agents/weather_v1.md`):
```markdown
你是足球天气影响分析专家。分析以下天气数据对比赛的影响。
{{context}}
严格按此 JSON 输出……
```
重启服务即生效,终裁会自动收到第 6 份报告。
## 与单 Agent 模式的关系
`mode: "single"` 走原有单次调用路径(一个大 context + 一个 prompt),用于:
- 与 multi 模式做 A/B 基线对比
- 快速验证(省 token)
- 调试单个 prompt
eval summary 按 `prompt_version` 分组:`v1`/`v2` 是 single,`multi_v1`/`multi_v2` 是 multi,可直接对比准确率。
+175
View File
@@ -0,0 +1,175 @@
# 05 · 数据层与数据库
## 数据源
| 数据源 | 用途 | 必需 Key | 说明 |
|---|---|---|---|
| bzzoiro | 赛果/赛程(主源) | `BZZOIRO_KEY` | 五大联赛历史 + 实时 |
| understat | xG 回填 | 无(公开) | 仅五大联赛,补 `match_stats.xg` |
| api-football | 伤停 | `API_FOOTBALL_KEY` | 当前只采集计数,未接入 context |
### bzzoiro
- 端点:`/api/v2/events/`,按 `league_id` + 日期范围分页
- 限速:`REQUEST_INTERVAL = 1.2s`,429 自动重试 3 次(轮换 key)
- 联赛映射(`src/data/config.py`):
```python
BZZOIRO_LEAGUE_IDS = {"E0": 1, "SP1": 3, "D1": 5, "I1": 4, "F1": 6, "CL": 7, "EL": 8}
```
### understat
- 端点:`/getLeagueData/{league}/{season}`,返回 JS 包裹的 JSON(需正则提取)
- 只回填 xG(`match_stats.home_xg`/`away_xg`),**不创建新比赛**
- 通过"天级日期 + 队名归一"匹配已有比赛
## 数据清洗契约
所有数据源统一清洗为 `NormalizedMatch`(`src/data/normalize.py`),字段:
| 字段 | 类型 | 说明 |
|---|---|---|
| `league_type` | str | 联赛代码(小写,如 `E0`) |
| `date` | datetime | UTC 时间 |
| `home_team` / `away_team` | str | 归一化后的规范名 |
| `match_status` | str | `finished`/`scheduled`/... |
| `home_goals` / `away_goals` | int? | 全场比分 |
| `home_ht_goals` / `away_ht_goals` | int? | 半场比分 |
| `home_xg` / `away_xg` | float? | 期望进球 |
| `home_shots` / `away_shots` | int? | 射门 |
| `home_shots_on_target` / `away_shots_on_target` | int? | 射正 |
| `home_corners` / `away_corners` | int? | 角球 |
| `home_possession` | float? | 主队控球率 |
| `home_yellow_cards` / `away_yellow_cards` | int? | 黄牌 |
| `home_red_cards` / `away_red_cards` | int? | 红牌 |
| `match_stage` | str? | 轮次(如"第 5 轮") |
| `season_label` | str | 赛季标签(如 `2026-2027`) |
### 校验规则(`validate()`)
- `finished` 无比分 → 抛错(或降级为 `scheduled`)
- 比分 030,xG 020,射门/射正/角球 0100,红黄牌 020,控球率 0100
- 半场比分 ≤ 全场比分
- 无比分小数(`_to_int` 严格: `"2.8"` → `None`,不截断)
### 队名归一化
`src/data/team_names.py` 维护 `NORMALIZE_MAP`(如 `Man City` → `Manchester City`),未命中映射的队名原样返回。
归一前先做 Unicode NFKD 去重音。
## 数据库 Schema
5 张表:
```sql
-- 联赛
CREATE TABLE leagues (
id SERIAL PRIMARY KEY,
code VARCHAR(20) UNIQUE NOT NULL, -- 'E0' / 'SP1'
name VARCHAR(100) NOT NULL,
country VARCHAR(50),
created_at TIMESTAMPTZ
);
-- 球队
CREATE TABLE teams (
id SERIAL PRIMARY KEY,
name VARCHAR(120) UNIQUE NOT NULL, -- 规范名(归一后)
name_zh VARCHAR(60), -- 中文名
team_type VARCHAR(20) DEFAULT 'club',
created_at TIMESTAMPTZ
);
-- 比赛(核心)
CREATE TABLE matches (
id SERIAL PRIMARY KEY,
league_id INT REFERENCES leagues(id),
season VARCHAR(12), -- '2026-2027'
home_team_id INT REFERENCES teams(id),
away_team_id INT REFERENCES teams(id),
match_date TIMESTAMPTZ NOT NULL,
match_date_date DATE NOT NULL, -- 天级日期(去重键)
match_status VARCHAR(20) DEFAULT 'scheduled',
home_goals INT, away_goals INT,
home_ht_goals INT, away_ht_goals INT,
match_stage VARCHAR(100),
created_at TIMESTAMPTZ, updated_at TIMESTAMPTZ
);
-- 唯一约束: 同联赛同对阵同天只存一场(天级去重)
CREATE UNIQUE INDEX ix_matches_unique
ON matches(league_id, home_team_id, away_team_id, match_date_date);
-- 比赛统计(xG、射门、控球等)
CREATE TABLE match_stats (
match_id INT PRIMARY KEY REFERENCES matches(id) ON DELETE CASCADE,
home_xg FLOAT, away_xg FLOAT,
home_shots INT, away_shots INT,
home_shots_on_target INT, away_shots_on_target INT,
home_corners INT, away_corners INT,
home_possession FLOAT,
home_yellow_cards INT, away_yellow_cards INT,
home_red_cards INT, away_red_cards INT,
updated_at TIMESTAMPTZ
);
-- LLM 预测记录
CREATE TABLE predictions (
id SERIAL PRIMARY KEY,
match_id INT REFERENCES matches(id) ON DELETE CASCADE,
provider VARCHAR(30) NOT NULL, -- 'openai' / 'anthropic'
model VARCHAR(80) NOT NULL,
prompt_version VARCHAR(20) NOT NULL DEFAULT 'v1',
mode VARCHAR(20) NOT NULL DEFAULT 'single', -- 'single' / 'multi'
prompt_tokens INT, completion_tokens INT,
latency_ms INT,
pred_home_goals FLOAT, pred_away_goals FLOAT,
pred_1x2 VARCHAR(3), -- '1' / 'X' / '2'
confidence FLOAT,
reasoning TEXT,
raw_response JSONB, -- LLM 完整原始响应
agent_outputs JSONB, -- multi 模式: 5 份专家报告
created_at TIMESTAMPTZ,
actual_home_goals INT, actual_away_goals INT, -- 赛后回填
settled BOOLEAN DEFAULT FALSE
);
```
### 关键设计点
1. **`match_date_date`(天级日期)**: 用于天级去重。bzzoiro 返回的时间带时分秒,精确匹配不可靠,故拆出 `DATE` 列做唯一键。
2. **`ix_matches_unique`**: `(league_id, home_team_id, away_team_id, match_date_date)` 唯一,保证同一场比赛重复采集时 upsert 而非插入重复行。
3. **`predictions` 级联删除**: `ON DELETE CASCADE`,删比赛时自动清其预测。
4. **`mode` + `prompt_version`**: `single` 模式存 `v1`/`v2`,`multi` 模式存 `multi_v1`/`multi_v2`,eval summary 按这两列天然分组对比。
## 入库语义(幂等)
`ingest_bzzoiro` 的 upsert 逻辑:
- **不存在**: 插入新比赛 + 初始 stats
- **已存在**: 只补空字段
- 比分:只在原记录为 `None` 时覆盖
- 状态:只允许单向升级(`scheduled` → `finished`),防止完赛行被覆盖成赛程
- stats:只补空(`home_xg` 已有值时不覆盖)
`ingest_understat` 只回填 xG(也只补空),不创建比赛。
## 采集建议
```bash
# 1. 首次采集: 5 大联赛近 2 赛季赛果
curl -X POST /api/v1/ingest/bzzoiro \
-d '{"leagues":["E0","SP1","D1","I1","F1"],"date_from":"2024-08-01","date_to":"2026-09-08"}'
# 2. 增量采集(每日 cron): 只拉最近 7 天
curl -X POST /api/v1/ingest/bzzoiro \
-d '{"leagues":["E0"],"date_from":"2026-09-01","date_to":"2026-09-08"}'
# 3. xG 回填(可选,提升 xg agent 质量)
curl -X POST /api/v1/ingest/understat -d '{"league":"E0","season":2025}'
curl -X POST /api/v1/ingest/understat -d '{"league":"E0","season":2026}'
```
建议用外部 cron(如系统 crontab)定时触发,不引入 worker/redis。
+148
View File
@@ -0,0 +1,148 @@
# 06 · 部署
## 前置要求
- Docker & Docker Compose
- bzzoiro API Key(必填)
- LLM API Key(必填,OpenAI / Deepseek / 兼容接口)
## Docker Compose 部署(推荐)
```bash
# 1. 配置环境变量
cp .env.example .env
# 编辑 .env: 填 LLM_API_KEY / BZZOIRO_KEY
# 2. 启动(自动建表)
docker compose up -d --build
# 3. 验证
curl http://localhost:8000/health
```
`docker-compose.yml` 仅 2 个服务:
| 服务 | 端口 | 说明 |
|---|---|---|
| `postgres` | 5432 | PostgreSQL 16 |
| `api` | 8000 | FastAPI 应用 |
数据卷 `pgdata` 持久化数据库,重启不丢数据。
## 本地开发部署
```bash
# 1. 安装依赖
pip install -e ".[dev]"
# 2. 启动 PostgreSQL(单独)
docker run -d --name profeto-pg \
-e POSTGRES_USER=football -e POSTGRES_PASSWORD=football -e POSTGRES_DB=football \
-p 5432:5432 postgres:16-alpine
# 3. 配置 .env
cp .env.example .env
# 4. 建表
alembic upgrade head
# 5. 启动 API
uvicorn src.api.app:app --reload
# 6. 启动前端(另一个终端)
cd frontend && npm install && npm run dev
```
访问:
- API 文档: http://localhost:8000/docs
- 前端界面: http://localhost:5173
## 环境变量
| 变量 | 必需 | 默认值 | 说明 |
|---|---|---|---|
| `APP_ENV` | ❌ | `development` | `production` / `development` |
| `LOG_LEVEL` | ❌ | `INFO` | 日志级别 |
| `DATABASE_URL` | ✅ | — | PostgreSQL 连接 URL |
| `LLM_PROVIDER` | ❌ | `openai` | 提供商名(仅标记) |
| `LLM_API_KEY` | ✅ | — | API Key |
| `LLM_BASE_URL` | ❌ | `https://api.openai.com/v1` | 接口地址(Ollama/Deepseek 用) |
| `LLM_MODEL` | ❌ | `gpt-4o` | 默认模型 |
| `LLM_TIMEOUT` | ❌ | `60` | 单次调用超时(秒) |
| `LLM_SPECIALIST_MODEL` | ❌ | — | 专家模型(回落 `LLM_MODEL`) |
| `LLM_AGGREGATOR_MODEL` | ❌ | — | 终裁模型(回落 `LLM_MODEL`) |
| `BZZOIRO_KEY` | ✅ | — | bzzoiro 数据源 Key |
| `BZZOIRO_BASE` | ❌ | `https://sports.bzzoiro.com/api/v2` | bzzoiro 接口地址 |
| `API_FOOTBALL_KEY` | ❌ | — | 伤停数据源 Key |
| `CORS_ORIGINS` | ❌ | `http://localhost:5173,...` | 允许的跨域来源 |
## LLM 提供商配置示例
### OpenAI
```bash
LLM_API_KEY=sk-xxxx
LLM_BASE_URL=https://api.openai.com/v1
LLM_MODEL=gpt-4o
```
### Deepseek
```bash
LLM_API_KEY=sk-xxxx
LLM_BASE_URL=https://api.deepseek.com/v1
LLM_MODEL=deepseek-chat
```
### Ollama(本地)
```bash
LLM_API_KEY=ollama
LLM_BASE_URL=http://localhost:11434/v1
LLM_MODEL=llama3.1
```
### 分档配置(专家用便宜模型)
```bash
LLM_MODEL=gpt-4o
LLM_SPECIALIST_MODEL=gpt-4o-mini
LLM_AGGREGATOR_MODEL=gpt-4o
```
## 数据库迁移
Alembic 管理 schema 变更:
```bash
# 查看当前版本
alembic current
# 升级到最新
alembic upgrade head
# 回退一级
alembic downgrade -1
# 生成新迁移(改 models.py 后)
alembic revision --autogenerate -m "描述"
# 空迁移(手动写 SQL)
alembic revision -m "描述"
```
已有迁移:
- `0001_initial`: 初始 5 张表
- `0002_agent_outputs`: predictions 加 `mode` + `agent_outputs`
## 备份与恢复
```bash
# 备份
docker exec profeto-postgres pg_dump -U football football > backup.sql
# 恢复
cat backup.sql | docker exec -i profeto-postgres psql -U football football
```
## 监控
- `/health`: 存活检查
- 日志:容器 stdout(`docker compose logs -f api`)
- 评估汇总:`GET /api/v1/eval/summary`(准确率/RMSAE/校准度)
+209
View File
@@ -0,0 +1,209 @@
# 07 · 开发指南
## 本地开发环境搭建
```bash
# 1. 克隆并进入项目
cd Profeto
# 2. 安装依赖(含 dev)
pip install -e ".[dev]"
# 3. 启动 PostgreSQL
docker run -d --name profeto-pg \
-e POSTGRES_USER=football -e POSTGRES_PASSWORD=football -e POSTGRES_DB=football \
-p 5432:5432 postgres:16-alpine
# 4. 配置环境变量
cp .env.example .env
# 编辑 .env 填 LLM_API_KEY / BZZOIRO_KEY
# 5. 建表
alembic upgrade head
# 6. 启动 API(热重载)
uvicorn src.api.app:app --reload
# 7. 启动前端(另一个终端)
cd frontend && npm install && npm run dev
```
## 项目结构
```
Profeto/
├── src/ # 后端源码
│ ├── api/
│ │ ├── routes/ # FastAPI 路由
│ │ │ ├── leagues.py # 联赛/比赛查询
│ │ │ ├── predict.py # 预测入口
│ │ │ ├── ingest.py # 数据采集
│ │ │ └── eval.py # 评估回填
│ │ ├── schemas.py # Pydantic 模型
│ │ └── app.py # FastAPI 工厂
│ ├── db/
│ │ ├── base.py # SQLAlchemy async engine + session
│ │ └── models.py # 5 张表 ORM
│ ├── data/
│ │ ├── bzzoiro.py # bzzoiro 采集 + 入库
│ │ ├── understat.py # understat xG 回填
│ │ ├── injuries.py # 伤停采集
│ │ ├── normalize.py # 数据清洗契约
│ │ ├── team_names.py # 队名归一化映射
│ │ └── config.py # 联赛映射常量
│ ├── llm/
│ │ ├── provider.py # LLM 提供商抽象(OpenAI-compatible)
│ │ ├── context_builder.py # 数据切片 + 拼接
│ │ ├── predict.py # 预测入口(单/多模式分派)
│ │ ├── eval.py # 评估统计
│ │ ├── agents/
│ │ │ ├── base.py # AgentSpec + run_agent
│ │ │ └── orchestrator.py # 多 agent 编排
│ │ └── prompts/
│ │ ├── match_prediction_v1.md # 单 agent prompt
│ │ └── agents/ # 多 agent prompt
│ │ ├── form_v1.md
│ │ ├── stats_v1.md
│ │ ├── home_away_v1.md
│ │ ├── injuries_v1.md
│ │ ├── h2h_v1.md
│ │ └── aggregator_v1.md
│ └── core/
│ └── config.py # pydantic-settings 配置
├── frontend/ # React 单页前端
├── alembic/ # 数据库迁移
│ └── versions/
│ ├── 0001_initial.py
│ └── 0002_agent_outputs.py
├── tests/ # 测试
│ ├── test_core.py # 核心逻辑测试
│ └── test_agents.py # 多 agent 测试
├── docs/ # 本文档
├── pyproject.toml # 依赖 + 构建配置
├── alembic.ini # Alembic 配置
├── Dockerfile # 生产镜像
├── docker-compose.yml # 本地/生产编排
└── .env.example # 环境变量模板
```
## 测试
```bash
# 跑全部测试
pytest
# 详细输出
pytest -v
# 只跑 agent 测试
pytest tests/test_agents.py -v
# 覆盖率
pytest --cov=src --cov-report=term-missing
```
当前测试覆盖:
- `test_core.py`(13 项):数据清洗、队名归一、赛季标签、LLM 解析
- `test_agents.py`(20 项):no_data 门控、fail-open、prompt 加载、报告解析、终裁渲染
### 写新测试的模式
mock LLM 提供商(避免真实调用):
```python
class MockProvider:
model = "test-model"
async def chat(self, system, user, **kw):
from src.llm.provider import LLMResponse
return LLMResponse(
content="{}",
parsed={"data_sufficiency": "high", "analysis": "ok",
"home_edge": 0.5, "confidence": 0.8,
"key_evidence": ["证据"]},
prompt_tokens=10, completion_tokens=5, latency_ms=100,
)
```
## 常见开发任务
### 1. 修改 Agent Prompt
直接编辑 `src/llm/prompts/agents/{name}_v1.md`,无需重启(有 `lru_cache`,改完清缓存或重启)。
迭代版本:
```bash
cp src/llm/prompts/agents/h2h_v1.md src/llm/prompts/agents/h2h_v2.md
# 编辑 h2h_v2.md
# 调用时传 prompt_version: "v2"
```
### 2. 新增 Agent
见 [04-agents.md § 如何新增一个专家 Agent](04-agents.md)。
### 3. 新增数据源
1.`src/data/` 写采集模块(参考 `understat.py`)
2.`normalize.py` 加清洗函数
3.`context_builder.py` 加切片函数
4.`api/routes/ingest.py` 加端点
5.`api/schemas.py` 加请求/响应模型
### 4. 新增联赛
编辑 `src/data/config.py`:
```python
BZZOIRO_LEAGUE_IDS["新代码"] = league_id
LEAGUE_NAMES["新代码"] = "联赛名"
LEAGUE_COUNTRIES["新代码"] = "国家"
```
### 5. 数据库 Schema 变更
```bash
# 1. 改 src/db/models.py
# 2. 生成迁移
alembic revision --autogenerate -m "描述"
# 3. 检查生成的迁移文件(自动推断不完美)
# 4. 应用
alembic upgrade head
```
## 前端开发
```bash
cd frontend
npm install
npm run dev # 开发(热重载,代理 /api → localhost:8000)
npm run build # 生产构建 → dist/
npm run preview # 预览生产构建
```
技术栈:React + TypeScript + Tailwind CSS + Vite。
主要页面:`src/pages/Matches.tsx`(比赛列表 + 预测 + agent 报告展示)。
## 编码约定
- **异步优先**:所有 IO 用 `async/await`,SQLAlchemy 用 async session
- **session 管理**:
- 路由读操作:依赖注入 `get_db_read`(不自动 commit)
- 路由写操作:依赖注入 `get_db`(自动 commit)
- 内部/ingest:直接用 `AsyncSessionLocal()` 自己管事务
- **错误处理**:领域层抛 `ValueError`/`RuntimeError`,路由层转 HTTP 状态码
- **日志**:用 `logging.getLogger(__name__)`,关键路径打 info/debug
- **类型提示**:全量标注,`Mapped[T]` + `mapped_column`
## 提交前检查清单
```bash
# 1. 测试全绿
pytest
# 2. 语法检查
python -m py_compile src/**/*.py
# 3. 确认 .env 不提交(已在 .gitignore)
# 4. 文档同步(改功能时更新 docs/)
```
+46
View File
@@ -0,0 +1,46 @@
"""检查 injuries 数据源 api-football 的响应结构。"""
API_FOOTBALL_INJURY_RESPONSE_EXAMPLE = """
{
"response": [
{
"player": {
"id": 12345,
"name": "Bukayo Saka",
"photo": "https://...",
"type": "Missing Fixture"
},
"team": {"id": 42, "name": "Arsenal"},
"fixture": {"id": 100000, "date": "2026-09-15T19:00:00+00:00"},
"league": {"id": 39, "name": "Premier League"},
"reason": "Hamstring Injury",
"type": "Missing Fixture"
}
]
}
"""
"""
injuries 表设计:
CREATE TABLE injuries (
id SERIAL PRIMARY KEY,
player_id INT, -- api-football 球员 ID
player_name VARCHAR(120) NOT NULL, -- 球员名
team_id INT REFERENCES teams(id), -- 关联球队(按名归一后匹配)
fixture_id INT, -- api-football 比赛 ID(无法直接关联 matches.id)
league_id INT,
injury_type VARCHAR(50), -- 'Missing Fixture' / 'Suspended'
reason VARCHAR(200), -- 伤停原因(如 'Hamstring Injury')
injury_date DATE, -- 伤停日期
return_date DATE, -- 预计回归日期(如有)
retrieved_at TIMESTAMPTZ DEFAULT now(),
UNIQUE(player_id, fixture_id, injury_type) -- 幂等
);
-- 查询某队某场比赛的伤停:
SELECT player_name, injury_type, reason
FROM injuries
WHERE team_id = :team_id
AND injury_date <= :match_date
AND (return_date IS NULL OR return_date >= :match_date);
"""
+26
View File
@@ -0,0 +1,26 @@
# Profeto 文档索引
| 文档 | 内容 |
|---|---|
| [01-架构总览](01-architecture.md) | 系统架构、数据流、技术选型、与旧项目对比 |
| [02-快速开始](02-quickstart.md) | 安装、启动、首次跑通全流程 |
| [03-API 参考](03-api.md) | 全部 12 个端点、请求/响应示例、curl 全流程 |
| [04-多 Agent 预测](04-agents.md) | 5 专家 + 终裁架构、执行语义、输出契约、prompt 版本化、如何新增 agent |
| [05-数据层与数据库](05-data.md) | 三数据源、清洗契约、5 张表 schema、入库语义、采集建议 |
| [06-部署](06-deployment.md) | Docker Compose、本地部署、环境变量、LLM 提供商配置、迁移、备份 |
| [07-开发指南](07-development.md) | 项目结构、测试、常见开发任务(prompt/agent/数据源/联赛)、前端开发 |
## 项目简介
Profeto 是一个**足球 LLM 预测服务**:FastAPI 提供干净的数据层(赛果/xG/积分榜),
5 个领域专家 agent(近期状态/攻防数据/主客因素/阵容完整性/历史交锋)并行分析,
终裁 agent 汇总输出结构化预测,赛后回填实际结果持续评估准确率。
与旧项目 MatchPro(自研 7 套统计模型 + 6 容器)相比:~45 个文件、2 容器、预测完全交给 LLM。
## 推荐阅读顺序
1. **新手**:01 → 02 → 03(跑通第一个预测)
2. **调优 prompt**:04(理解 agent 契约)→ 03(eval summary 看效果)
3. **加数据源/联赛**:05 → 07
4. **上线**:06
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Profeto - 足球 LLM 预测</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+25
View File
@@ -0,0 +1,25 @@
{
"name": "profeto-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.19",
"postcss": "^8.4.39",
"tailwindcss": "^3.4.6",
"typescript": "^5.5.3",
"vite": "^5.3.4"
}
}
+5
View File
@@ -0,0 +1,5 @@
export default {
content: ['./index.html', './src/**/*.{ts,tsx}'],
theme: { extend: {} },
plugins: [],
}
+15
View File
@@ -0,0 +1,15 @@
import Matches from './pages/Matches'
export default function App() {
return (
<div className="min-h-screen bg-gray-50">
<header className="bg-white border-b px-6 py-3 flex items-center justify-between">
<h1 className="text-xl font-bold text-blue-700"> Profeto</h1>
<span className="text-sm text-gray-500"> LLM </span>
</header>
<main className="max-w-5xl mx-auto p-6">
<Matches />
</main>
</div>
)
}
+3
View File
@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
+10
View File
@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
+285
View File
@@ -0,0 +1,285 @@
import { useCallback, useEffect, useState } from 'react'
interface Match {
id: number
league_code: string | null
season: string | null
home_team: string
away_team: string
home_team_zh: string | null
away_team_zh: string | null
match_date: string
match_status: string
home_goals: number | null
away_goals: number | null
match_stage: string | null
home_xg: number | null
away_xg: number | null
}
interface Prediction {
prediction_id: number
provider: string
model: string
prompt_version: string | null
mode: string
pred_home_goals: number | null
pred_away_goals: number | null
pred_1x2: string | null
confidence: number | null
reasoning: string | null
agent_outputs: AgentReport[] | null
agent_weights: Record<string, number> | null
context: string
latency_ms: number | null
}
interface AgentReport {
agent: string
status: string
data_sufficiency: string
analysis: string
home_edge: number | null
confidence: number | null
key_evidence: string[]
exp_home_goals: number | null
exp_away_goals: number | null
probable_score: string | null
model: string
latency_ms: number | null
}
const AGENT_LABELS: Record<string, string> = {
h2h: '历史交锋',
form: '近期状态',
stats: '攻防数据',
home_away: '主客因素',
injuries: '阵容完整性',
}
const LEAGUES = [
{ code: 'E0', name: '英超' },
{ code: 'SP1', name: '西甲' },
{ code: 'D1', name: '德甲' },
{ code: 'I1', name: '意甲' },
{ code: 'F1', name: '法甲' },
]
export default function Matches() {
const [league, setLeague] = useState('E0')
const [status, setStatus] = useState('scheduled')
const [matches, setMatches] = useState<Match[]>([])
const [loading, setLoading] = useState(false)
const [predictingId, setPredictingId] = useState<number | null>(null)
const [prediction, setPrediction] = useState<Prediction | null>(null)
const [error, setError] = useState<string | null>(null)
const load = useCallback(async () => {
setLoading(true)
setError(null)
try {
const params = new URLSearchParams({ league, status, limit: '50' })
const res = await fetch(`/api/v1/matches?${params}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json()
setMatches(data.items)
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setLoading(false)
}
}, [league, status])
useEffect(() => { load() }, [load])
const predict = async (matchId: number) => {
setPredictingId(matchId)
setError(null)
setPrediction(null)
try {
const res = await fetch('/api/v1/predict', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ match_id: matchId }),
})
if (!res.ok) {
const t = await res.text()
throw new Error(`HTTP ${res.status}: ${t}`)
}
const data = await res.json()
setPrediction(data)
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setPredictingId(null)
}
}
const fmtDate = (s: string) => {
const d = new Date(s)
return d.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
}
return (
<div className="space-y-4">
{/* 筛选 */}
<div className="flex gap-3 items-center flex-wrap">
<select value={league} onChange={e => setLeague(e.target.value)}
className="border rounded px-3 py-1.5 text-sm">
{LEAGUES.map(l => <option key={l.code} value={l.code}>{l.name}</option>)}
</select>
<select value={status} onChange={e => setStatus(e.target.value)}
className="border rounded px-3 py-1.5 text-sm">
<option value="scheduled"></option>
<option value="finished"></option>
<option value=""></option>
</select>
<button onClick={load} disabled={loading}
className="bg-blue-600 text-white text-sm px-4 py-1.5 rounded hover:bg-blue-700 disabled:opacity-50">
{loading ? '加载中...' : '刷新'}
</button>
<span className="text-sm text-gray-500"> {matches.length} </span>
</div>
{error && <div className="bg-red-50 border border-red-200 text-red-700 px-4 py-2 rounded text-sm">{error}</div>}
{/* 比赛表 */}
<div className="bg-white rounded border overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-100 text-gray-600">
<tr>
<th className="text-left px-4 py-2"></th>
<th className="text-left px-4 py-2"></th>
<th className="text-left px-4 py-2"></th>
<th className="text-center px-4 py-2"></th>
<th className="text-center px-4 py-2"></th>
<th className="text-center px-4 py-2"></th>
</tr>
</thead>
<tbody>
{matches.length === 0 && !loading && (
<tr><td colSpan={6} className="text-center text-gray-400 py-8">,</td></tr>
)}
{matches.map(m => (
<tr key={m.id} className="border-t hover:bg-gray-50">
<td className="px-4 py-2 text-gray-600">{fmtDate(m.match_date)}</td>
<td className="px-4 py-2 font-medium">{m.home_team_zh || m.home_team}</td>
<td className="px-4 py-2 font-medium">{m.away_team_zh || m.away_team}</td>
<td className="px-4 py-2 text-center">
{m.home_goals !== null ? `${m.home_goals} - ${m.away_goals}` : '-'}
</td>
<td className="px-4 py-2 text-center">
<span className={`text-xs px-2 py-0.5 rounded ${
m.match_status === 'finished' ? 'bg-green-100 text-green-700' :
m.match_status === 'scheduled' ? 'bg-blue-100 text-blue-700' : 'bg-gray-100'
}`}>
{m.match_status === 'finished' ? '完赛' : m.match_status === 'scheduled' ? '未开赛' : m.match_status}
</span>
</td>
<td className="px-4 py-2 text-center">
<button onClick={() => predict(m.id)}
disabled={predictingId === m.id}
className="text-blue-600 hover:underline text-xs disabled:opacity-50">
{predictingId === m.id ? '预测中...' : 'LLM 预测'}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* 预测结果 */}
{prediction && (
<div className="bg-white rounded border p-5 space-y-3">
<h3 className="font-bold text-lg">🤖 LLM </h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div className="bg-blue-50 rounded p-3">
<div className="text-gray-500 text-xs"></div>
<div className="text-xl font-bold">{prediction.pred_home_goals ?? '-'}</div>
</div>
<div className="bg-blue-50 rounded p-3">
<div className="text-gray-500 text-xs"></div>
<div className="text-xl font-bold">{prediction.pred_away_goals ?? '-'}</div>
</div>
<div className="bg-amber-50 rounded p-3">
<div className="text-gray-500 text-xs"></div>
<div className="text-xl font-bold">{prediction.pred_1x2 ?? '-'}</div>
</div>
<div className="bg-green-50 rounded p-3">
<div className="text-gray-500 text-xs"></div>
<div className="text-xl font-bold">
{prediction.confidence !== null ? `${(prediction.confidence * 100).toFixed(0)}%` : '-'}
</div>
</div>
</div>
<div className="text-xs text-gray-400">
{prediction.provider} / {prediction.model} · {prediction.latency_ms}ms
{prediction.mode === 'multi' && ' · 多 Agent 模式'}
</div>
{/* 各专家 agent 报告 */}
{prediction.agent_outputs && prediction.agent_outputs.length > 0 && (
<div className="space-y-2">
<div className="text-sm font-medium text-gray-700"> Agent </div>
{prediction.agent_outputs.map((r) => (
<details key={r.agent} className="bg-white border rounded">
<summary className="cursor-pointer px-3 py-2 text-sm flex items-center justify-between">
<span className="font-medium">
{AGENT_LABELS[r.agent] || r.agent}
{r.status !== 'ok' && (
<span className={`ml-2 text-xs px-1.5 py-0.5 rounded ${
r.status === 'no_data' ? 'bg-gray-100 text-gray-500' : 'bg-red-100 text-red-600'
}`}>
{r.status === 'no_data' ? '无数据' : '失败'}
</span>
)}
</span>
<span className="flex gap-3 text-xs text-gray-500">
{r.home_edge !== null && (
<span className={r.home_edge > 0 ? 'text-blue-600' : r.home_edge < 0 ? 'text-amber-600' : ''}>
{r.home_edge > 0 ? '+' : ''}{r.home_edge.toFixed(2)}
</span>
)}
{r.confidence !== null && <span> {(r.confidence * 100).toFixed(0)}%</span>}
{r.probable_score && <span> {r.probable_score}</span>}
</span>
</summary>
<div className="px-3 pb-3 pt-1 space-y-2 text-sm">
{r.analysis && <p className="text-gray-700">{r.analysis}</p>}
{r.key_evidence.length > 0 && (
<ul className="text-xs text-gray-500 list-disc pl-4">
{r.key_evidence.map((e, i) => <li key={i}>{e}</li>)}
</ul>
)}
{r.exp_home_goals !== null && r.exp_away_goals !== null && (
<div className="text-xs text-gray-500">
: {r.exp_home_goals.toFixed(1)} - {r.exp_away_goals.toFixed(1)}
</div>
)}
<div className="text-xs text-gray-400">
{r.data_sufficiency} · {r.model} · {r.latency_ms}ms
</div>
</div>
</details>
))}
</div>
)}
{prediction.reasoning && (
<div className="bg-gray-50 rounded p-3">
<div className="text-xs text-gray-500 mb-1"></div>
<div className="text-sm whitespace-pre-wrap">{prediction.reasoning}</div>
</div>
)}
<details className="text-xs">
<summary className="cursor-pointer text-gray-500 hover:text-gray-700"></summary>
<pre className="mt-2 bg-gray-900 text-green-300 p-3 rounded overflow-x-auto text-xs">
{prediction.context}
</pre>
</details>
</div>
)}
</div>
)
}
+6
View File
@@ -0,0 +1,6 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{ts,tsx}'],
theme: { extend: {} },
plugins: [],
}
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+12
View File
@@ -0,0 +1,12 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': 'http://localhost:8000',
},
},
})
+33
View File
@@ -0,0 +1,33 @@
[project]
name = "profeto"
version = "0.1.0"
description = "足球数据 + LLM 预测服务"
requires-python = ">=3.11"
dependencies = [
"fastapi>=0.110",
"uvicorn[standard]>=0.29",
"pydantic>=2.7",
"pydantic-settings>=2.3",
"SQLAlchemy>=2.0",
"asyncpg>=0.29",
"psycopg2-binary>=2.9",
"httpx>=0.27",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0",
"pytest-asyncio>=0.23",
"httpx>=0.27",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
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,
)
+213
View File
@@ -0,0 +1,213 @@
"""多 agent 架构测试。"""
import pytest
from src.llm.agents.base import AgentReport, load_agent_prompt, _is_no_data
from src.llm.context_builder import MatchHeader
class TestNoDataGate:
"""no_data 门控: 切片无数据 → 跳过 LLM。"""
def test_is_no_data_all_lines(self):
assert _is_no_data("── 伤停 ──\n 无数据") is True
def test_is_no_data_with_content(self):
assert _is_no_data("── 交锋 ──\n 2026-03: A 2-1 B") is False
def test_is_no_data_empty(self):
assert _is_no_data("") is True
def test_is_no_data_mixed(self):
# 部分有数据部分无 → 不是 no_data
assert _is_no_data("── xG ──\n A: xG 1.5 vs B 0.8\n B: 无 xG 数据") is False
def test_stub_no_data_report(self):
from src.llm.agents.base import _stub_no_data
r = _stub_no_data("injuries")
assert r.status == "no_data"
assert r.data_sufficiency == "none"
assert r.home_edge is None
class TestPromptLoading:
"""agent prompt 模板加载。"""
@pytest.mark.parametrize("name", ["form", "stats", "home_away", "injuries", "h2h", "aggregator"])
def test_all_prompts_exist(self, name):
tpl = load_agent_prompt(name, "v1")
assert "{{context}}" in tpl or "{{agent_reports}}" in tpl
def test_prompt_not_found(self):
with pytest.raises(FileNotFoundError):
load_agent_prompt("nonexistent", "v1")
class TestReportParsing:
"""LLM JSON 输出 → AgentReport 解析(宽容处理)。"""
def _make_header(self) -> MatchHeader:
return MatchHeader(
match_id=1, home_name="A", away_name="B", league_name="PL",
season="2026-2027", match_date="2026-09-15", match_dt=None,
stage=None, home_team_id=10, away_team_id=20, league_id=1,
)
def test_parse_full_report(self):
from src.llm.agents.base import _parse_report
from src.llm.provider import LLMResponse
parsed = {
"data_sufficiency": "high",
"analysis": "主队交锋占优",
"home_edge": 0.6,
"confidence": 0.8,
"key_evidence": ["近5次交锋主队4胜", "主场交锋3连胜"],
}
resp = LLMResponse(content="{}", parsed=parsed, prompt_tokens=100, completion_tokens=50, latency_ms=500)
r = _parse_report("h2h", parsed, resp, "gpt-4o-mini")
assert r.status == "ok"
assert r.home_edge == 0.6
assert r.confidence == 0.8
assert len(r.key_evidence) == 2
assert r.data_sufficiency == "high"
def test_parse_xg_report_with_score(self):
from src.llm.agents.base import _parse_report
from src.llm.provider import LLMResponse
parsed = {
"data_sufficiency": "medium",
"analysis": "主队火力更强",
"home_edge": 0.4,
"confidence": 0.7,
"key_evidence": ["场均xG 2.1"],
"exp_home_goals": 2.1,
"exp_away_goals": 1.2,
"probable_score": {"home": 2, "away": 1, "prob": 0.14},
}
resp = LLMResponse(content="{}", parsed=parsed)
r = _parse_report("xg", parsed, resp, "gpt-4o-mini")
assert r.exp_home_goals == 2.1
assert r.probable_score == "2-1"
def test_parse_bad_values_forgiving(self):
"""非法数值/字段宽容降级,不抛异常。"""
from src.llm.agents.base import _parse_report
from src.llm.provider import LLMResponse
parsed = {
"data_sufficiency": "bogus", # 非法 → medium
"home_edge": "very strong", # 非法 → None
"confidence": None,
"key_evidence": "单字符串", # → [str]
}
resp = LLMResponse(content="{}", parsed=parsed)
r = _parse_report("form", parsed, resp, "m")
assert r.data_sufficiency == "medium"
assert r.home_edge is None
assert r.key_evidence == ["单字符串"]
class TestRunAgent:
"""run_agent 执行器: 门控 + fail-open。"""
@pytest.mark.asyncio
async def test_no_data_skips_llm(self):
"""切片无数据 → 不调 LLM,直接 stub。"""
from src.llm.agents.base import AgentSpec, run_agent
async def empty_slice(header, before=None):
return "── 伤停 ──\n 无数据"
spec = AgentSpec(name="injuries", system_prompt="s", slice_fn=empty_slice)
header = self._make_header()
class ExplodingProvider:
async def chat(self, *a, **kw):
raise AssertionError("LLM 不应被调用")
r = await run_agent(spec, header, ExplodingProvider(), version="v1")
assert r.status == "no_data"
@pytest.mark.asyncio
async def test_llm_error_fail_open(self):
"""LLM 调用失败 → status=error,不抛异常。"""
from src.llm.agents.base import AgentSpec, run_agent
async def good_slice(header, before=None):
return "── 交锋 ──\n 2026-03: A 2-1 B"
spec = AgentSpec(name="h2h", system_prompt="s", slice_fn=good_slice)
header = self._make_header()
class FailProvider:
async def chat(self, *a, **kw):
from src.llm.provider import LLMResponse
return LLMResponse(content="", error="timeout")
r = await run_agent(spec, header, FailProvider(), version="v1")
assert r.status == "error"
assert "LLM 调用失败" in r.analysis
@pytest.mark.asyncio
async def test_success_path(self):
"""正常路径: 切片 → LLM → 解析。"""
from src.llm.agents.base import AgentSpec, run_agent
from src.llm.provider import LLMResponse
async def good_slice(header, before=None):
return "── 交锋 ──\n 2026-03: A 2-1 B"
spec = AgentSpec(name="h2h", system_prompt="s", slice_fn=good_slice)
header = self._make_header()
class OkProvider:
model = "test-model"
async def chat(self, system, user, **kw):
assert "{{context}}" not in user # 模板已渲染
assert "A 2-1 B" in user
return LLMResponse(
content="{}",
parsed={"data_sufficiency": "high", "analysis": "ok", "home_edge": 0.5, "confidence": 0.9},
prompt_tokens=10, completion_tokens=5, latency_ms=100,
)
r = await run_agent(spec, header, OkProvider(), version="v1")
assert r.status == "ok"
assert r.home_edge == 0.5
assert r.model == "test-model"
def _make_header(self) -> MatchHeader:
return MatchHeader(
match_id=1, home_name="A", away_name="B", league_name="PL",
season="2026-2027", match_date="2026-09-15", match_dt=None,
stage=None, home_team_id=10, away_team_id=20, league_id=1,
)
class TestOrchestratorAggregation:
"""终裁输入拼装逻辑。"""
def test_reports_to_json(self):
from src.llm.agents.orchestrator import _reports_to_json
import json
reports = [
AgentReport(agent="h2h", status="ok", home_edge=0.5, confidence=0.8, analysis="a"),
AgentReport(agent="injuries", status="no_data", data_sufficiency="none"),
]
text = _reports_to_json(reports)
data = json.loads(text)
assert len(data) == 2
assert data[0]["agent"] == "h2h"
assert data[1]["status"] == "no_data"
def test_aggregator_prompt_renders(self):
"""终裁 prompt 模板两占位符都能渲染。"""
tpl = load_agent_prompt("aggregator", "v1")
rendered = (
tpl
.replace("{{match_header}}", "对阵: A vs B")
.replace("{{agent_reports}}", '[{"agent": "h2h"}]')
)
assert "{{match_header}}" not in rendered
assert "{{agent_reports}}" not in rendered
+112
View File
@@ -0,0 +1,112 @@
"""测试核心路径。"""
import pytest
from datetime import datetime, timezone
from src.data.normalize import NormalizedMatch, normalize_bzzoiro, _parse_date, _to_int, _to_float, derive_season_label
class TestNormalize:
def test_parse_date_iso(self):
assert _parse_date("2026-09-15T15:00:00Z") is not None
def test_parse_date_bare(self):
assert _parse_date("2026-09-15") is not None
def test_parse_date_none(self):
assert _parse_date(None) is None
assert _parse_date("") is None
def test_to_int_strict(self):
assert _to_int("2") == 2
assert _to_int("2.0") == 2
assert _to_int("2.8") is None # 拒绝非整数值
assert _to_int(None) is None
assert _to_int("-") is None
def test_to_float(self):
assert _to_float("1.5") == 1.5
assert _to_float(None) is None
def test_normalize_bzzoiro_finished(self):
raw = {
"event_date": "2026-09-15T15:00:00Z",
"status": "finished",
"home_team": "Man City",
"away_team": "Man United",
"home_score": 2,
"away_score": 1,
"round_number": 5,
}
m = normalize_bzzoiro(raw, "E0")
assert m is not None
assert m.home_team == "Manchester City"
assert m.away_team == "Manchester United"
assert m.home_goals == 2
assert m.away_goals == 1
assert m.match_status == "finished"
assert m.match_stage == "第 5 轮"
def test_normalize_bzzoiro_unknown_status(self):
raw = {"event_date": "2026-09-15", "status": "weird", "home_team": "A", "away_team": "B"}
assert normalize_bzzoiro(raw, "E0") is None
def test_normalized_match_validate_finished_no_score(self):
m = NormalizedMatch(
league_type="E0", date=_parse_date("2026-09-15"),
home_team="A", away_team="B", match_status="finished",
)
with pytest.raises(ValueError, match="must have score"):
m.validate()
def test_normalized_match_validate_goals_range(self):
m = NormalizedMatch(
league_type="E0", date=_parse_date("2026-09-15"),
home_team="A", away_team="B", match_status="finished",
home_goals=50, away_goals=0,
)
with pytest.raises(ValueError, match="out of range"):
m.validate()
class TestSeasonLabel:
def test_august_is_new_season(self):
# 8 月属于新赛季
d = datetime(2026, 8, 15, tzinfo=timezone.utc)
assert derive_season_label(d) == "2026-2027"
def test_july_is_old_season(self):
# 7 月属于上一赛季
d = datetime(2026, 7, 15, tzinfo=timezone.utc)
assert derive_season_label(d) == "2025-2026"
def test_january_is_old_season(self):
d = datetime(2026, 1, 15, tzinfo=timezone.utc)
assert derive_season_label(d) == "2025-2026"
class TestProviderMock:
"""用 mock 测试 LLM provider 的解析逻辑。"""
@pytest.mark.asyncio
async def test_provider_parses_json(self, monkeypatch):
from src.llm.provider import LLMProvider
async def fake_post(*args, **kwargs):
class FakeResp:
status_code = 200
def raise_for_status(self): pass
def json(self):
return {
"choices": [{"message": {"content": '{"pred_home_goals": 1.5, "pred_away_goals": 1.0, "1x2": "1", "confidence": 0.7, "reasoning": "test"}'}}],
"usage": {"prompt_tokens": 100, "completion_tokens": 50},
}
return FakeResp()
import httpx
monkeypatch.setattr(httpx.AsyncClient, "post", fake_post)
p = LLMProvider(api_key="test", model="gpt-4o")
resp = await p.chat("sys", "user", json_mode=True)
assert resp.error is None
assert resp.parsed is not None
assert resp.parsed["pred_home_goals"] == 1.5
assert resp.parsed["1x2"] == "1"