paper-dynasty-database/app/routers_v2/season_stats.py
Cal Corum 1b4eab9d99 refactor: replace incremental delta upserts with full recalculation in season stats
The previous approach accumulated per-game deltas into season stats rows,
which was fragile — partial processing corrupted stats, upsert bugs
compounded, and there was no self-healing mechanism.

Now update_season_stats() recomputes full season totals from all StratPlay
rows for each affected player whenever a game is processed. The result
replaces whatever was stored, eliminating double-counting and enabling
self-healing via force=True.

Also fixes:
- evolution_evaluator.py: broken PlayerSeasonStats import → queries
  BattingSeasonStats or PitchingSeasonStats based on card_type
- evolution_evaluator.py: r.k → r.strikeouts
- test_evolution_models.py, test_postgame_evolution.py: PlayerSeasonStats
  → BattingSeasonStats (model never existed)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 10:17:13 -05:00

68 lines
2.4 KiB
Python

"""Season stats API endpoints.
Covers WP-13 (Post-Game Callback Integration):
POST /api/v2/season-stats/update-game/{game_id}
Delegates to app.services.season_stats.update_season_stats() which
aggregates StratPlay and Decision rows for a completed game and
performs an additive upsert into player_season_stats.
Idempotency is enforced by the service layer: re-delivery of the same
game_id returns {"updated": 0, "skipped": true} without modifying stats.
"""
import logging
from fastapi import APIRouter, Depends, HTTPException
from ..dependencies import oauth2_scheme, valid_token
router = APIRouter(prefix="/api/v2/season-stats", tags=["season-stats"])
logger = logging.getLogger(__name__)
@router.post("/update-game/{game_id}")
async def update_game_season_stats(
game_id: int, force: bool = False, token: str = Depends(oauth2_scheme)
):
"""Recalculate season stats from all StratPlay and Decision rows for a game.
Calls update_season_stats(game_id, force=force) from the service layer which:
- Aggregates all StratPlay rows by (player_id, team_id, season)
- Merges Decision rows into pitching groups
- Performs an additive ON CONFLICT upsert into player_season_stats
- Guards against double-counting via the last_game FK check
Query params:
- force: if true, bypasses the idempotency guard and reprocesses a
previously seen game_id (useful for correcting stats after data fixes)
Response: {"updated": N, "skipped": false}
- N: total player_season_stats rows upserted (batters + pitchers)
- skipped: true when this game_id was already processed and force=false
Errors from the service are logged but re-raised as 500 so the bot
knows to retry.
"""
if not valid_token(token):
logging.warning("Bad Token: [REDACTED]")
raise HTTPException(status_code=401, detail="Unauthorized")
from ..services.season_stats import update_season_stats
try:
result = update_season_stats(game_id, force=force)
except Exception as exc:
logger.error("update-game/%d failed: %s", game_id, exc, exc_info=True)
raise HTTPException(
status_code=500,
detail=f"Season stats update failed for game {game_id}: {exc}",
)
updated = result.get("batters_updated", 0) + result.get("pitchers_updated", 0)
return {
"updated": updated,
"skipped": result.get("skipped", False),
}