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:
1 parent
2c6748a736
commit
36077c94ab
10 files changed
+2456
-4911
No files matched your search
+75
-4460
File diff suppressed because it is too large.
Load diff
+4
-3
@@ -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.
|
||||
"""
|
||||
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
+28
-34
@@ -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
@@ -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
@@ -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"])
|
||||
|
||||
@@ -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
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
Reference in new issue
Block a user