refactor: extract scanner (6) + deepseek/sim (9) routes

- routes/scanner.py: scan, scan/add, re-enrich, auto-classify, auto-classify-all, edhrec
- routes/deepseek.py: sim/run, sim/status, sim/result, sim/run-from-deck,
  sim/run-deepseek, deepseek/connect, deepseek/status, deepseek/configure, deepseek/logs
- lab_api.py: 4464 -> 3265 lines (42 remaining endpoints)
- Total: 64 extracted + 42 remaining = 106 endpoints verified
This commit is contained in:
KoalaTrapLord committed 2026-03-15 06:56:02 +00:00
1 parent 463b7c1483
commit 87a2090c68
3 files changed
+1253 -1203

No files matched your search

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