Files
Profeto/src/llm/slices/standings.py
T
shangfangjian 49d78136a1 fix(P0-02): 积分榜改为追加快照(append-only) + available_at cutoff
去掉 uq_standings_league_season_team,改为 (league, season, team, available_at) 唯一;
每次采集 INSERT 新行(available_at=now),ON CONFLICT DO NOTHING,不覆盖旧行。

standings_slice(before):DISTINCT ON (team_id) WHERE available_at<=cutoff
ORDER available_at DESC;before=None → cutoff=now()。
公开 list_standings 取每队最新可用快照(子查询 max available_at)。

迁移 0023 + 切片/路由/docs 同步;测试 test_p0_standings_cutoff(5/5)。
284 测试全绿。
2026-09-22 02:45:50 +08:00

92 lines
4.1 KiB
Python

"""D - 联赛排名切片: 积分榜快照(支持 cutoff 的历史还原,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.db.models import League, Standing
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)、分区,评估整体实力差距。
P0-02: 支持 cutoff(before)。取 available_at<=cutoff 的每队最新快照
(DISTINCT ON team_id ORDER available_at DESC);before=None 时 cutoff=now()。
回测时可还原历史时刻榜单,不再只是"最新快照、忽略 cutoff"。
db: 可选共享 session(见 context_builder 模块 docstring)。
语义区分:
- 两队都有积分榜行 → has_data=True(明确的排名信息)
- 任一队缺失 → has_data=False(升班马/杯赛无榜,信息不完整时明确声明)
"""
# P0-02: before=None → cutoff=now()(取最新可用快照)
if before is None:
from datetime import datetime, timezone
before = datetime.now(timezone.utc)
if db is not None:
league = (await db.execute(select(League).where(League.id == header.league_id))).scalar_one_or_none()
# P0-02: DISTINCT ON (team_id) 取 available_at<=cutoff 的最新快照
rows = (
(
await db.execute(
select(Standing)
.options(selectinload(Standing.team))
.where(Standing.league_id == header.league_id)
.where(Standing.available_at <= before)
.distinct(Standing.team_id)
.order_by(Standing.team_id, Standing.available_at.desc())
)
)
.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)