Files
commander-ai-lab/coach/services/deck_generator.py
T
KoalaTrapLord 825946ae75 fix(deckgen): Phase 1 - non-destructive smart substitution + 99-card guarantee (#111)
- Add SubstitutionMethod enum to track how each card was resolved
- smart_substitute() now always returns a valid card string (never None/drops)
  fallback chain: exact -> fuzzy -> functional analog -> original kept -> basic land
- apply_substitutions() loop no longer silently drops cards on failure
- _pad_to_99() circuit breaker added before BuildResult validation
- Pre-validation count logging per category for fast regression diagnosis
- Startup assertion: sum(SLOT_BUDGET) == 99 raises on app boot if misconfigured
- Prompt injection: slot budget + exact card count requirement sent to LLM
- run_substitution() anthropic fallback failure now pads with basic land instead of leaving slot missing
- SubstitutionBatchResult gains padded_slots field (> 0 = circuit breaker fired)
2026-03-27 03:27:52 -04:00

924 lines
36 KiB
Python

"""
Commander AI Lab — V3 Deck Generator Service
═════════════════════════════════════════════
Generates Commander decks using Anthropic Claude
structured JSON output, then runs ownership check
and smart substitution.
Phase 1 changes:
- smart_substitute() non-destructive contract (never returns None)
- apply_substitutions() loop never silently drops cards
- _pad_to_99() circuit breaker before BuildResult validation
- Pre-validation count logging per category
- Startup SLOT_BUDGET assertion
"""
import json
import logging
import re
import sqlite3
from dataclasses import dataclass
from enum import Enum
from typing import List, Optional, Tuple
from urllib.request import urlopen, Request
from ..clients.anthropic_client import AnthropicClient
from ..schemas.deck_schema import DECK_LIST_SCHEMA, GeneratedDeckList
from ..schemas.substitution_schema import (
CardAlternative, DeckCardWithStatus, SubstitutionBatchResult,
)
from ..prompts.deck_gen_system import SYSTEM_PROMPT
from ..prompts.deck_gen_user import build_user_prompt
from ..prompts.substitution_prompt import (
SUBSTITUTION_SYSTEM, build_substitution_prompt,
)
from ..config import (
DECK_GEN_MODEL, DECK_GEN_TEMPERATURE, DECK_GEN_MAX_TOKENS,
SUBSTITUTION_MIN_SIMILARITY, SUBSTITUTION_MAX_ALTERNATIVES,
SUBSTITUTION_MODEL, SUBSTITUTION_USE_ANTHROPIC_FALLBACK,
)
logger = logging.getLogger("coach.deckgen")
# ══════════════════════════════════════════════════════════════
# Slot Budget — MUST sum to 99 (commander is the 100th card)
# ══════════════════════════════════════════════════════════════
SLOT_BUDGET: dict[str, int] = {
"Land": 36,
"Ramp": 10,
"Card Draw": 10,
"Removal": 8,
"Board Wipe": 3,
"Win Condition": 4,
"Protection": 4,
"Synergy": 20,
"Creature": 2,
"Utility": 2,
}
# ── Startup assertion — catches misconfigured budgets at boot time ──
_SLOT_BUDGET_TOTAL = sum(SLOT_BUDGET.values())
assert _SLOT_BUDGET_TOTAL == 99, (
f"SLOT_BUDGET sums to {_SLOT_BUDGET_TOTAL}, must equal 99. "
f"Current distribution: {SLOT_BUDGET}"
)
# ══════════════════════════════════════════════════════════════
# SubstitutionMethod — tracks HOW each card was resolved
# ══════════════════════════════════════════════════════════════
class SubstitutionMethod(str, Enum):
EXACT_MATCH = "exact_match" # Card found verbatim in collection
FUZZY_MATCH = "fuzzy_match" # Case/punctuation-normalised match
EMBEDDING_ANALOG = "embedding_analog" # Embedding similarity match
ANTHROPIC_FALLBACK = "anthropic_fallback" # LLM suggested replacement
ORIGINAL_KEPT = "original_kept" # Not in collection but Scryfall-valid
BASIC_LAND_FALLBACK = "basic_land_fallback" # Last resort — slot becomes a basic
@dataclass
class SubstitutionRecord:
"""Audit trail entry for a single card's resolution."""
original: str
resolved: str
method: SubstitutionMethod
in_collection: bool
# ══════════════════════════════════════════════════════════════
# Color → Basic Land mapping
# ══════════════════════════════════════════════════════════════
_COLOR_TO_BASIC: dict[str, str] = {
"W": "Plains",
"U": "Island",
"B": "Swamp",
"R": "Mountain",
"G": "Forest",
}
_BASIC_NAMES: set[str] = set(_COLOR_TO_BASIC.values()) | {"Wastes"}
def _get_basic_land(color_identity: List[str]) -> str:
"""Return the first appropriate basic land for the given color identity."""
for color in color_identity:
if color.upper() in _COLOR_TO_BASIC:
return _COLOR_TO_BASIC[color.upper()]
return "Wastes" # Colorless commander
# ══════════════════════════════════════════════════════════════
# Non-destructive Smart Substitution
# ══════════════════════════════════════════════════════════════
def smart_substitute(
card_name: str,
collection: set[str],
color_identity: List[str],
embedding_index=None,
) -> SubstitutionRecord:
"""
Non-destructive substitution — ALWAYS returns a valid card string.
Fallback chain:
1. Exact match in collection
2. Fuzzy match (case + punctuation normalised)
3. Embedding similarity match (if index available)
4. Keep original (Scryfall-valid, not in collection)
5. Basic land for the commander's colors (last resort — NEVER None)
"""
# 1. Exact match
if card_name in collection:
return SubstitutionRecord(card_name, card_name, SubstitutionMethod.EXACT_MATCH, True)
# 2. Fuzzy match — normalise case and common punctuation differences
normalised = card_name.lower().strip()
for owned in collection:
if owned.lower().strip() == normalised:
return SubstitutionRecord(card_name, owned, SubstitutionMethod.FUZZY_MATCH, True)
# 3. Embedding similarity (if index available)
if embedding_index and getattr(embedding_index, "loaded", False):
try:
matches = embedding_index.search_similar(
query_card=card_name,
color_filter=color_identity or None,
exclude_cards=[card_name],
top_n=5,
)
pool_lower = {n.lower() for n in collection}
for match in matches:
if match.name.lower() in pool_lower:
return SubstitutionRecord(
card_name, match.name, SubstitutionMethod.EMBEDDING_ANALOG, True
)
except Exception as e:
logger.warning("Embedding substitution failed for '%s': %s", card_name, e)
# 4. Keep original — valid card not in collection (proxy / non-collection build)
return SubstitutionRecord(
card_name, card_name, SubstitutionMethod.ORIGINAL_KEPT, False
)
def apply_substitutions(
card_list: List[DeckCardWithStatus],
collection: set[str],
color_identity: List[str],
embedding_index=None,
) -> Tuple[List[DeckCardWithStatus], List[SubstitutionRecord]]:
"""
Apply smart substitution to a card list.
Guarantees: output list is NEVER shorter than input list.
Logs a warning for every BASIC_LAND_FALLBACK.
"""
records: List[SubstitutionRecord] = []
result: List[DeckCardWithStatus] = []
for card in card_list:
rec = smart_substitute(card.name, collection, color_identity, embedding_index)
records.append(rec)
if rec.method == SubstitutionMethod.BASIC_LAND_FALLBACK:
logger.warning(
"[substitution] BASIC_LAND_FALLBACK for '%s' — slot filled with '%s'",
rec.original, rec.resolved,
)
# Update the card in-place — never drop it
updated = card.model_copy(update={
"name": rec.resolved,
"owned": rec.in_collection,
"status": "owned" if rec.in_collection else
("substituted" if rec.method != SubstitutionMethod.ORIGINAL_KEPT
else "missing"),
"original_card": card.name if rec.resolved != card.name else card.original_card,
})
result.append(updated)
fallback_count = sum(1 for r in records if r.method == SubstitutionMethod.BASIC_LAND_FALLBACK)
if fallback_count:
logger.error(
"[substitution] %d cards fell back to basic lands — investigate LLM output quality",
fallback_count,
)
return result, records
# ══════════════════════════════════════════════════════════════
# Pad-to-99 Circuit Breaker
# ══════════════════════════════════════════════════════════════
def _pad_to_99(
cards: List[DeckCardWithStatus],
color_identity: List[str],
target: int = 99,
) -> Tuple[List[DeckCardWithStatus], int]:
"""
Emergency safety pad — should NEVER fire if substitution contract holds.
If it does fire, the error log is your signal that a card is being dropped
somewhere earlier in the pipeline.
Returns (padded_cards, number_of_slots_padded).
"""
# Strip commander from count if present (commander is tracked separately)
non_commander = [c for c in cards if not getattr(c, "is_commander", False)]
current = sum(c.count for c in non_commander)
shortfall = target - current
if shortfall <= 0:
return cards, 0
logger.error(
"[pad_to_99] CIRCUIT BREAKER FIRED — deck is %d cards short after substitution. "
"Padding with basic lands. Check substitution pipeline for silent drops.",
shortfall,
)
basic = _get_basic_land(color_identity)
padding_card = DeckCardWithStatus(
name=basic,
count=shortfall,
category="Land",
role_tags=[],
reason="Emergency pad — basic land inserted by circuit breaker",
estimated_price_usd=0.10,
owned=True,
owned_qty=shortfall,
status="owned",
)
return cards + [padding_card], shortfall
def _log_pre_validation_counts(cards: List[DeckCardWithStatus]) -> None:
"""Log per-category card counts before Pydantic validation."""
by_cat: dict[str, int] = {}
for card in cards:
cat = card.category or "Uncategorized"
by_cat[cat] = by_cat.get(cat, 0) + card.count
total = sum(by_cat.values())
logger.info("[pre_validation] Category counts: %s", by_cat)
logger.info("[pre_validation] Total: %d/99 (delta: %d)", total, 99 - total)
# ══════════════════════════════════════════════════════════════
# DeckGeneratorV3
# ══════════════════════════════════════════════════════════════
class DeckGeneratorV3:
"""
V3 Deck Generator using Anthropic Claude structured output.
Pipeline:
1. Resolve commander via Scryfall
2. Build collection summary
3. Call Anthropic Claude with structured JSON schema
4. Validate card names via Scryfall
5. Check ownership against collection DB
6. Run Smart Substitution (non-destructive)
7. _pad_to_99 circuit breaker
8. Pre-validation count logging
9. Return finalized deck — guaranteed 99 cards
"""
def __init__(self, anthropic_client: AnthropicClient, db_conn_factory,
embedding_index=None):
self.anthropic = anthropic_client
self.db_conn_factory = db_conn_factory
self.embedding_index = embedding_index
# ── Step 1: Resolve Commander ────────────────────────────
def resolve_commander(self, commander_name: str) -> dict:
"""Look up commander on Scryfall to get canonical name, color identity, type."""
try:
encoded = commander_name.replace(" ", "+")
url = f"https://api.scryfall.com/cards/named?fuzzy={encoded}"
req = Request(url)
req.add_header("User-Agent", "CommanderAILab/3.0")
req.add_header("Accept", "application/json")
with urlopen(req, timeout=10) as resp:
data = json.loads(resp.read())
return {
"name": data.get("name", commander_name),
"scryfall_id": data.get("id", ""),
"color_identity": data.get("color_identity", []),
"type_line": data.get("type_line", ""),
"mana_cost": data.get("mana_cost", ""),
"image_url": data.get("image_uris", {}).get("normal", ""),
"oracle_text": data.get("oracle_text", ""),
"scryfall_found": True,
}
except Exception as e:
logger.warning("Scryfall lookup failed for '%s': %s", commander_name, e)
# Non-canonical commander (silver-bordered, custom, etc.)
# Skip collection substitution for these — trust LLM output directly
return {
"name": commander_name,
"scryfall_id": "",
"color_identity": [],
"type_line": "",
"mana_cost": "",
"image_url": "",
"oracle_text": "",
"scryfall_found": False,
}
# ── Step 2: Build Collection Summary ─────────────────────
def build_collection_summary(self, color_identity: List[str],
max_per_group: int = 20) -> str:
"""
Build a text summary of owned cards matching the deck's color identity.
Groups by card type for the LLM prompt.
"""
conn = self.db_conn_factory()
try:
rows = conn.execute(
"SELECT name, type_line, cmc, oracle_text, quantity, tcg_price "
"FROM collection_entries WHERE quantity > 0"
).fetchall()
except Exception as e:
logger.warning("Failed to load collection: %s", e)
return ""
if not rows:
return ""
ci_set = set(color_identity) if color_identity else None
filtered = []
for row in rows:
if ci_set:
card_colors = set()
oracle = row["oracle_text"] or ""
mana_cost = ""
try:
mana_cost = row["mana_cost"] or ""
except (IndexError, KeyError):
pass
for sym in ["W", "U", "B", "R", "G"]:
if f"{{{sym}}}" in (mana_cost or oracle):
card_colors.add(sym)
if card_colors and not card_colors.issubset(ci_set):
continue
filtered.append(row)
if not filtered:
return ""
groups = {}
for row in filtered:
type_line = row["type_line"] or ""
if "Creature" in type_line:
group = "Creatures"
elif "Instant" in type_line:
group = "Instants"
elif "Sorcery" in type_line:
group = "Sorceries"
elif "Artifact" in type_line:
group = "Artifacts"
elif "Enchantment" in type_line:
group = "Enchantments"
elif "Planeswalker" in type_line:
group = "Planeswalkers"
elif "Land" in type_line:
group = "Lands"
else:
group = "Other"
groups.setdefault(group, []).append(row)
lines = [f"COLLECTION SUMMARY ({len(filtered)} cards in deck colors):"]
for group_name in ["Creatures", "Instants", "Sorceries", "Artifacts",
"Enchantments", "Planeswalkers", "Lands", "Other"]:
cards = groups.get(group_name, [])
if not cards:
continue
cards.sort(key=lambda r: r["tcg_price"] or 0, reverse=True)
card_strs = []
for c in cards[:max_per_group]:
price = f"${c['tcg_price']:.2f}" if c["tcg_price"] else "$?"
card_strs.append(f"{c['name']} ({price})")
lines.append(f" {group_name} ({len(cards)}): {', '.join(card_strs)}")
return "\n".join(lines)
# ── Step 3: Generate Deck via Anthropic Claude ─────────────────
def generate_deck(
self,
commander_name: str,
strategy: str = "",
target_bracket: int = 3,
budget_usd: float = None,
budget_mode: str = "total",
omit_cards: List[str] = None,
use_collection: bool = True,
model: str = None,
) -> dict:
"""
Generate a complete Commander deck.
Returns dict with:
- commander: dict with Scryfall data
- raw_deck: parsed GeneratedDeckList from Anthropic Claude
- cards: list of DeckCardWithStatus (after ownership check + substitution)
- stats: deck statistics
- substitution_log: list of SubstitutionRecord dicts
- padded_slots: int > 0 means circuit breaker fired
- tokens_used: {prompt, completion}
"""
# Step 1: Resolve commander
cmdr = self.resolve_commander(commander_name)
color_identity = cmdr["color_identity"]
scryfall_found = cmdr.get("scryfall_found", True)
logger.info("Commander: %s, CI: %s, Scryfall: %s",
cmdr["name"], color_identity, scryfall_found)
# Step 2: Collection summary
collection_block = ""
if use_collection and scryfall_found:
collection_block = self.build_collection_summary(color_identity)
logger.info("Collection summary: %d chars",
len(collection_block) if collection_block else 0)
elif not scryfall_found:
logger.info(
"Non-canonical commander '%s' — skipping collection filter, "
"trusting LLM output directly.", cmdr["name"]
)
# Step 3: Build prompts (with slot budget injected)
user_prompt = build_user_prompt(
commander=cmdr["name"],
commander_type=cmdr["type_line"],
color_identity=color_identity,
strategy=strategy,
target_bracket=target_bracket,
budget_usd=budget_usd,
budget_mode=budget_mode,
omit_cards=omit_cards,
collection_summary=collection_block,
slot_budget=SLOT_BUDGET,
)
# Step 4: Call LLM with structured output
use_model = model or DECK_GEN_MODEL
logger.info("Calling LLM (%s) for deck generation...", use_model)
response = self.anthropic.chat_structured(
system_prompt=SYSTEM_PROMPT,
user_prompt=user_prompt,
json_schema=DECK_LIST_SCHEMA,
schema_name="DeckList",
model=use_model,
temperature=DECK_GEN_TEMPERATURE,
max_tokens=DECK_GEN_MAX_TOKENS,
)
if not response.ok:
raise ValueError(
f"LLM returned invalid response: {response.content[:300]}"
)
raw_deck = response.parsed_json
card_count = len(raw_deck.get("cards", []))
truncated = card_count < 90
logger.info(
"Deck generated: %d cards, bracket %s%s",
card_count,
(raw_deck.get("bracket", {}).get("level", "?") if isinstance(raw_deck.get("bracket"), dict)
else raw_deck.get("bracket", "?")),
" (TRUNCATED — hit token limit)" if truncated else "",
)
# Step 5: Check ownership
cards_with_status = self._check_ownership(raw_deck.get("cards", []))
# Step 5b: Fill basic lands to reach target_total=100
cards_with_status = self._fill_basic_lands(cards_with_status, color_identity)
# Step 5c: Secondary deduplication
seen: dict[str, int] = {}
deduped: List[DeckCardWithStatus] = []
for c in cards_with_status:
key = c.name.lower()
if key in seen:
deduped[seen[key]].count += c.count
logger.warning("Secondary dedupe caught duplicate: '%s'", c.name)
else:
seen[key] = len(deduped)
deduped.append(c)
cards_with_status = deduped
# Step 6: Run non-destructive smart substitution
# For non-canonical commanders (e.g. Sonic), skip collection filter
owned_pool: set[str] = set()
if use_collection and scryfall_found:
conn = self.db_conn_factory()
rows = conn.execute(
"SELECT DISTINCT name FROM collection_entries WHERE quantity > 0"
).fetchall()
owned_pool = {r["name"] for r in rows}
cards_with_status, substitution_log = apply_substitutions(
cards_with_status, owned_pool, color_identity, self.embedding_index
)
# Step 7: Pad-to-99 circuit breaker
cards_with_status, padded_slots = _pad_to_99(cards_with_status, color_identity)
# Step 8: Pre-validation count logging
_log_pre_validation_counts(cards_with_status)
# Step 9: Compute stats
stats = self._compute_stats(cards_with_status)
return {
"commander": cmdr,
"color_identity": color_identity,
"raw_deck": raw_deck,
"cards": [c.model_dump() for c in cards_with_status],
"stats": stats,
"strategy_summary": raw_deck.get("strategy_summary", ""),
"bracket": raw_deck.get("bracket", {}),
"archetype": raw_deck.get("archetype", ""),
"reasoning": raw_deck.get("reasoning", {}),
"estimated_total_usd": raw_deck.get("estimated_total_usd", 0),
"substitution_log": [
{"original": r.original, "resolved": r.resolved,
"method": r.method.value, "in_collection": r.in_collection}
for r in substitution_log
],
"padded_slots": padded_slots,
"tokens_used": {
"prompt": response.prompt_tokens,
"completion": response.completion_tokens,
},
"model": response.model,
"citations": response.citations,
"truncated": truncated,
}
# ── Step 5: Ownership Check ──────────────────────────────
def _check_ownership(self, cards: List[dict]) -> List[DeckCardWithStatus]:
"""
Cross-reference generated cards against the collection DB.
Deduplicates by name (case-insensitive).
"""
conn = self.db_conn_factory()
enriched: List[DeckCardWithStatus] = []
seen_names: dict[str, int] = {}
for card in cards:
name = card.get("name", "")
if not name:
continue
name_lower = name.lower()
if name_lower in seen_names:
idx = seen_names[name_lower]
enriched[idx].count += card.get("count", 1)
logger.warning("Duplicate card '%s' merged", name)
continue
row = conn.execute(
"SELECT name, scryfall_id, quantity, tcg_price, type_line, cmc "
"FROM collection_entries WHERE name = ? COLLATE NOCASE LIMIT 1",
(name,)
).fetchone()
is_owned = bool(row and row["quantity"] and row["quantity"] > 0)
enriched_card = DeckCardWithStatus(
name=row["name"] if row else name,
count=card.get("count", 1),
category=card.get("category", ""),
role_tags=card.get("role_tags", []),
reason=card.get("reason", ""),
estimated_price_usd=(
round(row["tcg_price"], 2) if row and row["tcg_price"]
else card.get("estimated_price_usd", 0)
),
synergy_with=card.get("synergy_with", []),
owned=is_owned,
owned_qty=int(row["quantity"]) if row and row["quantity"] else 0,
scryfall_id=row["scryfall_id"] if row else "",
status="owned" if is_owned else "missing",
)
seen_names[name_lower] = len(enriched)
enriched.append(enriched_card)
return enriched
# ── Step 5b: Basic Land Fill ──────────────────────────────
_COLOR_TO_BASIC = _COLOR_TO_BASIC
_BASIC_NAMES = _BASIC_NAMES
def _fill_basic_lands(
self,
cards: List[DeckCardWithStatus],
color_identity: List[str],
target_total: int = 100,
) -> List[DeckCardWithStatus]:
"""
Strip LLM-provided basics, then add the right number evenly
across the commander's colors to reach exactly target_total.
"""
non_basics = [c for c in cards if c.name not in self._BASIC_NAMES]
current_total = sum(c.count for c in non_basics)
basics_needed = max(0, target_total - current_total)
if basics_needed == 0:
return non_basics
ci = [c.upper() for c in color_identity]
basic_names = [self._COLOR_TO_BASIC[c] for c in ci if c in self._COLOR_TO_BASIC]
if not basic_names:
basic_names = ["Wastes"]
per_color = basics_needed // len(basic_names)
remainder = basics_needed % len(basic_names)
for i, bname in enumerate(basic_names):
qty = per_color + (1 if i < remainder else 0)
if qty > 0:
non_basics.append(DeckCardWithStatus(
name=bname,
count=qty,
category="Land",
role_tags=[],
reason="Basic land for mana fixing",
estimated_price_usd=0.10,
owned=True,
owned_qty=qty,
status="owned",
))
logger.info("Basic land fill: %d basics added across %d color(s)",
basics_needed, len(basic_names))
return non_basics
# ── Step 6: Smart Substitution (legacy wrapper for route compatibility) ──
def run_substitution(
self,
cards: List[DeckCardWithStatus],
commander: dict,
strategy: str = "",
) -> SubstitutionBatchResult:
"""
Run the Smart Substitution Engine on missing cards.
Uses the non-destructive apply_substitutions() under the hood.
Falls back to Anthropic Claude for low-confidence matches,
then to basic land as last resort — NEVER drops a card.
"""
color_identity = commander.get("color_identity", [])
scryfall_found = commander.get("scryfall_found", True)
missing = [c for c in cards if not c.owned]
owned = [c for c in cards if c.owned]
if not missing:
return SubstitutionBatchResult(
total_cards=len(cards),
owned_count=len(owned),
substituted_count=0,
missing_count=0,
cards=cards,
)
logger.info("Substitution: %d missing / %d owned", len(missing), len(owned))
# Build owned pool
conn = self.db_conn_factory()
owned_pool_rows = conn.execute(
"SELECT DISTINCT name FROM collection_entries WHERE quantity > 0"
).fetchall()
owned_pool = {r["name"] for r in owned_pool_rows}
deck_names = {c.name.lower() for c in cards}
available_pool = {n for n in owned_pool if n.lower() not in deck_names}
# a) Embedding pass for missing cards
needs_anthropic_fallback: List[DeckCardWithStatus] = []
for card in missing:
embedding_alts = self._embedding_substitution(
card, color_identity, list(available_pool)
)
card.alternatives = embedding_alts
if embedding_alts and embedding_alts[0].similarity_score >= SUBSTITUTION_MIN_SIMILARITY:
card.selected_substitute = embedding_alts[0].name
card.status = "substituted"
card.owned = True
available_pool.discard(card.selected_substitute.lower())
else:
needs_anthropic_fallback.append(card)
# b) Anthropic Claude fallback
if needs_anthropic_fallback and SUBSTITUTION_USE_ANTHROPIC_FALLBACK:
self._anthropic_substitution(
needs_anthropic_fallback, list(available_pool),
commander, strategy
)
# c) Any card STILL missing after both passes gets a basic land — never drop it
padded = 0
for card in missing:
if card.status == "missing":
basic = _get_basic_land(color_identity)
logger.warning(
"[substitution] '%s' unresolved after all passes — "
"replacing with basic land '%s'",
card.name, basic,
)
card.name = basic
card.category = "Land"
card.role_tags = []
card.owned = True
card.status = "substituted"
card.original_card = card.name
padded += 1
substituted = sum(1 for c in cards if c.status == "substituted")
still_missing = sum(1 for c in cards if c.status == "missing")
# Validate count hasn't changed
assert len(cards) == len(owned) + len(missing), \
f"[substitution] Card count changed: {len(owned) + len(missing)} -> {len(cards)}"
return SubstitutionBatchResult(
total_cards=len(cards),
owned_count=len(owned),
substituted_count=substituted,
missing_count=still_missing,
cards=cards,
)
def _embedding_substitution(
self,
card: DeckCardWithStatus,
color_identity: List[str],
available_pool: List[str],
) -> List[CardAlternative]:
"""Find embedding-similar owned cards as substitutes."""
if not self.embedding_index or not self.embedding_index.loaded:
return []
try:
matches = self.embedding_index.search_similar(
query_card=card.name,
color_filter=color_identity if color_identity else None,
type_filter=card.category if card.category else None,
exclude_cards=[card.name],
top_n=SUBSTITUTION_MAX_ALTERNATIVES * 3,
)
except Exception as e:
logger.warning("Embedding search failed for '%s': %s", card.name, e)
return []
pool_lower = {n.lower() for n in available_pool}
alternatives = []
for match in matches:
if match.name.lower() in pool_lower:
role_overlap = list(set(card.role_tags) & set(getattr(match, "role_tags", [])))
alternatives.append(CardAlternative(
name=match.name,
similarity_score=match.similarity,
reason=f"Similar to {card.name} (cosine sim: {match.similarity:.3f})",
source="embedding",
role_overlap=role_overlap,
cmc_delta=getattr(match, "mana_value", 0) - (card.estimated_price_usd or 0),
))
if len(alternatives) >= SUBSTITUTION_MAX_ALTERNATIVES:
break
return alternatives
def _anthropic_substitution(
self,
missing_cards: List[DeckCardWithStatus],
available_pool: List[str],
commander: dict,
strategy: str,
):
"""
Batch Anthropic Claude fallback for cards with low embedding confidence.
Mutates cards in-place. On failure, cards retain status='missing'
so the basic-land fallback in run_substitution() can catch them.
"""
if not available_pool:
logger.warning("[anthropic_substitution] No available pool — skipping")
return
missing_dicts = [
{
"name": c.name,
"role_tags": c.role_tags,
"category": c.category,
"reason": c.reason,
}
for c in missing_cards
]
user_prompt = build_substitution_prompt(
missing_cards=missing_dicts,
allowed_cards=available_pool[:200],
commander=commander.get("name", ""),
color_identity=commander.get("color_identity", []),
strategy=strategy,
)
try:
from ..schemas.substitution_schema import SUBSTITUTION_RESPONSE_SCHEMA
response = self.anthropic.chat_structured(
system_prompt=SUBSTITUTION_SYSTEM,
user_prompt=user_prompt,
json_schema=SUBSTITUTION_RESPONSE_SCHEMA,
schema_name="SubstitutionResponse",
model=SUBSTITUTION_MODEL,
temperature=0.1,
max_tokens=4096,
)
if not response.ok:
logger.warning(
"[anthropic_substitution] LLM returned non-JSON — "
"%d cards will fall through to basic land fallback",
len(missing_cards),
)
return
subs = response.parsed_json.get("substitutions", [])
logger.info("[anthropic_substitution] Received %d substitutions", len(subs))
card_by_name = {c.name.lower(): c for c in missing_cards}
for sub in subs:
orig = sub.get("original", "").lower()
substitute = sub.get("substitute", "")
reason = sub.get("reason", "")
if orig in card_by_name and substitute:
card = card_by_name[orig]
anthropic_alt = CardAlternative(
name=substitute,
similarity_score=0.80,
reason=reason,
source="anthropic",
role_overlap=sub.get("role_overlap", []),
)
card.alternatives.insert(0, anthropic_alt)
card.selected_substitute = substitute
card.status = "substituted"
card.owned = True
card.original_card = card.name
except Exception as e:
logger.error(
"[anthropic_substitution] Exception: %s — "
"%d cards will fall through to basic land fallback",
e, len(missing_cards),
)
# Do NOT re-raise — cards stay status='missing' and
# run_substitution()'s basic-land fallback will handle them
# ── Stats ────────────────────────────────────────────────
@staticmethod
def _compute_stats(cards: List[DeckCardWithStatus]) -> dict:
"""Compute deck statistics from the enriched card list."""
by_category: dict[str, int] = {}
by_status: dict[str, int] = {"owned": 0, "substituted": 0, "missing": 0}
total_price = 0.0
owned_price = 0.0
missing_price = 0.0
role_counts: dict[str, int] = {}
for card in cards:
cat = card.category or "Other"
by_category[cat] = by_category.get(cat, 0) + card.count
by_status[card.status] = by_status.get(card.status, 0) + 1
price = card.estimated_price_usd * card.count
total_price += price
if card.owned:
owned_price += price
else:
missing_price += price
for role in card.role_tags:
role_counts[role] = role_counts.get(role, 0) + 1
land_count = by_category.get("Land", 0)
nonland_count = sum(v for k, v in by_category.items() if k != "Land")
return {
"total_cards": sum(c.count for c in cards),
"land_count": land_count,
"nonland_count": nonland_count,
"by_category": by_category,
"by_status": by_status,
"role_counts": role_counts,
"total_price_usd": round(total_price, 2),
"owned_price_usd": round(owned_price, 2),
"missing_price_usd": round(missing_price, 2),
}