test+docs: P0 回归测试与文档同步

回归测试(P0 三项,不依赖 DB 的静态断言):
- tests/test_regressions.py:
  · context_builder 三个查询函数 + backtest 必须 eager-load 关系
  · Match.{stats,home_team,away_team,league} 必须 lazy="selectin"
  · bzzoiro normalized_matches 必须携带并解包 (nm, raw) 配对
  这些是「集成方式」缺陷,原先的单元测试(全 mock slice_fn/provider)
  照不出来,故此专项静态锁定。

文档同步(修正与实际不符的表述):
- docs/01-architecture.md: app.py 标注改为「lifespan 仅验证连接,不建表」;
  补 deps.py / repositories.py / unit_of_work.py / validation.py /
  backtest.py / sources.py / http_client.py / retry.py;迁移范围改 0001~0006
- docs/07-development.md: 目录树修正 leagues.py→matches.py,补齐
  backtest.py / deps.py 及 0003~0006 迁移文件

附带: src/core/retry.py 加 NOTE 说明其当前无调用点(P3),
避免「看似有重试实则未生效」的误判。
This commit is contained in:
WorkBuddy
2026-09-15 16:55:54 +08:00
parent c89bfe2af7
commit f965650c10
4 changed files with 143 additions and 14 deletions
+18 -8
View File
@@ -57,38 +57,48 @@
Profeto/ Profeto/
├── src/ ├── src/
│ ├── api/ │ ├── api/
│ │ ├── app.py # FastAPI 工厂(lifespan 建表) │ │ ├── app.py # FastAPI 工厂(lifespan 仅验证 DB 连接,不建表)
│ │ ├── deps.py # 依赖:管理接口鉴权(X-API-Key)
│ │ ├── schemas.py # Pydantic v2 请求/响应 │ │ ├── schemas.py # Pydantic v2 请求/响应
│ │ └── routes/ │ │ └── routes/
│ │ ├── matches.py # 联赛/比赛查询(游标分页) │ │ ├── matches.py # 联赛/比赛查询(游标分页)
│ │ ├── predict.py # 预测 + 预测历史 │ │ ├── predict.py # 预测 + 预测历史
│ │ ├── ingest.py # 采集触发(自管 session) │ │ ├── ingest.py # 采集触发(自管 session,需鉴权)
│ │ ── eval.py # 赛后回填 + 准确率汇总 │ │ ── eval.py # 赛后回填 + 准确率汇总
│ │ └── backtest.py # 历史回测(需鉴权)
│ ├── db/ │ ├── db/
│ │ ├── base.py # async engine + get_db/get_db_read │ │ ├── base.py # async engine + get_db/get_db_read
│ │ ── models.py # 6 张表 ORM │ │ ── models.py # 6 张表 ORM
│ │ ├── repositories.py # 仓储层
│ │ └── unit_of_work.py # 事务边界
│ ├── data/ │ ├── data/
│ │ ├── bzzoiro.py # 赛果采集 + 幂等入库 │ │ ├── bzzoiro.py # 赛果采集 + 幂等入库
│ │ ├── understat.py # xG 回填 │ │ ├── understat.py # xG 回填
│ │ ├── injuries.py # 伤停采集(带文件缓存) │ │ ├── injuries.py # 伤停采集(带文件缓存)
│ │ ├── normalize.py # NormalizedMatch 清洗契约 │ │ ├── normalize.py # NormalizedMatch 清洗契约
│ │ ├── team_names.py # 队名归一映射 │ │ ├── team_names.py # 队名归一映射
│ │ ├── sources.py # 数据源注册表
│ │ └── config.py # 联赛代码映射 │ │ └── config.py # 联赛代码映射
│ ├── llm/ │ ├── llm/
│ │ ├── provider.py # OpenAI-compatible 抽象(共享连接池/JSON 兜底解析) │ │ ├── provider.py # OpenAI-compatible 抽象(共享连接池/JSON 兜底解析)
│ │ ├── context_builder.py # 数据切片(h2h/form/stats/home_away/injuries)+ 单 agent 拼接 │ │ ├── context_builder.py # 数据切片(h2h/form/stats/home_away/injuries)+ 单 agent 拼接
│ │ ├── predict.py # 预测入口(mode 分派 + 缓存) │ │ ├── predict.py # 预测入口(mode 分派 + 缓存)
│ │ ├── eval.py # 准确率统计 │ │ ├── eval.py # 准确率统计
│ │ ├── validation.py # LLM 输出严格校验(Pydantic)
│ │ ├── backtest.py # 回测执行
│ │ ├── agents/ │ │ ├── agents/
│ │ │ ├── base.py # AgentSpec / AgentReport / run_agent │ │ │ ├── base.py # AgentSpec / AgentReport / run_agent
│ │ │ └── orchestrator.py # 并行专家 → 终裁 → 存库 │ │ │ └── orchestrator.py # 并行专家 → 终裁 → 存库
│ │ └── prompts/ │ │ └── prompts/
│ │ ├── match_prediction_v1/v2.md # 单 agent 模板 │ │ ├── match_prediction_v1/v2.md # 单 agent 模板
│ │ └── agents/{h2h,form,stats,home_away,injuries,aggregator}_v1.md │ │ └── agents/{h2h,form,stats,home_away,injuries,aggregator}_v1.md
│ └── core/config.py # pydantic-settings │ └── core/
├── alembic/versions/ # 0001 建表 + 0002 agent 字段 │ ├── config.py # pydantic-settings
├── frontend/src/pages/Matches.tsx # 单页(预测面板 + 专家报告折叠区) │ ├── http_client.py # 共享 httpx 客户端
├── tests/ # 33 项(核心 13 + agent 20) │ └── retry.py # 重试工具
├── alembic/versions/ # 0001~0006(0001 建表 → 0006 漂移清理)
├── frontend/src/pages/Matches.tsx # 单页(预测面板 + 专家报告折叠区 + 游标分页)
├── tests/ # 核心 + agent 测试
├── docker-compose.yml # api + postgres 两容器 ├── docker-compose.yml # api + postgres 两容器
└── docs/ # 本文档 └── docs/ # 本文档
``` ```
+18 -5
View File
@@ -35,27 +35,34 @@ Profeto/
├── src/ # 后端源码 ├── src/ # 后端源码
│ ├── api/ │ ├── api/
│ │ ├── routes/ # FastAPI 路由 │ │ ├── routes/ # FastAPI 路由
│ │ │ ├── leagues.py # 联赛/比赛查询 │ │ │ ├── matches.py # 联赛/比赛查询
│ │ │ ├── predict.py # 预测入口 │ │ │ ├── predict.py # 预测入口
│ │ │ ├── ingest.py # 数据采集 │ │ │ ├── ingest.py # 数据采集
│ │ │ ── eval.py # 评估回填 │ │ │ ── eval.py # 评估回填
│ │ │ └── backtest.py # 历史回测
│ │ ├── deps.py # 依赖:管理接口鉴权(X-API-Key)
│ │ ├── schemas.py # Pydantic 模型 │ │ ├── schemas.py # Pydantic 模型
│ │ └── app.py # FastAPI 工厂 │ │ └── app.py # FastAPI 工厂
│ ├── db/ │ ├── db/
│ │ ├── base.py # SQLAlchemy async engine + session │ │ ├── base.py # SQLAlchemy async engine + session
│ │ ── models.py # 6 张表 ORM │ │ ── models.py # 6 张表 ORM
│ │ ├── repositories.py # 仓储层(查询封装)
│ │ └── unit_of_work.py # 事务边界
│ ├── data/ │ ├── data/
│ │ ├── bzzoiro.py # bzzoiro 采集 + 入库 │ │ ├── bzzoiro.py # bzzoiro 采集 + 入库
│ │ ├── understat.py # understat xG 回填 │ │ ├── understat.py # understat xG 回填
│ │ ├── injuries.py # 伤停采集 │ │ ├── injuries.py # 伤停采集
│ │ ├── normalize.py # 数据清洗契约 │ │ ├── normalize.py # 数据清洗契约
│ │ ├── team_names.py # 队名归一化映射 │ │ ├── team_names.py # 队名归一化映射
│ │ ├── sources.py # 数据源注册表
│ │ └── config.py # 联赛映射常量 │ │ └── config.py # 联赛映射常量
│ ├── llm/ │ ├── llm/
│ │ ├── provider.py # LLM 提供商抽象(OpenAI-compatible) │ │ ├── provider.py # LLM 提供商抽象(OpenAI-compatible)
│ │ ├── context_builder.py # 数据切片 + 拼接 │ │ ├── context_builder.py # 数据切片 + 拼接
│ │ ├── predict.py # 预测入口(单/多模式分派) │ │ ├── predict.py # 预测入口(单/多模式分派)
│ │ ├── eval.py # 评估统计 │ │ ├── eval.py # 评估统计
│ │ ├── validation.py # LLM 输出严格校验
│ │ ├── backtest.py # 回测执行
│ │ ├── agents/ │ │ ├── agents/
│ │ │ ├── base.py # AgentSpec + run_agent │ │ │ ├── base.py # AgentSpec + run_agent
│ │ │ └── orchestrator.py # 多 agent 编排 │ │ │ └── orchestrator.py # 多 agent 编排
@@ -69,12 +76,18 @@ Profeto/
│ │ ├── h2h_v1.md │ │ ├── h2h_v1.md
│ │ └── aggregator_v1.md │ │ └── aggregator_v1.md
│ └── core/ │ └── core/
── config.py # pydantic-settings 配置 ── config.py # pydantic-settings 配置
│ ├── http_client.py # 共享 httpx 客户端
│ └── retry.py # 重试工具
├── frontend/ # React 单页前端 ├── frontend/ # React 单页前端
├── alembic/ # 数据库迁移 ├── alembic/ # 数据库迁移
│ └── versions/ │ └── versions/
│ ├── 0001_initial.py │ ├── 0001_initial.py
── 0002_agent_outputs.py ── 0002_agent_outputs.py
│ ├── 0003_injuries.py
│ ├── 0004_snapshot_and_constraints.py
│ ├── 0005_prediction_status_and_stats_provenance.py
│ └── 0006_schema_model_drift_cleanup.py
├── tests/ # 测试 ├── tests/ # 测试
│ ├── test_core.py # 核心逻辑测试 │ ├── test_core.py # 核心逻辑测试
│ └── test_agents.py # 多 agent 测试 │ └── test_agents.py # 多 agent 测试
+8 -1
View File
@@ -1,4 +1,11 @@
"""重试工具:带指数退避的瞬态错误重试。""" """重试工具:带指数退避的瞬态错误重试。
NOTE(审查报告 P3):当前全项目**无调用点** —— bzzoiro 在 `_fetch_json_sync`
里自带了一套重试逻辑,understat/injuries 各自也有。这里保留是作为后续统一
重试策略的落点,但请勿误以为它已在生效。
如果决定不引入统一重试,建议删除本文件以避免"看起来有重试、实际没有"的误判。
"""
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
+99
View File
@@ -0,0 +1,99 @@
"""回归测试:锁定 P0 三项「静默失效」缺陷不再复发。
这些用例不依赖数据库 —— 它们用静态分析检查代码结构,
因为三个 P0 的本质都是「集成方式错误」,在 mock 掉 slice_fn /
provider 的单元测试里永远照不出来(这正是它们当初漏网的原因)。
- P0-1/P0-2: 查询 Match 的函数必须 eager-load 切片会访问的关系
- P0-3: bzzoiro 写 source_event_id 时用的 raw 必须与 nm 配对
"""
from __future__ import annotations
import re
from pathlib import Path
SRC = Path(__file__).resolve().parent.parent / "src"
# 切片函数会读取的关系属性 → 查询时必须 eager-load
MATCH_RELATIONS = ("stats", "home_team", "away_team", "league")
def _read(rel: str) -> str:
return (SRC / rel).read_text(encoding="utf-8")
class TestEagerLoadCoverage:
"""P0-1 / P0-2: 凡是 select(Match) 且后续访问关系的函数,必须有 eager-load。"""
def test_context_builder_getters_eager_load(self):
"""_get_form / _get_h2h / _get_home_away 必须预加载关系。
models.py 已声明 lazy="selectin" 兜底,但这里同时检查显式
selectinload —— 显式声明是查询意图的固化,也被 P0 修复所依赖。
"""
src = _read("llm/context_builder.py")
for fn in ("_get_form", "_get_h2h", "_get_home_away"):
# 截取函数体
m = re.search(rf"async def {fn}\(.*?(?=\nasync def |\n# =|\Z)", src, re.S)
assert m, f"{fn} 未找到"
body = m.group(0)
assert "selectinload" in body, (
f"{fn} 查询 Match 但未 eager-load 关系 —— "
"this would raise MissingGreenlet in async SQLAlchemy (P0-2)"
)
def test_backtest_candidates_eager_load(self):
"""回测取历史比赛必须 eager-load(否则 session 关闭后访问关系必炸)。"""
src = _read("llm/backtest.py")
assert "selectinload" in src, "backtest 未 eager-load 关系 (P0-1)"
def test_relationship_default_is_selectin(self):
"""models.py 中 Match 的高频关系应声明 lazy='selectin' 作为兜底。"""
src = _read("db/models.py")
# 找到 Match 类定义段
m = re.search(r"class Match\(Base\):.*?(?=\nclass )", src, re.S)
assert m, "Match 类未找到"
body = m.group(0)
for rel in MATCH_RELATIONS:
# 关系声明可能跨多行(stats/home_team/away_team 都是),因此按
# 「从 `rel: Mapped` 到下一个 `xxx: Mapped` 之前」整段匹配。
m_rel = re.search(
rf"^\s*{rel}: Mapped.*?(?=^\s*\w+: Mapped|\Z)", body, re.M | re.S
)
assert m_rel, f"Match.{rel} 未找到"
assert 'lazy="selectin"' in m_rel.group(0), (
f"Match.{rel} 未声明 lazy='selectin' —— 兜底缺失 (P0-2)"
)
class TestBzzoiroLineage:
"""P0-3: source_event_id 必须取配对的 raw,不能是循环残留变量。"""
def test_normalized_matches_carries_raw(self):
src = _read("data/bzzoiro.py")
# 规范化结果必须与原始 event 成对保存
assert "normalized_matches.append((nm, raw))" in src, (
"normalized_matches 未携带 (nm, raw) 元组 —— raw 变量泄漏会回归 (P0-3)"
)
# 内层消费循环必须解包成对变量
assert re.search(r"for nm, raw in normalized_matches", src), (
"消费循环未解包 (nm, raw) —— 血缘字段会取到错误 event (P0-3)"
)
def test_no_orphan_raw_use(self):
"""source_event_id 所在行必须在解包循环内(用缩进 + 上下文粗判)。"""
src = _read("data/bzzoiro.py")
lines = src.splitlines()
# 找到 "for nm, raw in normalized_matches" 所在行号
start = next(
(i for i, ln in enumerate(lines) if "for nm, raw in normalized_matches" in ln),
None,
)
assert start is not None
# 该循环之后、下一个同/更低缩进的顶层语句之前的范围
seg = "\n".join(lines[start:])
uses = [ln for ln in seg.splitlines() if "source_event_id" in ln]
assert uses, "未找到 source_event_id 赋值"
assert all("raw.get(" in ln for ln in uses), (
"source_event_id 未使用配对的 raw (P0-3)"
)