claude-configs/skills/paper-dynasty/workflows/card_utilities.py
Cal Corum 047ec745eb Add new skills, commands, scripts; update Paper Dynasty workflows
New:
- backlog, cognitive-memory, optimise-claude skills
- commands/ and scripts/ directories
- usage-data tracking

Updated:
- Paper Dynasty: consolidated workflows, updated API client and CLI
- .gitignore, CLAUDE.md, settings.json

Removed:
- Deprecated Paper Dynasty workflows (card-refresh, database-sync,
  discord-app-troubleshooting, gauntlet-cleanup, custom-player-db)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 14:10:21 -06:00

439 lines
13 KiB
Python

#!/usr/bin/env python3
"""
Card Refresh Utility Functions
Reusable functions for regenerating and uploading player cards to S3.
HOW CACHING WORKS:
The API caches generated card images by the `d=YYYY-M-D` query parameter.
Once a card is rendered for a given date, subsequent requests return the cached
image — even if the underlying database data changed. To force regeneration,
pass a future date (typically tomorrow) as cache_bust_date.
TROUBLESHOOTING STALE CARDS:
1. Verify the database has correct values (battingcards / pitchingcards tables)
2. Ensure cache_bust_date is a future date (not today or earlier)
3. Confirm S3 upload succeeded (file exists in s3://paper-dynasty/cards/...)
4. Confirm player.image URL was updated with the new date
5. Clear browser cache / use incognito — CloudFront may also cache
S3 UPLOAD ISSUES:
- Check AWS creds: `aws sts get-caller-identity`
- Bucket: paper-dynasty (us-east-1)
- Required IAM permissions: s3:PutObject, s3:GetObject
"""
import os
import sys
import asyncio
from datetime import datetime, timedelta
from pathlib import Path
from typing import List, Dict, Optional, Tuple
import aiohttp
import boto3
from botocore.exceptions import ClientError
# Add parent directory for imports
sys.path.insert(0, '/home/cal/.claude/skills/paper-dynasty')
from api_client import PaperDynastyAPI
# AWS Configuration
AWS_BUCKET = "paper-dynasty"
AWS_REGION = "us-east-1"
def get_cache_bust_date(days_ahead: int = 1) -> str:
"""
Get a cache-busting date for card regeneration
Args:
days_ahead: Number of days in the future (default: 1 = tomorrow)
Returns:
Date string in format "YYYY-M-D"
"""
future_date = datetime.now() + timedelta(days=days_ahead)
return f"{future_date.year}-{future_date.month}-{future_date.day}"
async def fetch_card_image(
session: aiohttp.ClientSession,
player_id: int,
card_type: str,
cache_bust_date: str,
output_dir: Path
) -> Tuple[int, str, Optional[str]]:
"""
Fetch a single card image from the API
Args:
session: aiohttp session
player_id: Player ID
card_type: 'batting' or 'pitching'
cache_bust_date: Date string for cache-busting
output_dir: Directory to save the image
Returns:
Tuple of (player_id, status, file_path or error_message)
"""
card_url = f"https://pd.manticorum.com/api/v2/players/{player_id}/{card_type}card?d={cache_bust_date}"
try:
async with session.get(card_url, timeout=aiohttp.ClientTimeout(total=30)) as response:
if response.status != 200:
return (player_id, 'error', f"HTTP {response.status}")
file_path = output_dir / f"player-{player_id}-{card_type}card.png"
with open(file_path, 'wb') as f:
f.write(await response.read())
return (player_id, 'success', str(file_path))
except asyncio.TimeoutError:
return (player_id, 'error', 'Request timeout')
except Exception as e:
return (player_id, 'error', str(e))
async def fetch_cards_batch(
player_ids: List[int],
card_type: str,
cache_bust_date: str,
output_dir: Path
) -> Dict[int, str]:
"""
Fetch multiple cards in parallel
Args:
player_ids: List of player IDs
card_type: 'batting' or 'pitching'
cache_bust_date: Date string for cache-busting
output_dir: Directory to save images
Returns:
Dict mapping player_id to local file path
"""
results = {}
async with aiohttp.ClientSession() as session:
tasks = [
fetch_card_image(session, pid, card_type, cache_bust_date, output_dir)
for pid in player_ids
]
completed = await asyncio.gather(*tasks, return_exceptions=True)
for player_id, status, result in completed:
if isinstance(result, Exception):
print(f" ❌ Player {player_id}: {result}")
elif status == 'success':
results[player_id] = result
print(f" ✓ Player {player_id}: {Path(result).name}")
else:
print(f" ❌ Player {player_id}: {result}")
return results
def upload_to_s3(
file_path: str,
player_id: int,
cardset_id: int,
cache_bust_date: str,
card_type: str = 'batting'
) -> str:
"""
Upload a card image to S3
Args:
file_path: Local file path
player_id: Player ID
cardset_id: Cardset ID
cache_bust_date: Date string for cache-busting
card_type: 'batting' or 'pitching'
Returns:
S3 URL with cache-busting query parameter
"""
s3_client = boto3.client('s3', region_name=AWS_REGION)
s3_key = f"cards/cardset-{cardset_id:03d}/player-{player_id}/{card_type}card.png"
try:
with open(file_path, 'rb') as f:
s3_client.put_object(
Bucket=AWS_BUCKET,
Key=s3_key,
Body=f,
ContentType='image/png',
CacheControl='public, max-age=31536000'
)
s3_url = f"https://{AWS_BUCKET}.s3.{AWS_REGION}.amazonaws.com/{s3_key}?d={cache_bust_date}"
return s3_url
except ClientError as e:
raise Exception(f"S3 upload failed: {e}")
def regenerate_cards_for_players(
player_ids: List[int],
cardset_id: int,
cache_bust_date: Optional[str] = None,
upload_to_s3_flag: bool = True,
update_player_records: bool = True,
environment: str = 'prod',
card_type: str = 'batting',
batch_size: int = 50
) -> Dict:
"""
Complete pipeline: fetch cards, upload to S3, update player records
Args:
player_ids: List of player IDs to refresh
cardset_id: Cardset ID
cache_bust_date: Date for cache-busting (default: tomorrow)
upload_to_s3_flag: Whether to upload to S3
update_player_records: Whether to update player.image URLs
environment: 'prod' or 'dev'
card_type: 'batting' or 'pitching'
batch_size: Number of players per batch
Returns:
Dict with 'success', 'failures', 'total'
"""
if cache_bust_date is None:
cache_bust_date = get_cache_bust_date()
# Create output directory
output_dir = Path("/tmp/card_refresh") / f"{cardset_id}_{cache_bust_date.replace('-', '')}"
output_dir.mkdir(parents=True, exist_ok=True)
api = PaperDynastyAPI(environment=environment, verbose=False)
results = {
'success': 0,
'failures': [],
'total': len(player_ids),
's3_urls': {}
}
print(f"Regenerating {len(player_ids)} cards for cardset {cardset_id}")
print(f"Cache-bust date: {cache_bust_date}")
print(f"Output dir: {output_dir}")
print()
# Process in batches
for i in range(0, len(player_ids), batch_size):
batch = player_ids[i:i + batch_size]
batch_num = (i // batch_size) + 1
total_batches = (len(player_ids) + batch_size - 1) // batch_size
print(f"Batch {batch_num}/{total_batches} ({len(batch)} players)")
# Fetch cards
local_files = asyncio.run(fetch_cards_batch(
batch, card_type, cache_bust_date, output_dir
))
# Upload to S3 and update records
for player_id, file_path in local_files.items():
try:
if upload_to_s3_flag:
s3_url = upload_to_s3(
file_path, player_id, cardset_id, cache_bust_date, card_type
)
results['s3_urls'][player_id] = s3_url
if update_player_records:
api.patch('players', object_id=player_id, params=[('image', s3_url)])
results['success'] += 1
except Exception as e:
print(f" ❌ Failed to process player {player_id}: {e}")
results['failures'].append({'player_id': player_id, 'error': str(e)})
print()
return results
def verify_switch_hitters(cardset_id: int, environment: str = 'prod') -> Dict:
"""
Verify all switch hitters in a cardset have correct handedness
Args:
cardset_id: Cardset ID to check
environment: 'prod' or 'dev'
Returns:
Dict with 'correct', 'incorrect', and 'details'
"""
api = PaperDynastyAPI(environment=environment, verbose=False)
# Get all batting cards for the cardset
players = api.get('players', params=[('cardset_id', cardset_id)])['players']
results = {
'correct': [],
'incorrect': [],
'total_checked': 0
}
print(f"Checking switch hitters in cardset {cardset_id}...")
for player in players:
player_id = player['player_id']
# Check if player has a batting card
try:
# Get battingcard record via API v2
bc_response = api.get('battingcards', params=[
('player_id', player_id),
('variant', 0)
])
if bc_response['count'] == 0:
continue # No batting card
battingcard = bc_response['battingcards'][0]
hand = battingcard.get('hand')
if hand == 'S':
results['total_checked'] += 1
# Verify image URL has recent date (not cached with old data)
image_url = player.get('image', '')
# Check if image is from S3 and relatively recent
if 's3.amazonaws.com' in image_url:
results['correct'].append({
'player_id': player_id,
'name': player['p_name'],
'hand': hand,
'image_url': image_url
})
else:
results['incorrect'].append({
'player_id': player_id,
'name': player['p_name'],
'hand': hand,
'issue': 'No S3 URL',
'image_url': image_url
})
except Exception as e:
print(f" Error checking player {player_id}: {e}")
continue
print(f"\nResults:")
print(f" Total switch hitters: {results['total_checked']}")
print(f" Correct: {len(results['correct'])}")
print(f" Needs refresh: {len(results['incorrect'])}")
if results['incorrect']:
print(f"\nSwitch hitters needing refresh:")
for player in results['incorrect']:
print(f" - {player['name']} (ID: {player['player_id']}): {player['issue']}")
return results
def regenerate_cards_for_cardset(
cardset_id: int,
environment: str = 'prod',
cache_bust_date: Optional[str] = None,
player_filter: Optional[Dict] = None,
batch_size: int = 50
) -> Dict:
"""
Regenerate all cards for an entire cardset
Args:
cardset_id: Cardset ID
environment: 'prod' or 'dev'
cache_bust_date: Date for cache-busting (default: tomorrow)
player_filter: Optional dict of filters (e.g., {'pos_include': 'SP'})
batch_size: Players per batch
Returns:
Dict with results
"""
api = PaperDynastyAPI(environment=environment, verbose=False)
# Get players
params = [('cardset_id', cardset_id)]
if player_filter:
for key, value in player_filter.items():
params.append((key, value))
players = api.get('players', params=params)['players']
# Separate batters and pitchers
batters = []
pitchers = []
for player in players:
positions = [player.get(f'pos_{i}') for i in range(1, 9)]
positions = [p for p in positions if p]
if any(pos in ['SP', 'RP', 'CP'] for pos in positions):
pitchers.append(player['player_id'])
else:
batters.append(player['player_id'])
print(f"Cardset {cardset_id}: {len(batters)} batters, {len(pitchers)} pitchers")
results = {'batters': {}, 'pitchers': {}}
if batters:
print("\n=== REGENERATING BATTING CARDS ===")
results['batters'] = regenerate_cards_for_players(
batters, cardset_id, cache_bust_date,
upload_to_s3_flag=True,
update_player_records=True,
environment=environment,
card_type='batting',
batch_size=batch_size
)
if pitchers:
print("\n=== REGENERATING PITCHING CARDS ===")
results['pitchers'] = regenerate_cards_for_players(
pitchers, cardset_id, cache_bust_date,
upload_to_s3_flag=True,
update_player_records=True,
environment=environment,
card_type='pitching',
batch_size=batch_size
)
return results
if __name__ == "__main__":
# Example usage
print("Card Utilities - Example Usage\n")
# Get tomorrow's date
tomorrow = get_cache_bust_date()
print(f"Tomorrow's date: {tomorrow}\n")
# Example: Refresh specific players
# player_ids = [12785, 12788, 12854]
# results = regenerate_cards_for_players(
# player_ids=player_ids,
# cardset_id=27,
# cache_bust_date=tomorrow,
# upload_to_s3_flag=True,
# update_player_records=True
# )
# print(f"Success: {results['success']}/{results['total']}")
# Example: Verify switch hitters
# verify_switch_hitters(cardset_id=27, environment='prod')
# Example: Refresh entire cardset
# regenerate_cards_for_cardset(cardset_id=27, batch_size=50)