refactor: context_builder 按 slice 拆到 src/llm/slices/ 包
单文件拆分(仅搬迁无逻辑修改): - 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 通过。
This commit is contained in:
+28
-543
@@ -1,9 +1,9 @@
|
|||||||
"""上下文构建器:数据切片 + 拼接。
|
"""上下文构建器:数据切片 + 拼接(聚合门面)。
|
||||||
|
|
||||||
架构:
|
实现按 slice 拆分(单文件 → slices 包),本模块只做再导出:
|
||||||
- match_header: 比赛基础信息(对阵双方/联赛/时间)
|
- 切片函数: 每个领域 agent 一个数据切片 → src/llm/slices/{form,h2h,stats,home_away,standings}.py
|
||||||
- 切片函数: 每个领域 agent 一个数据切片(h2h / form / standings / stats)
|
- 共享类型/头信息/查询助手 → src/llm/slices/common.py
|
||||||
- build_context: 单 agent 路径,拼接全部切片(行为与旧版一致)
|
- build_context: 单 agent 路径,拼接全部切片(行为与旧版一致) → src/llm/slices/aggregate.py
|
||||||
|
|
||||||
multi-agent 路径由 agents/orchestrator.py 调用切片函数,每个专家只拿自己的切片。
|
multi-agent 路径由 agents/orchestrator.py 调用切片函数,每个专家只拿自己的切片。
|
||||||
|
|
||||||
@@ -11,548 +11,33 @@ multi-agent 路径由 agents/orchestrator.py 调用切片函数,每个专家只
|
|||||||
build_context 创建一个共享 session 并传给所有切片函数,
|
build_context 创建一个共享 session 并传给所有切片函数,
|
||||||
避免每个切片独立创建 session —— 回测 20 场并发时,
|
避免每个切片独立创建 session —— 回测 20 场并发时,
|
||||||
5 个切片 × 20 场 = 100 个连接会耗尽连接池(pool_size=15)。
|
5 个切片 × 20 场 = 100 个连接会耗尽连接池(pool_size=15)。
|
||||||
|
|
||||||
|
消费方(路由/orchestrator/tests)仍从本模块 import,签名与拆分前完全一致。
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
# ── 共享类型与基础(判空/赛果/统计可用性) ──
|
||||||
from dataclasses import dataclass
|
from src.llm.slices.common import ( # noqa: F401
|
||||||
from typing import TYPE_CHECKING
|
MatchContext,
|
||||||
|
MatchHeader,
|
||||||
from sqlalchemy import select
|
SliceResult,
|
||||||
from sqlalchemy.orm import selectinload
|
_is_stats_available,
|
||||||
|
_outcome,
|
||||||
from src.db.base import AsyncSessionLocal
|
header_text,
|
||||||
from src.db.models import Match
|
load_match_header,
|
||||||
|
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ── 领域切片 ──
|
||||||
|
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
|
||||||
|
|
||||||
def header_text(h: MatchHeader) -> str:
|
# ── 底层查询助手(切片函数共用;orchestrator/tests 直接引用) ──
|
||||||
stage = f" {h.stage}" if h.stage else ""
|
from src.llm.slices.form import _get_form # noqa: F401
|
||||||
return (
|
from src.llm.slices.h2h import _get_h2h # noqa: F401
|
||||||
f"对阵: {h.home_name} vs {h.away_name} | {h.league_name} {h.season or '?'}{stage} | {h.match_date}"
|
from src.llm.slices.home_away import _get_home_away # noqa: F401
|
||||||
)
|
|
||||||
|
|
||||||
|
# ── 单 agent 聚合入口 ──
|
||||||
# ============================================================
|
from src.llm.slices.aggregate import build_context # noqa: F401
|
||||||
# 切片函数: 每个领域 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())
|
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""slices 包:按领域拆分的数据切片(form/stats/h2h/home_away/standings)。
|
||||||
|
|
||||||
|
对外统一经 src.llm.context_builder 再导出;本包 __init__ 不承载导出,
|
||||||
|
保持「context_builder 是唯一公开入口」的 import 约定。
|
||||||
|
"""
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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}"
|
||||||
|
)
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -70,7 +70,7 @@ class TestH2HCurrentHomePerspective:
|
|||||||
_make_h2h_match(101, home_id=2, away_id=1, home_goals=3, away_goals=1,
|
_make_h2h_match(101, home_id=2, away_id=1, home_goals=3, away_goals=1,
|
||||||
home_name="阿森纳", away_name="利物浦"),
|
home_name="阿森纳", away_name="利物浦"),
|
||||||
]
|
]
|
||||||
import src.llm.context_builder as cb
|
import src.llm.slices.h2h as cb
|
||||||
orig = cb._get_h2h
|
orig = cb._get_h2h
|
||||||
async def mock_get_h2h(db, home_id, away_id, before, *, limit):
|
async def mock_get_h2h(db, home_id, away_id, before, *, limit):
|
||||||
return matches
|
return matches
|
||||||
@@ -100,7 +100,7 @@ class TestH2HCurrentHomePerspective:
|
|||||||
_make_h2h_match(201, home_id=1, away_id=2, home_goals=2, away_goals=1,
|
_make_h2h_match(201, home_id=1, away_id=2, home_goals=2, away_goals=1,
|
||||||
home_name="曼城", away_name="诺维奇"),
|
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 def mock_get_h2h(db, h, a, before, **kw):
|
||||||
# 真实契约是 async(见 context_builder.py 的 `h2h = await _get_h2h(...)`),
|
# 真实契约是 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,
|
_make_h2h_match(301, home_id=2, away_id=1, home_goals=0, away_goals=2,
|
||||||
home_name="热刺", away_name="切尔西"), # 切尔西客场 2-0 赢
|
home_name="热刺", away_name="切尔西"), # 切尔西客场 2-0 赢
|
||||||
]
|
]
|
||||||
import src.llm.context_builder as cb
|
import src.llm.slices.h2h as cb
|
||||||
orig = cb._get_h2h
|
orig = cb._get_h2h
|
||||||
async def mock_get_h2h(db, h, a, before, *, limit):
|
async def mock_get_h2h(db, h, a, before, *, limit):
|
||||||
return matches
|
return matches
|
||||||
|
|||||||
@@ -206,7 +206,7 @@ class TestBacktestXgNotVisible:
|
|||||||
|
|
||||||
header = _make_header(match_dt)
|
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):
|
async def mock_get_form(db, team_id, before, *, limit=10):
|
||||||
# before=cutoff(1月13日),比赛在1月15日,满足 before 条件
|
# before=cutoff(1月13日),比赛在1月15日,满足 before 条件
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ class TestFormSliceHomeAwayIdentity:
|
|||||||
home_name="曼城",
|
home_name="曼城",
|
||||||
away_name="利物浦",
|
away_name="利物浦",
|
||||||
)
|
)
|
||||||
import src.llm.context_builder as cb
|
import src.llm.slices.form as cb
|
||||||
orig_get_form = cb._get_form
|
orig_get_form = cb._get_form
|
||||||
async def mock_get_form(db, team_id, before, *, limit):
|
async def mock_get_form(db, team_id, before, *, limit):
|
||||||
return [hist_match] if team_id == 1 else []
|
return [hist_match] if team_id == 1 else []
|
||||||
@@ -132,7 +132,7 @@ class TestFormSliceHomeAwayIdentity:
|
|||||||
home_name="阿森纳",
|
home_name="阿森纳",
|
||||||
away_name="切尔西",
|
away_name="切尔西",
|
||||||
)
|
)
|
||||||
import src.llm.context_builder as cb
|
import src.llm.slices.form as cb
|
||||||
orig_get_form = cb._get_form
|
orig_get_form = cb._get_form
|
||||||
async def mock_get_form(db, team_id, before, *, limit):
|
async def mock_get_form(db, team_id, before, *, limit):
|
||||||
return [hist_match] if team_id == 2 else []
|
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,
|
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),
|
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
|
orig_get_form = cb._get_form
|
||||||
async def mock_get_form(db, team_id, before, *, limit):
|
async def mock_get_form(db, team_id, before, *, limit):
|
||||||
return [hist_match] if team_id == 1 else []
|
return [hist_match] if team_id == 1 else []
|
||||||
|
|||||||
@@ -32,17 +32,24 @@ class TestEagerLoadCoverage:
|
|||||||
|
|
||||||
models.py 已声明 lazy="selectin" 兜底,但这里同时检查显式
|
models.py 已声明 lazy="selectin" 兜底,但这里同时检查显式
|
||||||
selectinload —— 显式声明是查询意图的固化,也被 P0 修复所依赖。
|
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"):
|
for fn in ("_get_form", "_get_h2h", "_get_home_away"):
|
||||||
# 截取函数体
|
# 截取函数体(仅当前文件定义了该函数才检查)
|
||||||
m = re.search(rf"async def {fn}\(.*?(?=\nasync def |\n# =|\Z)", src, re.S)
|
m = re.search(rf"async def {fn}\(.*?(?=\nasync def |\n# =|\Z)", src, re.S)
|
||||||
assert m, f"{fn} 未找到"
|
if not m:
|
||||||
|
continue
|
||||||
body = m.group(0)
|
body = m.group(0)
|
||||||
assert "selectinload" in body, (
|
assert "selectinload" in body, (
|
||||||
f"{fn} 查询 Match 但未 eager-load 关系 —— "
|
f"{fn} 查询 Match 但未 eager-load 关系 —— "
|
||||||
"this would raise MissingGreenlet in async SQLAlchemy (P0-2)"
|
"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"):
|
||||||
|
assert f"async def {fn}(" in all_src, f"{fn} 未在 slices 包中找到(拆分后迁移缺失?)"
|
||||||
|
|
||||||
def test_backtest_candidates_eager_load(self):
|
def test_backtest_candidates_eager_load(self):
|
||||||
"""回测取历史比赛必须 eager-load(否则 session 关闭后访问关系必炸)。"""
|
"""回测取历史比赛必须 eager-load(否则 session 关闭后访问关系必炸)。"""
|
||||||
|
|||||||
Reference in New Issue
Block a user