From fd74f157e8586ec1950bec90cac9e072224e0a65 Mon Sep 17 00:00:00 2001 From: KoalaTrapLord Date: Tue, 17 Mar 2026 16:17:53 -0400 Subject: [PATCH] Phase 4: Slim lab_api.py to thin main() - closes #25 Refactor lab_api.py to streamline imports, improve logging, and enhance argument parsing. --- lab_api.py | 275 ++++++++++++----------------------------------------- 1 file changed, 59 insertions(+), 216 deletions(-) diff --git a/lab_api.py b/lab_api.py index 73ddff3..00c791f 100644 --- a/lab_api.py +++ b/lab_api.py @@ -1,109 +1,30 @@ #!/usr/bin/env python3 """ -Commander AI Lab — FastAPI Backend v3 -═════════════════════════════════════ - -v3 adds: - POST /api/lab/import/url — Import deck from Archidekt/EDHREC URL - POST /api/lab/import/text — Import deck from card list text - GET /api/lab/meta/commanders — List available commanders in meta mapping - GET /api/lab/meta/search — Search commanders by name - POST /api/lab/meta/fetch — Fetch EDHREC average deck for a commander - POST /api/lab/start — Extended: accepts imported deck profiles - -Runs on port 8080 by default. Serves the web UI static files at /. +Commander AI Lab — FastAPI Backend +Thin entry point: registers routers and starts uvicorn. """ - import argparse -import asyncio -import csv -import io -import json import logging -import logging.handlers import os -import re -import sqlite3 -import subprocess -import sys -import threading -import time -import uuid -from datetime import datetime from pathlib import Path -from typing import Optional -from urllib.request import urlopen, Request -from urllib.error import URLError try: - from fastapi import FastAPI, HTTPException, BackgroundTasks, Request as FastAPIRequest + from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware - from fastapi.responses import FileResponse, JSONResponse, StreamingResponse from fastapi.staticfiles import StaticFiles - from pydantic import BaseModel except ImportError: print("ERROR: FastAPI not installed. Run: pip install fastapi uvicorn") - sys.exit(1) - - -# ══════════════════════════════════════════════════════════════ -# Shared Module — configuration, models, helpers -# ══════════════════════════════════════════════════════════════ + import sys; sys.exit(1) from routes.shared import ( - # Configuration - Config, CFG, setup_logging, - # Loggers - # (we re-create named loggers locally below for convenience) - # In-memory state - BatchState, COMMANDER_META, - # Pydantic models - StartRequest, StartResponse, StatusResponse, DeckInfo, - ImportUrlRequest, ImportTextRequest, MetaFetchRequest, - CreateDeckRequest, UpdateDeckRequest, AddDeckCardRequest, - PatchDeckCardRequest, BulkAddRequest, BulkAddRecommendedRequest, - DeckGenerationSourceConfig, DeckGenerationRequest, GeneratedDeckCard, - DeckGenV3Request, DeckGenV3SubstituteRequest, - # Database - _get_db_conn, init_collection_db, - COLLECTION_DB_PATH, - # Collection helpers - _row_to_dict, _snake_to_camel, _add_image_url, - _build_collection_filters, - _detect_card_roles, _classify_card_type, - VALID_SORT_FIELDS, - # Deck helpers - _get_deck_or_404, _compute_deck_analysis, _check_ratio_limit, - # Scryfall - _ScryfallCache, _scryfall_cache, _scryfall_rate_limit, - _fetch_scryfall_api, _enrich_from_scryfall, _scryfall_fuzzy_lookup, - SCRYFALL_CACHE_DB_PATH, SCRYFALL_CACHE_TTL_SECONDS, - _API_HEADERS, - # Import/fetch helpers - _http_get, _import_from_url, _fetch_archidekt_deck, - _fetch_edhrec_average, _parse_text_decklist, - _save_profile_to_dck, _to_edhrec_slug, - _parse_finish, _parse_text_line, _auto_infer_mapping, _parse_csv_content, - # Precon helpers - load_precon_index, _sanitize_filename, _deck_to_dck, + CFG, + setup_logging, + init_collection_db, download_precon_database, - PRECON_DIR, PRECON_INDEX, GITHUB_PRECON_URL, PRECON_CACHE_HOURS, - # Java/batch - _find_java17, get_java17, build_java_command, - parse_dck_file, _load_deck_cards_by_name, - run_batch_subprocess, _run_process_blocking, - _run_deepseek_batch_thread, - _get_deepseek_brain, - # AI profiles & ML - AI_PROFILES, _ml_logging_enabled, - # EDHREC cache - _edhrec_cache, _EDHREC_CACHE_TTL, - _edhrec_cache_get, _edhrec_cache_set, - # Commander meta - load_commander_meta, BUILTIN_COMMANDERS, + load_commander_meta, + PRECON_INDEX, + COMMANDER_META, ) - -# Route modules from routes.collection import router as collection_router from routes.deckbuilder import router as deckbuilder_router from routes.precon import router as precon_router @@ -115,34 +36,10 @@ from routes.deckgen import router as deckgen_router from routes.coach import router as coach_router, init_coach_service from routes.ml import router as ml_router - -# ══════════════════════════════════════════════════════════════ -# Logging Setup -# ══════════════════════════════════════════════════════════════ - -_LOG_FORMAT = "%(asctime)s %(levelname)-5s [%(name)s] %(message)s" -_LOG_DATE_FMT = "%Y-%m-%d %H:%M:%S" -_LOG_DIR = Path(os.environ.get("CAL_LOG_DIR", "logs")) - -# Named loggers — convenience aliases for remaining endpoints log = logging.getLogger("commander_ai_lab.api") -log_batch = logging.getLogger("commander_ai_lab.batch") -log_sim = logging.getLogger("commander_ai_lab.sim") -log_coach = logging.getLogger("commander_ai_lab.coach") -log_deckgen = logging.getLogger("commander_ai_lab.deckgen") -log_collect = logging.getLogger("commander_ai_lab.collection") -log_scan = logging.getLogger("commander_ai_lab.scanner") -log_ml = logging.getLogger("commander_ai_lab.ml") -log_cache = logging.getLogger("commander_ai_lab.cache") -log_pplx = logging.getLogger("commander_ai_lab.pplx") - - -# ══════════════════════════════════════════════════════════════ -# App Setup -# ══════════════════════════════════════════════════════════════ +# ── App ──────────────────────────────────────────────────────── app = FastAPI(title="Commander AI Lab API", version="3.0.0") - app.add_middleware( CORSMiddleware, allow_origins=["*"], @@ -150,7 +47,7 @@ app.add_middleware( allow_headers=["*"], ) -# Register extracted route modules +# Register route modules app.include_router(collection_router) app.include_router(deckbuilder_router) app.include_router(precon_router) @@ -165,8 +62,7 @@ app.include_router(ml_router) @app.on_event("startup") async def _on_startup(): - """Safety net: ensure DB and precon index are initialized even when - main() is bypassed (e.g. uvicorn lab_api:app --reload).""" + """Ensure DB and precon index are ready (supports uvicorn --reload).""" import routes.shared as _shared _shared.init_collection_db() if not _shared.PRECON_INDEX: @@ -175,25 +71,13 @@ async def _on_startup(): _shared.load_commander_meta() -# ══════════════════════════════════════════════════════════════ -# In-Memory State (used by remaining endpoints) -# ══════════════════════════════════════════════════════════════ - -active_batches: dict[str, BatchState] = {} - -# Static File Serving (React SPA) — MUST be after all API routes -# ══════════════════════════════════════════════════════════════ - -# UI Serving: legacy HTML/JS/CSS pages (primary) or React SPA (fallback) +# ── Static UI ────────────────────────────────────────────────── _legacy_ui_dir = Path(__file__).parent / "ui" _spa_dir = Path(__file__).parent / "frontend" / "commander-ai-lab-ui" / "dist" if _legacy_ui_dir.exists(): - # Primary: serve the proven multi-page HTML/JS/CSS UI app.mount("/", StaticFiles(directory=str(_legacy_ui_dir), html=True), name="ui") - elif _spa_dir.exists(): - # Fallback: React SPA (only if legacy ui/ folder is missing) _spa_assets = _spa_dir / "assets" if _spa_assets.exists(): app.mount("/assets", StaticFiles(directory=str(_spa_assets)), name="spa-assets") @@ -207,46 +91,22 @@ elif _spa_dir.exists(): return FileResponse(str(_spa_dir / "index.html")) -# ══════════════════════════════════════════════════════════════ -# Startup -# ══════════════════════════════════════════════════════════════ - -def parse_args(): - parser = argparse.ArgumentParser(description="Commander AI Lab API Server v3") - parser.add_argument("--forge-jar", default=os.environ.get("FORGE_JAR", ""), - help="Path to forge-gui-desktop jar-with-dependencies") - parser.add_argument("--forge-dir", default=os.environ.get("FORGE_DIR", ""), - help="Forge working directory (contains res/)") - parser.add_argument("--forge-decks-dir", default=os.environ.get("FORGE_DECKS_DIR", ""), - help="Path to Commander deck files (default: %%APPDATA%%/Forge/decks/commander)") - parser.add_argument("--lab-jar", default=os.environ.get("LAB_JAR", ""), - help="Path to commander-ai-lab.jar (default: auto-detect from target/)") - parser.add_argument("--port", type=int, default=int(os.environ.get("LAB_PORT", "8080"))) - parser.add_argument("--ximilar-key", default=os.environ.get("XIMILAR_API_KEY", ""), - help="Ximilar API key for card scanner (env: XIMILAR_API_KEY)") - parser.add_argument("--pplx-key", default=os.environ.get("PPLX_API_KEY", ""), - help="Perplexity API key for AI deck research/generation (env: PPLX_API_KEY)") - parser.add_argument("--verbose", "-v", action="store_true", - help="Enable DEBUG-level logging (default: INFO)") - return parser.parse_args() +# ── Startup ──────────────────────────────────────────────────── +def _parse_args(): + p = argparse.ArgumentParser(description="Commander AI Lab API Server") + p.add_argument("--forge-jar", default=os.environ.get("FORGE_JAR", "")) + p.add_argument("--forge-dir", default=os.environ.get("FORGE_DIR", "")) + p.add_argument("--forge-decks-dir", default=os.environ.get("FORGE_DECKS_DIR", "")) + p.add_argument("--lab-jar", default=os.environ.get("LAB_JAR", "")) + p.add_argument("--port", type=int, default=int(os.environ.get("LAB_PORT", "8080"))) + p.add_argument("--ximilar-key", default=os.environ.get("XIMILAR_API_KEY", "")) + p.add_argument("--pplx-key", default=os.environ.get("PPLX_API_KEY", "")) + p.add_argument("--verbose", "-v", action="store_true") + return p.parse_args() -def resolve_lab_jar() -> str: - target_dir = Path(__file__).parent / "target" - if target_dir.exists(): - for pattern in [ - "commander-ai-lab-*-jar-with-dependencies.jar", - "commander-ai-lab-*-shaded.jar", - "commander-ai-lab-*.jar", - ]: - jars = sorted(target_dir.glob(pattern)) - jars = [j for j in jars if not j.name.startswith("original-")] - if jars: - return str(jars[0]) - return "" - - -def resolve_forge_decks_dir() -> str: +def _resolve_forge_decks_dir() -> str: + import sys if sys.platform == "win32": appdata = os.environ.get("APPDATA", "") if appdata: @@ -263,66 +123,49 @@ def resolve_forge_decks_dir() -> str: return "" -# load_commander_meta() is now in routes/shared.py +def _resolve_lab_jar() -> str: + target_dir = Path(__file__).parent / "target" + if target_dir.exists(): + for pattern in [ + "commander-ai-lab-*-jar-with-dependencies.jar", + "commander-ai-lab-*-shaded.jar", + "commander-ai-lab-*.jar", + ]: + jars = sorted(target_dir.glob(pattern)) + jars = [j for j in jars if not j.name.startswith("original-")] + if jars: + return str(jars[0]) + return "" def main(): - args = parse_args() - setup_logging(logging.DEBUG if getattr(args, 'verbose', False) else logging.INFO) + args = _parse_args() + setup_logging(logging.DEBUG if args.verbose else logging.INFO) - CFG.forge_jar = args.forge_jar - CFG.forge_dir = args.forge_dir - CFG.forge_decks_dir = args.forge_decks_dir or resolve_forge_decks_dir() - CFG.lab_jar = args.lab_jar or resolve_lab_jar() - CFG.port = args.port + CFG.forge_jar = args.forge_jar + CFG.forge_dir = args.forge_dir + CFG.forge_decks_dir = args.forge_decks_dir or _resolve_forge_decks_dir() + CFG.lab_jar = args.lab_jar or _resolve_lab_jar() + CFG.port = args.port CFG.ximilar_api_key = args.ximilar_key - CFG.pplx_api_key = args.pplx_key + CFG.pplx_api_key = args.pplx_key - log.info("╔══════════════════════════════════════════════════╗") - log.info("║ Commander AI Lab — API Server v3.0.0 ║") - log.info("╚══════════════════════════════════════════════════╝") - log.info("") - log.info(f" Forge JAR: {CFG.forge_jar}") - log.info(f" Forge Dir: {CFG.forge_dir}") - log.info(f" Decks Dir: {CFG.forge_decks_dir}") - log.info(f" Lab JAR: {CFG.lab_jar}") - log.info(f" Results Dir: {CFG.results_dir}") - log.info(f" Port: {CFG.port}") - log.info(f" Ximilar: {'configured' if CFG.ximilar_api_key else 'NOT SET (scanner will fail)'}") - log.info(f" Perplexity: {'configured' if CFG.pplx_api_key else 'NOT SET (AI research/gen disabled)'}") + from routes.shared import get_java17 j17 = get_java17() - log.info(f" Java 17: {j17 if j17 != 'java' else 'NOT FOUND (batch sim may fail on Java 25+)'}") - log.info(f" LM Studio: http://192.168.0.122:1234") + + log.info("Commander AI Lab — API Server v3.0.0") + log.info(f" Port: {CFG.port}") + log.info(f" Forge JAR: {CFG.forge_jar or 'NOT SET'}") + log.info(f" Decks Dir: {CFG.forge_decks_dir or 'NOT SET'}") + log.info(f" Ximilar: {'configured' if CFG.ximilar_api_key else 'NOT SET'}") + log.info(f" Perplexity: {'configured' if CFG.pplx_api_key else 'NOT SET'}") + log.info(f" Java 17: {j17}") load_commander_meta() - download_precon_database() # Auto-downloads all 163+ Commander precons on first run + download_precon_database() init_collection_db() init_coach_service() - # Ximilar API key check - if not CFG.ximilar_api_key: - log_scan.warning(" WARNING: --ximilar-key not set. Card scanner will not work.") - log.info(" Set via CLI: --ximilar-key YOUR_KEY") - log.info(" Or env var: XIMILAR_API_KEY=YOUR_KEY") - - if not CFG.forge_jar: - log.warning("WARNING: --forge-jar not set. /api/lab/start will fail.") - if not CFG.lab_jar: - log.warning("WARNING: Lab JAR not found. Build with: mvn package -DskipTests") - if not CFG.forge_decks_dir: - log.warning("WARNING: Commander decks dir not found. /api/lab/decks will return empty.") - - log.info("") - log.info(f" Starting server on http://localhost:{CFG.port}") - if _spa_dir.exists(): - log.info(f" Web UI: http://localhost:{CFG.port}/ (React SPA)") - log.info(f" Routes: / (Batch Sim), /collection, /decks, /autogen, /simulator, /coach, /training") - else: - log.info(f" Web UI: http://localhost:{CFG.port}/index.html (legacy HTML)") - log.info(f" NOTE: React SPA not built. Run: cd frontend/commander-ai-lab-ui && npm install && npm run build") - log.info(f" API docs: http://localhost:{CFG.port}/docs") - log.info("") - import uvicorn uvicorn.run(app, host="0.0.0.0", port=CFG.port, log_level="info")