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