"""上下文构建器:数据切片 + 拼接。 架构: - match_header: 比赛基础信息(对阵双方/联赛/时间) - 切片函数: 每个领域 agent 一个数据切片(h2h / form / standings / stats) - 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 时间是否已可用。 available_at 语义:该条统计「对外可被使用」的最早时间, 至少不得早于比赛结束。用于回测防泄漏。 规则: - before is None(实盘):available_at 为 None 时允许(兼容旧数据) - before is not None(回测):available_at 为 None 视为不可用(保守) - available_at > cutoff:不可用(数据在 cutoff 之后才生成) """ if before is None: # 实盘模式:无时间信息时允许(兼容旧数据) return True # 回测模式(cutoff 不为 None): # available_at 为 None → 无法确认是否在 cutoff 前可用,保守视为不可用 if stats.available_at is None: return False 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_standings: bool match_dt: object | None = None # 比赛时间(回测防泄漏 + 快照用) cutoff: 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: # 从当前主队视角统计:判断当前主队在每场交锋中是主是客 current_home_wins = current_home_draws = current_home_losses = 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_team_id == header.home_team_id: # 当前主队当时是主队 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}") else: lines.append(f" {d}: {hm.home_team.name} vs {hm.away_team.name} (无比分)") total = current_home_wins + current_home_draws + current_home_losses if total: lines.append( f" 总计 {total} 场(从当前主队 {header.home_name} 视角): " f"{current_home_wins}胜 {current_home_draws}平 {current_home_losses}负" ) 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 # P0-1 修复:每场历史比赛必须根据「该队当时是主是客」判断 side, # 不能用本场 side 硬套 —— 否则客场输球会被算成主场赢球。 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} 场) ──") if form: wins = draws = losses = 0 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) 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 is_home else fm.stats.away_xg xg = f" (xG {own:.1f})" opp = fm.away_team.name if is_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 # P0-1 修复:每场历史比赛必须根据「该队当时是主是客」判断 side, # 不能用本场 side 硬套 —— 否则进球/失球/xG 全部算反。 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: 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 is_home = (fm.home_team_id == team_id) gf += fm.home_goals if is_home else fm.away_goals ga += fm.away_goals if is_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 is_home else fm.stats.away_shots sot += fm.stats.home_shots_on_target if is_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 is_home else (100 - fm.stats.home_possession) n_poss += 1 if fm.stats.home_xg is not None: xg += fm.stats.home_xg if is_home else fm.stats.away_xg xga += fm.stats.away_xg if is_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 standings_slice(header: MatchHeader, *, before=None, db: AsyncSession | None = None) -> SliceResult: """D - 联赛排名切片: 两队积分榜位置、积分、近期走势(form)、分区,评估整体实力差距。 before 参数保留与其他切片一致的签名(积分榜是最新快照,无历史版本,不受 cutoff 影响)。 db: 可选共享 session(见模块 docstring)。 语义区分: - 两队都有积分榜行 → has_data=True(明确的排名信息) - 任一队缺失 → has_data=False(升班马/杯赛无榜,信息不完整时明确声明) """ from src.db.models import League, Standing if db is not None: league = (await db.execute(select(League).where(League.id == header.league_id))).scalar_one_or_none() rows = ( ( await db.execute( select(Standing) .options(selectinload(Standing.team)) .where(Standing.league_id == header.league_id) .order_by(Standing.position.asc()) ) ) .scalars() .all() if league else [] ) else: async with AsyncSessionLocal() as new_db: return await standings_slice(header, before=before, db=new_db) lines = [f"── 联赛排名({header.league_name} 共 {len(rows)} 队) ──"] n_records = 0 def _fmt(row) -> str: zg = f" xG差 {row.xgd:+.1f}" if row.xg_for is not None and row.xg_against is not None and row.goal_diff is not None else "" form = f" 近5场 {row.form}" if row.form else "" zone = f" [{row.zone}]" if row.zone else "" return ( f" 第 {row.position} 名: {row.points} 分 / {row.played} 场 " f"({row.won}胜{row.drawn}平{row.lost}负, 进{row.goals_for}失{row.goals_against} 净胜{row.goal_diff:+d}" f"{zg}){form}{zone}" ) for label, team_id in (("主队", header.home_team_id), ("客队", header.away_team_id)): row = next((r for r in rows if r.team_id == team_id), None) if row is None: lines.append(f" {label}: 暂无积分榜数据(可能杯赛/赛季未开始)") else: n_records += 1 lines.append(f" {label} {header.home_name if label == '主队' else header.away_name}:") lines.append(_fmt(row)) # 两队排名对比摘要 home_row = next((r for r in rows if r.team_id == header.home_team_id), None) away_row = next((r for r in rows if r.team_id == header.away_team_id), None) if home_row and away_row: diff = home_row.position - away_row.position # 正数=主队排名更靠前(名次更小) lead = f"主队排名高 {diff} 位" if diff > 0 else (f"客队排名高 {-diff} 位" if diff < 0 else "两队同排名结构") pts_diff = home_row.points - away_row.points lines.append(f" 排名对比: {lead}, 分差 {pts_diff:+d}") # has_data: 两队都有行才算完整;只有一队时仍有价值,但标记不完整 has_data = n_records >= 1 return SliceResult(text="\n".join(lines), has_data=has_data, n_records=n_records) # ============================================================ # 单 agent 路径: 拼接全部切片(行为与旧版一致) # ============================================================ async def build_context(match_id: int, *, form_last: int = 5, h2h_last: int = 5, backtest: bool = False, cutoff_at=None) -> MatchContext: """单 agent 路径的完整上下文: 拼接全部切片(before=cutoff,防未来信息)。 has_stats / has_standings 直接取切片显式声明的 has_data, 不再靠文案子串匹配(见审查报告 P2-1)。 P2-6: backtest=True 时 cutoff = match_date - 1天,确保只用赛前数据。 cutoff_at: 显式截止时间(优先于 backtest 自动计算)。 P1-1: 使用单个共享 session 贯穿所有切片查询,避免连接池耗尽。 """ async with AsyncSessionLocal() as db: header = await load_match_header(match_id, db=db) # 计算数据截止时间: 显式 > 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 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("") standings_res = await standings_slice(header, before=cutoff, db=db) parts.append(standings_res.text) return MatchContext( match_id=match_id, text="\n".join(parts), has_stats=form_res.has_data or stats_res.has_data, has_standings=standings_res.has_data, match_dt=header.match_dt, cutoff=cutoff, ) # ============================================================ # 底层查询(切片函数共用) # ============================================================ 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())