Starts from upstream 54cc5e9. Cards reach the simulator with type and text (filled from the Scryfall bulk DB) and the right quantities; the LLM engine asks several questions per turn and with reasoning off; Moxfield and Archidekt deck links work in every import and card names resolve locally; deck builds are fitted to the commander's colour identity; Scryfall format/User-Agent/429 handling fixed; coach init fixed. See FARMHOUSE-CHANGES.md.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Er49soaMyG7Yburq57PeB
379 lines
13 KiB
Python
379 lines
13 KiB
Python
"""
|
|
services/scryfall_bulk.py
|
|
=========================
|
|
Downloads and indexes the full Scryfall `default_cards` bulk export into a
|
|
local SQLite database with FTS5 full-text search over card names and oracle
|
|
text. This replaces per-card live Scryfall API calls for the RAG pipeline.
|
|
|
|
Usage (one-time or nightly):
|
|
from services.scryfall_bulk import ensure_bulk_db
|
|
ensure_bulk_db() # call at startup; no-ops if data is fresh
|
|
|
|
Public API:
|
|
ensure_bulk_db() -> None (download if stale)
|
|
lookup_card(name) -> dict | None
|
|
search_oracle(query, colors, limit) -> list[dict]
|
|
get_bulk_db() -> sqlite3.Connection
|
|
|
|
Data path: ./data/scryfall_bulk.db
|
|
Re-download: every SCRYFALL_BULK_STALENESS_DAYS days (default 7)
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import sqlite3
|
|
import threading
|
|
import time
|
|
from pathlib import Path
|
|
from urllib.request import Request, urlopen
|
|
|
|
logger = logging.getLogger("commander_ai_lab.scryfall_bulk")
|
|
|
|
# ── Config ──────────────────────────────────────────────────────────────────
|
|
BULK_DB_PATH = Path(__file__).parent.parent / "data" / "scryfall_bulk.db"
|
|
BULK_META_URL = "https://api.scryfall.com/bulk-data"
|
|
SCRYFALL_BULK_STALENESS_DAYS = int(
|
|
os.environ.get("SCRYFALL_BULK_STALENESS_DAYS", "7")
|
|
)
|
|
_API_HEADERS = {"User-Agent": "CommanderAILab/3.0", "Accept": "application/json"}
|
|
|
|
# ── Module-level singletons ──────────────────────────────────────────────────
|
|
_bulk_conn: sqlite3.Connection | None = None
|
|
_bulk_lock = threading.Lock()
|
|
|
|
|
|
# ── DB init ──────────────────────────────────────────────────────────────────
|
|
|
|
def _init_db(conn: sqlite3.Connection) -> None:
|
|
"""Create tables and FTS5 virtual table on first run."""
|
|
conn.execute("PRAGMA journal_mode=WAL")
|
|
conn.execute("PRAGMA synchronous=NORMAL")
|
|
conn.executescript("""
|
|
CREATE TABLE IF NOT EXISTS cards (
|
|
scryfall_id TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
oracle_text TEXT DEFAULT '',
|
|
type_line TEXT DEFAULT '',
|
|
mana_cost TEXT DEFAULT '',
|
|
cmc REAL DEFAULT 0,
|
|
color_identity TEXT DEFAULT '[]',
|
|
keywords TEXT DEFAULT '[]',
|
|
power TEXT DEFAULT '',
|
|
toughness TEXT DEFAULT '',
|
|
rarity TEXT DEFAULT '',
|
|
edhrec_rank INTEGER DEFAULT 0,
|
|
legalities TEXT DEFAULT '{}',
|
|
rulings_uri TEXT DEFAULT '',
|
|
set_code TEXT DEFAULT '',
|
|
updated_at TEXT DEFAULT (datetime('now'))
|
|
);
|
|
|
|
CREATE VIRTUAL TABLE IF NOT EXISTS cards_fts USING fts5(
|
|
name,
|
|
oracle_text,
|
|
content='cards',
|
|
content_rowid='rowid'
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS meta (
|
|
key TEXT PRIMARY KEY,
|
|
value TEXT NOT NULL
|
|
);
|
|
""")
|
|
conn.commit()
|
|
|
|
|
|
# ── Staleness check ───────────────────────────────────────────────────────────
|
|
|
|
def _is_stale(conn: sqlite3.Connection) -> bool:
|
|
row = conn.execute(
|
|
"SELECT value FROM meta WHERE key = 'last_download'"
|
|
).fetchone()
|
|
if not row:
|
|
return True
|
|
try:
|
|
age_days = (time.time() - float(row[0])) / 86400
|
|
return age_days > SCRYFALL_BULK_STALENESS_DAYS
|
|
except (ValueError, TypeError):
|
|
return True
|
|
|
|
|
|
# ── Download + index ──────────────────────────────────────────────────────────
|
|
|
|
def _get_bulk_download_url() -> str:
|
|
"""Fetch the current default_cards download URL from the Scryfall manifest."""
|
|
req = Request(BULK_META_URL, headers=_API_HEADERS)
|
|
with urlopen(req, timeout=15) as resp:
|
|
meta = json.loads(resp.read())
|
|
for entry in meta.get("data", []):
|
|
if entry.get("type") == "default_cards":
|
|
return entry.get("jsonl_download_uri") or entry["download_uri"] # FARMHOUSE_SCRYFALL_URL_V1
|
|
raise RuntimeError("Could not find default_cards entry in Scryfall bulk-data manifest")
|
|
|
|
|
|
def _download_and_index(conn: sqlite3.Connection) -> int:
|
|
"""
|
|
Download the full default_cards JSON from Scryfall and index into SQLite.
|
|
Replaces all existing rows. Returns number of cards indexed.
|
|
"""
|
|
logger.info("Fetching Scryfall bulk-data manifest...")
|
|
url = _get_bulk_download_url()
|
|
logger.info("Downloading Scryfall default_cards bulk export (~100 MB)...")
|
|
|
|
req = Request(url, headers=_API_HEADERS) # FARMHOUSE_SCRYFALL_JSONL_V1
|
|
_resp = urlopen(req, timeout=300)
|
|
|
|
def _cards_stream():
|
|
import gzip
|
|
stream = gzip.GzipFile(fileobj=_resp) if url.split("?")[0].endswith(".gz") else _resp
|
|
for _line in stream:
|
|
_line = _line.strip().rstrip(b",")
|
|
if _line and _line not in (b"[", b"]"):
|
|
yield json.loads(_line)
|
|
|
|
cards_json = _cards_stream()
|
|
|
|
# Wipe existing data for a clean rebuild
|
|
conn.execute("DELETE FROM cards")
|
|
conn.execute("DELETE FROM cards_fts")
|
|
conn.commit()
|
|
|
|
BATCH_SIZE = 1000
|
|
inserted = 0
|
|
batch: list[tuple] = []
|
|
|
|
_SKIP_LAYOUTS = {"token", "emblem", "art_series", "double_faced_token"}
|
|
|
|
for card in cards_json:
|
|
if card.get("layout") in _SKIP_LAYOUTS:
|
|
continue
|
|
|
|
# Handle double-faced / meld cards: concatenate face oracle texts
|
|
oracle = card.get("oracle_text", "")
|
|
if not oracle and card.get("card_faces"):
|
|
oracle = "\n//\n".join(
|
|
f.get("oracle_text", "")
|
|
for f in card["card_faces"]
|
|
if f.get("oracle_text")
|
|
)
|
|
|
|
mana_cost = card.get("mana_cost", "")
|
|
if not mana_cost and card.get("card_faces"):
|
|
mana_cost = card["card_faces"][0].get("mana_cost", "")
|
|
|
|
batch.append((
|
|
card.get("id", ""),
|
|
card.get("name", ""),
|
|
oracle,
|
|
card.get("type_line", ""),
|
|
mana_cost,
|
|
float(card.get("cmc") or 0),
|
|
json.dumps(card.get("color_identity", [])),
|
|
json.dumps(card.get("keywords", [])),
|
|
card.get("power", "") or "",
|
|
card.get("toughness", "") or "",
|
|
card.get("rarity", ""),
|
|
int(card.get("edhrec_rank") or 0),
|
|
json.dumps(card.get("legalities", {})),
|
|
card.get("rulings_uri", "") or "",
|
|
card.get("set", ""),
|
|
))
|
|
|
|
if len(batch) >= BATCH_SIZE:
|
|
_flush_batch(conn, batch)
|
|
inserted += len(batch)
|
|
batch.clear()
|
|
logger.debug(f" Indexed {inserted} cards...")
|
|
|
|
if batch:
|
|
_flush_batch(conn, batch)
|
|
inserted += len(batch)
|
|
|
|
# Rebuild FTS5 index from cards table
|
|
conn.execute("INSERT INTO cards_fts(cards_fts) VALUES('rebuild')")
|
|
|
|
# Record download timestamp
|
|
conn.execute(
|
|
"INSERT OR REPLACE INTO meta (key, value) VALUES ('last_download', ?)",
|
|
(str(time.time()),)
|
|
)
|
|
conn.execute(
|
|
"INSERT OR REPLACE INTO meta (key, value) VALUES ('card_count', ?)",
|
|
(str(inserted),)
|
|
)
|
|
conn.commit()
|
|
|
|
logger.info(f"Scryfall bulk index complete: {inserted} cards stored.")
|
|
return inserted
|
|
|
|
|
|
def _flush_batch(conn: sqlite3.Connection, batch: list[tuple]) -> None:
|
|
conn.executemany(
|
|
"""
|
|
INSERT OR REPLACE INTO cards
|
|
(scryfall_id, name, oracle_text, type_line, mana_cost, cmc,
|
|
color_identity, keywords, power, toughness, rarity,
|
|
edhrec_rank, legalities, rulings_uri, set_code)
|
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
|
""",
|
|
batch,
|
|
)
|
|
conn.commit()
|
|
|
|
|
|
# ── Public API ────────────────────────────────────────────────────────────────
|
|
|
|
def get_bulk_db() -> sqlite3.Connection:
|
|
"""
|
|
Return the module-level SQLite connection, creating it on first call.
|
|
Thread-safe via _bulk_lock.
|
|
"""
|
|
global _bulk_conn
|
|
with _bulk_lock:
|
|
if _bulk_conn is None:
|
|
BULK_DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
_bulk_conn = sqlite3.connect(
|
|
str(BULK_DB_PATH), check_same_thread=False
|
|
)
|
|
_bulk_conn.row_factory = sqlite3.Row
|
|
_init_db(_bulk_conn)
|
|
return _bulk_conn
|
|
|
|
|
|
def ensure_bulk_db() -> None:
|
|
"""
|
|
Call at startup. Downloads and indexes the full Scryfall card set if the
|
|
local DB is missing or older than SCRYFALL_BULK_STALENESS_DAYS.
|
|
No-ops if data is already fresh.
|
|
"""
|
|
conn = get_bulk_db()
|
|
if _is_stale(conn):
|
|
logger.info(
|
|
"Scryfall bulk DB is stale or missing — downloading now "
|
|
"(~100 MB, one-time per week)..."
|
|
)
|
|
try:
|
|
_download_and_index(conn)
|
|
except Exception as exc:
|
|
logger.error(
|
|
f"Scryfall bulk download failed: {exc}. "
|
|
"Continuing without bulk data — per-card Scryfall cache will be used."
|
|
)
|
|
else:
|
|
row = conn.execute(
|
|
"SELECT value FROM meta WHERE key = 'card_count'"
|
|
).fetchone()
|
|
count = row[0] if row else "unknown"
|
|
logger.info(f"Scryfall bulk DB is fresh ({count} cards cached).")
|
|
|
|
|
|
def lookup_card(name: str) -> dict | None:
|
|
"""
|
|
Exact case-insensitive card name lookup.
|
|
|
|
Returns a dict with all columns from the `cards` table, or None if not
|
|
found. Prefers the most-recently-printed printing (lowest edhrec_rank).
|
|
"""
|
|
conn = get_bulk_db()
|
|
row = conn.execute(
|
|
"""
|
|
SELECT * FROM cards
|
|
WHERE LOWER(name) = LOWER(?)
|
|
ORDER BY edhrec_rank ASC
|
|
LIMIT 1
|
|
""",
|
|
(name,),
|
|
).fetchone()
|
|
return dict(row) if row else None
|
|
|
|
|
|
def search_oracle(
|
|
query: str,
|
|
color_identity: list[str] | None = None,
|
|
type_filter: str | None = None,
|
|
limit: int = 10,
|
|
) -> list[dict]:
|
|
"""
|
|
Full-text search over card names and oracle text using SQLite FTS5.
|
|
|
|
Args:
|
|
query: Natural language or keyword query string.
|
|
color_identity: If provided, restrict results to cards whose color
|
|
identity is a subset of this list (e.g. ['W', 'U']).
|
|
type_filter: Optional type-line substring filter (e.g. 'Creature').
|
|
limit: Maximum number of results to return.
|
|
|
|
Returns:
|
|
List of card dicts ordered by FTS5 relevance rank.
|
|
"""
|
|
conn = get_bulk_db()
|
|
|
|
# Build FTS5 query: wrap each token in quotes to allow phrase matching
|
|
tokens = [t.strip() for t in query.split() if t.strip()]
|
|
if not tokens:
|
|
return []
|
|
fts_query = " OR ".join(f'"{t}"' for t in tokens)
|
|
|
|
# Over-fetch to allow post-filtering by color/type
|
|
fetch_limit = max(limit * 5, 50)
|
|
rows = conn.execute(
|
|
"""
|
|
SELECT c.*
|
|
FROM cards c
|
|
JOIN cards_fts fts ON fts.rowid = c.rowid
|
|
WHERE cards_fts MATCH ?
|
|
ORDER BY rank
|
|
LIMIT ?
|
|
""",
|
|
(fts_query, fetch_limit),
|
|
).fetchall()
|
|
|
|
results: list[dict] = []
|
|
for row in rows:
|
|
card = dict(row)
|
|
|
|
# Color identity filter: card's CI must be a subset of deck's CI
|
|
if color_identity:
|
|
try:
|
|
card_ci = set(json.loads(card.get("color_identity") or "[]"))
|
|
except (json.JSONDecodeError, TypeError):
|
|
card_ci = set()
|
|
if not card_ci.issubset(set(color_identity)):
|
|
continue
|
|
|
|
# Optional type-line filter
|
|
if type_filter:
|
|
if type_filter.lower() not in (card.get("type_line") or "").lower():
|
|
continue
|
|
|
|
results.append(card)
|
|
if len(results) >= limit:
|
|
break
|
|
|
|
return results
|
|
|
|
|
|
def get_stats() -> dict:
|
|
"""Return metadata about the current bulk DB state."""
|
|
conn = get_bulk_db()
|
|
rows = conn.execute("SELECT key, value FROM meta").fetchall()
|
|
meta = {r["key"]: r["value"] for r in rows}
|
|
card_count = conn.execute("SELECT COUNT(*) FROM cards").fetchone()[0]
|
|
stale = _is_stale(conn)
|
|
last_dl = meta.get("last_download")
|
|
age_days: float | None = None
|
|
if last_dl:
|
|
try:
|
|
age_days = round((time.time() - float(last_dl)) / 86400, 1)
|
|
except (ValueError, TypeError):
|
|
pass
|
|
return {
|
|
"card_count": card_count,
|
|
"stale": stale,
|
|
"age_days": age_days,
|
|
"db_path": str(BULK_DB_PATH),
|
|
"staleness_threshold_days": SCRYFALL_BULK_STALENESS_DAYS,
|
|
}
|