feat(coach): Phase 1 sim stats pass-through in prompt template (#108)

Surfaces previously-computed-but-unused stats into the Claude prompt:

1. USER_PROMPT_TEMPLATE gains a new '### Sim Insights' section that shows:
   - Deck-level opening hand quality: avg keptInOpeningHandRate across all
     cards, plus the 3 worst offenders with their actual rates
   - Curve velocity: avg avgTurnCast weighted by castRate, flagging cards
     cast on avg turn > 6 as 'late'
   - Synergy outliers: bottom-3 synergyScore cards flagged as 'isolated'
   - Archetype weak spots: archetypes with winRate < 40% flagged explicitly

2. _format_archetypes() now annotates weak spots with [WEAK SPOT] and
   strong matchups with [STRENGTH] so Claude sees them immediately.

3. _format_top_cards() adds keptInOpeningHandRate to each line so
   the 'good' cards also show their hand-keepability signal.

4. New helper _format_sim_insights() computes all deck-level aggregates
   from CardPerformance list — no new fields on DeckMeta needed.

No schema changes. Fully backward-compatible with existing DeckReport.
This commit is contained in:
KoalaTrapLord committed 2026-03-29 05:55:51 -04:00
1 parent e77a945805
commit e1bb2e7209
1 file changed
+289 -161
+289 -161
View File
@@ -4,6 +4,13 @@ Commander AI Lab — Coach Prompt Template
Constructs system and user prompts for the LLM coach. Constructs system and user prompts for the LLM coach.
Injects deck report data, underperformer analysis, and Injects deck report data, underperformer analysis, and
candidate replacement cards into a structured template. candidate replacement cards into a structured template.
Phase 1 additions (#108):
- Sim Insights section: deck-level opening hand quality, curve velocity,
synergy outliers, and archetype weak spots surfaced directly in the
user prompt so Claude reasons from numbers, not vibes.
- _format_archetypes() annotates weak spots and strengths inline.
- _format_top_cards() adds keptInOpeningHandRate per card.
""" """
from typing import List, Dict, Optional from typing import List, Dict, Optional
@@ -12,9 +19,9 @@ from .models import DeckReport, CoachGoals, CardPerformance
from .config import MAX_CANDIDATES_PER_UNDERPERFORMER, MAX_UNDERPERFORMERS from .config import MAX_CANDIDATES_PER_UNDERPERFORMER, MAX_UNDERPERFORMERS
# ══════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════
# System Prompt # System Prompt
# ══════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════
SYSTEM_PROMPT_TEMPLATE = """You are an expert Magic: The Gathering Commander deck coach. SYSTEM_PROMPT_TEMPLATE = """You are an expert Magic: The Gathering Commander deck coach.
You analyze deck performance reports from simulation data and provide specific, You analyze deck performance reports from simulation data and provide specific,
@@ -36,6 +43,11 @@ RULES:
- When reasoning about cuts, also consider keptInOpeningHandRate (low = bad in opening hands) and avgTurnCast (high = too slow for the curve). - When reasoning about cuts, also consider keptInOpeningHandRate (low = bad in opening hands) and avgTurnCast (high = too slow for the curve).
- When evaluating synergy, use the synergyScore: cards below 0.1 have weak co-occurrence with the rest of the deck. - When evaluating synergy, use the synergyScore: cards below 0.1 have weak co-occurrence with the rest of the deck.
- Use perArchetypeWinRates to identify which matchup types this deck struggles with and tailor suggestions accordingly. - Use perArchetypeWinRates to identify which matchup types this deck struggles with and tailor suggestions accordingly.
- The Sim Insights section shows pre-computed deck-level signals — use these directly in your reasoning.
- Cards flagged as [OPENING HAND TRAP] have keptInOpeningHandRate below the deck average — treat as mulligan liabilities.
- Cards flagged as [LATE CASTER] have avgTurnCast > 6 — consider whether they fit the deck's tempo.
- Cards flagged as [ISOLATED] have synergyScore in the bottom 3 of the deck — they may not support the gameplan.
- Archetypes flagged as [WEAK SPOT] in the Archetype Win Rates section are where this deck loses most — prioritize fixes for those matchups.
- Provide at least 5 heuristic hints covering different strategic dimensions. - Provide at least 5 heuristic hints covering different strategic dimensions.
- Assess how dependent the deck is on the commander being in play. - Assess how dependent the deck is on the commander being in play.
- Evaluate opening hand quality and identify cards that cause bad mulligans. - Evaluate opening hand quality and identify cards that cause bad mulligans.
@@ -110,7 +122,7 @@ You MUST respond ONLY with valid JSON matching this exact structure:
"Detailed strategic tip explaining the reasoning and data behind the suggestion" "Detailed strategic tip explaining the reasoning and data behind the suggestion"
], ],
"manaBaseAdvice": "Specific mana base improvement suggestion including land count, color fixing, and utility lands, or null if mana base is solid", "manaBaseAdvice": "Specific mana base improvement suggestion including land count, color fixing, and utility lands, or null if mana base is solid",
"rawTextExplanation": "3-5 paragraph in-depth analysis covering: (1) overall deck strategy assessment and how well the deck executes its game plan based on simulation data, (2) mana curve and resource analysis with specific numbers including avgTurnCast for key cards, (3) card synergy evaluation highlighting the strongest and weakest interactions using synergyScore data, (4) meta considerations and matchup-specific insights referencing perArchetypeWinRates, and (5) prioritized list of improvements ranked by expected impact on win rate", "rawTextExplanation": "3-5 paragraph in-depth analysis covering: (1) overall deck strategy assessment and how well the deck executes its game plan based on simulation data, (2) mana curve and resource analysis with specific numbers including avgTurnCast for key cards, (3) card synergy evaluation highlighting the strongest and weakest interactions using synergyScore data, (4) meta considerations and matchup-specific insights referencing perArchetypeWinRates and the [WEAK SPOT] archetypes flagged in Sim Insights, and (5) prioritized list of improvements ranked by expected impact on win rate",
"removalCoverage": {{ "removalCoverage": {{
"creatures": "Assessment of creature removal", "creatures": "Assessment of creature removal",
"artifacts": "Assessment of artifact removal", "artifacts": "Assessment of artifact removal",
@@ -174,9 +186,9 @@ You MUST respond ONLY with valid JSON matching this exact structure:
Do NOT include any text outside the JSON object. No markdown fences, no preamble.""" Do NOT include any text outside the JSON object. No markdown fences, no preamble."""
# ══════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════
# Quick Digest Prompt # Quick Digest Prompt
# ══════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════
QUICK_DIGEST_SYSTEM_PROMPT = """You are an expert Magic: The Gathering Commander deck coach. QUICK_DIGEST_SYSTEM_PROMPT = """You are an expert Magic: The Gathering Commander deck coach.
Provide a FAST summary analysis of this deck. Be concise. Provide a FAST summary analysis of this deck. Be concise.
@@ -184,6 +196,8 @@ Provide a FAST summary analysis of this deck. Be concise.
RULES: RULES:
- NEVER suggest cutting the commander ({commander}). - NEVER suggest cutting the commander ({commander}).
- Only suggest cards within the deck's color identity: {color_identity}. - Only suggest cards within the deck's color identity: {color_identity}.
- Cards flagged [OPENING HAND TRAP] have low keptInOpeningHandRate — prime cut candidates.
- Cards flagged [WEAK SPOT] in archetype win rates show where the deck loses most.
{goals_section} {goals_section}
You MUST respond ONLY with valid JSON matching this exact structure: You MUST respond ONLY with valid JSON matching this exact structure:
@@ -207,9 +221,9 @@ You MUST respond ONLY with valid JSON matching this exact structure:
Do NOT include any text outside the JSON object. No markdown fences, no preamble.""" Do NOT include any text outside the JSON object. No markdown fences, no preamble."""
# ══════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════
# User Prompt Builder # User Prompt Template
# ══════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════
USER_PROMPT_TEMPLATE = """## Deck Report: {deck_name} USER_PROMPT_TEMPLATE = """## Deck Report: {deck_name}
Commander: {commander} Commander: {commander}
@@ -224,6 +238,9 @@ Avg Game Length: {avg_game_length:.1f} turns
- Card Types: {type_summary} - Card Types: {type_summary}
- Functional Roles: {role_summary} - Functional Roles: {role_summary}
### Sim Insights (pre-computed deck-level signals)
{sim_insights_section}
### Matchup Data ### Matchup Data
{matchup_section} {matchup_section}
@@ -246,98 +263,195 @@ Avg Game Length: {avg_game_length:.1f} turns
Based on this simulation data, provide your coaching suggestions.""" Based on this simulation data, provide your coaching suggestions."""
def _format_goals_section(goals: Optional[CoachGoals]) -> str: def _format_goals_section(goals: Optional[CoachGoals]) -> str:
"""Format goals into system prompt rules.""" """Format goals into system prompt rules."""
if not goals: if not goals:
return "- Aim for a balanced, well-rounded deck." return "- Aim for a balanced, well-rounded deck."
lines = [] lines = []
if goals.targetPowerLevel is not None: if goals.targetPowerLevel is not None:
lines.append(f"- Target power level: {goals.targetPowerLevel}/10.") lines.append(f"- Target power level: {goals.targetPowerLevel}/10.")
if goals.targetPowerLevel <= 4: if goals.targetPowerLevel <= 4:
lines.append("- Keep suggestions casual and fun-focused.") lines.append("- Keep suggestions casual and fun-focused.")
elif goals.targetPowerLevel >= 8: elif goals.targetPowerLevel >= 8:
lines.append("- Prioritize efficiency and competitive staples.") lines.append("- Prioritize efficiency and competitive staples.")
if goals.metaFocus: if goals.metaFocus:
lines.append(f"- Optimize for a {goals.metaFocus} strategy.") lines.append(f"- Optimize for a {goals.metaFocus} strategy.")
if goals.budget: if goals.budget:
budget_map = {"budget": "under $5 per card", "medium": "under $20 per card", budget_map = {"budget": "under $5 per card", "medium": "under $20 per card",
"no-limit": "no budget restrictions"} "no-limit": "no budget restrictions"}
lines.append(f"- Budget constraint: {budget_map.get(goals.budget, goals.budget)}.") lines.append(f"- Budget constraint: {budget_map.get(goals.budget, goals.budget)}.")
if goals.focusAreas: if goals.focusAreas:
lines.append(f"- Focus improvement on: {', '.join(goals.focusAreas)}.") lines.append(f"- Focus improvement on: {', '.join(goals.focusAreas)}.")
return "\n".join(lines) if lines else "- Aim for a balanced, well-rounded deck." return "\n".join(lines) if lines else "- Aim for a balanced, well-rounded deck."
def _format_curve(buckets: List[int]) -> str: def _format_curve(buckets: List[int]) -> str:
"""Format mana curve buckets as a readable string.""" """Format mana curve buckets as a readable string."""
labels = ["0", "1", "2", "3", "4", "5", "6", "7+"] labels = ["0", "1", "2", "3", "4", "5", "6", "7+"]
parts = [f"{labels[i]}={buckets[i]}" for i in range(min(len(buckets), 8)) if buckets[i] > 0] parts = [f"{labels[i]}={buckets[i]}" for i in range(min(len(buckets), 8)) if buckets[i] > 0]
return ", ".join(parts) if parts else "not available (analyze deck structure manually)" return ", ".join(parts) if parts else "not available (analyze deck structure manually)"
def _format_top_cards(cards: List[CardPerformance], top_n: int = 10) -> str: def _format_top_cards(cards: List[CardPerformance], top_n: int = 10) -> str:
"""Format the best performing cards with extended stats.""" """Format the best performing cards with extended stats.
sorted_cards = sorted(cards, key=lambda c: c.impactScore, reverse=True)
lines = [] Phase 1: adds keptInOpeningHandRate so Claude sees hand quality for
for c in sorted_cards[:top_n]: the good cards too, not just the underperformers.
tags = f" [{', '.join(c.tags)}]" if c.tags else "" """
turn_str = f", avgTurnCast={c.avgTurnCast:.1f}" if c.avgTurnCast is not None else "" sorted_cards = sorted(cards, key=lambda c: c.impactScore, reverse=True)
lines.append( lines = []
f"- {c.name}: impact={c.impactScore:.3f}, " for c in sorted_cards[:top_n]:
f"castRate={c.castRate:.1%}, drawnRate={c.drawnRate:.1%}, " tags = f" [{', '.join(c.tags)}]" if c.tags else ""
f"synergy={c.synergyScore:.3f}{turn_str}{tags}" turn_str = f", avgTurnCast={c.avgTurnCast:.1f}" if c.avgTurnCast is not None else ""
) lines.append(
return "\n".join(lines) if lines else "No card performance data available." f"- {c.name}: impact={c.impactScore:.3f}, "
f"castRate={c.castRate:.1%}, drawnRate={c.drawnRate:.1%}, "
f"synergy={c.synergyScore:.3f}, "
f"keptInOpeningHand={c.keptInOpeningHandRate:.1%}"
f"{turn_str}{tags}"
)
return "\n".join(lines) if lines else "No card performance data available."
def _format_underperformers(cards: List[CardPerformance], def _format_underperformers(cards: List[CardPerformance],
underperformer_names: List[str]) -> str: underperformer_names: List[str]) -> str:
"""Format underperforming cards with full available stats.""" """Format underperforming cards with full available stats."""
card_map = {c.name: c for c in cards} card_map = {c.name: c for c in cards}
lines = [] lines = []
for name in underperformer_names[:MAX_UNDERPERFORMERS]: for name in underperformer_names[:MAX_UNDERPERFORMERS]:
c = card_map.get(name) c = card_map.get(name)
if c: if c:
turn_str = f", avgTurnCast={c.avgTurnCast:.1f}" if c.avgTurnCast is not None else "" turn_str = f", avgTurnCast={c.avgTurnCast:.1f}" if c.avgTurnCast is not None else ""
lines.append( flags = []
f"- {c.name}: impact={c.impactScore:.3f}, " if c.keptInOpeningHandRate < 0.25:
f"deadCardRate={c.deadCardRate:.1%}, " flags.append("OPENING HAND TRAP")
f"castRate={c.castRate:.1%}, " if c.avgTurnCast is not None and c.avgTurnCast > 6:
f"clunkiness={c.clunkinessScore:.3f}, " flags.append("LATE CASTER")
f"synergy={c.synergyScore:.3f}, " if c.synergyScore < 0.1:
f"keptInOpeningHand={c.keptInOpeningHandRate:.1%}" flags.append("ISOLATED")
f"{turn_str}" flag_str = f" [{', '.join(flags)}]" if flags else ""
) lines.append(
f"- {c.name}{flag_str}: impact={c.impactScore:.3f}, "
f"deadCardRate={c.deadCardRate:.1%}, "
f"castRate={c.castRate:.1%}, "
f"clunkiness={c.clunkinessScore:.3f}, "
f"synergy={c.synergyScore:.3f}, "
f"keptInOpeningHand={c.keptInOpeningHandRate:.1%}"
f"{turn_str}"
)
else:
lines.append(f"- {name}: (no detailed stats)")
return "\n".join(lines) if lines else "No clear underperformers identified."
def _format_sim_insights(cards: List[CardPerformance],
archetype_rates: Dict[str, float]) -> str:
"""Compute and format deck-level sim signals for the Sim Insights section.
Phase 1 (#108): surfaces pre-computed per-card stats as deck-level
aggregates so Claude reasons from numbers directly.
Signals produced:
1. Opening hand quality — deck avg keptInOpeningHandRate + worst offenders
2. Curve velocity — castRate-weighted avg avgTurnCast + late-caster flags
3. Synergy outliers — bottom-3 synergyScore cards flagged as [ISOLATED]
4. Archetype weak spots — archetypes < 40% win rate flagged [WEAK SPOT]
"""
if not cards:
return "No simulation data available."
lines = []
# ── 1. Opening hand quality ───────────────────────────────────────────
hand_rates = [c.keptInOpeningHandRate for c in cards if c.keptInOpeningHandRate > 0]
if hand_rates:
avg_keep = sum(hand_rates) / len(hand_rates)
lines.append(f"Opening Hand Quality: deck avg keptInOpeningHandRate = {avg_keep:.1%}")
# Worst offenders: cards significantly below the deck average
threshold = avg_keep * 0.6 # >40% below avg is an outlier
offenders = sorted(
[c for c in cards if c.keptInOpeningHandRate > 0 and c.keptInOpeningHandRate < threshold],
key=lambda c: c.keptInOpeningHandRate
)[:3]
if offenders:
lines.append(" Opening hand traps (keptInOpeningHandRate far below deck avg):")
for c in offenders:
lines.append(f" - {c.name}: {c.keptInOpeningHandRate:.1%} [OPENING HAND TRAP]")
else: else:
lines.append(f"- {name}: (no detailed stats)") lines.append("Opening Hand Quality: no keptInOpeningHandRate data available")
return "\n".join(lines) if lines else "No clear underperformers identified."
# ── 2. Curve velocity ───────────────────────────────────────────────
cast_cards = [c for c in cards if c.avgTurnCast is not None and c.castRate > 0]
if cast_cards:
total_weight = sum(c.castRate for c in cast_cards)
if total_weight > 0:
weighted_avg_turn = sum(
c.avgTurnCast * c.castRate for c in cast_cards
) / total_weight
lines.append(f"Curve Velocity: castRate-weighted avg avgTurnCast = {weighted_avg_turn:.1f}")
late_casters = sorted(
[c for c in cast_cards if c.avgTurnCast > 6],
key=lambda c: c.avgTurnCast,
reverse=True
)[:3]
if late_casters:
lines.append(" Late casters (avgTurnCast > 6 — may not fit the curve):")
for c in late_casters:
lines.append(f" - {c.name}: avgTurnCast={c.avgTurnCast:.1f} [LATE CASTER]")
else:
lines.append("Curve Velocity: no avgTurnCast data available")
# ── 3. Synergy outliers ──────────────────────────────────────────────
synergy_cards = sorted(
[c for c in cards if c.castRate > 0.05], # ignore near-never-cast cards
key=lambda c: c.synergyScore
)
if synergy_cards:
bottom3 = synergy_cards[:3]
lines.append("Synergy Outliers (lowest synergyScore among cast cards):")
for c in bottom3:
lines.append(f" - {c.name}: synergyScore={c.synergyScore:.3f} [ISOLATED]")
# ── 4. Archetype weak spots ──────────────────────────────────────────
if archetype_rates:
weak = {k: v for k, v in archetype_rates.items() if v < 0.40}
if weak:
lines.append("Archetype Weak Spots (win rate < 40%):")
for arch, wr in sorted(weak.items(), key=lambda x: x[1]):
lines.append(f" - vs {arch}: {wr:.1%} [WEAK SPOT]")
else:
lines.append("Archetype Weak Spots: none (all matchups >= 40% win rate)")
return "\n".join(lines) if lines else "No sim insights available."
def _format_candidates(candidates: Dict[str, List[dict]]) -> str: def _format_candidates(candidates: Dict[str, List[dict]]) -> str:
"""Format replacement candidates grouped by the card they'd replace.""" """Format replacement candidates grouped by the card they'd replace."""
if not candidates: if not candidates:
return ( return (
"No embedding-based candidates available. " "No embedding-based candidates available. "
"Use your MTG knowledge to suggest cards that fit the deck's " "Use your MTG knowledge to suggest cards that fit the deck's "
"color identity, strategy, and missing functional roles. " "color identity, strategy, and missing functional roles. "
"Suggest at least 3 different cards across different roles " "Suggest at least 3 different cards across different roles "
"(ramp, draw, removal, threats, utility)." "(ramp, draw, removal, threats, utility)."
) )
lines = [] lines = []
for underperformer, replacements in list(candidates.items())[:MAX_UNDERPERFORMERS]: for underperformer, replacements in list(candidates.items())[:MAX_UNDERPERFORMERS]:
lines.append(f"\nReplacements for '{underperformer}':") lines.append(f"\nReplacements for '{underperformer}':")
for r in replacements[:MAX_CANDIDATES_PER_UNDERPERFORMER]: for r in replacements[:MAX_CANDIDATES_PER_UNDERPERFORMER]:
name = r.get("name", "Unknown") name = r.get("name", "Unknown")
types = r.get("types", "") types = r.get("types", "")
mv = r.get("mana_value", "?") mv = r.get("mana_value", "?")
sim = r.get("similarity", 0) sim = r.get("similarity", 0)
text_preview = r.get("text", "")[:80] text_preview = r.get("text", "")[:80]
lines.append(f" - {name} (MV:{mv}, {types}) [similarity: {sim:.3f}]") lines.append(f" - {name} (MV:{mv}, {types}) [similarity: {sim:.3f}]")
if text_preview: if text_preview:
lines.append(f" Oracle: {text_preview}") lines.append(f" Oracle: {text_preview}")
lines.append("\nChoose from these candidates when possible, but also suggest ") lines.append("\nChoose from these candidates when possible, but also suggest ")
lines.append("cards NOT in this list if they better address the deck's weaknesses.") lines.append("cards NOT in this list if they better address the deck's weaknesses.")
return "\n".join(lines) return "\n".join(lines)
def _format_rag_cards(rag_cards: List[dict]) -> str: def _format_rag_cards(rag_cards: List[dict]) -> str:
"""Format RAG-retrieved cards from ChromaDB vector search.""" """Format RAG-retrieved cards from ChromaDB vector search."""
@@ -357,97 +471,111 @@ def _format_rag_cards(rag_cards: List[dict]) -> str:
return "\n".join(lines) return "\n".join(lines)
def _format_matchups(report: DeckReport) -> str: def _format_matchups(report: DeckReport) -> str:
"""Format per-opponent matchup data.""" """Format per-opponent matchup data."""
if not report.matchups: if not report.matchups:
return "No matchup data available." return "No matchup data available."
lines = [] lines = []
for m in report.matchups: for m in report.matchups:
lines.append( lines.append(
f"- vs {m.opponentDeck}: {m.winRate:.1%} " f"- vs {m.opponentDeck}: {m.winRate:.1%} "
f"({m.gamesPlayed} games)" f"({m.gamesPlayed} games)"
) )
return "\n".join(lines) return "\n".join(lines)
def _format_archetypes(report: DeckReport) -> str: def _format_archetypes(report: DeckReport) -> str:
"""Format per-archetype win rates from DeckMeta.""" """Format per-archetype win rates from DeckMeta.
rates = report.meta.perArchetypeWinRates
if not rates: Phase 1: annotates weak spots ([WEAK SPOT] < 40%) and strengths
return "No archetype breakdown available." ([STRENGTH] >= 55%) so Claude sees the signal immediately.
lines = [] """
for archetype, wr in sorted(rates.items(), key=lambda x: x[1]): rates = report.meta.perArchetypeWinRates
lines.append(f"- vs {archetype}: {wr:.1%}") if not rates:
return "\n".join(lines) return "No archetype breakdown available."
lines = []
for archetype, wr in sorted(rates.items(), key=lambda x: x[1]):
if wr < 0.40:
tag = " [WEAK SPOT]"
elif wr >= 0.55:
tag = " [STRENGTH]"
else:
tag = ""
lines.append(f"- vs {archetype}: {wr:.1%}{tag}")
return "\n".join(lines)
def _format_combos(report: DeckReport) -> str: def _format_combos(report: DeckReport) -> str:
"""Format known combos section.""" """Format known combos section."""
if not report.knownCombos: if not report.knownCombos:
return "" return ""
lines = ["### Known Combos"] lines = ["### Known Combos"]
for combo in report.knownCombos: for combo in report.knownCombos:
cards = " + ".join(combo.cardNames) cards = " + ".join(combo.cardNames)
lines.append( lines.append(
f"- {cards}: winRate={combo.winRateWhenAssembled:.1%}, " f"- {cards}: winRate={combo.winRateWhenAssembled:.1%}, "
f"assemblyRate={combo.assemblyRate:.1%}" f"assemblyRate={combo.assemblyRate:.1%}"
) )
return "\n".join(lines) return "\n".join(lines)
# ══════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════
# Main Builder Functions # Main Builder Functions
# ══════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════
def build_system_prompt(report: DeckReport, def build_system_prompt(report: DeckReport,
goals: Optional[CoachGoals] = None) -> str: goals: Optional[CoachGoals] = None) -> str:
"""Build the system prompt with deck-specific rules.""" """Build the system prompt with deck-specific rules."""
color_str = "/".join(report.colorIdentity) if report.colorIdentity else "any" color_str = "/".join(report.colorIdentity) if report.colorIdentity else "any"
return SYSTEM_PROMPT_TEMPLATE.format( return SYSTEM_PROMPT_TEMPLATE.format(
commander=report.commander, commander=report.commander,
color_identity=color_str, color_identity=color_str,
goals_section=_format_goals_section(goals), goals_section=_format_goals_section(goals),
) )
def build_quick_system_prompt(report: DeckReport, def build_quick_system_prompt(report: DeckReport,
goals: Optional[CoachGoals] = None) -> str: goals: Optional[CoachGoals] = None) -> str:
"""Build the quick digest system prompt (~400-600 token target).""" """Build the quick digest system prompt (~400-600 token target)."""
color_str = "/".join(report.colorIdentity) if report.colorIdentity else "any" color_str = "/".join(report.colorIdentity) if report.colorIdentity else "any"
return QUICK_DIGEST_SYSTEM_PROMPT.format( return QUICK_DIGEST_SYSTEM_PROMPT.format(
commander=report.commander, commander=report.commander,
color_identity=color_str, color_identity=color_str,
goals_section=_format_goals_section(goals), goals_section=_format_goals_section(goals),
) )
def build_user_prompt(report: DeckReport, def build_user_prompt(report: DeckReport,
candidates: Dict[str, List[dict]] = None, candidates: Dict[str, List[dict]] = None,
rag_cards: List[dict] = None) -> str: rag_cards: List[dict] = None) -> str:
"""Build the user prompt with full deck report data.""" """Build the user prompt with full deck report data.
color_str = "/".join(report.colorIdentity) if report.colorIdentity else "unknown"
type_parts = [f"{k}={v}" for k, v in report.structure.cardTypeCounts.items()] \ Phase 1: adds sim_insights_section computed from card-level stats.
if report.structure.cardTypeCounts else ["unknown"] """
role_parts = [f"{k}={v}" for k, v in report.structure.functionalCounts.items()] \ color_str = "/".join(report.colorIdentity) if report.colorIdentity else "unknown"
if report.structure.functionalCounts else ["unknown"] type_parts = [f"{k}={v}" for k, v in report.structure.cardTypeCounts.items()] \
return USER_PROMPT_TEMPLATE.format( if report.structure.cardTypeCounts else ["unknown"]
deck_name=report.deckId, role_parts = [f"{k}={v}" for k, v in report.structure.functionalCounts.items()] \
commander=report.commander, if report.structure.functionalCounts else ["unknown"]
color_identity=color_str, return USER_PROMPT_TEMPLATE.format(
games_simulated=report.meta.gamesSimulated, deck_name=report.deckId,
win_rate=report.meta.overallWinRate, commander=report.commander,
avg_game_length=report.meta.avgGameLength, color_identity=color_str,
land_count=report.structure.landCount, games_simulated=report.meta.gamesSimulated,
curve_summary=_format_curve(report.structure.curveBuckets), win_rate=report.meta.overallWinRate,
type_summary=", ".join(type_parts), avg_game_length=report.meta.avgGameLength,
role_summary=", ".join(role_parts), land_count=report.structure.landCount,
matchup_section=_format_matchups(report), curve_summary=_format_curve(report.structure.curveBuckets),
archetype_section=_format_archetypes(report), type_summary=", ".join(type_parts),
top_cards=_format_top_cards(report.cards), role_summary=", ".join(role_parts),
underperformer_section=_format_underperformers( sim_insights_section=_format_sim_insights(
report.cards, report.underperformers), report.cards, report.meta.perArchetypeWinRates),
candidate_section=_format_candidates(candidates or {}), matchup_section=_format_matchups(report),
rag_section=_format_rag_cards(rag_cards or []), archetype_section=_format_archetypes(report),
combo_section=_format_combos(report), top_cards=_format_top_cards(report.cards),
) underperformer_section=_format_underperformers(
report.cards, report.underperformers),
candidate_section=_format_candidates(candidates or {}),
rag_section=_format_rag_cards(rag_cards or []),
combo_section=_format_combos(report),
)