feat: 足球 LLM 预测服务初始提交

Profeto — 给 LLM 提供数据,让 LLM 预测足球比分。

核心模块:
- FastAPI 后端 + PostgreSQL (SQLAlchemy async)
- 多 Agent LLM 预测 (5 专家 + 终裁)
- 数据采集 (bzzoiro / understat / injuries)
- React 前端 (Vite + Tailwind)

包含:
- 数据源抽象 (DataSource 协议 + 注册表)
- Alembic 数据库迁移
- Prompt 模板 (单/多 Agent)
- 核心路径单元测试
This commit is contained in:
shangfangjian
2026-09-09 02:10:47 +08:00
commit 0a27b18c27
74 changed files with 5667 additions and 0 deletions
+107
View File
@@ -0,0 +1,107 @@
# 01 · 架构总览
## 系统架构
```
┌─────────────────────────────────────────────────────┐
│ 前端(单页 React + Vite + Tailwind) │
│ 比赛列表 → 选比赛 → LLM 预测 → 专家报告 + 最终结论 │
└──────────────────────────▲──────────────────────────┘
│ REST (/api/v1)
┌──────────────────────────┴──────────────────────────┐
│ FastAPI(单进程,全 async) │
│ │
│ 数据查询 预测编排 采集(手动/cron 触发) │
│ ┌──────┐ ┌────────────┐ ┌───────────────────┐ │
│ │matches│ │ orchestrator│ │ bzzoiro (赛果) │ │
│ │leagues│ │ ┌─ 5 专家并行(便宜模型) │ │
│ └──┬───┘ │ │ h2h / form / stats / │ │
│ │ │ │ home_away / injuries │ │
│ │ │ └─ aggregator 终裁(强模型) │ │
│ │ └────────────┘ └───────────────────┘ │
│ │ │ └ understat (xG) │
│ ┌──┴──────────────┴──┐ └ injuries (伤停) │
│ │ PostgreSQL (5 张表) │ httpx → 外部 API │
│ └────────────────────┘ │
└─────────────────────────────────────────────────────┘
```
## 核心数据流(一次多 Agent 预测)
1. `POST /predict {match_id}` → orchestrator
2. `load_match_header`: 查比赛 + 双方 + 联赛(一次 eager load)
3. **5 个专家 agent 并行**(`asyncio.gather`),每个:
- 各自的数据切片函数查库(近况/交锋/积分榜 SQL 聚合/伤停/xG)
- 切片无数据 → **跳过 LLM**,直接 `no_data` stub(省 token、防幻觉)
- 有数据 → 专属 prompt(专家模型,便宜快)→ 结构化 JSON 报告(`home_edge` 方向性评分 + 证据)
4. **终裁 agent**:5 份报告 + 比赛信息 → 权衡采信度(`agent_weights`)→ 最终预测 JSON
5.`predictions` 表(含 `agent_outputs` 全部报告)
6. 赛后 `POST /eval/settle` 回填实际比分 → `GET /eval/summary` 按 模型×prompt 版本 聚合准确率
## 关键设计决策
| 决策 | 理由 |
|---|---|
| **多专家并行而非单次大 prompt** | 每维度独立迭代 prompt;报告可归因(哪个维度分析错了);总延迟 ≈ 2 次串行调用 |
| **专家/终裁模型分档** | 专家用便宜模型快速分析,终裁用强模型汇总决策,成本与质量平衡(`LLM_SPECIALIST_MODEL` / `LLM_AGGREGATOR_MODEL`) |
| **no_data 门控** | 无数据维度(如伤停未接入)不调 LLM,终裁知道维度缺失,不编造 |
| **fail-open** | 单个专家失败只标记 `status=error`,其余照常;研究场景可用性优先 |
| **`match_date_date` 天级去重** | 不同源时间精度不同,秒级匹配会产生重复行;天级 + 数据库唯一约束 |
| **积分榜 SQL 聚合 + season 过滤** | `UNION ALL` 主客双视角 + `GROUP BY` 在库内算,只算当前赛季(修复过跨赛季 bug) |
| **单 agent 模式保留** | `mode="single"` 走旧单次路径,与 multi 形成天然 A/B(eval 按 `prompt_version` 分组) |
| **无 worker/redis/队列** | 采集是 cron 触发的短任务,单进程足够;违背简化初衷的基础设施一律不加 |
## 目录结构
```
Profeto/
├── src/
│ ├── api/
│ │ ├── app.py # FastAPI 工厂(lifespan 建表)
│ │ ├── schemas.py # Pydantic v2 请求/响应
│ │ └── routes/
│ │ ├── matches.py # 联赛/比赛查询(游标分页)
│ │ ├── predict.py # 预测 + 预测历史
│ │ ├── ingest.py # 采集触发(自管 session)
│ │ └── eval.py # 赛后回填 + 准确率汇总
│ ├── db/
│ │ ├── base.py # async engine + get_db/get_db_read
│ │ └── models.py # 5 张表 ORM
│ ├── data/
│ │ ├── bzzoiro.py # 赛果采集 + 幂等入库
│ │ ├── understat.py # xG 回填
│ │ ├── injuries.py # 伤停采集(带文件缓存)
│ │ ├── normalize.py # NormalizedMatch 清洗契约
│ │ ├── team_names.py # 队名归一映射
│ │ └── config.py # 联赛代码映射
│ ├── llm/
│ │ ├── provider.py # OpenAI-compatible 抽象(共享连接池/JSON 兜底解析)
│ │ ├── context_builder.py # 数据切片(h2h/form/stats/home_away/injuries)+ 单 agent 拼接
│ │ ├── predict.py # 预测入口(mode 分派 + 缓存)
│ │ ├── eval.py # 准确率统计
│ │ ├── agents/
│ │ │ ├── base.py # AgentSpec / AgentReport / run_agent
│ │ │ └── orchestrator.py # 并行专家 → 终裁 → 存库
│ │ └── prompts/
│ │ ├── match_prediction_v1/v2.md # 单 agent 模板
│ │ └── agents/{h2h,form,stats,home_away,injuries,aggregator}_v1.md
│ └── core/config.py # pydantic-settings
├── alembic/versions/ # 0001 建表 + 0002 agent 字段
├── frontend/src/pages/Matches.tsx # 单页(预测面板 + 专家报告折叠区)
├── tests/ # 33 项(核心 13 + agent 20)
├── docker-compose.yml # api + postgres 两容器
└── docs/ # 本文档
```
## 技术栈
| 层 | 选型 |
|---|---|
| API | FastAPI + uvicorn(全 async) |
| ORM | SQLAlchemy 2.0 async + asyncpg |
| 数据库 | PostgreSQL 16(JSONB 存 agent 报告) |
| HTTP | httpx(共享连接池)/ urllib(bzzoiro 同步限速) |
| LLM | OpenAI-compatible 接口(openai/deepseek/ollama 等任一) |
| 前端 | Vite + React 18 + TypeScript + Tailwind |
| 测试 | pytest + pytest-asyncio(33 项,自包含) |
| 部署 | Docker Compose(api + postgres) |
+129
View File
@@ -0,0 +1,129 @@
# 02 · 快速开始
## 前置条件
- Python 3.11+
- Docker Desktop(跑 PostgreSQL)
- 一个 OpenAI-compatible 的 LLM API Key(OpenAI / Deepseek / Ollama 等任一)
- bzzoiro 数据源 Key(旧项目 MatchPro 的同一 Key)
## 1. 安装
```bash
cd P:\Profeto
pip install -e ".[dev]"
```
## 2. 配置环境变量
```bash
cp .env.example .env
```
编辑 `.env`,至少填这三项:
```ini
LLM_API_KEY=sk-xxx # 必填
LLM_BASE_URL=https://api.openai.com/v1 # 换成你的提供商
LLM_MODEL=gpt-4o-mini # 默认模型
# 可选分档(推荐):
LLM_SPECIALIST_MODEL=gpt-4o-mini # 5 个专家用(便宜快)
LLM_AGGREGATOR_MODEL=gpt-4o # 终裁用(强)
BZZOIRO_KEY=xxx # 数据采集用
```
## 3. 启动数据库 + 建表
```bash
docker compose up -d postgres
alembic upgrade head
```
## 4. 启动服务
```bash
# 后端
uvicorn src.api.app:app --reload
# 前端(另开终端)
cd frontend
npm install
npm run dev
```
- 前端界面: http://localhost:5173
- API 文档(Swagger): http://localhost:8000/docs
## 5. 首次跑通全流程
### 采集历史数据(英超近两个月为例)
```bash
curl -X POST http://localhost:8000/api/v1/ingest/bzzoiro \
-H "Content-Type: application/json" \
-d '{"leagues":["E0"],"date_from":"2026-08-01","date_to":"2026-09-08"}'
```
数据量大时**直接拉整赛季**(约 380 场,含近几个赛季更好,近况/交锋/积分榜都需要历史):
```bash
curl -X POST http://localhost:8000/api/v1/ingest/bzzoiro \
-H "Content-Type: application/json" \
-d '{"leagues":["E0"],"date_from":"2025-08-01","date_to":"2026-09-08"}'
```
### 回填 xG(可选,让攻防数据 agent 有数据)
```bash
curl -X POST http://localhost:8000/api/v1/ingest/understat \
-H "Content-Type: application/json" \
-d '{"league":"E0","season":2025}'
```
### 查比赛
浏览器打开 http://localhost:5173 ,选"英超 / 未开赛";
或:
```bash
curl "http://localhost:8000/api/v1/matches?league=E0&status=scheduled"
```
### LLM 预测
页面上点"LLM 预测",预测面板会展示最终结论 + 5 个专家 agent 的折叠报告(方向性评分/证据/分析);
或:
```bash
curl -X POST http://localhost:8000/api/v1/predict \
-H "Content-Type: application/json" \
-d '{"match_id": 1}'
```
不传 `mode` 默认走多 agent;`"mode":"single"` 走单次调用旧路径(用于对比)。
### 赛后评估
比赛结束后,采集最新赛果(同一条 ingest 命令会自动把 scheduled 升级为 finished 并补比分),
然后回填预测:
```bash
curl -X POST http://localhost:8000/api/v1/eval/settle \
-H "Content-Type: application/json" \
-d '{"prediction_id": 1, "home_goals": 2, "away_goals": 1}'
# 汇总准确率(按 模型 × prompt 版本,含 multi/single 对比)
curl http://localhost:8000/api/v1/eval/summary
```
## 常见问题
| 问题 | 处理 |
|---|---|
| 连不上数据库 | `docker compose ps` 确认 postgres 健康;`.env``DATABASE_URL` 与 compose 一致 |
| predict 返回 502 | 看 uvicorn 日志的 LLM error;确认 `LLM_BASE_URL`/`LLM_API_KEY`;`response_format` 不兼容的网关会报错(改用支持 json mode 的模型) |
| 采集 0 场 | bzzoiro Key 失效或联赛代码写错;先 `GET /api/v1/leagues` 看库里有没有联赛 |
| 专家报告全是 no_data | 历史数据不够 —— 近况需要每队近 5 场、积分榜需要本赛季已完赛比赛,多拉几周数据 |
| xg agent 报无 xG 数据 | 先跑 understat 回填;注意 understat 只有五大联赛 |
+189
View File
@@ -0,0 +1,189 @@
# 03 · API 参考
Base URL: `http://localhost:8000` · 交互式文档: `/docs`(Swagger)与 `/redoc`
所有数据端点返回 JSON。错误统一为 `{"detail": "<message>"}` + 对应 HTTP 状态码。
---
## 数据查询
### `GET /api/v1/leagues`
列出已入库联赛。
```json
[{"id": 1, "code": "E0", "name": "Premier League", "country": "England"}]
```
### `GET /api/v1/matches`
比赛列表,游标分页。
| 参数 | 说明 |
|---|---|
| `league` | 联赛代码,如 `E0` / `SP1` / `D1` / `I1` / `F1` |
| `status` | `scheduled` / `finished`(不传 = 全部) |
| `date` | `YYYY-MM-DD`,当天比赛 |
| `cursor` | 上一页返回的 `next_cursor` |
| `limit` | 1100,默认 50 |
响应(倒序,含双方中文名与 xG):
```json
{
"items": [
{"id": 42, "league_code": "E0", "season": "2026-2027",
"home_team": "Arsenal", "away_team": "Manchester United",
"home_team_zh": null, "away_team_zh": null,
"match_date": "2026-09-15T19:00:00Z", "match_status": "scheduled",
"home_goals": null, "away_goals": null,
"match_stage": "第 5 轮", "home_xg": null, "away_xg": null}
],
"next_cursor": "2026-09-15T19:00:00+00:00|41",
"has_more": true
}
```
### `GET /api/v1/matches/{id}`
单场比赛详情,字段同上。
---
## 预测
### `POST /api/v1/predict`
对一场比赛做 LLM 预测。**核心端点。**
请求:
```json
{
"match_id": 42,
"mode": "multi",
"model": "gpt-4o",
"prompt_version": "v1"
}
```
| 字段 | 默认 | 说明 |
|---|---|---|
| `match_id` | 必填 | 比赛 ID |
| `mode` | `multi` | `multi` = 5 专家 + 终裁;`single` = 单次调用 |
| `model` | 配置值 | 覆盖本次模型(single 模式下生效) |
| `prompt_version` | `v1` | prompt 版本(multi 模式即 agent prompt 版本) |
响应(multi 模式):
```json
{
"prediction_id": 7,
"provider": "openai",
"model": "gpt-4o",
"prompt_version": "multi_v1",
"mode": "multi",
"pred_home_goals": 2.1,
"pred_away_goals": 1.0,
"pred_1x2": "1",
"confidence": 0.68,
"reasoning": "综合 xg 报告的进球期望 2.1-1.0 与 form 报告的三连胜势头……",
"agent_outputs": [
{"agent": "h2h", "status": "ok", "data_sufficiency": "medium",
"analysis": "近 5 次交锋主队 3 胜……", "home_edge": 0.4,
"confidence": 0.7, "key_evidence": ["近5次交锋主队3胜", "主场交锋3连胜"],
"exp_home_goals": null, "exp_away_goals": null, "probable_score": null,
"model": "gpt-4o-mini", "latency_ms": 2100,
"prompt_tokens": 380, "completion_tokens": 120},
{"agent": "injuries", "status": "no_data", "data_sufficiency": "none",
"analysis": "该维度无数据,跳过分析。", "home_edge": null, "confidence": null,
"key_evidence": [], "exp_home_goals": null, "exp_away_goals": null,
"probable_score": null, "model": "", "latency_ms": null,
"prompt_tokens": null, "completion_tokens": null}
],
"agent_weights": {"form": 0.9, "stats": 0.8, "home_away": 0.7, "injuries": 0.0, "h2h": 0.8},
"context": "[5 份报告的 JSON 串]",
"latency_ms": 9800
}
```
`agent.status` 取值:`ok` / `no_data`(维度无数据,已跳过 LLM)/ `error`(调用失败,fail-open 不阻断)/ `parse_error`
错误:404 比赛不存在;502 LLM 调用失败(终裁失败时整体失败,专家失败不会)。
### `GET /api/v1/predictions?match_id=&limit=`
预测历史(倒序),含 `settled` 与实际比分回填状态。
### `GET /api/v1/predictions/{id}`
单条预测详情(含完整 `agent_outputs`)。
---
## 数据采集
### `POST /api/v1/ingest/bzzoiro`
从 bzzoiro 采集赛果/赛程并入库(幂等,重复跑安全)。
```json
{"leagues": ["E0", "SP1"], "date_from": "2025-08-01", "date_to": "2026-09-08", "status": "finished"}
```
- `status` 还可传 `scheduled` 拉未来赛程
- 响应含每联赛 `inserted`/`updated`/`errors` 统计
### `POST /api/v1/ingest/understat`
回填 xG(只补空字段,不创建比赛):
```json
{"league": "E0", "season": 2025}
```
`season=2025` 表示 2025-2026 赛季。仅支持五大联赛。
### `POST /api/v1/ingest/injuries`
采集伤停(需 `API_FOOTBALL_KEY`,当前只返回计数,尚未接入 context):
```json
{"date": "2026-09-10"}
```
---
## 评估
### `POST /api/v1/eval/settle`
赛后回填实际比分:
```json
{"prediction_id": 7, "home_goals": 2, "away_goals": 1}
```
### `GET /api/v1/eval/summary`
`provider × model` 聚合已结算预测:
```json
{"summary": [
{"provider": "openai", "model": "gpt-4o", "total": 12,
"accuracy_1x2": 58.3, "avg_score_rmse": 1.21, "avg_confidence": 0.65}
]}
```
> 提示:multi 模式存的 `model` 是终裁模型、`prompt_version` 是 `multi_v1`,
> 因此 summary 里天然可对比 multi vs single、以及不同 prompt 版本的效果。
---
## 基础
| 端点 | 说明 |
|---|---|
| `GET /health` | 存活检查 |
| `GET /docs` | Swagger UI |
+165
View File
@@ -0,0 +1,165 @@
# 04 · 多 Agent 预测架构
Profeto 的核心预测路径是 **5 个领域专家 Agent 并行分析 + 1 个终裁 Agent 汇总决策**
每个专家只拿到自己维度的数据切片,输出结构化 JSON;终裁综合 5 份报告给出最终预测。
## Agent 一览
| Agent | 职责 | 数据切片 | 输出核心 |
|---|---|---|---|
| `form` 近期状态 | 分析比分与关键事件,判断近期走势 | 两队近 N 场赛果(含 xG) | `home_edge` + 走势判断 |
| `stats` 攻防数据 | 评估进球、射门与控球,量化攻防强度 | 近 N 场进球/射门/控球/xG 统计 | `home_edge` + 攻防强度 |
| `home_away` 主客因素 | 对比主场与客场表现,评估地理优势影响 | 主队主场战绩 + 客队客场战绩 | `home_edge` + 地理优势 |
| `injuries` 阵容完整性 | 汇总伤停与停赛名单,评估战力缺失程度 | 伤停数据(当前无源 → no_data 门控) | `home_edge``no_data` |
| `h2h` 历史交锋 | 分析过去数年以及近期的交手数据,提取交手规律 | 近 N 次交锋(含主客方向 + 总计统计) | `home_edge` + 交手规律 |
| `aggregator` 终裁 | 权衡 5 份报告 → 最终结论 | 5 份结构化报告 + 比赛头信息 | 最终预测 + 各报告采信度 |
## 数据流
```
POST /predict {match_id, mode: "multi"}
├─ load_match_header ──► MatchHeader(比赛基础信息,所有 agent 共享)
├─ asyncio.gather(并行执行 5 专家, before=match_date 防未来信息泄漏)
│ ├─ form agent ─┐
│ ├─ stats agent │ 每个 agent 拿到专属数据切片
│ ├─ home_away agent │ → no_data 门控 → 调 LLM → 输出 JSON 报告
│ ├─ injuries agent │ (无数据 → 跳过 LLM,返回 stub)
│ └─ h2h agent ─┘
├─ aggregator agent(5 份报告 + 比赛头 → 最终 JSON)
└─ 存 predictions(mode="multi", agent_outputs JSONB)
```
## 执行语义
### 1. 并行执行
5 个专家通过 `asyncio.gather` 并发,总延迟 ≈ `max(专家延迟) + 终裁延迟` ≈ 2 次串行 LLM 调用。
### 2. no_data 门控(省 token、防幻觉)
数据切片为空时(如伤停数据源未接入),**跳过 LLM 调用**,直接返回:
```json
{"agent": "injuries", "status": "no_data", "data_sufficiency": "none",
"analysis": "该维度无数据,跳过分析。"}
```
终裁 Agent 会看到这个 `no_data` 状态,不会编造伤停分析。
### 3. fail-open(单专家失败不阻断)
单个专家 LLM 调用失败 → 其报告标记 `status: error`,其余 4 份 + 终裁照常执行。
只有**终裁 Agent 失败**才会整体返回 502。
### 4. 防未来信息泄漏
所有切片查询都带 `before=match_date`,确保只用比赛**之前**的数据。
这对历史回测(对已完赛比赛跑预测)尤其重要。
## 输出契约
### 专家 Agent 统一输出(JSON)
```json
{
"agent": "h2h",
"status": "ok",
"data_sufficiency": "high",
"analysis": "近 5 次交锋主队 3 胜 1 平 1 负,主场交锋 3 连胜……",
"home_edge": 0.4,
"confidence": 0.7,
"key_evidence": ["近5次交锋主队3胜", "主场交锋3连胜"]
}
```
| 字段 | 说明 |
|---|---|
| `status` | `ok` / `no_data` / `error` / `parse_error` |
| `data_sufficiency` | `high` / `medium` / `low` / `none` |
| `home_edge` | -1.0 ~ 1.0,正数=利主队,负数=利客队 |
| `confidence` | 0.0 ~ 1.0,该专家对自己分析的信心 |
| `key_evidence` | 关键证据列表(最多 5 条) |
### 终裁 Agent 输出
在现有最终预测 schema 基础上新增 `agent_weights`:
```json
{
"pred_home_goals": 2.1,
"pred_away_goals": 1.0,
"1x2": "1",
"confidence": 0.68,
"reasoning": "综合 stats 报告的攻防强度与 form 报告的三连胜势头……",
"agent_weights": {"form": 0.9, "stats": 0.8, "home_away": 0.7, "injuries": 0.0, "h2h": 0.8}
}
```
`agent_weights` 体现终裁对各专家报告的采信度(0–1),可用于后续分析"哪个维度对预测贡献大"。
## 模型分档配置
专家用便宜快模型,终裁用强模型,各自回落 `LLM_MODEL`:
| 环境变量 | 说明 | 回落 |
|---|---|---|
| `LLM_SPECIALIST_MODEL` | 5 个专家共用模型 | `LLM_MODEL` |
| `LLM_AGGREGATOR_MODEL` | 终裁模型 | `LLM_MODEL` |
示例(专家用 gpt-4o-mini,终裁用 gpt-4o):
```bash
LLM_MODEL=gpt-4o
LLM_SPECIALIST_MODEL=gpt-4o-mini
LLM_AGGREGATOR_MODEL=gpt-4o
```
## Prompt 版本化
每个 Agent 有独立 prompt 文件,位于 `src/llm/prompts/agents/`:
```
agents/
├── form_v1.md # 近期状态专家
├── stats_v1.md # 攻防数据专家
├── home_away_v1.md # 主客因素专家
├── injuries_v1.md # 阵容完整性专家
├── h2h_v1.md # 历史交锋专家
└── aggregator_v1.md # 终裁
```
调用时传 `prompt_version: "v1"` 即加载所有 `*_v1.md`
迭代 prompt 时:复制 `h2h_v1.md``h2h_v2.md`,改内容,传 `prompt_version: "v2"`
`predictions.prompt_version` 存的是 `multi_v2`,与 single 模式的 `v1`/`v2` 天然分组,可在 eval summary 中 A/B 对比。
## 如何新增一个专家 Agent
三步:
**1. 写切片函数**(`src/llm/context_builder.py`):
```python
async def weather_slice(header: MatchHeader, *, before=None) -> str:
"""天气切片示例。"""
return "── 天气 ──\n 比赛日: 小雨 15°C"
```
**2. 注册 AgentSpec**(`src/llm/agents/orchestrator.py``SPECIALIST_SPECS`):
```python
AgentSpec(name="weather", system_prompt="你是足球天气影响分析专家。只输出 JSON。",
slice_fn=weather_slice)
```
**3. 写 prompt 文件**(`src/llm/prompts/agents/weather_v1.md`):
```markdown
你是足球天气影响分析专家。分析以下天气数据对比赛的影响。
{{context}}
严格按此 JSON 输出……
```
重启服务即生效,终裁会自动收到第 6 份报告。
## 与单 Agent 模式的关系
`mode: "single"` 走原有单次调用路径(一个大 context + 一个 prompt),用于:
- 与 multi 模式做 A/B 基线对比
- 快速验证(省 token)
- 调试单个 prompt
eval summary 按 `prompt_version` 分组:`v1`/`v2` 是 single,`multi_v1`/`multi_v2` 是 multi,可直接对比准确率。
+175
View File
@@ -0,0 +1,175 @@
# 05 · 数据层与数据库
## 数据源
| 数据源 | 用途 | 必需 Key | 说明 |
|---|---|---|---|
| bzzoiro | 赛果/赛程(主源) | `BZZOIRO_KEY` | 五大联赛历史 + 实时 |
| understat | xG 回填 | 无(公开) | 仅五大联赛,补 `match_stats.xg` |
| api-football | 伤停 | `API_FOOTBALL_KEY` | 当前只采集计数,未接入 context |
### bzzoiro
- 端点:`/api/v2/events/`,按 `league_id` + 日期范围分页
- 限速:`REQUEST_INTERVAL = 1.2s`,429 自动重试 3 次(轮换 key)
- 联赛映射(`src/data/config.py`):
```python
BZZOIRO_LEAGUE_IDS = {"E0": 1, "SP1": 3, "D1": 5, "I1": 4, "F1": 6, "CL": 7, "EL": 8}
```
### understat
- 端点:`/getLeagueData/{league}/{season}`,返回 JS 包裹的 JSON(需正则提取)
- 只回填 xG(`match_stats.home_xg`/`away_xg`),**不创建新比赛**
- 通过"天级日期 + 队名归一"匹配已有比赛
## 数据清洗契约
所有数据源统一清洗为 `NormalizedMatch`(`src/data/normalize.py`),字段:
| 字段 | 类型 | 说明 |
|---|---|---|
| `league_type` | str | 联赛代码(小写,如 `E0`) |
| `date` | datetime | UTC 时间 |
| `home_team` / `away_team` | str | 归一化后的规范名 |
| `match_status` | str | `finished`/`scheduled`/... |
| `home_goals` / `away_goals` | int? | 全场比分 |
| `home_ht_goals` / `away_ht_goals` | int? | 半场比分 |
| `home_xg` / `away_xg` | float? | 期望进球 |
| `home_shots` / `away_shots` | int? | 射门 |
| `home_shots_on_target` / `away_shots_on_target` | int? | 射正 |
| `home_corners` / `away_corners` | int? | 角球 |
| `home_possession` | float? | 主队控球率 |
| `home_yellow_cards` / `away_yellow_cards` | int? | 黄牌 |
| `home_red_cards` / `away_red_cards` | int? | 红牌 |
| `match_stage` | str? | 轮次(如"第 5 轮") |
| `season_label` | str | 赛季标签(如 `2026-2027`) |
### 校验规则(`validate()`)
- `finished` 无比分 → 抛错(或降级为 `scheduled`)
- 比分 030,xG 020,射门/射正/角球 0100,红黄牌 020,控球率 0100
- 半场比分 ≤ 全场比分
- 无比分小数(`_to_int` 严格: `"2.8"` → `None`,不截断)
### 队名归一化
`src/data/team_names.py` 维护 `NORMALIZE_MAP`(如 `Man City` → `Manchester City`),未命中映射的队名原样返回。
归一前先做 Unicode NFKD 去重音。
## 数据库 Schema
5 张表:
```sql
-- 联赛
CREATE TABLE leagues (
id SERIAL PRIMARY KEY,
code VARCHAR(20) UNIQUE NOT NULL, -- 'E0' / 'SP1'
name VARCHAR(100) NOT NULL,
country VARCHAR(50),
created_at TIMESTAMPTZ
);
-- 球队
CREATE TABLE teams (
id SERIAL PRIMARY KEY,
name VARCHAR(120) UNIQUE NOT NULL, -- 规范名(归一后)
name_zh VARCHAR(60), -- 中文名
team_type VARCHAR(20) DEFAULT 'club',
created_at TIMESTAMPTZ
);
-- 比赛(核心)
CREATE TABLE matches (
id SERIAL PRIMARY KEY,
league_id INT REFERENCES leagues(id),
season VARCHAR(12), -- '2026-2027'
home_team_id INT REFERENCES teams(id),
away_team_id INT REFERENCES teams(id),
match_date TIMESTAMPTZ NOT NULL,
match_date_date DATE NOT NULL, -- 天级日期(去重键)
match_status VARCHAR(20) DEFAULT 'scheduled',
home_goals INT, away_goals INT,
home_ht_goals INT, away_ht_goals INT,
match_stage VARCHAR(100),
created_at TIMESTAMPTZ, updated_at TIMESTAMPTZ
);
-- 唯一约束: 同联赛同对阵同天只存一场(天级去重)
CREATE UNIQUE INDEX ix_matches_unique
ON matches(league_id, home_team_id, away_team_id, match_date_date);
-- 比赛统计(xG、射门、控球等)
CREATE TABLE match_stats (
match_id INT PRIMARY KEY REFERENCES matches(id) ON DELETE CASCADE,
home_xg FLOAT, away_xg FLOAT,
home_shots INT, away_shots INT,
home_shots_on_target INT, away_shots_on_target INT,
home_corners INT, away_corners INT,
home_possession FLOAT,
home_yellow_cards INT, away_yellow_cards INT,
home_red_cards INT, away_red_cards INT,
updated_at TIMESTAMPTZ
);
-- LLM 预测记录
CREATE TABLE predictions (
id SERIAL PRIMARY KEY,
match_id INT REFERENCES matches(id) ON DELETE CASCADE,
provider VARCHAR(30) NOT NULL, -- 'openai' / 'anthropic'
model VARCHAR(80) NOT NULL,
prompt_version VARCHAR(20) NOT NULL DEFAULT 'v1',
mode VARCHAR(20) NOT NULL DEFAULT 'single', -- 'single' / 'multi'
prompt_tokens INT, completion_tokens INT,
latency_ms INT,
pred_home_goals FLOAT, pred_away_goals FLOAT,
pred_1x2 VARCHAR(3), -- '1' / 'X' / '2'
confidence FLOAT,
reasoning TEXT,
raw_response JSONB, -- LLM 完整原始响应
agent_outputs JSONB, -- multi 模式: 5 份专家报告
created_at TIMESTAMPTZ,
actual_home_goals INT, actual_away_goals INT, -- 赛后回填
settled BOOLEAN DEFAULT FALSE
);
```
### 关键设计点
1. **`match_date_date`(天级日期)**: 用于天级去重。bzzoiro 返回的时间带时分秒,精确匹配不可靠,故拆出 `DATE` 列做唯一键。
2. **`ix_matches_unique`**: `(league_id, home_team_id, away_team_id, match_date_date)` 唯一,保证同一场比赛重复采集时 upsert 而非插入重复行。
3. **`predictions` 级联删除**: `ON DELETE CASCADE`,删比赛时自动清其预测。
4. **`mode` + `prompt_version`**: `single` 模式存 `v1`/`v2`,`multi` 模式存 `multi_v1`/`multi_v2`,eval summary 按这两列天然分组对比。
## 入库语义(幂等)
`ingest_bzzoiro` 的 upsert 逻辑:
- **不存在**: 插入新比赛 + 初始 stats
- **已存在**: 只补空字段
- 比分:只在原记录为 `None` 时覆盖
- 状态:只允许单向升级(`scheduled` → `finished`),防止完赛行被覆盖成赛程
- stats:只补空(`home_xg` 已有值时不覆盖)
`ingest_understat` 只回填 xG(也只补空),不创建比赛。
## 采集建议
```bash
# 1. 首次采集: 5 大联赛近 2 赛季赛果
curl -X POST /api/v1/ingest/bzzoiro \
-d '{"leagues":["E0","SP1","D1","I1","F1"],"date_from":"2024-08-01","date_to":"2026-09-08"}'
# 2. 增量采集(每日 cron): 只拉最近 7 天
curl -X POST /api/v1/ingest/bzzoiro \
-d '{"leagues":["E0"],"date_from":"2026-09-01","date_to":"2026-09-08"}'
# 3. xG 回填(可选,提升 xg agent 质量)
curl -X POST /api/v1/ingest/understat -d '{"league":"E0","season":2025}'
curl -X POST /api/v1/ingest/understat -d '{"league":"E0","season":2026}'
```
建议用外部 cron(如系统 crontab)定时触发,不引入 worker/redis。
+148
View File
@@ -0,0 +1,148 @@
# 06 · 部署
## 前置要求
- Docker & Docker Compose
- bzzoiro API Key(必填)
- LLM API Key(必填,OpenAI / Deepseek / 兼容接口)
## Docker Compose 部署(推荐)
```bash
# 1. 配置环境变量
cp .env.example .env
# 编辑 .env: 填 LLM_API_KEY / BZZOIRO_KEY
# 2. 启动(自动建表)
docker compose up -d --build
# 3. 验证
curl http://localhost:8000/health
```
`docker-compose.yml` 仅 2 个服务:
| 服务 | 端口 | 说明 |
|---|---|---|
| `postgres` | 5432 | PostgreSQL 16 |
| `api` | 8000 | FastAPI 应用 |
数据卷 `pgdata` 持久化数据库,重启不丢数据。
## 本地开发部署
```bash
# 1. 安装依赖
pip install -e ".[dev]"
# 2. 启动 PostgreSQL(单独)
docker run -d --name profeto-pg \
-e POSTGRES_USER=football -e POSTGRES_PASSWORD=football -e POSTGRES_DB=football \
-p 5432:5432 postgres:16-alpine
# 3. 配置 .env
cp .env.example .env
# 4. 建表
alembic upgrade head
# 5. 启动 API
uvicorn src.api.app:app --reload
# 6. 启动前端(另一个终端)
cd frontend && npm install && npm run dev
```
访问:
- API 文档: http://localhost:8000/docs
- 前端界面: http://localhost:5173
## 环境变量
| 变量 | 必需 | 默认值 | 说明 |
|---|---|---|---|
| `APP_ENV` | ❌ | `development` | `production` / `development` |
| `LOG_LEVEL` | ❌ | `INFO` | 日志级别 |
| `DATABASE_URL` | ✅ | — | PostgreSQL 连接 URL |
| `LLM_PROVIDER` | ❌ | `openai` | 提供商名(仅标记) |
| `LLM_API_KEY` | ✅ | — | API Key |
| `LLM_BASE_URL` | ❌ | `https://api.openai.com/v1` | 接口地址(Ollama/Deepseek 用) |
| `LLM_MODEL` | ❌ | `gpt-4o` | 默认模型 |
| `LLM_TIMEOUT` | ❌ | `60` | 单次调用超时(秒) |
| `LLM_SPECIALIST_MODEL` | ❌ | — | 专家模型(回落 `LLM_MODEL`) |
| `LLM_AGGREGATOR_MODEL` | ❌ | — | 终裁模型(回落 `LLM_MODEL`) |
| `BZZOIRO_KEY` | ✅ | — | bzzoiro 数据源 Key |
| `BZZOIRO_BASE` | ❌ | `https://sports.bzzoiro.com/api/v2` | bzzoiro 接口地址 |
| `API_FOOTBALL_KEY` | ❌ | — | 伤停数据源 Key |
| `CORS_ORIGINS` | ❌ | `http://localhost:5173,...` | 允许的跨域来源 |
## LLM 提供商配置示例
### OpenAI
```bash
LLM_API_KEY=sk-xxxx
LLM_BASE_URL=https://api.openai.com/v1
LLM_MODEL=gpt-4o
```
### Deepseek
```bash
LLM_API_KEY=sk-xxxx
LLM_BASE_URL=https://api.deepseek.com/v1
LLM_MODEL=deepseek-chat
```
### Ollama(本地)
```bash
LLM_API_KEY=ollama
LLM_BASE_URL=http://localhost:11434/v1
LLM_MODEL=llama3.1
```
### 分档配置(专家用便宜模型)
```bash
LLM_MODEL=gpt-4o
LLM_SPECIALIST_MODEL=gpt-4o-mini
LLM_AGGREGATOR_MODEL=gpt-4o
```
## 数据库迁移
Alembic 管理 schema 变更:
```bash
# 查看当前版本
alembic current
# 升级到最新
alembic upgrade head
# 回退一级
alembic downgrade -1
# 生成新迁移(改 models.py 后)
alembic revision --autogenerate -m "描述"
# 空迁移(手动写 SQL)
alembic revision -m "描述"
```
已有迁移:
- `0001_initial`: 初始 5 张表
- `0002_agent_outputs`: predictions 加 `mode` + `agent_outputs`
## 备份与恢复
```bash
# 备份
docker exec profeto-postgres pg_dump -U football football > backup.sql
# 恢复
cat backup.sql | docker exec -i profeto-postgres psql -U football football
```
## 监控
- `/health`: 存活检查
- 日志:容器 stdout(`docker compose logs -f api`)
- 评估汇总:`GET /api/v1/eval/summary`(准确率/RMSAE/校准度)
+209
View File
@@ -0,0 +1,209 @@
# 07 · 开发指南
## 本地开发环境搭建
```bash
# 1. 克隆并进入项目
cd Profeto
# 2. 安装依赖(含 dev)
pip install -e ".[dev]"
# 3. 启动 PostgreSQL
docker run -d --name profeto-pg \
-e POSTGRES_USER=football -e POSTGRES_PASSWORD=football -e POSTGRES_DB=football \
-p 5432:5432 postgres:16-alpine
# 4. 配置环境变量
cp .env.example .env
# 编辑 .env 填 LLM_API_KEY / BZZOIRO_KEY
# 5. 建表
alembic upgrade head
# 6. 启动 API(热重载)
uvicorn src.api.app:app --reload
# 7. 启动前端(另一个终端)
cd frontend && npm install && npm run dev
```
## 项目结构
```
Profeto/
├── src/ # 后端源码
│ ├── api/
│ │ ├── routes/ # FastAPI 路由
│ │ │ ├── leagues.py # 联赛/比赛查询
│ │ │ ├── predict.py # 预测入口
│ │ │ ├── ingest.py # 数据采集
│ │ │ └── eval.py # 评估回填
│ │ ├── schemas.py # Pydantic 模型
│ │ └── app.py # FastAPI 工厂
│ ├── db/
│ │ ├── base.py # SQLAlchemy async engine + session
│ │ └── models.py # 5 张表 ORM
│ ├── data/
│ │ ├── bzzoiro.py # bzzoiro 采集 + 入库
│ │ ├── understat.py # understat xG 回填
│ │ ├── injuries.py # 伤停采集
│ │ ├── normalize.py # 数据清洗契约
│ │ ├── team_names.py # 队名归一化映射
│ │ └── config.py # 联赛映射常量
│ ├── llm/
│ │ ├── provider.py # LLM 提供商抽象(OpenAI-compatible)
│ │ ├── context_builder.py # 数据切片 + 拼接
│ │ ├── predict.py # 预测入口(单/多模式分派)
│ │ ├── eval.py # 评估统计
│ │ ├── agents/
│ │ │ ├── base.py # AgentSpec + run_agent
│ │ │ └── orchestrator.py # 多 agent 编排
│ │ └── prompts/
│ │ ├── match_prediction_v1.md # 单 agent prompt
│ │ └── agents/ # 多 agent prompt
│ │ ├── form_v1.md
│ │ ├── stats_v1.md
│ │ ├── home_away_v1.md
│ │ ├── injuries_v1.md
│ │ ├── h2h_v1.md
│ │ └── aggregator_v1.md
│ └── core/
│ └── config.py # pydantic-settings 配置
├── frontend/ # React 单页前端
├── alembic/ # 数据库迁移
│ └── versions/
│ ├── 0001_initial.py
│ └── 0002_agent_outputs.py
├── tests/ # 测试
│ ├── test_core.py # 核心逻辑测试
│ └── test_agents.py # 多 agent 测试
├── docs/ # 本文档
├── pyproject.toml # 依赖 + 构建配置
├── alembic.ini # Alembic 配置
├── Dockerfile # 生产镜像
├── docker-compose.yml # 本地/生产编排
└── .env.example # 环境变量模板
```
## 测试
```bash
# 跑全部测试
pytest
# 详细输出
pytest -v
# 只跑 agent 测试
pytest tests/test_agents.py -v
# 覆盖率
pytest --cov=src --cov-report=term-missing
```
当前测试覆盖:
- `test_core.py`(13 项):数据清洗、队名归一、赛季标签、LLM 解析
- `test_agents.py`(20 项):no_data 门控、fail-open、prompt 加载、报告解析、终裁渲染
### 写新测试的模式
mock LLM 提供商(避免真实调用):
```python
class MockProvider:
model = "test-model"
async def chat(self, system, user, **kw):
from src.llm.provider import LLMResponse
return LLMResponse(
content="{}",
parsed={"data_sufficiency": "high", "analysis": "ok",
"home_edge": 0.5, "confidence": 0.8,
"key_evidence": ["证据"]},
prompt_tokens=10, completion_tokens=5, latency_ms=100,
)
```
## 常见开发任务
### 1. 修改 Agent Prompt
直接编辑 `src/llm/prompts/agents/{name}_v1.md`,无需重启(有 `lru_cache`,改完清缓存或重启)。
迭代版本:
```bash
cp src/llm/prompts/agents/h2h_v1.md src/llm/prompts/agents/h2h_v2.md
# 编辑 h2h_v2.md
# 调用时传 prompt_version: "v2"
```
### 2. 新增 Agent
见 [04-agents.md § 如何新增一个专家 Agent](04-agents.md)。
### 3. 新增数据源
1.`src/data/` 写采集模块(参考 `understat.py`)
2.`normalize.py` 加清洗函数
3.`context_builder.py` 加切片函数
4.`api/routes/ingest.py` 加端点
5.`api/schemas.py` 加请求/响应模型
### 4. 新增联赛
编辑 `src/data/config.py`:
```python
BZZOIRO_LEAGUE_IDS["新代码"] = league_id
LEAGUE_NAMES["新代码"] = "联赛名"
LEAGUE_COUNTRIES["新代码"] = "国家"
```
### 5. 数据库 Schema 变更
```bash
# 1. 改 src/db/models.py
# 2. 生成迁移
alembic revision --autogenerate -m "描述"
# 3. 检查生成的迁移文件(自动推断不完美)
# 4. 应用
alembic upgrade head
```
## 前端开发
```bash
cd frontend
npm install
npm run dev # 开发(热重载,代理 /api → localhost:8000)
npm run build # 生产构建 → dist/
npm run preview # 预览生产构建
```
技术栈:React + TypeScript + Tailwind CSS + Vite。
主要页面:`src/pages/Matches.tsx`(比赛列表 + 预测 + agent 报告展示)。
## 编码约定
- **异步优先**:所有 IO 用 `async/await`,SQLAlchemy 用 async session
- **session 管理**:
- 路由读操作:依赖注入 `get_db_read`(不自动 commit)
- 路由写操作:依赖注入 `get_db`(自动 commit)
- 内部/ingest:直接用 `AsyncSessionLocal()` 自己管事务
- **错误处理**:领域层抛 `ValueError`/`RuntimeError`,路由层转 HTTP 状态码
- **日志**:用 `logging.getLogger(__name__)`,关键路径打 info/debug
- **类型提示**:全量标注,`Mapped[T]` + `mapped_column`
## 提交前检查清单
```bash
# 1. 测试全绿
pytest
# 2. 语法检查
python -m py_compile src/**/*.py
# 3. 确认 .env 不提交(已在 .gitignore)
# 4. 文档同步(改功能时更新 docs/)
```
+46
View File
@@ -0,0 +1,46 @@
"""检查 injuries 数据源 api-football 的响应结构。"""
API_FOOTBALL_INJURY_RESPONSE_EXAMPLE = """
{
"response": [
{
"player": {
"id": 12345,
"name": "Bukayo Saka",
"photo": "https://...",
"type": "Missing Fixture"
},
"team": {"id": 42, "name": "Arsenal"},
"fixture": {"id": 100000, "date": "2026-09-15T19:00:00+00:00"},
"league": {"id": 39, "name": "Premier League"},
"reason": "Hamstring Injury",
"type": "Missing Fixture"
}
]
}
"""
"""
injuries 表设计:
CREATE TABLE injuries (
id SERIAL PRIMARY KEY,
player_id INT, -- api-football 球员 ID
player_name VARCHAR(120) NOT NULL, -- 球员名
team_id INT REFERENCES teams(id), -- 关联球队(按名归一后匹配)
fixture_id INT, -- api-football 比赛 ID(无法直接关联 matches.id)
league_id INT,
injury_type VARCHAR(50), -- 'Missing Fixture' / 'Suspended'
reason VARCHAR(200), -- 伤停原因(如 'Hamstring Injury')
injury_date DATE, -- 伤停日期
return_date DATE, -- 预计回归日期(如有)
retrieved_at TIMESTAMPTZ DEFAULT now(),
UNIQUE(player_id, fixture_id, injury_type) -- 幂等
);
-- 查询某队某场比赛的伤停:
SELECT player_name, injury_type, reason
FROM injuries
WHERE team_id = :team_id
AND injury_date <= :match_date
AND (return_date IS NULL OR return_date >= :match_date);
"""
+26
View File
@@ -0,0 +1,26 @@
# Profeto 文档索引
| 文档 | 内容 |
|---|---|
| [01-架构总览](01-architecture.md) | 系统架构、数据流、技术选型、与旧项目对比 |
| [02-快速开始](02-quickstart.md) | 安装、启动、首次跑通全流程 |
| [03-API 参考](03-api.md) | 全部 12 个端点、请求/响应示例、curl 全流程 |
| [04-多 Agent 预测](04-agents.md) | 5 专家 + 终裁架构、执行语义、输出契约、prompt 版本化、如何新增 agent |
| [05-数据层与数据库](05-data.md) | 三数据源、清洗契约、5 张表 schema、入库语义、采集建议 |
| [06-部署](06-deployment.md) | Docker Compose、本地部署、环境变量、LLM 提供商配置、迁移、备份 |
| [07-开发指南](07-development.md) | 项目结构、测试、常见开发任务(prompt/agent/数据源/联赛)、前端开发 |
## 项目简介
Profeto 是一个**足球 LLM 预测服务**:FastAPI 提供干净的数据层(赛果/xG/积分榜),
5 个领域专家 agent(近期状态/攻防数据/主客因素/阵容完整性/历史交锋)并行分析,
终裁 agent 汇总输出结构化预测,赛后回填实际结果持续评估准确率。
与旧项目 MatchPro(自研 7 套统计模型 + 6 容器)相比:~45 个文件、2 容器、预测完全交给 LLM。
## 推荐阅读顺序
1. **新手**:01 → 02 → 03(跑通第一个预测)
2. **调优 prompt**:04(理解 agent 契约)→ 03(eval summary 看效果)
3. **加数据源/联赛**:05 → 07
4. **上线**:06