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.
582 lines
27 KiB
Python
582 lines
27 KiB
Python
"""
|
|
Commander AI Lab — Coach Prompt Template
|
|
════════════════════════════════════════
|
|
Constructs system and user prompts for the LLM coach.
|
|
Injects deck report data, underperformer analysis, and
|
|
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 .models import DeckReport, CoachGoals, CardPerformance
|
|
from .config import MAX_CANDIDATES_PER_UNDERPERFORMER, MAX_UNDERPERFORMERS
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════
|
|
# System Prompt
|
|
# ════════════════════════════════════════════════════════════
|
|
|
|
SYSTEM_PROMPT_TEMPLATE = """You are an expert Magic: The Gathering Commander deck coach.
|
|
You analyze deck performance reports from simulation data and provide specific,
|
|
actionable suggestions to improve deck construction.
|
|
|
|
RULES:
|
|
- NEVER suggest cutting the commander ({commander}).
|
|
- Only suggest cards within the deck's color identity: {color_identity}.
|
|
- Base suggestions on the simulation data provided, not general "goodstuff" advice.
|
|
- Consider mana curve, role balance (ramp, draw, removal, threats), and synergy.
|
|
- When suggesting replacements, prefer cards that fill the same functional role.
|
|
- IMPORTANT: Suggest 5-8 DIFFERENT cards to cut and 5-8 DIFFERENT cards to add.
|
|
- Each suggested add MUST be a different card — never suggest the same card twice.
|
|
- Vary your suggestions across different functional roles (ramp, draw, removal, threats, utility).
|
|
- If the deck is missing a functional role (e.g., no card draw, no removal), prioritize adding cards for that role.
|
|
- If candidate replacements are provided from embeddings search, prefer those over generic suggestions.
|
|
- For each suggested cut, provide a DETAILED reason citing specific stats (impact score, cast rate, dead card rate, clunkiness, synergy score).
|
|
- For each suggested add, explain the SPECIFIC synergy with existing cards in the deck.
|
|
- 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.
|
|
- 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.
|
|
- Assess how dependent the deck is on the commander being in play.
|
|
- Evaluate opening hand quality and identify cards that cause bad mulligans.
|
|
- Rank your suggested swaps by priority so the user knows what to change first.
|
|
- Categorize the removal package by permanent type and flag coverage gaps.
|
|
- Evaluate the ramp package for fragility (creature/artifact-heavy vs land-based).
|
|
- Distinguish one-time draw from repeatable draw engines.
|
|
- Identify the top 3 win conditions and rate each for independence.
|
|
- Flag internal anti-synergies or card conflicts.
|
|
- Assess pod threat perception: does this deck draw too much hate too early?
|
|
- Evaluate when the deck comes online (tempo profile) and whether that's viable.
|
|
- Reason about matchups vs fast combo, stax/prison, and control archetypes.
|
|
{goals_section}
|
|
|
|
You MUST respond ONLY with valid JSON matching this exact structure:
|
|
{{
|
|
"summary": "4-6 sentence detailed overview of the deck's strengths, weaknesses, key synergies, and strategic identity. Reference specific win rates and simulation data.",
|
|
"suggestedCuts": [
|
|
{{
|
|
"cardName": "Card To Remove",
|
|
"reason": "Detailed explanation citing specific simulation stats (impact score, cast rate, dead card rate, clunkiness score, synergy score, avg turn cast) and why this card underperforms in this specific deck context",
|
|
"replacementOptions": ["Better Card 1", "Better Card 2"],
|
|
"functionalRole": "ramp|draw|removal|threat|utility|combo|protection",
|
|
"estimatedImpact": "How cutting this card is expected to improve the deck"
|
|
}}
|
|
],
|
|
"suggestedAdds": [
|
|
{{
|
|
"cardName": "Card To Add",
|
|
"role": "ramp|draw|removal|threat|utility|combo|protection",
|
|
"reason": "Detailed explanation of why this card improves the deck, referencing specific weaknesses it addresses",
|
|
"synergyWith": ["Card In Deck 1", "Card In Deck 2"],
|
|
"estimatedManaCost": "The mana value of the suggested card",
|
|
"strategicRationale": "How this card fits into the overall game plan and addresses a specific gap"
|
|
}}
|
|
],
|
|
"upgradePriority": [
|
|
{{
|
|
"rank": 1,
|
|
"cut": "Card to remove",
|
|
"add": "Card to add instead",
|
|
"reasoning": "Why this is the single highest-impact swap",
|
|
"expectedImpact": "high|medium|low"
|
|
}}
|
|
],
|
|
"commanderDependency": {{
|
|
"score": 5,
|
|
"dependentCards": ["Card that is nearly dead without commander"],
|
|
"recoveryPlan": "Strategy if the commander gets removed or tucked repeatedly"
|
|
}},
|
|
"mulliganAnalysis": {{
|
|
"estimatedKeepRate": "Percentage estimate of keepable 7-card hands, e.g. ~70%",
|
|
"worstOffenders": ["Card most responsible for unkeepable hands"],
|
|
"recommendation": "What to change to improve opening hand quality"
|
|
}},
|
|
"manaCurveAnalysis": {{
|
|
"currentAssessment": "Detailed analysis of the current mana curve distribution and its implications",
|
|
"recommendations": "Specific suggestions for curve adjustments with reasoning",
|
|
"idealRange": "What the curve should look like for this deck's strategy"
|
|
}},
|
|
"roleBreakdown": {{
|
|
"ramp": "Assessment of ramp package - count, quality, and suggestions",
|
|
"draw": "Assessment of card draw package",
|
|
"removal": "Assessment of removal package",
|
|
"threats": "Assessment of win conditions and threats",
|
|
"protection": "Assessment of protection and interaction"
|
|
}},
|
|
"synergyAnalysis": [
|
|
"Description of a key synergy chain in the deck and how well it performed in simulation"
|
|
],
|
|
"heuristicHints": [
|
|
"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",
|
|
"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": {{
|
|
"creatures": "Assessment of creature removal",
|
|
"artifacts": "Assessment of artifact removal",
|
|
"enchantments": "Assessment of enchantment removal",
|
|
"planeswalkers": "Assessment of planeswalker removal",
|
|
"lands": "Assessment of land destruction",
|
|
"massRemoval": "Assessment of board wipes",
|
|
"gaps": ["Permanent types with zero or weak coverage"]
|
|
}},
|
|
"rampQuality": {{
|
|
"manaRocks": "2-mana rocks, signets, etc.",
|
|
"landFetch": "Cultivate, Kodama's Reach, etc.",
|
|
"manaDorks": "Llanowar Elves, Birds, etc.",
|
|
"costReducers": "Medallions, Helm of Awakening, etc.",
|
|
"fragility": "Overall fragility assessment",
|
|
"canReachFourByTurnThree": "yes/no with reasoning"
|
|
}},
|
|
"drawEngineProfile": {{
|
|
"burstDraw": ["One-time draw spells"],
|
|
"repeatableEngines": ["Sustained draw engines"],
|
|
"assessment": "Overall draw quality",
|
|
"sustainability": "Whether draw is sustainable or burst-only"
|
|
}},
|
|
"winConditions": [
|
|
{{
|
|
"name": "Name of win condition",
|
|
"cards": ["Key cards involved"],
|
|
"independence": "high|medium|low",
|
|
"description": "How this win con functions"
|
|
}}
|
|
],
|
|
"antiSynergyFlags": [
|
|
{{
|
|
"cards": ["Card A", "Card B"],
|
|
"conflict": "Description of conflict",
|
|
"severity": "high|medium|low"
|
|
}}
|
|
],
|
|
"podPresence": {{
|
|
"threatLevel": "early|medium|late|low",
|
|
"politicalTools": ["Pillowfort or group effect cards"],
|
|
"adversarialRating": "low|medium|high",
|
|
"recommendation": "How to manage threat perception in a pod"
|
|
}},
|
|
"tempoAssessment": {{
|
|
"peakTurn": 5,
|
|
"profile": "early|mid|late",
|
|
"survivalWindow": "Assessment of whether the deck survives to its power window",
|
|
"assessment": "Overall tempo evaluation"
|
|
}},
|
|
"metaMatchups": [
|
|
{{
|
|
"archetype": "fast-combo|stax|control|aggro|midrange",
|
|
"assessment": "How the deck fares against this archetype",
|
|
"keyThreats": ["Cards/strategies that threaten this deck"],
|
|
"keyAnswers": ["Cards in the deck that answer this archetype"],
|
|
"overallRating": "favored|even|unfavored"
|
|
}}
|
|
]
|
|
}}
|
|
|
|
Do NOT include any text outside the JSON object. No markdown fences, no preamble."""
|
|
|
|
# ════════════════════════════════════════════════════════════
|
|
# Quick Digest Prompt
|
|
# ════════════════════════════════════════════════════════════
|
|
|
|
QUICK_DIGEST_SYSTEM_PROMPT = """You are an expert Magic: The Gathering Commander deck coach.
|
|
Provide a FAST summary analysis of this deck. Be concise.
|
|
|
|
RULES:
|
|
- NEVER suggest cutting the commander ({commander}).
|
|
- 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}
|
|
|
|
You MUST respond ONLY with valid JSON matching this exact structure:
|
|
{{
|
|
"summary": "3-sentence deck overview: what it does, main strength, main weakness.",
|
|
"upgradePriority": [
|
|
{{
|
|
"rank": 1,
|
|
"cut": "Card to remove",
|
|
"add": "Card to add instead",
|
|
"reasoning": "Why this is a high-impact swap",
|
|
"expectedImpact": "high|medium|low"
|
|
}}
|
|
],
|
|
"commanderDependency": {{
|
|
"score": 5,
|
|
"dependentCards": ["Card that is nearly dead without commander"],
|
|
"recoveryPlan": "Brief recovery strategy"
|
|
}}
|
|
}}
|
|
Do NOT include any text outside the JSON object. No markdown fences, no preamble."""
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════
|
|
# User Prompt Template
|
|
# ════════════════════════════════════════════════════════════
|
|
|
|
USER_PROMPT_TEMPLATE = """## Deck Report: {deck_name}
|
|
Commander: {commander}
|
|
Color Identity: {color_identity}
|
|
Games Simulated: {games_simulated}
|
|
Overall Win Rate: {win_rate:.1%}
|
|
Avg Game Length: {avg_game_length:.1f} turns
|
|
|
|
### Deck Structure
|
|
- Lands: {land_count}
|
|
- Mana Curve: {curve_summary}
|
|
- Card Types: {type_summary}
|
|
- Functional Roles: {role_summary}
|
|
|
|
### Sim Insights (pre-computed deck-level signals)
|
|
{sim_insights_section}
|
|
|
|
### Matchup Data
|
|
{matchup_section}
|
|
|
|
### Archetype Win Rates
|
|
{archetype_section}
|
|
|
|
### Top Performing Cards (by Impact Score)
|
|
{top_cards}
|
|
|
|
### Underperforming Cards (candidates for cuts)
|
|
{underperformer_section}
|
|
|
|
|
|
### RAG-Retrieved Similar Cards (from ChromaDB)
|
|
{rag_section}
|
|
### Candidate Replacement Cards (from embeddings search)
|
|
{candidate_section}
|
|
|
|
{combo_section}
|
|
|
|
Based on this simulation data, provide your coaching suggestions."""
|
|
|
|
|
|
def _format_goals_section(goals: Optional[CoachGoals]) -> str:
|
|
"""Format goals into system prompt rules."""
|
|
if not goals:
|
|
return "- Aim for a balanced, well-rounded deck."
|
|
lines = []
|
|
if goals.targetPowerLevel is not None:
|
|
lines.append(f"- Target power level: {goals.targetPowerLevel}/10.")
|
|
if goals.targetPowerLevel <= 4:
|
|
lines.append("- Keep suggestions casual and fun-focused.")
|
|
elif goals.targetPowerLevel >= 8:
|
|
lines.append("- Prioritize efficiency and competitive staples.")
|
|
if goals.metaFocus:
|
|
lines.append(f"- Optimize for a {goals.metaFocus} strategy.")
|
|
if goals.budget:
|
|
budget_map = {"budget": "under $5 per card", "medium": "under $20 per card",
|
|
"no-limit": "no budget restrictions"}
|
|
lines.append(f"- Budget constraint: {budget_map.get(goals.budget, goals.budget)}.")
|
|
if 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."
|
|
|
|
|
|
def _format_curve(buckets: List[int]) -> str:
|
|
"""Format mana curve buckets as a readable string."""
|
|
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]
|
|
return ", ".join(parts) if parts else "not available (analyze deck structure manually)"
|
|
|
|
|
|
def _format_top_cards(cards: List[CardPerformance], top_n: int = 10) -> str:
|
|
"""Format the best performing cards with extended stats.
|
|
|
|
Phase 1: adds keptInOpeningHandRate so Claude sees hand quality for
|
|
the good cards too, not just the underperformers.
|
|
"""
|
|
sorted_cards = sorted(cards, key=lambda c: c.impactScore, reverse=True)
|
|
lines = []
|
|
for c in sorted_cards[:top_n]:
|
|
tags = f" [{', '.join(c.tags)}]" if c.tags else ""
|
|
turn_str = f", avgTurnCast={c.avgTurnCast:.1f}" if c.avgTurnCast is not None else ""
|
|
lines.append(
|
|
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],
|
|
underperformer_names: List[str]) -> str:
|
|
"""Format underperforming cards with full available stats."""
|
|
card_map = {c.name: c for c in cards}
|
|
lines = []
|
|
for name in underperformer_names[:MAX_UNDERPERFORMERS]:
|
|
c = card_map.get(name)
|
|
if c:
|
|
turn_str = f", avgTurnCast={c.avgTurnCast:.1f}" if c.avgTurnCast is not None else ""
|
|
flags = []
|
|
if c.keptInOpeningHandRate < 0.25:
|
|
flags.append("OPENING HAND TRAP")
|
|
if c.avgTurnCast is not None and c.avgTurnCast > 6:
|
|
flags.append("LATE CASTER")
|
|
if c.synergyScore < 0.1:
|
|
flags.append("ISOLATED")
|
|
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:
|
|
lines.append("Opening Hand Quality: no keptInOpeningHandRate data available")
|
|
|
|
# ── 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:
|
|
"""Format replacement candidates grouped by the card they'd replace."""
|
|
if not candidates:
|
|
return (
|
|
"No embedding-based candidates available. "
|
|
"Use your MTG knowledge to suggest cards that fit the deck's "
|
|
"color identity, strategy, and missing functional roles. "
|
|
"Suggest at least 3 different cards across different roles "
|
|
"(ramp, draw, removal, threats, utility)."
|
|
)
|
|
lines = []
|
|
for underperformer, replacements in list(candidates.items())[:MAX_UNDERPERFORMERS]:
|
|
lines.append(f"\nReplacements for '{underperformer}':")
|
|
for r in replacements[:MAX_CANDIDATES_PER_UNDERPERFORMER]:
|
|
name = r.get("name", "Unknown")
|
|
types = r.get("types", "")
|
|
mv = r.get("mana_value", "?")
|
|
sim = r.get("similarity", 0)
|
|
text_preview = r.get("text", "")[:80]
|
|
lines.append(f" - {name} (MV:{mv}, {types}) [similarity: {sim:.3f}]")
|
|
if text_preview:
|
|
lines.append(f" Oracle: {text_preview}")
|
|
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.")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def _format_rag_cards(rag_cards: List[dict]) -> str:
|
|
"""Format RAG-retrieved cards from ChromaDB vector search."""
|
|
if not rag_cards:
|
|
return "No RAG results available."
|
|
lines = []
|
|
for card in rag_cards[:15]:
|
|
name = card.get("name", "Unknown")
|
|
types = card.get("types", "")
|
|
mv = card.get("mana_value", "?")
|
|
mc = card.get("mana_cost", "")
|
|
dist = card.get("distance", 0)
|
|
text = card.get("text", "")[:80]
|
|
lines.append(f"- {name} ({mc}, MV:{mv}, {types}) [distance: {dist:.3f}]")
|
|
if text:
|
|
lines.append(f" Oracle: {text}")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def _format_matchups(report: DeckReport) -> str:
|
|
"""Format per-opponent matchup data."""
|
|
if not report.matchups:
|
|
return "No matchup data available."
|
|
lines = []
|
|
for m in report.matchups:
|
|
lines.append(
|
|
f"- vs {m.opponentDeck}: {m.winRate:.1%} "
|
|
f"({m.gamesPlayed} games)"
|
|
)
|
|
return "\n".join(lines)
|
|
|
|
|
|
def _format_archetypes(report: DeckReport) -> str:
|
|
"""Format per-archetype win rates from DeckMeta.
|
|
|
|
Phase 1: annotates weak spots ([WEAK SPOT] < 40%) and strengths
|
|
([STRENGTH] >= 55%) so Claude sees the signal immediately.
|
|
"""
|
|
rates = report.meta.perArchetypeWinRates
|
|
if not rates:
|
|
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:
|
|
"""Format known combos section."""
|
|
if not report.knownCombos:
|
|
return ""
|
|
lines = ["### Known Combos"]
|
|
for combo in report.knownCombos:
|
|
cards = " + ".join(combo.cardNames)
|
|
lines.append(
|
|
f"- {cards}: winRate={combo.winRateWhenAssembled:.1%}, "
|
|
f"assemblyRate={combo.assemblyRate:.1%}"
|
|
)
|
|
return "\n".join(lines)
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════
|
|
# Main Builder Functions
|
|
# ════════════════════════════════════════════════════════════
|
|
|
|
def build_system_prompt(report: DeckReport,
|
|
goals: Optional[CoachGoals] = None) -> str:
|
|
"""Build the system prompt with deck-specific rules."""
|
|
color_str = "/".join(report.colorIdentity) if report.colorIdentity else "any"
|
|
return SYSTEM_PROMPT_TEMPLATE.format(
|
|
commander=report.commander,
|
|
color_identity=color_str,
|
|
goals_section=_format_goals_section(goals),
|
|
)
|
|
|
|
|
|
def build_quick_system_prompt(report: DeckReport,
|
|
goals: Optional[CoachGoals] = None) -> str:
|
|
"""Build the quick digest system prompt (~400-600 token target)."""
|
|
color_str = "/".join(report.colorIdentity) if report.colorIdentity else "any"
|
|
return QUICK_DIGEST_SYSTEM_PROMPT.format(
|
|
commander=report.commander,
|
|
color_identity=color_str,
|
|
goals_section=_format_goals_section(goals),
|
|
)
|
|
|
|
|
|
def build_user_prompt(report: DeckReport,
|
|
candidates: Dict[str, List[dict]] = None,
|
|
rag_cards: List[dict] = None) -> str:
|
|
"""Build the user prompt with full deck report data.
|
|
|
|
Phase 1: adds sim_insights_section computed from card-level stats.
|
|
"""
|
|
color_str = "/".join(report.colorIdentity) if report.colorIdentity else "unknown"
|
|
type_parts = [f"{k}={v}" for k, v in report.structure.cardTypeCounts.items()] \
|
|
if report.structure.cardTypeCounts else ["unknown"]
|
|
role_parts = [f"{k}={v}" for k, v in report.structure.functionalCounts.items()] \
|
|
if report.structure.functionalCounts else ["unknown"]
|
|
return USER_PROMPT_TEMPLATE.format(
|
|
deck_name=report.deckId,
|
|
commander=report.commander,
|
|
color_identity=color_str,
|
|
games_simulated=report.meta.gamesSimulated,
|
|
win_rate=report.meta.overallWinRate,
|
|
avg_game_length=report.meta.avgGameLength,
|
|
land_count=report.structure.landCount,
|
|
curve_summary=_format_curve(report.structure.curveBuckets),
|
|
type_summary=", ".join(type_parts),
|
|
role_summary=", ".join(role_parts),
|
|
sim_insights_section=_format_sim_insights(
|
|
report.cards, report.meta.perArchetypeWinRates),
|
|
matchup_section=_format_matchups(report),
|
|
archetype_section=_format_archetypes(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),
|
|
)
|