Files
commander-ai-lab/coach/report_generator.py
T
2026-03-10 12:13:56 -04:00

355 lines
12 KiB
Python

"""
Commander AI Lab — Deck Report Generator (Python)
══════════════════════════════════════════════════
Reads batch result JSON files from results/ and generates
deck report JSONs in deck-reports/ that the coach service consumes.
This is the Python equivalent of Java ReportAggregator, allowing
report generation without a separate Java invocation.
"""
import json
import logging
from datetime import datetime, timezone
from pathlib import Path
from typing import List, Optional, Dict
from .config import DECK_REPORTS_DIR
logger = logging.getLogger("coach.report_generator")
UNDERPERFORMER_THRESHOLD = -0.05
MAX_UNDERPERFORMERS = 8
MAX_OVERPERFORMERS = 8
def generate_deck_reports(results_dir: str, reports_dir: str = None) -> List[str]:
"""
Scan all batch-*.json files in results_dir and generate
one deck report per unique deck across all batches.
Returns list of deck IDs that were generated.
"""
results_path = Path(results_dir)
output_path = Path(reports_dir) if reports_dir else DECK_REPORTS_DIR
output_path.mkdir(parents=True, exist_ok=True)
# Load all batch results
batch_files = sorted(results_path.glob("batch-*.json"))
if not batch_files:
logger.info("No batch result files found in %s", results_dir)
return []
all_batches = []
for bf in batch_files:
try:
with open(bf, "r", encoding="utf-8") as f:
data = json.load(f)
all_batches.append(data)
except Exception as e:
logger.warning("Failed to load %s: %s", bf, e)
if not all_batches:
return []
# Find all unique decks across all batches
deck_seats: Dict[str, dict] = {} # deckName -> {seatIndex, commanderName, colorIdentity}
for batch in all_batches:
for di in batch.get("decks", []):
name = di.get("deckName", "")
if name and name not in deck_seats:
deck_seats[name] = {
"seatIndex": di.get("seatIndex", 0),
"commanderName": di.get("commanderName", ""),
"colorIdentity": di.get("colorIdentity", []),
}
# Generate report for each deck
generated = []
for deck_name, deck_info in deck_seats.items():
try:
report = _build_deck_report(deck_name, deck_info, all_batches)
out_file = output_path / f"{deck_name}.json"
with open(out_file, "w", encoding="utf-8") as f:
json.dump(report, f, indent=2)
generated.append(deck_name)
logger.info(
"Generated report for %s: %d games, %.1f%% win rate",
deck_name,
report["meta"]["gamesSimulated"],
report["meta"]["overallWinRate"] * 100,
)
except Exception as e:
logger.error("Failed to generate report for %s: %s", deck_name, e)
return generated
def generate_single_deck_report(batch_json_path: str, reports_dir: str = None) -> List[str]:
"""
Generate deck reports from a single batch result JSON file.
Merges with existing reports if they exist.
Returns list of deck IDs that were updated.
"""
output_path = Path(reports_dir) if reports_dir else DECK_REPORTS_DIR
output_path.mkdir(parents=True, exist_ok=True)
try:
with open(batch_json_path, "r", encoding="utf-8") as f:
batch = json.load(f)
except Exception as e:
logger.error("Failed to load batch result: %s", e)
return []
# Also load existing batch results for comprehensive reports
results_dir = Path(batch_json_path).parent
all_batches = []
for bf in sorted(results_dir.glob("batch-*.json")):
try:
with open(bf, "r", encoding="utf-8") as f:
all_batches.append(json.load(f))
except Exception:
continue
if not all_batches:
all_batches = [batch]
# Generate reports for all decks in this batch
generated = []
for di in batch.get("decks", []):
deck_name = di.get("deckName", "")
if not deck_name:
continue
deck_info = {
"seatIndex": di.get("seatIndex", 0),
"commanderName": di.get("commanderName", ""),
"colorIdentity": di.get("colorIdentity", []),
}
try:
report = _build_deck_report(deck_name, deck_info, all_batches)
out_file = output_path / f"{deck_name}.json"
with open(out_file, "w", encoding="utf-8") as f:
json.dump(report, f, indent=2)
generated.append(deck_name)
logger.info(
"Updated report for %s: %d games, %.1f%% win rate",
deck_name,
report["meta"]["gamesSimulated"],
report["meta"]["overallWinRate"] * 100,
)
except Exception as e:
logger.error("Failed to generate report for %s: %s", deck_name, e)
return generated
def _build_deck_report(deck_name: str, deck_info: dict, all_batches: list) -> dict:
"""Build a complete deck report from batch data."""
seat_index = deck_info["seatIndex"]
commander = deck_info.get("commanderName", deck_name)
colors = deck_info.get("colorIdentity", [])
total_games = 0
total_wins = 0
total_turns = 0
# Per-card accumulators
card_map: Dict[str, dict] = {}
# Matchup tracking
matchup_map: Dict[str, List[int]] = {} # opponent -> [games, wins]
for batch in all_batches:
# Check if this deck is in this batch
batch_deck = None
for di in batch.get("decks", []):
if di.get("deckName") == deck_name:
batch_deck = di
break
if batch_deck is None:
continue
batch_seat = batch_deck.get("seatIndex", seat_index)
# Update commander/colors from batch if we don't have them
if not commander or commander == deck_name:
commander = batch_deck.get("commanderName", deck_name)
if not colors:
colors = batch_deck.get("colorIdentity", [])
# Track opponents
opponents = [
di.get("deckName", "")
for di in batch.get("decks", [])
if di.get("seatIndex") != batch_seat
]
# Process each game
for game in batch.get("games", []):
total_games += 1
total_turns += game.get("totalTurns", 0)
# Find this deck's player result
my_result = None
for pr in game.get("playerResults", []):
if pr.get("seatIndex") == batch_seat:
my_result = pr
break
if my_result is None:
continue
won = my_result.get("isWinner", False)
if won:
total_wins += 1
# Matchups
for opp in opponents:
if opp not in matchup_map:
matchup_map[opp] = [0, 0]
matchup_map[opp][0] += 1
if won:
matchup_map[opp][1] += 1
# Per-card stats
for pcs in my_result.get("cardStats", []):
card_name = pcs.get("cardName", "")
if not card_name:
continue
if card_name not in card_map:
card_map[card_name] = {
"name": card_name,
"gamesSeen": 0,
"gamesDrawn": 0,
"gamesCast": 0,
"gamesWhenCast": 0,
"winsWhenCast": 0,
"gamesDeadCard": 0,
"gamesKeptInOpening": 0,
"totalTurnCast": 0.0,
"totalDamage": 0.0,
}
cp = card_map[card_name]
cp["gamesSeen"] += 1
if pcs.get("drawn", False):
cp["gamesDrawn"] += 1
if pcs.get("cast", False):
cp["gamesCast"] += 1
cp["gamesWhenCast"] += 1
if won:
cp["winsWhenCast"] += 1
turn_cast = pcs.get("turnCast", -1)
if turn_cast >= 0:
cp["totalTurnCast"] += turn_cast
if pcs.get("stuckInHand", False):
cp["gamesDeadCard"] += 1
if pcs.get("inOpeningHand", False) and pcs.get("keptInOpeningHand", False):
cp["gamesKeptInOpening"] += 1
cp["totalDamage"] += pcs.get("damageDealt", 0)
# ── Compute deck-level stats ──────────────────────────────
overall_win_rate = total_wins / total_games if total_games > 0 else 0.0
avg_game_length = total_turns / total_games if total_games > 0 else 0.0
# ── Compute matchups ──────────────────────────────────────
matchups = []
for opp_name, stats in matchup_map.items():
matchups.append({
"opponentDeck": opp_name,
"opponentCommander": "",
"gamesPlayed": stats[0],
"winRate": stats[1] / stats[0] if stats[0] > 0 else 0.0,
})
# ── Compute per-card performance ──────────────────────────
cards = []
for cp in card_map.values():
gs = cp["gamesSeen"]
if gs == 0:
continue
drawn_rate = cp["gamesDrawn"] / gs
cast_rate = cp["gamesCast"] / cp["gamesDrawn"] if cp["gamesDrawn"] > 0 else 0.0
dead_card_rate = cp["gamesDeadCard"] / cp["gamesDrawn"] if cp["gamesDrawn"] > 0 else 0.0
kept_rate = cp["gamesKeptInOpening"] / gs
# Impact score
win_rate_when_cast = (
cp["winsWhenCast"] / cp["gamesWhenCast"]
if cp["gamesWhenCast"] > 0
else 0.0
)
impact_score = (win_rate_when_cast - overall_win_rate) * cast_rate
# Clunkiness
clunkiness = dead_card_rate * (1.0 - cast_rate)
# Average turn cast
avg_turn_cast = (
cp["totalTurnCast"] / cp["gamesCast"] if cp["gamesCast"] > 0 else None
)
avg_damage = cp["totalDamage"] / gs
cards.append({
"name": cp["name"],
"drawnRate": round(drawn_rate, 4),
"castRate": round(cast_rate, 4),
"keptInOpeningHandRate": round(kept_rate, 4),
"deadCardRate": round(dead_card_rate, 4),
"impactScore": round(impact_score, 4),
"synergyScore": 0.0,
"clunkinessScore": round(clunkiness, 4),
"avgTurnCast": round(avg_turn_cast, 2) if avg_turn_cast is not None else None,
"avgDamageDealt": round(avg_damage, 2),
"tags": [],
})
# ── Identify under/over performers ────────────────────────
sorted_by_impact = sorted(cards, key=lambda c: c["impactScore"])
underperformers = []
for c in sorted_by_impact[:MAX_UNDERPERFORMERS]:
if c["impactScore"] < UNDERPERFORMER_THRESHOLD:
underperformers.append(c["name"])
overperformers = []
for c in reversed(sorted_by_impact[-MAX_OVERPERFORMERS:]):
if c["impactScore"] > 0:
overperformers.append(c["name"])
# ── Build final report ────────────────────────────────────
return {
"deckId": deck_name,
"commander": commander,
"colorIdentity": colors,
"meta": {
"gamesSimulated": total_games,
"overallWinRate": round(overall_win_rate, 4),
"avgGameLength": round(avg_game_length, 2),
"perArchetypeWinRates": {},
},
"matchups": matchups,
"structure": {
"landCount": 0,
"curveBuckets": [0] * 8,
"cardTypeCounts": {},
"functionalCounts": {},
},
"cards": cards,
"underperformers": underperformers,
"overperformers": overperformers,
"knownCombos": [],
"lastUpdated": datetime.now(timezone.utc).isoformat(),
}