diff --git a/lab_api.py b/lab_api.py index df4b439..19a8c06 100644 --- a/lab_api.py +++ b/lab_api.py @@ -111,6 +111,8 @@ from routes.import_routes import router as import_router from routes.lab import router as lab_router from routes.scanner import router as scanner_router from routes.deepseek import router as deepseek_router +from routes.deckgen import router as deckgen_router +from routes.coach import router as coach_router, init_coach_service # ══════════════════════════════════════════════════════════════ @@ -155,6 +157,8 @@ app.include_router(import_router) app.include_router(lab_router) app.include_router(scanner_router) app.include_router(deepseek_router) +app.include_router(deckgen_router) +app.include_router(coach_router) @app.on_event("startup") @@ -175,2212 +179,6 @@ async def _on_startup(): active_batches: dict[str, BatchState] = {} -# ══════════════════════════════════════════════════════════════ -# Auto Deck Generator -# ══════════════════════════════════════════════════════════════ - - -def _resolve_commander(req: DeckGenerationRequest) -> dict: - """ - Resolve commander info from a DeckGenerationRequest. - Returns dict with: name, scryfall_id, color_identity, type_line, mana_cost, image_url - """ - conn = _get_db_conn() - - # Try scryfall_id first - if req.commander_scryfall_id: - row = conn.execute( - "SELECT name, type_line, color_identity, mana_cost, scryfall_id FROM collection_entries WHERE scryfall_id = ? LIMIT 1", - (req.commander_scryfall_id,) - ).fetchone() - if row: - ci = row["color_identity"] - if isinstance(ci, str): - try: - ci = json.loads(ci) - except Exception: - ci = [] - return { - "name": row["name"], - "scryfall_id": row["scryfall_id"], - "color_identity": ci, - "type_line": row["type_line"], - "mana_cost": row["mana_cost"] or "", - "image_url": f"https://api.scryfall.com/cards/{row['scryfall_id']}?format=image&version=normal", - } - # Fallback to Scryfall API - _scryfall_rate_limit() - try: - url = f"https://api.scryfall.com/cards/{req.commander_scryfall_id}" - rq = Request(url, headers={"User-Agent": "CommanderAILab/1.0", "Accept": "application/json"}) - with urlopen(rq, timeout=15) as resp: - data = json.loads(resp.read().decode("utf-8")) - return _scryfall_to_commander(data) - except Exception: - pass - - # Try name search - if req.commander_name: - name_lower = req.commander_name.strip().lower() - row = conn.execute( - "SELECT name, type_line, color_identity, mana_cost, scryfall_id FROM collection_entries WHERE LOWER(name) = ? LIMIT 1", - (name_lower,) - ).fetchone() - if row: - ci = row["color_identity"] - if isinstance(ci, str): - try: - ci = json.loads(ci) - except Exception: - ci = [] - return { - "name": row["name"], - "scryfall_id": row["scryfall_id"], - "color_identity": ci, - "type_line": row["type_line"], - "mana_cost": row["mana_cost"] or "", - "image_url": f"https://api.scryfall.com/cards/{row['scryfall_id']}?format=image&version=normal", - } - - # Fallback to Scryfall fuzzy search - _scryfall_rate_limit() - try: - import urllib.parse - encoded = urllib.parse.quote(req.commander_name) - url = f"https://api.scryfall.com/cards/named?fuzzy={encoded}" - rq = Request(url, headers={"User-Agent": "CommanderAILab/1.0", "Accept": "application/json"}) - with urlopen(rq, timeout=15) as resp: - data = json.loads(resp.read().decode("utf-8")) - return _scryfall_to_commander(data) - except Exception: - pass - - return None - - -def _scryfall_to_commander(data: dict) -> dict: - """Convert Scryfall API response to commander info dict.""" - ci = data.get("color_identity", []) - image_uris = data.get("image_uris", {}) - if not image_uris and data.get("card_faces"): - image_uris = data["card_faces"][0].get("image_uris", {}) - return { - "name": data.get("name", ""), - "scryfall_id": data.get("id", ""), - "color_identity": ci, - "type_line": data.get("type_line", ""), - "mana_cost": data.get("mana_cost", ""), - "image_url": image_uris.get("normal", ""), - } - - -def _get_collection_for_colors(color_identity: list) -> list: - """ - Fetch all collection cards compatible with the given color identity. - Returns list of dicts with card details. - """ - conn = _get_db_conn() - rows = conn.execute( - "SELECT * FROM collection_entries WHERE quantity > 0" - ).fetchall() - - valid = [] - for row in rows: - card = _row_to_dict(row) - card_ci = card.get("color_identity", []) - if isinstance(card_ci, str): - try: - card_ci = json.loads(card_ci) - except Exception: - card_ci = [] - - # Color identity check: all card colors must be within commander's identity - if color_identity and card_ci: - if not all(c in color_identity for c in card_ci): - continue - - # Skip if no scryfall_id - if not card.get("scryfall_id"): - continue - - card["color_identity"] = card_ci - valid.append(card) - - return valid - - -def _generate_deck(req: DeckGenerationRequest) -> dict: - """ - Core deck generation algorithm. - - 1. Resolve commander - 2. Load collection filtered to color identity - 3. Call enabled source adapters (stubbed) - 4. Build candidate pool, score by role/type fit - 5. Fill slots per target ratios, preferring owned cards - 6. Return response dict - """ - # 1. Resolve commander - commander = _resolve_commander(req) - if not commander: - return {"error": "Commander not found. Please check the name or Scryfall ID."} - - color_identity = req.color_identity or commander.get("color_identity", []) - log_deckgen.info(f" Commander: {commander['name']}, CI: {color_identity}") - - # 2. Load collection - collection = _get_collection_for_colors(color_identity) - log_deckgen.info(f" {len(collection)} cards in collection match color identity") - - # 3. Call source adapters (currently stubbed) - template_cards = [] # list of (name, weight, source_name) - sources = req.sources or DeckGenerationSourceConfig() - - try: - if sources.use_archidekt: - from deck_sources.archidekt_adapter import fetch_template_decks as archidekt_fetch - for td in archidekt_fetch(commander["name"], color_identity, {"url": sources.archidekt_url}): - for tc in td.cards: - template_cards.append((tc.name, tc.quantity, "archidekt")) - except Exception as e: - log_deckgen.error(f" Archidekt adapter error: {e}") - - try: - if sources.use_edhrec: - from deck_sources.edhrec_adapter import fetch_template_decks as edhrec_fetch - for td in edhrec_fetch(commander["name"], color_identity): - for tc in td.cards: - template_cards.append((tc.name, tc.quantity, "edhrec")) - except Exception as e: - log_deckgen.error(f" EDHREC adapter error: {e}") - - try: - if sources.use_moxfield: - from deck_sources.moxfield_adapter import fetch_template_decks as moxfield_fetch - for td in moxfield_fetch(commander["name"], color_identity, {"url": sources.moxfield_url}): - for tc in td.cards: - template_cards.append((tc.name, tc.quantity, "moxfield")) - except Exception as e: - log_deckgen.error(f" Moxfield adapter error: {e}") - - try: - if sources.use_mtggoldfish: - from deck_sources.mtggoldfish_adapter import fetch_template_decks as mtggoldfish_fetch - for td in mtggoldfish_fetch(commander["name"], color_identity, {"url": sources.mtggoldfish_url}): - for tc in td.cards: - template_cards.append((tc.name, tc.quantity, "mtggoldfish")) - except Exception as e: - log_deckgen.error(f" MTGGoldfish adapter error: {e}") - - # 4. Build candidate pool from collection, scored by type need - # Build a map: name_lower -> card dict (deduped) - candidate_map = {} - for card in collection: - name = card.get("name", "") - if not name: - continue - key = name.lower() - # Skip the commander itself - if key == commander["name"].lower(): - continue - if key not in candidate_map: - type_line = card.get("type_line", "") - card_type = _classify_card_type(type_line) - card_roles = _detect_card_roles( - card.get("oracle_text", ""), - type_line, - card.get("keywords", []) - ) - candidate_map[key] = { - "scryfall_id": card.get("scryfall_id", ""), - "name": name, - "type_line": type_line, - "mana_cost": card.get("mana_cost", ""), - "cmc": float(card.get("cmc", 0)), - "card_type": card_type, - "roles": card_roles, - "source": "collection", - "quantity": 1, - "owned_qty": int(card.get("quantity", 0)), - "is_proxy": False, - "edhrec_rank": int(card.get("edhrec_rank", 99999)), - "image_url": f"https://api.scryfall.com/cards/{card.get('scryfall_id', '')}?format=image&version=normal" - if card.get("scryfall_id") else "", - } - else: - # Merge quantities - candidate_map[key]["owned_qty"] += int(card.get("quantity", 0)) - - # Add template cards that aren't in collection (as potential proxies) - for tname, tqty, tsource in template_cards: - key = tname.lower() - if key == commander["name"].lower(): - continue - if key in candidate_map: - # Boost priority for cards that appear in templates - candidate_map[key]["_template_weight"] = candidate_map[key].get("_template_weight", 0) + tqty - if candidate_map[key]["source"] == "collection": - candidate_map[key]["source"] = f"collection+{tsource}" - elif req.allow_proxies: - candidate_map[key] = { - "scryfall_id": "", - "name": tname, - "type_line": "", - "mana_cost": "", - "cmc": 0, - "card_type": "Other", - "roles": [], - "source": tsource, - "quantity": 1, - "owned_qty": 0, - "is_proxy": True, - "edhrec_rank": 99999, - "image_url": "", - "_template_weight": tqty, - } - - # 5. Fill slots per target ratios - targets = { - "Land": req.target_land_count, - "Creature": req.target_creature_count, - "Instant": req.target_instant_count, - "Sorcery": req.target_sorcery_count, - "Artifact": req.target_artifact_count, - "Enchantment": req.target_enchantment_count, - "Planeswalker": req.target_planeswalker_count, - } - - # Group candidates by type - by_type = {} - for card in candidate_map.values(): - ct = card["card_type"] - by_type.setdefault(ct, []).append(card) - - # Score and sort candidates within each type - for ct, cards in by_type.items(): - for card in cards: - score = 0 - # Prefer owned cards heavily - if card["owned_qty"] > 0: - score += 100 - # Prefer cards with functional roles - for r in card.get("roles", []): - if r in ("Ramp", "Draw", "Removal", "BoardWipe"): - score += 10 - else: - score += 3 - # Prefer lower EDHREC rank (more popular cards) - edhrec_rank = card.get("edhrec_rank", 99999) - if edhrec_rank < 500: - score += 15 - elif edhrec_rank < 2000: - score += 10 - elif edhrec_rank < 5000: - score += 5 - # Prefer cards from templates - score += card.get("_template_weight", 0) * 5 - # Prefer lower CMC (curve optimization) - cmc = card.get("cmc", 0) - if cmc <= 2: - score += 5 - elif cmc <= 4: - score += 3 - card["_score"] = score - cards.sort(key=lambda x: x["_score"], reverse=True) - - # Pick cards for each slot type - deck_cards = [] - used_names = set() # track by name to avoid duplicates - - for card_type, target in targets.items(): - candidates = by_type.get(card_type, []) - picked = 0 - for card in candidates: - if picked >= target: - break - name_key = card["name"].lower() - if name_key in used_names: - continue - # If only collection, skip proxies - if req.only_cards_in_collection and card["owned_qty"] <= 0: - continue - used_names.add(name_key) - deck_cards.append(card) - picked += 1 - - # Fill remaining slots up to 99 (commander is #100) - total = sum(targets.values()) - if total < 99: - remaining = 99 - len(deck_cards) - # Fill with best remaining cards from any type - all_remaining = [] - for ct, cards in by_type.items(): - for card in cards: - if card["name"].lower() not in used_names: - if not req.only_cards_in_collection or card["owned_qty"] > 0: - all_remaining.append(card) - all_remaining.sort(key=lambda x: x.get("_score", 0), reverse=True) - for card in all_remaining[:remaining]: - used_names.add(card["name"].lower()) - deck_cards.append(card) - elif len(deck_cards) > 99: - deck_cards = deck_cards[:99] - - # 6. Compute stats - stats = {"total": len(deck_cards) + 1, "land": 0, "nonland": 0, "by_type": {}, "owned": 0, "proxy": 0} - for card in deck_cards: - ct = card.get("card_type", "Other") - stats["by_type"][ct] = stats["by_type"].get(ct, 0) + 1 - if ct == "Land": - stats["land"] += 1 - else: - stats["nonland"] += 1 - if card.get("owned_qty", 0) > 0: - stats["owned"] += 1 - else: - stats["proxy"] += 1 - - # Clean up internal scoring keys - clean_cards = [] - for card in deck_cards: - clean_cards.append({ - "scryfall_id": card.get("scryfall_id", ""), - "name": card.get("name", ""), - "type_line": card.get("type_line", ""), - "mana_cost": card.get("mana_cost", ""), - "cmc": card.get("cmc", 0), - "card_type": card.get("card_type", "Other"), - "roles": card.get("roles", []), - "source": card.get("source", "collection"), - "quantity": 1, - "image_url": card.get("image_url", ""), - "owned_qty": card.get("owned_qty", 0), - "is_proxy": card.get("is_proxy", False), - }) - - log_deckgen.info(f" Generated deck: {len(clean_cards)} cards + commander") - log_deckgen.info(f" Stats: {stats}") - - return { - "commander": commander, - "color_identity": color_identity, - "cards": clean_cards, - "stats": stats, - "targets": targets, - } - - -@app.get("/api/deck-generator/config") -async def deck_generator_config(): - """ - Return default ratios, supported sources, and limits. - Used by the frontend to prefill the generator form. - """ - return { - "defaults": { - "target_land_count": 37, - "target_instant_count": 10, - "target_sorcery_count": 8, - "target_artifact_count": 10, - "target_enchantment_count": 8, - "target_creature_count": 25, - "target_planeswalker_count": 2, - "only_cards_in_collection": False, - "allow_proxies": True, - }, - "sources": [ - {"id": "archidekt", "name": "Archidekt", "enabled": True, "experimental": False}, - {"id": "edhrec", "name": "EDHREC", "enabled": True, "experimental": False}, - {"id": "moxfield", "name": "Moxfield", "enabled": False, "experimental": True}, - {"id": "mtggoldfish", "name": "MTGGoldfish", "enabled": False, "experimental": True}, - ], - "limits": { - "deck_size": 100, - "max_external_templates": 5, - }, - } - - -@app.post("/api/deck-generator/preview") -async def deck_generator_preview(req: DeckGenerationRequest): - """ - Generate a deck preview without saving it. - Returns the generated deck data for user review. - """ - result = _generate_deck(req) - if "error" in result: - raise HTTPException(400, result["error"]) - return result - - -@app.post("/api/deck-generator/commit") -async def deck_generator_commit(req: DeckGenerationRequest): - """ - Generate a deck and save it to the Deck Builder. - Returns the generated deck data plus deck_id and deck_name. - """ - result = _generate_deck(req) - if "error" in result: - raise HTTPException(400, result["error"]) - - commander = result["commander"] - color_identity = result["color_identity"] - cards = result["cards"] - - # Determine deck name - deck_name = req.deck_name or f"Auto - {commander['name']} - {datetime.now().strftime('%Y-%m-%d %H:%M')}" - - # Create the deck in the database - conn = _get_db_conn() - color_identity_json = json.dumps(color_identity) - cur = conn.execute( - """ - INSERT INTO decks (name, commander_scryfall_id, commander_name, color_identity, strategy_tag) - VALUES (?, ?, ?, ?, ?) - """, - ( - deck_name, - commander.get("scryfall_id", ""), - commander.get("name", ""), - color_identity_json, - "auto-generated", - ) - ) - conn.commit() - deck_id = cur.lastrowid - - # Insert commander as a deck card with is_commander=1 - conn.execute( - "INSERT INTO deck_cards (deck_id, scryfall_id, card_name, quantity, is_commander, role_tag) VALUES (?, ?, ?, 1, 1, 'Commander')", - (deck_id, commander.get("scryfall_id", ""), commander.get("name", "")) - ) - - # Insert the 99 cards - for card in cards: - scryfall_id = card.get("scryfall_id", "") - card_name = card.get("name", "") - quantity = card.get("quantity", 1) - if not scryfall_id and not card_name: - continue - conn.execute( - "INSERT INTO deck_cards (deck_id, scryfall_id, card_name, quantity, is_commander, role_tag) VALUES (?, ?, ?, ?, 0, ?)", - (deck_id, scryfall_id, card_name, quantity, card.get("card_type", "")) - ) - conn.commit() - - # Also export a .dck file to the Forge decks directory so it appears in the Sim Lab - try: - decks_dir = CFG.forge_decks_dir - if decks_dir and os.path.isdir(decks_dir): - safe_name = re.sub(r'[^a-zA-Z0-9_\-\s]', '', deck_name).strip().replace(' ', '_') - if not safe_name: - safe_name = f"AutoDeck_{deck_id}" - dck_path = os.path.join(decks_dir, f"{safe_name}.dck") - dck_lines = [] - dck_lines.append("[metadata]") - dck_lines.append(f"Name={deck_name}") - dck_lines.append("") - dck_lines.append("[Commander]") - dck_lines.append(f"1 {commander['name']}") - dck_lines.append("") - dck_lines.append("[Main]") - for card in cards: - card_name = card.get("name", "") - if card_name: - dck_lines.append(f"{card.get('quantity', 1)} {card_name}") - with open(dck_path, "w", encoding="utf-8") as f: - f.write("\n".join(dck_lines)) - log_deckgen.info(f" Exported .dck file: {dck_path}") - except Exception as e: - log_deckgen.warning(f" Warning: Failed to export .dck file: {e}") - - log_deckgen.info(f" Saved deck '{deck_name}' (ID: {deck_id}) with {len(cards)} cards + commander") - - result["deck_id"] = deck_id - result["deck_name"] = deck_name - return result - - -@app.get("/api/deck-generator/commander-search") -async def deck_generator_commander_search(q: str = ""): - """ - Search for legendary creatures/planeswalkers to use as commander. - First checks collection, then falls back to Scryfall. - """ - if not q or len(q) < 2: - return {"results": []} - - conn = _get_db_conn() - q_lower = f"%{q.lower()}%" - - # Search collection first - rows = conn.execute( - """ - SELECT DISTINCT name, type_line, color_identity, mana_cost, scryfall_id - FROM collection_entries - WHERE LOWER(name) LIKE ? AND is_legendary = 1 - AND (type_line LIKE '%Creature%' OR type_line LIKE '%Planeswalker%') - ORDER BY name ASC - LIMIT 20 - """, - (q_lower,) - ).fetchall() - - results = [] - for r in rows: - ci = r["color_identity"] - if isinstance(ci, str): - try: - ci = json.loads(ci) - except Exception: - ci = [] - results.append({ - "name": r["name"], - "type_line": r["type_line"], - "color_identity": ci, - "mana_cost": r["mana_cost"] or "", - "scryfall_id": r["scryfall_id"], - "in_collection": True, - "image_url": f"https://api.scryfall.com/cards/{r['scryfall_id']}?format=image&version=normal" - if r["scryfall_id"] else "", - }) - - # If few results, supplement with Scryfall - if len(results) < 5: - try: - import urllib.parse - encoded = urllib.parse.quote(q) - _scryfall_rate_limit() - url = f"https://api.scryfall.com/cards/search?q={encoded}+t%3Alegendary+(t%3Acreature+OR+t%3Aplaneswalker)&order=edhrec&unique=cards" - rq = Request(url, headers={"User-Agent": "CommanderAILab/1.0", "Accept": "application/json"}) - with urlopen(rq, timeout=15) as resp: - data = json.loads(resp.read().decode("utf-8")) - - existing_names = {r["name"].lower() for r in results} - for card in data.get("data", [])[:10]: - if card["name"].lower() in existing_names: - continue - image_uris = card.get("image_uris", {}) - if not image_uris and card.get("card_faces"): - image_uris = card["card_faces"][0].get("image_uris", {}) - results.append({ - "name": card["name"], - "type_line": card.get("type_line", ""), - "color_identity": card.get("color_identity", []), - "mana_cost": card.get("mana_cost", ""), - "scryfall_id": card.get("id", ""), - "in_collection": False, - "image_url": image_uris.get("normal", image_uris.get("small", "")), - }) - except Exception as e: - log_deckgen.error(f" Scryfall search error: {e}") - - return {"results": results[:20]} - - -# ══════════════════════════════════════════════════════════════ -# Perplexity API — AI Deck Research & Generation -# ══════════════════════════════════════════════════════════════ - - -class DeckResearchRequest(BaseModel): - deck_id: int # Deck to research - goal: Optional[str] = "Identify weaknesses and suggest upgrades" - budget_usd: Optional[float] = None - omit_cards: Optional[list[str]] = [] - use_collection: bool = True - - -class DeckGenerateAIRequest(BaseModel): - commander: str - budget_usd: Optional[float] = None - budget_mode: str = "total" # "total" or "per_card" - omit_cards: Optional[list[str]] = [] - use_collection: bool = True - - -def _build_collection_summary(color_identity: list[str] | None = None) -> dict: - """Build a compact collection summary for the AI, optionally filtered by color identity.""" - conn = _get_db_conn() - - # Base query: all collection entries with oracle text - where = "WHERE quantity > 0" - params = [] - - # Filter by color identity if provided - if color_identity: - # Cards must only contain colors in the commander's identity (+ colorless) - ci_set = set(c.upper() for c in color_identity) - # We'll do a post-filter since color_identity is JSON in the DB - pass # filter in Python after fetch - - rows = conn.execute(f""" - SELECT name, type_line, cmc, oracle_text, keywords, tcg_price, quantity, - color_identity, category, is_game_changer, salt_score - FROM collection_entries {where} - ORDER BY edhrec_rank ASC, tcg_price DESC - """, params).fetchall() - - # Color identity filter - def card_fits_identity(card_ci_str, ci_set): - if not ci_set: - return True - try: - card_ci = json.loads(card_ci_str) if card_ci_str else [] - if not isinstance(card_ci, list): - return True - return all(c.upper() in ci_set for c in card_ci if c.upper() not in ('', 'C')) - except Exception: - return True - - # Role detection from type_line, oracle_text, and category - def detect_role(row): - tl = (row['type_line'] or '').lower() - oracle = (row['oracle_text'] or '').lower() - cat = (row['category'] or '').lower() - kw = (row['keywords'] or '').lower() - - # Priority order - if 'land' in tl: - return 'lands' - if any(w in cat for w in ['ramp', 'mana']): - return 'ramp' - if any(w in oracle for w in ['add {', 'add one mana', 'search your library for a basic land', 'search your library for a land']): - return 'ramp' - if 'mana' in cat or ('artifact' in tl and ('add' in oracle and '{' in oracle)): - return 'ramp' - if any(w in cat for w in ['draw', 'card advantage']): - return 'card_draw' - if 'draw' in oracle and 'card' in oracle: - return 'card_draw' - if any(w in cat for w in ['removal', 'targeted removal']): - return 'removal' - if 'destroy target' in oracle or 'exile target' in oracle or 'deals' in oracle: - return 'removal' - if any(w in cat for w in ['board wipe', 'boardwipe', 'wrath']): - return 'board_wipes' - if 'destroy all' in oracle or 'exile all' in oracle: - return 'board_wipes' - if any(w in cat for w in ['win', 'finisher', 'combo']): - return 'win_conditions' - if 'you win the game' in oracle or 'extra turn' in oracle or 'infinite' in oracle: - return 'win_conditions' - if 'creature' in tl: - return 'creatures' - return 'utility' - - # Group cards by role - groups: dict[str, list] = { - 'ramp': [], 'card_draw': [], 'removal': [], 'board_wipes': [], - 'lands': [], 'win_conditions': [], 'creatures': [], 'utility': [] - } - filtered_count = 0 - - for r in rows: - if color_identity and not card_fits_identity(r['color_identity'], ci_set): - continue - filtered_count += 1 - role = detect_role(r) - groups[role].append({ - 'name': r['name'], - 'count': r['quantity'], - 'price': round(r['tcg_price'] or 0, 2), - 'cmc': r['cmc'] or 0, - }) - - # Limit each group to top 30 - group_descriptions = { - 'ramp': 'Mana rocks and land ramp in deck colors', - 'card_draw': 'Card draw and card advantage engines', - 'removal': 'Targeted removal spells (destroy, exile, bounce)', - 'board_wipes': 'Board wipes and mass removal', - 'lands': 'Non-basic lands that fit the color identity', - 'win_conditions': 'Win conditions, combo pieces, and finishers', - 'creatures': 'Creatures (non-commander)', - 'utility': 'Utility spells, enchantments, artifacts, and planeswalkers', - } - - result_groups = [] - for gid, cards in groups.items(): - if not cards: - continue - result_groups.append({ - 'group_id': gid, - 'description': group_descriptions.get(gid, ''), - 'cards': cards[:30], - }) - - return { - 'total_cards': len(rows), - 'filtered_cards': filtered_count, - 'groups': result_groups, - } - - -def _call_pplx_api(messages: list[dict], max_tokens: int = 4096, temperature: float = 0.2) -> str: - """Call Perplexity API chat/completions endpoint. Returns the assistant message content.""" - if not CFG.pplx_api_key: - raise HTTPException(400, 'Perplexity API key not configured. Set PPLX_API_KEY env var or --pplx-key.') - - payload = { - 'model': 'sonar', - 'messages': messages, - 'max_tokens': max_tokens, - 'temperature': temperature, - 'return_related_questions': False, - } - - req_data = json.dumps(payload).encode('utf-8') - req = Request('https://api.perplexity.ai/chat/completions', data=req_data, method='POST') - req.add_header('Content-Type', 'application/json') - req.add_header('Authorization', f'Bearer {CFG.pplx_api_key}') - - try: - with urlopen(req, timeout=120) as resp: - data = json.loads(resp.read()) - choices = data.get('choices', []) - if not choices: - raise ValueError('Empty response from Perplexity API') - content = choices[0].get('message', {}).get('content', '') - usage = data.get('usage', {}) - log_pplx.debug(f'tokens: prompt={usage.get("prompt_tokens","?")}, ' - f'completion={usage.get("completion_tokens","?")}, ' - f'model={data.get("model","?")}') - return content - except URLError as e: - raise HTTPException(502, f'Perplexity API call failed: {e}') - except json.JSONDecodeError as e: - raise HTTPException(502, f'Perplexity API returned invalid JSON: {e}') - - -def _extract_json_from_response(text: str) -> dict: - """Extract JSON object from LLM response, handling markdown fences and extra text.""" - # Strip markdown code fences - cleaned = re.sub(r'^```(?:json)?\s*', '', text.strip()) - cleaned = re.sub(r'\s*```$', '', cleaned.strip()) - - # Try direct parse - try: - return json.loads(cleaned) - except json.JSONDecodeError: - pass - - # Try to find JSON object in the text - match = re.search(r'\{[\s\S]*\}', text) - if match: - try: - return json.loads(match.group(0)) - except json.JSONDecodeError: - pass - - raise ValueError(f'Could not extract JSON from response: {text[:300]}') - - -def _postprocess_deck_cards(cards: list[dict], color_identity: list[str] | None = None) -> list[dict]: - """ - Post-process AI-suggested cards: - - Validate names against collection DB - - Attach real prices - - Set from_collection flag - """ - conn = _get_db_conn() - processed = [] - for card in cards: - name = card.get('name', '') - if not name: - continue - - # Look up in collection - row = conn.execute( - "SELECT name, tcg_price, quantity, scryfall_id, type_line, cmc, oracle_text " - "FROM collection_entries WHERE name = ? COLLATE NOCASE LIMIT 1", - (name,) - ).fetchone() - - entry = { - 'name': row['name'] if row else name, - 'count': card.get('count', 1), - 'role': card.get('role', ''), - 'from_collection': bool(row and row['quantity'] and row['quantity'] > 0), - 'estimated_price_usd': round(row['tcg_price'], 2) if row and row['tcg_price'] else card.get('estimated_price_usd', 0), - 'scryfall_id': row['scryfall_id'] if row else '', - } - # Preserve extra fields from AI response - for extra_key in ('reason', 'synergy_with', 'priority', 'severity'): - if extra_key in card: - entry[extra_key] = card[extra_key] - processed.append(entry) - - return processed - - -# Color-to-basic-land mapping for deterministic fill -_COLOR_TO_BASIC = { - 'W': 'Plains', - 'U': 'Island', - 'B': 'Swamp', - 'R': 'Mountain', - 'G': 'Forest', -} -BASIC_LAND_NAMES = set(_COLOR_TO_BASIC.values()) | {'Wastes'} - - -def _fill_basic_lands(cards: list[dict], color_identity: list[str] | None = None, target_total: int = 100) -> list[dict]: - """ - Deterministically fill basic lands so the deck hits exactly target_total cards. - - The LLM may list basics with wrong counts, duplicates, or omit them. - This function: - 1. Strips all basic-land entries the LLM provided - 2. Counts remaining cards (non-basics) and their total count - 3. Computes how many basic land slots are needed - 4. Distributes basics evenly across the commander's colors - """ - # Separate basics from non-basics - non_basics = [] - for card in cards: - name = card.get('name', '').strip() - if name not in BASIC_LAND_NAMES: - non_basics.append(card) - - # Count total non-basic cards - non_basic_total = sum(c.get('count', 1) for c in non_basics) - - # How many basic land copies we need - basics_needed = max(0, target_total - non_basic_total) - - if basics_needed == 0: - return non_basics - - # Determine which basics to use from color identity - ci = [c.upper() for c in (color_identity or [])] - basic_names = [_COLOR_TO_BASIC[c] for c in ci if c in _COLOR_TO_BASIC] - if not basic_names: - # Colorless commander — use Wastes - basic_names = ['Wastes'] - - # Distribute evenly, then add remainders one-by-one - per_color = basics_needed // len(basic_names) - remainder = basics_needed % len(basic_names) - - for i, bname in enumerate(basic_names): - qty = per_color + (1 if i < remainder else 0) - if qty > 0: - non_basics.append({ - 'name': bname, - 'count': qty, - 'role': 'land', - 'category': 'Land', - 'role_tags': [], - 'reason': 'Basic land for mana fixing', - 'estimated_price_usd': 0.10, - 'from_collection': False, - 'is_basic': True, - }) - - return non_basics - - -# ── Research Endpoint ───────────────────────────────────────── - -@app.post('/api/deck-research') -async def deck_research(req: DeckResearchRequest): - """Analyze an existing deck using Perplexity AI and suggest improvements.""" - conn = _get_db_conn() - - # Load deck info - deck = conn.execute('SELECT * FROM decks WHERE id = ?', (req.deck_id,)).fetchone() - if not deck: - raise HTTPException(404, f'Deck {req.deck_id} not found') - - deck_name = deck['name'] - commander_name = deck['commander_name'] or 'Unknown' - color_identity_str = deck['color_identity'] or '[]' - try: - color_identity = json.loads(color_identity_str) - except Exception: - color_identity = [] - - # Load deck cards - card_rows = conn.execute(""" - SELECT dc.card_name, dc.quantity, dc.is_commander, - ce.type_line, ce.cmc, ce.oracle_text, ce.tcg_price - FROM deck_cards dc - LEFT JOIN ( - SELECT scryfall_id, type_line, cmc, oracle_text, tcg_price - FROM collection_entries GROUP BY scryfall_id - ) ce ON ce.scryfall_id = dc.scryfall_id - WHERE dc.deck_id = ? - ORDER BY dc.is_commander DESC, dc.card_name ASC - """, (req.deck_id,)).fetchall() - - if not card_rows: - raise HTTPException(400, 'Deck has no cards') - - # Build decklist text - decklist_lines = [] - total_price = 0.0 - for r in card_rows: - price = r['tcg_price'] or 0 - total_price += price * (r['quantity'] or 1) - cmdr_tag = ' [COMMANDER]' if r['is_commander'] else '' - decklist_lines.append(f"{r['quantity'] or 1}x {r['card_name']}{cmdr_tag}") - - decklist_text = '\n'.join(decklist_lines) - - # Build collection summary if requested - collection_block = '' - if req.use_collection: - summary = _build_collection_summary(color_identity) - if summary['filtered_cards'] > 0: - coll_lines = [f'\nCOLLECTION SUMMARY ({summary["filtered_cards"]} cards in deck colors):'] - for grp in summary['groups']: - card_names = [f"{c['name']} (${c['price']})" for c in grp['cards'][:15]] - coll_lines.append(f" {grp['group_id'].upper()} ({grp['description']}): {', '.join(card_names)}") - collection_block = '\n'.join(coll_lines) - - omit_block = '' - if req.omit_cards: - omit_block = f'\nDO NOT suggest these cards: {", ".join(req.omit_cards)}' - - budget_block = '' - if req.budget_usd: - budget_block = f'\nBUDGET: ${req.budget_usd} total for upgrades. Current deck value: ~${total_price:.0f}' - - # Build messages - system_msg = """You are an elite Magic: The Gathering Commander analyst with encyclopedic knowledge of the format, metagame, and every card ever printed. Provide a DEEP, COMPREHENSIVE analysis. -Always respond with ONLY a JSON object, no markdown fences, no extra text. - -JSON schema: -{ - "overall_rating": "1-10 integer", - "rating_explanation": "2-3 sentence explanation of the rating", - "deck_description": "3-5 sentence overview of what this deck does, its game plan, and how it wins", - "archetype": "aggro|midrange|control|combo|stax|voltron|aristocrats|spellslinger|tokens|tribal|group_hug|lands|reanimator|other", - "bracket_level": { - "level": 1-4, - "reasoning": "why this bracket", - "power_ceiling": "what power level this deck could reach with upgrades" - }, - "win_conditions": [ - {"name": "Win condition name", "cards_involved": ["card1", "card2"], "description": "How this wins the game", "reliability": "high|medium|low"} - ], - "synergy_packages": [ - {"package_name": "Package Name (e.g. Sacrifice Engine, Blink Package)", "cards": ["card1", "card2", "card3"], "description": "How these cards work together", "strength": "strong|moderate|weak"} - ], - "strengths": ["strength 1", "strength 2"], - "weaknesses": ["weakness 1", "weakness 2"], - "threat_assessment": { - "early_game": "1-2 sentences on turns 1-3 plan", - "mid_game": "1-2 sentences on turns 4-7 plan", - "late_game": "1-2 sentences on turns 8+ plan", - "vulnerability": "What shuts this deck down (e.g. graveyard hate, board wipes)" - }, - "mana_analysis": { - "land_count": "current land count assessment", - "color_fixing": "assessment of color fixing quality", - "ramp_package": "assessment of ramp quantity and quality", - "curve_assessment": "is the mana curve appropriate for the strategy", - "problem_cards": ["cards that are hard to cast or mana-inefficient"] - }, - "cuts": [{"name": "card to remove", "reason": "why", "severity": "must_cut|should_cut|consider_cutting"}], - "adds": [{"name": "card to add", "count": 1, "role": "ramp|removal|draw|creature|utility|land|combo_piece|protection|finisher", "estimated_price_usd": 2.5, "reason": "why this card", "synergy_with": ["existing card it synergizes with"], "priority": "critical|high|medium|nice_to_have"}], - "role_gaps": { - "ramp": {"current": 8, "recommended": 10, "note": "needs 2 more ramp sources"}, - "card_draw": {"current": 5, "recommended": 10, "note": ""}, - "removal": {"current": 6, "recommended": 8, "note": ""}, - "board_wipes": {"current": 2, "recommended": 3, "note": ""}, - "protection": {"current": 1, "recommended": 3, "note": ""}, - "lands": {"current": 35, "recommended": 36, "note": ""} - }, - "strategy_notes": "detailed strategic advice for piloting this deck" -}""" - - user_msg = f"""Provide a DEEP, COMPREHENSIVE analysis of this Commander deck. - -DECK NAME: {deck_name} -COMMANDER: {commander_name} -COLOR IDENTITY: {', '.join(color_identity) if color_identity else 'Unknown'} -CURRENT DECK VALUE: ~${total_price:.0f} -GOAL: {req.goal} - -DECKLIST ({len(card_rows)} cards): -{decklist_text} -{budget_block}{omit_block}{collection_block} - -Analyze EVERYTHING: the deck's identity, strategy, archetype, bracket level (1-4 per Commander Rules Committee), ALL synergy packages between cards, ALL win conditions, game plan by phase (early/mid/late), mana base health, every role gap. Suggest specific cuts with severity and specific adds with priority and synergy tags. For adds, prioritize cards from the COLLECTION SUMMARY when available.""" - - content = _call_pplx_api([ - {'role': 'system', 'content': system_msg}, - {'role': 'user', 'content': user_msg}, - ], max_tokens=8192) - - try: - analysis = _extract_json_from_response(content) - except ValueError as e: - return JSONResponse({'error': str(e), 'raw_response': content[:500]}, status_code=422) - - # Post-process "adds" — validate against DB - if 'adds' in analysis and isinstance(analysis['adds'], list): - analysis['adds'] = _postprocess_deck_cards(analysis['adds'], color_identity) - - # Compute real total cost of adds - adds_total = sum(c.get('estimated_price_usd', 0) * c.get('count', 1) for c in analysis.get('adds', [])) - analysis['adds_total_usd'] = round(adds_total, 2) - analysis['deck_name'] = deck_name - analysis['commander'] = commander_name - analysis['color_identity'] = color_identity - analysis['card_count'] = len(card_rows) - analysis['deck_value_usd'] = round(total_price, 2) - - return analysis - - -# ── Generate Endpoint ───────────────────────────────────────── - -@app.post('/api/deck-generate') -async def deck_generate_ai(req: DeckGenerateAIRequest): - """Generate a full 100-card Commander deck using Perplexity AI.""" - commander = req.commander.strip() - if not commander: - raise HTTPException(400, 'Commander name is required') - - # Look up commander on Scryfall for color identity - color_identity = [] - commander_type = '' - try: - scry_url = f'https://api.scryfall.com/cards/named?fuzzy={commander.replace(" ", "+")}' - scry_req = Request(scry_url) - scry_req.add_header('User-Agent', 'CommanderAILab/1.0') - with urlopen(scry_req, timeout=10) as resp: - scry_data = json.loads(resp.read()) - color_identity = scry_data.get('color_identity', []) - commander = scry_data.get('name', commander) # Use canonical name - commander_type = scry_data.get('type_line', '') - except Exception as e: - log_pplx.error(f'Scryfall lookup failed for "{commander}": {e}') - - # Build collection summary - collection_block = '' - if req.use_collection: - summary = _build_collection_summary(color_identity or None) - if summary['filtered_cards'] > 0: - coll_lines = [f'\nCOLLECTION SUMMARY ({summary["filtered_cards"]} cards available):'] - for grp in summary['groups']: - card_names = [f"{c['name']} (${c['price']})" for c in grp['cards'][:20]] - coll_lines.append(f" {grp['group_id'].upper()}: {', '.join(card_names)}") - collection_block = '\n'.join(coll_lines) - - omit_block = '' - if req.omit_cards: - omit_block = f'\nOMIT LIST (do NOT include): {", ".join(req.omit_cards)}' - - budget_block = '' - if req.budget_usd: - mode_desc = 'total deck cost' if req.budget_mode == 'total' else 'per card' - budget_block = f'\nBUDGET: ${req.budget_usd} {mode_desc}. Stay within budget.' - - system_msg = """You are an expert Magic: The Gathering Commander deck builder. -Build a complete, legal 100-card Commander deck (1 commander + 99 other cards). -Prefer cards from the player's collection when available. -Respect the budget and omit list. -Always respond with ONLY a JSON object, no markdown fences, no extra text. - -JSON schema: -{ - "commander": "Commander Name", - "strategy": "1-2 sentence strategy description", - "cards": [ - {"name": "Card Name", "count": 1, "role": "ramp|removal|draw|creature|land|utility|win_condition", "estimated_price_usd": 2.5} - ], - "reasoning": { - "strategy": "detailed strategy explanation", - "mana_curve": "mana curve reasoning", - "key_synergies": "key synergies and combos", - "budget_notes": "how budget was managed", - "collection_usage_notes": "which collection cards were used and why" - }, - "estimated_total_usd": 187.5 -} - -Deck building rules: -- Exactly 100 cards total (commander + 99) -- No more than 1 copy of any card (except basic lands) -- 36-38 lands including commander-colored basics and utility lands -- ~10 ramp sources, ~10 card draw, ~8-10 removal, ~2-3 board wipes -- Include the commander in the cards list with role "commander" -- All cards must be legal in Commander format""" - - user_msg = f"""Build a complete 100-card Commander deck for: - -COMMANDER: {commander} -TYPE: {commander_type} -COLOR IDENTITY: {', '.join(color_identity) if color_identity else 'Unknown'} -{budget_block}{omit_block}{collection_block} - -Build the deck as JSON. Prioritize collection cards when they fit the strategy.""" - - content = _call_pplx_api([ - {'role': 'system', 'content': system_msg}, - {'role': 'user', 'content': user_msg}, - ], max_tokens=8192, temperature=0.3) - - try: - result = _extract_json_from_response(content) - except ValueError as e: - return JSONResponse({'error': str(e), 'raw_response': content[:500]}, status_code=422) - - # Post-process cards - if 'cards' in result and isinstance(result['cards'], list): - result['cards'] = _postprocess_deck_cards(result['cards'], color_identity) - # Fill basic lands deterministically to hit exactly 100 - result['cards'] = _fill_basic_lands(result['cards'], color_identity, target_total=100) - - # Compute real totals - real_total = sum(c.get('estimated_price_usd', 0) * c.get('count', 1) for c in result.get('cards', [])) - from_collection_count = sum(1 for c in result.get('cards', []) if c.get('from_collection')) - result['real_total_usd'] = round(real_total, 2) - result['from_collection_count'] = from_collection_count - result['total_cards'] = sum(c.get('count', 1) for c in result.get('cards', [])) - result['color_identity'] = color_identity - - return result - - -@app.get('/api/pplx/status') -async def pplx_status(): - """Check if Perplexity API is configured.""" - return { - 'configured': bool(CFG.pplx_api_key), - 'key_prefix': CFG.pplx_api_key[:8] + '...' if CFG.pplx_api_key else '', - } - - -# ══════════════════════════════════════════════════════════════ -# V3 Deck Generator (Perplexity Structured Output) -# ══════════════════════════════════════════════════════════════ - -@app.get('/api/deck/v3/status') -async def deck_gen_v3_status(): - """Check V3 deck generator status.""" - return { - 'initialized': _deck_gen_v3 is not None, - 'pplx_configured': bool(CFG.pplx_api_key), - 'model': _deck_gen_v3.pplx.model if _deck_gen_v3 else None, - 'embeddings_loaded': ( - _coach_embeddings.loaded if _coach_embeddings else False - ), - 'embedding_cards': ( - _coach_embeddings.card_count if _coach_embeddings else 0 - ), - 'error': _deck_gen_v3_error, - } - - -@app.post('/api/deck/v3/generate') -async def deck_gen_v3_generate(req: DeckGenV3Request): - """ - V3 Deck Generation — Perplexity structured output with Smart Substitution. - - Pipeline: - 1. Resolve commander via Scryfall - 2. Build collection summary from DB - 3. Call Perplexity Sonar with JSON schema enforcement - 4. Cross-reference cards with collection for ownership - 5. Run Smart Substitution (embedding + Perplexity fallback) - 6. Return complete deck with substitution data - """ - if _deck_gen_v3 is None: - raise HTTPException(503, 'V3 Deck Generator not initialized. Check PPLX_API_KEY.') - - if not req.commander_name or len(req.commander_name.strip()) < 2: - raise HTTPException(400, 'Commander name is required (min 2 chars)') - - try: - # Generate deck - result = _deck_gen_v3.generate_deck( - commander_name=req.commander_name.strip(), - strategy=req.strategy, - target_bracket=req.target_bracket, - budget_usd=req.budget_usd, - budget_mode=req.budget_mode, - omit_cards=req.omit_cards, - use_collection=req.use_collection, - model=req.model, - ) - - # Run substitution if requested - if req.run_substitution and result.get('cards'): - from coach.schemas.substitution_schema import DeckCardWithStatus - cards = [DeckCardWithStatus(**c) for c in result['cards']] - sub_result = _deck_gen_v3.run_substitution( - cards=cards, - commander=result['commander'], - strategy=req.strategy, - ) - result['cards'] = [c.model_dump() for c in sub_result.cards] - result['substitution_stats'] = { - 'owned': sub_result.owned_count, - 'substituted': sub_result.substituted_count, - 'missing': sub_result.missing_count, - } - # Recompute stats with updated cards - from coach.services.deck_generator import DeckGeneratorV3 - result['stats'] = DeckGeneratorV3._compute_stats(sub_result.cards) - - return result - - except ValueError as e: - return JSONResponse({'error': str(e)}, status_code=422) - except Exception as e: - log_deckgen.error(f'Error: {e}') - import traceback - traceback.print_exc() - raise HTTPException(500, f'Deck generation failed: {str(e)}') - - -@app.post('/api/deck/v3/commit') -async def deck_gen_v3_commit(req: DeckGenV3Request): - """ - V3 Generate + Commit — generates the deck and saves it to the Deck Builder DB. - Also exports a .dck file for Forge Sim Lab. - """ - if _deck_gen_v3 is None: - raise HTTPException(503, 'V3 Deck Generator not initialized.') - - if not req.commander_name or len(req.commander_name.strip()) < 2: - raise HTTPException(400, 'Commander name is required') - - try: - # Generate deck (same as preview) - result = _deck_gen_v3.generate_deck( - commander_name=req.commander_name.strip(), - strategy=req.strategy, - target_bracket=req.target_bracket, - budget_usd=req.budget_usd, - budget_mode=req.budget_mode, - omit_cards=req.omit_cards, - use_collection=req.use_collection, - model=req.model, - ) - - # Run substitution - if req.run_substitution and result.get('cards'): - from coach.schemas.substitution_schema import DeckCardWithStatus - cards = [DeckCardWithStatus(**c) for c in result['cards']] - sub_result = _deck_gen_v3.run_substitution( - cards=cards, - commander=result['commander'], - strategy=req.strategy, - ) - result['cards'] = [c.model_dump() for c in sub_result.cards] - - # Save to DB - commander = result['commander'] - color_identity = result.get('color_identity', []) - cards = result.get('cards', []) - strategy = result.get('strategy_summary', '') - bracket = result.get('bracket', {}).get('level', 0) - - deck_name = ( - req.deck_name - or f"V3 - {commander['name']} - B{bracket} - {datetime.now().strftime('%Y-%m-%d %H:%M')}" - ) - - conn = _get_db_conn() - cur = conn.execute( - "INSERT INTO decks (name, commander_scryfall_id, commander_name, color_identity, strategy_tag) " - "VALUES (?, ?, ?, ?, ?)", - (deck_name, commander.get('scryfall_id', ''), - commander.get('name', ''), json.dumps(color_identity), - f"v3-auto|B{bracket}|{strategy[:60]}") - ) - conn.commit() - deck_id = cur.lastrowid - - # Insert commander - conn.execute( - "INSERT INTO deck_cards (deck_id, scryfall_id, card_name, quantity, is_commander, role_tag) " - "VALUES (?, ?, ?, 1, 1, 'Commander')", - (deck_id, commander.get('scryfall_id', ''), commander.get('name', '')) - ) - - # Insert the 99 - for card in cards: - card_name = card.get('name', '') - if not card_name or card_name == commander.get('name', ''): - continue # Skip commander (already inserted) - scryfall_id = card.get('scryfall_id', '') - # Use substitute name if substituted - if card.get('status') == 'substituted' and card.get('selected_substitute'): - card_name = card['selected_substitute'] - conn.execute( - "INSERT INTO deck_cards (deck_id, scryfall_id, card_name, quantity, is_commander, role_tag) " - "VALUES (?, ?, ?, ?, 0, ?)", - (deck_id, scryfall_id, card_name, - card.get('count', 1), card.get('category', '')) - ) - conn.commit() - - # Export .dck for Forge - try: - decks_dir = CFG.forge_decks_dir - if decks_dir and os.path.isdir(decks_dir): - safe_name = re.sub(r'[^a-zA-Z0-9_\-\s]', '', deck_name).strip().replace(' ', '_') - if not safe_name: - safe_name = f"V3Deck_{deck_id}" - dck_path = os.path.join(decks_dir, f"{safe_name}.dck") - dck_lines = ["[metadata]", f"Name={deck_name}", "", - "[Commander]", f"1 {commander['name']}", "", "[Main]"] - for card in cards: - cname = card.get('name', '') - if card.get('status') == 'substituted' and card.get('selected_substitute'): - cname = card['selected_substitute'] - if cname and cname != commander.get('name', ''): - dck_lines.append(f"{card.get('count', 1)} {cname}") - with open(dck_path, 'w', encoding='utf-8') as f: - f.write('\n'.join(dck_lines)) - log_deckgen.info(f" Exported .dck: {dck_path}") - except Exception as e: - log_deckgen.error(f" .dck export failed: {e}") - - result['deck_id'] = deck_id - result['deck_name'] = deck_name - log_deckgen.info(f" Committed deck '{deck_name}' (ID: {deck_id})") - return result - - except ValueError as e: - return JSONResponse({'error': str(e)}, status_code=422) - except Exception as e: - log_deckgen.error(f'Commit error: {e}') - import traceback - traceback.print_exc() - raise HTTPException(500, f'Deck generation/commit failed: {str(e)}') - - -@app.post('/api/deck/v3/export/csv') -async def deck_gen_v3_export_csv(req: DeckGenV3Request): - """Generate a deck and return as CSV.""" - if _deck_gen_v3 is None: - raise HTTPException(503, 'V3 Deck Generator not initialized.') - - result = _deck_gen_v3.generate_deck( - commander_name=req.commander_name.strip(), - strategy=req.strategy, - target_bracket=req.target_bracket, - budget_usd=req.budget_usd, - budget_mode=req.budget_mode, - omit_cards=req.omit_cards, - use_collection=req.use_collection, - model=req.model, - ) - - cards = result.get('cards', []) - commander = result.get('commander', {}) - - output = io.StringIO() - writer = csv.writer(output) - writer.writerow(['Count', 'Name', 'Category', 'Roles', 'Status', 'Price_USD', 'Reason']) - # Commander row - writer.writerow([1, commander.get('name', ''), 'Commander', '', 'owned', '', 'Commander']) - for card in cards: - if card.get('name') == commander.get('name', ''): - continue - writer.writerow([ - card.get('count', 1), - card.get('name', ''), - card.get('category', ''), - '; '.join(card.get('role_tags', [])), - card.get('status', 'unknown'), - card.get('estimated_price_usd', 0), - card.get('reason', ''), - ]) - - csv_content = output.getvalue() - safe_name = re.sub(r'[^a-zA-Z0-9_\-\s]', '', commander.get('name', 'deck')).strip().replace(' ', '_') - return StreamingResponse( - io.BytesIO(csv_content.encode('utf-8')), - media_type='text/csv', - headers={'Content-Disposition': f'attachment; filename="{safe_name}_deck.csv"'}, - ) - - -@app.post('/api/deck/v3/export/dck') -async def deck_gen_v3_export_dck(req: DeckGenV3Request): - """Generate a deck and return as Forge .dck format.""" - if _deck_gen_v3 is None: - raise HTTPException(503, 'V3 Deck Generator not initialized.') - - result = _deck_gen_v3.generate_deck( - commander_name=req.commander_name.strip(), - strategy=req.strategy, - target_bracket=req.target_bracket, - budget_usd=req.budget_usd, - budget_mode=req.budget_mode, - omit_cards=req.omit_cards, - use_collection=req.use_collection, - model=req.model, - ) - - cards = result.get('cards', []) - commander = result.get('commander', {}) - - lines = [ - '[metadata]', - f'Name={commander.get("name", "Deck")} - V3 Auto', - '', - '[Commander]', - f'1 {commander.get("name", "")}', - '', - '[Main]', - ] - for card in cards: - cname = card.get('name', '') - if card.get('status') == 'substituted' and card.get('selected_substitute'): - cname = card['selected_substitute'] - if cname and cname != commander.get('name', ''): - lines.append(f"{card.get('count', 1)} {cname}") - - dck_content = '\n'.join(lines) - safe_name = re.sub(r'[^a-zA-Z0-9_\-\s]', '', commander.get('name', 'deck')).strip().replace(' ', '_') - return StreamingResponse( - io.BytesIO(dck_content.encode('utf-8')), - media_type='text/plain', - headers={'Content-Disposition': f'attachment; filename="{safe_name}.dck"'}, - ) - - -@app.post('/api/deck/v3/export/moxfield') -async def deck_gen_v3_export_moxfield(req: DeckGenV3Request): - """Generate a deck and return in Moxfield paste format.""" - if _deck_gen_v3 is None: - raise HTTPException(503, 'V3 Deck Generator not initialized.') - - result = _deck_gen_v3.generate_deck( - commander_name=req.commander_name.strip(), - strategy=req.strategy, - target_bracket=req.target_bracket, - budget_usd=req.budget_usd, - budget_mode=req.budget_mode, - omit_cards=req.omit_cards, - use_collection=req.use_collection, - model=req.model, - ) - - cards = result.get('cards', []) - commander = result.get('commander', {}) - - # Moxfield format: card lines in main, commander in dedicated section - lines = [] - lines.append('// Commander') - lines.append(f'1 {commander.get("name", "")}') - lines.append('') - lines.append('// Deck') - for card in cards: - cname = card.get('name', '') - if card.get('status') == 'substituted' and card.get('selected_substitute'): - cname = card['selected_substitute'] - if cname and cname != commander.get('name', ''): - lines.append(f"{card.get('count', 1)} {cname}") - - txt = '\n'.join(lines) - return {'format': 'moxfield', 'content': txt, 'commander': commander.get('name', '')} - - -@app.post('/api/deck/v3/export/shopping') -async def deck_gen_v3_export_shopping(req: DeckGenV3Request): - """Generate a deck and return a shopping list of cards not owned.""" - if _deck_gen_v3 is None: - raise HTTPException(503, 'V3 Deck Generator not initialized.') - - result = _deck_gen_v3.generate_deck( - commander_name=req.commander_name.strip(), - strategy=req.strategy, - target_bracket=req.target_bracket, - budget_usd=req.budget_usd, - budget_mode=req.budget_mode, - omit_cards=req.omit_cards, - use_collection=req.use_collection, - model=req.model, - ) - - cards = result.get('cards', []) - shopping = [] - total = 0.0 - for card in cards: - if not card.get('owned', True) and card.get('status') != 'substituted': - price = card.get('estimated_price_usd', 0) - shopping.append({ - 'name': card.get('name', ''), - 'count': card.get('count', 1), - 'category': card.get('category', ''), - 'estimated_price_usd': price, - 'reason': card.get('reason', ''), - }) - total += price * card.get('count', 1) - - return { - 'commander': result.get('commander', {}).get('name', ''), - 'shopping_list': shopping, - 'total_missing': len(shopping), - 'estimated_cost_usd': round(total, 2), - } - - -# ══════════════════════════════════════════════════════════════ -# Coach Service (LLM-powered deck coaching) -# ══════════════════════════════════════════════════════════════ - -# Global coach instances (initialized at startup) -_coach_service = None -_coach_embeddings = None -_coach_llm = None -_deck_gen_v3 = None # V3 Deck Generator (Perplexity structured output) -_deck_gen_v3_error = None # Error message if V3 init failed - - -def init_coach_service(): - """Initialize the coach service with LLM client and embeddings.""" - global _coach_service, _coach_embeddings, _coach_llm, _deck_gen_v3, _deck_gen_v3_error - try: - from coach.llm_client import LMStudioClient - from coach.embeddings import MTGEmbeddingIndex - from coach.coach_service import CoachService - from coach.config import ensure_dirs - - ensure_dirs() - - _coach_llm = LMStudioClient() - _coach_embeddings = MTGEmbeddingIndex() - - # Try to load embeddings (non-blocking — download happens on first use) - try: - from coach.config import EMBEDDINGS_NPZ - if EMBEDDINGS_NPZ.exists(): - _coach_embeddings.load() - log_coach.info(f" Coach: Embeddings loaded ({_coach_embeddings.card_count} cards)") - else: - log_coach.warning(" Coach: Embeddings not yet downloaded (will download on first use)") - except Exception as e: - log_coach.error(f" Coach: Embeddings load failed: {e}") - - _coach_service = CoachService(_coach_llm, _coach_embeddings) - - # Check LLM connection - llm_status = _coach_llm.check_connection() - if llm_status.get("connected"): - log_coach.info(f" Coach LLM: Connected ({llm_status.get('active_model', 'unknown')})") - else: - log_coach.warning(f" Coach LLM: Not connected (start LM Studio on 192.168.0.122:1234)") - - log_coach.info(" Coach: Service initialized") - - # Initialize V3 Deck Generator (Perplexity) - _deck_gen_v3_error = None - if CFG.pplx_api_key: - try: - from coach.clients.perplexity_client import PerplexityClient - from coach.services.deck_generator import DeckGeneratorV3 - from coach.config import DECK_GEN_MODEL - - pplx_client = PerplexityClient( - api_key=CFG.pplx_api_key, - model=DECK_GEN_MODEL, - ) - _deck_gen_v3 = DeckGeneratorV3( - pplx_client=pplx_client, - db_conn_factory=_get_db_conn, - embedding_index=_coach_embeddings, - ) - log_deckgen.info(f" Deck Gen V3: Initialized (model: {DECK_GEN_MODEL})") - except ImportError as e: - _deck_gen_v3_error = f"Missing dependency: {e}. Run: pip install openai" - log_deckgen.error(f" Deck Gen V3: {_deck_gen_v3_error}") - _deck_gen_v3 = None - except Exception as e: - _deck_gen_v3_error = str(e) - log_deckgen.error(f" Deck Gen V3: Failed to initialize: {e}") - _deck_gen_v3 = None - else: - _deck_gen_v3_error = "PPLX_API_KEY not set" - log_deckgen.info(" Deck Gen V3: Skipped (no PPLX_API_KEY)") - - except Exception as e: - log_coach.error(f" Coach: Failed to initialize: {e}") - _coach_service = None - - -@app.get("/api/coach/status") -async def coach_status(): - """Check coach subsystem health.""" - if _coach_service is None: - return {"llmConnected": False, "embeddingsLoaded": False, - "embeddingCards": 0, "deckReportsAvailable": 0, - "error": "Coach service not initialized"} - status = _coach_service.get_status() - return status.model_dump() - - -@app.get("/api/coach/decks") -async def coach_list_decks(): - """List all decks available for coaching (from deck builder DB).""" - conn = _get_db_conn() - rows = conn.execute( - """SELECT d.id as deck_id, d.name as deck_name, d.commander_name, - COUNT(dc.id) as card_count - FROM decks d - LEFT JOIN deck_cards dc ON dc.deck_id = d.id - GROUP BY d.id - ORDER BY d.name""" - ).fetchall() - - # Also get report availability from coach service - report_ids = set() - if _coach_service: - report_ids = set(_coach_service.list_deck_reports()) - - decks = [] - for r in rows: - deck_name = r["deck_name"] - # Check if a report exists (by deck name slug match) - has_report = any( - deck_name.lower().replace(" ", "-") == rid.lower() or - deck_name.lower() == rid.lower() - for rid in report_ids - ) - decks.append({ - "deck_id": r["deck_id"], - "deck_name": r["deck_name"], - "commander": r["commander_name"] or "", - "card_count": r["card_count"], - "has_report": has_report, - "report_count": 1 if has_report else 0, - "last_report_date": None, - }) - return decks - - -@app.get("/api/coach/decks/{deck_id}/report") -async def coach_get_report(deck_id: str): - """Get the latest DeckReport for a deck.""" - if _coach_service is None: - raise HTTPException(500, "Coach service not initialized") - report = _coach_service.load_deck_report(deck_id) - if report is None: - raise HTTPException(404, f"Deck report not found: {deck_id}") - return report.model_dump() - - -class CoachRequestBody(BaseModel): - goals: Optional[dict] = None - - -class CoachChatMessage(BaseModel): - role: str # "user" or "assistant" - content: str - -class CoachChatRequest(BaseModel): - deck_id: str - messages: list[dict] # conversation history [{role, content}] - goals: Optional[dict] = None - stream: Optional[bool] = False - -class CoachApplyRequest(BaseModel): - session_id: str - deck_id: int # numeric deck ID in the DB - accepted_cuts: list[str] = [] # card names to remove - accepted_adds: list[str] = [] # card names to add - -class CoachGoalsRequest(BaseModel): - target_power_level: Optional[int] = None # 1-10 - meta_focus: Optional[str] = None # aggro, control, combo, midrange, stax - budget: Optional[str] = None # budget, medium, no-limit - focus_areas: list[str] = [] # e.g., ["ramp", "card draw"] - - -@app.post("/api/coach/decks/{deck_id}") -async def coach_run_session(deck_id: str, body: CoachRequestBody = None): - """Trigger a coaching session for a deck.""" - if _coach_service is None: - raise HTTPException(500, "Coach service not initialized") - - # Load embeddings on first use if not loaded - if _coach_embeddings and not _coach_embeddings.loaded: - try: - _coach_embeddings.load(force_download=True) - except Exception as e: - log_coach.error(f" Coach: Embeddings download failed: {e}") - - goals = None - if body and body.goals: - from coach.models import CoachGoals - try: - goals = CoachGoals(**body.goals) - except Exception: - goals = None - - # Build a fallback DeckReport from the DB if no simulation report exists - fallback_report = None - try: - fallback_report = _build_deck_report_from_db(deck_id) - except Exception as e: - log_coach.error(f" Coach: Fallback report build failed for '{deck_id}': {e}") - - try: - session = await _coach_service.run_coaching_session(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"Coach session failed: {e}") - - -def _build_deck_report_from_db(deck_slug: str): - """ - Build a lightweight DeckReport from the deck builder DB when no - simulation report exists. Allows the coach to analyze deck composition - even without simulation data. - """ - from coach.models import DeckReport, CardPerformance, DeckStructure - conn = _get_db_conn() - - # Find the deck by slug match against the deck name - rows = conn.execute( - "SELECT id, name, commander_name, color_identity FROM decks ORDER BY id" - ).fetchall() - - matched_deck = None - for r in rows: - name = r["name"] or "" - slug = name.lower().replace(" ", "-") - # Also try a more thorough slugify - import re - clean_slug = re.sub(r'[^a-z0-9]+', '-', name.lower()).strip('-') - if slug == deck_slug.lower() or clean_slug == deck_slug.lower() or name.lower() == deck_slug.lower(): - matched_deck = r - break - - if matched_deck is None: - return None - - deck_id = matched_deck["id"] - deck_name = matched_deck["name"] - commander = matched_deck["commander_name"] or "" - - # Parse color identity - ci_raw = matched_deck["color_identity"] or "[]" - try: - color_identity = json.loads(ci_raw) if isinstance(ci_raw, str) else ci_raw - except Exception: - color_identity = [] - - # Load all cards in this deck - card_rows = conn.execute( - """SELECT dc.card_name, dc.quantity, dc.is_commander, dc.role_tag, - ce.type_line, ce.cmc, ce.oracle_text - FROM deck_cards dc - LEFT JOIN collection_entries ce ON LOWER(dc.card_name) = LOWER(ce.name) - WHERE dc.deck_id = ?""", - (deck_id,) - ).fetchall() - - cards = [] - type_counts = {} - cmc_buckets = [0] * 8 - land_count = 0 - - for cr in card_rows: - card_name = cr["card_name"] or "" - type_line = cr["type_line"] or "" - cmc = cr["cmc"] or 0 - qty = cr["quantity"] or 1 - role_tag = cr["role_tag"] or "" - - # Build tags from type_line and role_tag - tags = [] - if role_tag: - tags.append(role_tag) - if "Land" in type_line: - tags.append("land") - land_count += qty - elif "Creature" in type_line: - tags.append("creature") - elif "Instant" in type_line: - tags.append("instant") - elif "Sorcery" in type_line: - tags.append("sorcery") - elif "Artifact" in type_line: - tags.append("artifact") - elif "Enchantment" in type_line: - tags.append("enchantment") - elif "Planeswalker" in type_line: - tags.append("planeswalker") - - # Type count - for t in ["Creature", "Instant", "Sorcery", "Artifact", "Enchantment", "Planeswalker", "Land"]: - if t in type_line: - type_counts[t] = type_counts.get(t, 0) + qty - - # CMC bucket - bucket = min(int(cmc), 7) - cmc_buckets[bucket] += qty - - # CardPerformance with zeroed-out sim stats - cards.append(CardPerformance( - name=card_name, - drawnRate=0.0, - castRate=0.0, - impactScore=0.0, - tags=tags, - )) - - slug = deck_slug - return DeckReport( - deckId=slug, - commander=commander, - colorIdentity=color_identity, - cards=cards, - structure=DeckStructure( - landCount=land_count, - curveBuckets=cmc_buckets, - cardTypeCounts=type_counts, - ), - ) - - -@app.get("/api/coach/sessions") -async def coach_list_sessions(deck_id: str = None): - """List all coaching sessions, optionally filtered by deck.""" - if _coach_service is None: - raise HTTPException(500, "Coach service not initialized") - return {"sessions": _coach_service.list_sessions(deck_id)} - - -@app.get("/api/coach/sessions/{session_id}") -async def coach_get_session(session_id: str): - """Get a specific coaching session.""" - if _coach_service is None: - raise HTTPException(500, "Coach service not initialized") - session = _coach_service.load_session(session_id) - if session is None: - raise HTTPException(404, f"Session not found: {session_id}") - return session.model_dump() - - -@app.post("/api/coach/embeddings/download") -async def coach_download_embeddings(): - """Download and convert MTG card embeddings from HuggingFace.""" - if _coach_embeddings is None: - raise HTTPException(500, "Coach service not initialized") - try: - _coach_embeddings.load(force_download=True) - return { - "success": True, - "cards": _coach_embeddings.card_count, - "message": f"Loaded {_coach_embeddings.card_count} card embeddings" - } - except Exception as e: - raise HTTPException(500, f"Failed to download embeddings: {e}") - - -@app.get("/api/coach/embeddings/search") -async def coach_search_similar(card: str, colors: str = None, top_n: int = 10): - """Search for similar cards by name.""" - if _coach_embeddings is None or not _coach_embeddings.loaded: - raise HTTPException(503, "Embeddings not loaded") - color_filter = list(colors.upper()) if colors else None - matches = _coach_embeddings.search_similar( - query_card=card, color_filter=color_filter, top_n=top_n - ) - return {"query": card, "matches": [m.to_dict() for m in matches]} - - -@app.post("/api/coach/chat") -async def coach_chat(body: CoachChatRequest): - """Multi-turn coaching chat. Sends conversation history to LLM and returns response.""" - if _coach_service is None: - raise HTTPException(500, "Coach service not initialized") - - # Load deck report for context - report = _coach_service.load_deck_report(body.deck_id) - - # Build system prompt with report context - from coach.prompt_template import build_system_prompt - from coach.models import CoachGoals - - goals = None - if body.goals: - try: - goals = CoachGoals(**body.goals) - except Exception: - goals = None - - system_prompt = "" - if report: - system_prompt = build_system_prompt(report, goals) - else: - system_prompt = ( - "You are an expert Magic: The Gathering Commander deck coach. " - "Answer questions about deck building, strategy, card choices, and game theory. " - "Be specific and actionable in your advice." - ) - - # Build messages array for LLM - messages = [{"role": "system", "content": system_prompt}] - for msg in body.messages: - messages.append({"role": msg.get("role", "user"), "content": msg.get("content", "")}) - - # Call LLM with multi-turn messages - try: - import json - from urllib.request import urlopen, Request as UrlRequest - from coach.config import LM_STUDIO_URL, LM_STUDIO_TIMEOUT - - model_name = _coach_llm._resolve_model() - llm_body = { - "model": model_name, - "messages": messages, - "temperature": 0.7, - "max_tokens": 4096, - } - - if body.stream: - # SSE streaming response - llm_body["stream"] = True - - async def generate(): - import asyncio - def _stream(): - req = UrlRequest( - f"{_coach_llm.base_url}/chat/completions", - data=json.dumps(llm_body).encode("utf-8"), - headers={"Content-Type": "application/json"}, - method="POST", - ) - import http.client - import urllib.parse - parsed = urllib.parse.urlparse(f"{_coach_llm.base_url}/chat/completions") - conn = http.client.HTTPConnection(parsed.hostname, parsed.port, timeout=LM_STUDIO_TIMEOUT) - conn.request("POST", parsed.path, body=json.dumps(llm_body).encode("utf-8"), - headers={"Content-Type": "application/json"}) - resp = conn.getresponse() - chunks = [] - while True: - line = resp.readline() - if not line: - break - line = line.decode("utf-8").strip() - if line.startswith("data: "): - data = line[6:] - if data == "[DONE]": - chunks.append("[DONE]") - break - chunks.append(data) - conn.close() - return chunks - - loop = asyncio.get_event_loop() - chunks = await loop.run_in_executor(None, _stream) - - for chunk_str in chunks: - if chunk_str == "[DONE]": - yield f"data: [DONE]\n\n" - break - try: - chunk = json.loads(chunk_str) - delta = chunk.get("choices", [{}])[0].get("delta", {}) - content = delta.get("content", "") - if content: - # Strip think tags from streaming chunks - yield f"data: {json.dumps({'content': content})}\n\n" - except json.JSONDecodeError: - continue - - return StreamingResponse(generate(), media_type="text/event-stream") - else: - # Non-streaming: regular call - req = UrlRequest( - f"{_coach_llm.base_url}/chat/completions", - data=json.dumps(llm_body).encode("utf-8"), - headers={"Content-Type": "application/json"}, - method="POST", - ) - - import asyncio - loop = asyncio.get_event_loop() - def _call(): - with urlopen(req, timeout=LM_STUDIO_TIMEOUT) as resp: - return json.loads(resp.read().decode("utf-8")) - - raw = await loop.run_in_executor(None, _call) - - content = raw.get("choices", [{}])[0].get("message", {}).get("content", "") - # Strip think tags - content = _coach_llm._strip_think_tags(content) - usage = raw.get("usage", {}) - - return { - "content": content, - "model": raw.get("model", ""), - "prompt_tokens": usage.get("prompt_tokens", 0), - "completion_tokens": usage.get("completion_tokens", 0), - } - - except Exception as e: - raise HTTPException(500, f"Chat failed: {e}") - - -@app.post("/api/coach/apply") -async def coach_apply_suggestions(body: CoachApplyRequest): - """Apply accepted coaching suggestions to a deck — remove cuts, add adds.""" - conn = _get_db_conn() - - # Verify deck exists - deck = conn.execute("SELECT * FROM decks WHERE id = ?", (body.deck_id,)).fetchone() - if not deck: - raise HTTPException(404, f"Deck {body.deck_id} not found") - - results = {"cuts": [], "adds": [], "errors": []} - - # Process cuts: remove cards by name - for card_name in body.accepted_cuts: - row = conn.execute( - "SELECT id FROM deck_cards WHERE deck_id = ? AND card_name = ? LIMIT 1", - (body.deck_id, card_name) - ).fetchone() - if row: - conn.execute("DELETE FROM deck_cards WHERE id = ?", (row["id"],)) - results["cuts"].append({"name": card_name, "status": "removed"}) - else: - # Try case-insensitive - row = conn.execute( - "SELECT id, card_name FROM deck_cards WHERE deck_id = ? AND LOWER(card_name) = LOWER(?) LIMIT 1", - (body.deck_id, card_name) - ).fetchone() - if row: - conn.execute("DELETE FROM deck_cards WHERE id = ?", (row["id"],)) - results["cuts"].append({"name": row["card_name"], "status": "removed"}) - else: - results["errors"].append({"name": card_name, "error": "Card not found in deck"}) - - # Process adds: look up in collection first, then Scryfall - for card_name in body.accepted_adds: - # Try to find in collection - ce = conn.execute( - "SELECT scryfall_id, name FROM collection_entries WHERE LOWER(name) = LOWER(?) LIMIT 1", - (card_name,) - ).fetchone() - - scryfall_id = None - resolved_name = card_name - - if ce: - scryfall_id = ce["scryfall_id"] - resolved_name = ce["name"] - else: - # Try Scryfall API lookup - try: - import urllib.parse - encoded = urllib.parse.quote(card_name) - scry_req = UrlRequest( - f"https://api.scryfall.com/cards/named?fuzzy={encoded}", - headers={"User-Agent": "commander-ai-lab/1.0"} - ) - with urlopen(scry_req, timeout=10) as resp: - card_data = json.loads(resp.read().decode("utf-8")) - scryfall_id = card_data.get("id") - resolved_name = card_data.get("name", card_name) - except Exception: - pass - - if scryfall_id: - # Check if already in deck - existing = conn.execute( - "SELECT id FROM deck_cards WHERE deck_id = ? AND scryfall_id = ?", - (body.deck_id, scryfall_id) - ).fetchone() - if not existing: - conn.execute( - "INSERT INTO deck_cards (deck_id, scryfall_id, card_name, quantity) VALUES (?, ?, ?, 1)", - (body.deck_id, scryfall_id, resolved_name) - ) - results["adds"].append({"name": resolved_name, "scryfall_id": scryfall_id, "status": "added"}) - else: - results["adds"].append({"name": resolved_name, "status": "already_in_deck"}) - else: - results["errors"].append({"name": card_name, "error": "Could not resolve card"}) - - conn.execute("UPDATE decks SET updated_at = datetime('now') WHERE id = ?", (body.deck_id,)) - conn.commit() - - results["total_cuts"] = len(results["cuts"]) - results["total_adds"] = len([a for a in results["adds"] if a["status"] == "added"]) - return results - - -@app.get("/api/coach/cards-like") -async def coach_cards_like(card: str, colors: str = None, top_n: int = 10): - """Find cards similar to a given card using embeddings. UI-friendly version.""" - if _coach_embeddings is None or not _coach_embeddings.loaded: - raise HTTPException(503, "Embeddings not loaded") - - color_filter = list(colors.upper()) if colors else None - matches = _coach_embeddings.search_similar( - query_card=card, color_filter=color_filter, top_n=top_n - ) - - conn = _get_db_conn() - results = [] - for m in matches: - # Check if card is in collection - owned = conn.execute( - "SELECT SUM(quantity) as qty FROM collection_entries WHERE LOWER(name) = LOWER(?)", - (m.name,) - ).fetchone() - owned_qty = (owned["qty"] or 0) if owned else 0 - - # Get image URL and price - ce = conn.execute( - "SELECT image_url, tcg_price FROM collection_entries WHERE LOWER(name) = LOWER(?) LIMIT 1", - (m.name,) - ).fetchone() - - results.append({ - "name": m.name, - "similarity": round(m.similarity, 4), - "types": m.types, - "mana_value": m.mana_value, - "mana_cost": m.mana_cost, - "text": m.text[:200] if m.text else "", - "owned_qty": owned_qty, - "image_url": ce["image_url"] if ce else None, - "tcg_price": ce["tcg_price"] if ce else None, - }) - - return {"query": card, "results": results} - - -@app.post("/api/coach/reports/generate") -async def coach_generate_reports(): - """Rebuild all deck reports from batch result JSONs in results/.""" - try: - from coach.report_generator import generate_deck_reports - lab_root = Path(__file__).parent - results_dir = str(lab_root / CFG.results_dir) - reports_dir = str(lab_root / "deck-reports") - updated = generate_deck_reports(results_dir, reports_dir) - return { - "status": "ok", - "decksUpdated": updated, - "count": len(updated), - "message": f"Generated reports for {len(updated)} decks" if updated else "No batch results found", - } - except Exception as e: - raise HTTPException(500, f"Report generation failed: {e}") - - # ══════════════════════════════════════════════════════════════ # ML Training Data Endpoints # ══════════════════════════════════════════════════════════════ diff --git a/routes/coach.py b/routes/coach.py new file mode 100644 index 0000000..14878b5 --- /dev/null +++ b/routes/coach.py @@ -0,0 +1,698 @@ +""" +routes/coach.py +=============== +LLM-powered deck coaching endpoints: + GET /api/coach/status + GET /api/coach/decks + GET /api/coach/decks/{deck_id}/report + POST /api/coach/decks/{deck_id} + GET /api/coach/sessions + GET /api/coach/sessions/{session_id} + POST /api/coach/embeddings/download + GET /api/coach/embeddings/search + POST /api/coach/chat + POST /api/coach/apply + GET /api/coach/cards-like + POST /api/coach/reports/generate + +Globals: + _coach_service, _coach_embeddings, _coach_llm + _deck_gen_v3, _deck_gen_v3_error + init_coach_service(), _build_deck_report_from_db() +""" +from __future__ import annotations + +import json +import os +import time +from pathlib import Path +from typing import Optional + +from fastapi import APIRouter, HTTPException +from fastapi.responses import StreamingResponse +from pydantic import BaseModel + +from routes.shared import ( + CFG, + log_coach, log_deckgen, + _get_db_conn, + _compute_deck_analysis, +) + +router = APIRouter(tags=["coach"]) + + +# ══════════════════════════════════════════════════════════════ +# Coach Service (LLM-powered deck coaching) +# ══════════════════════════════════════════════════════════════ + +# Global coach instances (initialized at startup) +_coach_service = None +_coach_embeddings = None +_coach_llm = None +_deck_gen_v3 = None # V3 Deck Generator (Perplexity structured output) +_deck_gen_v3_error = None # Error message if V3 init failed + + +def init_coach_service(): + """Initialize the coach service with LLM client and embeddings.""" + global _coach_service, _coach_embeddings, _coach_llm, _deck_gen_v3, _deck_gen_v3_error + try: + from coach.llm_client import LMStudioClient + from coach.embeddings import MTGEmbeddingIndex + from coach.coach_service import CoachService + from coach.config import ensure_dirs + + ensure_dirs() + + _coach_llm = LMStudioClient() + _coach_embeddings = MTGEmbeddingIndex() + + # Try to load embeddings (non-blocking — download happens on first use) + try: + from coach.config import EMBEDDINGS_NPZ + if EMBEDDINGS_NPZ.exists(): + _coach_embeddings.load() + log_coach.info(f" Coach: Embeddings loaded ({_coach_embeddings.card_count} cards)") + else: + log_coach.warning(" Coach: Embeddings not yet downloaded (will download on first use)") + except Exception as e: + log_coach.error(f" Coach: Embeddings load failed: {e}") + + _coach_service = CoachService(_coach_llm, _coach_embeddings) + + # Check LLM connection + llm_status = _coach_llm.check_connection() + if llm_status.get("connected"): + log_coach.info(f" Coach LLM: Connected ({llm_status.get('active_model', 'unknown')})") + else: + log_coach.warning(f" Coach LLM: Not connected (start LM Studio on 192.168.0.122:1234)") + + log_coach.info(" Coach: Service initialized") + + # Initialize V3 Deck Generator (Perplexity) + _deck_gen_v3_error = None + if CFG.pplx_api_key: + try: + from coach.clients.perplexity_client import PerplexityClient + from coach.services.deck_generator import DeckGeneratorV3 + from coach.config import DECK_GEN_MODEL + + pplx_client = PerplexityClient( + api_key=CFG.pplx_api_key, + model=DECK_GEN_MODEL, + ) + _deck_gen_v3 = DeckGeneratorV3( + pplx_client=pplx_client, + db_conn_factory=_get_db_conn, + embedding_index=_coach_embeddings, + ) + log_deckgen.info(f" Deck Gen V3: Initialized (model: {DECK_GEN_MODEL})") + except ImportError as e: + _deck_gen_v3_error = f"Missing dependency: {e}. Run: pip install openai" + log_deckgen.error(f" Deck Gen V3: {_deck_gen_v3_error}") + _deck_gen_v3 = None + except Exception as e: + _deck_gen_v3_error = str(e) + log_deckgen.error(f" Deck Gen V3: Failed to initialize: {e}") + _deck_gen_v3 = None + else: + _deck_gen_v3_error = "PPLX_API_KEY not set" + log_deckgen.info(" Deck Gen V3: Skipped (no PPLX_API_KEY)") + + except Exception as e: + log_coach.error(f" Coach: Failed to initialize: {e}") + _coach_service = None + + +@router.get("/api/coach/status") +async def coach_status(): + """Check coach subsystem health.""" + if _coach_service is None: + return {"llmConnected": False, "embeddingsLoaded": False, + "embeddingCards": 0, "deckReportsAvailable": 0, + "error": "Coach service not initialized"} + status = _coach_service.get_status() + return status.model_dump() + + +@router.get("/api/coach/decks") +async def coach_list_decks(): + """List all decks available for coaching (from deck builder DB).""" + conn = _get_db_conn() + rows = conn.execute( + """SELECT d.id as deck_id, d.name as deck_name, d.commander_name, + COUNT(dc.id) as card_count + FROM decks d + LEFT JOIN deck_cards dc ON dc.deck_id = d.id + GROUP BY d.id + ORDER BY d.name""" + ).fetchall() + + # Also get report availability from coach service + report_ids = set() + if _coach_service: + report_ids = set(_coach_service.list_deck_reports()) + + decks = [] + for r in rows: + deck_name = r["deck_name"] + # Check if a report exists (by deck name slug match) + has_report = any( + deck_name.lower().replace(" ", "-") == rid.lower() or + deck_name.lower() == rid.lower() + for rid in report_ids + ) + decks.append({ + "deck_id": r["deck_id"], + "deck_name": r["deck_name"], + "commander": r["commander_name"] or "", + "card_count": r["card_count"], + "has_report": has_report, + "report_count": 1 if has_report else 0, + "last_report_date": None, + }) + return decks + + +@router.get("/api/coach/decks/{deck_id}/report") +async def coach_get_report(deck_id: str): + """Get the latest DeckReport for a deck.""" + if _coach_service is None: + raise HTTPException(500, "Coach service not initialized") + report = _coach_service.load_deck_report(deck_id) + if report is None: + raise HTTPException(404, f"Deck report not found: {deck_id}") + return report.model_dump() + + +class CoachRequestBody(BaseModel): + goals: Optional[dict] = None + + +class CoachChatMessage(BaseModel): + role: str # "user" or "assistant" + content: str + +class CoachChatRequest(BaseModel): + deck_id: str + messages: list[dict] # conversation history [{role, content}] + goals: Optional[dict] = None + stream: Optional[bool] = False + +class CoachApplyRequest(BaseModel): + session_id: str + deck_id: int # numeric deck ID in the DB + accepted_cuts: list[str] = [] # card names to remove + accepted_adds: list[str] = [] # card names to add + +class CoachGoalsRequest(BaseModel): + target_power_level: Optional[int] = None # 1-10 + meta_focus: Optional[str] = None # aggro, control, combo, midrange, stax + budget: Optional[str] = None # budget, medium, no-limit + focus_areas: list[str] = [] # e.g., ["ramp", "card draw"] + + +@router.post("/api/coach/decks/{deck_id}") +async def coach_run_session(deck_id: str, body: CoachRequestBody = None): + """Trigger a coaching session for a deck.""" + if _coach_service is None: + raise HTTPException(500, "Coach service not initialized") + + # Load embeddings on first use if not loaded + if _coach_embeddings and not _coach_embeddings.loaded: + try: + _coach_embeddings.load(force_download=True) + except Exception as e: + log_coach.error(f" Coach: Embeddings download failed: {e}") + + goals = None + if body and body.goals: + from coach.models import CoachGoals + try: + goals = CoachGoals(**body.goals) + except Exception: + goals = None + + # Build a fallback DeckReport from the DB if no simulation report exists + fallback_report = None + try: + fallback_report = _build_deck_report_from_db(deck_id) + except Exception as e: + log_coach.error(f" Coach: Fallback report build failed for '{deck_id}': {e}") + + try: + session = await _coach_service.run_coaching_session(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"Coach session failed: {e}") + + +def _build_deck_report_from_db(deck_slug: str): + """ + Build a lightweight DeckReport from the deck builder DB when no + simulation report exists. Allows the coach to analyze deck composition + even without simulation data. + """ + from coach.models import DeckReport, CardPerformance, DeckStructure + conn = _get_db_conn() + + # Find the deck by slug match against the deck name + rows = conn.execute( + "SELECT id, name, commander_name, color_identity FROM decks ORDER BY id" + ).fetchall() + + matched_deck = None + for r in rows: + name = r["name"] or "" + slug = name.lower().replace(" ", "-") + # Also try a more thorough slugify + import re + clean_slug = re.sub(r'[^a-z0-9]+', '-', name.lower()).strip('-') + if slug == deck_slug.lower() or clean_slug == deck_slug.lower() or name.lower() == deck_slug.lower(): + matched_deck = r + break + + if matched_deck is None: + return None + + deck_id = matched_deck["id"] + deck_name = matched_deck["name"] + commander = matched_deck["commander_name"] or "" + + # Parse color identity + ci_raw = matched_deck["color_identity"] or "[]" + try: + color_identity = json.loads(ci_raw) if isinstance(ci_raw, str) else ci_raw + except Exception: + color_identity = [] + + # Load all cards in this deck + card_rows = conn.execute( + """SELECT dc.card_name, dc.quantity, dc.is_commander, dc.role_tag, + ce.type_line, ce.cmc, ce.oracle_text + FROM deck_cards dc + LEFT JOIN collection_entries ce ON LOWER(dc.card_name) = LOWER(ce.name) + WHERE dc.deck_id = ?""", + (deck_id,) + ).fetchall() + + cards = [] + type_counts = {} + cmc_buckets = [0] * 8 + land_count = 0 + + for cr in card_rows: + card_name = cr["card_name"] or "" + type_line = cr["type_line"] or "" + cmc = cr["cmc"] or 0 + qty = cr["quantity"] or 1 + role_tag = cr["role_tag"] or "" + + # Build tags from type_line and role_tag + tags = [] + if role_tag: + tags.append(role_tag) + if "Land" in type_line: + tags.append("land") + land_count += qty + elif "Creature" in type_line: + tags.append("creature") + elif "Instant" in type_line: + tags.append("instant") + elif "Sorcery" in type_line: + tags.append("sorcery") + elif "Artifact" in type_line: + tags.append("artifact") + elif "Enchantment" in type_line: + tags.append("enchantment") + elif "Planeswalker" in type_line: + tags.append("planeswalker") + + # Type count + for t in ["Creature", "Instant", "Sorcery", "Artifact", "Enchantment", "Planeswalker", "Land"]: + if t in type_line: + type_counts[t] = type_counts.get(t, 0) + qty + + # CMC bucket + bucket = min(int(cmc), 7) + cmc_buckets[bucket] += qty + + # CardPerformance with zeroed-out sim stats + cards.append(CardPerformance( + name=card_name, + drawnRate=0.0, + castRate=0.0, + impactScore=0.0, + tags=tags, + )) + + slug = deck_slug + return DeckReport( + deckId=slug, + commander=commander, + colorIdentity=color_identity, + cards=cards, + structure=DeckStructure( + landCount=land_count, + curveBuckets=cmc_buckets, + cardTypeCounts=type_counts, + ), + ) + + +@router.get("/api/coach/sessions") +async def coach_list_sessions(deck_id: str = None): + """List all coaching sessions, optionally filtered by deck.""" + if _coach_service is None: + raise HTTPException(500, "Coach service not initialized") + return {"sessions": _coach_service.list_sessions(deck_id)} + + +@router.get("/api/coach/sessions/{session_id}") +async def coach_get_session(session_id: str): + """Get a specific coaching session.""" + if _coach_service is None: + raise HTTPException(500, "Coach service not initialized") + session = _coach_service.load_session(session_id) + if session is None: + raise HTTPException(404, f"Session not found: {session_id}") + return session.model_dump() + + +@router.post("/api/coach/embeddings/download") +async def coach_download_embeddings(): + """Download and convert MTG card embeddings from HuggingFace.""" + if _coach_embeddings is None: + raise HTTPException(500, "Coach service not initialized") + try: + _coach_embeddings.load(force_download=True) + return { + "success": True, + "cards": _coach_embeddings.card_count, + "message": f"Loaded {_coach_embeddings.card_count} card embeddings" + } + except Exception as e: + raise HTTPException(500, f"Failed to download embeddings: {e}") + + +@router.get("/api/coach/embeddings/search") +async def coach_search_similar(card: str, colors: str = None, top_n: int = 10): + """Search for similar cards by name.""" + if _coach_embeddings is None or not _coach_embeddings.loaded: + raise HTTPException(503, "Embeddings not loaded") + color_filter = list(colors.upper()) if colors else None + matches = _coach_embeddings.search_similar( + query_card=card, color_filter=color_filter, top_n=top_n + ) + return {"query": card, "matches": [m.to_dict() for m in matches]} + + +@router.post("/api/coach/chat") +async def coach_chat(body: CoachChatRequest): + """Multi-turn coaching chat. Sends conversation history to LLM and returns response.""" + if _coach_service is None: + raise HTTPException(500, "Coach service not initialized") + + # Load deck report for context + report = _coach_service.load_deck_report(body.deck_id) + + # Build system prompt with report context + from coach.prompt_template import build_system_prompt + from coach.models import CoachGoals + + goals = None + if body.goals: + try: + goals = CoachGoals(**body.goals) + except Exception: + goals = None + + system_prompt = "" + if report: + system_prompt = build_system_prompt(report, goals) + else: + system_prompt = ( + "You are an expert Magic: The Gathering Commander deck coach. " + "Answer questions about deck building, strategy, card choices, and game theory. " + "Be specific and actionable in your advice." + ) + + # Build messages array for LLM + messages = [{"role": "system", "content": system_prompt}] + for msg in body.messages: + messages.append({"role": msg.get("role", "user"), "content": msg.get("content", "")}) + + # Call LLM with multi-turn messages + try: + import json + from urllib.request import urlopen, Request as UrlRequest + from coach.config import LM_STUDIO_URL, LM_STUDIO_TIMEOUT + + model_name = _coach_llm._resolve_model() + llm_body = { + "model": model_name, + "messages": messages, + "temperature": 0.7, + "max_tokens": 4096, + } + + if body.stream: + # SSE streaming response + llm_body["stream"] = True + + async def generate(): + import asyncio + def _stream(): + req = UrlRequest( + f"{_coach_llm.base_url}/chat/completions", + data=json.dumps(llm_body).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST", + ) + import http.client + import urllib.parse + parsed = urllib.parse.urlparse(f"{_coach_llm.base_url}/chat/completions") + conn = http.client.HTTPConnection(parsed.hostname, parsed.port, timeout=LM_STUDIO_TIMEOUT) + conn.request("POST", parsed.path, body=json.dumps(llm_body).encode("utf-8"), + headers={"Content-Type": "application/json"}) + resp = conn.getresponse() + chunks = [] + while True: + line = resp.readline() + if not line: + break + line = line.decode("utf-8").strip() + if line.startswith("data: "): + data = line[6:] + if data == "[DONE]": + chunks.append("[DONE]") + break + chunks.append(data) + conn.close() + return chunks + + loop = asyncio.get_event_loop() + chunks = await loop.run_in_executor(None, _stream) + + for chunk_str in chunks: + if chunk_str == "[DONE]": + yield f"data: [DONE]\n\n" + break + try: + chunk = json.loads(chunk_str) + delta = chunk.get("choices", [{}])[0].get("delta", {}) + content = delta.get("content", "") + if content: + # Strip think tags from streaming chunks + yield f"data: {json.dumps({'content': content})}\n\n" + except json.JSONDecodeError: + continue + + return StreamingResponse(generate(), media_type="text/event-stream") + else: + # Non-streaming: regular call + req = UrlRequest( + f"{_coach_llm.base_url}/chat/completions", + data=json.dumps(llm_body).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST", + ) + + import asyncio + loop = asyncio.get_event_loop() + def _call(): + with urlopen(req, timeout=LM_STUDIO_TIMEOUT) as resp: + return json.loads(resp.read().decode("utf-8")) + + raw = await loop.run_in_executor(None, _call) + + content = raw.get("choices", [{}])[0].get("message", {}).get("content", "") + # Strip think tags + content = _coach_llm._strip_think_tags(content) + usage = raw.get("usage", {}) + + return { + "content": content, + "model": raw.get("model", ""), + "prompt_tokens": usage.get("prompt_tokens", 0), + "completion_tokens": usage.get("completion_tokens", 0), + } + + except Exception as e: + raise HTTPException(500, f"Chat failed: {e}") + + +@router.post("/api/coach/apply") +async def coach_apply_suggestions(body: CoachApplyRequest): + """Apply accepted coaching suggestions to a deck — remove cuts, add adds.""" + conn = _get_db_conn() + + # Verify deck exists + deck = conn.execute("SELECT * FROM decks WHERE id = ?", (body.deck_id,)).fetchone() + if not deck: + raise HTTPException(404, f"Deck {body.deck_id} not found") + + results = {"cuts": [], "adds": [], "errors": []} + + # Process cuts: remove cards by name + for card_name in body.accepted_cuts: + row = conn.execute( + "SELECT id FROM deck_cards WHERE deck_id = ? AND card_name = ? LIMIT 1", + (body.deck_id, card_name) + ).fetchone() + if row: + conn.execute("DELETE FROM deck_cards WHERE id = ?", (row["id"],)) + results["cuts"].append({"name": card_name, "status": "removed"}) + else: + # Try case-insensitive + row = conn.execute( + "SELECT id, card_name FROM deck_cards WHERE deck_id = ? AND LOWER(card_name) = LOWER(?) LIMIT 1", + (body.deck_id, card_name) + ).fetchone() + if row: + conn.execute("DELETE FROM deck_cards WHERE id = ?", (row["id"],)) + results["cuts"].append({"name": row["card_name"], "status": "removed"}) + else: + results["errors"].append({"name": card_name, "error": "Card not found in deck"}) + + # Process adds: look up in collection first, then Scryfall + for card_name in body.accepted_adds: + # Try to find in collection + ce = conn.execute( + "SELECT scryfall_id, name FROM collection_entries WHERE LOWER(name) = LOWER(?) LIMIT 1", + (card_name,) + ).fetchone() + + scryfall_id = None + resolved_name = card_name + + if ce: + scryfall_id = ce["scryfall_id"] + resolved_name = ce["name"] + else: + # Try Scryfall API lookup + try: + import urllib.parse + encoded = urllib.parse.quote(card_name) + scry_req = UrlRequest( + f"https://api.scryfall.com/cards/named?fuzzy={encoded}", + headers={"User-Agent": "commander-ai-lab/1.0"} + ) + with urlopen(scry_req, timeout=10) as resp: + card_data = json.loads(resp.read().decode("utf-8")) + scryfall_id = card_data.get("id") + resolved_name = card_data.get("name", card_name) + except Exception: + pass + + if scryfall_id: + # Check if already in deck + existing = conn.execute( + "SELECT id FROM deck_cards WHERE deck_id = ? AND scryfall_id = ?", + (body.deck_id, scryfall_id) + ).fetchone() + if not existing: + conn.execute( + "INSERT INTO deck_cards (deck_id, scryfall_id, card_name, quantity) VALUES (?, ?, ?, 1)", + (body.deck_id, scryfall_id, resolved_name) + ) + results["adds"].append({"name": resolved_name, "scryfall_id": scryfall_id, "status": "added"}) + else: + results["adds"].append({"name": resolved_name, "status": "already_in_deck"}) + else: + results["errors"].append({"name": card_name, "error": "Could not resolve card"}) + + conn.execute("UPDATE decks SET updated_at = datetime('now') WHERE id = ?", (body.deck_id,)) + conn.commit() + + results["total_cuts"] = len(results["cuts"]) + results["total_adds"] = len([a for a in results["adds"] if a["status"] == "added"]) + return results + + +@router.get("/api/coach/cards-like") +async def coach_cards_like(card: str, colors: str = None, top_n: int = 10): + """Find cards similar to a given card using embeddings. UI-friendly version.""" + if _coach_embeddings is None or not _coach_embeddings.loaded: + raise HTTPException(503, "Embeddings not loaded") + + color_filter = list(colors.upper()) if colors else None + matches = _coach_embeddings.search_similar( + query_card=card, color_filter=color_filter, top_n=top_n + ) + + conn = _get_db_conn() + results = [] + for m in matches: + # Check if card is in collection + owned = conn.execute( + "SELECT SUM(quantity) as qty FROM collection_entries WHERE LOWER(name) = LOWER(?)", + (m.name,) + ).fetchone() + owned_qty = (owned["qty"] or 0) if owned else 0 + + # Get image URL and price + ce = conn.execute( + "SELECT image_url, tcg_price FROM collection_entries WHERE LOWER(name) = LOWER(?) LIMIT 1", + (m.name,) + ).fetchone() + + results.append({ + "name": m.name, + "similarity": round(m.similarity, 4), + "types": m.types, + "mana_value": m.mana_value, + "mana_cost": m.mana_cost, + "text": m.text[:200] if m.text else "", + "owned_qty": owned_qty, + "image_url": ce["image_url"] if ce else None, + "tcg_price": ce["tcg_price"] if ce else None, + }) + + return {"query": card, "results": results} + + +@router.post("/api/coach/reports/generate") +async def coach_generate_reports(): + """Rebuild all deck reports from batch result JSONs in results/.""" + try: + from coach.report_generator import generate_deck_reports + lab_root = Path(__file__).parent + results_dir = str(lab_root / CFG.results_dir) + reports_dir = str(lab_root / "deck-reports") + updated = generate_deck_reports(results_dir, reports_dir) + return { + "status": "ok", + "decksUpdated": updated, + "count": len(updated), + "message": f"Generated reports for {len(updated)} decks" if updated else "No batch results found", + } + except Exception as e: + raise HTTPException(500, f"Report generation failed: {e}") + + diff --git a/routes/deckgen.py b/routes/deckgen.py new file mode 100644 index 0000000..0ec61e5 --- /dev/null +++ b/routes/deckgen.py @@ -0,0 +1,1598 @@ +""" +routes/deckgen.py +================= +Deck generation endpoints (classic, Perplexity V1, V3): + GET /api/deck-generator/config + POST /api/deck-generator/preview + POST /api/deck-generator/commit + GET /api/deck-generator/commander-search + POST /api/deck-research + POST /api/deck-generate + GET /api/pplx/status + GET /api/deck/v3/status + POST /api/deck/v3/generate + POST /api/deck/v3/commit + POST /api/deck/v3/export/csv + POST /api/deck/v3/export/dck + POST /api/deck/v3/export/moxfield + POST /api/deck/v3/export/shopping +""" +from __future__ import annotations + +import json +import os +import re +import time +from pathlib import Path +from typing import Optional +from urllib.request import urlopen, Request + +from fastapi import APIRouter, HTTPException +from fastapi.responses import JSONResponse, StreamingResponse +from pydantic import BaseModel + +import routes.coach as _coach # for _deck_gen_v3 / _deck_gen_v3_error module-level access +from routes.shared import ( + CFG, + log_deckgen, log_pplx, + _get_db_conn, + _scryfall_rate_limit, _API_HEADERS, + DeckGenerationRequest, DeckGenV3Request, DeckGenV3SubstituteRequest, + GeneratedDeckCard, DeckGenerationSourceConfig, +) + +router = APIRouter(tags=["deckgen"]) + + +# ══════════════════════════════════════════════════════════════ +# Auto Deck Generator +# ══════════════════════════════════════════════════════════════ + + +def _resolve_commander(req: DeckGenerationRequest) -> dict: + """ + Resolve commander info from a DeckGenerationRequest. + Returns dict with: name, scryfall_id, color_identity, type_line, mana_cost, image_url + """ + conn = _get_db_conn() + + # Try scryfall_id first + if req.commander_scryfall_id: + row = conn.execute( + "SELECT name, type_line, color_identity, mana_cost, scryfall_id FROM collection_entries WHERE scryfall_id = ? LIMIT 1", + (req.commander_scryfall_id,) + ).fetchone() + if row: + ci = row["color_identity"] + if isinstance(ci, str): + try: + ci = json.loads(ci) + except Exception: + ci = [] + return { + "name": row["name"], + "scryfall_id": row["scryfall_id"], + "color_identity": ci, + "type_line": row["type_line"], + "mana_cost": row["mana_cost"] or "", + "image_url": f"https://api.scryfall.com/cards/{row['scryfall_id']}?format=image&version=normal", + } + # Fallback to Scryfall API + _scryfall_rate_limit() + try: + url = f"https://api.scryfall.com/cards/{req.commander_scryfall_id}" + rq = Request(url, headers={"User-Agent": "CommanderAILab/1.0", "Accept": "application/json"}) + with urlopen(rq, timeout=15) as resp: + data = json.loads(resp.read().decode("utf-8")) + return _scryfall_to_commander(data) + except Exception: + pass + + # Try name search + if req.commander_name: + name_lower = req.commander_name.strip().lower() + row = conn.execute( + "SELECT name, type_line, color_identity, mana_cost, scryfall_id FROM collection_entries WHERE LOWER(name) = ? LIMIT 1", + (name_lower,) + ).fetchone() + if row: + ci = row["color_identity"] + if isinstance(ci, str): + try: + ci = json.loads(ci) + except Exception: + ci = [] + return { + "name": row["name"], + "scryfall_id": row["scryfall_id"], + "color_identity": ci, + "type_line": row["type_line"], + "mana_cost": row["mana_cost"] or "", + "image_url": f"https://api.scryfall.com/cards/{row['scryfall_id']}?format=image&version=normal", + } + + # Fallback to Scryfall fuzzy search + _scryfall_rate_limit() + try: + import urllib.parse + encoded = urllib.parse.quote(req.commander_name) + url = f"https://api.scryfall.com/cards/named?fuzzy={encoded}" + rq = Request(url, headers={"User-Agent": "CommanderAILab/1.0", "Accept": "application/json"}) + with urlopen(rq, timeout=15) as resp: + data = json.loads(resp.read().decode("utf-8")) + return _scryfall_to_commander(data) + except Exception: + pass + + return None + + +def _scryfall_to_commander(data: dict) -> dict: + """Convert Scryfall API response to commander info dict.""" + ci = data.get("color_identity", []) + image_uris = data.get("image_uris", {}) + if not image_uris and data.get("card_faces"): + image_uris = data["card_faces"][0].get("image_uris", {}) + return { + "name": data.get("name", ""), + "scryfall_id": data.get("id", ""), + "color_identity": ci, + "type_line": data.get("type_line", ""), + "mana_cost": data.get("mana_cost", ""), + "image_url": image_uris.get("normal", ""), + } + + +def _get_collection_for_colors(color_identity: list) -> list: + """ + Fetch all collection cards compatible with the given color identity. + Returns list of dicts with card details. + """ + conn = _get_db_conn() + rows = conn.execute( + "SELECT * FROM collection_entries WHERE quantity > 0" + ).fetchall() + + valid = [] + for row in rows: + card = _row_to_dict(row) + card_ci = card.get("color_identity", []) + if isinstance(card_ci, str): + try: + card_ci = json.loads(card_ci) + except Exception: + card_ci = [] + + # Color identity check: all card colors must be within commander's identity + if color_identity and card_ci: + if not all(c in color_identity for c in card_ci): + continue + + # Skip if no scryfall_id + if not card.get("scryfall_id"): + continue + + card["color_identity"] = card_ci + valid.append(card) + + return valid + + +def _generate_deck(req: DeckGenerationRequest) -> dict: + """ + Core deck generation algorithm. + + 1. Resolve commander + 2. Load collection filtered to color identity + 3. Call enabled source adapters (stubbed) + 4. Build candidate pool, score by role/type fit + 5. Fill slots per target ratios, preferring owned cards + 6. Return response dict + """ + # 1. Resolve commander + commander = _resolve_commander(req) + if not commander: + return {"error": "Commander not found. Please check the name or Scryfall ID."} + + color_identity = req.color_identity or commander.get("color_identity", []) + log_deckgen.info(f" Commander: {commander['name']}, CI: {color_identity}") + + # 2. Load collection + collection = _get_collection_for_colors(color_identity) + log_deckgen.info(f" {len(collection)} cards in collection match color identity") + + # 3. Call source adapters (currently stubbed) + template_cards = [] # list of (name, weight, source_name) + sources = req.sources or DeckGenerationSourceConfig() + + try: + if sources.use_archidekt: + from deck_sources.archidekt_adapter import fetch_template_decks as archidekt_fetch + for td in archidekt_fetch(commander["name"], color_identity, {"url": sources.archidekt_url}): + for tc in td.cards: + template_cards.append((tc.name, tc.quantity, "archidekt")) + except Exception as e: + log_deckgen.error(f" Archidekt adapter error: {e}") + + try: + if sources.use_edhrec: + from deck_sources.edhrec_adapter import fetch_template_decks as edhrec_fetch + for td in edhrec_fetch(commander["name"], color_identity): + for tc in td.cards: + template_cards.append((tc.name, tc.quantity, "edhrec")) + except Exception as e: + log_deckgen.error(f" EDHREC adapter error: {e}") + + try: + if sources.use_moxfield: + from deck_sources.moxfield_adapter import fetch_template_decks as moxfield_fetch + for td in moxfield_fetch(commander["name"], color_identity, {"url": sources.moxfield_url}): + for tc in td.cards: + template_cards.append((tc.name, tc.quantity, "moxfield")) + except Exception as e: + log_deckgen.error(f" Moxfield adapter error: {e}") + + try: + if sources.use_mtggoldfish: + from deck_sources.mtggoldfish_adapter import fetch_template_decks as mtggoldfish_fetch + for td in mtggoldfish_fetch(commander["name"], color_identity, {"url": sources.mtggoldfish_url}): + for tc in td.cards: + template_cards.append((tc.name, tc.quantity, "mtggoldfish")) + except Exception as e: + log_deckgen.error(f" MTGGoldfish adapter error: {e}") + + # 4. Build candidate pool from collection, scored by type need + # Build a map: name_lower -> card dict (deduped) + candidate_map = {} + for card in collection: + name = card.get("name", "") + if not name: + continue + key = name.lower() + # Skip the commander itself + if key == commander["name"].lower(): + continue + if key not in candidate_map: + type_line = card.get("type_line", "") + card_type = _classify_card_type(type_line) + card_roles = _detect_card_roles( + card.get("oracle_text", ""), + type_line, + card.get("keywords", []) + ) + candidate_map[key] = { + "scryfall_id": card.get("scryfall_id", ""), + "name": name, + "type_line": type_line, + "mana_cost": card.get("mana_cost", ""), + "cmc": float(card.get("cmc", 0)), + "card_type": card_type, + "roles": card_roles, + "source": "collection", + "quantity": 1, + "owned_qty": int(card.get("quantity", 0)), + "is_proxy": False, + "edhrec_rank": int(card.get("edhrec_rank", 99999)), + "image_url": f"https://api.scryfall.com/cards/{card.get('scryfall_id', '')}?format=image&version=normal" + if card.get("scryfall_id") else "", + } + else: + # Merge quantities + candidate_map[key]["owned_qty"] += int(card.get("quantity", 0)) + + # Add template cards that aren't in collection (as potential proxies) + for tname, tqty, tsource in template_cards: + key = tname.lower() + if key == commander["name"].lower(): + continue + if key in candidate_map: + # Boost priority for cards that appear in templates + candidate_map[key]["_template_weight"] = candidate_map[key].get("_template_weight", 0) + tqty + if candidate_map[key]["source"] == "collection": + candidate_map[key]["source"] = f"collection+{tsource}" + elif req.allow_proxies: + candidate_map[key] = { + "scryfall_id": "", + "name": tname, + "type_line": "", + "mana_cost": "", + "cmc": 0, + "card_type": "Other", + "roles": [], + "source": tsource, + "quantity": 1, + "owned_qty": 0, + "is_proxy": True, + "edhrec_rank": 99999, + "image_url": "", + "_template_weight": tqty, + } + + # 5. Fill slots per target ratios + targets = { + "Land": req.target_land_count, + "Creature": req.target_creature_count, + "Instant": req.target_instant_count, + "Sorcery": req.target_sorcery_count, + "Artifact": req.target_artifact_count, + "Enchantment": req.target_enchantment_count, + "Planeswalker": req.target_planeswalker_count, + } + + # Group candidates by type + by_type = {} + for card in candidate_map.values(): + ct = card["card_type"] + by_type.setdefault(ct, []).append(card) + + # Score and sort candidates within each type + for ct, cards in by_type.items(): + for card in cards: + score = 0 + # Prefer owned cards heavily + if card["owned_qty"] > 0: + score += 100 + # Prefer cards with functional roles + for r in card.get("roles", []): + if r in ("Ramp", "Draw", "Removal", "BoardWipe"): + score += 10 + else: + score += 3 + # Prefer lower EDHREC rank (more popular cards) + edhrec_rank = card.get("edhrec_rank", 99999) + if edhrec_rank < 500: + score += 15 + elif edhrec_rank < 2000: + score += 10 + elif edhrec_rank < 5000: + score += 5 + # Prefer cards from templates + score += card.get("_template_weight", 0) * 5 + # Prefer lower CMC (curve optimization) + cmc = card.get("cmc", 0) + if cmc <= 2: + score += 5 + elif cmc <= 4: + score += 3 + card["_score"] = score + cards.sort(key=lambda x: x["_score"], reverse=True) + + # Pick cards for each slot type + deck_cards = [] + used_names = set() # track by name to avoid duplicates + + for card_type, target in targets.items(): + candidates = by_type.get(card_type, []) + picked = 0 + for card in candidates: + if picked >= target: + break + name_key = card["name"].lower() + if name_key in used_names: + continue + # If only collection, skip proxies + if req.only_cards_in_collection and card["owned_qty"] <= 0: + continue + used_names.add(name_key) + deck_cards.append(card) + picked += 1 + + # Fill remaining slots up to 99 (commander is #100) + total = sum(targets.values()) + if total < 99: + remaining = 99 - len(deck_cards) + # Fill with best remaining cards from any type + all_remaining = [] + for ct, cards in by_type.items(): + for card in cards: + if card["name"].lower() not in used_names: + if not req.only_cards_in_collection or card["owned_qty"] > 0: + all_remaining.append(card) + all_remaining.sort(key=lambda x: x.get("_score", 0), reverse=True) + for card in all_remaining[:remaining]: + used_names.add(card["name"].lower()) + deck_cards.append(card) + elif len(deck_cards) > 99: + deck_cards = deck_cards[:99] + + # 6. Compute stats + stats = {"total": len(deck_cards) + 1, "land": 0, "nonland": 0, "by_type": {}, "owned": 0, "proxy": 0} + for card in deck_cards: + ct = card.get("card_type", "Other") + stats["by_type"][ct] = stats["by_type"].get(ct, 0) + 1 + if ct == "Land": + stats["land"] += 1 + else: + stats["nonland"] += 1 + if card.get("owned_qty", 0) > 0: + stats["owned"] += 1 + else: + stats["proxy"] += 1 + + # Clean up internal scoring keys + clean_cards = [] + for card in deck_cards: + clean_cards.append({ + "scryfall_id": card.get("scryfall_id", ""), + "name": card.get("name", ""), + "type_line": card.get("type_line", ""), + "mana_cost": card.get("mana_cost", ""), + "cmc": card.get("cmc", 0), + "card_type": card.get("card_type", "Other"), + "roles": card.get("roles", []), + "source": card.get("source", "collection"), + "quantity": 1, + "image_url": card.get("image_url", ""), + "owned_qty": card.get("owned_qty", 0), + "is_proxy": card.get("is_proxy", False), + }) + + log_deckgen.info(f" Generated deck: {len(clean_cards)} cards + commander") + log_deckgen.info(f" Stats: {stats}") + + return { + "commander": commander, + "color_identity": color_identity, + "cards": clean_cards, + "stats": stats, + "targets": targets, + } + + +@router.get("/api/deck-generator/config") +async def deck_generator_config(): + """ + Return default ratios, supported sources, and limits. + Used by the frontend to prefill the generator form. + """ + return { + "defaults": { + "target_land_count": 37, + "target_instant_count": 10, + "target_sorcery_count": 8, + "target_artifact_count": 10, + "target_enchantment_count": 8, + "target_creature_count": 25, + "target_planeswalker_count": 2, + "only_cards_in_collection": False, + "allow_proxies": True, + }, + "sources": [ + {"id": "archidekt", "name": "Archidekt", "enabled": True, "experimental": False}, + {"id": "edhrec", "name": "EDHREC", "enabled": True, "experimental": False}, + {"id": "moxfield", "name": "Moxfield", "enabled": False, "experimental": True}, + {"id": "mtggoldfish", "name": "MTGGoldfish", "enabled": False, "experimental": True}, + ], + "limits": { + "deck_size": 100, + "max_external_templates": 5, + }, + } + + +@router.post("/api/deck-generator/preview") +async def deck_generator_preview(req: DeckGenerationRequest): + """ + Generate a deck preview without saving it. + Returns the generated deck data for user review. + """ + result = _generate_deck(req) + if "error" in result: + raise HTTPException(400, result["error"]) + return result + + +@router.post("/api/deck-generator/commit") +async def deck_generator_commit(req: DeckGenerationRequest): + """ + Generate a deck and save it to the Deck Builder. + Returns the generated deck data plus deck_id and deck_name. + """ + result = _generate_deck(req) + if "error" in result: + raise HTTPException(400, result["error"]) + + commander = result["commander"] + color_identity = result["color_identity"] + cards = result["cards"] + + # Determine deck name + deck_name = req.deck_name or f"Auto - {commander['name']} - {datetime.now().strftime('%Y-%m-%d %H:%M')}" + + # Create the deck in the database + conn = _get_db_conn() + color_identity_json = json.dumps(color_identity) + cur = conn.execute( + """ + INSERT INTO decks (name, commander_scryfall_id, commander_name, color_identity, strategy_tag) + VALUES (?, ?, ?, ?, ?) + """, + ( + deck_name, + commander.get("scryfall_id", ""), + commander.get("name", ""), + color_identity_json, + "auto-generated", + ) + ) + conn.commit() + deck_id = cur.lastrowid + + # Insert commander as a deck card with is_commander=1 + conn.execute( + "INSERT INTO deck_cards (deck_id, scryfall_id, card_name, quantity, is_commander, role_tag) VALUES (?, ?, ?, 1, 1, 'Commander')", + (deck_id, commander.get("scryfall_id", ""), commander.get("name", "")) + ) + + # Insert the 99 cards + for card in cards: + scryfall_id = card.get("scryfall_id", "") + card_name = card.get("name", "") + quantity = card.get("quantity", 1) + if not scryfall_id and not card_name: + continue + conn.execute( + "INSERT INTO deck_cards (deck_id, scryfall_id, card_name, quantity, is_commander, role_tag) VALUES (?, ?, ?, ?, 0, ?)", + (deck_id, scryfall_id, card_name, quantity, card.get("card_type", "")) + ) + conn.commit() + + # Also export a .dck file to the Forge decks directory so it appears in the Sim Lab + try: + decks_dir = CFG.forge_decks_dir + if decks_dir and os.path.isdir(decks_dir): + safe_name = re.sub(r'[^a-zA-Z0-9_\-\s]', '', deck_name).strip().replace(' ', '_') + if not safe_name: + safe_name = f"AutoDeck_{deck_id}" + dck_path = os.path.join(decks_dir, f"{safe_name}.dck") + dck_lines = [] + dck_lines.append("[metadata]") + dck_lines.append(f"Name={deck_name}") + dck_lines.append("") + dck_lines.append("[Commander]") + dck_lines.append(f"1 {commander['name']}") + dck_lines.append("") + dck_lines.append("[Main]") + for card in cards: + card_name = card.get("name", "") + if card_name: + dck_lines.append(f"{card.get('quantity', 1)} {card_name}") + with open(dck_path, "w", encoding="utf-8") as f: + f.write("\n".join(dck_lines)) + log_deckgen.info(f" Exported .dck file: {dck_path}") + except Exception as e: + log_deckgen.warning(f" Warning: Failed to export .dck file: {e}") + + log_deckgen.info(f" Saved deck '{deck_name}' (ID: {deck_id}) with {len(cards)} cards + commander") + + result["deck_id"] = deck_id + result["deck_name"] = deck_name + return result + + +@router.get("/api/deck-generator/commander-search") +async def deck_generator_commander_search(q: str = ""): + """ + Search for legendary creatures/planeswalkers to use as commander. + First checks collection, then falls back to Scryfall. + """ + if not q or len(q) < 2: + return {"results": []} + + conn = _get_db_conn() + q_lower = f"%{q.lower()}%" + + # Search collection first + rows = conn.execute( + """ + SELECT DISTINCT name, type_line, color_identity, mana_cost, scryfall_id + FROM collection_entries + WHERE LOWER(name) LIKE ? AND is_legendary = 1 + AND (type_line LIKE '%Creature%' OR type_line LIKE '%Planeswalker%') + ORDER BY name ASC + LIMIT 20 + """, + (q_lower,) + ).fetchall() + + results = [] + for r in rows: + ci = r["color_identity"] + if isinstance(ci, str): + try: + ci = json.loads(ci) + except Exception: + ci = [] + results.append({ + "name": r["name"], + "type_line": r["type_line"], + "color_identity": ci, + "mana_cost": r["mana_cost"] or "", + "scryfall_id": r["scryfall_id"], + "in_collection": True, + "image_url": f"https://api.scryfall.com/cards/{r['scryfall_id']}?format=image&version=normal" + if r["scryfall_id"] else "", + }) + + # If few results, supplement with Scryfall + if len(results) < 5: + try: + import urllib.parse + encoded = urllib.parse.quote(q) + _scryfall_rate_limit() + url = f"https://api.scryfall.com/cards/search?q={encoded}+t%3Alegendary+(t%3Acreature+OR+t%3Aplaneswalker)&order=edhrec&unique=cards" + rq = Request(url, headers={"User-Agent": "CommanderAILab/1.0", "Accept": "application/json"}) + with urlopen(rq, timeout=15) as resp: + data = json.loads(resp.read().decode("utf-8")) + + existing_names = {r["name"].lower() for r in results} + for card in data.get("data", [])[:10]: + if card["name"].lower() in existing_names: + continue + image_uris = card.get("image_uris", {}) + if not image_uris and card.get("card_faces"): + image_uris = card["card_faces"][0].get("image_uris", {}) + results.append({ + "name": card["name"], + "type_line": card.get("type_line", ""), + "color_identity": card.get("color_identity", []), + "mana_cost": card.get("mana_cost", ""), + "scryfall_id": card.get("id", ""), + "in_collection": False, + "image_url": image_uris.get("normal", image_uris.get("small", "")), + }) + except Exception as e: + log_deckgen.error(f" Scryfall search error: {e}") + + return {"results": results[:20]} + + +# ══════════════════════════════════════════════════════════════ +# Perplexity API — AI Deck Research & Generation +# ══════════════════════════════════════════════════════════════ + + +class DeckResearchRequest(BaseModel): + deck_id: int # Deck to research + goal: Optional[str] = "Identify weaknesses and suggest upgrades" + budget_usd: Optional[float] = None + omit_cards: Optional[list[str]] = [] + use_collection: bool = True + + +class DeckGenerateAIRequest(BaseModel): + commander: str + budget_usd: Optional[float] = None + budget_mode: str = "total" # "total" or "per_card" + omit_cards: Optional[list[str]] = [] + use_collection: bool = True + + +def _build_collection_summary(color_identity: list[str] | None = None) -> dict: + """Build a compact collection summary for the AI, optionally filtered by color identity.""" + conn = _get_db_conn() + + # Base query: all collection entries with oracle text + where = "WHERE quantity > 0" + params = [] + + # Filter by color identity if provided + if color_identity: + # Cards must only contain colors in the commander's identity (+ colorless) + ci_set = set(c.upper() for c in color_identity) + # We'll do a post-filter since color_identity is JSON in the DB + pass # filter in Python after fetch + + rows = conn.execute(f""" + SELECT name, type_line, cmc, oracle_text, keywords, tcg_price, quantity, + color_identity, category, is_game_changer, salt_score + FROM collection_entries {where} + ORDER BY edhrec_rank ASC, tcg_price DESC + """, params).fetchall() + + # Color identity filter + def card_fits_identity(card_ci_str, ci_set): + if not ci_set: + return True + try: + card_ci = json.loads(card_ci_str) if card_ci_str else [] + if not isinstance(card_ci, list): + return True + return all(c.upper() in ci_set for c in card_ci if c.upper() not in ('', 'C')) + except Exception: + return True + + # Role detection from type_line, oracle_text, and category + def detect_role(row): + tl = (row['type_line'] or '').lower() + oracle = (row['oracle_text'] or '').lower() + cat = (row['category'] or '').lower() + kw = (row['keywords'] or '').lower() + + # Priority order + if 'land' in tl: + return 'lands' + if any(w in cat for w in ['ramp', 'mana']): + return 'ramp' + if any(w in oracle for w in ['add {', 'add one mana', 'search your library for a basic land', 'search your library for a land']): + return 'ramp' + if 'mana' in cat or ('artifact' in tl and ('add' in oracle and '{' in oracle)): + return 'ramp' + if any(w in cat for w in ['draw', 'card advantage']): + return 'card_draw' + if 'draw' in oracle and 'card' in oracle: + return 'card_draw' + if any(w in cat for w in ['removal', 'targeted removal']): + return 'removal' + if 'destroy target' in oracle or 'exile target' in oracle or 'deals' in oracle: + return 'removal' + if any(w in cat for w in ['board wipe', 'boardwipe', 'wrath']): + return 'board_wipes' + if 'destroy all' in oracle or 'exile all' in oracle: + return 'board_wipes' + if any(w in cat for w in ['win', 'finisher', 'combo']): + return 'win_conditions' + if 'you win the game' in oracle or 'extra turn' in oracle or 'infinite' in oracle: + return 'win_conditions' + if 'creature' in tl: + return 'creatures' + return 'utility' + + # Group cards by role + groups: dict[str, list] = { + 'ramp': [], 'card_draw': [], 'removal': [], 'board_wipes': [], + 'lands': [], 'win_conditions': [], 'creatures': [], 'utility': [] + } + filtered_count = 0 + + for r in rows: + if color_identity and not card_fits_identity(r['color_identity'], ci_set): + continue + filtered_count += 1 + role = detect_role(r) + groups[role].append({ + 'name': r['name'], + 'count': r['quantity'], + 'price': round(r['tcg_price'] or 0, 2), + 'cmc': r['cmc'] or 0, + }) + + # Limit each group to top 30 + group_descriptions = { + 'ramp': 'Mana rocks and land ramp in deck colors', + 'card_draw': 'Card draw and card advantage engines', + 'removal': 'Targeted removal spells (destroy, exile, bounce)', + 'board_wipes': 'Board wipes and mass removal', + 'lands': 'Non-basic lands that fit the color identity', + 'win_conditions': 'Win conditions, combo pieces, and finishers', + 'creatures': 'Creatures (non-commander)', + 'utility': 'Utility spells, enchantments, artifacts, and planeswalkers', + } + + result_groups = [] + for gid, cards in groups.items(): + if not cards: + continue + result_groups.append({ + 'group_id': gid, + 'description': group_descriptions.get(gid, ''), + 'cards': cards[:30], + }) + + return { + 'total_cards': len(rows), + 'filtered_cards': filtered_count, + 'groups': result_groups, + } + + +def _call_pplx_api(messages: list[dict], max_tokens: int = 4096, temperature: float = 0.2) -> str: + """Call Perplexity API chat/completions endpoint. Returns the assistant message content.""" + if not CFG.pplx_api_key: + raise HTTPException(400, 'Perplexity API key not configured. Set PPLX_API_KEY env var or --pplx-key.') + + payload = { + 'model': 'sonar', + 'messages': messages, + 'max_tokens': max_tokens, + 'temperature': temperature, + 'return_related_questions': False, + } + + req_data = json.dumps(payload).encode('utf-8') + req = Request('https://api.perplexity.ai/chat/completions', data=req_data, method='POST') + req.add_header('Content-Type', 'application/json') + req.add_header('Authorization', f'Bearer {CFG.pplx_api_key}') + + try: + with urlopen(req, timeout=120) as resp: + data = json.loads(resp.read()) + choices = data.get('choices', []) + if not choices: + raise ValueError('Empty response from Perplexity API') + content = choices[0].get('message', {}).get('content', '') + usage = data.get('usage', {}) + log_pplx.debug(f'tokens: prompt={usage.get("prompt_tokens","?")}, ' + f'completion={usage.get("completion_tokens","?")}, ' + f'model={data.get("model","?")}') + return content + except URLError as e: + raise HTTPException(502, f'Perplexity API call failed: {e}') + except json.JSONDecodeError as e: + raise HTTPException(502, f'Perplexity API returned invalid JSON: {e}') + + +def _extract_json_from_response(text: str) -> dict: + """Extract JSON object from LLM response, handling markdown fences and extra text.""" + # Strip markdown code fences + cleaned = re.sub(r'^```(?:json)?\s*', '', text.strip()) + cleaned = re.sub(r'\s*```$', '', cleaned.strip()) + + # Try direct parse + try: + return json.loads(cleaned) + except json.JSONDecodeError: + pass + + # Try to find JSON object in the text + match = re.search(r'\{[\s\S]*\}', text) + if match: + try: + return json.loads(match.group(0)) + except json.JSONDecodeError: + pass + + raise ValueError(f'Could not extract JSON from response: {text[:300]}') + + +def _postprocess_deck_cards(cards: list[dict], color_identity: list[str] | None = None) -> list[dict]: + """ + Post-process AI-suggested cards: + - Validate names against collection DB + - Attach real prices + - Set from_collection flag + """ + conn = _get_db_conn() + processed = [] + for card in cards: + name = card.get('name', '') + if not name: + continue + + # Look up in collection + row = conn.execute( + "SELECT name, tcg_price, quantity, scryfall_id, type_line, cmc, oracle_text " + "FROM collection_entries WHERE name = ? COLLATE NOCASE LIMIT 1", + (name,) + ).fetchone() + + entry = { + 'name': row['name'] if row else name, + 'count': card.get('count', 1), + 'role': card.get('role', ''), + 'from_collection': bool(row and row['quantity'] and row['quantity'] > 0), + 'estimated_price_usd': round(row['tcg_price'], 2) if row and row['tcg_price'] else card.get('estimated_price_usd', 0), + 'scryfall_id': row['scryfall_id'] if row else '', + } + # Preserve extra fields from AI response + for extra_key in ('reason', 'synergy_with', 'priority', 'severity'): + if extra_key in card: + entry[extra_key] = card[extra_key] + processed.append(entry) + + return processed + + +# Color-to-basic-land mapping for deterministic fill +_COLOR_TO_BASIC = { + 'W': 'Plains', + 'U': 'Island', + 'B': 'Swamp', + 'R': 'Mountain', + 'G': 'Forest', +} +BASIC_LAND_NAMES = set(_COLOR_TO_BASIC.values()) | {'Wastes'} + + +def _fill_basic_lands(cards: list[dict], color_identity: list[str] | None = None, target_total: int = 100) -> list[dict]: + """ + Deterministically fill basic lands so the deck hits exactly target_total cards. + + The LLM may list basics with wrong counts, duplicates, or omit them. + This function: + 1. Strips all basic-land entries the LLM provided + 2. Counts remaining cards (non-basics) and their total count + 3. Computes how many basic land slots are needed + 4. Distributes basics evenly across the commander's colors + """ + # Separate basics from non-basics + non_basics = [] + for card in cards: + name = card.get('name', '').strip() + if name not in BASIC_LAND_NAMES: + non_basics.append(card) + + # Count total non-basic cards + non_basic_total = sum(c.get('count', 1) for c in non_basics) + + # How many basic land copies we need + basics_needed = max(0, target_total - non_basic_total) + + if basics_needed == 0: + return non_basics + + # Determine which basics to use from color identity + ci = [c.upper() for c in (color_identity or [])] + basic_names = [_COLOR_TO_BASIC[c] for c in ci if c in _COLOR_TO_BASIC] + if not basic_names: + # Colorless commander — use Wastes + basic_names = ['Wastes'] + + # Distribute evenly, then add remainders one-by-one + per_color = basics_needed // len(basic_names) + remainder = basics_needed % len(basic_names) + + for i, bname in enumerate(basic_names): + qty = per_color + (1 if i < remainder else 0) + if qty > 0: + non_basics.append({ + 'name': bname, + 'count': qty, + 'role': 'land', + 'category': 'Land', + 'role_tags': [], + 'reason': 'Basic land for mana fixing', + 'estimated_price_usd': 0.10, + 'from_collection': False, + 'is_basic': True, + }) + + return non_basics + + +# ── Research Endpoint ───────────────────────────────────────── + +@router.post('/api/deck-research') +async def deck_research(req: DeckResearchRequest): + """Analyze an existing deck using Perplexity AI and suggest improvements.""" + conn = _get_db_conn() + + # Load deck info + deck = conn.execute('SELECT * FROM decks WHERE id = ?', (req.deck_id,)).fetchone() + if not deck: + raise HTTPException(404, f'Deck {req.deck_id} not found') + + deck_name = deck['name'] + commander_name = deck['commander_name'] or 'Unknown' + color_identity_str = deck['color_identity'] or '[]' + try: + color_identity = json.loads(color_identity_str) + except Exception: + color_identity = [] + + # Load deck cards + card_rows = conn.execute(""" + SELECT dc.card_name, dc.quantity, dc.is_commander, + ce.type_line, ce.cmc, ce.oracle_text, ce.tcg_price + FROM deck_cards dc + LEFT JOIN ( + SELECT scryfall_id, type_line, cmc, oracle_text, tcg_price + FROM collection_entries GROUP BY scryfall_id + ) ce ON ce.scryfall_id = dc.scryfall_id + WHERE dc.deck_id = ? + ORDER BY dc.is_commander DESC, dc.card_name ASC + """, (req.deck_id,)).fetchall() + + if not card_rows: + raise HTTPException(400, 'Deck has no cards') + + # Build decklist text + decklist_lines = [] + total_price = 0.0 + for r in card_rows: + price = r['tcg_price'] or 0 + total_price += price * (r['quantity'] or 1) + cmdr_tag = ' [COMMANDER]' if r['is_commander'] else '' + decklist_lines.append(f"{r['quantity'] or 1}x {r['card_name']}{cmdr_tag}") + + decklist_text = '\n'.join(decklist_lines) + + # Build collection summary if requested + collection_block = '' + if req.use_collection: + summary = _build_collection_summary(color_identity) + if summary['filtered_cards'] > 0: + coll_lines = [f'\nCOLLECTION SUMMARY ({summary["filtered_cards"]} cards in deck colors):'] + for grp in summary['groups']: + card_names = [f"{c['name']} (${c['price']})" for c in grp['cards'][:15]] + coll_lines.append(f" {grp['group_id'].upper()} ({grp['description']}): {', '.join(card_names)}") + collection_block = '\n'.join(coll_lines) + + omit_block = '' + if req.omit_cards: + omit_block = f'\nDO NOT suggest these cards: {", ".join(req.omit_cards)}' + + budget_block = '' + if req.budget_usd: + budget_block = f'\nBUDGET: ${req.budget_usd} total for upgrades. Current deck value: ~${total_price:.0f}' + + # Build messages + system_msg = """You are an elite Magic: The Gathering Commander analyst with encyclopedic knowledge of the format, metagame, and every card ever printed. Provide a DEEP, COMPREHENSIVE analysis. +Always respond with ONLY a JSON object, no markdown fences, no extra text. + +JSON schema: +{ + "overall_rating": "1-10 integer", + "rating_explanation": "2-3 sentence explanation of the rating", + "deck_description": "3-5 sentence overview of what this deck does, its game plan, and how it wins", + "archetype": "aggro|midrange|control|combo|stax|voltron|aristocrats|spellslinger|tokens|tribal|group_hug|lands|reanimator|other", + "bracket_level": { + "level": 1-4, + "reasoning": "why this bracket", + "power_ceiling": "what power level this deck could reach with upgrades" + }, + "win_conditions": [ + {"name": "Win condition name", "cards_involved": ["card1", "card2"], "description": "How this wins the game", "reliability": "high|medium|low"} + ], + "synergy_packages": [ + {"package_name": "Package Name (e.g. Sacrifice Engine, Blink Package)", "cards": ["card1", "card2", "card3"], "description": "How these cards work together", "strength": "strong|moderate|weak"} + ], + "strengths": ["strength 1", "strength 2"], + "weaknesses": ["weakness 1", "weakness 2"], + "threat_assessment": { + "early_game": "1-2 sentences on turns 1-3 plan", + "mid_game": "1-2 sentences on turns 4-7 plan", + "late_game": "1-2 sentences on turns 8+ plan", + "vulnerability": "What shuts this deck down (e.g. graveyard hate, board wipes)" + }, + "mana_analysis": { + "land_count": "current land count assessment", + "color_fixing": "assessment of color fixing quality", + "ramp_package": "assessment of ramp quantity and quality", + "curve_assessment": "is the mana curve appropriate for the strategy", + "problem_cards": ["cards that are hard to cast or mana-inefficient"] + }, + "cuts": [{"name": "card to remove", "reason": "why", "severity": "must_cut|should_cut|consider_cutting"}], + "adds": [{"name": "card to add", "count": 1, "role": "ramp|removal|draw|creature|utility|land|combo_piece|protection|finisher", "estimated_price_usd": 2.5, "reason": "why this card", "synergy_with": ["existing card it synergizes with"], "priority": "critical|high|medium|nice_to_have"}], + "role_gaps": { + "ramp": {"current": 8, "recommended": 10, "note": "needs 2 more ramp sources"}, + "card_draw": {"current": 5, "recommended": 10, "note": ""}, + "removal": {"current": 6, "recommended": 8, "note": ""}, + "board_wipes": {"current": 2, "recommended": 3, "note": ""}, + "protection": {"current": 1, "recommended": 3, "note": ""}, + "lands": {"current": 35, "recommended": 36, "note": ""} + }, + "strategy_notes": "detailed strategic advice for piloting this deck" +}""" + + user_msg = f"""Provide a DEEP, COMPREHENSIVE analysis of this Commander deck. + +DECK NAME: {deck_name} +COMMANDER: {commander_name} +COLOR IDENTITY: {', '.join(color_identity) if color_identity else 'Unknown'} +CURRENT DECK VALUE: ~${total_price:.0f} +GOAL: {req.goal} + +DECKLIST ({len(card_rows)} cards): +{decklist_text} +{budget_block}{omit_block}{collection_block} + +Analyze EVERYTHING: the deck's identity, strategy, archetype, bracket level (1-4 per Commander Rules Committee), ALL synergy packages between cards, ALL win conditions, game plan by phase (early/mid/late), mana base health, every role gap. Suggest specific cuts with severity and specific adds with priority and synergy tags. For adds, prioritize cards from the COLLECTION SUMMARY when available.""" + + content = _call_pplx_api([ + {'role': 'system', 'content': system_msg}, + {'role': 'user', 'content': user_msg}, + ], max_tokens=8192) + + try: + analysis = _extract_json_from_response(content) + except ValueError as e: + return JSONResponse({'error': str(e), 'raw_response': content[:500]}, status_code=422) + + # Post-process "adds" — validate against DB + if 'adds' in analysis and isinstance(analysis['adds'], list): + analysis['adds'] = _postprocess_deck_cards(analysis['adds'], color_identity) + + # Compute real total cost of adds + adds_total = sum(c.get('estimated_price_usd', 0) * c.get('count', 1) for c in analysis.get('adds', [])) + analysis['adds_total_usd'] = round(adds_total, 2) + analysis['deck_name'] = deck_name + analysis['commander'] = commander_name + analysis['color_identity'] = color_identity + analysis['card_count'] = len(card_rows) + analysis['deck_value_usd'] = round(total_price, 2) + + return analysis + + +# ── Generate Endpoint ───────────────────────────────────────── + +@router.post('/api/deck-generate') +async def deck_generate_ai(req: DeckGenerateAIRequest): + """Generate a full 100-card Commander deck using Perplexity AI.""" + commander = req.commander.strip() + if not commander: + raise HTTPException(400, 'Commander name is required') + + # Look up commander on Scryfall for color identity + color_identity = [] + commander_type = '' + try: + scry_url = f'https://api.scryfall.com/cards/named?fuzzy={commander.replace(" ", "+")}' + scry_req = Request(scry_url) + scry_req.add_header('User-Agent', 'CommanderAILab/1.0') + with urlopen(scry_req, timeout=10) as resp: + scry_data = json.loads(resp.read()) + color_identity = scry_data.get('color_identity', []) + commander = scry_data.get('name', commander) # Use canonical name + commander_type = scry_data.get('type_line', '') + except Exception as e: + log_pplx.error(f'Scryfall lookup failed for "{commander}": {e}') + + # Build collection summary + collection_block = '' + if req.use_collection: + summary = _build_collection_summary(color_identity or None) + if summary['filtered_cards'] > 0: + coll_lines = [f'\nCOLLECTION SUMMARY ({summary["filtered_cards"]} cards available):'] + for grp in summary['groups']: + card_names = [f"{c['name']} (${c['price']})" for c in grp['cards'][:20]] + coll_lines.append(f" {grp['group_id'].upper()}: {', '.join(card_names)}") + collection_block = '\n'.join(coll_lines) + + omit_block = '' + if req.omit_cards: + omit_block = f'\nOMIT LIST (do NOT include): {", ".join(req.omit_cards)}' + + budget_block = '' + if req.budget_usd: + mode_desc = 'total deck cost' if req.budget_mode == 'total' else 'per card' + budget_block = f'\nBUDGET: ${req.budget_usd} {mode_desc}. Stay within budget.' + + system_msg = """You are an expert Magic: The Gathering Commander deck builder. +Build a complete, legal 100-card Commander deck (1 commander + 99 other cards). +Prefer cards from the player's collection when available. +Respect the budget and omit list. +Always respond with ONLY a JSON object, no markdown fences, no extra text. + +JSON schema: +{ + "commander": "Commander Name", + "strategy": "1-2 sentence strategy description", + "cards": [ + {"name": "Card Name", "count": 1, "role": "ramp|removal|draw|creature|land|utility|win_condition", "estimated_price_usd": 2.5} + ], + "reasoning": { + "strategy": "detailed strategy explanation", + "mana_curve": "mana curve reasoning", + "key_synergies": "key synergies and combos", + "budget_notes": "how budget was managed", + "collection_usage_notes": "which collection cards were used and why" + }, + "estimated_total_usd": 187.5 +} + +Deck building rules: +- Exactly 100 cards total (commander + 99) +- No more than 1 copy of any card (except basic lands) +- 36-38 lands including commander-colored basics and utility lands +- ~10 ramp sources, ~10 card draw, ~8-10 removal, ~2-3 board wipes +- Include the commander in the cards list with role "commander" +- All cards must be legal in Commander format""" + + user_msg = f"""Build a complete 100-card Commander deck for: + +COMMANDER: {commander} +TYPE: {commander_type} +COLOR IDENTITY: {', '.join(color_identity) if color_identity else 'Unknown'} +{budget_block}{omit_block}{collection_block} + +Build the deck as JSON. Prioritize collection cards when they fit the strategy.""" + + content = _call_pplx_api([ + {'role': 'system', 'content': system_msg}, + {'role': 'user', 'content': user_msg}, + ], max_tokens=8192, temperature=0.3) + + try: + result = _extract_json_from_response(content) + except ValueError as e: + return JSONResponse({'error': str(e), 'raw_response': content[:500]}, status_code=422) + + # Post-process cards + if 'cards' in result and isinstance(result['cards'], list): + result['cards'] = _postprocess_deck_cards(result['cards'], color_identity) + # Fill basic lands deterministically to hit exactly 100 + result['cards'] = _fill_basic_lands(result['cards'], color_identity, target_total=100) + + # Compute real totals + real_total = sum(c.get('estimated_price_usd', 0) * c.get('count', 1) for c in result.get('cards', [])) + from_collection_count = sum(1 for c in result.get('cards', []) if c.get('from_collection')) + result['real_total_usd'] = round(real_total, 2) + result['from_collection_count'] = from_collection_count + result['total_cards'] = sum(c.get('count', 1) for c in result.get('cards', [])) + result['color_identity'] = color_identity + + return result + + +@router.get('/api/pplx/status') +async def pplx_status(): + """Check if Perplexity API is configured.""" + return { + 'configured': bool(CFG.pplx_api_key), + 'key_prefix': CFG.pplx_api_key[:8] + '...' if CFG.pplx_api_key else '', + } + + +# ══════════════════════════════════════════════════════════════ +# V3 Deck Generator (Perplexity Structured Output) +# ══════════════════════════════════════════════════════════════ + +@router.get('/api/deck/v3/status') +async def deck_gen_v3_status(): + """Check V3 deck generator status.""" + return { + 'initialized': _coach._deck_gen_v3 is not None, + 'pplx_configured': bool(CFG.pplx_api_key), + 'model': _coach._deck_gen_v3.pplx.model if _coach._deck_gen_v3 else None, + 'embeddings_loaded': ( + _coach_embeddings.loaded if _coach_embeddings else False + ), + 'embedding_cards': ( + _coach_embeddings.card_count if _coach_embeddings else 0 + ), + 'error': _coach._deck_gen_v3_error, + } + + +@router.post('/api/deck/v3/generate') +async def deck_gen_v3_generate(req: DeckGenV3Request): + """ + V3 Deck Generation — Perplexity structured output with Smart Substitution. + + Pipeline: + 1. Resolve commander via Scryfall + 2. Build collection summary from DB + 3. Call Perplexity Sonar with JSON schema enforcement + 4. Cross-reference cards with collection for ownership + 5. Run Smart Substitution (embedding + Perplexity fallback) + 6. Return complete deck with substitution data + """ + if _coach._deck_gen_v3 is None: + raise HTTPException(503, 'V3 Deck Generator not initialized. Check PPLX_API_KEY.') + + if not req.commander_name or len(req.commander_name.strip()) < 2: + raise HTTPException(400, 'Commander name is required (min 2 chars)') + + try: + # Generate deck + result = _coach._deck_gen_v3.generate_deck( + commander_name=req.commander_name.strip(), + strategy=req.strategy, + target_bracket=req.target_bracket, + budget_usd=req.budget_usd, + budget_mode=req.budget_mode, + omit_cards=req.omit_cards, + use_collection=req.use_collection, + model=req.model, + ) + + # Run substitution if requested + if req.run_substitution and result.get('cards'): + from coach.schemas.substitution_schema import DeckCardWithStatus + cards = [DeckCardWithStatus(**c) for c in result['cards']] + sub_result = _coach._deck_gen_v3.run_substitution( + cards=cards, + commander=result['commander'], + strategy=req.strategy, + ) + result['cards'] = [c.model_dump() for c in sub_result.cards] + result['substitution_stats'] = { + 'owned': sub_result.owned_count, + 'substituted': sub_result.substituted_count, + 'missing': sub_result.missing_count, + } + # Recompute stats with updated cards + from coach.services.deck_generator import DeckGeneratorV3 + result['stats'] = DeckGeneratorV3._compute_stats(sub_result.cards) + + return result + + except ValueError as e: + return JSONResponse({'error': str(e)}, status_code=422) + except Exception as e: + log_deckgen.error(f'Error: {e}') + import traceback + traceback.print_exc() + raise HTTPException(500, f'Deck generation failed: {str(e)}') + + +@router.post('/api/deck/v3/commit') +async def deck_gen_v3_commit(req: DeckGenV3Request): + """ + V3 Generate + Commit — generates the deck and saves it to the Deck Builder DB. + Also exports a .dck file for Forge Sim Lab. + """ + if _coach._deck_gen_v3 is None: + raise HTTPException(503, 'V3 Deck Generator not initialized.') + + if not req.commander_name or len(req.commander_name.strip()) < 2: + raise HTTPException(400, 'Commander name is required') + + try: + # Generate deck (same as preview) + result = _coach._deck_gen_v3.generate_deck( + commander_name=req.commander_name.strip(), + strategy=req.strategy, + target_bracket=req.target_bracket, + budget_usd=req.budget_usd, + budget_mode=req.budget_mode, + omit_cards=req.omit_cards, + use_collection=req.use_collection, + model=req.model, + ) + + # Run substitution + if req.run_substitution and result.get('cards'): + from coach.schemas.substitution_schema import DeckCardWithStatus + cards = [DeckCardWithStatus(**c) for c in result['cards']] + sub_result = _coach._deck_gen_v3.run_substitution( + cards=cards, + commander=result['commander'], + strategy=req.strategy, + ) + result['cards'] = [c.model_dump() for c in sub_result.cards] + + # Save to DB + commander = result['commander'] + color_identity = result.get('color_identity', []) + cards = result.get('cards', []) + strategy = result.get('strategy_summary', '') + bracket = result.get('bracket', {}).get('level', 0) + + deck_name = ( + req.deck_name + or f"V3 - {commander['name']} - B{bracket} - {datetime.now().strftime('%Y-%m-%d %H:%M')}" + ) + + conn = _get_db_conn() + cur = conn.execute( + "INSERT INTO decks (name, commander_scryfall_id, commander_name, color_identity, strategy_tag) " + "VALUES (?, ?, ?, ?, ?)", + (deck_name, commander.get('scryfall_id', ''), + commander.get('name', ''), json.dumps(color_identity), + f"v3-auto|B{bracket}|{strategy[:60]}") + ) + conn.commit() + deck_id = cur.lastrowid + + # Insert commander + conn.execute( + "INSERT INTO deck_cards (deck_id, scryfall_id, card_name, quantity, is_commander, role_tag) " + "VALUES (?, ?, ?, 1, 1, 'Commander')", + (deck_id, commander.get('scryfall_id', ''), commander.get('name', '')) + ) + + # Insert the 99 + for card in cards: + card_name = card.get('name', '') + if not card_name or card_name == commander.get('name', ''): + continue # Skip commander (already inserted) + scryfall_id = card.get('scryfall_id', '') + # Use substitute name if substituted + if card.get('status') == 'substituted' and card.get('selected_substitute'): + card_name = card['selected_substitute'] + conn.execute( + "INSERT INTO deck_cards (deck_id, scryfall_id, card_name, quantity, is_commander, role_tag) " + "VALUES (?, ?, ?, ?, 0, ?)", + (deck_id, scryfall_id, card_name, + card.get('count', 1), card.get('category', '')) + ) + conn.commit() + + # Export .dck for Forge + try: + decks_dir = CFG.forge_decks_dir + if decks_dir and os.path.isdir(decks_dir): + safe_name = re.sub(r'[^a-zA-Z0-9_\-\s]', '', deck_name).strip().replace(' ', '_') + if not safe_name: + safe_name = f"V3Deck_{deck_id}" + dck_path = os.path.join(decks_dir, f"{safe_name}.dck") + dck_lines = ["[metadata]", f"Name={deck_name}", "", + "[Commander]", f"1 {commander['name']}", "", "[Main]"] + for card in cards: + cname = card.get('name', '') + if card.get('status') == 'substituted' and card.get('selected_substitute'): + cname = card['selected_substitute'] + if cname and cname != commander.get('name', ''): + dck_lines.append(f"{card.get('count', 1)} {cname}") + with open(dck_path, 'w', encoding='utf-8') as f: + f.write('\n'.join(dck_lines)) + log_deckgen.info(f" Exported .dck: {dck_path}") + except Exception as e: + log_deckgen.error(f" .dck export failed: {e}") + + result['deck_id'] = deck_id + result['deck_name'] = deck_name + log_deckgen.info(f" Committed deck '{deck_name}' (ID: {deck_id})") + return result + + except ValueError as e: + return JSONResponse({'error': str(e)}, status_code=422) + except Exception as e: + log_deckgen.error(f'Commit error: {e}') + import traceback + traceback.print_exc() + raise HTTPException(500, f'Deck generation/commit failed: {str(e)}') + + +@router.post('/api/deck/v3/export/csv') +async def deck_gen_v3_export_csv(req: DeckGenV3Request): + """Generate a deck and return as CSV.""" + if _coach._deck_gen_v3 is None: + raise HTTPException(503, 'V3 Deck Generator not initialized.') + + result = _coach._deck_gen_v3.generate_deck( + commander_name=req.commander_name.strip(), + strategy=req.strategy, + target_bracket=req.target_bracket, + budget_usd=req.budget_usd, + budget_mode=req.budget_mode, + omit_cards=req.omit_cards, + use_collection=req.use_collection, + model=req.model, + ) + + cards = result.get('cards', []) + commander = result.get('commander', {}) + + output = io.StringIO() + writer = csv.writer(output) + writer.writerow(['Count', 'Name', 'Category', 'Roles', 'Status', 'Price_USD', 'Reason']) + # Commander row + writer.writerow([1, commander.get('name', ''), 'Commander', '', 'owned', '', 'Commander']) + for card in cards: + if card.get('name') == commander.get('name', ''): + continue + writer.writerow([ + card.get('count', 1), + card.get('name', ''), + card.get('category', ''), + '; '.join(card.get('role_tags', [])), + card.get('status', 'unknown'), + card.get('estimated_price_usd', 0), + card.get('reason', ''), + ]) + + csv_content = output.getvalue() + safe_name = re.sub(r'[^a-zA-Z0-9_\-\s]', '', commander.get('name', 'deck')).strip().replace(' ', '_') + return StreamingResponse( + io.BytesIO(csv_content.encode('utf-8')), + media_type='text/csv', + headers={'Content-Disposition': f'attachment; filename="{safe_name}_deck.csv"'}, + ) + + +@router.post('/api/deck/v3/export/dck') +async def deck_gen_v3_export_dck(req: DeckGenV3Request): + """Generate a deck and return as Forge .dck format.""" + if _coach._deck_gen_v3 is None: + raise HTTPException(503, 'V3 Deck Generator not initialized.') + + result = _coach._deck_gen_v3.generate_deck( + commander_name=req.commander_name.strip(), + strategy=req.strategy, + target_bracket=req.target_bracket, + budget_usd=req.budget_usd, + budget_mode=req.budget_mode, + omit_cards=req.omit_cards, + use_collection=req.use_collection, + model=req.model, + ) + + cards = result.get('cards', []) + commander = result.get('commander', {}) + + lines = [ + '[metadata]', + f'Name={commander.get("name", "Deck")} - V3 Auto', + '', + '[Commander]', + f'1 {commander.get("name", "")}', + '', + '[Main]', + ] + for card in cards: + cname = card.get('name', '') + if card.get('status') == 'substituted' and card.get('selected_substitute'): + cname = card['selected_substitute'] + if cname and cname != commander.get('name', ''): + lines.append(f"{card.get('count', 1)} {cname}") + + dck_content = '\n'.join(lines) + safe_name = re.sub(r'[^a-zA-Z0-9_\-\s]', '', commander.get('name', 'deck')).strip().replace(' ', '_') + return StreamingResponse( + io.BytesIO(dck_content.encode('utf-8')), + media_type='text/plain', + headers={'Content-Disposition': f'attachment; filename="{safe_name}.dck"'}, + ) + + +@router.post('/api/deck/v3/export/moxfield') +async def deck_gen_v3_export_moxfield(req: DeckGenV3Request): + """Generate a deck and return in Moxfield paste format.""" + if _coach._deck_gen_v3 is None: + raise HTTPException(503, 'V3 Deck Generator not initialized.') + + result = _coach._deck_gen_v3.generate_deck( + commander_name=req.commander_name.strip(), + strategy=req.strategy, + target_bracket=req.target_bracket, + budget_usd=req.budget_usd, + budget_mode=req.budget_mode, + omit_cards=req.omit_cards, + use_collection=req.use_collection, + model=req.model, + ) + + cards = result.get('cards', []) + commander = result.get('commander', {}) + + # Moxfield format: card lines in main, commander in dedicated section + lines = [] + lines.append('// Commander') + lines.append(f'1 {commander.get("name", "")}') + lines.append('') + lines.append('// Deck') + for card in cards: + cname = card.get('name', '') + if card.get('status') == 'substituted' and card.get('selected_substitute'): + cname = card['selected_substitute'] + if cname and cname != commander.get('name', ''): + lines.append(f"{card.get('count', 1)} {cname}") + + txt = '\n'.join(lines) + return {'format': 'moxfield', 'content': txt, 'commander': commander.get('name', '')} + + +@router.post('/api/deck/v3/export/shopping') +async def deck_gen_v3_export_shopping(req: DeckGenV3Request): + """Generate a deck and return a shopping list of cards not owned.""" + if _coach._deck_gen_v3 is None: + raise HTTPException(503, 'V3 Deck Generator not initialized.') + + result = _coach._deck_gen_v3.generate_deck( + commander_name=req.commander_name.strip(), + strategy=req.strategy, + target_bracket=req.target_bracket, + budget_usd=req.budget_usd, + budget_mode=req.budget_mode, + omit_cards=req.omit_cards, + use_collection=req.use_collection, + model=req.model, + ) + + cards = result.get('cards', []) + shopping = [] + total = 0.0 + for card in cards: + if not card.get('owned', True) and card.get('status') != 'substituted': + price = card.get('estimated_price_usd', 0) + shopping.append({ + 'name': card.get('name', ''), + 'count': card.get('count', 1), + 'category': card.get('category', ''), + 'estimated_price_usd': price, + 'reason': card.get('reason', ''), + }) + total += price * card.get('count', 1) + + return { + 'commander': result.get('commander', {}).get('name', ''), + 'shopping_list': shopping, + 'total_missing': len(shopping), + 'estimated_cost_usd': round(total, 2), + } + +