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:
1 parent
e77a945805
commit
e1bb2e7209
1 file changed
+289
-161
+289
-161
@@ -4,6 +4,13 @@ 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
|
||||
@@ -12,9 +19,9 @@ 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,
|
||||
@@ -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 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.
|
||||
@@ -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"
|
||||
],
|
||||
"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": {{
|
||||
"creatures": "Assessment of creature 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."""
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 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.
|
||||
@@ -184,6 +196,8 @@ 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:
|
||||
@@ -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."""
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# User Prompt Builder
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# User Prompt Template
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
USER_PROMPT_TEMPLATE = """## Deck Report: {deck_name}
|
||||
Commander: {commander}
|
||||
@@ -224,6 +238,9 @@ Avg Game Length: {avg_game_length:.1f} turns
|
||||
- Card Types: {type_summary}
|
||||
- Functional Roles: {role_summary}
|
||||
|
||||
### Sim Insights (pre-computed deck-level signals)
|
||||
{sim_insights_section}
|
||||
|
||||
### Matchup Data
|
||||
{matchup_section}
|
||||
|
||||
@@ -246,98 +263,195 @@ Avg Game Length: {avg_game_length:.1f} turns
|
||||
|
||||
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."
|
||||
"""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)"
|
||||
"""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."""
|
||||
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}{turn_str}{tags}"
|
||||
)
|
||||
return "\n".join(lines) if lines else "No card performance data available."
|
||||
"""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 ""
|
||||
lines.append(
|
||||
f"- {c.name}: 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}"
|
||||
)
|
||||
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(f"- {name}: (no detailed stats)")
|
||||
return "\n".join(lines) if lines else "No clear underperformers identified."
|
||||
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)
|
||||
"""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."""
|
||||
@@ -357,97 +471,111 @@ def _format_rag_cards(rag_cards: List[dict]) -> str:
|
||||
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)
|
||||
"""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."""
|
||||
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]):
|
||||
lines.append(f"- vs {archetype}: {wr:.1%}")
|
||||
return "\n".join(lines)
|
||||
"""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)
|
||||
"""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),
|
||||
)
|
||||
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),
|
||||
)
|
||||
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,
|
||||
candidates: Dict[str, List[dict]] = None,
|
||||
rag_cards: List[dict] = None) -> str:
|
||||
"""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()] \
|
||||
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),
|
||||
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),
|
||||
)
|
||||
"""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),
|
||||
)
|
||||
Reference in new issue
Block a user