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:
1 parent
463b7c1483
commit
87a2090c68
3 files changed
+1253
-1203
No files matched your search
+4
-1203
File diff suppressed because it is too large.
Load diff
@@ -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',
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user