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 → 唯一索引
485 lines
20 KiB
Python
485 lines
20 KiB
Python
"""上下文构建器:数据切片 + 拼接。
|
|
|
|
架构:
|
|
- 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
|
|
|
|
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__)
|
|
|
|
|
|
def _outcome(home_goals: int, away_goals: int, side: str) -> str:
|
|
"""从某队视角看赛果: W/D/L。"""
|
|
if home_goals is None or away_goals is None:
|
|
return "?"
|
|
if side == "home":
|
|
return "W" if home_goals > away_goals else ("D" if home_goals == away_goals else "L")
|
|
return "W" if away_goals > home_goals else ("D" if away_goals == home_goals else "L")
|
|
|
|
|
|
def _is_stats_available(stats, before) -> bool:
|
|
"""检查统计数据在 cutoff 时间是否已可用。"""
|
|
if before is None:
|
|
return True
|
|
if stats.available_at is None:
|
|
return True # 无时间信息时保守处理:允许使用
|
|
return stats.available_at <= before
|
|
|
|
|
|
@dataclass
|
|
class SliceResult:
|
|
"""数据切片的显式结果(替代「靠文案子串猜有无数据」)。
|
|
|
|
旧实现用 `"无数据" in slice_text` 判断,依赖具体文案 —— 一旦某个切片
|
|
写成「无比分数据」「无伤停数据」这类变体,判断就会静默失配
|
|
(见审查报告 P2-1)。这里让切片函数直接声明 `has_data`,不再猜。
|
|
"""
|
|
text: str
|
|
has_data: bool
|
|
n_records: int = 0
|
|
|
|
def __str__(self) -> str: # 让老调用点可直接当 str 用
|
|
return self.text
|
|
|
|
|
|
@dataclass
|
|
class MatchContext:
|
|
match_id: int
|
|
text: str
|
|
has_stats: bool
|
|
has_injuries: bool
|
|
match_dt: object | None = None # 比赛时间(回测防泄漏 + 快照用)
|
|
|
|
|
|
@dataclass
|
|
class MatchHeader:
|
|
"""比赛基础信息(所有 agent 共享)。"""
|
|
match_id: int
|
|
home_name: str
|
|
away_name: str
|
|
league_name: str
|
|
season: str | None
|
|
match_date: str
|
|
match_dt: object # 原始 datetime,回测防泄漏用
|
|
stage: str | None
|
|
home_team_id: int
|
|
away_team_id: int
|
|
league_id: int
|
|
|
|
|
|
async def load_match_header(match_id: int, 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:
|
|
return MatchHeader(
|
|
match_id=m.id,
|
|
home_name=m.home_team.name_zh or m.home_team.name,
|
|
away_name=m.away_team.name_zh or m.away_team.name,
|
|
league_name=m.league.name if m.league else "?",
|
|
season=m.season,
|
|
match_date=m.match_date.strftime("%Y-%m-%d %H:%M UTC") if m.match_date else "?",
|
|
match_dt=m.match_date,
|
|
stage=m.match_stage,
|
|
home_team_id=m.home_team_id,
|
|
away_team_id=m.away_team_id,
|
|
league_id=m.league_id,
|
|
)
|
|
|
|
|
|
def header_text(h: MatchHeader) -> str:
|
|
stage = f" {h.stage}" if h.stage else ""
|
|
return (
|
|
f"对阵: {h.home_name} vs {h.away_name} | {h.league_name} {h.season or '?'}{stage} | {h.match_date}"
|
|
)
|
|
|
|
|
|
# ============================================================
|
|
# 切片函数: 每个领域 agent 一个
|
|
# ============================================================
|
|
|
|
async def h2h_slice(header: MatchHeader, *, limit: int = 8, before=None, 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:
|
|
home_wins = draws = away_wins = 0
|
|
for hm in h2h:
|
|
d = hm.match_date.strftime("%Y-%m") if hm.match_date else "?"
|
|
if hm.home_goals is not None:
|
|
n_with_score += 1
|
|
if hm.home_goals > hm.away_goals: home_wins += 1
|
|
elif hm.home_goals == hm.away_goals: draws += 1
|
|
else: away_wins += 1
|
|
lines.append(f" {d}: {hm.home_team.name} {hm.home_goals}-{hm.away_goals} {hm.away_team.name}")
|
|
else:
|
|
lines.append(f" {d}: {hm.home_team.name} vs {hm.away_team.name} (无比分)")
|
|
total = home_wins + draws + away_wins
|
|
if total:
|
|
lines.append(f" 总计 {total} 场: 主队 {home_wins}胜 {draws}平 {away_wins}负")
|
|
else:
|
|
lines.append(" 无数据")
|
|
# has_data 以「有比分的交锋」为准:仅有对阵无比分时不足以支撑分析
|
|
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, 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 (
|
|
("主队", header.home_name, home_form, "home"),
|
|
("客队", header.away_name, away_form, "away"),
|
|
):
|
|
lines.append(f"── {label}近况({name},近 {limit} 场) ──")
|
|
if form:
|
|
wins = draws = losses = 0
|
|
for fm in form:
|
|
o = _outcome(fm.home_goals, fm.away_goals, side)
|
|
if o == "W": wins += 1
|
|
elif o == "D": draws += 1
|
|
else: losses += 1
|
|
if fm.home_goals is not None:
|
|
n_scored += 1
|
|
score = f"{fm.home_goals}-{fm.away_goals}" if fm.home_goals is not None else "vs"
|
|
xg = ""
|
|
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
|
|
xg = f" (xG {own:.1f})"
|
|
opp = fm.away_team.name if side == "home" else fm.home_team.name
|
|
lines.append(f" {o} {score} vs {opp}{xg}")
|
|
lines.append(f" 近 {len(form)} 场: {wins}胜 {draws}平 {losses}负")
|
|
else:
|
|
lines.append(" 无数据")
|
|
return 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, 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 (
|
|
("主队", header.home_name, home_form, "home"),
|
|
("客队", header.away_name, away_form, "away"),
|
|
):
|
|
if form:
|
|
gf = ga = shots = sot = poss = xg = xga = 0
|
|
n = n_shots = n_poss = n_xg = 0
|
|
for fm in form:
|
|
if fm.home_goals is None: continue
|
|
gf += fm.home_goals if side == "home" else fm.away_goals
|
|
ga += fm.away_goals if side == "home" else fm.home_goals
|
|
n += 1
|
|
# 只使用 cutoff 之前已可用的统计数据
|
|
if fm.stats and _is_stats_available(fm.stats, before):
|
|
if fm.stats.home_shots is not None:
|
|
shots += fm.stats.home_shots if side == "home" else fm.stats.away_shots
|
|
sot += fm.stats.home_shots_on_target if side == "home" else fm.stats.away_shots_on_target
|
|
n_shots += 1
|
|
if fm.stats.home_possession is not None:
|
|
poss += fm.stats.home_possession if side == "home" else (100 - fm.stats.home_possession)
|
|
n_poss += 1
|
|
if fm.stats.home_xg is not None:
|
|
xg += fm.stats.home_xg if side == "home" else fm.stats.away_xg
|
|
xga += fm.stats.away_xg if side == "home" else fm.stats.home_xg
|
|
n_xg += 1
|
|
n_total += n
|
|
if n > 0:
|
|
lines.append(f" {label} {name}:")
|
|
lines.append(f" 场均进球 {gf/n:.2f}, 场均失球 {ga/n:.2f}")
|
|
if n_shots: lines.append(f" 场均射门 {shots/n_shots:.1f}, 射正 {sot/n_shots:.1f}")
|
|
if n_poss: lines.append(f" 平均控球 {poss/n_poss:.1f}%")
|
|
if n_xg: lines.append(f" 场均 xG {xg/n_xg:.2f}, 场均被 xG {xga/n_xg:.2f}")
|
|
else:
|
|
lines.append(f" {label} {name}: 无比分数据")
|
|
else:
|
|
lines.append(f" {label} {name}: 无数据")
|
|
return 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, 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 (
|
|
("主队主场", header.home_name, home_home, "home"),
|
|
("客队客场", header.away_name, away_away, "away"),
|
|
):
|
|
if matches:
|
|
wins = draws = losses = gf = ga = 0
|
|
for m in matches:
|
|
if m.home_goals is None: continue
|
|
o = _outcome(m.home_goals, m.away_goals, side)
|
|
if o == "W": wins += 1
|
|
elif o == "D": draws += 1
|
|
else: losses += 1
|
|
gf += m.home_goals if side == "home" else m.away_goals
|
|
ga += m.away_goals if side == "home" else m.home_goals
|
|
n = wins + draws + losses
|
|
n_total += n
|
|
if n > 0:
|
|
pct = wins / n * 100
|
|
lines.append(f" {label} {name}(近 {n} 场): {wins}胜 {draws}平 {losses}负, 胜率 {pct:.0f}%")
|
|
lines.append(f" 场均进球 {gf/n:.2f}, 场均失球 {ga/n:.2f}")
|
|
else:
|
|
lines.append(f" {label} {name}: 无比分数据")
|
|
else:
|
|
lines.append(f" {label} {name}: 无数据")
|
|
return SliceResult(text="\n".join(lines), has_data=n_total > 0, n_records=n_total)
|
|
|
|
|
|
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
|
|
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
|
|
for label, injuries in (("主队", home_injuries), ("客队", away_injuries)):
|
|
if injuries:
|
|
n_records += len(injuries)
|
|
lines.append(f" {label}伤停({len(injuries)}人):")
|
|
for inj in injuries[:8]: # 最多显示 8 条
|
|
reason = inj.reason or inj.injury_type or "未知"
|
|
lines.append(f" - {inj.player_name}: {reason}")
|
|
if len(injuries) > 8:
|
|
lines.append(f" ...及其他 {len(injuries) - 8} 人")
|
|
else:
|
|
lines.append(f" {label}: 无伤停数据")
|
|
|
|
if n_records == 0:
|
|
return SliceResult(text="── 阵容完整性 ──\n 无数据", has_data=False, n_records=0)
|
|
|
|
return SliceResult(text="\n".join(lines), has_data=True, n_records=n_records)
|
|
|
|
|
|
# ============================================================
|
|
# 单 agent 路径: 拼接全部切片(行为与旧版一致)
|
|
# ============================================================
|
|
|
|
async def build_context(match_id: int, *, form_last: int = 5, h2h_last: int = 5, backtest: bool = False) -> MatchContext:
|
|
"""单 agent 路径的完整上下文: 拼接全部切片(before=比赛时间,防未来信息)。
|
|
|
|
has_stats / has_injuries 直接取切片显式声明的 has_data,
|
|
不再靠文案子串匹配(见审查报告 P2-1)。
|
|
|
|
P2-6: backtest=True 时 cutoff = match_date - 1天,确保只用赛前数据。
|
|
|
|
P1-1: 使用单个共享 session 贯穿所有切片查询,避免连接池耗尽。
|
|
"""
|
|
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, db=db)
|
|
parts.append(form_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, db=db)
|
|
parts.append(stats_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, 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,
|
|
)
|
|
|
|
|
|
# ============================================================
|
|
# 底层查询(切片函数共用)
|
|
# ============================================================
|
|
|
|
async def _load_match(db, match_id: int) -> Match:
|
|
stmt = (
|
|
select(Match)
|
|
.where(Match.id == match_id)
|
|
.options(
|
|
selectinload(Match.league),
|
|
selectinload(Match.home_team),
|
|
selectinload(Match.away_team),
|
|
selectinload(Match.stats),
|
|
)
|
|
)
|
|
m = (await db.execute(stmt)).scalar_one_or_none()
|
|
if m is None:
|
|
raise ValueError(f"match {match_id} not found")
|
|
return m
|
|
|
|
|
|
async def _get_form(db, team_id: int, before, *, limit: int = 5) -> list[Match]:
|
|
"""某队近 N 场(已完赛)。before=None 表示不限制(预测赛前的场景由调用方保证)。
|
|
|
|
必须预加载 stats / home_team / away_team:切片函数会读取这些关系,
|
|
而 async session 下惰性加载会抛 MissingGreenlet。
|
|
(models.py 已声明 lazy="selectin",此处显式声明以固化查询意图。)
|
|
"""
|
|
stmt = (
|
|
select(Match)
|
|
.options(
|
|
selectinload(Match.stats),
|
|
selectinload(Match.home_team),
|
|
selectinload(Match.away_team),
|
|
)
|
|
.where(Match.match_status == "finished")
|
|
.where(Match.home_goals.is_not(None))
|
|
.where((Match.home_team_id == team_id) | (Match.away_team_id == team_id))
|
|
.order_by(Match.match_date.desc())
|
|
.limit(limit)
|
|
)
|
|
if before is not None:
|
|
stmt = stmt.where(Match.match_date < before)
|
|
result = await db.execute(stmt)
|
|
return list(result.scalars().all())
|
|
|
|
|
|
async def _get_h2h(db, home_id: int, away_id: int, before, *, limit: int = 5) -> list[Match]:
|
|
"""两队交锋史。需预加载 home_team / away_team(切片输出队名)。"""
|
|
stmt = (
|
|
select(Match)
|
|
.options(
|
|
selectinload(Match.home_team),
|
|
selectinload(Match.away_team),
|
|
)
|
|
.where(Match.match_status == "finished")
|
|
.where(Match.home_goals.is_not(None))
|
|
.where(
|
|
((Match.home_team_id == home_id) & (Match.away_team_id == away_id))
|
|
| ((Match.home_team_id == away_id) & (Match.away_team_id == home_id))
|
|
)
|
|
.order_by(Match.match_date.desc())
|
|
.limit(limit)
|
|
)
|
|
if before is not None:
|
|
stmt = stmt.where(Match.match_date < before)
|
|
result = await db.execute(stmt)
|
|
return list(result.scalars().all())
|
|
|
|
|
|
async def _get_home_away(db, team_id: int, side: str, before, *, limit: int = 10) -> list[Match]:
|
|
"""某队主场/客场近 N 场。side='home' 取主场,'away' 取客场。
|
|
|
|
当前只用标量字段,但统一预加载以免后续扩展时踩坑。
|
|
"""
|
|
stmt = (
|
|
select(Match)
|
|
.options(
|
|
selectinload(Match.stats),
|
|
selectinload(Match.home_team),
|
|
selectinload(Match.away_team),
|
|
)
|
|
.where(Match.match_status == "finished")
|
|
.where(Match.home_goals.is_not(None))
|
|
.order_by(Match.match_date.desc())
|
|
.limit(limit)
|
|
)
|
|
if side == "home":
|
|
stmt = stmt.where(Match.home_team_id == team_id)
|
|
else:
|
|
stmt = stmt.where(Match.away_team_id == team_id)
|
|
if before is not None:
|
|
stmt = stmt.where(Match.match_date < before)
|
|
result = await db.execute(stmt)
|
|
return list(result.scalars().all())
|