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
+67
View File
@@ -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)