From 44816794d348b15c12e14a83188ba5cfa8eae6bb Mon Sep 17 00:00:00 2001 From: shangfangjian Date: Tue, 22 Sep 2026 01:33:14 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20context=5Fbuilder=20=E6=8C=89=20sli?= =?UTF-8?q?ce=20=E6=8B=86=E5=88=B0=20src/llm/slices/=20=E5=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 单文件拆分(仅搬迁无逻辑修改): - common.py 共享类型/头信息/_outcome/_is_stats_available - form.py form_slice + _get_form - h2h.py h2h_slice + _get_h2h - stats.py stats_slice(复用 form._get_form) - home_away.py home_away_slice + _get_home_away - standings.py standings_slice - aggregate.py build_context context_builder.py 改为纯 re-export 门面,公开签名不变。 同步修复测试 patch 目标(p0_home_away/h2h_perspective/multi_agent_cutoff) 与 regressions 源码断言(读 slices/*.py)。 全量测试 270 通过。 --- src/llm/context_builder.py | 579 ++----------------------------- src/llm/slices/__init__.py | 5 + src/llm/slices/aggregate.py | 65 ++++ src/llm/slices/common.py | 149 ++++++++ src/llm/slices/form.py | 85 +++++ src/llm/slices/h2h.py | 88 +++++ src/llm/slices/home_away.py | 82 +++++ src/llm/slices/standings.py | 81 +++++ src/llm/slices/stats.py | 67 ++++ tests/test_h2h_perspective.py | 6 +- tests/test_multi_agent_cutoff.py | 2 +- tests/test_p0_home_away.py | 6 +- tests/test_regressions.py | 25 +- 13 files changed, 677 insertions(+), 563 deletions(-) create mode 100644 src/llm/slices/__init__.py create mode 100644 src/llm/slices/aggregate.py create mode 100644 src/llm/slices/common.py create mode 100644 src/llm/slices/form.py create mode 100644 src/llm/slices/h2h.py create mode 100644 src/llm/slices/home_away.py create mode 100644 src/llm/slices/standings.py create mode 100644 src/llm/slices/stats.py diff --git a/src/llm/context_builder.py b/src/llm/context_builder.py index a78dbd0..a8b1eba 100644 --- a/src/llm/context_builder.py +++ b/src/llm/context_builder.py @@ -1,9 +1,9 @@ -"""上下文构建器:数据切片 + 拼接。 +"""上下文构建器:数据切片 + 拼接(聚合门面)。 -架构: - - match_header: 比赛基础信息(对阵双方/联赛/时间) - - 切片函数: 每个领域 agent 一个数据切片(h2h / form / standings / stats) - - build_context: 单 agent 路径,拼接全部切片(行为与旧版一致) +实现按 slice 拆分(单文件 → slices 包),本模块只做再导出: + - 切片函数: 每个领域 agent 一个数据切片 → src/llm/slices/{form,h2h,stats,home_away,standings}.py + - 共享类型/头信息/查询助手 → src/llm/slices/common.py + - build_context: 单 agent 路径,拼接全部切片(行为与旧版一致) → src/llm/slices/aggregate.py multi-agent 路径由 agents/orchestrator.py 调用切片函数,每个专家只拿自己的切片。 @@ -11,548 +11,33 @@ multi-agent 路径由 agents/orchestrator.py 调用切片函数,每个专家只 build_context 创建一个共享 session 并传给所有切片函数, 避免每个切片独立创建 session —— 回测 20 场并发时, 5 个切片 × 20 场 = 100 个连接会耗尽连接池(pool_size=15)。 + +消费方(路由/orchestrator/tests)仍从本模块 import,签名与拆分前完全一致。 """ 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()) +# ── 共享类型与基础(判空/赛果/统计可用性) ── +from src.llm.slices.common import ( # noqa: F401 + MatchContext, + MatchHeader, + SliceResult, + _is_stats_available, + _outcome, + header_text, + load_match_header, +) + +# ── 领域切片 ── +from src.llm.slices.form import form_slice # noqa: F401 +from src.llm.slices.h2h import h2h_slice # noqa: F401 +from src.llm.slices.home_away import home_away_slice # noqa: F401 +from src.llm.slices.standings import standings_slice # noqa: F401 +from src.llm.slices.stats import stats_slice # noqa: F401 + +# ── 底层查询助手(切片函数共用;orchestrator/tests 直接引用) ── +from src.llm.slices.form import _get_form # noqa: F401 +from src.llm.slices.h2h import _get_h2h # noqa: F401 +from src.llm.slices.home_away import _get_home_away # noqa: F401 + +# ── 单 agent 聚合入口 ── +from src.llm.slices.aggregate import build_context # noqa: F401 diff --git a/src/llm/slices/__init__.py b/src/llm/slices/__init__.py new file mode 100644 index 0000000..bf68c97 --- /dev/null +++ b/src/llm/slices/__init__.py @@ -0,0 +1,5 @@ +"""slices 包:按领域拆分的数据切片(form/stats/h2h/home_away/standings)。 + +对外统一经 src.llm.context_builder 再导出;本包 __init__ 不承载导出, +保持「context_builder 是唯一公开入口」的 import 约定。 +""" diff --git a/src/llm/slices/aggregate.py b/src/llm/slices/aggregate.py new file mode 100644 index 0000000..708b2f8 --- /dev/null +++ b/src/llm/slices/aggregate.py @@ -0,0 +1,65 @@ +"""单 agent 聚合路径: 拼接全部切片(build_context)。 + +共享 session 贯穿所有切片(见 context_builder 模块 docstring 的性能说明)。 +""" +from __future__ import annotations + +from src.db.base import AsyncSessionLocal +from src.llm.slices.common import MatchContext, header_text, load_match_header +from src.llm.slices.form import form_slice +from src.llm.slices.h2h import h2h_slice +from src.llm.slices.home_away import home_away_slice +from src.llm.slices.standings import standings_slice +from src.llm.slices.stats import stats_slice + + +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, + ) diff --git a/src/llm/slices/common.py b/src/llm/slices/common.py new file mode 100644 index 0000000..8887dbc --- /dev/null +++ b/src/llm/slices/common.py @@ -0,0 +1,149 @@ +"""切片共享基础:结果类型 / 比赛头信息 / 赛果与统计可用性判定。 + +从 context_builder.py 按领域拆出(单文件 → slices 包),仅做搬迁无逻辑修改。 +各领域切片见同包 form/h2h/stats/home_away/standings 模块; +聚合入口 build_context 见 aggregate.py;对外统一经 context_builder 再导出。 +""" +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(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 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}" + ) diff --git a/src/llm/slices/form.py b/src/llm/slices/form.py new file mode 100644 index 0000000..73df214 --- /dev/null +++ b/src/llm/slices/form.py @@ -0,0 +1,85 @@ +"""A - 近期状态切片: 近 N 场赛果 / 走势(form)。""" +from __future__ import annotations + +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 +from src.llm.slices.common import MatchHeader, SliceResult, _is_stats_available, _outcome + +if TYPE_CHECKING: + from sqlalchemy.ext.asyncio import AsyncSession + + +async def form_slice(header: MatchHeader, *, limit: int = 5, before=None, db: AsyncSession | None = None) -> SliceResult: + """A - 近期状态切片: 两队近 N 场赛果、关键事件、走势判断。before=match_date 用于回测。 + + db: 可选共享 session,避免每个切片独立建连(见 context_builder 模块 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 _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()) diff --git a/src/llm/slices/h2h.py b/src/llm/slices/h2h.py new file mode 100644 index 0000000..51dc906 --- /dev/null +++ b/src/llm/slices/h2h.py @@ -0,0 +1,88 @@ +"""E - 历史交锋切片: 交手史与胜负规律(h2h)。""" +from __future__ import annotations + +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 +from src.llm.slices.common import MatchHeader, SliceResult + +if TYPE_CHECKING: + from sqlalchemy.ext.asyncio import AsyncSession + + +async def h2h_slice(header: MatchHeader, *, limit: int = 8, before=None, db: AsyncSession | None = None) -> SliceResult: + """E - 历史交锋切片: 过去数年 + 近期交手数据,提取交手规律。before=match_date 用于回测。 + + db: 可选共享 session,避免每个切片独立建连(见 context_builder 模块 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 _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()) diff --git a/src/llm/slices/home_away.py b/src/llm/slices/home_away.py new file mode 100644 index 0000000..88061bc --- /dev/null +++ b/src/llm/slices/home_away.py @@ -0,0 +1,82 @@ +"""C - 主客因素切片: 主场战绩 vs 客场战绩(home_away)。""" +from __future__ import annotations + +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 +from src.llm.slices.common import MatchHeader, SliceResult, _outcome + +if TYPE_CHECKING: + from sqlalchemy.ext.asyncio import AsyncSession + + +async def home_away_slice(header: MatchHeader, *, limit: int = 10, before=None, db: AsyncSession | None = None) -> SliceResult: + """C - 主客因素切片: 主场战绩 vs 客场战绩,评估地理优势影响。before=match_date 用于回测。 + + db: 可选共享 session,避免每个切片独立建连(见 context_builder 模块 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 _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()) diff --git a/src/llm/slices/standings.py b/src/llm/slices/standings.py new file mode 100644 index 0000000..aff4248 --- /dev/null +++ b/src/llm/slices/standings.py @@ -0,0 +1,81 @@ +"""D - 联赛排名切片: 积分榜位置与实力差距(standings)。""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +from sqlalchemy import select +from sqlalchemy.orm import selectinload + +from src.db.base import AsyncSessionLocal +from src.llm.slices.common import MatchHeader, SliceResult + +if TYPE_CHECKING: + from sqlalchemy.ext.asyncio import AsyncSession + + +async def standings_slice(header: MatchHeader, *, before=None, db: AsyncSession | None = None) -> SliceResult: + """D - 联赛排名切片: 两队积分榜位置、积分、近期走势(form)、分区,评估整体实力差距。 + + before 参数保留与其他切片一致的签名(积分榜是最新快照,无历史版本,不受 cutoff 影响)。 + db: 可选共享 session(见 context_builder 模块 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) diff --git a/src/llm/slices/stats.py b/src/llm/slices/stats.py new file mode 100644 index 0000000..ad31751 --- /dev/null +++ b/src/llm/slices/stats.py @@ -0,0 +1,67 @@ +"""B - 攻防数据切片: 进球/射门/控球/xG 聚合(stats)。""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +from src.db.base import AsyncSessionLocal +from src.llm.slices.common import MatchHeader, SliceResult, _is_stats_available +from src.llm.slices.form import _get_form + +if TYPE_CHECKING: + from sqlalchemy.ext.asyncio import AsyncSession + + +async def stats_slice(header: MatchHeader, *, limit: int = 10, before=None, db: AsyncSession | None = None) -> SliceResult: + """B - 攻防数据切片: 进球、射门、控球,评估攻防强度。before=match_date 用于回测。 + + db: 可选共享 session,避免每个切片独立建连(见 context_builder 模块 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) diff --git a/tests/test_h2h_perspective.py b/tests/test_h2h_perspective.py index 930f776..bbd4d46 100644 --- a/tests/test_h2h_perspective.py +++ b/tests/test_h2h_perspective.py @@ -70,7 +70,7 @@ class TestH2HCurrentHomePerspective: _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 + import src.llm.slices.h2h as cb orig = cb._get_h2h async def mock_get_h2h(db, home_id, away_id, before, *, limit): return matches @@ -100,7 +100,7 @@ class TestH2HCurrentHomePerspective: _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 + import src.llm.slices.h2h as cb async def mock_get_h2h(db, h, a, before, **kw): # 真实契约是 async(见 context_builder.py 的 `h2h = await _get_h2h(...)`), @@ -122,7 +122,7 @@ class TestH2HCurrentHomePerspective: _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 + import src.llm.slices.h2h as cb orig = cb._get_h2h async def mock_get_h2h(db, h, a, before, *, limit): return matches diff --git a/tests/test_multi_agent_cutoff.py b/tests/test_multi_agent_cutoff.py index e03100a..ca686ca 100644 --- a/tests/test_multi_agent_cutoff.py +++ b/tests/test_multi_agent_cutoff.py @@ -206,7 +206,7 @@ class TestBacktestXgNotVisible: header = _make_header(match_dt) - import src.llm.context_builder as cb + import src.llm.slices.stats as cb async def mock_get_form(db, team_id, before, *, limit=10): # before=cutoff(1月13日),比赛在1月15日,满足 before 条件 diff --git a/tests/test_p0_home_away.py b/tests/test_p0_home_away.py index 9e442ec..473be03 100644 --- a/tests/test_p0_home_away.py +++ b/tests/test_p0_home_away.py @@ -101,7 +101,7 @@ class TestFormSliceHomeAwayIdentity: home_name="曼城", away_name="利物浦", ) - import src.llm.context_builder as cb + import src.llm.slices.form 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 [] @@ -132,7 +132,7 @@ class TestFormSliceHomeAwayIdentity: home_name="阿森纳", away_name="切尔西", ) - import src.llm.context_builder as cb + import src.llm.slices.form 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 [] @@ -169,7 +169,7 @@ class TestStatsSliceHomeAwayIdentity: 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 + import src.llm.slices.stats 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 [] diff --git a/tests/test_regressions.py b/tests/test_regressions.py index 6f73c13..170bff7 100644 --- a/tests/test_regressions.py +++ b/tests/test_regressions.py @@ -32,17 +32,24 @@ class TestEagerLoadCoverage: models.py 已声明 lazy="selectin" 兜底,但这里同时检查显式 selectinload —— 显式声明是查询意图的固化,也被 P0 修复所依赖。 + (context_builder 已按 slice 拆分到 src/llm/slices/,getter 随实现迁移。) """ - src = _read("llm/context_builder.py") + for rel in ("llm/slices/form.py", "llm/slices/h2h.py", "llm/slices/home_away.py"): + src = _read(rel) + for fn in ("_get_form", "_get_h2h", "_get_home_away"): + # 截取函数体(仅当前文件定义了该函数才检查) + m = re.search(rf"async def {fn}\(.*?(?=\nasync def |\n# =|\Z)", src, re.S) + if not m: + continue + body = m.group(0) + assert "selectinload" in body, ( + f"{fn} 查询 Match 但未 eager-load 关系 —— " + "this would raise MissingGreenlet in async SQLAlchemy (P0-2)" + ) + # 守卫完整性: 三个 getter 必须都能在 slices 包中找到 + all_src = "\n".join(_read(r) for r in ("llm/slices/form.py", "llm/slices/h2h.py", "llm/slices/home_away.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)" - ) + assert f"async def {fn}(" in all_src, f"{fn} 未在 slices 包中找到(拆分后迁移缺失?)" def test_backtest_candidates_eager_load(self): """回测取历史比赛必须 eager-load(否则 session 关闭后访问关系必炸)。"""