去掉 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 测试全绿。
92 lines
4.1 KiB
Python
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)
|