fix: 数据库与数据管线 6 个 P1 + 5 个 P2 审查问题修复

P1-1 [context_builder] build_context 共享 session,切片函数传 db 参数,
        回测 20 场并发连接需求从 100+ 降至每场 1 个
P1-2 [bzzoiro] 预加载改为按 raw_events 日期范围 ±30 天按需加载
P1-3 [understat] 批量查询球队 + 比赛,从 1140 次往返降到 3 次
P1-4 [injuries] 批量幂等检查 + 分批 flush,IntegrityError 逐条回退
P1-5 [predict] 删除 threading.Lock,dict 操作原子无需同步锁
P1-6 [models] 添加 (match_id, provider, model) 唯一约束 + 迁移

P2-1 [unit_of_work] get_uow 返回类型改为 AsyncIterator[AsyncSession]
P2-2 [normalize] _parse_date 失败时记录 warning 避免静默丢数据
P2-3 [repositories] find_by_teams_and_date 改用 match_date_date 等值匹配
P2-4 [migration] 幽灵列 cutoff_at 已在 0006 迁移删除(已有)
P2-5 [migration] injuries 约束命名对齐 ORM,UniqueConstraint → 唯一索引
This commit is contained in:
shangfangjian
2026-09-16 03:09:39 +08:00
parent 983b620659
commit ff0045ad93
11 changed files with 458 additions and 123 deletions
+100 -48
View File
@@ -1,16 +1,22 @@
"""上下文构建器:数据切片 + 拼接。
架构:
- match_header: 比赛基础信息(对阵双方/联赛/时间)
- 切片函数: 每个领域 agent 一个数据切片(h2h / form / standings / injuries / xg)
- build_context: 单 agent 路径,拼接全部切片(行为与旧版一致)
- match_header: 比赛基础信息(对阵双方/联赛/时间)
- 切片函数: 每个领域 agent 一个数据切片(h2h / form / standings / injuries / xg)
- build_context: 单 agent 路径,拼接全部切片(行为与旧版一致)
multi-agent 路径由 agents/orchestrator.py 调用切片函数,每个专家只拿自己的切片。
性能说明:
build_context 创建一个共享 session 并传给所有切片函数,
避免每个切片独立创建 session —— 回测 20 场并发时,
5 个切片 × 20 场 = 100 个连接会耗尽连接池(pool_size=15)。
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import TYPE_CHECKING
from sqlalchemy import select
from sqlalchemy.orm import selectinload
@@ -18,6 +24,9 @@ from sqlalchemy.orm import selectinload
from src.db.base import AsyncSessionLocal
from src.db.models import Match
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
logger = logging.getLogger(__name__)
@@ -80,11 +89,19 @@ class MatchHeader:
league_id: int
async def load_match_header(match_id: int) -> MatchHeader:
"""加载比赛头信息(各 agent 共用)。"""
async with AsyncSessionLocal() as db:
async def load_match_header(match_id: int, db: AsyncSession | None = None) -> MatchHeader:
"""加载比赛头信息(各 agent 共用)。
Args:
match_id: 比赛 ID
db: 可选的共享 session。不传则自建(向后兼容)。
"""
if db is not None:
m = await _load_match(db, match_id)
return _to_header(m)
async with AsyncSessionLocal() as new_db:
m = await _load_match(new_db, match_id)
return _to_header(m)
def _to_header(m: Match) -> MatchHeader:
@@ -114,10 +131,16 @@ def header_text(h: MatchHeader) -> str:
# 切片函数: 每个领域 agent 一个
# ============================================================
async def h2h_slice(header: MatchHeader, *, limit: int = 8, before=None) -> SliceResult:
"""E - 历史交锋切片: 过去数年 + 近期交手数据,提取交手规律。before=match_date 用于回测。"""
async with AsyncSessionLocal() as db:
async def h2h_slice(header: MatchHeader, *, limit: int = 8, before=None, db: AsyncSession | None = None) -> SliceResult:
"""E - 历史交锋切片: 过去数年 + 近期交手数据,提取交手规律。before=match_date 用于回测。
db: 可选共享 session,避免每个切片独立建连(见模块 docstring)。
"""
if db is not None:
h2h = await _get_h2h(db, header.home_team_id, header.away_team_id, before=before, limit=limit)
else:
async with AsyncSessionLocal() as new_db:
h2h = await _get_h2h(new_db, header.home_team_id, header.away_team_id, before=before, limit=limit)
lines = [f"── 历史交锋(近 {limit} 次) ──"]
n_with_score = 0
if h2h:
@@ -141,11 +164,18 @@ async def h2h_slice(header: MatchHeader, *, limit: int = 8, before=None) -> Slic
return SliceResult(text="\n".join(lines), has_data=n_with_score > 0, n_records=n_with_score)
async def form_slice(header: MatchHeader, *, limit: int = 5, before=None) -> SliceResult:
"""A - 近期状态切片: 两队近 N 场赛果、关键事件、走势判断。before=match_date 用于回测。"""
async with AsyncSessionLocal() as db:
async def form_slice(header: MatchHeader, *, limit: int = 5, before=None, db: AsyncSession | None = None) -> SliceResult:
"""A - 近期状态切片: 两队近 N 场赛果、关键事件、走势判断。before=match_date 用于回测。
db: 可选共享 session,避免每个切片独立建连(见模块 docstring)。
"""
if db is not None:
home_form = await _get_form(db, header.home_team_id, before=before, limit=limit)
away_form = await _get_form(db, header.away_team_id, before=before, limit=limit)
else:
async with AsyncSessionLocal() as new_db:
home_form = await _get_form(new_db, header.home_team_id, before=before, limit=limit)
away_form = await _get_form(new_db, header.away_team_id, before=before, limit=limit)
lines = []
n_scored = 0
for label, name, form, side in (
@@ -175,11 +205,18 @@ async def form_slice(header: MatchHeader, *, limit: int = 5, before=None) -> Sli
return SliceResult(text="\n".join(lines), has_data=n_scored > 0, n_records=n_scored)
async def stats_slice(header: MatchHeader, *, limit: int = 10, before=None) -> SliceResult:
"""B - 攻防数据切片: 进球、射门、控球,评估攻防强度。before=match_date 用于回测。"""
async with AsyncSessionLocal() as db:
async def stats_slice(header: MatchHeader, *, limit: int = 10, before=None, db: AsyncSession | None = None) -> SliceResult:
"""B - 攻防数据切片: 进球、射门、控球,评估攻防强度。before=match_date 用于回测。
db: 可选共享 session,避免每个切片独立建连(见模块 docstring)。
"""
if db is not None:
home_form = await _get_form(db, header.home_team_id, before=before, limit=limit)
away_form = await _get_form(db, header.away_team_id, before=before, limit=limit)
else:
async with AsyncSessionLocal() as new_db:
home_form = await _get_form(new_db, header.home_team_id, before=before, limit=limit)
away_form = await _get_form(new_db, header.away_team_id, before=before, limit=limit)
lines = [f"── 攻防数据(近 {limit} 场) ──"]
n_total = 0
for label, name, form, side in (
@@ -221,11 +258,18 @@ async def stats_slice(header: MatchHeader, *, limit: int = 10, before=None) -> S
return SliceResult(text="\n".join(lines), has_data=n_total > 0, n_records=n_total)
async def home_away_slice(header: MatchHeader, *, limit: int = 10, before=None) -> SliceResult:
"""C - 主客因素切片: 主场战绩 vs 客场战绩,评估地理优势影响。before=match_date 用于回测。"""
async with AsyncSessionLocal() as db:
async def home_away_slice(header: MatchHeader, *, limit: int = 10, before=None, db: AsyncSession | None = None) -> SliceResult:
"""C - 主客因素切片: 主场战绩 vs 客场战绩,评估地理优势影响。before=match_date 用于回测。
db: 可选共享 session,避免每个切片独立建连(见模块 docstring)。
"""
if db is not None:
home_home = await _get_home_away(db, header.home_team_id, "home", before=before, limit=limit)
away_away = await _get_home_away(db, header.away_team_id, "away", before=before, limit=limit)
else:
async with AsyncSessionLocal() as new_db:
home_home = await _get_home_away(new_db, header.home_team_id, "home", before=before, limit=limit)
away_away = await _get_home_away(new_db, header.away_team_id, "away", before=before, limit=limit)
lines = ["── 主客因素 ──"]
n_total = 0
for label, name, matches, side in (
@@ -255,17 +299,22 @@ async def home_away_slice(header: MatchHeader, *, limit: int = 10, before=None)
return SliceResult(text="\n".join(lines), has_data=n_total > 0, n_records=n_total)
async def injuries_slice(header: MatchHeader, *, before=None) -> SliceResult:
async def injuries_slice(header: MatchHeader, *, before=None, db: AsyncSession | None = None) -> SliceResult:
"""D - 阵容完整性切片: 伤停与停赛名单,评估战力缺失程度。
before=cutoff: 只使用 cutoff 之前已采集的伤停数据,防回测泄漏。
db: 可选共享 session(见模块 docstring)。
"""
from src.data.injuries import get_injuries_for_match
cutoff = before or header.match_dt
async with AsyncSessionLocal() as db:
if db is not None:
home_injuries = await get_injuries_for_match(db, header.home_team_id, cutoff, as_of=cutoff)
away_injuries = await get_injuries_for_match(db, header.away_team_id, cutoff, as_of=cutoff)
else:
async with AsyncSessionLocal() as new_db:
home_injuries = await get_injuries_for_match(new_db, header.home_team_id, cutoff, as_of=cutoff)
away_injuries = await get_injuries_for_match(new_db, header.away_team_id, cutoff, as_of=cutoff)
lines = ["── 阵容完整性 ──"]
n_records = 0
@@ -298,41 +347,44 @@ async def build_context(match_id: int, *, form_last: int = 5, h2h_last: int = 5,
不再靠文案子串匹配(见审查报告 P2-1)。
P2-6: backtest=True 时 cutoff = match_date - 1天,确保只用赛前数据。
P1-1: 使用单个共享 session 贯穿所有切片查询,避免连接池耗尽。
"""
header = await load_match_header(match_id)
# P2-6: 回测模式下 cutoff 提前 1 天,防止比赛日数据泄漏
cutoff = header.match_dt
if backtest and header.match_dt:
from datetime import timedelta
cutoff = header.match_dt - timedelta(days=1)
parts = [header_text(header), ""]
async with AsyncSessionLocal() as db:
header = await load_match_header(match_id, db=db)
# P2-6: 回测模式下 cutoff 提前 1 天,防止比赛日数据泄漏
cutoff = header.match_dt
if backtest and header.match_dt:
from datetime import timedelta
cutoff = header.match_dt - timedelta(days=1)
parts = [header_text(header), ""]
form_res = await form_slice(header, limit=form_last, before=cutoff)
parts.append(form_res.text)
parts.append("")
form_res = await form_slice(header, limit=form_last, before=cutoff, db=db)
parts.append(form_res.text)
parts.append("")
h2h_res = await h2h_slice(header, limit=h2h_last, before=cutoff)
parts.append(h2h_res.text)
parts.append("")
h2h_res = await h2h_slice(header, limit=h2h_last, before=cutoff, db=db)
parts.append(h2h_res.text)
parts.append("")
stats_res = await stats_slice(header, before=cutoff)
parts.append(stats_res.text)
parts.append("")
stats_res = await stats_slice(header, before=cutoff, db=db)
parts.append(stats_res.text)
parts.append("")
home_away_res = await home_away_slice(header, before=cutoff)
parts.append(home_away_res.text)
parts.append("")
home_away_res = await home_away_slice(header, before=cutoff, db=db)
parts.append(home_away_res.text)
parts.append("")
injuries_res = await injuries_slice(header, before=cutoff)
parts.append(injuries_res.text)
injuries_res = await injuries_slice(header, before=cutoff, db=db)
parts.append(injuries_res.text)
return MatchContext(
match_id=match_id,
text="\n".join(parts),
has_stats=form_res.has_data or stats_res.has_data,
has_injuries=injuries_res.has_data,
match_dt=header.match_dt,
)
return MatchContext(
match_id=match_id,
text="\n".join(parts),
has_stats=form_res.has_data or stats_res.has_data,
has_injuries=injuries_res.has_data,
match_dt=header.match_dt,
)
# ============================================================