commit 0a27b18c27e022ad2c3c88f088abddd6d2bde095 Author: shangfangjian Date: Wed Sep 9 02:10:47 2026 +0800 feat: 足球 LLM 预测服务初始提交 Profeto — 给 LLM 提供数据,让 LLM 预测足球比分。 核心模块: - FastAPI 后端 + PostgreSQL (SQLAlchemy async) - 多 Agent LLM 预测 (5 专家 + 终裁) - 数据采集 (bzzoiro / understat / injuries) - React 前端 (Vite + Tailwind) 包含: - 数据源抽象 (DataSource 协议 + 注册表) - Alembic 数据库迁移 - Prompt 模板 (单/多 Agent) - 核心路径单元测试 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..cf85ac4 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ffc056a --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +__pycache__/ +*.pyc +.env +.venv/ +.pytest_cache/ +frontend/node_modules/ +frontend/dist/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..027ba3b --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..49c6877 --- /dev/null +++ b/README.md @@ -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 + +个人研究项目,预测结果不构成投注建议。 diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..4a2f83f --- /dev/null +++ b/alembic.ini @@ -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 /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 diff --git a/alembic/README b/alembic/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..87ac464 --- /dev/null +++ b/alembic/env.py @@ -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() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..1101630 --- /dev/null +++ b/alembic/script.py.mako @@ -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"} diff --git a/alembic/versions/0001_initial.py b/alembic/versions/0001_initial.py new file mode 100644 index 0000000..13bd555 --- /dev/null +++ b/alembic/versions/0001_initial.py @@ -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 ### diff --git a/alembic/versions/0002_agent_outputs.py b/alembic/versions/0002_agent_outputs.py new file mode 100644 index 0000000..7c0ff68 --- /dev/null +++ b/alembic/versions/0002_agent_outputs.py @@ -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') diff --git a/alembic/versions/0003_injuries.py b/alembic/versions/0003_injuries.py new file mode 100644 index 0000000..633e75d --- /dev/null +++ b/alembic/versions/0003_injuries.py @@ -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') diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..fcf0052 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/docs/01-architecture.md b/docs/01-architecture.md new file mode 100644 index 0000000..0582256 --- /dev/null +++ b/docs/01-architecture.md @@ -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) | diff --git a/docs/02-quickstart.md b/docs/02-quickstart.md new file mode 100644 index 0000000..69599c5 --- /dev/null +++ b/docs/02-quickstart.md @@ -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 只有五大联赛 | diff --git a/docs/03-api.md b/docs/03-api.md new file mode 100644 index 0000000..8d7e429 --- /dev/null +++ b/docs/03-api.md @@ -0,0 +1,189 @@ +# 03 · API 参考 + +Base URL: `http://localhost:8000` · 交互式文档: `/docs`(Swagger)与 `/redoc` + +所有数据端点返回 JSON。错误统一为 `{"detail": ""}` + 对应 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 | diff --git a/docs/04-agents.md b/docs/04-agents.md new file mode 100644 index 0000000..7cb47f2 --- /dev/null +++ b/docs/04-agents.md @@ -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,可直接对比准确率。 diff --git a/docs/05-data.md b/docs/05-data.md new file mode 100644 index 0000000..6999eaf --- /dev/null +++ b/docs/05-data.md @@ -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。 diff --git a/docs/06-deployment.md b/docs/06-deployment.md new file mode 100644 index 0000000..70949a5 --- /dev/null +++ b/docs/06-deployment.md @@ -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/校准度) diff --git a/docs/07-development.md b/docs/07-development.md new file mode 100644 index 0000000..fd2fb25 --- /dev/null +++ b/docs/07-development.md @@ -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/) +``` diff --git a/docs/08-injuries-data-gap.md b/docs/08-injuries-data-gap.md new file mode 100644 index 0000000..f78e007 --- /dev/null +++ b/docs/08-injuries-data-gap.md @@ -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); +""" diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..16be0c1 --- /dev/null +++ b/docs/README.md @@ -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 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..36ba107 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Profeto - 足球 LLM 预测 + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..ffc1ee8 --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2db3056 --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,5 @@ +export default { + content: ['./index.html', './src/**/*.{ts,tsx}'], + theme: { extend: {} }, + plugins: [], +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..a907011 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,15 @@ +import Matches from './pages/Matches' + +export default function App() { + return ( +
+
+

⚽ Profeto

+ 足球 LLM 预测服务 +
+
+ +
+
+ ) +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..b5c61c9 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..964aeb4 --- /dev/null +++ b/frontend/src/main.tsx @@ -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( + + + , +) diff --git a/frontend/src/pages/Matches.tsx b/frontend/src/pages/Matches.tsx new file mode 100644 index 0000000..5d6aaa3 --- /dev/null +++ b/frontend/src/pages/Matches.tsx @@ -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 | 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 = { + 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([]) + const [loading, setLoading] = useState(false) + const [predictingId, setPredictingId] = useState(null) + const [prediction, setPrediction] = useState(null) + const [error, setError] = useState(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 ( +
+ {/* 筛选 */} +
+ + + + 共 {matches.length} 场 +
+ + {error &&
{error}
} + + {/* 比赛表 */} +
+ + + + + + + + + + + + + {matches.length === 0 && !loading && ( + + )} + {matches.map(m => ( + + + + + + + + + ))} + +
日期主队客队比分状态操作
暂无数据,请先采集
{fmtDate(m.match_date)}{m.home_team_zh || m.home_team}{m.away_team_zh || m.away_team} + {m.home_goals !== null ? `${m.home_goals} - ${m.away_goals}` : '-'} + + + {m.match_status === 'finished' ? '完赛' : m.match_status === 'scheduled' ? '未开赛' : m.match_status} + + + +
+
+ + {/* 预测结果 */} + {prediction && ( +
+

🤖 LLM 预测结果

+
+
+
主进球
+
{prediction.pred_home_goals ?? '-'}
+
+
+
客进球
+
{prediction.pred_away_goals ?? '-'}
+
+
+
胜平负
+
{prediction.pred_1x2 ?? '-'}
+
+
+
置信度
+
+ {prediction.confidence !== null ? `${(prediction.confidence * 100).toFixed(0)}%` : '-'} +
+
+
+
+ {prediction.provider} / {prediction.model} · 耗时 {prediction.latency_ms}ms + {prediction.mode === 'multi' && ' · 多 Agent 模式'} +
+ + {/* 各专家 agent 报告 */} + {prediction.agent_outputs && prediction.agent_outputs.length > 0 && ( +
+
专家 Agent 报告
+ {prediction.agent_outputs.map((r) => ( +
+ + + {AGENT_LABELS[r.agent] || r.agent} + {r.status !== 'ok' && ( + + {r.status === 'no_data' ? '无数据' : '失败'} + + )} + + + {r.home_edge !== null && ( + 0 ? 'text-blue-600' : r.home_edge < 0 ? 'text-amber-600' : ''}> + 主队优势 {r.home_edge > 0 ? '+' : ''}{r.home_edge.toFixed(2)} + + )} + {r.confidence !== null && 信心 {(r.confidence * 100).toFixed(0)}%} + {r.probable_score && 比分 {r.probable_score}} + + +
+ {r.analysis &&

{r.analysis}

} + {r.key_evidence.length > 0 && ( +
    + {r.key_evidence.map((e, i) =>
  • {e}
  • )} +
+ )} + {r.exp_home_goals !== null && r.exp_away_goals !== null && ( +
+ 进球期望: {r.exp_home_goals.toFixed(1)} - {r.exp_away_goals.toFixed(1)} +
+ )} +
+ 数据充分度 {r.data_sufficiency} · {r.model} · {r.latency_ms}ms +
+
+
+ ))} +
+ )} + + {prediction.reasoning && ( +
+
推理过程
+
{prediction.reasoning}
+
+ )} +
+ 查看完整上下文 +
+              {prediction.context}
+            
+
+
+ )} +
+ ) +} diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js new file mode 100644 index 0000000..97ed72b --- /dev/null +++ b/frontend/tailwind.config.js @@ -0,0 +1,6 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: ['./index.html', './src/**/*.{ts,tsx}'], + theme: { extend: {} }, + plugins: [], +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..6bfa73a --- /dev/null +++ b/frontend/tsconfig.json @@ -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"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..52ad95a --- /dev/null +++ b/frontend/vite.config.ts @@ -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', + }, + }, +}) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c42902d --- /dev/null +++ b/pyproject.toml @@ -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"] diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/api/__init__.py b/src/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/api/app.py b/src/api/app.py new file mode 100644 index 0000000..4fff88e --- /dev/null +++ b/src/api/app.py @@ -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() diff --git a/src/api/routes/__init__.py b/src/api/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/api/routes/eval.py b/src/api/routes/eval.py new file mode 100644 index 0000000..a204428 --- /dev/null +++ b/src/api/routes/eval.py @@ -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() diff --git a/src/api/routes/ingest.py b/src/api/routes/ingest.py new file mode 100644 index 0000000..2e49ec9 --- /dev/null +++ b/src/api/routes/ingest.py @@ -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)) diff --git a/src/api/routes/matches.py b/src/api/routes/matches.py new file mode 100644 index 0000000..4cdbfad --- /dev/null +++ b/src/api/routes/matches.py @@ -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, + ) diff --git a/src/api/routes/predict.py b/src/api/routes/predict.py new file mode 100644 index 0000000..1a3f8f6 --- /dev/null +++ b/src/api/routes/predict.py @@ -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, + ) diff --git a/src/api/schemas.py b/src/api/schemas.py new file mode 100644 index 0000000..6da6b80 --- /dev/null +++ b/src/api/schemas.py @@ -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]] diff --git a/src/core/__init__.py b/src/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/core/config.py b/src/core/config.py new file mode 100644 index 0000000..2422a9a --- /dev/null +++ b/src/core/config.py @@ -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() diff --git a/src/core/http_client.py b/src/core/http_client.py new file mode 100644 index 0000000..8ef1482 --- /dev/null +++ b/src/core/http_client.py @@ -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 diff --git a/src/data/__init__.py b/src/data/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/data/bzzoiro.py b/src/data/bzzoiro.py new file mode 100644 index 0000000..9d4e96c --- /dev/null +++ b/src/data/bzzoiro.py @@ -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 diff --git a/src/data/config.py b/src/data/config.py new file mode 100644 index 0000000..20b30ed --- /dev/null +++ b/src/data/config.py @@ -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 限速(秒) diff --git a/src/data/injuries.py b/src/data/injuries.py new file mode 100644 index 0000000..b2c953c --- /dev/null +++ b/src/data/injuries.py @@ -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()) diff --git a/src/data/match_lookup.py b/src/data/match_lookup.py new file mode 100644 index 0000000..83eb167 --- /dev/null +++ b/src/data/match_lookup.py @@ -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() diff --git a/src/data/normalize.py b/src/data/normalize.py new file mode 100644 index 0000000..be5cfc7 --- /dev/null +++ b/src/data/normalize.py @@ -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), + ) diff --git a/src/data/sources.py b/src/data/sources.py new file mode 100644 index 0000000..acd1486 --- /dev/null +++ b/src/data/sources.py @@ -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 diff --git a/src/data/team_names.py b/src/data/team_names.py new file mode 100644 index 0000000..04d452c --- /dev/null +++ b/src/data/team_names.py @@ -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) diff --git a/src/data/understat.py b/src/data/understat.py new file mode 100644 index 0000000..66228b3 --- /dev/null +++ b/src/data/understat.py @@ -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 diff --git a/src/db/__init__.py b/src/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/db/base.py b/src/db/base.py new file mode 100644 index 0000000..829c649 --- /dev/null +++ b/src/db/base.py @@ -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) diff --git a/src/db/models.py b/src/db/models.py new file mode 100644 index 0000000..55c49bf --- /dev/null +++ b/src/db/models.py @@ -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"), + ) diff --git a/src/llm/__init__.py b/src/llm/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/llm/agents/__init__.py b/src/llm/agents/__init__.py new file mode 100644 index 0000000..07346af --- /dev/null +++ b/src/llm/agents/__init__.py @@ -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", +] diff --git a/src/llm/agents/base.py b/src/llm/agents/base.py new file mode 100644 index 0000000..b7dcd4f --- /dev/null +++ b/src/llm/agents/base.py @@ -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) diff --git a/src/llm/agents/orchestrator.py b/src/llm/agents/orchestrator.py new file mode 100644 index 0000000..cf7b283 --- /dev/null +++ b/src/llm/agents/orchestrator.py @@ -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, + ) diff --git a/src/llm/context_builder.py b/src/llm/context_builder.py new file mode 100644 index 0000000..a70c989 --- /dev/null +++ b/src/llm/context_builder.py @@ -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()) diff --git a/src/llm/eval.py b/src/llm/eval.py new file mode 100644 index 0000000..43c6ed3 --- /dev/null +++ b/src/llm/eval.py @@ -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} diff --git a/src/llm/predict.py b/src/llm/predict.py new file mode 100644 index 0000000..ece94b0 --- /dev/null +++ b/src/llm/predict.py @@ -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 diff --git a/src/llm/prompts/agents/aggregator_v1.md b/src/llm/prompts/agents/aggregator_v1.md new file mode 100644 index 0000000..2183032 --- /dev/null +++ b/src/llm/prompts/agents/aggregator_v1.md @@ -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": , + "pred_away_goals": , + "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>} +} +``` diff --git a/src/llm/prompts/agents/form_v1.md b/src/llm/prompts/agents/form_v1.md new file mode 100644 index 0000000..9d956bf --- /dev/null +++ b/src/llm/prompts/agents/form_v1.md @@ -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>"] +} +``` diff --git a/src/llm/prompts/agents/h2h_v1.md b/src/llm/prompts/agents/h2h_v1.md new file mode 100644 index 0000000..c650d22 --- /dev/null +++ b/src/llm/prompts/agents/h2h_v1.md @@ -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>"] +} +``` diff --git a/src/llm/prompts/agents/home_away_v1.md b/src/llm/prompts/agents/home_away_v1.md new file mode 100644 index 0000000..490c47c --- /dev/null +++ b/src/llm/prompts/agents/home_away_v1.md @@ -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>"] +} +``` diff --git a/src/llm/prompts/agents/injuries_v1.md b/src/llm/prompts/agents/injuries_v1.md new file mode 100644 index 0000000..0390cc1 --- /dev/null +++ b/src/llm/prompts/agents/injuries_v1.md @@ -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>"] +} +``` diff --git a/src/llm/prompts/agents/stats_v1.md b/src/llm/prompts/agents/stats_v1.md new file mode 100644 index 0000000..66245f8 --- /dev/null +++ b/src/llm/prompts/agents/stats_v1.md @@ -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>"] +} +``` diff --git a/src/llm/prompts/match_prediction_v1.md b/src/llm/prompts/match_prediction_v1.md new file mode 100644 index 0000000..c7e2a34 --- /dev/null +++ b/src/llm/prompts/match_prediction_v1.md @@ -0,0 +1,15 @@ +你是足球分析专家。根据以下数据预测比赛结果。只输出 JSON,不要解释。 + +{{context}} + +严格按此 JSON 输出: +```json +{ + "pred_home_goals": "", + "pred_away_goals": "", + "1x2": "<'1'|'X'|'2'>", + "confidence": "<0.0-1.0>", + "score_probable": {"home": "", "away": "", "prob": ""}, + "reasoning": "<200 字内推理>" +} +``` diff --git a/src/llm/prompts/match_prediction_v2.md b/src/llm/prompts/match_prediction_v2.md new file mode 100644 index 0000000..f8eaa05 --- /dev/null +++ b/src/llm/prompts/match_prediction_v2.md @@ -0,0 +1,21 @@ +你是足球分析专家。根据以下数据预测比赛结果。 + +{{context}} + +请分析: +1. 主客队近期状态差异 +2. 主客场因素 +3. 历史交锋心理优势 +4. 联赛排名差距 + +严格按此 JSON 输出,不要其他内容: +```json +{ + "pred_home_goals": "", + "pred_away_goals": "", + "1x2": "<'1'|'X'|'2'>", + "confidence": "<0.0-1.0>", + "score_probable": {"home": "", "away": "", "prob": ""}, + "reasoning": "<200 字内推理,需引用具体数据>" +} +``` diff --git a/src/llm/provider.py b/src/llm/provider.py new file mode 100644 index 0000000..77aa539 --- /dev/null +++ b/src/llm/provider.py @@ -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, + ) diff --git a/tests/test_agents.py b/tests/test_agents.py new file mode 100644 index 0000000..6ac2b9e --- /dev/null +++ b/tests/test_agents.py @@ -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 diff --git a/tests/test_core.py b/tests/test_core.py new file mode 100644 index 0000000..c07ed3e --- /dev/null +++ b/tests/test_core.py @@ -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"