diff --git a/coach/coach_service.py b/coach/coach_service.py index 4a34a09..cae1da2 100644 --- a/coach/coach_service.py +++ b/coach/coach_service.py @@ -25,6 +25,9 @@ from .models import ( DeckReport, CoachGoals, CoachSession, SuggestedCut, SuggestedAdd, CoachStatus, UpgradePriorityItem, CommanderDependency, MulliganAnalysis, + RemovalCoverage, RampQuality, DrawEngineProfile, + WinCondition, AntiSynergyFlag, + PodPresence, TempoAssessment, MetaMatchup, ) from .llm_client import LLMClient from .embeddings import MTGEmbeddingIndex @@ -62,7 +65,7 @@ class CoachService: self.embeddings = embeddings ensure_dirs() - # ── Deck Report I/O ──────────────────────────────────────── + # ── Deck Report I/O ──────────────────────────────────── def load_deck_report(self, deck_id: str) -> Optional[DeckReport]: """Load a deck report from disk.""" @@ -89,7 +92,7 @@ class CoachService: return [] return [f.stem for f in DECK_REPORTS_DIR.glob("*.json")] - # ── Coach Session I/O ────────────────────────────────────── + # ── Coach Session I/O ────────────────────────────── def save_session(self, session: CoachSession) -> Path: """Persist a coaching session to disk and SQLite.""" @@ -184,7 +187,7 @@ class CoachService: continue return sessions - # ── Main Coaching Pipeline ─────────────────────────────────── + # ── Main Coaching Pipeline ─────────────────────────────── async def run_coaching_session( self, @@ -202,7 +205,6 @@ class CoachService: 6. Parse response 7. Persist session """ - # 0. Auto-load embeddings if not loaded if not self.embeddings.loaded: logger.info("Embeddings not loaded — attempting auto-load...") try: @@ -214,7 +216,6 @@ class CoachService: except Exception as e: logger.warning("Embeddings auto-load failed: %s", e) - # 1. Load deck report report = self.load_deck_report(deck_id) if report is None and fallback_report is not None: report = fallback_report @@ -222,10 +223,8 @@ class CoachService: if report is None: raise ValueError(f"Deck report not found for: {deck_id}") - # 2. Get deck card names (for exclusion from candidates) deck_card_names = [c.name for c in report.cards] - # 3. Find replacement candidates for underperformers candidates: Dict[str, List[dict]] = {} underperformers = report.underperformers[:MAX_UNDERPERFORMERS] if not underperformers: @@ -254,11 +253,9 @@ class CoachService: else: logger.warning("Embeddings not loaded — skipping candidate search") - # 4. Build prompts system_prompt = build_system_prompt(report, goals) user_prompt = build_user_prompt(report, candidates) - # 5. Call LLM logger.info("Calling LLM for deck: %s (provider=%s)", deck_id, COACH_PROVIDER) if COACH_PROVIDER == "anthropic": @@ -266,8 +263,6 @@ class CoachService: anthropic_key = ANTHROPIC_API_KEY if not anthropic_key: raise ConnectionError("Anthropic API key not configured. Set ANTHROPIC_API_KEY env var.") - - # Use streaming to avoid the Anthropic SDK 10-minute non-streaming timeout. aclient = _anthropic.AsyncAnthropic(api_key=anthropic_key) async with aclient.messages.stream( model=ANTHROPIC_MODEL, @@ -279,8 +274,6 @@ class CoachService: content = await stream.get_final_text() final_msg = await stream.get_final_message() usage = getattr(final_msg, "usage", None) - - # Build an LLMResponse-compatible object from types import SimpleNamespace llm_response = SimpleNamespace( content=content, @@ -303,7 +296,6 @@ class CoachService: else: llm_response = await self.llm.chat(system_prompt, user_prompt) - # 6. Parse response into CoachSession session_id = f"sess-{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}-{uuid.uuid4().hex[:6]}" session = CoachSession( sessionId=session_id, @@ -321,11 +313,91 @@ class CoachService: session.rawTextExplanation = llm_response.content session.summary = "LLM returned non-JSON response. See raw explanation." - # 7. Persist self.save_session(session) logger.info("Coaching session saved: %s", session_id) return session + # ── Quick Digest ────────────────────────────────────────── + + async def run_quick_digest( + self, + deck_id: str, + goals: Optional[CoachGoals] = None, + fallback_report: Optional[DeckReport] = None, + ) -> CoachSession: + """Fast ~400-600 token call: summary + top 3 upgradePriority + commanderDependency.""" + report = self.load_deck_report(deck_id) + if report is None and fallback_report is not None: + report = fallback_report + if report is None: + raise ValueError(f"Deck report not found for: {deck_id}") + + from .prompt_template import build_quick_system_prompt, build_user_prompt + system_prompt = build_quick_system_prompt(report, goals) + user_prompt = build_user_prompt(report, {}) # no candidates for speed + + logger.info("Quick digest for deck: %s (provider=%s)", deck_id, COACH_PROVIDER) + + if COACH_PROVIDER == "anthropic": + import anthropic as _anthropic + anthropic_key = ANTHROPIC_API_KEY + if not anthropic_key: + raise ConnectionError("Anthropic API key not configured.") + aclient = _anthropic.AsyncAnthropic(api_key=anthropic_key) + async with aclient.messages.stream( + model=ANTHROPIC_MODEL, + system=system_prompt, + messages=[{"role": "user", "content": user_prompt}], + max_tokens=1024, + temperature=0.5, + ) as stream: + content = await stream.get_final_text() + final_msg = await stream.get_final_message() + usage = getattr(final_msg, "usage", None) + from types import SimpleNamespace + llm_response = SimpleNamespace( + content=content, + model=final_msg.model, + prompt_tokens=usage.input_tokens if usage else 0, + completion_tokens=usage.output_tokens if usage else 0, + parsed_json=None, + ) + try: + cleaned = re.sub(r'^```(?:json)?\s*', '', content.strip()) + cleaned = re.sub(r'\s*```$', '', cleaned.strip()) + llm_response.parsed_json = json.loads(cleaned) + except (json.JSONDecodeError, ValueError): + match = re.search(r'\{[\s\S]*\}', content) + if match: + try: + llm_response.parsed_json = json.loads(match.group(0)) + except json.JSONDecodeError: + pass + else: + llm_response = await self.llm.chat(system_prompt, user_prompt) + + session_id = f"quick-{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}-{uuid.uuid4().hex[:6]}" + session = CoachSession( + sessionId=session_id, + deckId=deck_id, + timestamp=datetime.now(timezone.utc).isoformat(), + modelUsed=llm_response.model, + promptTokens=llm_response.prompt_tokens, + completionTokens=llm_response.completion_tokens, + goals=goals, + isQuickDigest=True, + ) + if llm_response.parsed_json: + self._populate_session_from_json(session, llm_response.parsed_json) + else: + session.rawTextExplanation = llm_response.content + session.summary = "Quick digest returned non-JSON. See raw explanation." + + self.save_session(session) + return session + + # ── JSON Parser ─────────────────────────────────────────── + def _populate_session_from_json(self, session: CoachSession, data: dict): """Parse LLM JSON response into CoachSession fields.""" session.summary = data.get("summary", "") @@ -352,7 +424,7 @@ class CoachService: synergyWith=add_data.get("synergyWith", []), )) - # Phase 2: Parse upgrade priority + # Phase 2 for item in data.get("upgradePriority", []): if isinstance(item, dict): session.upgradePriority.append(UpgradePriorityItem( @@ -363,7 +435,6 @@ class CoachService: expectedImpact=item.get("expectedImpact", ""), )) - # Phase 2: Parse commander dependency cd = data.get("commanderDependency") if isinstance(cd, dict): session.commanderDependency = CommanderDependency( @@ -372,7 +443,6 @@ class CoachService: recoveryPlan=cd.get("recoveryPlan", ""), ) - # Phase 2: Parse mulligan analysis ma = data.get("mulliganAnalysis") if isinstance(ma, dict): session.mulliganAnalysis = MulliganAnalysis( @@ -381,7 +451,82 @@ class CoachService: recommendation=ma.get("recommendation", ""), ) - # ── Status Check ─────────────────────────────────────────── + # Phase 3: Removal coverage + rc = data.get("removalCoverage") + if isinstance(rc, dict): + session.removalCoverage = RemovalCoverage(**{ + k: rc.get(k, "" if k != "gaps" else []) + for k in ["creatures", "artifacts", "enchantments", "planeswalkers", "lands", "massRemoval", "gaps"] + }) + + # Phase 3: Ramp quality + rq = data.get("rampQuality") + if isinstance(rq, dict): + session.rampQuality = RampQuality(**{ + k: rq.get(k, "") for k in ["manaRocks", "landFetch", "manaDorks", "costReducers", "fragility", "canReachFourByTurnThree"] + }) + + # Phase 3: Draw engine profile + dep = data.get("drawEngineProfile") + if isinstance(dep, dict): + session.drawEngineProfile = DrawEngineProfile( + burstDraw=dep.get("burstDraw", []), + repeatableEngines=dep.get("repeatableEngines", []), + assessment=dep.get("assessment", ""), + sustainability=dep.get("sustainability", ""), + ) + + # Phase 3: Win conditions + for wc in data.get("winConditions", []): + if isinstance(wc, dict): + session.winConditions.append(WinCondition( + name=wc.get("name", ""), + cards=wc.get("cards", []), + independence=wc.get("independence", ""), + description=wc.get("description", ""), + )) + + # Phase 3: Anti-synergy flags + for af in data.get("antiSynergyFlags", []): + if isinstance(af, dict): + session.antiSynergyFlags.append(AntiSynergyFlag( + cards=af.get("cards", []), + conflict=af.get("conflict", ""), + severity=af.get("severity", ""), + )) + + # Phase 5: Pod presence + pp = data.get("podPresence") + if isinstance(pp, dict): + session.podPresence = PodPresence( + threatLevel=pp.get("threatLevel", ""), + politicalTools=pp.get("politicalTools", []), + adversarialRating=pp.get("adversarialRating", ""), + recommendation=pp.get("recommendation", ""), + ) + + # Phase 5: Tempo assessment + ta = data.get("tempoAssessment") + if isinstance(ta, dict): + session.tempoAssessment = TempoAssessment( + peakTurn=ta.get("peakTurn", 0), + profile=ta.get("profile", ""), + survivalWindow=ta.get("survivalWindow", ""), + assessment=ta.get("assessment", ""), + ) + + # Phase 5: Meta matchups + for mm in data.get("metaMatchups", []): + if isinstance(mm, dict): + session.metaMatchups.append(MetaMatchup( + archetype=mm.get("archetype", ""), + assessment=mm.get("assessment", ""), + keyThreats=mm.get("keyThreats", []), + keyAnswers=mm.get("keyAnswers", []), + overallRating=mm.get("overallRating", ""), + )) + + # ── Status Check ───────────────────────────────────────── def get_status(self) -> CoachStatus: """Check the health of all coach subsystems.""" diff --git a/coach/prompt_template.py b/coach/prompt_template.py index c517c71..f623af2 100644 --- a/coach/prompt_template.py +++ b/coach/prompt_template.py @@ -40,6 +40,14 @@ RULES: - 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: @@ -102,11 +110,103 @@ 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 (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}. +{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 Builder # ══════════════════════════════════════════════════════════════ @@ -290,6 +390,17 @@ def build_system_prompt(report: DeckReport, ) +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) -> str: """Build the user prompt with full deck report data.""" diff --git a/routes/coach.py b/routes/coach.py index 0b4ba15..939a3fc 100644 --- a/routes/coach.py +++ b/routes/coach.py @@ -6,6 +6,7 @@ LLM-powered deck coaching endpoints: GET /api/coach/decks GET /api/coach/decks/{deck_id}/report POST /api/coach/decks/{deck_id} + POST /api/coach/decks/{deck_id}/quick GET /api/coach/sessions GET /api/coach/sessions/{session_id} POST /api/coach/embeddings/download @@ -194,6 +195,34 @@ async def coach_run_session(deck_id: str, body: CoachRequestBody = None): raise HTTPException(500, f"Coach session failed: {e}") +@router.post("/api/coach/decks/{deck_id}/quick") +async def coach_run_quick_digest(deck_id: str, body: CoachRequestBody = None): + """Fast ~5s quick digest: summary, top 3 upgrades, commander dependency.""" + if _coach_service is None: + raise HTTPException(500, "Coach service not initialized") + goals = None + if body and body.goals: + from coach.models import CoachGoals + try: + goals = CoachGoals(**body.goals) + except Exception: + goals = None + fallback_report = None + try: + fallback_report = await _build_deck_report_from_db(deck_id) + except Exception as e: + log_coach.error(f" Coach: Quick fallback failed for '{deck_id}': {e}") + try: + session = await _coach_service.run_quick_digest(deck_id, goals, fallback_report=fallback_report) + return session.model_dump() + except ValueError as e: + raise HTTPException(404, str(e)) + except ConnectionError as e: + raise HTTPException(503, f"LLM connection failed: {e}") + except Exception as e: + raise HTTPException(500, f"Quick digest failed: {e}") + + async def _build_deck_report_from_db(deck_slug: str): """Build a lightweight DeckReport from DB (async). Allows coaching without simulation data.""" from coach.models import DeckReport, CardPerformance, DeckStructure diff --git a/ui/coach.html b/ui/coach.html index 1262802..a74c8bd 100644 --- a/ui/coach.html +++ b/ui/coach.html @@ -120,9 +120,8 @@
' + escHtml(session.summary) + '
'; } - - // Cuts if (cuts.length > 0) { - html += '' + escHtml(top.reasoning) + '
' : '') + + '