Phase 4: Slim lab_api.py to thin main() - closes #25

Refactor lab_api.py to streamline imports, improve logging, and enhance argument parsing.
This commit is contained in:
KoalaTrapLord authored and GitHub committed 2026-03-17 16:17:53 -04:00
1 parent 179a53c0af
commit fd74f157e8
1 file changed
+59 -216
+59 -216
View File
@@ -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")