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:
shangfangjian
2026-09-22 01:33:14 +08:00
parent f1016b610a
commit 44816794d3
13 changed files with 677 additions and 563 deletions
+85
View File
@@ -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())