refactor: extract 49 endpoints into 5 route modules

- Extract collection (10), deckbuilder (18), precon (4), import (5), lab (12) routes
- All share helpers/models/state from routes/shared.py
- Gut lab_api.py from 8872 to 4487 lines (57 remaining endpoints)
- Delete routes/_wire.py and routes/_autoload.py (replaced by include_router)
- Add _scryfall_fuzzy_lookup to shared.py
- 106 total endpoints verified: 49 extracted + 57 remaining
This commit is contained in:
KoalaTrapLord committed 2026-03-14 18:55:32 +00:00
1 parent 2c6748a736
commit 36077c94ab
10 files changed
+2456 -4911

No files matched your search

+75 -4460
View File
File diff suppressed because it is too large. Load diff
+4 -3
View File
@@ -1,5 +1,6 @@
"""Commander AI Lab — API router package.
"""
routes package — API routers for Commander AI Lab.
Each module in this package exposes an `APIRouter` that is
registered in `lab_api.py` via `app.include_router()`.
Each module defines a FastAPI APIRouter with real endpoint implementations.
All shared helpers, models, and state live in routes.shared.
"""
-23
View File
@@ -1,23 +0,0 @@
"""
routes/_autoload.py
===================
Optional zero-touch bootstrap for the routes package.
If you don't want to modify lab_api.py at all, add one line
anywhere after `app = FastAPI(...)` in lab_api.py::
from routes import _autoload # noqa — registers routers on startup
This module registers a FastAPI startup event that imports routes._wire,
which in turn calls _register_shared() and app.include_router() for all
five routers.
"""
from __future__ import annotations
def register_startup(app):
"""Attach the router-wiring startup event to a FastAPI app instance."""
@app.on_event("startup")
async def _wire_routers():
import routes._wire # noqa — side-effect: registers shared + mounts routers
-142
View File
@@ -1,142 +0,0 @@
"""
routes/_wire.py
===============
Wires the routes package into lab_api.py.
This module is imported ONCE at the bottom of lab_api.py after all
symbols (CFG, app, helper functions, endpoint functions) are defined.
It does two things:
1. Calls routes.shared._register_shared() so every router can access
shared state via routes.shared.get().
2. Calls app.include_router() for every APIRouter defined in the
routes/ package.
Keeping wiring here means lab_api.py only needs one new line:
import routes._wire # noqa — side-effect import
"""
from __future__ import annotations
import lab_api as _api # import the main module to grab its symbols
from routes.shared import _register_shared
from routes import (
collection,
deckbuilder,
import_routes,
lab,
precon,
)
# ------------------------------------------------------------------
# 1. Register shared symbols so routers can call routes.shared.get()
# ------------------------------------------------------------------
_register_shared(
# Config + app
CFG=_api.CFG,
app=_api.app,
# Loggers
log=_api.log,
log_batch=_api.log_batch,
log_sim=_api.log_sim,
log_coach=_api.log_coach,
log_deckgen=_api.log_deckgen,
log_collect=_api.log_collect,
log_scan=_api.log_scan,
log_ml=_api.log_ml,
log_cache=_api.log_cache,
log_pplx=_api.log_pplx,
# In-memory state
active_batches=_api.active_batches,
COMMANDER_META=_api.COMMANDER_META,
PRECON_INDEX=_api.PRECON_INDEX,
PRECON_DIR=_api.PRECON_DIR,
AI_PROFILES=_api.AI_PROFILES,
# DB
COLLECTION_DB_PATH=_api.COLLECTION_DB_PATH,
init_collection_db=_api.init_collection_db,
# Precon helpers
download_precon_database=_api.download_precon_database,
# Scryfall helpers
_enrich_from_scryfall=_api._enrich_from_scryfall,
_fetch_scryfall_api=_api._fetch_scryfall_api,
# Utility helpers
_row_to_dict=_api._row_to_dict,
_add_image_url=_api._add_image_url,
_snake_to_camel=_api._snake_to_camel,
_classify_card_type=_api._classify_card_type,
_detect_card_roles=_api._detect_card_roles,
_build_collection_filters=_api._build_collection_filters,
_get_deck_or_404=_api._get_deck_or_404,
_compute_deck_analysis=_api._compute_deck_analysis,
_to_edhrec_slug=_api._to_edhrec_slug,
_save_profile_to_dck=_api._save_profile_to_dck,
_parse_finish=_api._parse_finish,
_parse_text_line=_api._parse_text_line,
_parse_csv_content=_api._parse_csv_content,
_auto_infer_mapping=_api._auto_infer_mapping,
get_java17=_api.get_java17,
build_java_command=_api.build_java_command,
# Deck import helpers
_import_from_url=_api._import_from_url,
_parse_text_decklist=_api._parse_text_decklist,
_fetch_edhrec_average=_api._fetch_edhrec_average,
# Endpoint functions (delegated by thin router wrappers)
start_batch=_api.start_batch,
start_batch_deepseek=_api.start_batch_deepseek,
get_status=_api.get_status,
get_result=_api.get_result,
list_decks=_api.list_decks,
list_history=_api.list_history,
list_profiles=_api.list_profiles,
get_profile=_api.get_profile,
analyze_deck=_api.analyze_deck,
get_deck_trends=_api.get_deck_trends,
get_log=_api.get_log,
get_debug_log=_api.get_debug_log,
list_collection=_api.list_collection,
export_collection=_api.export_collection,
collection_sets=_api.collection_sets,
collection_keywords=_api.collection_keywords,
import_collection=_api.import_collection,
get_collection_card=_api.get_collection_card,
update_collection_card=_api.update_collection_card,
scryfall_cache_stats=_api.scryfall_cache_stats,
scryfall_cache_clear=_api.scryfall_cache_clear,
scryfall_cache_evict_expired=_api.scryfall_cache_evict_expired,
create_deck=_api.create_deck,
list_decks_db=_api.list_decks_db,
get_deck=_api.get_deck,
update_deck=_api.update_deck,
delete_deck=_api.delete_deck,
delete_all_decks=_api.delete_all_decks,
get_deck_cards=_api.get_deck_cards,
add_deck_card=_api.add_deck_card,
remove_deck_card=_api.remove_deck_card,
patch_deck_card=_api.patch_deck_card,
deck_analysis=_api.deck_analysis,
recommend_from_collection=_api.recommend_from_collection,
deck_edh_recs=_api.deck_edh_recs,
bulk_add_cards=_api.bulk_add_cards,
bulk_add_recommended=_api.bulk_add_recommended,
export_deck_to_sim=_api.export_deck_to_sim,
)
# ------------------------------------------------------------------
# 2. Mount all routers onto the FastAPI app
# ------------------------------------------------------------------
_api.app.include_router(lab.router)
_api.app.include_router(import_routes.router)
_api.app.include_router(precon.router)
_api.app.include_router(collection.router)
_api.app.include_router(deckbuilder.router)
+886 -66
View File
@@ -13,22 +13,182 @@ Collection management endpoints:
DELETE /api/cache/scryfall
POST /api/cache/scryfall/evict
"""
from __future__ import annotations
import csv
import io
import json
import re
import sqlite3
import time
from datetime import datetime, timezone
from typing import Optional
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from fastapi.responses import JSONResponse, StreamingResponse
from routes.shared import get as _get
from routes.shared import (
CFG,
_get_db_conn,
_row_to_dict,
_add_image_url,
_build_collection_filters,
_detect_card_roles,
_classify_card_type,
_enrich_from_scryfall,
_fetch_scryfall_api,
_parse_finish,
_parse_text_line,
_parse_csv_content,
_auto_infer_mapping,
_scryfall_cache,
_scryfall_rate_limit,
_snake_to_camel,
VALID_SORT_FIELDS,
_JSON_FIELDS,
SCRYFALL_CACHE_DB_PATH,
SCRYFALL_CACHE_TTL_SECONDS,
_API_HEADERS,
log_collect,
log_cache,
)
router = APIRouter(tags=["collection"])
# ---------------------------------------------------------------------------
# Collection list / search
# ---------------------------------------------------------------------------
@router.get("/api/collection/export")
async def export_collection(
format: str = "INTERNAL_CSV",
q: Optional[str] = None,
colors: Optional[str] = None,
types: Optional[str] = None,
isLegendary: Optional[bool] = None,
isBasic: Optional[bool] = None,
isGameChanger: Optional[bool] = None,
highSalt: Optional[bool] = None,
finish: Optional[str] = None,
cmcMin: Optional[float] = None,
cmcMax: Optional[float] = None,
priceMin: Optional[float] = None,
priceMax: Optional[float] = None,
category: Optional[str] = None,
):
"""Export collection in various formats with optional filters."""
where_str, params = _build_collection_filters(
q=q, colors=colors, types=types, isLegendary=isLegendary,
isBasic=isBasic, isGameChanger=isGameChanger, highSalt=highSalt,
finish=finish, cmcMin=cmcMin, cmcMax=cmcMax,
priceMin=priceMin, priceMax=priceMax, category=category,
)
sql = f"SELECT * FROM collection_entries {where_str} ORDER BY name ASC"
conn = _get_db_conn()
rows = [_row_to_dict(r) for r in conn.execute(sql, params).fetchall()]
fmt = format.upper()
output = io.StringIO()
if fmt == "INTERNAL_CSV":
if rows:
fieldnames = list(rows[0].keys())
else:
fieldnames = [
"id", "name", "type_line", "subtypes", "is_legendary", "is_basic",
"color_identity", "cmc", "oracle_text", "keywords", "tcg_price",
"salt_score", "is_game_changer", "category", "scryfall_id", "tcgplayer_id",
"quantity", "finish", "condition", "language", "notes", "tags",
"set_code", "collector_number", "created_at", "updated_at",
]
writer = csv.DictWriter(output, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(rows)
filename = "collection_export.csv"
media_type = "text/csv"
elif fmt == "MOXFIELD_CSV":
fieldnames = ["Count", "Name", "Edition", "Condition", "Language", "Foil", "Collector Number"]
writer = csv.DictWriter(output, fieldnames=fieldnames)
writer.writeheader()
for row in rows:
finish_val = row.get("finish", "NORMAL").upper()
foil_val = "foil" if finish_val == "FOIL" else ("etched" if finish_val == "ETCHED" else "")
writer.writerow({
"Count": row.get("quantity", 1),
"Name": row.get("name", ""),
"Edition": row.get("set_code", "").upper(),
"Condition": row.get("condition", ""),
"Language": row.get("language", ""),
"Foil": foil_val,
"Collector Number": row.get("collector_number", ""),
})
filename = "collection_moxfield.csv"
media_type = "text/csv"
elif fmt == "TEXT":
lines = []
for row in rows:
qty = row.get("quantity", 1)
name = row.get("name", "")
set_code = row.get("set_code", "").upper()
coll_num = row.get("collector_number", "")
if set_code and coll_num:
lines.append(f"{qty} {name} ({set_code}) {coll_num}")
elif set_code:
lines.append(f"{qty} {name} ({set_code})")
else:
lines.append(f"{qty} {name}")
output.write("\n".join(lines))
filename = "collection_export.txt"
media_type = "text/plain"
else:
raise HTTPException(400, f"Unknown format: {format}. Use INTERNAL_CSV, MOXFIELD_CSV, or TEXT.")
content = output.getvalue()
def iter_content():
yield content.encode("utf-8")
return StreamingResponse(
iter_content(),
media_type=media_type,
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.get("/api/collection/sets")
async def collection_sets():
"""Return distinct set codes + names in the collection (for filter autocomplete)."""
conn = _get_db_conn()
rows = conn.execute(
"SELECT DISTINCT UPPER(set_code) as code, set_name as name FROM collection_entries WHERE set_code != '' ORDER BY set_name ASC"
).fetchall()
result = []
for r in rows:
code = r["code"] if "code" in r.keys() else r[0]
name = r["name"] if "name" in r.keys() else r[1]
if code:
result.append({"code": code, "name": name or code})
return JSONResponse(result)
@router.get("/api/collection/keywords")
async def collection_keywords():
"""Return distinct keywords found in the collection."""
conn = _get_db_conn()
rows = conn.execute("SELECT keywords FROM collection_entries WHERE keywords != '' AND keywords != '[]'").fetchall()
kw_set = set()
for r in rows:
try:
kws = json.loads(r["keywords"]) if isinstance(r["keywords"], str) else r["keywords"]
if isinstance(kws, list):
for k in kws:
if k:
kw_set.add(k)
except (json.JSONDecodeError, TypeError):
pass
return JSONResponse(sorted(kw_set))
@router.get("/api/collection")
async def list_collection(
@@ -62,92 +222,752 @@ async def list_collection(
qtyMin: Optional[int] = None,
qtyMax: Optional[int] = None,
):
fn = _get("list_collection")
return await fn(
page=page, pageSize=pageSize, q=q, sortField=sortField, sortDir=sortDir,
colors=colors, types=types, isLegendary=isLegendary, isBasic=isBasic,
isGameChanger=isGameChanger, highSalt=highSalt, finish=finish,
cmcMin=cmcMin, cmcMax=cmcMax, priceMin=priceMin, priceMax=priceMax,
category=category, deck_id=deck_id, rarity=rarity, setCode=setCode,
powerMin=powerMin, powerMax=powerMax, toughMin=toughMin, toughMax=toughMax,
keyword=keyword, edhrecMin=edhrecMin, edhrecMax=edhrecMax,
qtyMin=qtyMin, qtyMax=qtyMax,
)
@router.get("/api/collection/export")
async def export_collection(
format: str = "INTERNAL_CSV",
q: Optional[str] = None,
colors: Optional[str] = None,
types: Optional[str] = None,
isLegendary: Optional[bool] = None,
isBasic: Optional[bool] = None,
isGameChanger: Optional[bool] = None,
highSalt: Optional[bool] = None,
finish: Optional[str] = None,
cmcMin: Optional[float] = None,
cmcMax: Optional[float] = None,
priceMin: Optional[float] = None,
priceMax: Optional[float] = None,
category: Optional[str] = None,
):
fn = _get("export_collection")
return await fn(
format=format, q=q, colors=colors, types=types,
isLegendary=isLegendary, isBasic=isBasic, isGameChanger=isGameChanger,
highSalt=highSalt, finish=finish, cmcMin=cmcMin, cmcMax=cmcMax,
"""List collection entries with search, sort, and filter.
Optional deck_id: when provided, includes in_deck_quantity for each result.
"""
where_str, params = _build_collection_filters(
q=q, colors=colors, types=types, isLegendary=isLegendary,
isBasic=isBasic, isGameChanger=isGameChanger, highSalt=highSalt,
finish=finish, cmcMin=cmcMin, cmcMax=cmcMax,
priceMin=priceMin, priceMax=priceMax, category=category,
rarity=rarity, setCode=setCode, powerMin=powerMin, powerMax=powerMax,
toughMin=toughMin, toughMax=toughMax, keyword=keyword,
edhrecMin=edhrecMin, edhrecMax=edhrecMax, qtyMin=qtyMin, qtyMax=qtyMax,
)
# Validate sort
sort_field = sortField if sortField in VALID_SORT_FIELDS else "name"
sort_dir = "DESC" if (sortDir or "").upper() == "DESC" else "ASC"
@router.get("/api/collection/sets")
async def collection_sets():
fn = _get("collection_sets")
return await fn()
conn = _get_db_conn()
# Count total
count_sql = f"SELECT COUNT(*) FROM collection_entries {where_str}"
total = conn.execute(count_sql, params).fetchone()[0]
@router.get("/api/collection/keywords")
async def collection_keywords():
fn = _get("collection_keywords")
return await fn()
# Paginate
offset = (max(1, page) - 1) * pageSize
data_sql = f"""
SELECT * FROM collection_entries {where_str}
ORDER BY {sort_field} {sort_dir}
LIMIT ? OFFSET ?
"""
rows = conn.execute(data_sql, params + [pageSize, offset]).fetchall()
items = [_add_image_url(_row_to_dict(r)) for r in rows]
# If deck_id provided, annotate each item with how many copies are already in that deck
if deck_id is not None:
# Build a map of scryfall_id -> quantity in deck
deck_card_rows = conn.execute(
"SELECT scryfall_id, SUM(quantity) as qty FROM deck_cards WHERE deck_id = ? GROUP BY scryfall_id",
(deck_id,)
).fetchall()
in_deck_map = {row["scryfall_id"]: row["qty"] for row in deck_card_rows}
for item in items:
item["in_deck_quantity"] = in_deck_map.get(item.get("scryfall_id", ""), 0)
@router.post("/api/collection/import")
async def import_collection(body: dict):
fn = _get("import_collection")
return await fn(body)
return {"items": items, "page": page, "pageSize": pageSize, "total": total}
@router.get("/api/collection/{cardId}")
async def get_collection_card(cardId: int):
fn = _get("get_collection_card")
return await fn(cardId)
"""Get a single collection entry by ID."""
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")
return _add_image_url(_row_to_dict(row))
@router.patch("/api/collection/{cardId}")
async def update_collection_card(cardId: int, body: dict):
fn = _get("update_collection_card")
return await fn(cardId, body)
"""Update mutable fields of a collection entry."""
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")
updates = {}
if "category" in body:
cat = body["category"]
if isinstance(cat, list):
updates["category"] = json.dumps(cat)
else:
updates["category"] = cat
if "tags" in body:
updates["tags"] = str(body["tags"])
if "notes" in body:
updates["notes"] = str(body["notes"])
if "finish" in body:
finish_val = str(body["finish"]).upper()
if finish_val not in ("NORMAL", "FOIL", "ETCHED"):
raise HTTPException(400, "finish must be NORMAL, FOIL, or ETCHED")
updates["finish"] = finish_val
if not updates:
return _add_image_url(_row_to_dict(row))
updates["updated_at"] = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
set_clause = ", ".join(f"{k} = ?" for k in updates)
values = list(updates.values()) + [cardId]
conn.execute(f"UPDATE collection_entries SET {set_clause} WHERE id = ?", values)
conn.commit()
updated_row = conn.execute("SELECT * FROM collection_entries WHERE id = ?", (cardId,)).fetchone()
return _add_image_url(_row_to_dict(updated_row))
# ---------------------------------------------------------------------------
# Scryfall cache management
# ---------------------------------------------------------------------------
# ══════════════════════════════════════════════════════════════
# Collection Import Helpers
# ══════════════════════════════════════════════════════════════
# ── Scryfall Response Cache ─────────────────────────────────
# SQLite-backed cache for Scryfall API responses.
# Keyed by (name, set_code, collector_number). Default TTL: 7 days.
# Scryfall data changes infrequently (set releases ~every 3 months).
# Eliminates redundant HTTP calls across imports and re-enrichments.
SCRYFALL_CACHE_DB_PATH = Path(__file__).parent / "scryfall_cache.db"
SCRYFALL_CACHE_TTL_SECONDS = int(os.environ.get("SCRYFALL_CACHE_TTL", 7 * 24 * 3600)) # 7 days
class _ScryfallCache:
"""
Thread-safe SQLite cache for raw Scryfall JSON responses.
Schema:
cache_key TEXT PRIMARY KEY — "name|set|cn" normalised lowercase
json_blob TEXT — raw Scryfall API JSON response
fetched_at TEXT — ISO-8601 UTC timestamp
"""
def __init__(self, db_path: Path = None, ttl_seconds: int = None):
self._db_path = db_path or SCRYFALL_CACHE_DB_PATH
self._ttl = ttl_seconds or SCRYFALL_CACHE_TTL_SECONDS
self._lock = threading.Lock()
self._conn: Optional[sqlite3.Connection] = None
self._hits = 0
self._misses = 0
self._init_db()
# ── internal ────────────────────────────────────────────
def _init_db(self):
self._conn = sqlite3.connect(str(self._db_path), check_same_thread=False)
self._conn.execute("PRAGMA journal_mode=WAL")
self._conn.execute("""
CREATE TABLE IF NOT EXISTS scryfall_cache (
cache_key TEXT PRIMARY KEY,
json_blob TEXT NOT NULL,
fetched_at TEXT NOT NULL DEFAULT (datetime('now'))
)
""")
self._conn.commit()
@staticmethod
def _make_key(name: str, set_code: str, collector_number: str) -> str:
return f"{name.strip().lower()}|{(set_code or '').strip().lower()}|{(collector_number or '').strip()}"
# ── public API ──────────────────────────────────────────
def get(self, name: str, set_code: str = "", collector_number: str = "") -> Optional[dict]:
"""Return cached Scryfall JSON dict, or None if missing / expired."""
key = self._make_key(name, set_code, collector_number)
with self._lock:
row = self._conn.execute(
"SELECT json_blob, fetched_at FROM scryfall_cache WHERE cache_key = ?",
(key,),
).fetchone()
if not row:
self._misses += 1
return None
# TTL check
try:
fetched = datetime.fromisoformat(row[1])
age = (datetime.utcnow() - fetched).total_seconds()
if age > self._ttl:
self._misses += 1
return None
except (ValueError, TypeError):
self._misses += 1
return None
self._hits += 1
try:
return json.loads(row[0])
except json.JSONDecodeError:
self._misses += 1
return None
def put(self, name: str, set_code: str, collector_number: str, card_data: dict):
"""Store a Scryfall response in the cache."""
key = self._make_key(name, set_code, collector_number)
blob = json.dumps(card_data, separators=(",", ":"))
with self._lock:
self._conn.execute(
"INSERT OR REPLACE INTO scryfall_cache (cache_key, json_blob, fetched_at) "
"VALUES (?, ?, datetime('now'))",
(key, blob),
)
self._conn.commit()
def stats(self) -> dict:
"""Return cache statistics."""
with self._lock:
total = self._conn.execute("SELECT COUNT(*) FROM scryfall_cache").fetchone()[0]
oldest_row = self._conn.execute(
"SELECT MIN(fetched_at) FROM scryfall_cache"
).fetchone()
newest_row = self._conn.execute(
"SELECT MAX(fetched_at) FROM scryfall_cache"
).fetchone()
db_size_bytes = os.path.getsize(str(self._db_path)) if self._db_path.exists() else 0
return {
"total_entries": total,
"hits": self._hits,
"misses": self._misses,
"hit_rate": round(self._hits / max(self._hits + self._misses, 1) * 100, 1),
"ttl_seconds": self._ttl,
"ttl_days": round(self._ttl / 86400, 1),
"oldest_entry": oldest_row[0] if oldest_row else None,
"newest_entry": newest_row[0] if newest_row else None,
"db_size_kb": round(db_size_bytes / 1024, 1),
"db_path": str(self._db_path),
}
def clear(self) -> int:
"""Delete all cached entries. Returns count of deleted rows."""
with self._lock:
count = self._conn.execute("SELECT COUNT(*) FROM scryfall_cache").fetchone()[0]
self._conn.execute("DELETE FROM scryfall_cache")
self._conn.commit()
self._conn.execute("VACUUM") # must run outside transaction
self._hits = 0
self._misses = 0
return count
def evict_expired(self) -> int:
"""Remove entries older than TTL. Returns count of evicted rows."""
cutoff = datetime.utcnow().isoformat()
with self._lock:
self._conn.execute(
"DELETE FROM scryfall_cache WHERE "
"(julianday(?) - julianday(fetched_at)) * 86400 > ?",
(cutoff, self._ttl),
)
deleted = self._conn.total_changes
self._conn.commit()
return deleted
_scryfall_cache = _ScryfallCache()
# Scryfall rate-limit semaphore (max 10 req/sec)
_scryfall_lock = threading.Lock()
_scryfall_last_call = 0.0
def _scryfall_rate_limit():
"""Enforce at most 10 Scryfall requests per second via simple sleep."""
global _scryfall_last_call
with _scryfall_lock:
now = time.monotonic()
elapsed = now - _scryfall_last_call
if elapsed < 0.1:
time.sleep(0.1 - elapsed)
_scryfall_last_call = time.monotonic()
def _parse_finish(raw: str) -> str:
"""Normalize finish string to NORMAL|FOIL|ETCHED."""
if not raw:
return "NORMAL"
r = raw.strip().lower()
if r in ("etched", "foil etched", "foil_etched", "etched foil"):
return "ETCHED"
if r in ("yes", "foil", "true", "1"):
return "FOIL"
return "NORMAL"
def _parse_text_line(line: str) -> dict:
"""
Parse a text import line into {name, quantity, set_code, collector_number}.
Supports formats:
- "3 Sol Ring (CMR) 123"
- "1x Sol Ring (CMR)"
- "Sol Ring [CMR:123]"
- "1 Sol Ring"
"""
line = line.strip()
if not line or line.startswith("#") or line.startswith("//"):
return {}
result = {"name": "", "quantity": 1, "set_code": "", "collector_number": ""}
# Strip quantity prefix
qty_match = re.match(r"^(\d+)x?\s+", line)
if qty_match:
result["quantity"] = int(qty_match.group(1))
line = line[qty_match.end():]
# Format: "Name [SET:CollNum]"
bracket_match = re.search(r"\[([A-Za-z0-9]+):(\S+)\]\s*$", line)
if bracket_match:
result["set_code"] = bracket_match.group(1).lower()
result["collector_number"] = bracket_match.group(2)
line = line[:bracket_match.start()].strip()
result["name"] = line
return result
# Format: "Name (SET) CollNum"
paren_coll_match = re.search(r"\(([A-Za-z0-9]+)\)\s+(\S+)\s*$", line)
if paren_coll_match:
result["set_code"] = paren_coll_match.group(1).lower()
result["collector_number"] = paren_coll_match.group(2)
line = line[:paren_coll_match.start()].strip()
result["name"] = line
return result
# Format: "Name (SET)"
paren_match = re.search(r"\(([A-Za-z0-9]+)\)\s*$", line)
if paren_match:
result["set_code"] = paren_match.group(1).lower()
line = line[:paren_match.start()].strip()
result["name"] = line
return result
result["name"] = line.strip()
return result
def _auto_infer_mapping(headers: list) -> dict:
"""
Auto-infer column → field mapping from header names.
Returns dict of {header: field_key}.
Field keys: name, quantity, set_code, collector_number, finish, condition, language, notes, tags
"""
mapping = {}
header_lower = {h: h.lower().strip() for h in headers}
field_patterns = {
"name": ["name", "card name", "card_name", "cardname", "title"],
"quantity": ["quantity", "qty", "count", "amount", "number", "#"],
"set_code": ["set", "set code", "set_code", "edition", "set_name", "edition code"],
"collector_number": [
"collector number", "collector_number", "collectornumber",
"col #", "col#", "number", "card number",
],
"finish": ["finish", "foil", "printing", "treatment"],
"condition": ["condition", "cond", "grade"],
"language": ["language", "lang", "locale"],
"notes": ["notes", "note", "comments", "comment"],
"tags": ["tags", "tag", "labels", "label"],
}
for header, lower_h in header_lower.items():
for field, patterns in field_patterns.items():
if lower_h in patterns:
mapping[header] = field
break
return mapping
def _parse_csv_content(content: str, source: str, mapping: Optional[dict]) -> list:
"""
Parse CSV/text content into a list of RawImportRow dicts.
Returns list of dicts with keys: name, quantity, set_code, collector_number, finish, condition, language, notes, tags
"""
rows = []
source_upper = (source or "").upper()
if source_upper == "TEXT":
for line in content.splitlines():
parsed = _parse_text_line(line)
if parsed.get("name"):
rows.append(parsed)
return rows
# CSV-based sources
reader = csv.DictReader(io.StringIO(content))
headers = reader.fieldnames or []
# Build effective mapping
if mapping:
col_map = {h: mapping.get(h, "") for h in headers}
else:
col_map = _auto_infer_mapping(headers)
# Normalize col_map field values to lowercase so both "NAME" (UI) and "name" (auto-infer) work
col_map = {h: (v.lower() if isinstance(v, str) else v) for h, v in col_map.items()}
for csv_row in reader:
row = {
"name": "",
"quantity": 1,
"set_code": "",
"collector_number": "",
"finish": "NORMAL",
"condition": "",
"language": "",
"notes": "",
"tags": "",
}
for header, field in col_map.items():
val = csv_row.get(header, "").strip()
if not val or field == "ignore" or not field:
continue
if field == "name":
# Strip finish suffix like "(Foil Etched)"
val = re.sub(r"\s*\(foil(?: etched)?\)\s*$", "", val, flags=re.IGNORECASE).strip()
row["name"] = val
elif field == "quantity":
try:
row["quantity"] = int(float(val))
except ValueError:
row["quantity"] = 1
elif field in ("set_code", "set_code_secondary"):
row["set_code"] = val.lower()
elif field in ("collector_number", "collector_number_secondary"):
row["collector_number"] = val
elif field == "finish":
row["finish"] = _parse_finish(val)
elif field == "condition":
row["condition"] = val
elif field == "language":
row["language"] = val
elif field == "notes":
row["notes"] = val
elif field == "tags":
row["tags"] = val
if row["name"]:
rows.append(row)
return rows
def _fetch_scryfall_api(name: str, set_code: str = "", collector_number: str = "") -> dict:
"""
Fetch raw card data from Scryfall API (with caching).
Returns the raw Scryfall JSON dict, or a dict with '_error' on failure.
Cache is checked first; live HTTP call only on miss.
"""
from urllib.parse import quote
# ── Cache check ──────────────────────────────────────
cached = _scryfall_cache.get(name, set_code, collector_number)
if cached is not None:
return cached
# ── Live fetch ───────────────────────────────────────
card_data = None
last_error = ""
# Try set + collector number first
if set_code and collector_number:
_scryfall_rate_limit()
try:
url = f"https://api.scryfall.com/cards/{set_code.lower()}/{collector_number}"
req = Request(url, headers=_API_HEADERS)
with urlopen(req, timeout=10) as resp:
card_data = json.loads(resp.read().decode("utf-8"))
except Exception as e:
last_error = str(e)
card_data = None
# Fall back to exact name lookup
if not card_data:
_scryfall_rate_limit()
try:
encoded_name = quote(name)
url = f"https://api.scryfall.com/cards/named?exact={encoded_name}"
req = Request(url, headers=_API_HEADERS)
with urlopen(req, timeout=10) as resp:
card_data = json.loads(resp.read().decode("utf-8"))
except Exception as e:
last_error = str(e)
return {"_error": f"Scryfall lookup failed for '{name}': {last_error}"}
if not card_data or card_data.get("object") == "error":
err_detail = card_data.get("details", "unknown error") if card_data else last_error
return {"_error": f"Scryfall returned error for '{name}': {err_detail}"}
# ── Cache the successful response ────────────────────
_scryfall_cache.put(name, set_code, collector_number, card_data)
# Also cache under the Scryfall-resolved name (handles spelling normalisation)
resolved_name = card_data.get("name", "")
if resolved_name and resolved_name.lower() != name.strip().lower():
_scryfall_cache.put(resolved_name, set_code, collector_number, card_data)
return card_data
def _enrich_from_scryfall(name: str, set_code: str = "", collector_number: str = "") -> dict:
"""
Fetch card data from Scryfall (cached) and return enriched fields dict.
Tries set+collectorNumber first, then exact name lookup.
Returns empty dict on failure.
"""
card_data = _fetch_scryfall_api(name, set_code, collector_number)
if not card_data or "_error" in card_data:
return card_data or {}
# Extract fields
type_line = card_data.get("type_line", "")
color_identity = card_data.get("color_identity", [])
keywords = card_data.get("keywords", [])
# Parse subtypes (after em-dash or hyphen)
subtypes = []
for sep in [" \u2014 ", " - "]:
if sep in type_line:
parts = type_line.split(sep, 1)
subtypes = [s.strip() for s in parts[1].split() if s.strip()]
break
is_legendary = 1 if "Legendary" in type_line else 0
is_basic = 1 if "Basic" in type_line else 0
# Get price
prices = card_data.get("prices", {})
tcg_price = 0.0
try:
price_val = prices.get("usd") or prices.get("usd_foil") or "0"
tcg_price = float(price_val) if price_val else 0.0
except (ValueError, TypeError):
tcg_price = 0.0
# Handle double-faced cards: oracle_text may be on card_faces
oracle_text = card_data.get("oracle_text", "")
if not oracle_text and card_data.get("card_faces"):
face_texts = [f.get("oracle_text", "") for f in card_data["card_faces"]]
oracle_text = "\n//\n".join(t for t in face_texts if t)
# Power/toughness (may be on card_faces for DFCs)
power = card_data.get("power", "")
toughness = card_data.get("toughness", "")
if not power and card_data.get("card_faces"):
power = card_data["card_faces"][0].get("power", "")
toughness = card_data["card_faces"][0].get("toughness", "")
# Mana cost (may be on card_faces for DFCs)
mana_cost = card_data.get("mana_cost", "")
if not mana_cost and card_data.get("card_faces"):
mana_cost = card_data["card_faces"][0].get("mana_cost", "")
# Auto-detect functional categories
auto_roles = _detect_card_roles(oracle_text, type_line, keywords)
return {
"name": card_data.get("name", name),
"type_line": type_line,
"subtypes": json.dumps(subtypes),
"is_legendary": is_legendary,
"is_basic": is_basic,
"color_identity": json.dumps(color_identity),
"cmc": card_data.get("cmc", 0.0),
"mana_cost": mana_cost,
"oracle_text": oracle_text,
"keywords": json.dumps(keywords),
"power": power or "",
"toughness": toughness or "",
"rarity": card_data.get("rarity", ""),
"set_name": card_data.get("set_name", ""),
"edhrec_rank": card_data.get("edhrec_rank", 0) or 0,
"tcg_price": tcg_price,
"salt_score": 0.0,
"is_game_changer": 0,
"category": json.dumps(auto_roles),
"scryfall_id": card_data.get("id", ""),
"tcgplayer_id": str(card_data.get("tcgplayer_id", "")),
}
# ══════════════════════════════════════════════════════════════
# Collection Import Endpoint
# ══════════════════════════════════════════════════════════════
@router.post("/api/collection/import")
async def import_collection(body: dict):
"""
Import cards into the collection from CSV, Moxfield, Archidekt, or plain text.
Body:
{
"source": "CSV" | "MOXFIELD" | "ARCHIDEKT" | "TEXT",
"mode": "MERGE" | "REPLACE",
"content": "...csv or text content...",
"mapping": { ... optional column mapping ... }
}
"""
source = str(body.get("source", "CSV")).upper()
mode = str(body.get("mode", "MERGE")).upper()
content = str(body.get("content", ""))
mapping = body.get("mapping", {})
if not content.strip():
raise HTTPException(400, "content is required")
imported_count = 0
updated_count = 0
failed_count = 0
errors = []
conn = _get_db_conn()
# REPLACE mode: clear existing entries
if mode == "REPLACE":
conn.execute("DELETE FROM collection_entries")
conn.commit()
# Parse the content into raw rows
try:
raw_rows = _parse_csv_content(content, source, mapping if mapping else None)
except Exception as e:
raise HTTPException(400, f"Failed to parse content: {str(e)}")
for raw in raw_rows:
name = raw.get("name", "").strip()
if not name:
continue
quantity = int(raw.get("quantity", 1))
set_code = raw.get("set_code", "").lower()
collector_number = raw.get("collector_number", "")
finish = _parse_finish(raw.get("finish", ""))
condition = raw.get("condition", "")
language = raw.get("language", "")
notes = raw.get("notes", "")
tags = raw.get("tags", "")
# Scryfall enrichment
try:
enriched = _enrich_from_scryfall(name, set_code, collector_number)
except Exception as e:
errors.append(f"Scryfall error for '{name}': {str(e)}")
failed_count += 1
continue
if not enriched or "_error" in enriched:
err_msg = enriched.get("_error", f"Card not found on Scryfall: '{name}'") if enriched else f"Card not found on Scryfall: '{name}'"
errors.append(err_msg)
failed_count += 1
continue
# Use Scryfall-resolved name if available
resolved_name = enriched.get("name", name)
# Check for existing entry (identity key)
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 and mode == "MERGE":
# Update quantity
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_count += 1
else:
# Insert new row
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_count += 1
return {
"importedCount": imported_count,
"updatedCount": updated_count,
"failedCount": failed_count,
"errors": errors,
}
# ══════════════════════════════════════════════════════════════
# Scryfall Cache Management Endpoints
# ══════════════════════════════════════════════════════════════
@router.get("/api/cache/scryfall")
async def scryfall_cache_stats():
fn = _get("scryfall_cache_stats")
return await fn()
"""Return Scryfall response cache statistics."""
return _scryfall_cache.stats()
@router.delete("/api/cache/scryfall")
async def scryfall_cache_clear():
fn = _get("scryfall_cache_clear")
return await fn()
"""Clear all cached Scryfall responses."""
deleted = _scryfall_cache.clear()
return {"cleared": deleted, "message": f"Deleted {deleted} cached entries"}
@router.post("/api/cache/scryfall/evict")
async def scryfall_cache_evict_expired():
fn = _get("scryfall_cache_evict_expired")
return await fn()
"""Remove only expired entries (older than TTL) from the cache."""
evicted = _scryfall_cache.evict_expired()
return {"evicted": evicted, "message": f"Evicted {evicted} expired entries"}
# ══════════════════════════════════════════════════════════════
# Deck Builder Helpers
# ══════════════════════════════════════════════════════════════
# Type classification priority order
_TYPE_PRIORITY = ["Land", "Instant", "Sorcery", "Artifact", "Enchantment", "Planeswalker", "Creature"]
# Target ranges: {type: [min, max]}
_TYPE_TARGETS = {
"Land": [36, 38],
"Instant": [9, 11],
"Sorcery": [7, 9],
"Artifact": [9, 11],
"Creature": [20, 30],
"Enchantment": [5, 10],
"Planeswalker": [0, 5],
}
+1031 -85
View File
File diff suppressed because it is too large. Load diff
+28 -34
View File
@@ -1,43 +1,38 @@
"""
routes/import_routes.py
=======================
Deck import + commander meta endpoints:
Deck import & commander meta endpoints:
POST /api/lab/import/url
POST /api/lab/import/text
GET /api/lab/meta/commanders
GET /api/lab/meta/search
POST /api/lab/meta/fetch
"""
from __future__ import annotations
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from typing import Optional
from routes.shared import get as _get
from fastapi import APIRouter, HTTPException, BackgroundTasks
from fastapi.responses import JSONResponse
router = APIRouter(prefix="/api/lab", tags=["import"])
from routes.shared import (
COMMANDER_META,
ImportUrlRequest,
ImportTextRequest,
MetaFetchRequest,
_import_from_url,
_parse_text_decklist,
_save_profile_to_dck,
_fetch_edhrec_average,
log,
)
router = APIRouter(tags=["import"])
class ImportUrlRequest(BaseModel):
url: str
class ImportTextRequest(BaseModel):
text: str
commander: Optional[str] = None
class MetaFetchRequest(BaseModel):
commander: str
@router.post("/import/url")
@router.post("/api/lab/import/url")
async def import_from_url(req: ImportUrlRequest):
"""Import a deck from Archidekt or EDHREC URL."""
_import_from_url = _get("_import_from_url")
_save_profile_to_dck = _get("_save_profile_to_dck")
"""Import a deck from Archidekt or EDHREC URL. Returns parsed DeckProfile + saves .dck file."""
url = req.url.strip()
try:
profile = _import_from_url(url)
@@ -57,11 +52,9 @@ async def import_from_url(req: ImportUrlRequest):
raise HTTPException(400, str(e))
@router.post("/import/text")
@router.post("/api/lab/import/text")
async def import_from_text(req: ImportTextRequest):
"""Import a deck from plain text card list."""
_parse_text_decklist = _get("_parse_text_decklist")
_save_profile_to_dck = _get("_save_profile_to_dck")
try:
profile = _parse_text_decklist(req.text, req.commander)
dck_path = _save_profile_to_dck(profile)
@@ -77,10 +70,9 @@ async def import_from_text(req: ImportTextRequest):
raise HTTPException(400, str(e))
@router.get("/meta/commanders")
@router.get("/api/lab/meta/commanders")
async def list_meta_commanders():
"""List available commanders in the meta mapping."""
COMMANDER_META = _get("COMMANDER_META")
commanders = []
for name, entries in COMMANDER_META.items():
entry = entries[0] if entries else {}
@@ -93,10 +85,9 @@ async def list_meta_commanders():
return {"commanders": commanders}
@router.get("/meta/search")
@router.get("/api/lab/meta/search")
async def search_meta_commanders(q: str = ""):
"""Search commanders by partial name match."""
COMMANDER_META = _get("COMMANDER_META")
query = q.lower()
matches = []
for name, entries in COMMANDER_META.items():
@@ -110,11 +101,9 @@ async def search_meta_commanders(q: str = ""):
return {"results": matches}
@router.post("/meta/fetch")
@router.post("/api/lab/meta/fetch")
async def fetch_meta_deck(req: MetaFetchRequest):
"""Fetch EDHREC average deck for a commander."""
_fetch_edhrec_average = _get("_fetch_edhrec_average")
_save_profile_to_dck = _get("_save_profile_to_dck")
"""Fetch EDHREC average deck for a commander and save as .dck file."""
try:
profile = _fetch_edhrec_average(req.commander)
dck_path = _save_profile_to_dck(profile)
@@ -131,3 +120,8 @@ async def fetch_meta_deck(req: MetaFetchRequest):
}
except Exception as e:
raise HTTPException(400, f"Failed to fetch meta deck for '{req.commander}': {str(e)}")
# ══════════════════════════════════════════════════════════════
# Existing Endpoints (v1/v2)
# ══════════════════════════════════════════════════════════════
+386 -81
View File
@@ -1,7 +1,7 @@
"""
routes/lab.py
=============
Simulator endpoints:
Batch simulation & lab management endpoints:
POST /api/lab/start
POST /api/lab/start-deepseek
GET /api/lab/status
@@ -14,126 +14,431 @@ Simulator endpoints:
GET /api/lab/trends/{deck_name}
GET /api/lab/log
GET /api/lab/debug-log
All heavy logic (run_batch_subprocess, _run_deepseek_batch_thread,
build_java_command, parse_dck_file, AI_PROFILES dict) lives in
lab_api.py until the full extraction is complete; this router
delegate-calls those functions via routes.shared.get().
"""
from __future__ import annotations
import glob
import json
import os
import threading
import uuid
from datetime import datetime
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request as FARequest
from fastapi import APIRouter, HTTPException, BackgroundTasks, Request as FastAPIRequest
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from routes.shared import get as _get
from routes.shared import (
CFG,
active_batches,
BatchState,
StartRequest,
StartResponse,
StatusResponse,
AI_PROFILES,
PRECON_DIR,
PRECON_INDEX,
build_java_command,
run_batch_subprocess,
_run_deepseek_batch_thread,
parse_dck_file,
_get_db_conn,
_load_deck_cards_by_name,
_ml_logging_enabled,
log,
log_batch,
)
router = APIRouter(prefix="/api/lab", tags=["simulator"])
router = APIRouter(tags=["lab"])
# ---------------------------------------------------------------------------
# Local lightweight models (don't need shared state to define)
# ---------------------------------------------------------------------------
@router.post("/api/lab/start", response_model=StartResponse)
async def start_batch(req: StartRequest, background_tasks: BackgroundTasks):
"""Start a new batch simulation run."""
if len(req.decks) < 2 or len(req.decks) > 4:
raise HTTPException(400, "2-4 decks required")
if not all(req.decks):
raise HTTPException(400, f"All {len(req.decks)} deck slots must be filled")
if req.numGames < 1 or req.numGames > 10000:
raise HTTPException(400, "numGames must be 1-10000")
if req.threads < 1 or req.threads > 16:
raise HTTPException(400, "threads must be 1-16")
class StartResponse(BaseModel):
batchId: str
status: str = "started"
message: str = ""
if not os.path.exists(CFG.forge_jar):
raise HTTPException(500, f"Forge JAR not found: {CFG.forge_jar}")
if not os.path.isdir(CFG.forge_dir):
raise HTTPException(500, f"Forge dir not found: {CFG.forge_dir}")
batch_id = str(uuid.uuid4())[:12]
state = BatchState(batch_id, req.numGames, req.threads)
active_batches[batch_id] = state
os.makedirs(CFG.results_dir, exist_ok=True)
output_path = os.path.join(CFG.results_dir, f"batch-{batch_id}.json")
state.result_path = output_path
background_tasks.add_task(
run_batch_subprocess,
state,
req.decks,
req.numGames,
req.threads,
req.seed,
req.clock,
output_path,
req.useLearnedPolicy,
req.policyStyle,
req.policyGreedy,
req.aiSimplified,
req.aiThinkTimeMs,
req.maxQueueDepth,
)
policy_msg = ""
if req.useLearnedPolicy:
policy_msg = f" [Learned Policy: {req.policyStyle}]"
return StartResponse(
batchId=batch_id,
status="started",
message=f"Running {req.numGames} games with {req.threads} threads{policy_msg}",
)
class StatusResponse(BaseModel):
batchId: str = ""
running: bool = False
completed: int = 0
total: int = 0
threads: int = 0
elapsedMs: int = 0
error: Optional[str] = None
simsPerSec: float = 0.0
run_id: str = ""
games_completed: int = 0
total_games: int = 0
current_decks: list = []
# ---------------------------------------------------------------------------
# Simulator endpoints
# ---------------------------------------------------------------------------
@router.post("/start", response_model=StartResponse)
async def start_batch(req: FARequest, background_tasks: BackgroundTasks):
"""Delegate to lab_api.start_batch (registered via shared registry)."""
fn = _get("start_batch")
return await fn(req, background_tasks)
@router.post("/start-deepseek")
async def start_batch_deepseek(request: FARequest, background_tasks: BackgroundTasks):
fn = _get("start_batch_deepseek")
return await fn(request, background_tasks)
@router.get("/status", response_model=StatusResponse)
@router.get("/api/lab/status", response_model=StatusResponse)
async def get_status(batchId: Optional[str] = None):
fn = _get("get_status")
return await fn(batchId=batchId)
# If no batchId given, find the most recent active (or last) batch
state = None
if batchId:
state = active_batches.get(batchId)
else:
# Return the most recently active batch, or any batch
for s in active_batches.values():
if s.running:
state = s
break
if state is None and active_batches:
state = list(active_batches.values())[-1]
if not state:
return StatusResponse(
batchId="", running=False, completed=0, total=0,
threads=0, elapsedMs=0, error=None, simsPerSec=0.0,
run_id="", games_completed=0, total_games=0, current_decks=[],
)
elapsed = int((datetime.now() - state.start_time).total_seconds() * 1000)
deck_names = getattr(state, 'deck_names', []) if hasattr(state, 'deck_names') else []
return StatusResponse(
batchId=state.batch_id,
running=state.running,
completed=state.completed_games,
total=state.total_games,
threads=state.threads,
elapsedMs=elapsed if state.running else state.elapsed_ms,
error=state.error,
simsPerSec=state.sims_per_sec,
# React SPA fields
run_id=state.batch_id,
games_completed=state.completed_games,
total_games=state.total_games,
current_decks=deck_names,
)
@router.get("/result")
@router.get("/api/lab/result")
async def get_result(batchId: Optional[str] = None):
fn = _get("get_result")
return await fn(batchId=batchId)
state = None
if batchId:
state = active_batches.get(batchId)
else:
# Find most recent completed batch
for s in reversed(list(active_batches.values())):
if not s.running and s.result_path:
state = s
break
if not state:
raise HTTPException(404, "No batch result available")
if state.running:
raise HTTPException(409, "Batch still running")
if state.error:
raise HTTPException(500, state.error)
if not state.result_path or not os.path.exists(state.result_path):
raise HTTPException(500, "Result file not found")
with open(state.result_path, "r", encoding="utf-8") as f:
data = json.load(f)
return JSONResponse(content=data)
@router.get("/decks")
@router.get("/api/lab/decks")
async def list_decks():
fn = _get("list_decks")
return await fn()
decks = []
seen_names = set()
# 1. Forge .dck files from decks directory
decks_dir = CFG.forge_decks_dir
if decks_dir and os.path.isdir(decks_dir):
for f in sorted(Path(decks_dir).glob("*.dck")):
deck_name = f.stem
decks.append({"name": deck_name, "filename": f.name, "source": "forge"})
seen_names.add(deck_name.lower())
# 2. Decks from the Deck Builder database
try:
conn = _get_db_conn()
rows = conn.execute(
"SELECT id, name, commander_name FROM decks ORDER BY id DESC"
).fetchall()
for row in rows:
db_name = row["name"]
if db_name.lower() not in seen_names:
decks.append({
"name": db_name,
"filename": "",
"source": "deckbuilder",
"deck_id": row["id"],
"commander": row["commander_name"] or "",
})
seen_names.add(db_name.lower())
except Exception as e:
log.warning(f" WARNING: Failed to load DB decks for /api/lab/decks: {e}")
return {"decks": decks}
@router.get("/history")
@router.get("/api/lab/history")
async def list_history():
fn = _get("list_history")
return await fn()
results_dir = Path(CFG.results_dir)
if not results_dir.exists():
return {"results": []}
results = []
for f in sorted(results_dir.glob("batch-*.json"), reverse=True):
try:
with open(f, "r", encoding="utf-8") as fh:
data = json.load(fh)
meta = data.get("metadata", {})
decks = data.get("decks", [])
results.append({
"filename": f.name,
"batchId": meta.get("batchId", ""),
"timestamp": meta.get("timestamp", ""),
"totalGames": meta.get("completedGames", 0),
"threads": meta.get("threads", 1),
"elapsedMs": meta.get("elapsedMs", 0),
"decks": [{"name": d.get("deckName", ""), "source": d.get("source", "")} for d in decks],
})
except Exception:
continue
return {"results": results[:50]}
@router.get("/profiles")
# ════════════════════════════════════════════════════════════
# AI Profiles
# ════════════════════════════════════════════════════════════
AI_PROFILES = {
"default": {
"name": "default",
"description": "Balanced \u2014 Forge's default AI behavior",
"aggression": 0.5, "cardAdvantage": 0.5, "removalPriority": 0.5,
"boardPresence": 0.5, "comboPriority": 0.3, "patience": 0.5,
},
"aggro": {
"name": "aggro",
"description": "Aggressive \u2014 attacks early, prioritizes damage",
"aggression": 0.9, "cardAdvantage": 0.3, "removalPriority": 0.3,
"boardPresence": 0.8, "comboPriority": 0.1, "patience": 0.1,
},
"control": {
"name": "control",
"description": "Control \u2014 defensive, removal-heavy, card advantage",
"aggression": 0.2, "cardAdvantage": 0.9, "removalPriority": 0.9,
"boardPresence": 0.3, "comboPriority": 0.4, "patience": 0.9,
},
"combo": {
"name": "combo",
"description": "Combo \u2014 ramps, digs for pieces, assembles combos",
"aggression": 0.2, "cardAdvantage": 0.8, "removalPriority": 0.4,
"boardPresence": 0.3, "comboPriority": 0.95, "patience": 0.7,
},
"midrange": {
"name": "midrange",
"description": "Midrange \u2014 flexible, strong board presence, value-oriented",
"aggression": 0.5, "cardAdvantage": 0.6, "removalPriority": 0.6,
"boardPresence": 0.7, "comboPriority": 0.3, "patience": 0.5,
},
}
@router.get("/api/lab/profiles")
async def list_profiles():
fn = _get("list_profiles")
return await fn()
return {"profiles": list(AI_PROFILES.values())}
@router.get("/profiles/{name}")
@router.get("/api/lab/profiles/{name}")
async def get_profile(name: str):
fn = _get("get_profile")
return await fn(name)
profile = AI_PROFILES.get(name.lower())
if not profile:
raise HTTPException(404, f"Profile '{name}' not found. Available: {list(AI_PROFILES.keys())}")
return profile
@router.get("/analytics/{deck_name}")
@router.get("/api/lab/analytics/{deck_name}")
async def analyze_deck(deck_name: str):
fn = _get("analyze_deck")
return await fn(deck_name)
decks_dir = CFG.forge_decks_dir
if not decks_dir or not os.path.isdir(decks_dir):
raise HTTPException(500, f"Decks directory not found: {decks_dir}")
deck_path = os.path.join(decks_dir, deck_name + ".dck")
if not os.path.exists(deck_path):
for f in os.listdir(decks_dir):
if f.lower() == deck_name.lower() + ".dck":
deck_path = os.path.join(decks_dir, f)
break
else:
raise HTTPException(404, f"Deck not found: {deck_name}")
try:
analysis = parse_dck_file(deck_path)
return analysis
except Exception as e:
raise HTTPException(500, f"Analysis failed: {str(e)}")
@router.get("/trends/{deck_name}")
def parse_dck_file(deck_path: str) -> dict:
cards = []
commander = None
section = "Main"
deck_name = Path(deck_path).stem
with open(deck_path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line or line.startswith("//"):
continue
if line.startswith("[") and line.endswith("]"):
section = line[1:-1]
continue
if line.startswith("Name="):
deck_name = line[5:].strip()
continue
m = re.match(r"^(\d+)\s+(.+?)(?:\|(.+))?$", line)
if m:
qty = int(m.group(1))
name = m.group(2).strip()
set_code = m.group(3).strip() if m.group(3) else ""
cards.append({
"quantity": qty, "name": name, "set": set_code,
"section": section,
"is_commander": 1 if section == "Commander" else 0,
})
if section == "Commander":
commander = name
total = sum(c["quantity"] for c in cards)
return {
"deckName": deck_name,
"commanderName": commander,
"totalCards": total,
"cardCount": len(cards),
"cards": cards[:200],
}
@router.get("/api/lab/trends/{deck_name}")
async def get_deck_trends(deck_name: str):
fn = _get("get_deck_trends")
return await fn(deck_name)
results_dir = Path(CFG.results_dir)
if not results_dir.exists():
return {"deckName": deck_name, "history": []}
history = []
for f in sorted(results_dir.glob("batch-*.json")):
try:
with open(f, "r", encoding="utf-8") as fh:
data = json.load(fh)
summary = data.get("summary", {})
meta = data.get("metadata", {})
per_deck = summary.get("perDeck", [])
for ds in per_deck:
if ds.get("deckName", "").lower() == deck_name.lower():
history.append({
"batchId": meta.get("batchId"),
"timestamp": meta.get("timestamp"),
"winRate": ds.get("winRate", 0),
"wins": ds.get("wins", 0),
"losses": ds.get("losses", 0),
"draws": ds.get("draws", 0),
"totalGames": meta.get("completedGames", 0),
})
break
except Exception:
continue
return {"deckName": deck_name, "history": history}
@router.get("/log")
@router.get("/api/lab/log")
async def get_log(batchId: str):
fn = _get("get_log")
return await fn(batchId=batchId)
state = active_batches.get(batchId)
if not state:
raise HTTPException(404, f"Batch {batchId} not found")
return {"lines": state.log_lines[-200:]}
@router.get("/debug-log")
@router.get("/api/lab/debug-log")
async def get_debug_log():
fn = _get("get_debug_log")
return await fn()
"""Return the raw Forge subprocess debug log for diagnosing simulation issues."""
# Check multiple possible locations for the debug log
candidates = []
if CFG.forge_dir:
candidates.append(Path(CFG.forge_dir).parent / "forge-sim-debug.log")
candidates.append(Path(CFG.forge_dir) / "forge-sim-debug.log")
candidates.append(Path("forge-sim-debug.log"))
for log_path in candidates:
if log_path.exists():
text = log_path.read_text(encoding="utf-8", errors="replace")
# Return last 50KB to avoid huge responses
if len(text) > 50000:
text = "... (truncated) ...\n" + text[-50000:]
return {"path": str(log_path), "content": text}
return {"path": None, "content": "No debug log found. Run a simulation first."}
@router.post('/api/lab/start-deepseek')
async def start_batch_deepseek(request: FastAPIRequest, background_tasks: BackgroundTasks):
"""Start a batch simulation using the Python sim engine + DeepSeek LLM opponent."""
body = await request.json()
decks = body.get('decks', [])
num_games = body.get('numGames', 30)
# Note: threads not applicable for Python sim (single-threaded LLM calls)
if not decks or len(decks) < 1:
raise HTTPException(400, 'At least 1 deck required')
if num_games < 1 or num_games > 500:
raise HTTPException(400, 'numGames must be 1-500')
# Filter empty deck slots
decks = [d for d in decks if d]
if not decks:
raise HTTPException(400, 'No valid decks provided')
batch_id = str(uuid.uuid4())[:12]
state = BatchState(batch_id, num_games, 1)
active_batches[batch_id] = state
os.makedirs(CFG.results_dir, exist_ok=True)
output_path = os.path.join(CFG.results_dir, f'batch-{batch_id}.json')
state.result_path = output_path
# Use threading instead of BackgroundTasks to avoid async issues with DB
t = threading.Thread(
target=_run_deepseek_batch_thread,
args=(state, decks, num_games, output_path),
daemon=True,
)
t.start()
return JSONResponse({
'batchId': batch_id,
'status': 'started',
'message': f'Running {num_games} games across {len(decks)} decks with DeepSeek AI',
'engine': 'deepseek',
})
# Global ML logging toggle (can be enabled via API)
_ml_logging_enabled = False
+26 -17
View File
@@ -7,43 +7,51 @@ Precon deck management endpoints:
POST /api/lab/precons/install-batch
POST /api/lab/precons/refresh
"""
from __future__ import annotations
import os
import shutil
from pathlib import Path
from fastapi import APIRouter, HTTPException
from routes.shared import get as _get
from routes.shared import (
CFG,
PRECON_INDEX,
PRECON_DIR,
download_precon_database,
log,
)
router = APIRouter(prefix="/api/lab", tags=["precon"])
router = APIRouter(tags=["precon"])
@router.get("/precons")
@router.get("/api/lab/precons")
async def list_precons():
"""List all available precon decks."""
PRECON_INDEX = _get("PRECON_INDEX")
return {"precons": PRECON_INDEX}
@router.post("/precons/install")
@router.post("/api/lab/precons/install")
async def install_precon(req: dict):
"""Install a precon deck to the Forge decks directory."""
PRECON_DIR = _get("PRECON_DIR")
CFG = _get("CFG")
import os
"""Install a precon deck to the Forge decks directory.
Body: {"fileName": "Elven_Empire.dck"}
"""
file_name = req.get("fileName", "")
if not file_name:
raise HTTPException(400, "fileName is required")
src = PRECON_DIR / file_name
if not src.exists():
raise HTTPException(404, f"Precon not found: {file_name}")
decks_dir = CFG.forge_decks_dir
if not decks_dir or not os.path.isdir(decks_dir):
raise HTTPException(500, f"Forge decks directory not found: {decks_dir}")
dst = Path(decks_dir) / file_name
shutil.copy2(str(src), str(dst))
deck_name = file_name.replace(".dck", "")
return {
"installed": True,
@@ -53,18 +61,19 @@ async def install_precon(req: dict):
}
@router.post("/precons/install-batch")
@router.post("/api/lab/precons/install-batch")
async def install_precons_batch(req: dict):
"""Install multiple precon decks at once."""
PRECON_DIR = _get("PRECON_DIR")
CFG = _get("CFG")
import os
"""Install multiple precon decks at once.
Body: {"fileNames": ["Elven_Empire.dck", "Necron_Dynasties.dck"]}
"""
file_names = req.get("fileNames", [])
if not file_names:
raise HTTPException(400, "fileNames list is required")
decks_dir = CFG.forge_decks_dir
if not decks_dir or not os.path.isdir(decks_dir):
raise HTTPException(500, f"Forge decks directory not found: {decks_dir}")
results = []
for file_name in file_names:
src = PRECON_DIR / file_name
@@ -75,13 +84,13 @@ async def install_precons_batch(req: dict):
shutil.copy2(str(src), str(dst))
deck_name = file_name.replace(".dck", "")
results.append({"fileName": file_name, "installed": True, "deckName": deck_name})
return {"results": results}
@router.post("/precons/refresh")
@router.post("/api/lab/precons/refresh")
async def refresh_precons():
"""Force re-download of all Commander precon decks from GitHub."""
download_precon_database = _get("download_precon_database")
result = download_precon_database(force=True)
if result.get("error"):
raise HTTPException(502, result["error"])
+20
View File
@@ -951,6 +951,26 @@ def _enrich_from_scryfall(name: str, set_code: str = "", collector_number: str =
}
def _scryfall_fuzzy_lookup(name: str) -> Optional[dict]:
"""
Fuzzy-search Scryfall for a card name (used by the scanner pipeline).
Returns the raw Scryfall JSON dict, or None on failure.
"""
from urllib.parse import quote
_scryfall_rate_limit()
try:
encoded = quote(name)
url = f"https://api.scryfall.com/cards/named?fuzzy={encoded}"
req = Request(url, headers=_API_HEADERS)
with urlopen(req, timeout=10) as resp:
data = json.loads(resp.read().decode("utf-8"))
if data.get("object") == "error":
return None
return data
except Exception:
return None
# ══════════════════════════════════════════════════════════════
# Import Helpers
# ══════════════════════════════════════════════════════════════