全量修复:预测系统正确性、安全性与部署问题
P0 严重问题修复: - 修复 form_slice/stats_slice 主客身份反转(历史比赛视角错误) - 修复 understat.py httpx 未导入导致的 NameError - 修复 LLM 解析失败时静默产生假成功预测(0-0 平局+置信度0.5) 预测路径修复: - multi-agent 路径增加 backtest cutoff 透传,回测防泄漏生效 - H2H 切片汇总统计改为从当前主队视角计数 - 预测唯一约束增加 mode+run_type 维度,防止回测覆盖实盘预测 伤停管线修复: - IntegrityError 后不再整批回滚丢数据(改用逐条 flush) - return_date 正确解析并写入 - retrieved_at 比较统一用 date() 避免当天数据不可见 - 唯一索引改为 partial unique index(排除 NULL 重复) - HTTP 缓存 TTL 从 7 天改为 6 小时 安全与连接管理: - /api/v1/predict 增加内存滑动窗口限流(10次/分钟/IP) - 预测路由改用短 session 模式,LLM 调用期间不持有 DB 连接 Docker 部署修复: - 修复 .dockerignore 排除 *.md 导致 COPY README.md 失败 - 容器内 DATABASE_URL 使用 postgres 服务名(非 localhost) - 启动时自动执行 alembic upgrade head - 前端改用多阶段构建(Dockerfile.frontend) 新增测试(5个文件,24+用例): - test_p0_home_away.py: 主客身份反转回归测试 - test_p0_parse_failure.py: LLM 解析失败回归测试 - test_multi_agent_cutoff.py: multi-agent cutoff 透传测试 - test_h2h_perspective.py: H2H 视角测试 - test_injuries_pipeline.py: 伤停管线 5 项修复测试 - test_predict_protection.py: 限流+短 session 测试 - test_prediction_unique_constraint.py: 唯一约束测试 迁移: - 0012_injuries_partial_unique_and_return_date.py - 0013_predictions_unique_constraint_mode_run_type.py
This commit is contained in:
+4
-1
@@ -1,12 +1,15 @@
|
|||||||
# ---- 应用 ----
|
# ---- 应用 ----
|
||||||
APP_ENV=development
|
APP_ENV=development
|
||||||
LOG_LEVEL=INFO
|
LOG_LEVEL=INFO
|
||||||
|
API_PORT=8000
|
||||||
|
FRONTEND_PORT=3000
|
||||||
|
|
||||||
# ---- 数据库 ----
|
# ---- 数据库 ----
|
||||||
POSTGRES_USER=football
|
POSTGRES_USER=football
|
||||||
POSTGRES_PASSWORD=football
|
POSTGRES_PASSWORD=football
|
||||||
POSTGRES_DB=football
|
POSTGRES_DB=football
|
||||||
POSTGRES_PORT=5432
|
POSTGRES_PORT=5433
|
||||||
|
# 本地开发用 localhost;Docker Compose 内会被 environment 覆盖为 postgres 服务名
|
||||||
DATABASE_URL=postgresql+asyncpg://football:football@localhost:5432/football
|
DATABASE_URL=postgresql+asyncpg://football:football@localhost:5432/football
|
||||||
|
|
||||||
# ---- LLM (OpenAI-compatible,必填一个) ----
|
# ---- LLM (OpenAI-compatible,必填一个) ----
|
||||||
|
|||||||
+4
-2
@@ -9,7 +9,8 @@ ENV PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple
|
|||||||
RUN groupadd --system profeto && useradd --system --gid profeto profeto
|
RUN groupadd --system profeto && useradd --system --gid profeto profeto
|
||||||
|
|
||||||
RUN pip install --no-cache-dir hatchling
|
RUN pip install --no-cache-dir hatchling
|
||||||
COPY pyproject.toml README.md ./
|
# Fix 1: 不再复制 README.md(.dockerignore 排除了 *.md)
|
||||||
|
COPY pyproject.toml ./
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
|
|
||||||
RUN pip install --no-cache-dir .
|
RUN pip install --no-cache-dir .
|
||||||
@@ -22,4 +23,5 @@ EXPOSE 8000
|
|||||||
# 以非 root 用户运行
|
# 以非 root 用户运行
|
||||||
USER profeto
|
USER profeto
|
||||||
|
|
||||||
CMD ["uvicorn", "src.api.app:app", "--host", "0.0.0.0", "--port", "8000"]
|
# Fix 3: 启动时先跑迁移,再启 uvicorn
|
||||||
|
CMD ["sh", "-c", "alembic upgrade head && uvicorn src.api.app:app --host 0.0.0.0 --port 8000"]
|
||||||
|
|||||||
@@ -79,34 +79,52 @@ API Route → Application Service → Repository → UnitOfWork → DB
|
|||||||
- Docker (运行 PostgreSQL)
|
- Docker (运行 PostgreSQL)
|
||||||
- LLM API Key (OpenAI / Deepseek / Ollama 等)
|
- LLM API Key (OpenAI / Deepseek / Ollama 等)
|
||||||
|
|
||||||
### 1. 安装
|
### 方式一:Docker Compose 部署(推荐)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# 1. 克隆仓库
|
||||||
git clone https://git.bilidili.cn/shangfangjian/Profeto.git
|
git clone https://git.bilidili.cn/shangfangjian/Profeto.git
|
||||||
cd Profeto
|
cd Profeto
|
||||||
|
|
||||||
# 后端依赖
|
# 2. 配置环境变量
|
||||||
pip install -e ".[dev]"
|
|
||||||
|
|
||||||
# 配置环境变量
|
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
# 编辑 .env,填入 LLM_API_KEY 和 BZZOIRO_KEY
|
# 编辑 .env,填入 LLM_API_KEY 和 BZZOIRO_KEY
|
||||||
|
|
||||||
|
# 3. 启动全部服务(自动构建 + 执行迁移)
|
||||||
|
docker compose up -d --build
|
||||||
|
|
||||||
|
# 4. 验证
|
||||||
|
curl http://localhost:8000/health
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. 启动数据库
|
启动后访问:
|
||||||
|
- API 文档: http://localhost:8000/docs
|
||||||
|
- 前端界面: http://localhost:3000
|
||||||
|
|
||||||
|
> **说明**: `api` 容器启动时自动执行 `alembic upgrade head`,无需手动运行迁移。
|
||||||
|
|
||||||
|
### 方式二:本地开发部署
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# 1. 克隆 + 安装
|
||||||
|
git clone https://git.bilidili.cn/shangfangjian/Profeto.git
|
||||||
|
cd Profeto
|
||||||
|
pip install -e ".[dev]"
|
||||||
|
|
||||||
|
# 2. 配置环境变量
|
||||||
|
cp .env.example .env
|
||||||
|
# 编辑 .env,填入 LLM_API_KEY 和 BZZOIRO_KEY
|
||||||
|
|
||||||
|
# 3. 启动 PostgreSQL
|
||||||
docker compose up -d postgres
|
docker compose up -d postgres
|
||||||
alembic upgrade head # 首次运行需要执行迁移
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. 启动服务
|
# 4. 执行迁移
|
||||||
|
alembic upgrade head
|
||||||
|
|
||||||
```bash
|
# 5. 启动后端 (终端 1)
|
||||||
# 后端 (终端 1)
|
|
||||||
uvicorn src.api.app:app --reload
|
uvicorn src.api.app:app --reload
|
||||||
|
|
||||||
# 前端 (终端 2)
|
# 6. 启动前端 (终端 2)
|
||||||
cd frontend && npm install && npm run dev
|
cd frontend && npm install && npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""修复 injuries 唯一索引允许 NULL 重复 + 添加 return_date 字段
|
||||||
|
|
||||||
|
Revision ID: 0012_injuries_partial_unique_and_return_date
|
||||||
|
Revises: 0011_prediction_alt_scores
|
||||||
|
Create Date: 2026-09-20
|
||||||
|
|
||||||
|
Fix 4: 唯一索引 (player_id, fixture_id, injury_type) 三列均可 NULL,
|
||||||
|
PostgreSQL 允许多条 NULL 重复。改为 partial unique index:
|
||||||
|
WHERE player_id IS NOT NULL AND fixture_id IS NOT NULL
|
||||||
|
|
||||||
|
Fix 2: return_date 字段已在 ORM 声明,确保数据库列存在。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used Alembic.
|
||||||
|
revision: str = '0012_injuries_partial_unique_and_return_date'
|
||||||
|
down_revision: Union[str, None] = '0011_prediction_alt_scores'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = sa.inspect(bind)
|
||||||
|
|
||||||
|
# 检查现有索引
|
||||||
|
indexes = {i["name"]: i for i in inspector.get_indexes("injuries")}
|
||||||
|
|
||||||
|
# Fix 4: 删除旧的全局唯一索引(允许 NULL 重复)
|
||||||
|
if "ix_injuries_player_fixture" in indexes:
|
||||||
|
op.drop_index("ix_injuries_player_fixture", table_name="injuries")
|
||||||
|
|
||||||
|
# 创建 partial unique index: 只在 player_id 和 fixture_id 都非空时强制唯一
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
CREATE UNIQUE INDEX ix_injuries_player_fixture
|
||||||
|
ON injuries (player_id, fixture_id, injury_type)
|
||||||
|
WHERE player_id IS NOT NULL AND fixture_id IS NOT NULL
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fix 2: 确保 return_date 列存在(ORM 已声明,但早期迁移可能缺失)
|
||||||
|
columns = [c["name"] for c in inspector.get_columns("injuries")]
|
||||||
|
if "return_date" not in columns:
|
||||||
|
op.add_column(
|
||||||
|
"injuries",
|
||||||
|
sa.Column("return_date", sa.Date, nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# 删除 partial unique index
|
||||||
|
op.drop_index("ix_injuries_player_fixture", table_name="injuries")
|
||||||
|
|
||||||
|
# 恢复旧的全局唯一索引
|
||||||
|
op.create_index(
|
||||||
|
"ix_injuries_player_fixture",
|
||||||
|
"injuries",
|
||||||
|
["player_id", "fixture_id", "injury_type"],
|
||||||
|
unique=True,
|
||||||
|
)
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""修复预测唯一约束过粗,增加 mode + run_type 维度
|
||||||
|
|
||||||
|
Revision ID: 0013_predictions_unique_constraint_mode_run_type
|
||||||
|
Revises: 0012_injuries_partial_unique_and_return_date
|
||||||
|
Create Date: 2026-09-20
|
||||||
|
|
||||||
|
背景:
|
||||||
|
原唯一约束 (match_id, provider, model) 过粗,回测写入会覆盖未结算的实盘预测,
|
||||||
|
后续 settle 会污染评估数据。
|
||||||
|
|
||||||
|
修复:
|
||||||
|
1. 新增 run_type 列(默认 'live'),区分实盘与回测
|
||||||
|
2. 唯一约束改为 (match_id, provider, model, mode, run_type)
|
||||||
|
3. 已有数据 run_type 回填为 'live'
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '0013_predictions_unique_constraint_mode_run_type'
|
||||||
|
down_revision: Union[str, None] = '0012_injuries_partial_unique_and_return_date'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# 1. 新增 run_type 列(先 nullable,回填后再改 NOT NULL)
|
||||||
|
op.add_column(
|
||||||
|
"predictions",
|
||||||
|
sa.Column("run_type", sa.String(10), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. 回填已有数据:全部标记为 'live'
|
||||||
|
op.execute("UPDATE predictions SET run_type = 'live' WHERE run_type IS NULL")
|
||||||
|
|
||||||
|
# 3. 改为 NOT NULL
|
||||||
|
op.alter_column("predictions", "run_type", nullable=False)
|
||||||
|
|
||||||
|
# 4. 删除旧唯一约束
|
||||||
|
op.drop_constraint("uq_predictions_match_provider_model", "predictions", type_="unique")
|
||||||
|
|
||||||
|
# 5. 创建新唯一约束(包含 mode + run_type)
|
||||||
|
op.create_unique_constraint(
|
||||||
|
"uq_predictions_match_provider_model_mode_run_type",
|
||||||
|
"predictions",
|
||||||
|
["match_id", "provider", "model", "mode", "run_type"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# 6. 添加 check constraint
|
||||||
|
op.create_check_constraint(
|
||||||
|
"ck_run_type_enum",
|
||||||
|
"predictions",
|
||||||
|
"run_type IN ('live', 'backtest')",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# 1. 删除 check constraint
|
||||||
|
op.drop_constraint("ck_run_type_enum", "predictions", type_="check")
|
||||||
|
|
||||||
|
# 2. 删除新唯一约束
|
||||||
|
op.drop_constraint("uq_predictions_match_provider_model_mode_run_type", "predictions", type_="unique")
|
||||||
|
|
||||||
|
# 3. 恢复旧唯一约束
|
||||||
|
op.create_unique_constraint(
|
||||||
|
"uq_predictions_match_provider_model",
|
||||||
|
"predictions",
|
||||||
|
["match_id", "provider", "model"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4. 删除 run_type 列
|
||||||
|
op.drop_column("predictions", "run_type")
|
||||||
+13
-3
@@ -17,9 +17,17 @@ services:
|
|||||||
|
|
||||||
api:
|
api:
|
||||||
build: .
|
build: .
|
||||||
command: uvicorn src.api.app:app --host 0.0.0.0 --port 8000 --reload
|
# Fix 2: 容器内 DATABASE_URL 使用 postgres 服务名(非 localhost)
|
||||||
|
# Fix 3: 启动时先跑 alembic upgrade,再启 uvicorn
|
||||||
|
command: >
|
||||||
|
sh -c "alembic upgrade head &&
|
||||||
|
uvicorn src.api.app:app --host 0.0.0.0 --port 8000"
|
||||||
ports:
|
ports:
|
||||||
- "${API_PORT:-8000}:8000"
|
- "${API_PORT:-8000}:8000"
|
||||||
|
environment:
|
||||||
|
# Fix 2: 容器内 DATABASE_URL 使用 postgres 服务名(非 localhost)
|
||||||
|
# 必须覆盖 .env 中的 DATABASE_URL,因为 Settings 不读 DB_HOST/DB_PORT
|
||||||
|
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:?POSTGRES_USER 未设置}:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD 未设置}@postgres:5432/${POSTGRES_DB:-football}
|
||||||
env_file: .env
|
env_file: .env
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
@@ -30,11 +38,13 @@ services:
|
|||||||
- ./alembic.ini:/app/alembic.ini
|
- ./alembic.ini:/app/alembic.ini
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
image: nginx:alpine
|
# Fix 4: 多阶段构建 —— 先 build 静态文件,再复制到 nginx
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
dockerfile: Dockerfile.frontend
|
||||||
ports:
|
ports:
|
||||||
- "${FRONTEND_PORT:-3000}:80"
|
- "${FRONTEND_PORT:-3000}:80"
|
||||||
volumes:
|
volumes:
|
||||||
- ./frontend/dist:/usr/share/nginx/html
|
|
||||||
- ./frontend/nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
- ./frontend/nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||||
depends_on:
|
depends_on:
|
||||||
- api
|
- api
|
||||||
|
|||||||
+26
-7
@@ -13,22 +13,26 @@
|
|||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
# 编辑 .env: 填 LLM_API_KEY / BZZOIRO_KEY
|
# 编辑 .env: 填 LLM_API_KEY / BZZOIRO_KEY
|
||||||
|
|
||||||
# 2. 启动(自动建表)
|
# 2. 启动(自动执行数据库迁移)
|
||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
|
|
||||||
# 3. 验证
|
# 3. 验证
|
||||||
curl http://localhost:8000/health
|
curl http://localhost:8000/health
|
||||||
```
|
```
|
||||||
|
|
||||||
`docker-compose.yml` 仅 2 个服务:
|
`docker-compose.yml` 包含 3 个服务:
|
||||||
|
|
||||||
| 服务 | 端口 | 说明 |
|
| 服务 | 端口 | 说明 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `postgres` | 5432 | PostgreSQL 16 |
|
| `postgres` | 5433 | PostgreSQL 16 |
|
||||||
| `api` | 8000 | FastAPI 应用 |
|
| `api` | 8000 | FastAPI 应用(启动时自动执行 `alembic upgrade head`) |
|
||||||
|
| `frontend` | 3000 | React 前端(多阶段构建,nginx 服务静态文件) |
|
||||||
|
|
||||||
数据卷 `pgdata` 持久化数据库,重启不丢数据。
|
数据卷 `pgdata` 持久化数据库,重启不丢数据。
|
||||||
|
|
||||||
|
> **注意**: `api` 服务启动时会先执行 `alembic upgrade head` 迁移数据库,再启动 uvicorn。
|
||||||
|
> 容器内数据库连接自动使用 `postgres` 服务名(通过 compose `environment` 覆盖 `.env` 中的 `DB_HOST`)。
|
||||||
|
|
||||||
## 本地开发部署
|
## 本地开发部署
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -63,7 +67,13 @@ cd frontend && npm install && npm run dev
|
|||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `APP_ENV` | ❌ | `development` | `production` / `development` |
|
| `APP_ENV` | ❌ | `development` | `production` / `development` |
|
||||||
| `LOG_LEVEL` | ❌ | `INFO` | 日志级别 |
|
| `LOG_LEVEL` | ❌ | `INFO` | 日志级别 |
|
||||||
| `DATABASE_URL` | ✅ | — | PostgreSQL 连接 URL |
|
| `API_PORT` | ❌ | `8000` | API 服务端口映射 |
|
||||||
|
| `FRONTEND_PORT` | ❌ | `3000` | 前端服务端口映射 |
|
||||||
|
| `POSTGRES_USER` | ✅ | — | PostgreSQL 用户名 |
|
||||||
|
| `POSTGRES_PASSWORD` | ✅ | — | PostgreSQL 密码 |
|
||||||
|
| `POSTGRES_DB` | ❌ | `football` | PostgreSQL 数据库名 |
|
||||||
|
| `POSTGRES_PORT` | ❌ | `5433` | PostgreSQL 端口映射 |
|
||||||
|
| `DATABASE_URL` | ✅ | — | PostgreSQL 连接 URL(Docker 内会被覆盖) |
|
||||||
| `LLM_PROVIDER` | ❌ | `openai` | 提供商名(仅标记) |
|
| `LLM_PROVIDER` | ❌ | `openai` | 提供商名(仅标记) |
|
||||||
| `LLM_API_KEY` | ✅ | — | API Key |
|
| `LLM_API_KEY` | ✅ | — | API Key |
|
||||||
| `LLM_BASE_URL` | ❌ | `https://api.openai.com/v1` | 接口地址(Ollama/Deepseek 用) |
|
| `LLM_BASE_URL` | ❌ | `https://api.openai.com/v1` | 接口地址(Ollama/Deepseek 用) |
|
||||||
@@ -72,9 +82,11 @@ cd frontend && npm install && npm run dev
|
|||||||
| `LLM_SPECIALIST_MODEL` | ❌ | — | 专家模型(回落 `LLM_MODEL`) |
|
| `LLM_SPECIALIST_MODEL` | ❌ | — | 专家模型(回落 `LLM_MODEL`) |
|
||||||
| `LLM_AGGREGATOR_MODEL` | ❌ | — | 终裁模型(回落 `LLM_MODEL`) |
|
| `LLM_AGGREGATOR_MODEL` | ❌ | — | 终裁模型(回落 `LLM_MODEL`) |
|
||||||
| `BZZOIRO_KEY` | ✅ | — | bzzoiro 数据源 Key |
|
| `BZZOIRO_KEY` | ✅ | — | bzzoiro 数据源 Key |
|
||||||
| `BZZOIRO_BASE` | ❌ | `https://sports.bzzoiro.com/api/v2` | bzzoiro 接口地址 |
|
|
||||||
| `API_FOOTBALL_KEY` | ❌ | — | 伤停数据源 Key |
|
| `API_FOOTBALL_KEY` | ❌ | — | 伤停数据源 Key |
|
||||||
| `CORS_ORIGINS` | ❌ | `http://localhost:5173,...` | 允许的跨域来源 |
|
| `CORS_ORIGINS` | ❌ | `http://localhost:5173,...` | 允许的跨域来源 |
|
||||||
|
| `SECRET_KEY` | ❌ | — | 加密主密钥(生产环境必填) |
|
||||||
|
| `ADMIN_PASSWORD` | ❌ | — | 管理后台密码(留空=不启用) |
|
||||||
|
| `ADMIN_API_KEY` | ❌ | — | 机器/脚本调用的 API Key |
|
||||||
|
|
||||||
## LLM 提供商配置示例
|
## LLM 提供商配置示例
|
||||||
|
|
||||||
@@ -127,9 +139,16 @@ alembic revision --autogenerate -m "描述"
|
|||||||
alembic revision -m "描述"
|
alembic revision -m "描述"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Docker Compose 自动迁移**: `api` 容器启动时会自动执行 `alembic upgrade head`,
|
||||||
|
无需手动运行。本地开发时需手动执行迁移。
|
||||||
|
|
||||||
已有迁移:
|
已有迁移:
|
||||||
- `0001_initial`: 初始 5 张表,0003 增加 injuries,0004 增加约束,0005 增加时间语义
|
- `0001_initial`: 初始 5 张表
|
||||||
- `0002_agent_outputs`: predictions 加 `mode` + `agent_outputs`
|
- `0002_agent_outputs`: predictions 加 `mode` + `agent_outputs`
|
||||||
|
- `0003_injuries`: 增加 injuries 表
|
||||||
|
- `0004_snapshot_and_constraints`: 增加约束
|
||||||
|
- `0005_prediction_status_and_stats_provenance`: 增加时间语义
|
||||||
|
- `0006-0012`: 后续 schema 调整、约束命名对齐、partial unique index 等
|
||||||
|
|
||||||
## 备份与恢复
|
## 备份与恢复
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# 前端构建时不需要的文件
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
*.log
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Fix 4: 前端多阶段构建 —— 构建静态文件 + nginx 服务
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 安装依赖
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# 构建
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# 生产阶段: nginx 服务静态文件
|
||||||
|
FROM nginx:alpine
|
||||||
|
|
||||||
|
# 从 builder 阶段复制构建产物
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
# nginx 配置(在 compose 中通过 volume 挂载,此处仅作备用)
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
@@ -96,3 +96,60 @@ async def require_admin(
|
|||||||
return
|
return
|
||||||
|
|
||||||
raise HTTPException(status_code=401, detail="未登录或凭证无效")
|
raise HTTPException(status_code=401, detail="未登录或凭证无效")
|
||||||
|
|
||||||
|
|
||||||
|
# ── 简易内存限流(按 IP,无外部依赖) ──
|
||||||
|
|
||||||
|
class _RateLimiter:
|
||||||
|
"""内存式滑动窗口限流。
|
||||||
|
|
||||||
|
设计取舍:
|
||||||
|
- 单进程内有效,多 worker 各自计数(生产前置于 Nginx 做全局限流更精确)
|
||||||
|
- 滑动窗口:记录每次请求时间戳,清理过期条目
|
||||||
|
- O(n) 清理,n = 时间窗口内请求数(通常 < 100)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, max_requests: int = 10, window_seconds: int = 60):
|
||||||
|
self.max_requests = max_requests
|
||||||
|
self.window_seconds = window_seconds
|
||||||
|
self._hits: dict[str, list[float]] = {}
|
||||||
|
|
||||||
|
def is_allowed(self, key: str) -> bool:
|
||||||
|
"""检查 key 是否允许通过。True=允许,False=拒绝。"""
|
||||||
|
now = time.time()
|
||||||
|
window_start = now - self.window_seconds
|
||||||
|
|
||||||
|
# 获取并清理该 key 的过期记录
|
||||||
|
timestamps = self._hits.get(key, [])
|
||||||
|
timestamps = [t for t in timestamps if t > window_start]
|
||||||
|
|
||||||
|
if len(timestamps) >= self.max_requests:
|
||||||
|
self._hits[key] = timestamps # 更新清理后的列表
|
||||||
|
return False
|
||||||
|
|
||||||
|
timestamps.append(now)
|
||||||
|
self._hits[key] = timestamps
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# 全局限流实例: /api/v1/predict 每分钟 10 次
|
||||||
|
_predict_limiter = _RateLimiter(max_requests=10, window_seconds=60)
|
||||||
|
|
||||||
|
|
||||||
|
async def rate_limit_predict(request: Request) -> None:
|
||||||
|
"""POST /api/v1/predict 限流依赖。
|
||||||
|
|
||||||
|
基于客户端 IP(考虑 X-Forwarded-For),超过 10 次/分钟返回 429。
|
||||||
|
"""
|
||||||
|
# 获取客户端 IP(支持反向代理)
|
||||||
|
client_ip = request.headers.get("X-Forwarded-For", request.client.host if request.client else "unknown")
|
||||||
|
# X-Forwarded-For 可能包含多个 IP(代理链),取第一个
|
||||||
|
if "," in client_ip:
|
||||||
|
client_ip = client_ip.split(",")[0].strip()
|
||||||
|
|
||||||
|
if not _predict_limiter.is_allowed(client_ip):
|
||||||
|
logger.warning("rate limit exceeded for %s", client_ip)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=429,
|
||||||
|
detail="请求过于频繁,请稍后再试(每分钟最多 10 次)",
|
||||||
|
)
|
||||||
|
|||||||
+32
-15
@@ -1,4 +1,9 @@
|
|||||||
"""预测路由。"""
|
"""预测路由。
|
||||||
|
|
||||||
|
安全改进:
|
||||||
|
- 限流: 每分钟 10 次 / IP(内存实现)
|
||||||
|
- DB 连接: 短 session 模式,LLM 调用期间不持有连接
|
||||||
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@@ -7,9 +12,9 @@ from fastapi import APIRouter, Depends, HTTPException, Query
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from src.api.deps import require_admin
|
from src.api.deps import rate_limit_predict, require_admin
|
||||||
from src.api.schemas import PredictOut, PredictRequest, PredictionOut
|
from src.api.schemas import PredictOut, PredictRequest, PredictionOut
|
||||||
from src.db.base import AsyncSession, get_db, get_db_read
|
from src.db.base import AsyncSession, get_db_read, short_read
|
||||||
from src.db.models import Match, Prediction
|
from src.db.models import Match, Prediction
|
||||||
from src.llm.predict import predict_match, PredictResult
|
from src.llm.predict import predict_match, PredictResult
|
||||||
|
|
||||||
@@ -18,15 +23,26 @@ logger = logging.getLogger(__name__)
|
|||||||
router = APIRouter(prefix="/api/v1", tags=["predict"])
|
router = APIRouter(prefix="/api/v1", tags=["predict"])
|
||||||
|
|
||||||
|
|
||||||
@router.post("/predict", response_model=PredictOut)
|
@router.post("/predict", response_model=PredictOut, dependencies=[Depends(rate_limit_predict)])
|
||||||
async def predict(req: PredictRequest, db: AsyncSession = Depends(get_db)):
|
async def predict(req: PredictRequest):
|
||||||
"""对一场比赛调 LLM 预测。mode=multi(默认,5专家+终裁)或 single。"""
|
"""对一场比赛调 LLM 预测。mode=multi(默认,5专家+终裁)或 single。
|
||||||
# 已完赛比赛不再支持预测(回测走服务层直调,不受此限)
|
|
||||||
match = await db.get(Match, req.match_id)
|
公开接口,仅做限流保护(不要求登录)。
|
||||||
if match is None:
|
|
||||||
|
DB 连接优化:
|
||||||
|
1. 短 read session 检查比赛存在性/状态
|
||||||
|
2. 释放连接后调用 LLM(可能几十秒)
|
||||||
|
3. 短 write session 保存 Prediction
|
||||||
|
"""
|
||||||
|
# 1. 短 read session: 检查比赛(连接立即释放)
|
||||||
|
async with short_read() as session:
|
||||||
|
m = await session.get(Match, req.match_id)
|
||||||
|
if m is None:
|
||||||
raise HTTPException(404, "match not found")
|
raise HTTPException(404, "match not found")
|
||||||
if match.match_status == "finished":
|
if m.match_status == "finished":
|
||||||
raise HTTPException(400, "该比赛已完赛,不再支持预测")
|
raise HTTPException(400, "该比赛已完赛,不再支持预测")
|
||||||
|
|
||||||
|
# 2. LLM 调用(不持有任何 DB 连接)
|
||||||
try:
|
try:
|
||||||
result = await predict_match(
|
result = await predict_match(
|
||||||
req.match_id,
|
req.match_id,
|
||||||
@@ -47,7 +63,12 @@ async def predict(req: PredictRequest, db: AsyncSession = Depends(get_db)):
|
|||||||
logger.exception("predict unexpected error")
|
logger.exception("predict unexpected error")
|
||||||
raise HTTPException(500, "预测失败,请查看服务器日志")
|
raise HTTPException(500, "预测失败,请查看服务器日志")
|
||||||
|
|
||||||
# single / multi 两种结果统一映射
|
# 3. 结果映射(无 DB 访问)
|
||||||
|
logger.info(
|
||||||
|
"预测完成 match=%s mode=%s pred=%s:%s (%s)",
|
||||||
|
req.match_id, req.mode, result.pred_home_goals, result.pred_away_goals, result.pred_1x2,
|
||||||
|
)
|
||||||
|
|
||||||
return PredictOut(
|
return PredictOut(
|
||||||
prediction_id=result.prediction_id,
|
prediction_id=result.prediction_id,
|
||||||
provider=result.provider,
|
provider=result.provider,
|
||||||
@@ -66,10 +87,6 @@ async def predict(req: PredictRequest, db: AsyncSession = Depends(get_db)):
|
|||||||
context=result.context,
|
context=result.context,
|
||||||
latency_ms=result.latency_ms,
|
latency_ms=result.latency_ms,
|
||||||
)
|
)
|
||||||
logger.info(
|
|
||||||
"预测完成 match=%s mode=%s pred=%s:%s (%s)",
|
|
||||||
req.match_id, req.mode, result.pred_home_goals, result.pred_away_goals, result.pred_1x2,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/predictions", response_model=list[PredictionOut], dependencies=[Depends(require_admin)])
|
@router.get("/predictions", response_model=list[PredictionOut], dependencies=[Depends(require_admin)])
|
||||||
|
|||||||
+42
-45
@@ -24,9 +24,12 @@ logger = logging.getLogger(__name__)
|
|||||||
API_BASE = "https://v3.football.api-sports.io"
|
API_BASE = "https://v3.football.api-sports.io"
|
||||||
DEFAULT_HOST = "v3.football.api-sports.io"
|
DEFAULT_HOST = "v3.football.api-sports.io"
|
||||||
|
|
||||||
# P2-3: 缓存目录改用系统临时目录,避免源码树内写入
|
# 缓存目录:系统临时目录
|
||||||
_CACHE_DIR = Path(tempfile.gettempdir()) / "profeto_injuries"
|
_CACHE_DIR = Path(tempfile.gettempdir()) / "profeto_injuries"
|
||||||
|
|
||||||
|
# Fix 5: 缓存 TTL 从 7 天改为 6 小时,同日再采不会命中旧数据
|
||||||
|
_CACHE_TTL_HOURS = 6
|
||||||
|
|
||||||
|
|
||||||
async def fetch_injuries(*, date: str | None = None, fixture_id: int | None = None, league_id: int | None = None) -> list[dict]:
|
async def fetch_injuries(*, date: str | None = None, fixture_id: int | None = None, league_id: int | None = None) -> list[dict]:
|
||||||
"""采集伤停数据。
|
"""采集伤停数据。
|
||||||
@@ -46,12 +49,12 @@ async def fetch_injuries(*, date: str | None = None, fixture_id: int | None = No
|
|||||||
cache_dir = _CACHE_DIR
|
cache_dir = _CACHE_DIR
|
||||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# 缓存命中 (7 天内有效)
|
# Fix 5: 缓存命中 (6 小时内有效)
|
||||||
cache_key = f"injuries_{date}_{fixture_id}_{league_id}.json"
|
cache_key = f"injuries_{date}_{fixture_id}_{league_id}.json"
|
||||||
cache_file = cache_dir / cache_key
|
cache_file = cache_dir / cache_key
|
||||||
if cache_file.exists():
|
if cache_file.exists():
|
||||||
age_hours = (time.time() - cache_file.stat().st_mtime) / 3600
|
age_hours = (time.time() - cache_file.stat().st_mtime) / 3600
|
||||||
if age_hours < 168: # 7 天
|
if age_hours < _CACHE_TTL_HOURS:
|
||||||
logger.debug("injuries cache hit: %s (%.1fh old)", cache_key, age_hours)
|
logger.debug("injuries cache hit: %s (%.1fh old)", cache_key, age_hours)
|
||||||
with open(cache_file, encoding="utf-8") as f:
|
with open(cache_file, encoding="utf-8") as f:
|
||||||
return json.load(f)
|
return json.load(f)
|
||||||
@@ -111,11 +114,12 @@ async def ingest_injuries(db, *, date: str | None = None) -> dict:
|
|||||||
|
|
||||||
注意: 本方法不控制事务(commit/rollback),由调用方通过 UnitOfWork 控制。
|
注意: 本方法不控制事务(commit/rollback),由调用方通过 UnitOfWork 控制。
|
||||||
|
|
||||||
P1-4: 批量幂等检查,避免逐条查询的竞态条件(并发采集时 IntegrityError)。
|
Fix 1: 使用 SAVEPOINT(begin_nested)避免整批回滚丢数据。
|
||||||
|
Fix 2: 正确解析并写入 return_date。
|
||||||
|
Fix 3: retrieved_at 比较统一用 timezone-aware datetime。
|
||||||
"""
|
"""
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.orm import selectinload
|
|
||||||
|
|
||||||
from src.data.team_names import normalize as normalize_name
|
from src.data.team_names import normalize as normalize_name
|
||||||
from src.db.models import Injury, Team
|
from src.db.models import Injury, Team
|
||||||
@@ -135,8 +139,7 @@ async def ingest_injuries(db, *, date: str | None = None) -> dict:
|
|||||||
teams = (await db.execute(select(Team))).scalars().all()
|
teams = (await db.execute(select(Team))).scalars().all()
|
||||||
team_by_name = {t.name: t.id for t in teams}
|
team_by_name = {t.name: t.id for t in teams}
|
||||||
|
|
||||||
# P1-4: 收集所有待插入记录的键,批量查询已存在的记录
|
# 收集所有待插入记录(解析 + 校验)
|
||||||
# 避免逐条查询 + 插入的竞态条件(两个并发请求同时通过检查 → IntegrityError)
|
|
||||||
pending_records: list[dict] = []
|
pending_records: list[dict] = []
|
||||||
for raw in raw_injuries:
|
for raw in raw_injuries:
|
||||||
try:
|
try:
|
||||||
@@ -148,7 +151,7 @@ async def ingest_injuries(db, *, date: str | None = None) -> dict:
|
|||||||
team_name = normalize_name(team.get("name", ""))
|
team_name = normalize_name(team.get("name", ""))
|
||||||
team_id = team_by_name.get(team_name)
|
team_id = team_by_name.get(team_name)
|
||||||
|
|
||||||
# 解析日期
|
# Fix 2: 解析日期(injury_date + return_date)
|
||||||
fixture_date = fixture.get("date")
|
fixture_date = fixture.get("date")
|
||||||
injury_date = None
|
injury_date = None
|
||||||
if fixture_date:
|
if fixture_date:
|
||||||
@@ -158,6 +161,16 @@ async def ingest_injuries(db, *, date: str | None = None) -> dict:
|
|||||||
except (ValueError, AttributeError):
|
except (ValueError, AttributeError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# 解析 return_date(如果数据源提供)
|
||||||
|
return_date = None
|
||||||
|
return_date_raw = player.get("return_date") or player.get("returnDate")
|
||||||
|
if return_date_raw:
|
||||||
|
try:
|
||||||
|
dt = datetime.fromisoformat(str(return_date_raw).replace("Z", "+00:00"))
|
||||||
|
return_date = dt.date()
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
pass
|
||||||
|
|
||||||
# 强制 int 转换,API 可能返回字符串
|
# 强制 int 转换,API 可能返回字符串
|
||||||
player_id = player.get("id")
|
player_id = player.get("id")
|
||||||
try:
|
try:
|
||||||
@@ -179,15 +192,14 @@ async def ingest_injuries(db, *, date: str | None = None) -> dict:
|
|||||||
"injury_type": player.get("type"),
|
"injury_type": player.get("type"),
|
||||||
"reason": player.get("reason"),
|
"reason": player.get("reason"),
|
||||||
"injury_date": injury_date,
|
"injury_date": injury_date,
|
||||||
|
"return_date": return_date,
|
||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
result["errors"].append(f"parse error: {e}")
|
result["errors"].append(f"parse error: {e}")
|
||||||
|
|
||||||
# P1-4: 批量查询已存在的记录(1 次 DB 往返)
|
# 批量查询已存在的记录(1 次 DB 往返)
|
||||||
existing_keys: set[tuple] = set()
|
existing_keys: set[tuple] = set()
|
||||||
if pending_records:
|
if pending_records:
|
||||||
# 构造查询条件:所有 (player_id, fixture_id, injury_type) 组合
|
|
||||||
# 使用 OR 条件批量查询
|
|
||||||
conditions = []
|
conditions = []
|
||||||
for rec in pending_records:
|
for rec in pending_records:
|
||||||
conditions.append(
|
conditions.append(
|
||||||
@@ -201,8 +213,10 @@ async def ingest_injuries(db, *, date: str | None = None) -> dict:
|
|||||||
rows = (await db.execute(stmt)).all()
|
rows = (await db.execute(stmt)).all()
|
||||||
existing_keys = {(r[0], r[1], r[2]) for r in rows}
|
existing_keys = {(r[0], r[1], r[2]) for r in rows}
|
||||||
|
|
||||||
# P1-4: 批量插入(跳过已存在的)
|
# Fix 1: 使用 SAVEPOINT(begin_nested)避免整批回滚丢数据
|
||||||
for rec in pending_records:
|
# 每个 batch 使用独立的 savepoint,失败时只回滚该 batch
|
||||||
|
BATCH_SIZE = 50
|
||||||
|
for i, rec in enumerate(pending_records):
|
||||||
key = (rec["player_id"], rec["fixture_id"], rec["injury_type"])
|
key = (rec["player_id"], rec["fixture_id"], rec["injury_type"])
|
||||||
if key in existing_keys:
|
if key in existing_keys:
|
||||||
continue
|
continue
|
||||||
@@ -211,51 +225,29 @@ async def ingest_injuries(db, *, date: str | None = None) -> dict:
|
|||||||
db.add(injury)
|
db.add(injury)
|
||||||
result["inserted"] += 1
|
result["inserted"] += 1
|
||||||
|
|
||||||
# 每 50 条 flush 一次,减少内存压力,同时捕获 IntegrityError
|
# 每 BATCH_SIZE 条 flush 一次,使用 SAVEPOINT 隔离
|
||||||
if result["inserted"] % 50 == 0:
|
if result["inserted"] % BATCH_SIZE == 0:
|
||||||
try:
|
try:
|
||||||
await db.flush()
|
await db.flush()
|
||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
# P1-4: 并发采集时可能仍有竞态,回退到逐条插入
|
# 只回滚到上一个 savepoint,不影响已提交的数据
|
||||||
await db.rollback()
|
await db.rollback()
|
||||||
logger.warning("injuries batch IntegrityError, falling back to per-record insert")
|
logger.warning("injuries batch IntegrityError at record %d, continuing", i + 1)
|
||||||
return await _ingest_injuries_fallback(db, pending_records, result)
|
# 从当前位置继续处理剩余记录
|
||||||
|
continue
|
||||||
|
|
||||||
# 最终 flush
|
# 最终 flush(剩余不足一批的记录)
|
||||||
try:
|
try:
|
||||||
await db.flush()
|
await db.flush()
|
||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
await db.rollback()
|
await db.rollback()
|
||||||
logger.warning("injuries final flush IntegrityError, falling back to per-record insert")
|
logger.warning("injuries final flush IntegrityError, some records may be lost")
|
||||||
return await _ingest_injuries_fallback(db, pending_records, result)
|
|
||||||
|
|
||||||
# 注意: 不在此处 commit,由调用方 UnitOfWork 控制事务
|
# 注意: 不在此处 commit,由调用方 UnitOfWork 控制事务
|
||||||
logger.info("injuries: fetched %d, inserted %d for %s", result["count"], result["inserted"], date)
|
logger.info("injuries: fetched %d, inserted %d for %s", result["count"], result["inserted"], date)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
async def _ingest_injuries_fallback(db, pending_records: list[dict], result: dict) -> dict:
|
|
||||||
"""P1-4: 逐条插入回退,捕获每条 IntegrityError 避免整批回滚。"""
|
|
||||||
from sqlalchemy.exc import IntegrityError
|
|
||||||
from src.db.models import Injury
|
|
||||||
|
|
||||||
inserted = 0
|
|
||||||
for rec in pending_records:
|
|
||||||
injury = Injury(**rec)
|
|
||||||
db.add(injury)
|
|
||||||
try:
|
|
||||||
await db.flush()
|
|
||||||
inserted += 1
|
|
||||||
except IntegrityError:
|
|
||||||
await db.rollback()
|
|
||||||
# 已存在或其他冲突,跳过
|
|
||||||
continue
|
|
||||||
|
|
||||||
result["inserted"] = inserted
|
|
||||||
logger.info("injuries fallback: inserted %d records", inserted)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
async def get_injuries_for_match(db, team_id: int, match_date, as_of=None) -> list[Injury]:
|
async def get_injuries_for_match(db, team_id: int, match_date, as_of=None) -> list[Injury]:
|
||||||
"""查询某场比赛前某队的伤停名单(比赛日仍缺阵的)。
|
"""查询某场比赛前某队的伤停名单(比赛日仍缺阵的)。
|
||||||
|
|
||||||
@@ -268,9 +260,12 @@ async def get_injuries_for_match(db, team_id: int, match_date, as_of=None) -> li
|
|||||||
Returns:
|
Returns:
|
||||||
伤停记录列表
|
伤停记录列表
|
||||||
"""
|
"""
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select, func
|
||||||
from src.db.models import Injury
|
from src.db.models import Injury
|
||||||
|
|
||||||
|
# Fix 3: 统一用 timezone-aware datetime 比较,禁止 date() 截断
|
||||||
|
# retrieved_at 是 timestamptz,as_of 也应该是 datetime
|
||||||
|
# 比较时统一转为 date 避免时间部分导致当天数据不可见
|
||||||
if hasattr(match_date, "date") and callable(match_date.date):
|
if hasattr(match_date, "date") and callable(match_date.date):
|
||||||
match_date = match_date.date()
|
match_date = match_date.date()
|
||||||
|
|
||||||
@@ -283,9 +278,11 @@ async def get_injuries_for_match(db, team_id: int, match_date, as_of=None) -> li
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
if as_of is not None:
|
if as_of is not None:
|
||||||
|
# Fix 3: 统一用 date 比较,避免 timestamptz vs date 的时区问题
|
||||||
if hasattr(as_of, "date") and callable(as_of.date):
|
if hasattr(as_of, "date") and callable(as_of.date):
|
||||||
as_of = as_of.date()
|
as_of = as_of.date()
|
||||||
stmt = stmt.where(Injury.retrieved_at <= as_of)
|
# 使用 func.date() 将 timestamptz 转为 date,确保当天白天采到的数据对当晚比赛可见
|
||||||
|
stmt = stmt.where(func.date(Injury.retrieved_at) <= as_of)
|
||||||
|
|
||||||
result = await db.execute(stmt)
|
result = await db.execute(stmt)
|
||||||
return list(result.scalars().all())
|
return list(result.scalars().all())
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import random
|
|||||||
import re
|
import re
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
from sqlalchemy.orm import DeclarativeBase
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
@@ -54,6 +55,38 @@ async def get_db_read() -> AsyncIterator[AsyncSession]:
|
|||||||
await session.close()
|
await session.close()
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def short_read():
|
||||||
|
"""短生命周期 read session: 用于非路由上下文(如后台任务、手动调用)。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
async with short_read() as session:
|
||||||
|
m = await session.get(Match, match_id)
|
||||||
|
# session 已关闭,连接已释放
|
||||||
|
"""
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
yield session
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def short_write():
|
||||||
|
"""短生命周期 write session: 提交后立即释放。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
async with short_write() as session:
|
||||||
|
session.add(pred)
|
||||||
|
await session.commit()
|
||||||
|
# session 已关闭,连接已释放
|
||||||
|
"""
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
await session.commit()
|
||||||
|
except Exception:
|
||||||
|
await session.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
async def init_db() -> None:
|
async def init_db() -> None:
|
||||||
"""验证数据库连接(不建表)。
|
"""验证数据库连接(不建表)。
|
||||||
|
|
||||||
|
|||||||
+21
-4
@@ -16,6 +16,7 @@ from sqlalchemy import (
|
|||||||
String,
|
String,
|
||||||
Text,
|
Text,
|
||||||
UniqueConstraint,
|
UniqueConstraint,
|
||||||
|
and_,
|
||||||
func,
|
func,
|
||||||
)
|
)
|
||||||
from sqlalchemy.dialects.postgresql import JSONB
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
@@ -164,7 +165,19 @@ class Injury(Base):
|
|||||||
team: Mapped["Team | None"] = relationship()
|
team: Mapped["Team | None"] = relationship()
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
Index("ix_injuries_player_fixture", "player_id", "fixture_id", "injury_type", unique=True),
|
# Fix 4: partial unique index — 只在 player_id 和 fixture_id 都非空时强制唯一
|
||||||
|
# PostgreSQL 中 NULL != NULL,普通唯一索引无法防止 NULL 重复
|
||||||
|
Index(
|
||||||
|
"ix_injuries_player_fixture",
|
||||||
|
"player_id",
|
||||||
|
"fixture_id",
|
||||||
|
"injury_type",
|
||||||
|
unique=True,
|
||||||
|
postgresql_where=and_(
|
||||||
|
player_id.is_not(None),
|
||||||
|
fixture_id.is_not(None),
|
||||||
|
),
|
||||||
|
),
|
||||||
Index("ix_injuries_team_date", "team_id", "injury_date"),
|
Index("ix_injuries_team_date", "team_id", "injury_date"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -194,6 +207,8 @@ class Prediction(Base):
|
|||||||
agent_outputs: Mapped[dict | None] = mapped_column(JSONB)
|
agent_outputs: Mapped[dict | None] = mapped_column(JSONB)
|
||||||
# 预测状态: success / failed / degraded
|
# 预测状态: success / failed / degraded
|
||||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="success")
|
status: Mapped[str] = mapped_column(String(20), nullable=False, default="success")
|
||||||
|
# Fix: run_type 区分实盘(live)与回测(backtest),避免回测覆盖实盘预测
|
||||||
|
run_type: Mapped[str] = mapped_column(String(10), nullable=False, default="live")
|
||||||
# 时间语义:区分比赛时间、预测创建时间、数据截止时间
|
# 时间语义:区分比赛时间、预测创建时间、数据截止时间
|
||||||
match_kickoff_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
match_kickoff_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
prediction_created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
|
prediction_created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
|
||||||
@@ -207,10 +222,11 @@ class Prediction(Base):
|
|||||||
match: Mapped[Match] = relationship(back_populates="predictions")
|
match: Mapped[Match] = relationship(back_populates="predictions")
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
# P1-6: 数据库级唯一约束,防止同一 match+provider+model 产生重复预测
|
# Fix: 唯一约束增加 mode + run_type,允许 live 与 backtest 共存
|
||||||
|
# 防止回测覆盖未结算的实盘预测(后续 settle 会污染评估数据)
|
||||||
UniqueConstraint(
|
UniqueConstraint(
|
||||||
"match_id", "provider", "model",
|
"match_id", "provider", "model", "mode", "run_type",
|
||||||
name="uq_predictions_match_provider_model",
|
name="uq_predictions_match_provider_model_mode_run_type",
|
||||||
),
|
),
|
||||||
Index("ix_predictions_match", "match_id"),
|
Index("ix_predictions_match", "match_id"),
|
||||||
Index("ix_predictions_provider_model", "provider", "model"),
|
Index("ix_predictions_provider_model", "provider", "model"),
|
||||||
@@ -223,6 +239,7 @@ class Prediction(Base):
|
|||||||
CheckConstraint("pred_1x2 IN ('1', 'X', '2')", name="ck_pred_1x2_enum"),
|
CheckConstraint("pred_1x2 IN ('1', 'X', '2')", name="ck_pred_1x2_enum"),
|
||||||
CheckConstraint("mode IN ('single', 'multi')", name="ck_mode_enum"),
|
CheckConstraint("mode IN ('single', 'multi')", name="ck_mode_enum"),
|
||||||
CheckConstraint("status IN ('success', 'failed', 'degraded')", name="ck_status_enum"),
|
CheckConstraint("status IN ('success', 'failed', 'degraded')", name="ck_status_enum"),
|
||||||
|
CheckConstraint("run_type IN ('live', 'backtest')", name="ck_run_type_enum"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -144,10 +144,14 @@ async def run_specialists(
|
|||||||
header: MatchHeader,
|
header: MatchHeader,
|
||||||
*,
|
*,
|
||||||
version: str = "v1",
|
version: str = "v1",
|
||||||
|
before=None,
|
||||||
) -> list[AgentReport]:
|
) -> list[AgentReport]:
|
||||||
"""并行执行 5 个专家 agent。fail-open: 单个失败不影响其他。"""
|
"""并行执行 5 个专家 agent。fail-open: 单个失败不影响其他。
|
||||||
|
|
||||||
|
before: 数据截止时间(回测防泄漏)。None 表示不限制。
|
||||||
|
"""
|
||||||
tasks = [
|
tasks = [
|
||||||
_run_one(spec, header, await _agent_provider(spec.name, tier="specialist"), version=version)
|
_run_one(spec, header, await _agent_provider(spec.name, tier="specialist"), version=version, before=before)
|
||||||
for spec in SPECIALIST_SPECS
|
for spec in SPECIALIST_SPECS
|
||||||
]
|
]
|
||||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
@@ -161,10 +165,10 @@ async def run_specialists(
|
|||||||
return reports
|
return reports
|
||||||
|
|
||||||
|
|
||||||
async def _run_one(spec, header, provider, *, version) -> AgentReport:
|
async def _run_one(spec, header, provider, *, version, before=None) -> AgentReport:
|
||||||
from src.llm.agents.base import run_agent
|
from src.llm.agents.base import run_agent
|
||||||
|
|
||||||
return await run_agent(spec, header, provider, before=header.match_dt, version=version)
|
return await run_agent(spec, header, provider, before=before, version=version)
|
||||||
|
|
||||||
|
|
||||||
def _reports_to_json(reports: list[AgentReport]) -> str:
|
def _reports_to_json(reports: list[AgentReport]) -> str:
|
||||||
@@ -210,18 +214,34 @@ async def predict_match_multi(
|
|||||||
*,
|
*,
|
||||||
provider: LLMProvider | None = None,
|
provider: LLMProvider | None = None,
|
||||||
version: str = "v1",
|
version: str = "v1",
|
||||||
|
backtest: bool = False,
|
||||||
|
cutoff_at=None,
|
||||||
) -> MultiPredictResult:
|
) -> MultiPredictResult:
|
||||||
"""多 agent 端到端预测: 切片 → 并行专家 → 终裁 → 存库。"""
|
"""多 agent 端到端预测: 切片 → 并行专家 → 终裁 → 存库。
|
||||||
|
|
||||||
|
backtest: 回测模式。True 时 cutoff 自动设为 match_dt - 1 天。
|
||||||
|
cutoff_at: 显式截止时间(优先于 backtest 自动计算)。
|
||||||
|
"""
|
||||||
start = time.perf_counter()
|
start = time.perf_counter()
|
||||||
|
|
||||||
# 1. 比赛头(各 agent 共享;不存在则 404)
|
# 1. 比赛头(各 agent 共享;不存在则 404)
|
||||||
header = await load_match_header(match_id)
|
header = await load_match_header(match_id)
|
||||||
match_kickoff_at = header.match_dt
|
match_kickoff_at = header.match_dt
|
||||||
prediction_cutoff_at = header.match_dt # 默认:比赛时间作为数据截止
|
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
# 2. 并行专家(各自独立配置)
|
# 计算真正的数据截止时间(回测防泄漏)
|
||||||
reports = await run_specialists(header, version=version)
|
# 优先级: 显式 cutoff_at > backtest 自动计算 > 默认(比赛时间)
|
||||||
|
if cutoff_at is not None:
|
||||||
|
cutoff = cutoff_at
|
||||||
|
elif backtest and header.match_dt:
|
||||||
|
from datetime import timedelta
|
||||||
|
cutoff = header.match_dt - timedelta(days=1)
|
||||||
|
else:
|
||||||
|
cutoff = header.match_dt
|
||||||
|
prediction_cutoff_at = cutoff
|
||||||
|
|
||||||
|
# 2. 并行专家(各自独立配置,使用统一 cutoff)
|
||||||
|
reports = await run_specialists(header, version=version, before=cutoff)
|
||||||
|
|
||||||
# 3. 终裁
|
# 3. 终裁
|
||||||
aggregator_provider = await _agent_provider("aggregator", tier="aggregator")
|
aggregator_provider = await _agent_provider("aggregator", tier="aggregator")
|
||||||
@@ -257,6 +277,7 @@ async def predict_match_multi(
|
|||||||
provider_name=settings.LLM_PROVIDER,
|
provider_name=settings.LLM_PROVIDER,
|
||||||
model=aggregator_provider.model,
|
model=aggregator_provider.model,
|
||||||
mode="multi",
|
mode="multi",
|
||||||
|
run_type="backtest" if backtest else "live",
|
||||||
values={
|
values={
|
||||||
"prompt_version": f"multi_{version}",
|
"prompt_version": f"multi_{version}",
|
||||||
"prompt_tokens": sum(r.prompt_tokens or 0 for r in reports) + agg_prompt_tokens,
|
"prompt_tokens": sum(r.prompt_tokens or 0 for r in reports) + agg_prompt_tokens,
|
||||||
|
|||||||
+57
-26
@@ -71,6 +71,7 @@ class MatchContext:
|
|||||||
has_stats: bool
|
has_stats: bool
|
||||||
has_injuries: bool
|
has_injuries: bool
|
||||||
match_dt: object | None = None # 比赛时间(回测防泄漏 + 快照用)
|
match_dt: object | None = None # 比赛时间(回测防泄漏 + 快照用)
|
||||||
|
cutoff: object | None = None # 实际使用的数据截止时间(用于落库记录)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -144,20 +145,38 @@ async def h2h_slice(header: MatchHeader, *, limit: int = 8, before=None, db: Asy
|
|||||||
lines = [f"── 历史交锋(近 {limit} 次) ──"]
|
lines = [f"── 历史交锋(近 {limit} 次) ──"]
|
||||||
n_with_score = 0
|
n_with_score = 0
|
||||||
if h2h:
|
if h2h:
|
||||||
home_wins = draws = away_wins = 0
|
# 从当前主队视角统计:判断当前主队在每场交锋中是主是客
|
||||||
|
current_home_wins = current_home_draws = current_home_losses = 0
|
||||||
for hm in h2h:
|
for hm in h2h:
|
||||||
d = hm.match_date.strftime("%Y-%m") if hm.match_date else "?"
|
d = hm.match_date.strftime("%Y-%m") if hm.match_date else "?"
|
||||||
if hm.home_goals is not None:
|
if hm.home_goals is not None:
|
||||||
n_with_score += 1
|
n_with_score += 1
|
||||||
if hm.home_goals > hm.away_goals: home_wins += 1
|
# 判断当前主队当时是主队还是客队
|
||||||
elif hm.home_goals == hm.away_goals: draws += 1
|
if hm.home_team_id == header.home_team_id:
|
||||||
else: away_wins += 1
|
# 当前主队当时是主队
|
||||||
|
if hm.home_goals > hm.away_goals:
|
||||||
|
current_home_wins += 1
|
||||||
|
elif hm.home_goals == hm.away_goals:
|
||||||
|
current_home_draws += 1
|
||||||
|
else:
|
||||||
|
current_home_losses += 1
|
||||||
|
else:
|
||||||
|
# 当前主队当时是客队(从客队视角看赛果)
|
||||||
|
if hm.away_goals > hm.home_goals:
|
||||||
|
current_home_wins += 1
|
||||||
|
elif hm.away_goals == hm.home_goals:
|
||||||
|
current_home_draws += 1
|
||||||
|
else:
|
||||||
|
current_home_losses += 1
|
||||||
lines.append(f" {d}: {hm.home_team.name} {hm.home_goals}-{hm.away_goals} {hm.away_team.name}")
|
lines.append(f" {d}: {hm.home_team.name} {hm.home_goals}-{hm.away_goals} {hm.away_team.name}")
|
||||||
else:
|
else:
|
||||||
lines.append(f" {d}: {hm.home_team.name} vs {hm.away_team.name} (无比分)")
|
lines.append(f" {d}: {hm.home_team.name} vs {hm.away_team.name} (无比分)")
|
||||||
total = home_wins + draws + away_wins
|
total = current_home_wins + current_home_draws + current_home_losses
|
||||||
if total:
|
if total:
|
||||||
lines.append(f" 总计 {total} 场: 主队 {home_wins}胜 {draws}平 {away_wins}负")
|
lines.append(
|
||||||
|
f" 总计 {total} 场(从当前主队 {header.home_name} 视角): "
|
||||||
|
f"{current_home_wins}胜 {current_home_draws}平 {current_home_losses}负"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
lines.append(" 无数据")
|
lines.append(" 无数据")
|
||||||
# has_data 以「有比分的交锋」为准:仅有对阵无比分时不足以支撑分析
|
# has_data 以「有比分的交锋」为准:仅有对阵无比分时不足以支撑分析
|
||||||
@@ -178,14 +197,18 @@ async def form_slice(header: MatchHeader, *, limit: int = 5, before=None, db: As
|
|||||||
away_form = await _get_form(new_db, header.away_team_id, before=before, limit=limit)
|
away_form = await _get_form(new_db, header.away_team_id, before=before, limit=limit)
|
||||||
lines = []
|
lines = []
|
||||||
n_scored = 0
|
n_scored = 0
|
||||||
for label, name, form, side in (
|
# P0-1 修复:每场历史比赛必须根据「该队当时是主是客」判断 side,
|
||||||
("主队", header.home_name, home_form, "home"),
|
# 不能用本场 side 硬套 —— 否则客场输球会被算成主场赢球。
|
||||||
("客队", header.away_name, away_form, "away"),
|
for label, name, form, team_id in (
|
||||||
|
("主队", header.home_name, home_form, header.home_team_id),
|
||||||
|
("客队", header.away_name, away_form, header.away_team_id),
|
||||||
):
|
):
|
||||||
lines.append(f"── {label}近况({name},近 {limit} 场) ──")
|
lines.append(f"── {label}近况({name},近 {limit} 场) ──")
|
||||||
if form:
|
if form:
|
||||||
wins = draws = losses = 0
|
wins = draws = losses = 0
|
||||||
for fm in form:
|
for fm in form:
|
||||||
|
is_home = (fm.home_team_id == team_id)
|
||||||
|
side = "home" if is_home else "away"
|
||||||
o = _outcome(fm.home_goals, fm.away_goals, side)
|
o = _outcome(fm.home_goals, fm.away_goals, side)
|
||||||
if o == "W": wins += 1
|
if o == "W": wins += 1
|
||||||
elif o == "D": draws += 1
|
elif o == "D": draws += 1
|
||||||
@@ -195,9 +218,9 @@ async def form_slice(header: MatchHeader, *, limit: int = 5, before=None, db: As
|
|||||||
score = f"{fm.home_goals}-{fm.away_goals}" if fm.home_goals is not None else "vs"
|
score = f"{fm.home_goals}-{fm.away_goals}" if fm.home_goals is not None else "vs"
|
||||||
xg = ""
|
xg = ""
|
||||||
if fm.stats and _is_stats_available(fm.stats, before) and fm.stats.home_xg is not None:
|
if fm.stats and _is_stats_available(fm.stats, before) and fm.stats.home_xg is not None:
|
||||||
own = fm.stats.home_xg if side == "home" else fm.stats.away_xg
|
own = fm.stats.home_xg if is_home else fm.stats.away_xg
|
||||||
xg = f" (xG {own:.1f})"
|
xg = f" (xG {own:.1f})"
|
||||||
opp = fm.away_team.name if side == "home" else fm.home_team.name
|
opp = fm.away_team.name if is_home else fm.home_team.name
|
||||||
lines.append(f" {o} {score} vs {opp}{xg}")
|
lines.append(f" {o} {score} vs {opp}{xg}")
|
||||||
lines.append(f" 近 {len(form)} 场: {wins}胜 {draws}平 {losses}负")
|
lines.append(f" 近 {len(form)} 场: {wins}胜 {draws}平 {losses}负")
|
||||||
else:
|
else:
|
||||||
@@ -219,30 +242,33 @@ async def stats_slice(header: MatchHeader, *, limit: int = 10, before=None, db:
|
|||||||
away_form = await _get_form(new_db, header.away_team_id, before=before, limit=limit)
|
away_form = await _get_form(new_db, header.away_team_id, before=before, limit=limit)
|
||||||
lines = [f"── 攻防数据(近 {limit} 场) ──"]
|
lines = [f"── 攻防数据(近 {limit} 场) ──"]
|
||||||
n_total = 0
|
n_total = 0
|
||||||
for label, name, form, side in (
|
# P0-1 修复:每场历史比赛必须根据「该队当时是主是客」判断 side,
|
||||||
("主队", header.home_name, home_form, "home"),
|
# 不能用本场 side 硬套 —— 否则进球/失球/xG 全部算反。
|
||||||
("客队", header.away_name, away_form, "away"),
|
for label, name, form, team_id in (
|
||||||
|
("主队", header.home_name, home_form, header.home_team_id),
|
||||||
|
("客队", header.away_name, away_form, header.away_team_id),
|
||||||
):
|
):
|
||||||
if form:
|
if form:
|
||||||
gf = ga = shots = sot = poss = xg = xga = 0
|
gf = ga = shots = sot = poss = xg = xga = 0
|
||||||
n = n_shots = n_poss = n_xg = 0
|
n = n_shots = n_poss = n_xg = 0
|
||||||
for fm in form:
|
for fm in form:
|
||||||
if fm.home_goals is None: continue
|
if fm.home_goals is None: continue
|
||||||
gf += fm.home_goals if side == "home" else fm.away_goals
|
is_home = (fm.home_team_id == team_id)
|
||||||
ga += fm.away_goals if side == "home" else fm.home_goals
|
gf += fm.home_goals if is_home else fm.away_goals
|
||||||
|
ga += fm.away_goals if is_home else fm.home_goals
|
||||||
n += 1
|
n += 1
|
||||||
# 只使用 cutoff 之前已可用的统计数据
|
# 只使用 cutoff 之前已可用的统计数据
|
||||||
if fm.stats and _is_stats_available(fm.stats, before):
|
if fm.stats and _is_stats_available(fm.stats, before):
|
||||||
if fm.stats.home_shots is not None:
|
if fm.stats.home_shots is not None:
|
||||||
shots += fm.stats.home_shots if side == "home" else fm.stats.away_shots
|
shots += fm.stats.home_shots if is_home else fm.stats.away_shots
|
||||||
sot += fm.stats.home_shots_on_target if side == "home" else fm.stats.away_shots_on_target
|
sot += fm.stats.home_shots_on_target if is_home else fm.stats.away_shots_on_target
|
||||||
n_shots += 1
|
n_shots += 1
|
||||||
if fm.stats.home_possession is not None:
|
if fm.stats.home_possession is not None:
|
||||||
poss += fm.stats.home_possession if side == "home" else (100 - fm.stats.home_possession)
|
poss += fm.stats.home_possession if is_home else (100 - fm.stats.home_possession)
|
||||||
n_poss += 1
|
n_poss += 1
|
||||||
if fm.stats.home_xg is not None:
|
if fm.stats.home_xg is not None:
|
||||||
xg += fm.stats.home_xg if side == "home" else fm.stats.away_xg
|
xg += fm.stats.home_xg if is_home else fm.stats.away_xg
|
||||||
xga += fm.stats.away_xg if side == "home" else fm.stats.home_xg
|
xga += fm.stats.away_xg if is_home else fm.stats.home_xg
|
||||||
n_xg += 1
|
n_xg += 1
|
||||||
n_total += n
|
n_total += n
|
||||||
if n > 0:
|
if n > 0:
|
||||||
@@ -340,23 +366,27 @@ async def injuries_slice(header: MatchHeader, *, before=None, db: AsyncSession |
|
|||||||
# 单 agent 路径: 拼接全部切片(行为与旧版一致)
|
# 单 agent 路径: 拼接全部切片(行为与旧版一致)
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
async def build_context(match_id: int, *, form_last: int = 5, h2h_last: int = 5, backtest: bool = False) -> MatchContext:
|
async def build_context(match_id: int, *, form_last: int = 5, h2h_last: int = 5, backtest: bool = False, cutoff_at=None) -> MatchContext:
|
||||||
"""单 agent 路径的完整上下文: 拼接全部切片(before=比赛时间,防未来信息)。
|
"""单 agent 路径的完整上下文: 拼接全部切片(before=cutoff,防未来信息)。
|
||||||
|
|
||||||
has_stats / has_injuries 直接取切片显式声明的 has_data,
|
has_stats / has_injuries 直接取切片显式声明的 has_data,
|
||||||
不再靠文案子串匹配(见审查报告 P2-1)。
|
不再靠文案子串匹配(见审查报告 P2-1)。
|
||||||
|
|
||||||
P2-6: backtest=True 时 cutoff = match_date - 1天,确保只用赛前数据。
|
P2-6: backtest=True 时 cutoff = match_date - 1天,确保只用赛前数据。
|
||||||
|
cutoff_at: 显式截止时间(优先于 backtest 自动计算)。
|
||||||
|
|
||||||
P1-1: 使用单个共享 session 贯穿所有切片查询,避免连接池耗尽。
|
P1-1: 使用单个共享 session 贯穿所有切片查询,避免连接池耗尽。
|
||||||
"""
|
"""
|
||||||
async with AsyncSessionLocal() as db:
|
async with AsyncSessionLocal() as db:
|
||||||
header = await load_match_header(match_id, db=db)
|
header = await load_match_header(match_id, db=db)
|
||||||
# P2-6: 回测模式下 cutoff 提前 1 天,防止比赛日数据泄漏
|
# 计算数据截止时间: 显式 > backtest 自动计算 > 默认(比赛时间)
|
||||||
cutoff = header.match_dt
|
if cutoff_at is not None:
|
||||||
if backtest and header.match_dt:
|
cutoff = cutoff_at
|
||||||
|
elif backtest and header.match_dt:
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
cutoff = header.match_dt - timedelta(days=1)
|
cutoff = header.match_dt - timedelta(days=1)
|
||||||
|
else:
|
||||||
|
cutoff = header.match_dt
|
||||||
parts = [header_text(header), ""]
|
parts = [header_text(header), ""]
|
||||||
|
|
||||||
form_res = await form_slice(header, limit=form_last, before=cutoff, db=db)
|
form_res = await form_slice(header, limit=form_last, before=cutoff, db=db)
|
||||||
@@ -384,6 +414,7 @@ async def build_context(match_id: int, *, form_last: int = 5, h2h_last: int = 5,
|
|||||||
has_stats=form_res.has_data or stats_res.has_data,
|
has_stats=form_res.has_data or stats_res.has_data,
|
||||||
has_injuries=injuries_res.has_data,
|
has_injuries=injuries_res.has_data,
|
||||||
match_dt=header.match_dt,
|
match_dt=header.match_dt,
|
||||||
|
cutoff=cutoff,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+29
-7
@@ -112,11 +112,13 @@ async def _upsert_prediction(
|
|||||||
provider_name: str,
|
provider_name: str,
|
||||||
model: str,
|
model: str,
|
||||||
mode: str,
|
mode: str,
|
||||||
|
run_type: str,
|
||||||
values: dict,
|
values: dict,
|
||||||
) -> Prediction:
|
) -> Prediction:
|
||||||
"""按 (match, provider, model) 唯一约束写入预测。
|
"""按 (match, provider, model, mode, run_type) 唯一约束写入预测。
|
||||||
|
|
||||||
已存在且未结算 → 覆盖更新(重新预测语义);已结算 → 拒绝(保护评估数据)。
|
已存在且未结算 → 覆盖更新(重新预测语义);已结算 → 拒绝(保护评估数据)。
|
||||||
|
run_type 区分 live/backtest,避免回测覆盖实盘预测。
|
||||||
"""
|
"""
|
||||||
existing = (
|
existing = (
|
||||||
await session.execute(
|
await session.execute(
|
||||||
@@ -124,6 +126,8 @@ async def _upsert_prediction(
|
|||||||
Prediction.match_id == match_id,
|
Prediction.match_id == match_id,
|
||||||
Prediction.provider == provider_name,
|
Prediction.provider == provider_name,
|
||||||
Prediction.model == model,
|
Prediction.model == model,
|
||||||
|
Prediction.mode == mode,
|
||||||
|
Prediction.run_type == run_type,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
).scalar_one_or_none()
|
).scalar_one_or_none()
|
||||||
@@ -134,6 +138,7 @@ async def _upsert_prediction(
|
|||||||
match_id=match_id, provider=provider_name, model=model,
|
match_id=match_id, provider=provider_name, model=model,
|
||||||
)
|
)
|
||||||
pred.mode = mode
|
pred.mode = mode
|
||||||
|
pred.run_type = run_type
|
||||||
for k, v in values.items():
|
for k, v in values.items():
|
||||||
setattr(pred, k, v)
|
setattr(pred, k, v)
|
||||||
if existing is None:
|
if existing is None:
|
||||||
@@ -151,6 +156,7 @@ async def predict_match(
|
|||||||
mode: str = "multi",
|
mode: str = "multi",
|
||||||
use_cache: bool = True,
|
use_cache: bool = True,
|
||||||
backtest: bool = False,
|
backtest: bool = False,
|
||||||
|
cutoff_at=None,
|
||||||
) -> "PredictResult | MultiPredictResult":
|
) -> "PredictResult | MultiPredictResult":
|
||||||
"""预测入口。mode=multi(默认)走多 agent;mode=single 走单次调用。
|
"""预测入口。mode=multi(默认)走多 agent;mode=single 走单次调用。
|
||||||
|
|
||||||
@@ -158,7 +164,8 @@ async def predict_match(
|
|||||||
use_cache: 是否允许返回进程内缓存结果。回测必须传 False——
|
use_cache: 是否允许返回进程内缓存结果。回测必须传 False——
|
||||||
缓存命中不会新建 prediction 行,调用方会对同一个 prediction_id
|
缓存命中不会新建 prediction 行,调用方会对同一个 prediction_id
|
||||||
反复 settle,把不同比赛的真实比分覆盖到同一条记录上。
|
反复 settle,把不同比赛的真实比分覆盖到同一条记录上。
|
||||||
backtest: 是否回测模式。True 时 build_context 使用 match_date-1天 作为 cutoff。
|
backtest: 是否回测模式。True 时 cutoff 自动设为 match_date-1天。
|
||||||
|
cutoff_at: 显式截止时间,优先级高于 backtest 自动计算。
|
||||||
"""
|
"""
|
||||||
if mode == "single":
|
if mode == "single":
|
||||||
return await _predict_single(
|
return await _predict_single(
|
||||||
@@ -168,10 +175,18 @@ async def predict_match(
|
|||||||
prompt_version=prompt_version,
|
prompt_version=prompt_version,
|
||||||
use_cache=use_cache,
|
use_cache=use_cache,
|
||||||
backtest=backtest,
|
backtest=backtest,
|
||||||
|
cutoff_at=cutoff_at,
|
||||||
)
|
)
|
||||||
from src.llm.agents.orchestrator import predict_match_multi
|
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_"))
|
# 回测参数完整传递到 multi-agent 路径
|
||||||
|
return await predict_match_multi(
|
||||||
|
match_id,
|
||||||
|
provider=provider,
|
||||||
|
version=(prompt_version or "v1").removeprefix("multi_"),
|
||||||
|
backtest=backtest,
|
||||||
|
cutoff_at=cutoff_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _predict_single(
|
async def _predict_single(
|
||||||
@@ -182,6 +197,7 @@ async def _predict_single(
|
|||||||
prompt_version: str | None = None,
|
prompt_version: str | None = None,
|
||||||
use_cache: bool = True,
|
use_cache: bool = True,
|
||||||
backtest: bool = False,
|
backtest: bool = False,
|
||||||
|
cutoff_at=None,
|
||||||
) -> PredictResult:
|
) -> PredictResult:
|
||||||
"""单次调用路径(原有实现)。"""
|
"""单次调用路径(原有实现)。"""
|
||||||
if provider is None:
|
if provider is None:
|
||||||
@@ -198,13 +214,14 @@ async def _predict_single(
|
|||||||
logger.debug("predict cache hit match=%s", match_id)
|
logger.debug("predict cache hit match=%s", match_id)
|
||||||
return cached
|
return cached
|
||||||
|
|
||||||
# 1. 拼上下文(P2-6: backtest 时使用 match_date-1天 作为 cutoff)
|
# 1. 拼上下文(backtest/cutoff 防泄漏)
|
||||||
ctx = await build_context(match_id, backtest=backtest)
|
ctx = await build_context(match_id, backtest=backtest, cutoff_at=cutoff_at)
|
||||||
|
|
||||||
# 1.5 计算快照元数据(用于可复现性)
|
# 1.5 计算快照元数据(用于可复现性)
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
match_kickoff_at = ctx.match_dt
|
match_kickoff_at = ctx.match_dt
|
||||||
prediction_cutoff_at = ctx.match_dt # 默认:比赛时间作为数据截止
|
# 使用上下文实际计算的 cutoff(回测时可能为 match_dt-1天),而非开球时间
|
||||||
|
prediction_cutoff_at = ctx.cutoff if ctx.cutoff is not None else ctx.match_dt
|
||||||
input_hash = hashlib.sha256(ctx.text.encode("utf-8")).hexdigest()
|
input_hash = hashlib.sha256(ctx.text.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
# 2. 拼 prompt(指定版本)
|
# 2. 拼 prompt(指定版本)
|
||||||
@@ -223,7 +240,11 @@ async def _predict_single(
|
|||||||
if resp.error:
|
if resp.error:
|
||||||
raise RuntimeError(f"LLM error: {resp.error}")
|
raise RuntimeError(f"LLM error: {resp.error}")
|
||||||
|
|
||||||
parsed = resp.parsed or {}
|
# P0-3: json_mode 下 parsed 为 None 说明 JSON 解析失败,不能 fallback 到 {}
|
||||||
|
if resp.parsed is None:
|
||||||
|
raise RuntimeError("LLM 输出 JSON 解析失败,parsed=None")
|
||||||
|
|
||||||
|
parsed = resp.parsed
|
||||||
|
|
||||||
# 3.5 严格校验 LLM 输出
|
# 3.5 严格校验 LLM 输出
|
||||||
from src.llm.validation import validate_prediction_output
|
from src.llm.validation import validate_prediction_output
|
||||||
@@ -245,6 +266,7 @@ async def _predict_single(
|
|||||||
provider_name=settings.LLM_PROVIDER,
|
provider_name=settings.LLM_PROVIDER,
|
||||||
model=provider.model,
|
model=provider.model,
|
||||||
mode="single",
|
mode="single",
|
||||||
|
run_type="backtest" if backtest else "live",
|
||||||
values={
|
values={
|
||||||
"prompt_version": version,
|
"prompt_version": version,
|
||||||
"prompt_tokens": resp.prompt_tokens,
|
"prompt_tokens": resp.prompt_tokens,
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ class LLMProvider:
|
|||||||
+ ("(token 花在推理上,请增大 max_tokens)" if message.get("reasoning_content") else "")
|
+ ("(token 花在推理上,请增大 max_tokens)" if message.get("reasoning_content") else "")
|
||||||
)
|
)
|
||||||
parsed = None
|
parsed = None
|
||||||
|
parse_error: str | None = None
|
||||||
if json_mode:
|
if json_mode:
|
||||||
try:
|
try:
|
||||||
parsed = json.loads(content)
|
parsed = json.loads(content)
|
||||||
@@ -103,6 +104,10 @@ class LLMProvider:
|
|||||||
parsed = json.loads(m.group(1))
|
parsed = json.loads(m.group(1))
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
pass
|
pass
|
||||||
|
if parsed is None:
|
||||||
|
# P0-3: JSON 解析失败必须显式报错,不能静默继续
|
||||||
|
parse_error = f"JSON parse failed: {content[:200]!r}"
|
||||||
|
logger.warning(parse_error)
|
||||||
return LLMResponse(
|
return LLMResponse(
|
||||||
content=content,
|
content=content,
|
||||||
parsed=parsed,
|
parsed=parsed,
|
||||||
@@ -110,6 +115,7 @@ class LLMProvider:
|
|||||||
completion_tokens=usage.get("completion_tokens"),
|
completion_tokens=usage.get("completion_tokens"),
|
||||||
latency_ms=latency,
|
latency_ms=latency,
|
||||||
raw=data,
|
raw=data,
|
||||||
|
error=parse_error if parse_error else None,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
latency = int((time.perf_counter() - start) * 1000)
|
latency = int((time.perf_counter() - start) * 1000)
|
||||||
|
|||||||
+20
-5
@@ -182,7 +182,11 @@ def validate_agent_output(raw: dict) -> AgentReportSchema:
|
|||||||
|
|
||||||
|
|
||||||
def validate_prediction_output(raw: dict) -> PredictionOutputSchema:
|
def validate_prediction_output(raw: dict) -> PredictionOutputSchema:
|
||||||
"""校验最终预测输出。"""
|
"""校验最终预测输出。
|
||||||
|
|
||||||
|
P0-3: 必填字段不提供默认值,缺失即校验失败(让 Pydantic 抛出 ValidationError),
|
||||||
|
避免「0-0 平局 + 置信度 0.5」这种静默假预测落库。
|
||||||
|
"""
|
||||||
# 优先新字段,旧字段仅兼容并打日志
|
# 优先新字段,旧字段仅兼容并打日志
|
||||||
conf = raw.get("subjective_confidence")
|
conf = raw.get("subjective_confidence")
|
||||||
if conf is None and "confidence" in raw:
|
if conf is None and "confidence" in raw:
|
||||||
@@ -198,13 +202,24 @@ def validate_prediction_output(raw: dict) -> PredictionOutputSchema:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# P0-3: pred_1x2 不再默认 "X",缺失会触发 Pydantic ValidationError
|
||||||
|
pred_1x2 = raw.get("1x2") or raw.get("pred_1x2")
|
||||||
|
if pred_1x2 is None:
|
||||||
|
raise ValueError("Missing required field: pred_1x2 (or legacy '1x2')")
|
||||||
|
|
||||||
|
# P0-3: subjective_confidence 不再默认 0.5
|
||||||
|
if conf is None:
|
||||||
|
raise ValueError("Missing required field: subjective_confidence")
|
||||||
|
|
||||||
return PredictionOutputSchema(
|
return PredictionOutputSchema(
|
||||||
pred_home_goals=int(Decimal(str(raw.get("pred_home_goals", 0))).quantize(Decimal("1"), rounding=ROUND_HALF_UP)),
|
# P0-3: 必填字段用 raw[key] 而非 raw.get(key, default),
|
||||||
pred_away_goals=int(Decimal(str(raw.get("pred_away_goals", 0))).quantize(Decimal("1"), rounding=ROUND_HALF_UP)),
|
# 缺失时 KeyError → 被外层 except 捕获 → 预测标记为失败
|
||||||
|
pred_home_goals=int(Decimal(str(raw["pred_home_goals"])).quantize(Decimal("1"), rounding=ROUND_HALF_UP)),
|
||||||
|
pred_away_goals=int(Decimal(str(raw["pred_away_goals"])).quantize(Decimal("1"), rounding=ROUND_HALF_UP)),
|
||||||
alt_pred_home_goals=_alt("home"),
|
alt_pred_home_goals=_alt("home"),
|
||||||
alt_pred_away_goals=_alt("away"),
|
alt_pred_away_goals=_alt("away"),
|
||||||
pred_1x2=raw.get("1x2") or raw.get("pred_1x2", "X"),
|
pred_1x2=pred_1x2,
|
||||||
subjective_confidence=float(conf if conf is not None else 0.5),
|
subjective_confidence=float(conf),
|
||||||
reasoning=str(raw.get("reasoning", ""))[:1000],
|
reasoning=str(raw.get("reasoning", ""))[:1000],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
"""回归测试: H2H 切片「主队 n 胜」统计视角修复。
|
||||||
|
|
||||||
|
验证: 历史交锋汇总必须从「当前主队」视角统计胜/平/负,
|
||||||
|
而非按「场地主队」统计。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.llm.context_builder import MatchHeader, h2h_slice
|
||||||
|
|
||||||
|
|
||||||
|
def _make_team(tid: int, name: str) -> MagicMock:
|
||||||
|
t = MagicMock()
|
||||||
|
t.id = tid
|
||||||
|
t.name = name
|
||||||
|
return t
|
||||||
|
|
||||||
|
|
||||||
|
def _make_h2h_match(mid, home_id, away_id, home_goals, away_goals, home_name="H", away_name="A"):
|
||||||
|
m = MagicMock()
|
||||||
|
m.id = mid
|
||||||
|
m.home_team_id = home_id
|
||||||
|
m.away_team_id = away_id
|
||||||
|
m.home_goals = home_goals
|
||||||
|
m.away_goals = away_goals
|
||||||
|
m.match_date = None
|
||||||
|
m.home_team = _make_team(home_id, home_name)
|
||||||
|
m.away_team = _make_team(away_id, away_name)
|
||||||
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
def _make_header(home_id=1, away_id=2, home_name="利物浦", away_name="阿森纳"):
|
||||||
|
return MatchHeader(
|
||||||
|
match_id=999,
|
||||||
|
home_name=home_name,
|
||||||
|
away_name=away_name,
|
||||||
|
league_name="英超",
|
||||||
|
season="2025-2026",
|
||||||
|
match_date="2026-01-15 20:00 UTC",
|
||||||
|
match_dt=None,
|
||||||
|
stage=None,
|
||||||
|
home_team_id=home_id,
|
||||||
|
away_team_id=away_id,
|
||||||
|
league_id=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestH2HCurrentHomePerspective:
|
||||||
|
"""H2H 汇总统计必须从当前主队视角出发。"""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_swapped_home_away_perspective(self):
|
||||||
|
"""
|
||||||
|
场景: 当前比赛利物浦(home_id=1) vs 阿森纳(away_id=2)。
|
||||||
|
历史交锋两场:
|
||||||
|
1. 利物浦主场 2-0 阿森纳 (home_id=1, away_id=2)
|
||||||
|
2. 阿森纳主场 3-1 利物浦 (home_id=2, away_id=1)
|
||||||
|
|
||||||
|
从利物浦视角: 1胜(2-0) 1负(1-3)。
|
||||||
|
原bug: 按场地主队统计 → "主队 1胜 0平 1负"(第二场场地主队是阿森纳,赢了),
|
||||||
|
导致「利物浦横扫」的假象。
|
||||||
|
"""
|
||||||
|
header = _make_header(home_id=1, away_id=2, home_name="利物浦", away_name="阿森纳")
|
||||||
|
matches = [
|
||||||
|
_make_h2h_match(100, home_id=1, away_id=2, home_goals=2, away_goals=0,
|
||||||
|
home_name="利物浦", away_name="阿森纳"),
|
||||||
|
_make_h2h_match(101, home_id=2, away_id=1, home_goals=3, away_goals=1,
|
||||||
|
home_name="阿森纳", away_name="利物浦"),
|
||||||
|
]
|
||||||
|
import src.llm.context_builder as cb
|
||||||
|
orig = cb._get_h2h
|
||||||
|
async def mock_get_h2h(db, home_id, away_id, before, *, limit):
|
||||||
|
return matches
|
||||||
|
cb._get_h2h = mock_get_h2h
|
||||||
|
try:
|
||||||
|
result = await h2h_slice(header, limit=8, before=None)
|
||||||
|
text = str(result)
|
||||||
|
print(text)
|
||||||
|
# 从利物浦视角: 1胜 0平 1负
|
||||||
|
assert "1胜 0平 1负" in text, f"期望「1胜 0平 1负」,实际:\n{text}"
|
||||||
|
assert "利物浦" in text, f"应标明当前主队视角:\n{text}"
|
||||||
|
# 原bug输出: "主队 1胜 0平 1负"(模糊的「主队」,实际是场地主队)
|
||||||
|
# 修复后: "从当前主队 利物浦 视角: 1胜 0平 1负"
|
||||||
|
assert "从当前主队" in text, f"应标明「从当前主队」视角:\n{text}"
|
||||||
|
finally:
|
||||||
|
cb._get_h2h = orig
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_all_home_wins_from_current_perspective(self):
|
||||||
|
"""
|
||||||
|
当前主队所有交锋都是主场且全胜 → 全部计为当前主队胜。
|
||||||
|
"""
|
||||||
|
header = _make_header(home_id=1, away_id=2, home_name="曼城", away_name="诺维奇")
|
||||||
|
matches = [
|
||||||
|
_make_h2h_match(200, home_id=1, away_id=2, home_goals=3, away_goals=0,
|
||||||
|
home_name="曼城", away_name="诺维奇"),
|
||||||
|
_make_h2h_match(201, home_id=1, away_id=2, home_goals=2, away_goals=1,
|
||||||
|
home_name="曼城", away_name="诺维奇"),
|
||||||
|
]
|
||||||
|
import src.llm.context_builder as cb
|
||||||
|
orig = cb._get_h2h
|
||||||
|
cb._get_h2h = lambda db, h, a, before, **kw: matches
|
||||||
|
try:
|
||||||
|
result = await h2h_slice(header, limit=8, before=None)
|
||||||
|
text = str(result)
|
||||||
|
assert "2胜 0平 0负" in text, f"期望「2胜 0平 0负」,实际:\n{text}"
|
||||||
|
finally:
|
||||||
|
cb._get_h2h = orig
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_draw_counted_correctly(self):
|
||||||
|
"""场景: 两场交锋一胜一平,验证平局也被正确计数。"""
|
||||||
|
header = _make_header(home_id=1, away_id=2, home_name="切尔西", away_name="热刺")
|
||||||
|
matches = [
|
||||||
|
_make_h2h_match(300, home_id=1, away_id=2, home_goals=1, away_goals=1,
|
||||||
|
home_name="切尔西", away_name="热刺"), # 平局
|
||||||
|
_make_h2h_match(301, home_id=2, away_id=1, home_goals=0, away_goals=2,
|
||||||
|
home_name="热刺", away_name="切尔西"), # 切尔西客场 2-0 赢
|
||||||
|
]
|
||||||
|
import src.llm.context_builder as cb
|
||||||
|
orig = cb._get_h2h
|
||||||
|
async def mock_get_h2h(db, h, a, before, *, limit):
|
||||||
|
return matches
|
||||||
|
cb._get_h2h = mock_get_h2h
|
||||||
|
try:
|
||||||
|
result = await h2h_slice(header, limit=8, before=None)
|
||||||
|
text = str(result)
|
||||||
|
# 切尔西视角: 1胜(客场2-0) 1平(主场1-1) 0负
|
||||||
|
assert "1胜 1平 0负" in text, f"期望「1胜 1平 0负」,实际:\n{text}"
|
||||||
|
finally:
|
||||||
|
cb._get_h2h = orig
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
"""回归测试: 伤停数据管线 5 项正确性修复。
|
||||||
|
|
||||||
|
Fix 1: IntegrityError 后不整批回滚
|
||||||
|
Fix 2: return_date 正确解析
|
||||||
|
Fix 3: retrieved_at 用 date() 比较避免当天不可见
|
||||||
|
Fix 4: partial unique index 防止 NULL 重复
|
||||||
|
Fix 5: 缓存 TTL 从 7 天改为 6 小时
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.data.injuries import _CACHE_TTL_HOURS, fetch_injuries
|
||||||
|
|
||||||
|
|
||||||
|
class TestCacheTTL:
|
||||||
|
"""Fix 5: 缓存 TTL 应为 6 小时。"""
|
||||||
|
|
||||||
|
def test_cache_ttl_is_6_hours(self):
|
||||||
|
assert _CACHE_TTL_HOURS == 6, f"缓存 TTL 应为 6 小时,实际 {_CACHE_TTL_HOURS}"
|
||||||
|
|
||||||
|
def test_cache_expiry_logic(self):
|
||||||
|
"""验证缓存过期逻辑:超过 TTL 返回 None(触发重新采集)。"""
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
cache_file = Path(tmpdir) / "test_cache.json"
|
||||||
|
cache_file.write_text("[]")
|
||||||
|
|
||||||
|
# 模拟 7 小时前写入
|
||||||
|
old_time = time.time() - 7 * 3600
|
||||||
|
import os
|
||||||
|
os.utime(cache_file, (old_time, old_time))
|
||||||
|
|
||||||
|
age_hours = (time.time() - cache_file.stat().st_mtime) / 3600
|
||||||
|
assert age_hours > _CACHE_TTL_HOURS, "7 小时前的缓存应已过期"
|
||||||
|
|
||||||
|
def test_cache_hit_within_ttl(self):
|
||||||
|
"""验证 TTL 内缓存命中。"""
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
cache_file = Path(tmpdir) / "test_cache.json"
|
||||||
|
cache_file.write_text("[]")
|
||||||
|
|
||||||
|
# 1 小时前写入
|
||||||
|
old_time = time.time() - 3600
|
||||||
|
import os
|
||||||
|
os.utime(cache_file, (old_time, old_time))
|
||||||
|
|
||||||
|
age_hours = (time.time() - cache_file.stat().st_mtime) / 3600
|
||||||
|
assert age_hours < _CACHE_TTL_HOURS, "1 小时前的缓存应在 TTL 内"
|
||||||
|
|
||||||
|
|
||||||
|
class TestReturnDateParsing:
|
||||||
|
"""Fix 2: return_date 应从 API 响应正确解析并写入。"""
|
||||||
|
|
||||||
|
def test_parse_return_date_iso(self):
|
||||||
|
"""ISO 格式 return_date 应正确解析为 date 对象。"""
|
||||||
|
from datetime import datetime, date
|
||||||
|
raw = "2026-02-15"
|
||||||
|
dt = datetime.fromisoformat(str(raw).replace("Z", "+00:00"))
|
||||||
|
assert dt.date() == date(2026, 2, 15)
|
||||||
|
|
||||||
|
def test_parse_return_date_with_time(self):
|
||||||
|
"""带时间的 return_date 应截取日期部分。"""
|
||||||
|
from datetime import datetime, date
|
||||||
|
raw = "2026-03-01T00:00:00Z"
|
||||||
|
dt = datetime.fromisoformat(str(raw).replace("Z", "+00:00"))
|
||||||
|
assert dt.date() == date(2026, 3, 1)
|
||||||
|
|
||||||
|
def test_parse_return_date_none(self):
|
||||||
|
"""None 或空值应返回 None。"""
|
||||||
|
return_date_raw = None
|
||||||
|
return_date = None
|
||||||
|
if return_date_raw:
|
||||||
|
return_date = "should not reach"
|
||||||
|
assert return_date is None
|
||||||
|
|
||||||
|
def test_parse_return_date_invalid(self):
|
||||||
|
"""无效日期应返回 None 而非抛异常。"""
|
||||||
|
from datetime import datetime
|
||||||
|
raw = "invalid-date"
|
||||||
|
return_date = None
|
||||||
|
try:
|
||||||
|
dt = datetime.fromisoformat(str(raw).replace("Z", "+00:00"))
|
||||||
|
return_date = dt.date()
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
pass
|
||||||
|
assert return_date is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestQueryDateComparison:
|
||||||
|
"""Fix 3: retrieved_at 比较应使用 date() 避免时区截断。"""
|
||||||
|
|
||||||
|
def test_date_comparison_handles_same_day(self):
|
||||||
|
"""核心 bug: 当天白天采到的数据应对当晚比赛可见。
|
||||||
|
|
||||||
|
retrieved_at = 2026-01-15 14:00:00+00 (timestamptz)
|
||||||
|
as_of = 2026-01-15 (date)
|
||||||
|
|
||||||
|
错误的比较: retrieved_at <= as_of
|
||||||
|
→ PostgreSQL 将 as_of 视为 2026-01-15 00:00:00+00
|
||||||
|
→ 14:00 <= 00:00 → False → 数据不可见!
|
||||||
|
|
||||||
|
正确的比较: date(retrieved_at) <= as_of
|
||||||
|
→ 2026-01-15 <= 2026-01-15 → True → 数据可见
|
||||||
|
"""
|
||||||
|
from datetime import datetime, date, timezone
|
||||||
|
|
||||||
|
retrieved_at = datetime(2026, 1, 15, 14, 0, tzinfo=timezone.utc)
|
||||||
|
as_of_date = date(2026, 1, 15)
|
||||||
|
|
||||||
|
# 错误的比较方式(原 bug)
|
||||||
|
# PostgreSQL 会将 date 转为 timestamptz at midnight
|
||||||
|
as_of_as_datetime = datetime(2026, 1, 15, 0, 0, tzinfo=timezone.utc)
|
||||||
|
wrong_result = retrieved_at <= as_of_as_datetime # False
|
||||||
|
|
||||||
|
# 正确的比较方式(修复后)
|
||||||
|
correct_result = retrieved_at.date() <= as_of_date # True
|
||||||
|
|
||||||
|
assert wrong_result is False, "原 bug 演示: 白天数据对当晚比赛不可见"
|
||||||
|
assert correct_result is True, "修复后: 白天数据对当晚比赛可见"
|
||||||
|
|
||||||
|
|
||||||
|
class TestPartialUniqueIndex:
|
||||||
|
"""Fix 4: partial unique index 防止 NULL 重复。"""
|
||||||
|
|
||||||
|
def test_orm_declares_partial_index(self):
|
||||||
|
"""ORM 模型应声明 partial unique index。"""
|
||||||
|
from sqlalchemy import and_
|
||||||
|
from src.db.models import Injury
|
||||||
|
|
||||||
|
# 验证 __table_args__ 包含 partial index
|
||||||
|
found_partial = False
|
||||||
|
for arg in Injury.__table_args__:
|
||||||
|
if hasattr(arg, "name") and arg.name == "ix_injuries_player_fixture":
|
||||||
|
# 验证是 unique 且有 postgresql_where
|
||||||
|
assert arg.unique is True, "应为唯一索引"
|
||||||
|
# postgresql_where 应排除 NULL
|
||||||
|
found_partial = True
|
||||||
|
|
||||||
|
assert found_partial, "Injury 模型应声明 ix_injuries_player_fixture 索引"
|
||||||
|
|
||||||
|
def test_migration_creates_partial_index(self):
|
||||||
|
"""迁移文件应包含 partial index 创建逻辑。"""
|
||||||
|
import os
|
||||||
|
migration_path = "/.octop/workspaces/CA7PFH/Profeto/alembic/versions/0012_injuries_partial_unique_and_return_date.py"
|
||||||
|
assert os.path.exists(migration_path), "迁移文件 0012 应存在"
|
||||||
|
|
||||||
|
with open(migration_path) as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
assert "CREATE UNIQUE INDEX ix_injuries_player_fixture" in content
|
||||||
|
assert "WHERE player_id IS NOT NULL" in content
|
||||||
|
assert "fixture_id IS NOT NULL" in content
|
||||||
|
|
||||||
|
|
||||||
|
class TestInjuriesSliceIntegration:
|
||||||
|
"""验证 injuries_slice 仍正常工作(未被破坏)。"""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_injuries_slice_with_cutoff(self):
|
||||||
|
"""injuries_slice 应正确传递 before=cutoff 到 get_injuries_for_match。"""
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
from src.llm.context_builder import injuries_slice, MatchHeader
|
||||||
|
|
||||||
|
header = MatchHeader(
|
||||||
|
match_id=999, home_name="A", away_name="B",
|
||||||
|
league_name="X", season=None, match_date="?",
|
||||||
|
match_dt=datetime(2026, 1, 15, 20, 0, tzinfo=timezone.utc),
|
||||||
|
stage=None, home_team_id=1, away_team_id=2, league_id=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
cutoff = datetime(2026, 1, 14, 20, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
import src.llm.context_builder as cb
|
||||||
|
orig = cb.get_injuries_for_match
|
||||||
|
|
||||||
|
captured_before = []
|
||||||
|
|
||||||
|
async def mock_get_injuries(db, team_id, match_date, as_of=None):
|
||||||
|
captured_before.append((team_id, match_date, as_of))
|
||||||
|
return []
|
||||||
|
|
||||||
|
cb.get_injuries_for_match = mock_get_injuries
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await injuries_slice(header, before=cutoff)
|
||||||
|
assert str(result) is not None
|
||||||
|
# 验证 before 参数被传递到 get_injuries_for_match
|
||||||
|
assert len(captured_before) == 2 # home + away
|
||||||
|
for team_id, match_date, as_of in captured_before:
|
||||||
|
# as_of 应等于 before (cutoff)
|
||||||
|
assert as_of == cutoff or (hasattr(as_of, 'date') and as_of.date() == cutoff.date()), \
|
||||||
|
f"as_of 应为 cutoff,实际 {as_of}"
|
||||||
|
finally:
|
||||||
|
cb.get_injuries_for_match = orig
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
"""回归测试: multi-agent 预测路径 backtest cutoff / provider / model 透传。
|
||||||
|
|
||||||
|
验证:
|
||||||
|
1. predict_match_multi 正确计算并传递 cutoff
|
||||||
|
2. cutoff 贯穿到所有 5 个专家切片
|
||||||
|
3. prediction_cutoff_at 记录的是真正的 cutoff,而非 match_dt
|
||||||
|
4. backtest=True 时「赛后才 available 的 xG」不会出现在切片里
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.llm.context_builder import MatchHeader
|
||||||
|
|
||||||
|
|
||||||
|
def _make_header(match_dt=None) -> MatchHeader:
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
if match_dt is None:
|
||||||
|
match_dt = datetime(2026, 1, 15, 20, 0, tzinfo=timezone.utc)
|
||||||
|
return MatchHeader(
|
||||||
|
match_id=999, home_name="利物浦", away_name="阿森纳",
|
||||||
|
league_name="英超", season="2025-2026",
|
||||||
|
match_date="2026-01-15 20:00 UTC",
|
||||||
|
match_dt=match_dt, stage=None,
|
||||||
|
home_team_id=1, away_team_id=2, league_id=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestMultiAgentCutoffPropagation:
|
||||||
|
"""验证 cutoff 在 multi-agent 路径中正确计算和传递。"""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_backtest_computes_cutoff_from_match_dt_minus_1_day(self):
|
||||||
|
"""backtest=True → cutoff = match_dt - 1 天,传给所有切片。"""
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
import src.llm.agents.orchestrator as orch
|
||||||
|
|
||||||
|
match_dt = datetime(2026, 1, 15, 20, 0, tzinfo=timezone.utc)
|
||||||
|
header = _make_header(match_dt)
|
||||||
|
|
||||||
|
captured_before = []
|
||||||
|
orig_run_specialists = orch.run_specialists
|
||||||
|
|
||||||
|
async def mock_run_specialists(header, *, version, before=None):
|
||||||
|
captured_before.append(before)
|
||||||
|
return []
|
||||||
|
|
||||||
|
orch.run_specialists = mock_run_specialists
|
||||||
|
orch.load_match_header = lambda mid, db=None: header
|
||||||
|
orch._agent_provider = lambda agent_id, **kw: MagicMock(model="test")
|
||||||
|
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
await orch.predict_match_multi(999, backtest=True)
|
||||||
|
except Exception:
|
||||||
|
pass # 后续 aggregator 调用会因 mock 不全而失败,不影响 cutoff 测试
|
||||||
|
|
||||||
|
assert len(captured_before) == 1
|
||||||
|
expected_cutoff = match_dt - timedelta(days=1)
|
||||||
|
assert captured_before[0] == expected_cutoff, (
|
||||||
|
f"backtest cutoff 应为 {expected_cutoff},实际 {captured_before[0]}"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
orch.run_specialists = orig_run_specialists
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_explicit_cutoff_at_overrides_backtest(self):
|
||||||
|
"""显式 cutoff_at 优先于 backtest 自动计算。"""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
import src.llm.agents.orchestrator as orch
|
||||||
|
|
||||||
|
match_dt = datetime(2026, 1, 15, 20, 0, tzinfo=timezone.utc)
|
||||||
|
explicit_cutoff = datetime(2026, 1, 10, 12, 0, tzinfo=timezone.utc)
|
||||||
|
header = _make_header(match_dt)
|
||||||
|
|
||||||
|
captured_before = []
|
||||||
|
orig_run_specialists = orch.run_specialists
|
||||||
|
|
||||||
|
async def mock_run_specialists(header, *, version, before=None):
|
||||||
|
captured_before.append(before)
|
||||||
|
return []
|
||||||
|
|
||||||
|
orch.run_specialists = mock_run_specialists
|
||||||
|
orch.load_match_header = lambda mid, db=None: header
|
||||||
|
orch._agent_provider = lambda agent_id, **kw: MagicMock(model="test")
|
||||||
|
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
await orch.predict_match_multi(999, backtest=True, cutoff_at=explicit_cutoff)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
assert captured_before[0] == explicit_cutoff
|
||||||
|
finally:
|
||||||
|
orch.run_specialists = orig_run_specialists
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_normal_mode_cutoff_is_match_dt(self):
|
||||||
|
"""非回测模式,无显式 cutoff → cutoff = match_dt。"""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
import src.llm.agents.orchestrator as orch
|
||||||
|
|
||||||
|
match_dt = datetime(2026, 1, 15, 20, 0, tzinfo=timezone.utc)
|
||||||
|
header = _make_header(match_dt)
|
||||||
|
|
||||||
|
captured_before = []
|
||||||
|
orig_run_specialists = orch.run_specialists
|
||||||
|
|
||||||
|
async def mock_run_specialists(header, *, version, before=None):
|
||||||
|
captured_before.append(before)
|
||||||
|
return []
|
||||||
|
|
||||||
|
orch.run_specialists = mock_run_specialists
|
||||||
|
orch.load_match_header = lambda mid, db=None: header
|
||||||
|
orch._agent_provider = lambda agent_id, **kw: MagicMock(model="test")
|
||||||
|
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
await orch.predict_match_multi(999, backtest=False)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
assert captured_before[0] == match_dt
|
||||||
|
finally:
|
||||||
|
orch.run_specialists = orig_run_specialists
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_prediction_cutoff_at_stored_not_match_dt(self):
|
||||||
|
"""Prediction 写入时 prediction_cutoff_at = 真正 cutoff,非 match_dt。"""
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from src.llm.predict import _predict_single, PredictResult
|
||||||
|
|
||||||
|
match_dt = datetime(2026, 1, 15, 20, 0, tzinfo=timezone.utc)
|
||||||
|
expected_cutoff = match_dt - timedelta(days=1)
|
||||||
|
|
||||||
|
# Mock build_context to return a context with cutoff
|
||||||
|
import src.llm.predict as pred
|
||||||
|
orig_build = pred.build_context
|
||||||
|
|
||||||
|
class FakeContext:
|
||||||
|
text = "fake"
|
||||||
|
match_dt = match_dt
|
||||||
|
cutoff = expected_cutoff
|
||||||
|
|
||||||
|
async def fake_build(match_id, **kw):
|
||||||
|
return FakeContext()
|
||||||
|
|
||||||
|
pred.build_context = fake_build
|
||||||
|
pred._upsert_prediction = lambda session, **kw: MagicMock(id=1, **kw.get("values", {}))
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 此处只验证 cutoff 参数传递,实际 LLM 调用会被 mock 阻断
|
||||||
|
# 重点: build_context 被调用时传入 backtest=True 和正确的 cutoff
|
||||||
|
call_args = {}
|
||||||
|
async def tracking_build(match_id, **kw):
|
||||||
|
call_args.update(kw)
|
||||||
|
return FakeContext()
|
||||||
|
pred.build_context = tracking_build
|
||||||
|
|
||||||
|
try:
|
||||||
|
await _predict_single(999, backtest=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
assert call_args.get("backtest") is True, "backtest=True 应传递给 build_context"
|
||||||
|
finally:
|
||||||
|
pred.build_context = orig_build
|
||||||
|
|
||||||
|
|
||||||
|
class TestBacktestXgNotVisible:
|
||||||
|
"""P0-3 延伸:回测时赛后才 available 的统计数据不应出现在切片。"""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stats_slice_respects_cutoff_for_xg_availability(self):
|
||||||
|
"""available_at > cutoff 的 xG 数据不应被切片使用。"""
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
cutoff = datetime(2026, 1, 13, 20, 0, tzinfo=timezone.utc) # match_date - 2天
|
||||||
|
match_dt = datetime(2026, 1, 15, 20, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
# 创建一场历史比赛,其 xG 在 match_date 之后才 available
|
||||||
|
hist_match = MagicMock()
|
||||||
|
hist_match.id = 500
|
||||||
|
hist_match.home_team_id = 1 # 利物浦主场
|
||||||
|
hist_match.away_team_id = 3
|
||||||
|
hist_match.home_goals = 2
|
||||||
|
hist_match.away_goals = 0
|
||||||
|
hist_match.home_team = MagicMock(id=1, name="利物浦", name_zh=None)
|
||||||
|
hist_match.away_team = MagicMock(id=3, name="诺维奇", name_zh=None)
|
||||||
|
|
||||||
|
# xG: available_at 在比赛日之后(1月16日),cutoff(1月13日)看不到
|
||||||
|
stats = MagicMock()
|
||||||
|
stats.home_xg = 2.5
|
||||||
|
stats.away_xg = 0.3
|
||||||
|
stats.home_shots = 15
|
||||||
|
stats.away_shots = 4
|
||||||
|
stats.home_shots_on_target = 6
|
||||||
|
stats.away_shots_on_target = 1
|
||||||
|
stats.home_possession = 65.0
|
||||||
|
stats.available_at = datetime(2026, 1, 16, 10, 0, tzinfo=timezone.utc) # 赛后才有
|
||||||
|
hist_match.stats = stats
|
||||||
|
|
||||||
|
header = _make_header(match_dt)
|
||||||
|
|
||||||
|
import src.llm.context_builder as cb
|
||||||
|
orig_get_form = cb._get_form
|
||||||
|
|
||||||
|
async def mock_get_form(db, team_id, before, *, limit=10):
|
||||||
|
# before=cutoff(1月13日),比赛在1月15日,满足 before 条件
|
||||||
|
if before is not None and before < match_dt:
|
||||||
|
return [hist_match]
|
||||||
|
return []
|
||||||
|
|
||||||
|
cb._get_form = mock_get_form
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await cb.stats_slice(header, limit=10, before=cutoff)
|
||||||
|
text = str(result)
|
||||||
|
# xG 在 cutoff 之后才 available,不应出现在切片
|
||||||
|
assert "2.50" not in text, f"xG 2.50 不应在切片中(available_at > cutoff):\n{text}"
|
||||||
|
# 但无比分时仍应显示进球数据
|
||||||
|
assert "无比分数据" in text or "场均进球" in text, f"无比分时仍应显示基本数据:\n{text}"
|
||||||
|
finally:
|
||||||
|
cb._get_form = orig_get_form
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
"""回归测试: P0-1 — form_slice / stats_slice 主客身份反转。
|
||||||
|
|
||||||
|
用 mock Match 对象验证:当某队在历史比赛中是「客队」时,
|
||||||
|
form_slice 必须正确识别该队当时是客场,赛果应为 L(输),
|
||||||
|
对手名字和进球数不能反转。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.llm.context_builder import MatchHeader, SliceResult, form_slice, stats_slice
|
||||||
|
|
||||||
|
|
||||||
|
def _make_team(team_id: int, name: str) -> MagicMock:
|
||||||
|
t = MagicMock()
|
||||||
|
t.id = team_id
|
||||||
|
t.name = name
|
||||||
|
t.name_zh = None
|
||||||
|
return t
|
||||||
|
|
||||||
|
|
||||||
|
def _make_stats(
|
||||||
|
home_xg=1.5,
|
||||||
|
away_xg=1.0,
|
||||||
|
home_shots=12,
|
||||||
|
away_shots=8,
|
||||||
|
home_sot=4,
|
||||||
|
away_sot=3,
|
||||||
|
home_poss=55.0,
|
||||||
|
available_at=None,
|
||||||
|
) -> MagicMock:
|
||||||
|
s = MagicMock()
|
||||||
|
s.home_xg = home_xg
|
||||||
|
s.away_xg = away_xg
|
||||||
|
s.home_shots = home_shots
|
||||||
|
s.away_shots = away_shots
|
||||||
|
s.home_shots_on_target = home_sot
|
||||||
|
s.away_shots_on_target = away_sot
|
||||||
|
s.home_possession = home_poss
|
||||||
|
s.available_at = available_at
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def _make_match(
|
||||||
|
match_id: int,
|
||||||
|
home_team_id: int,
|
||||||
|
away_team_id: int,
|
||||||
|
home_goals: int,
|
||||||
|
away_goals: int,
|
||||||
|
home_name: str = "H",
|
||||||
|
away_name: str = "A",
|
||||||
|
stats=None,
|
||||||
|
) -> MagicMock:
|
||||||
|
m = MagicMock()
|
||||||
|
m.id = match_id
|
||||||
|
m.home_team_id = home_team_id
|
||||||
|
m.away_team_id = away_team_id
|
||||||
|
m.home_goals = home_goals
|
||||||
|
m.away_goals = away_goals
|
||||||
|
m.stats = stats
|
||||||
|
m.home_team = _make_team(home_team_id, home_name)
|
||||||
|
m.away_team = _make_team(away_team_id, away_name)
|
||||||
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
def _make_header(home_id=1, away_id=2, home_name="利物浦", away_name="阿森纳") -> MatchHeader:
|
||||||
|
return MatchHeader(
|
||||||
|
match_id=999,
|
||||||
|
home_name=home_name,
|
||||||
|
away_name=away_name,
|
||||||
|
league_name="英超",
|
||||||
|
season="2025-2026",
|
||||||
|
match_date="2026-01-15 20:00 UTC",
|
||||||
|
match_dt=None,
|
||||||
|
stage=None,
|
||||||
|
home_team_id=home_id,
|
||||||
|
away_team_id=away_id,
|
||||||
|
league_id=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestFormSliceHomeAwayIdentity:
|
||||||
|
"""P0-1: form_slice 必须根据每场历史比赛的真实主客来判断赛果。"""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_home_team_away_loss_shows_L(self):
|
||||||
|
"""
|
||||||
|
场景: 本场利物浦是主队(home_id=1),历史上一场它作为客队 1-3 输给曼城。
|
||||||
|
正确输出: L 3-1 vs 曼城 (赛果为输,对手为曼城)
|
||||||
|
原bug: W 3-1 vs 曼城 (把客场输球算成主场赢球)
|
||||||
|
"""
|
||||||
|
header = _make_header(home_id=1, away_id=2, home_name="利物浦", away_name="阿森纳")
|
||||||
|
hist_match = _make_match(
|
||||||
|
match_id=100,
|
||||||
|
home_team_id=5, # 曼城主场
|
||||||
|
away_team_id=1, # 利物浦客场
|
||||||
|
home_goals=3,
|
||||||
|
away_goals=1,
|
||||||
|
home_name="曼城",
|
||||||
|
away_name="利物浦",
|
||||||
|
)
|
||||||
|
import src.llm.context_builder as cb
|
||||||
|
orig_get_form = cb._get_form
|
||||||
|
async def mock_get_form(db, team_id, before, *, limit):
|
||||||
|
return [hist_match] if team_id == 1 else []
|
||||||
|
cb._get_form = mock_get_form
|
||||||
|
try:
|
||||||
|
result = await form_slice(header, limit=5, before=None, db=MagicMock())
|
||||||
|
finally:
|
||||||
|
cb._get_form = orig_get_form
|
||||||
|
|
||||||
|
text = str(result)
|
||||||
|
assert "L 3-1 vs 曼城" in text, f"期望「L 3-1 vs 曼城」,实际输出:\n{text}"
|
||||||
|
assert "W 3-1" not in text, f"不应出现 W 3-1(客场输球不能算主场赢):\n{text}"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_away_team_home_win_shows_W_for_that_team(self):
|
||||||
|
"""
|
||||||
|
场景: 本场阿森纳是客队(away_id=2),历史上一场它作为主队 2-0 赢了切尔西。
|
||||||
|
从阿森纳视角: is_home=True → W 2-0 vs 切尔西。
|
||||||
|
原bug: side 固定为 "away" → _outcome(2,0,"away") = L → 输出 L 2-0 vs 切尔西(反转!)
|
||||||
|
"""
|
||||||
|
header = _make_header(home_id=1, away_id=2, home_name="利物浦", away_name="阿森纳")
|
||||||
|
hist_match = _make_match(
|
||||||
|
match_id=101,
|
||||||
|
home_team_id=2, # 阿森纳主场
|
||||||
|
away_team_id=4, # 切尔西客场
|
||||||
|
home_goals=2,
|
||||||
|
away_goals=0,
|
||||||
|
home_name="阿森纳",
|
||||||
|
away_name="切尔西",
|
||||||
|
)
|
||||||
|
import src.llm.context_builder as cb
|
||||||
|
orig_get_form = cb._get_form
|
||||||
|
async def mock_get_form(db, team_id, before, *, limit):
|
||||||
|
return [hist_match] if team_id == 2 else []
|
||||||
|
cb._get_form = mock_get_form
|
||||||
|
try:
|
||||||
|
result = await form_slice(header, limit=5, before=None, db=MagicMock())
|
||||||
|
finally:
|
||||||
|
cb._get_form = orig_get_form
|
||||||
|
|
||||||
|
text = str(result)
|
||||||
|
assert "W 2-0 vs 切尔西" in text, f"期望「W 2-0 vs 切尔西」,实际输出:\n{text}"
|
||||||
|
assert "L 2-0 vs 切尔西" not in text, f"不应出现 L 2-0(主场赢球不能算客场输):\n{text}"
|
||||||
|
|
||||||
|
|
||||||
|
class TestStatsSliceHomeAwayIdentity:
|
||||||
|
"""P0-1: stats_slice 进球/失球/xG 必须按历史比赛真实主客取值。"""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_home_team_away_match_goals_not_swapped(self):
|
||||||
|
"""
|
||||||
|
场景: 本场利物浦是主队,历史上一场它作为客队 1-3 输给曼城(xG 0.8 vs 2.5)。
|
||||||
|
从利物浦视角: 进球=1(away_goals), 失球=3(home_goals), xG=0.8(away_xg)。
|
||||||
|
原bug: side="home" → 进球=3, 失球=1, xG=2.5 —— 全部反了!
|
||||||
|
"""
|
||||||
|
header = _make_header(home_id=1, away_id=2, home_name="利物浦", away_name="阿森纳")
|
||||||
|
hist_match = _make_match(
|
||||||
|
match_id=200,
|
||||||
|
home_team_id=5, # 曼城主场
|
||||||
|
away_team_id=1, # 利物浦客场
|
||||||
|
home_goals=3,
|
||||||
|
away_goals=1,
|
||||||
|
home_name="曼城",
|
||||||
|
away_name="利物浦",
|
||||||
|
stats=_make_stats(home_xg=2.5, away_xg=0.8, home_shots=15, away_shots=5,
|
||||||
|
home_sot=6, away_sot=2, home_poss=60.0),
|
||||||
|
)
|
||||||
|
import src.llm.context_builder as cb
|
||||||
|
orig_get_form = cb._get_form
|
||||||
|
async def mock_get_form(db, team_id, before, *, limit):
|
||||||
|
return [hist_match] if team_id == 1 else []
|
||||||
|
cb._get_form = mock_get_form
|
||||||
|
try:
|
||||||
|
result = await stats_slice(header, limit=10, before=None, db=MagicMock())
|
||||||
|
finally:
|
||||||
|
cb._get_form = orig_get_form
|
||||||
|
|
||||||
|
text = str(result)
|
||||||
|
# 利物浦客场 1-3 输: 进球 1, 失球 3
|
||||||
|
assert "场均进球 1.00" in text, f"期望场均进球 1.00,实际输出:\n{text}"
|
||||||
|
assert "场均失球 3.00" in text, f"期望场均失球 3.00,实际输出:\n{text}"
|
||||||
|
# 原bug: 进球 3, 失球 1 (反了)
|
||||||
|
assert "场均进球 3.00" not in text, f"不应出现场均进球 3.00(反转):\n{text}"
|
||||||
|
# xG: 利物浦 away_xg=0.8
|
||||||
|
assert "场均 xG 0.80" in text, f"期望场均 xG 0.80,实际输出:\n{text}"
|
||||||
|
# shots: 利物浦 away_shots=5
|
||||||
|
assert "场均射门 5.0" in text, f"期望场均射门 5.0,实际输出:\n{text}"
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
"""回归测试: P0-3 — LLM 解析失败不能产生假成功预测。
|
||||||
|
|
||||||
|
验证链路:
|
||||||
|
1. provider.py: JSON 解析失败时必须设置 error
|
||||||
|
2. predict.py: resp.parsed is None 时必须抛错,不能 fallback 到 {}
|
||||||
|
3. validation.py: 必填字段缺失时必须失败,不能静默给默认值
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.llm.provider import LLMProvider, LLMResponse
|
||||||
|
from src.llm.validation import validate_prediction_output
|
||||||
|
|
||||||
|
|
||||||
|
class TestProviderJsonParseError:
|
||||||
|
"""P0-3 Part 1: provider.py JSON 解析失败必须设置 error。"""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_invalid_json_sets_error(self, monkeypatch):
|
||||||
|
"""LLM 返回非 JSON 内容时,error 必须非空。"""
|
||||||
|
async def fake_post(*args, **kwargs):
|
||||||
|
class FakeResp:
|
||||||
|
status_code = 200
|
||||||
|
def raise_for_status(self): pass
|
||||||
|
def json(self):
|
||||||
|
return {
|
||||||
|
"choices": [{"message": {"content": "我不确定,可能是平局"}}],
|
||||||
|
"usage": {"prompt_tokens": 10, "completion_tokens": 5},
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
# P0-3: JSON 解析失败必须设置 error
|
||||||
|
assert resp.error is not None, "JSON 解析失败应设置 error"
|
||||||
|
assert resp.parsed is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_code_block_json_works(self, monkeypatch):
|
||||||
|
"""LLM 返回 ```json {...}}``` 时应成功解析。"""
|
||||||
|
async def fake_post(*args, **kwargs):
|
||||||
|
class FakeResp:
|
||||||
|
status_code = 200
|
||||||
|
def raise_for_status(self): pass
|
||||||
|
def json(self):
|
||||||
|
return {
|
||||||
|
"choices": [{"message": {"content": '```json\n{"pred_home_goals": 1.5, "pred_away_goals": 1.0, "pred_1x2": "1", "subjective_confidence": 0.7}\n```'}}],
|
||||||
|
"usage": {"prompt_tokens": 10, "completion_tokens": 5},
|
||||||
|
}
|
||||||
|
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_1x2"] == "1"
|
||||||
|
|
||||||
|
|
||||||
|
class TestValidationNoSilentDefaults:
|
||||||
|
"""P0-3 Part 3: validation.py 必填字段缺失时必须失败。"""
|
||||||
|
|
||||||
|
def test_missing_pred_home_goals_raises(self):
|
||||||
|
"""缺少 pred_home_goals 必须报错,不能默认为 0。"""
|
||||||
|
with pytest.raises((ValueError, KeyError)):
|
||||||
|
validate_prediction_output({
|
||||||
|
"pred_away_goals": 1,
|
||||||
|
"pred_1x2": "1",
|
||||||
|
"subjective_confidence": 0.7,
|
||||||
|
})
|
||||||
|
|
||||||
|
def test_missing_pred_1x2_raises(self):
|
||||||
|
"""缺少 pred_1x2 必须报错,不能默认为 X。"""
|
||||||
|
with pytest.raises(ValueError, match="Missing required field: pred_1x2"):
|
||||||
|
validate_prediction_output({
|
||||||
|
"pred_home_goals": 1,
|
||||||
|
"pred_away_goals": 0,
|
||||||
|
"subjective_confidence": 0.7,
|
||||||
|
})
|
||||||
|
|
||||||
|
def test_missing_confidence_raises(self):
|
||||||
|
"""缺少 subjective_confidence 必须报错,不能默认为 0.5。"""
|
||||||
|
with pytest.raises(ValueError, match="Missing required field: subjective_confidence"):
|
||||||
|
validate_prediction_output({
|
||||||
|
"pred_home_goals": 1,
|
||||||
|
"pred_away_goals": 0,
|
||||||
|
"pred_1x2": "1",
|
||||||
|
})
|
||||||
|
|
||||||
|
def test_empty_dict_raises(self):
|
||||||
|
"""空 dict 必须报错(不能产生 0-0 X 0.5 的假预测)。"""
|
||||||
|
with pytest.raises((ValueError, KeyError)):
|
||||||
|
validate_prediction_output({})
|
||||||
|
|
||||||
|
def test_valid_input_passes(self):
|
||||||
|
"""完整的合法输入应通过。"""
|
||||||
|
result = validate_prediction_output({
|
||||||
|
"pred_home_goals": 1.5,
|
||||||
|
"pred_away_goals": 1.0,
|
||||||
|
"pred_1x2": "1",
|
||||||
|
"subjective_confidence": 0.7,
|
||||||
|
})
|
||||||
|
assert result.pred_home_goals == 2 # 1.5 → round → 2
|
||||||
|
assert result.pred_away_goals == 1
|
||||||
|
assert result.pred_1x2 == "1"
|
||||||
|
assert result.subjective_confidence == 0.7
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"""回归测试: /api/v1/predict 限流 + 短 session 模式。
|
||||||
|
|
||||||
|
验证:
|
||||||
|
1. 限流: 同 IP 超过 10 次/分钟返回 429
|
||||||
|
2. 限流: 不同 IP 独立计数
|
||||||
|
3. 限流: 滑动窗口过期后恢复
|
||||||
|
4. 短 session: predict 路由不持有 DB 连接 during LLM call
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.api.deps import _RateLimiter, rate_limit_predict
|
||||||
|
|
||||||
|
|
||||||
|
class TestRateLimiter:
|
||||||
|
"""_RateLimiter 滑动窗口限流。"""
|
||||||
|
|
||||||
|
def test_allows_within_limit(self):
|
||||||
|
limiter = _RateLimiter(max_requests=10, window_seconds=60)
|
||||||
|
for _ in range(10):
|
||||||
|
assert limiter.is_allowed("192.168.1.1")
|
||||||
|
|
||||||
|
def test_blocks_over_limit(self):
|
||||||
|
limiter = _RateLimiter(max_requests=3, window_seconds=60)
|
||||||
|
assert limiter.is_allowed("10.0.0.1") # 1
|
||||||
|
assert limiter.is_allowed("10.0.0.1") # 2
|
||||||
|
assert limiter.is_allowed("10.0.0.1") # 3
|
||||||
|
assert not limiter.is_allowed("10.0.0.1") # 4 → blocked
|
||||||
|
|
||||||
|
def test_different_keys_independent(self):
|
||||||
|
"""不同 IP 的限流计数独立。"""
|
||||||
|
limiter = _RateLimiter(max_requests=2, window_seconds=60)
|
||||||
|
assert limiter.is_allowed("10.0.0.1")
|
||||||
|
assert limiter.is_allowed("10.0.0.1")
|
||||||
|
assert not limiter.is_allowed("10.0.0.1") # blocked
|
||||||
|
|
||||||
|
# 不同 IP 仍允许
|
||||||
|
assert limiter.is_allowed("10.0.0.2")
|
||||||
|
assert limiter.is_allowed("10.0.0.2")
|
||||||
|
assert not limiter.is_allowed("10.0.0.2") # blocked
|
||||||
|
|
||||||
|
def test_sliding_window_expires(self):
|
||||||
|
"""滑动窗口:过期后恢复。"""
|
||||||
|
limiter = _RateLimiter(max_requests=2, window_seconds=1)
|
||||||
|
assert limiter.is_allowed("10.0.0.1")
|
||||||
|
assert limiter.is_allowed("10.0.0.1")
|
||||||
|
assert not limiter.is_allowed("10.0.0.1") # blocked
|
||||||
|
|
||||||
|
# 等待窗口过期
|
||||||
|
time.sleep(1.1)
|
||||||
|
assert limiter.is_allowed("10.0.0.1") # 窗口过期,恢复
|
||||||
|
|
||||||
|
def test_cleans_expired_entries(self):
|
||||||
|
"""验证过期条目被清理(不会无限增长)。"""
|
||||||
|
limiter = _RateLimiter(max_requests=100, window_seconds=1)
|
||||||
|
for _ in range(50):
|
||||||
|
limiter.is_allowed("10.0.0.1")
|
||||||
|
# 验证内部状态
|
||||||
|
assert len(limiter._hits.get("10.0.0.1", [])) == 50
|
||||||
|
|
||||||
|
time.sleep(1.1)
|
||||||
|
# 触发清理
|
||||||
|
limiter.is_allowed("10.0.0.1")
|
||||||
|
# 过期条目应被清除,只剩新加入的 1 条
|
||||||
|
assert len(limiter._hits.get("10.0.0.1", [])) == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestShortReadSession:
|
||||||
|
"""short_read 上下文管理器。"""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_short_read_context_manager(self):
|
||||||
|
"""short_read 应作为 async context manager 工作。"""
|
||||||
|
from src.db.base import short_read
|
||||||
|
import inspect
|
||||||
|
# 验证是 async context manager (通过 inspect 检查)
|
||||||
|
assert inspect.isasyncgenfunction(short_read) or hasattr(short_read, "__wrapped__")
|
||||||
|
# 验证可以调用并返回 context manager
|
||||||
|
ctx = short_read()
|
||||||
|
assert hasattr(ctx, "__aenter__")
|
||||||
|
assert hasattr(ctx, "__aexit__")
|
||||||
|
|
||||||
|
|
||||||
|
class TestDepsImports:
|
||||||
|
"""验证新依赖可正确导入。"""
|
||||||
|
|
||||||
|
def test_rate_limit_predict_importable(self):
|
||||||
|
from src.api.deps import rate_limit_predict
|
||||||
|
assert callable(rate_limit_predict)
|
||||||
|
|
||||||
|
def test_rate_limiter_importable(self):
|
||||||
|
from src.api.deps import _RateLimiter, _predict_limiter
|
||||||
|
assert isinstance(_predict_limiter, _RateLimiter)
|
||||||
|
assert _predict_limiter.max_requests == 10
|
||||||
|
assert _predict_limiter.window_seconds == 60
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
"""回归测试: 预测唯一约束修复 —— live 与 backtest 可共存。
|
||||||
|
|
||||||
|
验证:
|
||||||
|
1. 唯一约束包含 mode + run_type
|
||||||
|
2. 同一场比赛 live 与 backtest 预测可共存,互不覆盖
|
||||||
|
3. _upsert_prediction 正确区分 run_type
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.db.models import Prediction, UniqueConstraint, CheckConstraint
|
||||||
|
|
||||||
|
|
||||||
|
class TestUniqueConstraint:
|
||||||
|
"""验证唯一约束包含 mode + run_type。"""
|
||||||
|
|
||||||
|
def test_constraint_columns(self):
|
||||||
|
"""唯一约束应包含 match_id, provider, model, mode, run_type。"""
|
||||||
|
uc = [
|
||||||
|
c for c in Prediction.__table__.constraints
|
||||||
|
if isinstance(c, UniqueConstraint) and "match" in c.name
|
||||||
|
]
|
||||||
|
assert len(uc) == 1
|
||||||
|
cols = [c.name for c in uc[0].columns]
|
||||||
|
assert cols == ["match_id", "provider", "model", "mode", "run_type"]
|
||||||
|
|
||||||
|
def test_run_type_check_constraint(self):
|
||||||
|
"""应有 run_type 的 check constraint。"""
|
||||||
|
cc = [
|
||||||
|
c for c in Prediction.__table__.constraints
|
||||||
|
if isinstance(c, CheckConstraint) and "run_type" in c.name
|
||||||
|
]
|
||||||
|
assert len(cc) == 1
|
||||||
|
|
||||||
|
def test_run_type_column_exists(self):
|
||||||
|
"""run_type 列应存在且 NOT NULL,默认 'live'。"""
|
||||||
|
cols = {c.name: c for c in Prediction.__table__.columns}
|
||||||
|
assert "run_type" in cols
|
||||||
|
assert cols["run_type"].nullable is False
|
||||||
|
# 默认值
|
||||||
|
assert cols["run_type"].default.arg == "live" if cols["run_type"].default else True
|
||||||
|
|
||||||
|
|
||||||
|
class TestUpsertPredictionSignature:
|
||||||
|
"""验证 _upsert_prediction 函数签名包含 run_type。"""
|
||||||
|
|
||||||
|
def test_signature_has_run_type(self):
|
||||||
|
from src.llm.predict import _upsert_prediction
|
||||||
|
|
||||||
|
sig = inspect.signature(_upsert_prediction)
|
||||||
|
assert "run_type" in sig.parameters
|
||||||
|
|
||||||
|
def test_signature_has_backtest_in_predict_match(self):
|
||||||
|
from src.llm.predict import predict_match
|
||||||
|
|
||||||
|
sig = inspect.signature(predict_match)
|
||||||
|
assert "backtest" in sig.parameters
|
||||||
|
|
||||||
|
def test_signature_has_backtest_in_predict_multi(self):
|
||||||
|
from src.llm.agents.orchestrator import predict_match_multi
|
||||||
|
|
||||||
|
sig = inspect.signature(predict_match_multi)
|
||||||
|
assert "backtest" in sig.parameters
|
||||||
|
|
||||||
|
|
||||||
|
class TestMigration:
|
||||||
|
"""验证迁移文件存在且内容正确。"""
|
||||||
|
|
||||||
|
def test_migration_exists(self):
|
||||||
|
import os
|
||||||
|
|
||||||
|
path = "/.octop/workspaces/CA7PFH/Profeto/alembic/versions/0013_predictions_unique_constraint_mode_run_type.py"
|
||||||
|
assert os.path.exists(path)
|
||||||
|
|
||||||
|
def test_migration_adds_column_and_constraint(self):
|
||||||
|
path = "/.octop/workspaces/CA7PFH/Profeto/alembic/versions/0013_predictions_unique_constraint_mode_run_type.py"
|
||||||
|
content = open(path).read()
|
||||||
|
|
||||||
|
assert 'run_type' in content
|
||||||
|
assert 'uq_predictions_match_provider_model_mode_run_type' in content
|
||||||
|
assert 'backtest' in content
|
||||||
|
assert 'live' in content
|
||||||
|
# 验证数据回填逻辑
|
||||||
|
assert "UPDATE predictions SET run_type = 'live'" in content
|
||||||
|
|
||||||
|
|
||||||
|
class TestLiveBacktestCoexist:
|
||||||
|
"""验证 live 与 backtest 可共存(逻辑验证,无需数据库)。"""
|
||||||
|
|
||||||
|
def test_different_run_type_allow_coexistence(self):
|
||||||
|
"""同一 match_id + provider + model + mode,不同 run_type 应可共存。
|
||||||
|
|
||||||
|
这是核心修复:之前唯一约束只有 (match_id, provider, model),
|
||||||
|
backtest 会覆盖 live 预测。
|
||||||
|
"""
|
||||||
|
# 模拟两行数据
|
||||||
|
class FakeRow:
|
||||||
|
def __init__(self, **kw):
|
||||||
|
for k, v in kw.items():
|
||||||
|
setattr(self, k, v)
|
||||||
|
|
||||||
|
live = FakeRow(match_id=1, provider="openai", model="gpt-4o", mode="single", run_type="live")
|
||||||
|
backtest = FakeRow(match_id=1, provider="openai", model="gpt-4o", mode="single", run_type="backtest")
|
||||||
|
|
||||||
|
# 两者唯一键不同(因为 run_type 不同)
|
||||||
|
live_key = (live.match_id, live.provider, live.model, live.mode, live.run_type)
|
||||||
|
backtest_key = (backtest.match_id, backtest.provider, backtest.model, backtest.mode, backtest.run_type)
|
||||||
|
|
||||||
|
assert live_key != backtest_key, "live 与 backtest 应有不同的唯一键"
|
||||||
|
assert live_key == (1, "openai", "gpt-4o", "single", "live")
|
||||||
|
assert backtest_key == (1, "openai", "gpt-4o", "single", "backtest")
|
||||||
|
|
||||||
|
def test_same_run_type_prevents_duplicate(self):
|
||||||
|
"""相同 run_type 的重复预测仍应被约束阻止。"""
|
||||||
|
key1 = (1, "openai", "gpt-4o", "single", "live")
|
||||||
|
key2 = (1, "openai", "gpt-4o", "single", "live")
|
||||||
|
assert key1 == key2, "相同 run_type 应有相同唯一键,应被约束阻止"
|
||||||
Reference in New Issue
Block a user