diff --git a/lab_api.py b/lab_api.py index e4d8dce..df4b439 100644 --- a/lab_api.py +++ b/lab_api.py @@ -109,6 +109,8 @@ from routes.deckbuilder import router as deckbuilder_router from routes.precon import router as precon_router 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 # ══════════════════════════════════════════════════════════════ @@ -151,6 +153,8 @@ app.include_router(deckbuilder_router) app.include_router(precon_router) app.include_router(import_router) app.include_router(lab_router) +app.include_router(scanner_router) +app.include_router(deepseek_router) @app.on_event("startup") @@ -171,1209 +175,6 @@ async def _on_startup(): active_batches: dict[str, BatchState] = {} -@app.post("/api/collection/scan") -async def scan_card_image(request: FastAPIRequest): - """ - Scan a card image and return recognized card name(s). - - Uses Ximilar Visual AI for card recognition. - - Accepts multipart/form-data with: - - file: image file (JPEG/PNG) - - mode: "single" (default) or "multi" - - Returns: - { "results": [ { raw_ocr, matched_name, set_code, scryfall_id, confidence, image_uri, error, collector_number, rarity, tcgplayer_url }, ... ] } - """ - try: - from scanner.pipeline import scan_single, scan_multi - except ImportError as e: - raise HTTPException(500, f"Scanner module not available: {e}") - - # Try CFG first, then fall back to env var - api_key = CFG.ximilar_api_key or os.environ.get("XIMILAR_API_KEY", "") - if not api_key: - raise HTTPException(500, "Card scanner not configured. Start the server with --ximilar-key YOUR_KEY or set XIMILAR_API_KEY env var.") - - # Parse multipart form - form = await request.form() - upload = form.get("file") - if upload is None: - raise HTTPException(400, "No file uploaded. Send an image as 'file' in multipart/form-data.") - - mode = str(form.get("mode", "single")).lower() - image_bytes = await upload.read() - - if len(image_bytes) == 0: - raise HTTPException(400, "Uploaded file is empty") - - # Limit file size (20 MB) - if len(image_bytes) > 20 * 1024 * 1024: - raise HTTPException(400, "Image file too large (max 20 MB)") - - log_scan.info(f" Processing {len(image_bytes)} bytes, mode={mode} (Ximilar AI)") - - if mode == "multi": - results = scan_multi(image_bytes, _scryfall_fuzzy_lookup, ximilar_api_key=api_key) - else: - result = scan_single(image_bytes, _scryfall_fuzzy_lookup, ximilar_api_key=api_key) - results = [result] - - return { - "results": [r.to_dict() for r in results] - } - - -@app.post("/api/collection/scan/add") -async def scan_add_cards(body: dict): - """ - Add scanned cards to the collection. - - Body: - { - "cards": [ - { "name": "Lightning Bolt", "set_code": "m11", "quantity": 1 }, - ... - ] - } - """ - cards = body.get("cards", []) - if not cards: - raise HTTPException(400, "No cards provided") - - conn = _get_db_conn() - imported = 0 - updated = 0 - errors = [] - - for card_req in cards: - name = str(card_req.get("name", "")).strip() - if not name: - continue - - quantity = int(card_req.get("quantity", 1)) - set_code = str(card_req.get("set_code", "")).lower() - collector_number = str(card_req.get("collector_number", "")) - finish = "NORMAL" - - try: - enriched = _enrich_from_scryfall(name, set_code, collector_number) - except Exception as e: - errors.append(f"Scryfall error for '{name}': {e}") - continue - - if not enriched or "_error" in enriched: - err_msg = enriched.get("_error", f"Not found: '{name}'") if enriched else f"Not found: '{name}'" - errors.append(err_msg) - continue - - resolved_name = enriched.get("name", name) - - # Check for existing entry - existing = conn.execute( - """SELECT id, quantity FROM collection_entries - WHERE name = ? AND set_code = ? AND collector_number = ? AND finish = ?""", - (resolved_name, set_code, collector_number, finish), - ).fetchone() - - if existing: - new_qty = existing["quantity"] + quantity - conn.execute( - "UPDATE collection_entries SET quantity = ?, updated_at = datetime('now') WHERE id = ?", - (new_qty, existing["id"]), - ) - conn.commit() - updated += 1 - else: - conn.execute( - """INSERT INTO collection_entries - (name, type_line, subtypes, is_legendary, is_basic, color_identity, - cmc, mana_cost, oracle_text, keywords, - power, toughness, rarity, set_name, edhrec_rank, - tcg_price, salt_score, is_game_changer, - category, scryfall_id, tcgplayer_id, - quantity, finish, condition, language, notes, tags, - set_code, collector_number) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", - ( - resolved_name, - enriched.get("type_line", ""), - enriched.get("subtypes", "[]"), - enriched.get("is_legendary", 0), - enriched.get("is_basic", 0), - enriched.get("color_identity", "[]"), - enriched.get("cmc", 0.0), - enriched.get("mana_cost", ""), - enriched.get("oracle_text", ""), - enriched.get("keywords", "[]"), - enriched.get("power", ""), - enriched.get("toughness", ""), - enriched.get("rarity", ""), - enriched.get("set_name", ""), - enriched.get("edhrec_rank", 0), - enriched.get("tcg_price", 0.0), - enriched.get("salt_score", 0.0), - enriched.get("is_game_changer", 0), - enriched.get("category", "[]"), - enriched.get("scryfall_id", ""), - enriched.get("tcgplayer_id", ""), - quantity, - finish, - "", # condition - "", # language - "", # notes - "", # tags - set_code, - collector_number, - ), - ) - conn.commit() - imported += 1 - - return { - "importedCount": imported, - "updatedCount": updated, - "failedCount": len(errors), - "errors": errors, - } - - -# ══════════════════════════════════════════════════════════════ -# Re-enrich Collection from Scryfall -# ══════════════════════════════════════════════════════════════ - -@app.post("/api/collection/re-enrich") -async def re_enrich_collection(): - """ - Re-fetch Scryfall data for all collection entries to backfill - missing fields (mana_cost, power, toughness, rarity, set_name, edhrec_rank). - Also refreshes: type_line, oracle_text, keywords, cmc, color_identity, prices. - Runs synchronously — may take a while for large collections. - """ - conn = _get_db_conn() - rows = conn.execute( - "SELECT id, name, set_code, collector_number FROM collection_entries ORDER BY id" - ).fetchall() - - total = len(rows) - enriched_count = 0 - skipped_count = 0 - errors = [] - - for row in rows: - card_id = row["id"] - name = row["name"] - set_code = row["set_code"] or "" - collector_number = row["collector_number"] or "" - - try: - data = _enrich_from_scryfall(name, set_code, collector_number) - except Exception as e: - errors.append(f"Error for '{name}': {e}") - skipped_count += 1 - continue - - if not data or "_error" in data: - err_msg = data.get("_error", f"Not found: '{name}'") if data else f"Not found: '{name}'" - errors.append(err_msg) - skipped_count += 1 - continue - - conn.execute( - """UPDATE collection_entries SET - type_line = ?, subtypes = ?, is_legendary = ?, is_basic = ?, - color_identity = ?, cmc = ?, mana_cost = ?, - oracle_text = ?, keywords = ?, - power = ?, toughness = ?, - rarity = ?, set_name = ?, edhrec_rank = ?, - tcg_price = ?, scryfall_id = ?, tcgplayer_id = ?, - category = ?, - updated_at = datetime('now') - WHERE id = ?""", - ( - data.get("type_line", ""), - data.get("subtypes", "[]"), - data.get("is_legendary", 0), - data.get("is_basic", 0), - data.get("color_identity", "[]"), - data.get("cmc", 0.0), - data.get("mana_cost", ""), - data.get("oracle_text", ""), - data.get("keywords", "[]"), - data.get("power", ""), - data.get("toughness", ""), - data.get("rarity", ""), - data.get("set_name", ""), - data.get("edhrec_rank", 0), - data.get("tcg_price", 0.0), - data.get("scryfall_id", ""), - data.get("tcgplayer_id", ""), - data.get("category", "[]"), - card_id, - ), - ) - conn.commit() - enriched_count += 1 - - return { - "total": total, - "enrichedCount": enriched_count, - "skippedCount": skipped_count, - "errors": errors[:50], # cap error list - } - - -# ══════════════════════════════════════════════════════════════ -# Auto-Classify Collection -# ══════════════════════════════════════════════════════════════ - -@app.post("/api/collection/auto-classify") -async def auto_classify_collection(): - """ - Run auto-classification on all collection entries. - Uses oracle_text, type_line, and keywords to detect functional roles - (Ramp, Draw, Removal, Board Wipe, Anthem, Stax, etc.). - Only updates cards whose current category is empty or '[]'. - """ - conn = _get_db_conn() - rows = conn.execute( - "SELECT id, oracle_text, type_line, keywords, category FROM collection_entries ORDER BY id" - ).fetchall() - - total = len(rows) - classified_count = 0 - skipped_count = 0 - - for row in rows: - card_id = row["id"] - existing_cats = row["category"] or "[]" - - # Parse existing categories - try: - cats = json.loads(existing_cats) - except Exception: - cats = [] - - # Skip cards that already have manually-set categories - if cats: - skipped_count += 1 - continue - - oracle_text = row["oracle_text"] or "" - type_line = row["type_line"] or "" - keywords = row["keywords"] or "[]" - - roles = _detect_card_roles(oracle_text, type_line, keywords) - - if roles: - conn.execute( - "UPDATE collection_entries SET category = ?, updated_at = datetime('now') WHERE id = ?", - (json.dumps(roles), card_id), - ) - classified_count += 1 - - conn.commit() - - return { - "total": total, - "classifiedCount": classified_count, - "skippedCount": skipped_count, - "message": f"Classified {classified_count} cards, skipped {skipped_count} (already had categories)", - } - - -@app.post("/api/collection/auto-classify-all") -async def auto_classify_all_collection(): - """ - Force re-classify ALL collection entries, overwriting existing categories. - """ - conn = _get_db_conn() - rows = conn.execute( - "SELECT id, oracle_text, type_line, keywords FROM collection_entries ORDER BY id" - ).fetchall() - - total = len(rows) - classified_count = 0 - - for row in rows: - card_id = row["id"] - oracle_text = row["oracle_text"] or "" - type_line = row["type_line"] or "" - keywords = row["keywords"] or "[]" - - roles = _detect_card_roles(oracle_text, type_line, keywords) - conn.execute( - "UPDATE collection_entries SET category = ?, updated_at = datetime('now') WHERE id = ?", - (json.dumps(roles), card_id), - ) - classified_count += 1 - - conn.commit() - - return { - "total": total, - "classifiedCount": classified_count, - "message": f"Re-classified all {classified_count} cards", - } - - -# ══════════════════════════════════════════════════════════════ -# EDHREC Recommendations Cache + Endpoint -# ══════════════════════════════════════════════════════════════ - -# Simple in-memory cache: {cache_key: {"data": ..., "expires": float}} -_edhrec_cache: dict = {} -_EDHREC_TTL = 3600 # 1 hour - - -def _edhrec_cache_get(key: str): - entry = _edhrec_cache.get(key) - if entry and time.monotonic() < entry["expires"]: - return entry["data"] - return None - - -def _edhrec_cache_set(key: str, data): - _edhrec_cache[key] = {"data": data, "expires": time.monotonic() + _EDHREC_TTL} - - -@app.get("/api/collection/{cardId}/edhrec") -async def get_card_edhrec(cardId: int): - """ - Get EDHREC recommendations for a collection card. - For legendary creatures (potential commanders): fetch commander synergy data. - For other cards: fetch 'also played with' data. - """ - conn = _get_db_conn() - row = conn.execute("SELECT * FROM collection_entries WHERE id = ?", (cardId,)).fetchone() - if not row: - raise HTTPException(404, f"Card with id {cardId} not found") - - card = _row_to_dict(row) - name = card["name"] - type_line = card.get("type_line", "") - is_legendary = card.get("is_legendary", 0) - - # Determine if it's a legendary creature (potential commander) - is_commander = bool( - is_legendary and - re.search(r"\bCreature\b", type_line, re.IGNORECASE) - ) - - slug = _to_edhrec_slug(name) - cache_key = f"edhrec:{'cmd' if is_commander else 'card'}:{slug}" - - cached = _edhrec_cache_get(cache_key) - if cached: - return cached - - recommendations = [] - links = { - "edhrecPage": ( - f"https://edhrec.com/commanders/{slug}" - if is_commander - else f"https://edhrec.com/cards/{slug}" - ), - "archidektSearch": f"https://archidekt.com/search/cards?q={name.replace(' ', '+')}", - "moxfieldSearch": f"https://www.moxfield.com/search?q={name.replace(' ', '+')}", - } - - try: - if is_commander: - url = f"https://json.edhrec.com/pages/commanders/{slug}.json" - else: - url = f"https://json.edhrec.com/pages/cards/{slug}.json" - - req = Request(url, headers=_API_HEADERS) - with urlopen(req, timeout=10) as resp: - data = json.loads(resp.read().decode("utf-8")) - - if is_commander: - # Extract top cards from cardlists - container = data.get("container", {}) - json_dict = container.get("json_dict", {}) - for cardlist in json_dict.get("cardlists", []): - tag = cardlist.get("tag", "") - for cv in cardlist.get("cardviews", [])[:10]: - rec_name = cv.get("name", "") - if not rec_name: - continue - synergy = cv.get("synergy", 0.0) - recommendations.append({ - "name": rec_name, - "synergy": synergy, - "role": tag or "Recommended", - }) - else: - # Extract "also played with" cards - container = data.get("container", {}) - json_dict = container.get("json_dict", {}) - for cardlist in json_dict.get("cardlists", []): - tag = cardlist.get("tag", "") - for cv in cardlist.get("cardviews", [])[:10]: - rec_name = cv.get("name", "") - if not rec_name: - continue - synergy = cv.get("synergy", 0.0) - recommendations.append({ - "name": rec_name, - "synergy": synergy, - "role": tag or "Also Played With", - }) - - except Exception as e: - log_deckgen.error(f" Error fetching data for '{name}': {e}") - # Graceful degradation — return empty results - - result = { - "recommendations": recommendations[:30], - "links": links, - } - _edhrec_cache_set(cache_key, result) - return result - - -# ══════════════════════════════════════════════════════════════ -# Python Simulator API -# ══════════════════════════════════════════════════════════════ - -import threading as _threading -import uuid as _uuid - -# In-memory store for simulation runs -_sim_runs = {} # sim_id -> { status, result, error } -_sim_lock = _threading.Lock() - - -def _run_sim_thread_v2(sim_id: str, card_data: list[dict], num_games: int, deck_name: str, record_logs: bool): - """Background thread for simulations with full card data from the DB.""" - try: - import sys, os - src_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'src') - if src_dir not in sys.path: - sys.path.insert(0, src_dir) - - from commander_ai_lab.sim.models import Card - from commander_ai_lab.sim.engine import GameEngine - from commander_ai_lab.sim.rules import enrich_card - from commander_ai_lab.lab.experiments import _generate_training_deck - - with _sim_lock: - _sim_runs[sim_id]['status'] = 'running' - - # Build deck with real card data from DB - deck_a = [] - for cd in card_data: - c = Card(name=cd['name']) - if cd.get('type_line'): - c.type_line = cd['type_line'] - if cd.get('cmc'): - c.cmc = float(cd['cmc']) - if cd.get('power') and cd.get('toughness'): - c.power = str(cd['power']) - c.toughness = str(cd['toughness']) - c.pt = c.power + '/' + c.toughness - if cd.get('oracle_text'): - c.oracle_text = cd['oracle_text'] - if cd.get('mana_cost'): - c.mana_cost = cd['mana_cost'] - if cd.get('keywords'): - kw = cd['keywords'] - if isinstance(kw, str): - try: - import json as _json - kw = _json.loads(kw) - except Exception: - kw = [] - if isinstance(kw, list): - c.keywords = kw - # Enrich fills in flags like is_removal, is_ramp, is_board_wipe - enrich_card(c) - deck_a.append(c) - - deck_b = _generate_training_deck() - - engine = GameEngine(max_turns=25, record_log=record_logs) - import time - start = time.time() - - wins = 0 - losses = 0 - total_turns = 0 - total_damage_dealt = 0 - total_damage_received = 0 - total_spells_cast = 0 - total_creatures_played = 0 - total_removal_used = 0 - total_ramp_played = 0 - total_cards_drawn = 0 - total_max_board = 0 - game_results = [] - - for i in range(num_games): - result = engine.run(deck_a, deck_b, name_a=deck_name, name_b="Training Deck") - - game_data = result.to_dict() - game_data['gameNumber'] = i + 1 - game_results.append(game_data) - - if result.winner == 0: - wins += 1 - else: - losses += 1 - - total_turns += result.turns - if result.player_a_stats: - total_damage_dealt += result.player_a_stats.damage_dealt - total_damage_received += result.player_a_stats.damage_received - total_spells_cast += result.player_a_stats.spells_cast - total_creatures_played += result.player_a_stats.creatures_played - total_removal_used += result.player_a_stats.removal_used - total_ramp_played += result.player_a_stats.ramp_played - total_cards_drawn += result.player_a_stats.cards_drawn - total_max_board += result.player_a_stats.max_board_size - - with _sim_lock: - _sim_runs[sim_id]['completed'] = i + 1 - - elapsed = time.time() - start - n = num_games - - summary = { - 'deckName': deck_name, - 'opponentName': 'Training Deck', - 'totalGames': n, - 'wins': wins, - 'losses': losses, - 'winRate': round(wins / n * 100, 1) if n > 0 else 0.0, - 'avgTurns': round(total_turns / n, 1) if n > 0 else 0.0, - 'avgDamageDealt': round(total_damage_dealt / n, 1) if n > 0 else 0.0, - 'avgDamageReceived': round(total_damage_received / n, 1) if n > 0 else 0.0, - 'avgSpellsCast': round(total_spells_cast / n, 1) if n > 0 else 0.0, - 'avgCreaturesPlayed': round(total_creatures_played / n, 1) if n > 0 else 0.0, - 'avgRemovalUsed': round(total_removal_used / n, 1) if n > 0 else 0.0, - 'avgRampPlayed': round(total_ramp_played / n, 1) if n > 0 else 0.0, - 'avgCardsDrawn': round(total_cards_drawn / n, 1) if n > 0 else 0.0, - 'avgMaxBoardSize': round(total_max_board / n, 1) if n > 0 else 0.0, - 'elapsedSeconds': round(elapsed, 3), - } - - with _sim_lock: - _sim_runs[sim_id]['status'] = 'complete' - _sim_runs[sim_id]['result'] = { - 'summary': summary, - 'games': game_results, - } - except Exception as e: - import traceback - with _sim_lock: - _sim_runs[sim_id]['status'] = 'error' - _sim_runs[sim_id]['error'] = str(e) - traceback.print_exc() - - -def _run_sim_thread(sim_id: str, decklist: list, num_games: int, deck_name: str, record_logs: bool): - """Background thread for running Monte Carlo simulations.""" - try: - import sys, os - # Add src/ to path if needed for the simulator package - src_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'src') - if src_dir not in sys.path: - sys.path.insert(0, src_dir) - - from commander_ai_lab.sim.models import Card - from commander_ai_lab.sim.engine import GameEngine - from commander_ai_lab.sim.rules import enrich_card, parse_decklist - from commander_ai_lab.lab.experiments import build_deck, _generate_training_deck - - with _sim_lock: - _sim_runs[sim_id]['status'] = 'running' - - deck_a = build_deck(decklist) - deck_b = _generate_training_deck() - - engine = GameEngine(max_turns=25, record_log=record_logs) - import time - start = time.time() - - wins = 0 - losses = 0 - total_turns = 0 - total_damage_dealt = 0 - total_damage_received = 0 - total_spells_cast = 0 - total_creatures_played = 0 - total_removal_used = 0 - total_ramp_played = 0 - total_cards_drawn = 0 - total_max_board = 0 - game_results = [] - - for i in range(num_games): - result = engine.run(deck_a, deck_b, name_a=deck_name, name_b="Training Deck") - - game_data = result.to_dict() - game_data['gameNumber'] = i + 1 - game_results.append(game_data) - - if result.winner == 0: - wins += 1 - else: - losses += 1 - - total_turns += result.turns - if result.player_a_stats: - total_damage_dealt += result.player_a_stats.damage_dealt - total_damage_received += result.player_a_stats.damage_received - total_spells_cast += result.player_a_stats.spells_cast - total_creatures_played += result.player_a_stats.creatures_played - total_removal_used += result.player_a_stats.removal_used - total_ramp_played += result.player_a_stats.ramp_played - total_cards_drawn += result.player_a_stats.cards_drawn - total_max_board += result.player_a_stats.max_board_size - - # Update progress - with _sim_lock: - _sim_runs[sim_id]['completed'] = i + 1 - - elapsed = time.time() - start - n = num_games - - summary = { - 'deckName': deck_name, - 'opponentName': 'Training Deck', - 'totalGames': n, - 'wins': wins, - 'losses': losses, - 'winRate': round(wins / n * 100, 1) if n > 0 else 0.0, - 'avgTurns': round(total_turns / n, 1) if n > 0 else 0.0, - 'avgDamageDealt': round(total_damage_dealt / n, 1) if n > 0 else 0.0, - 'avgDamageReceived': round(total_damage_received / n, 1) if n > 0 else 0.0, - 'avgSpellsCast': round(total_spells_cast / n, 1) if n > 0 else 0.0, - 'avgCreaturesPlayed': round(total_creatures_played / n, 1) if n > 0 else 0.0, - 'avgRemovalUsed': round(total_removal_used / n, 1) if n > 0 else 0.0, - 'avgRampPlayed': round(total_ramp_played / n, 1) if n > 0 else 0.0, - 'avgCardsDrawn': round(total_cards_drawn / n, 1) if n > 0 else 0.0, - 'avgMaxBoardSize': round(total_max_board / n, 1) if n > 0 else 0.0, - 'elapsedSeconds': round(elapsed, 3), - } - - with _sim_lock: - _sim_runs[sim_id]['status'] = 'complete' - _sim_runs[sim_id]['result'] = { - 'summary': summary, - 'games': game_results, - } - except Exception as e: - import traceback - with _sim_lock: - _sim_runs[sim_id]['status'] = 'error' - _sim_runs[sim_id]['error'] = str(e) - traceback.print_exc() - - -@app.post('/api/sim/run') -async def sim_run(request: FastAPIRequest): - """Start a Monte Carlo simulation with the Python engine.""" - body = await request.json() - decklist = body.get('decklist', []) - num_games = body.get('numGames', 10) - deck_name = body.get('deckName', 'My Deck') - record_logs = body.get('recordLogs', True) - - if not decklist: - return JSONResponse({'error': 'decklist is required'}, status_code=400) - if num_games < 1 or num_games > 1000: - return JSONResponse({'error': 'numGames must be 1-1000'}, status_code=400) - - sim_id = str(_uuid.uuid4())[:8] - with _sim_lock: - _sim_runs[sim_id] = { - 'status': 'queued', - 'completed': 0, - 'total': num_games, - 'deckName': deck_name, - 'result': None, - 'error': None, - } - - t = _threading.Thread(target=_run_sim_thread, args=(sim_id, decklist, num_games, deck_name, record_logs), daemon=True) - t.start() - - return JSONResponse({'simId': sim_id, 'status': 'queued', 'total': num_games}) - - -@app.get('/api/sim/status') -async def sim_status(simId: str): - """Poll simulation progress.""" - with _sim_lock: - run = _sim_runs.get(simId) - if not run: - return JSONResponse({'error': 'sim not found'}, status_code=404) - return JSONResponse({ - 'simId': simId, - 'status': run['status'], - 'completed': run['completed'], - 'total': run['total'], - 'deckName': run.get('deckName', ''), - 'error': run.get('error'), - }) - - -@app.get('/api/sim/result') -async def sim_result(simId: str): - """Get completed simulation results.""" - with _sim_lock: - run = _sim_runs.get(simId) - if not run: - return JSONResponse({'error': 'sim not found'}, status_code=404) - if run['status'] != 'complete': - return JSONResponse({'error': 'sim not complete', 'status': run['status']}, status_code=400) - return JSONResponse(run['result']) - - -@app.post('/api/sim/run-from-deck') -async def sim_run_from_deck(request: FastAPIRequest): - """Start simulation using a deck from the Deck Builder (by deck ID).""" - body = await request.json() - deck_id = body.get('deckId') - num_games = body.get('numGames', 10) - record_logs = body.get('recordLogs', True) - - if not deck_id: - return JSONResponse({'error': 'deckId required'}, status_code=400) - - conn = _get_db_conn() - cur = conn.cursor() - cur.execute('SELECT name FROM decks WHERE id = ?', (deck_id,)) - row = cur.fetchone() - if not row: - return JSONResponse({'error': 'deck not found'}, status_code=404) - deck_name = row[0] - - # Pull full card data from collection join so the sim engine has real types/stats - cur.execute(""" - SELECT dc.card_name, dc.quantity, - ce.type_line, ce.cmc, ce.power, ce.toughness, - ce.oracle_text, ce.keywords, ce.mana_cost - FROM deck_cards dc - LEFT JOIN ( - SELECT scryfall_id, type_line, cmc, power, toughness, - oracle_text, keywords, mana_cost - FROM collection_entries GROUP BY scryfall_id - ) ce ON ce.scryfall_id = dc.scryfall_id - WHERE dc.deck_id = ? - """, (deck_id,)) - card_data = [] - for r in cur.fetchall(): - for _ in range(r[1] or 1): - card_data.append({ - 'name': r[0], - 'type_line': r[2] or '', - 'cmc': r[3] or 0, - 'power': r[4] or '', - 'toughness': r[5] or '', - 'oracle_text': r[6] or '', - 'keywords': r[7] or '', - 'mana_cost': r[8] or '', - }) - if not card_data: - return JSONResponse({'error': 'deck has no cards'}, status_code=400) - - sim_id = str(_uuid.uuid4())[:8] - with _sim_lock: - _sim_runs[sim_id] = { - 'status': 'queued', - 'completed': 0, - 'total': num_games, - 'deckName': deck_name, - 'result': None, - 'error': None, - } - - t = _threading.Thread(target=_run_sim_thread_v2, args=(sim_id, card_data, num_games, deck_name, record_logs), daemon=True) - t.start() - - return JSONResponse({'simId': sim_id, 'status': 'queued', 'total': num_games, 'deckName': deck_name}) - - -# ══════════════════════════════════════════════════════════════ -# DeepSeek AI Opponent Brain -# ══════════════════════════════════════════════════════════════ - -# Global DeepSeek brain instance (lazy-initialized) -_deepseek_brain = None -_deepseek_lock = _threading.Lock() - -def _get_deepseek_brain(): - """Get or create the global DeepSeek brain instance.""" - global _deepseek_brain - if _deepseek_brain is None: - with _deepseek_lock: - if _deepseek_brain is None: - try: - import sys as _sys2, os as _os2 - src_dir = _os2.path.join(_os2.path.dirname(_os2.path.abspath(__file__)), 'src') - if src_dir not in _sys2.path: - _sys2.path.insert(0, src_dir) - from commander_ai_lab.sim.deepseek_brain import DeepSeekBrain, DeepSeekConfig - cfg = DeepSeekConfig() - # Allow env var overrides - if _os2.environ.get('DEEPSEEK_API_BASE'): - cfg.api_base = _os2.environ['DEEPSEEK_API_BASE'] - if _os2.environ.get('DEEPSEEK_MODEL'): - cfg.model = _os2.environ['DEEPSEEK_MODEL'] - cfg.log_dir = _os2.path.join(_os2.path.dirname(_os2.path.abspath(__file__)), 'logs', 'decisions') - _deepseek_brain = DeepSeekBrain(cfg) - except Exception as e: - log_sim.error(f'Failed to initialize brain: {e}') - return None - return _deepseek_brain - - -@app.post('/api/deepseek/connect') -async def deepseek_connect(request: FastAPIRequest): - """Test connection to the DeepSeek LLM endpoint and auto-detect model.""" - body = await request.json() if await request.body() else {} - api_base = body.get('apiBase', None) - model = body.get('model', None) - - brain = _get_deepseek_brain() - if brain is None: - return JSONResponse({'error': 'DeepSeek brain failed to initialize'}, status_code=500) - - # Allow runtime reconfiguration - if api_base: - brain.config.api_base = api_base - if model: - brain.config.model = model - - connected = brain.check_connection() - return JSONResponse({ - 'connected': connected, - 'apiBase': brain.config.api_base, - 'model': brain.config.model, - 'stats': brain.get_stats(), - }) - - -@app.get('/api/deepseek/status') -async def deepseek_status(): - """Get DeepSeek brain status and performance stats.""" - brain = _get_deepseek_brain() - if brain is None: - return JSONResponse({'connected': False, 'error': 'Brain not initialized'}) - return JSONResponse(brain.get_stats()) - - -@app.post('/api/deepseek/configure') -async def deepseek_configure(request: FastAPIRequest): - """Update DeepSeek configuration at runtime.""" - body = await request.json() - brain = _get_deepseek_brain() - if brain is None: - return JSONResponse({'error': 'Brain not initialized'}, status_code=500) - - if 'apiBase' in body: - brain.config.api_base = body['apiBase'] - if 'model' in body: - brain.config.model = body['model'] - if 'temperature' in body: - brain.config.temperature = float(body['temperature']) - if 'maxTokens' in body: - brain.config.max_tokens = int(body['maxTokens']) - if 'timeout' in body: - brain.config.request_timeout = float(body['timeout']) - if 'cacheEnabled' in body: - brain.config.cache_enabled = bool(body['cacheEnabled']) - if 'logDecisions' in body: - brain.config.log_decisions = bool(body['logDecisions']) - if 'fallbackOnTimeout' in body: - brain.config.fallback_on_timeout = bool(body['fallbackOnTimeout']) - - # Re-test connection with new settings - connected = brain.check_connection() - - return JSONResponse({ - 'connected': connected, - 'config': { - 'apiBase': brain.config.api_base, - 'model': brain.config.model, - 'temperature': brain.config.temperature, - 'maxTokens': brain.config.max_tokens, - 'timeout': brain.config.request_timeout, - 'cacheEnabled': brain.config.cache_enabled, - 'logDecisions': brain.config.log_decisions, - 'fallbackOnTimeout': brain.config.fallback_on_timeout, - }, - }) - - -@app.get('/api/deepseek/logs') -async def deepseek_logs(): - """Get decision log stats and flush pending entries.""" - brain = _get_deepseek_brain() - if brain is None: - return JSONResponse({'error': 'Brain not initialized'}, status_code=500) - - pending = len(brain._decision_log) - flushed_path = None - if pending > 0: - try: - flushed_path = brain.flush_log() - except Exception as e: - return JSONResponse({'error': f'Failed to flush: {e}'}, status_code=500) - - # List existing log files - import glob as _glob - log_dir = brain.config.log_dir - log_files = [] - if log_dir and os.path.isdir(log_dir): - for f in sorted(_glob.glob(os.path.join(log_dir, 'decisions_*.jsonl'))): - stat = os.stat(f) - log_files.append({ - 'filename': os.path.basename(f), - 'size_bytes': stat.st_size, - 'modified': stat.st_mtime, - }) - - return JSONResponse({ - 'flushed': pending, - 'flushedPath': flushed_path, - 'logFiles': log_files[-20:], # last 20 - }) - - -def _run_sim_thread_deepseek(sim_id: str, card_data: list[dict], num_games: int, deck_name: str, record_logs: bool): - """Background thread for simulations using DeepSeek AI opponent.""" - try: - import sys, os - src_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'src') - if src_dir not in sys.path: - sys.path.insert(0, src_dir) - - from commander_ai_lab.sim.models import Card - from commander_ai_lab.sim.deepseek_engine import DeepSeekGameEngine - from commander_ai_lab.sim.rules import enrich_card - from commander_ai_lab.lab.experiments import _generate_training_deck - - with _sim_lock: - _sim_runs[sim_id]['status'] = 'running' - - # Build player's deck with real card data - deck_a = [] - for cd in card_data: - c = Card(name=cd['name']) - if cd.get('type_line'): - c.type_line = cd['type_line'] - if cd.get('cmc'): - c.cmc = float(cd['cmc']) - if cd.get('power') and cd.get('toughness'): - c.power = str(cd['power']) - c.toughness = str(cd['toughness']) - c.pt = c.power + '/' + c.toughness - if cd.get('oracle_text'): - c.oracle_text = cd['oracle_text'] - if cd.get('mana_cost'): - c.mana_cost = cd['mana_cost'] - if cd.get('keywords'): - kw = cd['keywords'] - if isinstance(kw, str): - try: - import json as _json - kw = _json.loads(kw) - except Exception: - kw = [] - if isinstance(kw, list): - c.keywords = kw - enrich_card(c) - deck_a.append(c) - - deck_b = _generate_training_deck() - - # Get DeepSeek brain - brain = _get_deepseek_brain() - if brain and not brain._connected: - brain.check_connection() - - engine = DeepSeekGameEngine( - brain=brain, - ai_player_index=1, # Training deck is player B (index 1) - max_turns=25, - record_log=record_logs, - ) - - import time - start = time.time() - - wins = 0 - losses = 0 - total_turns = 0 - total_damage_dealt = 0 - total_damage_received = 0 - total_spells_cast = 0 - total_creatures_played = 0 - total_removal_used = 0 - total_ramp_played = 0 - total_cards_drawn = 0 - total_max_board = 0 - game_results = [] - - for i in range(num_games): - game_id = f'ds-sim-{sim_id[:8]}-g{i+1}' - result = engine.run(deck_a, deck_b, name_a=deck_name, name_b='DeepSeek AI', - game_id=game_id, archetype='midrange') - - game_data = result.to_dict() - game_data['gameNumber'] = i + 1 - game_results.append(game_data) - - if result.winner == 0: - wins += 1 - else: - losses += 1 - - total_turns += result.turns - if result.player_a_stats: - total_damage_dealt += result.player_a_stats.damage_dealt - total_damage_received += result.player_a_stats.damage_received - total_spells_cast += result.player_a_stats.spells_cast - total_creatures_played += result.player_a_stats.creatures_played - total_removal_used += result.player_a_stats.removal_used - total_ramp_played += result.player_a_stats.ramp_played - total_cards_drawn += result.player_a_stats.cards_drawn - total_max_board += result.player_a_stats.max_board_size - - with _sim_lock: - _sim_runs[sim_id]['completed'] = i + 1 - - elapsed = time.time() - start - n = num_games - - # Write ML decision JSONL for training pipeline - ml_decisions = engine.flush_ml_decisions() - if ml_decisions: - ml_jsonl_path = os.path.join('results', f'ml-decisions-sim-{sim_id[:8]}.jsonl') - os.makedirs('results', exist_ok=True) - with open(ml_jsonl_path, 'w', encoding='utf-8') as mf: - import json as _mljson - for dec in ml_decisions: - mf.write(_mljson.dumps(dec) + '\n') - - # Get DeepSeek stats for the summary - ds_stats = brain.get_stats() if brain else {} - - summary = { - 'deckName': deck_name, - 'opponentName': 'DeepSeek AI', - 'opponentType': 'deepseek', - 'totalGames': n, - 'wins': wins, - 'losses': losses, - 'winRate': round(wins / n * 100, 1) if n > 0 else 0.0, - 'avgTurns': round(total_turns / n, 1) if n > 0 else 0.0, - 'avgDamageDealt': round(total_damage_dealt / n, 1) if n > 0 else 0.0, - 'avgDamageReceived': round(total_damage_received / n, 1) if n > 0 else 0.0, - 'avgSpellsCast': round(total_spells_cast / n, 1) if n > 0 else 0.0, - 'avgCreaturesPlayed': round(total_creatures_played / n, 1) if n > 0 else 0.0, - 'avgRemovalUsed': round(total_removal_used / n, 1) if n > 0 else 0.0, - 'avgRampPlayed': round(total_ramp_played / n, 1) if n > 0 else 0.0, - 'avgCardsDrawn': round(total_cards_drawn / n, 1) if n > 0 else 0.0, - 'avgMaxBoardSize': round(total_max_board / n, 1) if n > 0 else 0.0, - 'elapsedSeconds': round(elapsed, 3), - 'deepseekStats': ds_stats, - } - - with _sim_lock: - _sim_runs[sim_id]['status'] = 'complete' - _sim_runs[sim_id]['result'] = { - 'summary': summary, - 'games': game_results, - } - except Exception as e: - import traceback - with _sim_lock: - _sim_runs[sim_id]['status'] = 'error' - _sim_runs[sim_id]['error'] = str(e) - traceback.print_exc() - - -@app.post('/api/sim/run-deepseek') -async def sim_run_deepseek(request: FastAPIRequest): - """Start simulation using DeepSeek AI as the opponent brain.""" - body = await request.json() - deck_id = body.get('deckId') - num_games = body.get('numGames', 5) # Default 5 (LLM is slower) - record_logs = body.get('recordLogs', True) - - if not deck_id: - return JSONResponse({'error': 'deckId required'}, status_code=400) - if num_games < 1 or num_games > 50: - return JSONResponse({'error': 'numGames must be 1-50 for DeepSeek mode'}, status_code=400) - - conn = _get_db_conn() - cur = conn.cursor() - cur.execute('SELECT name FROM decks WHERE id = ?', (deck_id,)) - row = cur.fetchone() - if not row: - return JSONResponse({'error': 'deck not found'}, status_code=404) - deck_name = row[0] - - cur.execute(""" - SELECT dc.card_name, dc.quantity, - ce.type_line, ce.cmc, ce.power, ce.toughness, - ce.oracle_text, ce.keywords, ce.mana_cost - FROM deck_cards dc - LEFT JOIN ( - SELECT scryfall_id, type_line, cmc, power, toughness, - oracle_text, keywords, mana_cost - FROM collection_entries GROUP BY scryfall_id - ) ce ON ce.scryfall_id = dc.scryfall_id - WHERE dc.deck_id = ? - """, (deck_id,)) - card_data = [] - for r in cur.fetchall(): - for _ in range(r[1] or 1): - card_data.append({ - 'name': r[0], - 'type_line': r[2] or '', - 'cmc': r[3] or 0, - 'power': r[4] or '', - 'toughness': r[5] or '', - 'oracle_text': r[6] or '', - 'keywords': r[7] or '', - 'mana_cost': r[8] or '', - }) - if not card_data: - return JSONResponse({'error': 'deck has no cards'}, status_code=400) - - sim_id = str(_uuid.uuid4())[:8] - with _sim_lock: - _sim_runs[sim_id] = { - 'status': 'queued', - 'completed': 0, - 'total': num_games, - 'deckName': deck_name, - 'result': None, - 'error': None, - } - - t = _threading.Thread( - target=_run_sim_thread_deepseek, - args=(sim_id, card_data, num_games, deck_name, record_logs), - daemon=True, - ) - t.start() - - return JSONResponse({ - 'simId': sim_id, - 'status': 'queued', - 'total': num_games, - 'deckName': deck_name, - 'opponentType': 'deepseek', - }) - - # ══════════════════════════════════════════════════════════════ # Auto Deck Generator # ══════════════════════════════════════════════════════════════ diff --git a/routes/deepseek.py b/routes/deepseek.py new file mode 100644 index 0000000..2ce9a29 --- /dev/null +++ b/routes/deepseek.py @@ -0,0 +1,769 @@ +""" +routes/deepseek.py +================== +Python simulator & DeepSeek AI opponent endpoints: + POST /api/sim/run + GET /api/sim/status + GET /api/sim/result + POST /api/sim/run-from-deck + POST /api/sim/run-deepseek + POST /api/deepseek/connect + GET /api/deepseek/status + POST /api/deepseek/configure + GET /api/deepseek/logs +""" +from __future__ import annotations + +import json +import os +import threading as _threading +import uuid as _uuid +from pathlib import Path + +from fastapi import APIRouter, HTTPException +from fastapi import Request as FastAPIRequest +from fastapi.responses import JSONResponse + +from routes.shared import ( + CFG, log_sim, + _get_db_conn, + _ml_logging_enabled, +) + +router = APIRouter(tags=["deepseek"]) + + +# In-memory store for simulation runs +_sim_runs = {} # sim_id -> { status, result, error } +_sim_lock = _threading.Lock() + + +def _run_sim_thread_v2(sim_id: str, card_data: list[dict], num_games: int, deck_name: str, record_logs: bool): + """Background thread for simulations with full card data from the DB.""" + try: + import sys, os + src_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'src') + if src_dir not in sys.path: + sys.path.insert(0, src_dir) + + from commander_ai_lab.sim.models import Card + from commander_ai_lab.sim.engine import GameEngine + from commander_ai_lab.sim.rules import enrich_card + from commander_ai_lab.lab.experiments import _generate_training_deck + + with _sim_lock: + _sim_runs[sim_id]['status'] = 'running' + + # Build deck with real card data from DB + deck_a = [] + for cd in card_data: + c = Card(name=cd['name']) + if cd.get('type_line'): + c.type_line = cd['type_line'] + if cd.get('cmc'): + c.cmc = float(cd['cmc']) + if cd.get('power') and cd.get('toughness'): + c.power = str(cd['power']) + c.toughness = str(cd['toughness']) + c.pt = c.power + '/' + c.toughness + if cd.get('oracle_text'): + c.oracle_text = cd['oracle_text'] + if cd.get('mana_cost'): + c.mana_cost = cd['mana_cost'] + if cd.get('keywords'): + kw = cd['keywords'] + if isinstance(kw, str): + try: + import json as _json + kw = _json.loads(kw) + except Exception: + kw = [] + if isinstance(kw, list): + c.keywords = kw + # Enrich fills in flags like is_removal, is_ramp, is_board_wipe + enrich_card(c) + deck_a.append(c) + + deck_b = _generate_training_deck() + + engine = GameEngine(max_turns=25, record_log=record_logs) + import time + start = time.time() + + wins = 0 + losses = 0 + total_turns = 0 + total_damage_dealt = 0 + total_damage_received = 0 + total_spells_cast = 0 + total_creatures_played = 0 + total_removal_used = 0 + total_ramp_played = 0 + total_cards_drawn = 0 + total_max_board = 0 + game_results = [] + + for i in range(num_games): + result = engine.run(deck_a, deck_b, name_a=deck_name, name_b="Training Deck") + + game_data = result.to_dict() + game_data['gameNumber'] = i + 1 + game_results.append(game_data) + + if result.winner == 0: + wins += 1 + else: + losses += 1 + + total_turns += result.turns + if result.player_a_stats: + total_damage_dealt += result.player_a_stats.damage_dealt + total_damage_received += result.player_a_stats.damage_received + total_spells_cast += result.player_a_stats.spells_cast + total_creatures_played += result.player_a_stats.creatures_played + total_removal_used += result.player_a_stats.removal_used + total_ramp_played += result.player_a_stats.ramp_played + total_cards_drawn += result.player_a_stats.cards_drawn + total_max_board += result.player_a_stats.max_board_size + + with _sim_lock: + _sim_runs[sim_id]['completed'] = i + 1 + + elapsed = time.time() - start + n = num_games + + summary = { + 'deckName': deck_name, + 'opponentName': 'Training Deck', + 'totalGames': n, + 'wins': wins, + 'losses': losses, + 'winRate': round(wins / n * 100, 1) if n > 0 else 0.0, + 'avgTurns': round(total_turns / n, 1) if n > 0 else 0.0, + 'avgDamageDealt': round(total_damage_dealt / n, 1) if n > 0 else 0.0, + 'avgDamageReceived': round(total_damage_received / n, 1) if n > 0 else 0.0, + 'avgSpellsCast': round(total_spells_cast / n, 1) if n > 0 else 0.0, + 'avgCreaturesPlayed': round(total_creatures_played / n, 1) if n > 0 else 0.0, + 'avgRemovalUsed': round(total_removal_used / n, 1) if n > 0 else 0.0, + 'avgRampPlayed': round(total_ramp_played / n, 1) if n > 0 else 0.0, + 'avgCardsDrawn': round(total_cards_drawn / n, 1) if n > 0 else 0.0, + 'avgMaxBoardSize': round(total_max_board / n, 1) if n > 0 else 0.0, + 'elapsedSeconds': round(elapsed, 3), + } + + with _sim_lock: + _sim_runs[sim_id]['status'] = 'complete' + _sim_runs[sim_id]['result'] = { + 'summary': summary, + 'games': game_results, + } + except Exception as e: + import traceback + with _sim_lock: + _sim_runs[sim_id]['status'] = 'error' + _sim_runs[sim_id]['error'] = str(e) + traceback.print_exc() + + +def _run_sim_thread(sim_id: str, decklist: list, num_games: int, deck_name: str, record_logs: bool): + """Background thread for running Monte Carlo simulations.""" + try: + import sys, os + # Add src/ to path if needed for the simulator package + src_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'src') + if src_dir not in sys.path: + sys.path.insert(0, src_dir) + + from commander_ai_lab.sim.models import Card + from commander_ai_lab.sim.engine import GameEngine + from commander_ai_lab.sim.rules import enrich_card, parse_decklist + from commander_ai_lab.lab.experiments import build_deck, _generate_training_deck + + with _sim_lock: + _sim_runs[sim_id]['status'] = 'running' + + deck_a = build_deck(decklist) + deck_b = _generate_training_deck() + + engine = GameEngine(max_turns=25, record_log=record_logs) + import time + start = time.time() + + wins = 0 + losses = 0 + total_turns = 0 + total_damage_dealt = 0 + total_damage_received = 0 + total_spells_cast = 0 + total_creatures_played = 0 + total_removal_used = 0 + total_ramp_played = 0 + total_cards_drawn = 0 + total_max_board = 0 + game_results = [] + + for i in range(num_games): + result = engine.run(deck_a, deck_b, name_a=deck_name, name_b="Training Deck") + + game_data = result.to_dict() + game_data['gameNumber'] = i + 1 + game_results.append(game_data) + + if result.winner == 0: + wins += 1 + else: + losses += 1 + + total_turns += result.turns + if result.player_a_stats: + total_damage_dealt += result.player_a_stats.damage_dealt + total_damage_received += result.player_a_stats.damage_received + total_spells_cast += result.player_a_stats.spells_cast + total_creatures_played += result.player_a_stats.creatures_played + total_removal_used += result.player_a_stats.removal_used + total_ramp_played += result.player_a_stats.ramp_played + total_cards_drawn += result.player_a_stats.cards_drawn + total_max_board += result.player_a_stats.max_board_size + + # Update progress + with _sim_lock: + _sim_runs[sim_id]['completed'] = i + 1 + + elapsed = time.time() - start + n = num_games + + summary = { + 'deckName': deck_name, + 'opponentName': 'Training Deck', + 'totalGames': n, + 'wins': wins, + 'losses': losses, + 'winRate': round(wins / n * 100, 1) if n > 0 else 0.0, + 'avgTurns': round(total_turns / n, 1) if n > 0 else 0.0, + 'avgDamageDealt': round(total_damage_dealt / n, 1) if n > 0 else 0.0, + 'avgDamageReceived': round(total_damage_received / n, 1) if n > 0 else 0.0, + 'avgSpellsCast': round(total_spells_cast / n, 1) if n > 0 else 0.0, + 'avgCreaturesPlayed': round(total_creatures_played / n, 1) if n > 0 else 0.0, + 'avgRemovalUsed': round(total_removal_used / n, 1) if n > 0 else 0.0, + 'avgRampPlayed': round(total_ramp_played / n, 1) if n > 0 else 0.0, + 'avgCardsDrawn': round(total_cards_drawn / n, 1) if n > 0 else 0.0, + 'avgMaxBoardSize': round(total_max_board / n, 1) if n > 0 else 0.0, + 'elapsedSeconds': round(elapsed, 3), + } + + with _sim_lock: + _sim_runs[sim_id]['status'] = 'complete' + _sim_runs[sim_id]['result'] = { + 'summary': summary, + 'games': game_results, + } + except Exception as e: + import traceback + with _sim_lock: + _sim_runs[sim_id]['status'] = 'error' + _sim_runs[sim_id]['error'] = str(e) + traceback.print_exc() + + +@router.post('/api/sim/run') +async def sim_run(request: FastAPIRequest): + """Start a Monte Carlo simulation with the Python engine.""" + body = await request.json() + decklist = body.get('decklist', []) + num_games = body.get('numGames', 10) + deck_name = body.get('deckName', 'My Deck') + record_logs = body.get('recordLogs', True) + + if not decklist: + return JSONResponse({'error': 'decklist is required'}, status_code=400) + if num_games < 1 or num_games > 1000: + return JSONResponse({'error': 'numGames must be 1-1000'}, status_code=400) + + sim_id = str(_uuid.uuid4())[:8] + with _sim_lock: + _sim_runs[sim_id] = { + 'status': 'queued', + 'completed': 0, + 'total': num_games, + 'deckName': deck_name, + 'result': None, + 'error': None, + } + + t = _threading.Thread(target=_run_sim_thread, args=(sim_id, decklist, num_games, deck_name, record_logs), daemon=True) + t.start() + + return JSONResponse({'simId': sim_id, 'status': 'queued', 'total': num_games}) + + +@router.get('/api/sim/status') +async def sim_status(simId: str): + """Poll simulation progress.""" + with _sim_lock: + run = _sim_runs.get(simId) + if not run: + return JSONResponse({'error': 'sim not found'}, status_code=404) + return JSONResponse({ + 'simId': simId, + 'status': run['status'], + 'completed': run['completed'], + 'total': run['total'], + 'deckName': run.get('deckName', ''), + 'error': run.get('error'), + }) + + +@router.get('/api/sim/result') +async def sim_result(simId: str): + """Get completed simulation results.""" + with _sim_lock: + run = _sim_runs.get(simId) + if not run: + return JSONResponse({'error': 'sim not found'}, status_code=404) + if run['status'] != 'complete': + return JSONResponse({'error': 'sim not complete', 'status': run['status']}, status_code=400) + return JSONResponse(run['result']) + + +@router.post('/api/sim/run-from-deck') +async def sim_run_from_deck(request: FastAPIRequest): + """Start simulation using a deck from the Deck Builder (by deck ID).""" + body = await request.json() + deck_id = body.get('deckId') + num_games = body.get('numGames', 10) + record_logs = body.get('recordLogs', True) + + if not deck_id: + return JSONResponse({'error': 'deckId required'}, status_code=400) + + conn = _get_db_conn() + cur = conn.cursor() + cur.execute('SELECT name FROM decks WHERE id = ?', (deck_id,)) + row = cur.fetchone() + if not row: + return JSONResponse({'error': 'deck not found'}, status_code=404) + deck_name = row[0] + + # Pull full card data from collection join so the sim engine has real types/stats + cur.execute(""" + SELECT dc.card_name, dc.quantity, + ce.type_line, ce.cmc, ce.power, ce.toughness, + ce.oracle_text, ce.keywords, ce.mana_cost + FROM deck_cards dc + LEFT JOIN ( + SELECT scryfall_id, type_line, cmc, power, toughness, + oracle_text, keywords, mana_cost + FROM collection_entries GROUP BY scryfall_id + ) ce ON ce.scryfall_id = dc.scryfall_id + WHERE dc.deck_id = ? + """, (deck_id,)) + card_data = [] + for r in cur.fetchall(): + for _ in range(r[1] or 1): + card_data.append({ + 'name': r[0], + 'type_line': r[2] or '', + 'cmc': r[3] or 0, + 'power': r[4] or '', + 'toughness': r[5] or '', + 'oracle_text': r[6] or '', + 'keywords': r[7] or '', + 'mana_cost': r[8] or '', + }) + if not card_data: + return JSONResponse({'error': 'deck has no cards'}, status_code=400) + + sim_id = str(_uuid.uuid4())[:8] + with _sim_lock: + _sim_runs[sim_id] = { + 'status': 'queued', + 'completed': 0, + 'total': num_games, + 'deckName': deck_name, + 'result': None, + 'error': None, + } + + t = _threading.Thread(target=_run_sim_thread_v2, args=(sim_id, card_data, num_games, deck_name, record_logs), daemon=True) + t.start() + + return JSONResponse({'simId': sim_id, 'status': 'queued', 'total': num_games, 'deckName': deck_name}) + + +# ══════════════════════════════════════════════════════════════ +# DeepSeek AI Opponent Brain +# ══════════════════════════════════════════════════════════════ + +# Global DeepSeek brain instance (lazy-initialized) +_deepseek_brain = None +_deepseek_lock = _threading.Lock() + +def _get_deepseek_brain(): + """Get or create the global DeepSeek brain instance.""" + global _deepseek_brain + if _deepseek_brain is None: + with _deepseek_lock: + if _deepseek_brain is None: + try: + import sys as _sys2, os as _os2 + src_dir = _os2.path.join(_os2.path.dirname(_os2.path.abspath(__file__)), 'src') + if src_dir not in _sys2.path: + _sys2.path.insert(0, src_dir) + from commander_ai_lab.sim.deepseek_brain import DeepSeekBrain, DeepSeekConfig + cfg = DeepSeekConfig() + # Allow env var overrides + if _os2.environ.get('DEEPSEEK_API_BASE'): + cfg.api_base = _os2.environ['DEEPSEEK_API_BASE'] + if _os2.environ.get('DEEPSEEK_MODEL'): + cfg.model = _os2.environ['DEEPSEEK_MODEL'] + cfg.log_dir = _os2.path.join(_os2.path.dirname(_os2.path.abspath(__file__)), 'logs', 'decisions') + _deepseek_brain = DeepSeekBrain(cfg) + except Exception as e: + log_sim.error(f'Failed to initialize brain: {e}') + return None + return _deepseek_brain + + +@router.post('/api/deepseek/connect') +async def deepseek_connect(request: FastAPIRequest): + """Test connection to the DeepSeek LLM endpoint and auto-detect model.""" + body = await request.json() if await request.body() else {} + api_base = body.get('apiBase', None) + model = body.get('model', None) + + brain = _get_deepseek_brain() + if brain is None: + return JSONResponse({'error': 'DeepSeek brain failed to initialize'}, status_code=500) + + # Allow runtime reconfiguration + if api_base: + brain.config.api_base = api_base + if model: + brain.config.model = model + + connected = brain.check_connection() + return JSONResponse({ + 'connected': connected, + 'apiBase': brain.config.api_base, + 'model': brain.config.model, + 'stats': brain.get_stats(), + }) + + +@router.get('/api/deepseek/status') +async def deepseek_status(): + """Get DeepSeek brain status and performance stats.""" + brain = _get_deepseek_brain() + if brain is None: + return JSONResponse({'connected': False, 'error': 'Brain not initialized'}) + return JSONResponse(brain.get_stats()) + + +@router.post('/api/deepseek/configure') +async def deepseek_configure(request: FastAPIRequest): + """Update DeepSeek configuration at runtime.""" + body = await request.json() + brain = _get_deepseek_brain() + if brain is None: + return JSONResponse({'error': 'Brain not initialized'}, status_code=500) + + if 'apiBase' in body: + brain.config.api_base = body['apiBase'] + if 'model' in body: + brain.config.model = body['model'] + if 'temperature' in body: + brain.config.temperature = float(body['temperature']) + if 'maxTokens' in body: + brain.config.max_tokens = int(body['maxTokens']) + if 'timeout' in body: + brain.config.request_timeout = float(body['timeout']) + if 'cacheEnabled' in body: + brain.config.cache_enabled = bool(body['cacheEnabled']) + if 'logDecisions' in body: + brain.config.log_decisions = bool(body['logDecisions']) + if 'fallbackOnTimeout' in body: + brain.config.fallback_on_timeout = bool(body['fallbackOnTimeout']) + + # Re-test connection with new settings + connected = brain.check_connection() + + return JSONResponse({ + 'connected': connected, + 'config': { + 'apiBase': brain.config.api_base, + 'model': brain.config.model, + 'temperature': brain.config.temperature, + 'maxTokens': brain.config.max_tokens, + 'timeout': brain.config.request_timeout, + 'cacheEnabled': brain.config.cache_enabled, + 'logDecisions': brain.config.log_decisions, + 'fallbackOnTimeout': brain.config.fallback_on_timeout, + }, + }) + + +@router.get('/api/deepseek/logs') +async def deepseek_logs(): + """Get decision log stats and flush pending entries.""" + brain = _get_deepseek_brain() + if brain is None: + return JSONResponse({'error': 'Brain not initialized'}, status_code=500) + + pending = len(brain._decision_log) + flushed_path = None + if pending > 0: + try: + flushed_path = brain.flush_log() + except Exception as e: + return JSONResponse({'error': f'Failed to flush: {e}'}, status_code=500) + + # List existing log files + import glob as _glob + log_dir = brain.config.log_dir + log_files = [] + if log_dir and os.path.isdir(log_dir): + for f in sorted(_glob.glob(os.path.join(log_dir, 'decisions_*.jsonl'))): + stat = os.stat(f) + log_files.append({ + 'filename': os.path.basename(f), + 'size_bytes': stat.st_size, + 'modified': stat.st_mtime, + }) + + return JSONResponse({ + 'flushed': pending, + 'flushedPath': flushed_path, + 'logFiles': log_files[-20:], # last 20 + }) + + +def _run_sim_thread_deepseek(sim_id: str, card_data: list[dict], num_games: int, deck_name: str, record_logs: bool): + """Background thread for simulations using DeepSeek AI opponent.""" + try: + import sys, os + src_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'src') + if src_dir not in sys.path: + sys.path.insert(0, src_dir) + + from commander_ai_lab.sim.models import Card + from commander_ai_lab.sim.deepseek_engine import DeepSeekGameEngine + from commander_ai_lab.sim.rules import enrich_card + from commander_ai_lab.lab.experiments import _generate_training_deck + + with _sim_lock: + _sim_runs[sim_id]['status'] = 'running' + + # Build player's deck with real card data + deck_a = [] + for cd in card_data: + c = Card(name=cd['name']) + if cd.get('type_line'): + c.type_line = cd['type_line'] + if cd.get('cmc'): + c.cmc = float(cd['cmc']) + if cd.get('power') and cd.get('toughness'): + c.power = str(cd['power']) + c.toughness = str(cd['toughness']) + c.pt = c.power + '/' + c.toughness + if cd.get('oracle_text'): + c.oracle_text = cd['oracle_text'] + if cd.get('mana_cost'): + c.mana_cost = cd['mana_cost'] + if cd.get('keywords'): + kw = cd['keywords'] + if isinstance(kw, str): + try: + import json as _json + kw = _json.loads(kw) + except Exception: + kw = [] + if isinstance(kw, list): + c.keywords = kw + enrich_card(c) + deck_a.append(c) + + deck_b = _generate_training_deck() + + # Get DeepSeek brain + brain = _get_deepseek_brain() + if brain and not brain._connected: + brain.check_connection() + + engine = DeepSeekGameEngine( + brain=brain, + ai_player_index=1, # Training deck is player B (index 1) + max_turns=25, + record_log=record_logs, + ) + + import time + start = time.time() + + wins = 0 + losses = 0 + total_turns = 0 + total_damage_dealt = 0 + total_damage_received = 0 + total_spells_cast = 0 + total_creatures_played = 0 + total_removal_used = 0 + total_ramp_played = 0 + total_cards_drawn = 0 + total_max_board = 0 + game_results = [] + + for i in range(num_games): + game_id = f'ds-sim-{sim_id[:8]}-g{i+1}' + result = engine.run(deck_a, deck_b, name_a=deck_name, name_b='DeepSeek AI', + game_id=game_id, archetype='midrange') + + game_data = result.to_dict() + game_data['gameNumber'] = i + 1 + game_results.append(game_data) + + if result.winner == 0: + wins += 1 + else: + losses += 1 + + total_turns += result.turns + if result.player_a_stats: + total_damage_dealt += result.player_a_stats.damage_dealt + total_damage_received += result.player_a_stats.damage_received + total_spells_cast += result.player_a_stats.spells_cast + total_creatures_played += result.player_a_stats.creatures_played + total_removal_used += result.player_a_stats.removal_used + total_ramp_played += result.player_a_stats.ramp_played + total_cards_drawn += result.player_a_stats.cards_drawn + total_max_board += result.player_a_stats.max_board_size + + with _sim_lock: + _sim_runs[sim_id]['completed'] = i + 1 + + elapsed = time.time() - start + n = num_games + + # Write ML decision JSONL for training pipeline + ml_decisions = engine.flush_ml_decisions() + if ml_decisions: + ml_jsonl_path = os.path.join('results', f'ml-decisions-sim-{sim_id[:8]}.jsonl') + os.makedirs('results', exist_ok=True) + with open(ml_jsonl_path, 'w', encoding='utf-8') as mf: + import json as _mljson + for dec in ml_decisions: + mf.write(_mljson.dumps(dec) + '\n') + + # Get DeepSeek stats for the summary + ds_stats = brain.get_stats() if brain else {} + + summary = { + 'deckName': deck_name, + 'opponentName': 'DeepSeek AI', + 'opponentType': 'deepseek', + 'totalGames': n, + 'wins': wins, + 'losses': losses, + 'winRate': round(wins / n * 100, 1) if n > 0 else 0.0, + 'avgTurns': round(total_turns / n, 1) if n > 0 else 0.0, + 'avgDamageDealt': round(total_damage_dealt / n, 1) if n > 0 else 0.0, + 'avgDamageReceived': round(total_damage_received / n, 1) if n > 0 else 0.0, + 'avgSpellsCast': round(total_spells_cast / n, 1) if n > 0 else 0.0, + 'avgCreaturesPlayed': round(total_creatures_played / n, 1) if n > 0 else 0.0, + 'avgRemovalUsed': round(total_removal_used / n, 1) if n > 0 else 0.0, + 'avgRampPlayed': round(total_ramp_played / n, 1) if n > 0 else 0.0, + 'avgCardsDrawn': round(total_cards_drawn / n, 1) if n > 0 else 0.0, + 'avgMaxBoardSize': round(total_max_board / n, 1) if n > 0 else 0.0, + 'elapsedSeconds': round(elapsed, 3), + 'deepseekStats': ds_stats, + } + + with _sim_lock: + _sim_runs[sim_id]['status'] = 'complete' + _sim_runs[sim_id]['result'] = { + 'summary': summary, + 'games': game_results, + } + except Exception as e: + import traceback + with _sim_lock: + _sim_runs[sim_id]['status'] = 'error' + _sim_runs[sim_id]['error'] = str(e) + traceback.print_exc() + + +@router.post('/api/sim/run-deepseek') +async def sim_run_deepseek(request: FastAPIRequest): + """Start simulation using DeepSeek AI as the opponent brain.""" + body = await request.json() + deck_id = body.get('deckId') + num_games = body.get('numGames', 5) # Default 5 (LLM is slower) + record_logs = body.get('recordLogs', True) + + if not deck_id: + return JSONResponse({'error': 'deckId required'}, status_code=400) + if num_games < 1 or num_games > 50: + return JSONResponse({'error': 'numGames must be 1-50 for DeepSeek mode'}, status_code=400) + + conn = _get_db_conn() + cur = conn.cursor() + cur.execute('SELECT name FROM decks WHERE id = ?', (deck_id,)) + row = cur.fetchone() + if not row: + return JSONResponse({'error': 'deck not found'}, status_code=404) + deck_name = row[0] + + cur.execute(""" + SELECT dc.card_name, dc.quantity, + ce.type_line, ce.cmc, ce.power, ce.toughness, + ce.oracle_text, ce.keywords, ce.mana_cost + FROM deck_cards dc + LEFT JOIN ( + SELECT scryfall_id, type_line, cmc, power, toughness, + oracle_text, keywords, mana_cost + FROM collection_entries GROUP BY scryfall_id + ) ce ON ce.scryfall_id = dc.scryfall_id + WHERE dc.deck_id = ? + """, (deck_id,)) + card_data = [] + for r in cur.fetchall(): + for _ in range(r[1] or 1): + card_data.append({ + 'name': r[0], + 'type_line': r[2] or '', + 'cmc': r[3] or 0, + 'power': r[4] or '', + 'toughness': r[5] or '', + 'oracle_text': r[6] or '', + 'keywords': r[7] or '', + 'mana_cost': r[8] or '', + }) + if not card_data: + return JSONResponse({'error': 'deck has no cards'}, status_code=400) + + sim_id = str(_uuid.uuid4())[:8] + with _sim_lock: + _sim_runs[sim_id] = { + 'status': 'queued', + 'completed': 0, + 'total': num_games, + 'deckName': deck_name, + 'result': None, + 'error': None, + } + + t = _threading.Thread( + target=_run_sim_thread_deepseek, + args=(sim_id, card_data, num_games, deck_name, record_logs), + daemon=True, + ) + t.start() + + return JSONResponse({ + 'simId': sim_id, + 'status': 'queued', + 'total': num_games, + 'deckName': deck_name, + 'opponentType': 'deepseek', + }) + + diff --git a/routes/scanner.py b/routes/scanner.py new file mode 100644 index 0000000..1f8fbf1 --- /dev/null +++ b/routes/scanner.py @@ -0,0 +1,480 @@ +""" +routes/scanner.py +================= +Card scanner & collection enrichment endpoints: + POST /api/collection/scan + POST /api/collection/scan/add + POST /api/collection/re-enrich + POST /api/collection/auto-classify + POST /api/collection/auto-classify-all + GET /api/collection/{cardId}/edhrec +""" +from __future__ import annotations + +import json +import os +import time +from pathlib import Path +from urllib.request import urlopen, Request +from urllib.error import URLError + +from fastapi import APIRouter, HTTPException +from fastapi import Request as FastAPIRequest + +from routes.shared import ( + CFG, log_scan, log_deckgen, + _get_db_conn, _row_to_dict, _enrich_from_scryfall, + _scryfall_fuzzy_lookup, _detect_card_roles, + _to_edhrec_slug, _API_HEADERS, + _edhrec_cache_get, _edhrec_cache_set, +) + +router = APIRouter(tags=["scanner"]) + + +@router.post("/api/collection/scan") +async def scan_card_image(request: FastAPIRequest): + """ + Scan a card image and return recognized card name(s). + + Uses Ximilar Visual AI for card recognition. + + Accepts multipart/form-data with: + - file: image file (JPEG/PNG) + - mode: "single" (default) or "multi" + + Returns: + { "results": [ { raw_ocr, matched_name, set_code, scryfall_id, confidence, image_uri, error, collector_number, rarity, tcgplayer_url }, ... ] } + """ + try: + from scanner.pipeline import scan_single, scan_multi + except ImportError as e: + raise HTTPException(500, f"Scanner module not available: {e}") + + # Try CFG first, then fall back to env var + api_key = CFG.ximilar_api_key or os.environ.get("XIMILAR_API_KEY", "") + if not api_key: + raise HTTPException(500, "Card scanner not configured. Start the server with --ximilar-key YOUR_KEY or set XIMILAR_API_KEY env var.") + + # Parse multipart form + form = await request.form() + upload = form.get("file") + if upload is None: + raise HTTPException(400, "No file uploaded. Send an image as 'file' in multipart/form-data.") + + mode = str(form.get("mode", "single")).lower() + image_bytes = await upload.read() + + if len(image_bytes) == 0: + raise HTTPException(400, "Uploaded file is empty") + + # Limit file size (20 MB) + if len(image_bytes) > 20 * 1024 * 1024: + raise HTTPException(400, "Image file too large (max 20 MB)") + + log_scan.info(f" Processing {len(image_bytes)} bytes, mode={mode} (Ximilar AI)") + + if mode == "multi": + results = scan_multi(image_bytes, _scryfall_fuzzy_lookup, ximilar_api_key=api_key) + else: + result = scan_single(image_bytes, _scryfall_fuzzy_lookup, ximilar_api_key=api_key) + results = [result] + + return { + "results": [r.to_dict() for r in results] + } + + +@router.post("/api/collection/scan/add") +async def scan_add_cards(body: dict): + """ + Add scanned cards to the collection. + + Body: + { + "cards": [ + { "name": "Lightning Bolt", "set_code": "m11", "quantity": 1 }, + ... + ] + } + """ + cards = body.get("cards", []) + if not cards: + raise HTTPException(400, "No cards provided") + + conn = _get_db_conn() + imported = 0 + updated = 0 + errors = [] + + for card_req in cards: + name = str(card_req.get("name", "")).strip() + if not name: + continue + + quantity = int(card_req.get("quantity", 1)) + set_code = str(card_req.get("set_code", "")).lower() + collector_number = str(card_req.get("collector_number", "")) + finish = "NORMAL" + + try: + enriched = _enrich_from_scryfall(name, set_code, collector_number) + except Exception as e: + errors.append(f"Scryfall error for '{name}': {e}") + continue + + if not enriched or "_error" in enriched: + err_msg = enriched.get("_error", f"Not found: '{name}'") if enriched else f"Not found: '{name}'" + errors.append(err_msg) + continue + + resolved_name = enriched.get("name", name) + + # Check for existing entry + existing = conn.execute( + """SELECT id, quantity FROM collection_entries + WHERE name = ? AND set_code = ? AND collector_number = ? AND finish = ?""", + (resolved_name, set_code, collector_number, finish), + ).fetchone() + + if existing: + new_qty = existing["quantity"] + quantity + conn.execute( + "UPDATE collection_entries SET quantity = ?, updated_at = datetime('now') WHERE id = ?", + (new_qty, existing["id"]), + ) + conn.commit() + updated += 1 + else: + conn.execute( + """INSERT INTO collection_entries + (name, type_line, subtypes, is_legendary, is_basic, color_identity, + cmc, mana_cost, oracle_text, keywords, + power, toughness, rarity, set_name, edhrec_rank, + tcg_price, salt_score, is_game_changer, + category, scryfall_id, tcgplayer_id, + quantity, finish, condition, language, notes, tags, + set_code, collector_number) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + ( + resolved_name, + enriched.get("type_line", ""), + enriched.get("subtypes", "[]"), + enriched.get("is_legendary", 0), + enriched.get("is_basic", 0), + enriched.get("color_identity", "[]"), + enriched.get("cmc", 0.0), + enriched.get("mana_cost", ""), + enriched.get("oracle_text", ""), + enriched.get("keywords", "[]"), + enriched.get("power", ""), + enriched.get("toughness", ""), + enriched.get("rarity", ""), + enriched.get("set_name", ""), + enriched.get("edhrec_rank", 0), + enriched.get("tcg_price", 0.0), + enriched.get("salt_score", 0.0), + enriched.get("is_game_changer", 0), + enriched.get("category", "[]"), + enriched.get("scryfall_id", ""), + enriched.get("tcgplayer_id", ""), + quantity, + finish, + "", # condition + "", # language + "", # notes + "", # tags + set_code, + collector_number, + ), + ) + conn.commit() + imported += 1 + + return { + "importedCount": imported, + "updatedCount": updated, + "failedCount": len(errors), + "errors": errors, + } + + +# ══════════════════════════════════════════════════════════════ +# Re-enrich Collection from Scryfall +# ══════════════════════════════════════════════════════════════ + +@router.post("/api/collection/re-enrich") +async def re_enrich_collection(): + """ + Re-fetch Scryfall data for all collection entries to backfill + missing fields (mana_cost, power, toughness, rarity, set_name, edhrec_rank). + Also refreshes: type_line, oracle_text, keywords, cmc, color_identity, prices. + Runs synchronously — may take a while for large collections. + """ + conn = _get_db_conn() + rows = conn.execute( + "SELECT id, name, set_code, collector_number FROM collection_entries ORDER BY id" + ).fetchall() + + total = len(rows) + enriched_count = 0 + skipped_count = 0 + errors = [] + + for row in rows: + card_id = row["id"] + name = row["name"] + set_code = row["set_code"] or "" + collector_number = row["collector_number"] or "" + + try: + data = _enrich_from_scryfall(name, set_code, collector_number) + except Exception as e: + errors.append(f"Error for '{name}': {e}") + skipped_count += 1 + continue + + if not data or "_error" in data: + err_msg = data.get("_error", f"Not found: '{name}'") if data else f"Not found: '{name}'" + errors.append(err_msg) + skipped_count += 1 + continue + + conn.execute( + """UPDATE collection_entries SET + type_line = ?, subtypes = ?, is_legendary = ?, is_basic = ?, + color_identity = ?, cmc = ?, mana_cost = ?, + oracle_text = ?, keywords = ?, + power = ?, toughness = ?, + rarity = ?, set_name = ?, edhrec_rank = ?, + tcg_price = ?, scryfall_id = ?, tcgplayer_id = ?, + category = ?, + updated_at = datetime('now') + WHERE id = ?""", + ( + data.get("type_line", ""), + data.get("subtypes", "[]"), + data.get("is_legendary", 0), + data.get("is_basic", 0), + data.get("color_identity", "[]"), + data.get("cmc", 0.0), + data.get("mana_cost", ""), + data.get("oracle_text", ""), + data.get("keywords", "[]"), + data.get("power", ""), + data.get("toughness", ""), + data.get("rarity", ""), + data.get("set_name", ""), + data.get("edhrec_rank", 0), + data.get("tcg_price", 0.0), + data.get("scryfall_id", ""), + data.get("tcgplayer_id", ""), + data.get("category", "[]"), + card_id, + ), + ) + conn.commit() + enriched_count += 1 + + return { + "total": total, + "enrichedCount": enriched_count, + "skippedCount": skipped_count, + "errors": errors[:50], # cap error list + } + + +# ══════════════════════════════════════════════════════════════ +# Auto-Classify Collection +# ══════════════════════════════════════════════════════════════ + +@router.post("/api/collection/auto-classify") +async def auto_classify_collection(): + """ + Run auto-classification on all collection entries. + Uses oracle_text, type_line, and keywords to detect functional roles + (Ramp, Draw, Removal, Board Wipe, Anthem, Stax, etc.). + Only updates cards whose current category is empty or '[]'. + """ + conn = _get_db_conn() + rows = conn.execute( + "SELECT id, oracle_text, type_line, keywords, category FROM collection_entries ORDER BY id" + ).fetchall() + + total = len(rows) + classified_count = 0 + skipped_count = 0 + + for row in rows: + card_id = row["id"] + existing_cats = row["category"] or "[]" + + # Parse existing categories + try: + cats = json.loads(existing_cats) + except Exception: + cats = [] + + # Skip cards that already have manually-set categories + if cats: + skipped_count += 1 + continue + + oracle_text = row["oracle_text"] or "" + type_line = row["type_line"] or "" + keywords = row["keywords"] or "[]" + + roles = _detect_card_roles(oracle_text, type_line, keywords) + + if roles: + conn.execute( + "UPDATE collection_entries SET category = ?, updated_at = datetime('now') WHERE id = ?", + (json.dumps(roles), card_id), + ) + classified_count += 1 + + conn.commit() + + return { + "total": total, + "classifiedCount": classified_count, + "skippedCount": skipped_count, + "message": f"Classified {classified_count} cards, skipped {skipped_count} (already had categories)", + } + + +@router.post("/api/collection/auto-classify-all") +async def auto_classify_all_collection(): + """ + Force re-classify ALL collection entries, overwriting existing categories. + """ + conn = _get_db_conn() + rows = conn.execute( + "SELECT id, oracle_text, type_line, keywords FROM collection_entries ORDER BY id" + ).fetchall() + + total = len(rows) + classified_count = 0 + + for row in rows: + card_id = row["id"] + oracle_text = row["oracle_text"] or "" + type_line = row["type_line"] or "" + keywords = row["keywords"] or "[]" + + roles = _detect_card_roles(oracle_text, type_line, keywords) + conn.execute( + "UPDATE collection_entries SET category = ?, updated_at = datetime('now') WHERE id = ?", + (json.dumps(roles), card_id), + ) + classified_count += 1 + + conn.commit() + + return { + "total": total, + "classifiedCount": classified_count, + "message": f"Re-classified all {classified_count} cards", + } + + +# ══════════════════════════════════════════════════════════════ +# EDHREC Recommendations Cache + Endpoint +# ══════════════════════════════════════════════════════════════ + +@router.get("/api/collection/{cardId}/edhrec") +async def get_card_edhrec(cardId: int): + """ + Get EDHREC recommendations for a collection card. + For legendary creatures (potential commanders): fetch commander synergy data. + For other cards: fetch 'also played with' data. + """ + conn = _get_db_conn() + row = conn.execute("SELECT * FROM collection_entries WHERE id = ?", (cardId,)).fetchone() + if not row: + raise HTTPException(404, f"Card with id {cardId} not found") + + card = _row_to_dict(row) + name = card["name"] + type_line = card.get("type_line", "") + is_legendary = card.get("is_legendary", 0) + + # Determine if it's a legendary creature (potential commander) + is_commander = bool( + is_legendary and + re.search(r"\bCreature\b", type_line, re.IGNORECASE) + ) + + slug = _to_edhrec_slug(name) + cache_key = f"edhrec:{'cmd' if is_commander else 'card'}:{slug}" + + cached = _edhrec_cache_get(cache_key) + if cached: + return cached + + recommendations = [] + links = { + "edhrecPage": ( + f"https://edhrec.com/commanders/{slug}" + if is_commander + else f"https://edhrec.com/cards/{slug}" + ), + "archidektSearch": f"https://archidekt.com/search/cards?q={name.replace(' ', '+')}", + "moxfieldSearch": f"https://www.moxfield.com/search?q={name.replace(' ', '+')}", + } + + try: + if is_commander: + url = f"https://json.edhrec.com/pages/commanders/{slug}.json" + else: + url = f"https://json.edhrec.com/pages/cards/{slug}.json" + + req = Request(url, headers=_API_HEADERS) + with urlopen(req, timeout=10) as resp: + data = json.loads(resp.read().decode("utf-8")) + + if is_commander: + # Extract top cards from cardlists + container = data.get("container", {}) + json_dict = container.get("json_dict", {}) + for cardlist in json_dict.get("cardlists", []): + tag = cardlist.get("tag", "") + for cv in cardlist.get("cardviews", [])[:10]: + rec_name = cv.get("name", "") + if not rec_name: + continue + synergy = cv.get("synergy", 0.0) + recommendations.append({ + "name": rec_name, + "synergy": synergy, + "role": tag or "Recommended", + }) + else: + # Extract "also played with" cards + container = data.get("container", {}) + json_dict = container.get("json_dict", {}) + for cardlist in json_dict.get("cardlists", []): + tag = cardlist.get("tag", "") + for cv in cardlist.get("cardviews", [])[:10]: + rec_name = cv.get("name", "") + if not rec_name: + continue + synergy = cv.get("synergy", 0.0) + recommendations.append({ + "name": rec_name, + "synergy": synergy, + "role": tag or "Also Played With", + }) + + except Exception as e: + log_deckgen.error(f" Error fetching data for '{name}': {e}") + # Graceful degradation — return empty results + + result = { + "recommendations": recommendations[:30], + "links": links, + } + _edhrec_cache_set(cache_key, result) + return result + +