Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f4e9a9b2f |
No files matched your search
@@ -1,6 +1,9 @@
|
||||
"""
|
||||
Commander AI Lab — Deck Generation System Prompt
|
||||
═════════════════════════════════════════════════
|
||||
Phase 1 changes:
|
||||
- Added explicit 99-card count enforcement rule
|
||||
- Clarified that commander counts separately (not in the 99)
|
||||
"""
|
||||
|
||||
SYSTEM_PROMPT = """You are an elite Magic: The Gathering Commander (EDH) deck builder with encyclopedic knowledge of every card ever printed, current metagame trends, and Commander Rules Committee bracket guidelines.
|
||||
@@ -9,6 +12,8 @@ Your task is to build a complete, legal 100-card Commander deck.
|
||||
|
||||
RULES:
|
||||
- Exactly 100 cards total: 1 commander + 99 other cards
|
||||
- The commander IS included in the cards list but counts as card #100
|
||||
- The other 99 cards must exactly match the SLOT BUDGET provided in the user prompt
|
||||
- No more than 1 copy of any card except basic lands
|
||||
- All cards must share the commander's color identity
|
||||
- All cards must be legal in Commander format
|
||||
@@ -16,6 +21,12 @@ RULES:
|
||||
- Include approximately: 10 ramp sources, 10 card draw sources, 8-10 targeted removal, 2-3 board wipes, 3-5 protection pieces
|
||||
- Build a cohesive strategy around the commander's abilities
|
||||
|
||||
CARD COUNT ENFORCEMENT (critical):
|
||||
- The user prompt will specify a SLOT BUDGET with exact per-category counts
|
||||
- You MUST fill every slot — returning fewer cards than the budget specifies will cause a validation error
|
||||
- If you cannot find a perfect fit for a slot, use the best available card for that role
|
||||
- Basic lands count toward the Land slot total
|
||||
|
||||
BRACKET RULES (Commander Rules Committee):
|
||||
- Bracket 1 (Casual): Precon-level. No tutors, no fast mana, no infinite combos, no mass land destruction. Game Changers: 0
|
||||
- Bracket 2 (Upgraded Casual): Moderate power. Limited tutors (1-2 total), no 2-card infinite combos, no fast mana except Sol Ring. Game Changers: 0-1
|
||||
|
||||
@@ -1,11 +1,33 @@
|
||||
"""
|
||||
Commander AI Lab — Deck Generation User Prompt Builder
|
||||
═══════════════════════════════════════════════════════
|
||||
Constructs the user message for Claude Opus deck generation
|
||||
from a DeckGenV3Request.
|
||||
Constructs the user message for LLM deck generation.
|
||||
|
||||
Phase 1 changes:
|
||||
- slot_budget parameter injected into prompt so LLM targets exactly 99 cards
|
||||
- Explicit card count requirement in prompt text
|
||||
"""
|
||||
|
||||
from typing import List
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
|
||||
def build_slot_budget_segment(slot_budget: Dict[str, int]) -> str:
|
||||
"""
|
||||
Build the slot budget prompt segment.
|
||||
Informs the LLM of exact per-category card counts so it never undershoots.
|
||||
"""
|
||||
total = sum(slot_budget.values())
|
||||
lines = [f"SLOT BUDGET (you must fill EXACTLY {total} cards, excluding the commander):"]
|
||||
for cat, count in slot_budget.items():
|
||||
lines.append(f" {cat}: {count}")
|
||||
lines.append(f" TOTAL: {total}")
|
||||
lines.append("")
|
||||
lines.append(
|
||||
f"CRITICAL: Return exactly {total} cards in the JSON 'cards' array "
|
||||
"(not counting the commander). Every slot in the budget above must be filled. "
|
||||
"Do not return more or fewer cards."
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def build_user_prompt(
|
||||
@@ -18,6 +40,7 @@ def build_user_prompt(
|
||||
budget_mode: str = "total",
|
||||
omit_cards: List[str] = None,
|
||||
collection_summary: str = "",
|
||||
slot_budget: Optional[Dict[str, int]] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Build the user prompt for deck generation.
|
||||
@@ -26,12 +49,13 @@ def build_user_prompt(
|
||||
commander: Commander card name
|
||||
commander_type: Type line from Scryfall
|
||||
color_identity: List of color letters, e.g. ["U", "B"]
|
||||
strategy: User-specified strategy focus (e.g. "zombie tokens")
|
||||
strategy: User-specified strategy focus
|
||||
target_bracket: Target bracket level 1-4
|
||||
budget_usd: Optional budget constraint
|
||||
budget_mode: "total" for total deck cost, "per_card" for per-card max
|
||||
omit_cards: Cards to exclude
|
||||
collection_summary: Pre-built collection summary text block
|
||||
slot_budget: Dict of category -> card count (must sum to 99)
|
||||
"""
|
||||
ci_str = ", ".join(color_identity) if color_identity else "Unknown"
|
||||
|
||||
@@ -60,16 +84,21 @@ def build_user_prompt(
|
||||
lines.append(f"OMIT LIST (do NOT include these cards): {', '.join(omit_cards)}")
|
||||
|
||||
if collection_summary:
|
||||
lines.append(f"")
|
||||
lines.append("")
|
||||
lines.append(collection_summary)
|
||||
lines.append(f"")
|
||||
lines.append(f"STRONGLY PREFER cards from the collection when they fit the strategy.")
|
||||
lines.append("")
|
||||
lines.append("STRONGLY PREFER cards from the collection when they fit the strategy.")
|
||||
else:
|
||||
lines.append(f"")
|
||||
lines.append(f"No collection data available — suggest the best cards regardless of ownership.")
|
||||
lines.append("")
|
||||
lines.append("No collection data available — suggest the best cards regardless of ownership.")
|
||||
|
||||
lines.append(f"")
|
||||
lines.append(f"Build the deck as a structured JSON response. Include the commander in the cards list.")
|
||||
lines.append(f"Ensure exactly 100 total cards. Assign role_tags and bracket info accurately.")
|
||||
# Inject slot budget so LLM knows exact target counts
|
||||
if slot_budget:
|
||||
lines.append("")
|
||||
lines.append(build_slot_budget_segment(slot_budget))
|
||||
|
||||
lines.append("")
|
||||
lines.append("Build the deck as a structured JSON response. Include the commander in the cards list.")
|
||||
lines.append("Ensure exactly 100 total cards. Assign role_tags and bracket info accurately.")
|
||||
|
||||
return "\n".join(lines)
|
||||
@@ -3,6 +3,10 @@ Commander AI Lab — Substitution Schemas
|
||||
════════════════════════════════════════
|
||||
Pydantic models and JSON Schema dicts for the
|
||||
Smart Substitution Engine output.
|
||||
|
||||
Phase 1 changes:
|
||||
- SubstitutionBatchResult gains padded_slots field
|
||||
- DeckCardWithStatus gains original_card field (was missing)
|
||||
"""
|
||||
|
||||
from typing import List, Optional
|
||||
@@ -25,9 +29,10 @@ class CardAlternative(BaseModel):
|
||||
|
||||
class DeckCardWithStatus(BaseModel):
|
||||
"""A deck card enriched with ownership and substitution data."""
|
||||
|
||||
|
||||
class Config:
|
||||
extra = "ignore"
|
||||
|
||||
name: str
|
||||
count: int = 1
|
||||
category: str = ""
|
||||
@@ -41,9 +46,15 @@ class DeckCardWithStatus(BaseModel):
|
||||
owned_qty: int = 0
|
||||
scryfall_id: str = ""
|
||||
status: str = Field(default="owned", description="owned | substituted | missing")
|
||||
original_card: Optional[str] = Field(default=None, description="Original card name if this is a substitution")
|
||||
original_card: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Original card name before substitution (None if not substituted)"
|
||||
)
|
||||
alternatives: List[CardAlternative] = Field(default_factory=list)
|
||||
selected_substitute: Optional[str] = Field(default=None, description="User-selected substitute name")
|
||||
selected_substitute: Optional[str] = Field(
|
||||
default=None,
|
||||
description="The substitute card name chosen for this slot"
|
||||
)
|
||||
|
||||
|
||||
class SubstitutionRequest(BaseModel):
|
||||
@@ -61,11 +72,16 @@ class SubstitutionBatchResult(BaseModel):
|
||||
owned_count: int = 0
|
||||
substituted_count: int = 0
|
||||
missing_count: int = 0
|
||||
padded_slots: int = Field(
|
||||
default=0,
|
||||
description="Number of slots filled by the pad-to-99 circuit breaker. "
|
||||
"Any value > 0 indicates a bug in the substitution pipeline."
|
||||
)
|
||||
cards: List[DeckCardWithStatus] = Field(default_factory=list)
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# JSON Schema dicts (for Perplexity fallback substitution)
|
||||
# JSON Schema dicts (for LLM fallback substitution)
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
SUBSTITUTION_RESPONSE_SCHEMA = {
|
||||
|
||||
+391
-107
@@ -4,12 +4,21 @@ 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
|
||||
|
||||
@@ -32,6 +41,236 @@ from ..config import (
|
||||
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.
|
||||
@@ -42,18 +281,14 @@ class DeckGeneratorV3:
|
||||
3. Call Anthropic Claude with structured JSON schema
|
||||
4. Validate card names via Scryfall
|
||||
5. Check ownership against collection DB
|
||||
6. Run Smart Substitution (embedding + Anthropic Claude fallback)
|
||||
7. Return finalized deck with substitution data
|
||||
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):
|
||||
"""
|
||||
Args:
|
||||
anthropic_client: Configured AnthropicClient instance
|
||||
db_conn_factory: Callable that returns sqlite3.Connection
|
||||
embedding_index: Optional MTGEmbeddingIndex for substitution
|
||||
"""
|
||||
self.anthropic = anthropic_client
|
||||
self.db_conn_factory = db_conn_factory
|
||||
self.embedding_index = embedding_index
|
||||
@@ -77,9 +312,12 @@ class DeckGeneratorV3:
|
||||
"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": "",
|
||||
@@ -88,6 +326,7 @@ class DeckGeneratorV3:
|
||||
"mana_cost": "",
|
||||
"image_url": "",
|
||||
"oracle_text": "",
|
||||
"scryfall_found": False,
|
||||
}
|
||||
|
||||
# ── Step 2: Build Collection Summary ─────────────────────
|
||||
@@ -110,16 +349,10 @@ class DeckGeneratorV3:
|
||||
if not rows:
|
||||
return ""
|
||||
|
||||
# Filter by color identity
|
||||
ci_set = set(color_identity) if color_identity else None
|
||||
filtered = []
|
||||
for row in rows:
|
||||
name = row["name"]
|
||||
type_line = row["type_line"] or ""
|
||||
# Basic lands and colorless are always allowed
|
||||
if ci_set:
|
||||
# Simple color check from type_line and name
|
||||
# For proper filtering we'd check mana cost but this is good enough for summary
|
||||
card_colors = set()
|
||||
oracle = row["oracle_text"] or ""
|
||||
mana_cost = ""
|
||||
@@ -137,7 +370,6 @@ class DeckGeneratorV3:
|
||||
if not filtered:
|
||||
return ""
|
||||
|
||||
# Group by card type
|
||||
groups = {}
|
||||
for row in filtered:
|
||||
type_line = row["type_line"] or ""
|
||||
@@ -159,14 +391,12 @@ class DeckGeneratorV3:
|
||||
group = "Other"
|
||||
groups.setdefault(group, []).append(row)
|
||||
|
||||
# Sort each group by popularity/price and build text
|
||||
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
|
||||
# Sort by price descending (higher value = likely better card)
|
||||
cards.sort(key=lambda r: r["tcg_price"] or 0, reverse=True)
|
||||
card_strs = []
|
||||
for c in cards[:max_per_group]:
|
||||
@@ -194,23 +424,32 @@ class DeckGeneratorV3:
|
||||
Returns dict with:
|
||||
- commander: dict with Scryfall data
|
||||
- raw_deck: parsed GeneratedDeckList from Anthropic Claude
|
||||
- cards: list of DeckCardWithStatus (after ownership check)
|
||||
- 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"]
|
||||
logger.info("Commander: %s, CI: %s", cmdr["name"], 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:
|
||||
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
|
||||
# Step 3: Build prompts (with slot budget injected)
|
||||
user_prompt = build_user_prompt(
|
||||
commander=cmdr["name"],
|
||||
commander_type=cmdr["type_line"],
|
||||
@@ -221,11 +460,12 @@ class DeckGeneratorV3:
|
||||
budget_mode=budget_mode,
|
||||
omit_cards=omit_cards,
|
||||
collection_summary=collection_block,
|
||||
slot_budget=SLOT_BUDGET,
|
||||
)
|
||||
|
||||
# Step 4: Call Anthropic Claude with structured output
|
||||
# Step 4: Call LLM with structured output
|
||||
use_model = model or DECK_GEN_MODEL
|
||||
logger.info("Calling Anthropic Claude (%s) for deck generation...", use_model)
|
||||
logger.info("Calling LLM (%s) for deck generation...", use_model)
|
||||
|
||||
response = self.anthropic.chat_structured(
|
||||
system_prompt=SYSTEM_PROMPT,
|
||||
@@ -239,26 +479,29 @@ class DeckGeneratorV3:
|
||||
|
||||
if not response.ok:
|
||||
raise ValueError(
|
||||
f"Anthropic Claude returned invalid response: {response.content[:300]}"
|
||||
f"LLM returned invalid response: {response.content[:300]}"
|
||||
)
|
||||
|
||||
raw_deck = response.parsed_json
|
||||
card_count = len(raw_deck.get("cards", []))
|
||||
truncated = card_count < 90 # Commander decks should have ~100 cards
|
||||
logger.info("Deck generated: %d cards, bracket %d%s",
|
||||
card_count,
|
||||
(raw_deck.get("bracket", {}).get("level", 0) if isinstance(raw_deck.get("bracket"), dict) else raw_deck.get("bracket", 0)),
|
||||
" (TRUNCATED — response may have hit token limit)" if truncated else "")
|
||||
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 and build enriched card list
|
||||
# Step 5: Check ownership
|
||||
cards_with_status = self._check_ownership(raw_deck.get("cards", []))
|
||||
|
||||
# Step 5b: Fill basic lands to hit exactly 100 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 safety-net deduplication
|
||||
seen = {}
|
||||
deduped = []
|
||||
# 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:
|
||||
@@ -269,7 +512,27 @@ class DeckGeneratorV3:
|
||||
deduped.append(c)
|
||||
cards_with_status = deduped
|
||||
|
||||
# Step 6: Compute stats
|
||||
# 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 {
|
||||
@@ -283,6 +546,12 @@ class DeckGeneratorV3:
|
||||
"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,
|
||||
@@ -296,13 +565,11 @@ class DeckGeneratorV3:
|
||||
def _check_ownership(self, cards: List[dict]) -> List[DeckCardWithStatus]:
|
||||
"""
|
||||
Cross-reference generated cards against the collection DB.
|
||||
Sets owned/scryfall_id/owned_qty and real price.
|
||||
Deduplicates cards by name (case-insensitive) — if the LLM
|
||||
returns the same card twice, the counts are merged.
|
||||
Deduplicates by name (case-insensitive).
|
||||
"""
|
||||
conn = self.db_conn_factory()
|
||||
enriched = []
|
||||
seen_names: dict[str, int] = {} # name_lower → index in enriched
|
||||
enriched: List[DeckCardWithStatus] = []
|
||||
seen_names: dict[str, int] = {}
|
||||
|
||||
for card in cards:
|
||||
name = card.get("name", "")
|
||||
@@ -314,10 +581,9 @@ class DeckGeneratorV3:
|
||||
if name_lower in seen_names:
|
||||
idx = seen_names[name_lower]
|
||||
enriched[idx].count += card.get("count", 1)
|
||||
logger.warning("Duplicate card '%s' merged (LLM returned it twice)", name)
|
||||
logger.warning("Duplicate card '%s' merged", name)
|
||||
continue
|
||||
|
||||
# Look up in collection
|
||||
row = conn.execute(
|
||||
"SELECT name, scryfall_id, quantity, tcg_price, type_line, cmc "
|
||||
"FROM collection_entries WHERE name = ? COLLATE NOCASE LIMIT 1",
|
||||
@@ -348,11 +614,8 @@ class DeckGeneratorV3:
|
||||
return enriched
|
||||
|
||||
# ── Step 5b: Basic Land Fill ──────────────────────────────
|
||||
_COLOR_TO_BASIC = {
|
||||
'W': 'Plains', 'U': 'Island', 'B': 'Swamp',
|
||||
'R': 'Mountain', 'G': 'Forest',
|
||||
}
|
||||
_BASIC_NAMES = set(_COLOR_TO_BASIC.values()) | {'Wastes'}
|
||||
_COLOR_TO_BASIC = _COLOR_TO_BASIC
|
||||
_BASIC_NAMES = _BASIC_NAMES
|
||||
|
||||
def _fill_basic_lands(
|
||||
self,
|
||||
@@ -374,7 +637,7 @@ class DeckGeneratorV3:
|
||||
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']
|
||||
basic_names = ["Wastes"]
|
||||
|
||||
per_color = basics_needed // len(basic_names)
|
||||
remainder = basics_needed % len(basic_names)
|
||||
@@ -385,20 +648,20 @@ class DeckGeneratorV3:
|
||||
non_basics.append(DeckCardWithStatus(
|
||||
name=bname,
|
||||
count=qty,
|
||||
category='Land',
|
||||
category="Land",
|
||||
role_tags=[],
|
||||
reason='Basic land for mana fixing',
|
||||
reason="Basic land for mana fixing",
|
||||
estimated_price_usd=0.10,
|
||||
owned=True,
|
||||
owned_qty=qty,
|
||||
status='owned',
|
||||
status="owned",
|
||||
))
|
||||
|
||||
logger.info("Basic land fill: %d basics added across %d color(s)",
|
||||
basics_needed, len(basic_names))
|
||||
basics_needed, len(basic_names))
|
||||
return non_basics
|
||||
|
||||
# ── Step 6: Smart Substitution ───────────────────────────
|
||||
# ── Step 6: Smart Substitution (legacy wrapper for route compatibility) ──
|
||||
def run_substitution(
|
||||
self,
|
||||
cards: List[DeckCardWithStatus],
|
||||
@@ -408,15 +671,12 @@ class DeckGeneratorV3:
|
||||
"""
|
||||
Run the Smart Substitution Engine on missing cards.
|
||||
|
||||
Pipeline:
|
||||
a) Identify missing cards
|
||||
b) For each: try embedding similarity (free, fast)
|
||||
c) For low-confidence matches: batch Anthropic Claude fallback
|
||||
d) Auto-select best substitute per card
|
||||
|
||||
Returns SubstitutionBatchResult with all cards updated.
|
||||
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]
|
||||
|
||||
@@ -431,47 +691,63 @@ class DeckGeneratorV3:
|
||||
|
||||
logger.info("Substitution: %d missing / %d owned", len(missing), len(owned))
|
||||
|
||||
# Build list of owned card names for substitution pool
|
||||
# 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]
|
||||
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}
|
||||
|
||||
# Exclude cards already in the deck
|
||||
deck_names = set(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
|
||||
needs_anthropic_fallback = []
|
||||
# a) Embedding pass for missing cards
|
||||
needs_anthropic_fallback: List[DeckCardWithStatus] = []
|
||||
for card in missing:
|
||||
embedding_alts = self._embedding_substitution(
|
||||
card, color_identity, available_pool
|
||||
card, color_identity, list(available_pool)
|
||||
)
|
||||
card.alternatives = embedding_alts
|
||||
|
||||
# Check if best match meets threshold
|
||||
if embedding_alts and embedding_alts[0].similarity_score >= SUBSTITUTION_MIN_SIMILARITY:
|
||||
card.selected_substitute = embedding_alts[0].name
|
||||
card.status = "substituted"
|
||||
# Remove selected from pool
|
||||
if card.selected_substitute.lower() in [n.lower() for n in available_pool]:
|
||||
available_pool = [n for n in available_pool
|
||||
if n.lower() != card.selected_substitute.lower()]
|
||||
card.owned = True
|
||||
available_pool.discard(card.selected_substitute.lower())
|
||||
else:
|
||||
needs_anthropic_fallback.append(card)
|
||||
|
||||
# b) Anthropic Claude fallback for low-confidence matches
|
||||
# b) Anthropic Claude fallback
|
||||
if needs_anthropic_fallback and SUBSTITUTION_USE_ANTHROPIC_FALLBACK:
|
||||
self._anthropic_substitution(
|
||||
needs_anthropic_fallback, available_pool,
|
||||
needs_anthropic_fallback, list(available_pool),
|
||||
commander, strategy
|
||||
)
|
||||
|
||||
# Count results
|
||||
# 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),
|
||||
@@ -490,33 +766,31 @@ class DeckGeneratorV3:
|
||||
if not self.embedding_index or not self.embedding_index.loaded:
|
||||
return []
|
||||
|
||||
# Use the embeddings search to find similar cards
|
||||
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, # Over-fetch, then filter to owned
|
||||
)
|
||||
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 []
|
||||
|
||||
# Filter to only cards in the available pool
|
||||
pool_lower = set(n.lower() for n in available_pool)
|
||||
pool_lower = {n.lower() for n in available_pool}
|
||||
alternatives = []
|
||||
for match in matches:
|
||||
if match.name.lower() in pool_lower:
|
||||
# Compute role overlap
|
||||
match_roles = [] # Would need card's role_tags from DB
|
||||
role_overlap = list(set(card.role_tags) & set(match_roles)) if match_roles else []
|
||||
|
||||
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=match.mana_value - (card.estimated_price_usd or 0),
|
||||
cmc_delta=getattr(match, "mana_value", 0) - (card.estimated_price_usd or 0),
|
||||
))
|
||||
|
||||
if len(alternatives) >= SUBSTITUTION_MAX_ALTERNATIVES:
|
||||
break
|
||||
|
||||
@@ -531,12 +805,13 @@ class DeckGeneratorV3:
|
||||
):
|
||||
"""
|
||||
Batch Anthropic Claude fallback for cards with low embedding confidence.
|
||||
Mutates the cards in-place with substitution data.
|
||||
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
|
||||
|
||||
# Build the request
|
||||
missing_dicts = [
|
||||
{
|
||||
"name": c.name,
|
||||
@@ -549,7 +824,7 @@ class DeckGeneratorV3:
|
||||
|
||||
user_prompt = build_substitution_prompt(
|
||||
missing_cards=missing_dicts,
|
||||
allowed_cards=available_pool[:200], # Limit to avoid token overflow
|
||||
allowed_cards=available_pool[:200],
|
||||
commander=commander.get("name", ""),
|
||||
color_identity=commander.get("color_identity", []),
|
||||
strategy=strategy,
|
||||
@@ -568,13 +843,16 @@ class DeckGeneratorV3:
|
||||
)
|
||||
|
||||
if not response.ok:
|
||||
logger.warning("Anthropic Claude substitution fallback failed — non-JSON response")
|
||||
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 Claude suggested %d substitutions", len(subs))
|
||||
logger.info("[anthropic_substitution] Received %d substitutions", len(subs))
|
||||
|
||||
# Apply substitutions
|
||||
card_by_name = {c.name.lower(): c for c in missing_cards}
|
||||
for sub in subs:
|
||||
orig = sub.get("original", "").lower()
|
||||
@@ -583,32 +861,38 @@ class DeckGeneratorV3:
|
||||
|
||||
if orig in card_by_name and substitute:
|
||||
card = card_by_name[orig]
|
||||
# Add as top alternative
|
||||
anthropic_alt = CardAlternative(
|
||||
name=substitute,
|
||||
similarity_score=0.80, # Nominal score for Anthropic Claude suggestions
|
||||
similarity_score=0.80,
|
||||
reason=reason,
|
||||
source="Anthropic Claude",
|
||||
source="anthropic",
|
||||
role_overlap=sub.get("role_overlap", []),
|
||||
)
|
||||
# Insert at front
|
||||
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 Claude substitution fallback error: %s", 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 = {}
|
||||
by_status = {"owned": 0, "substituted": 0, "missing": 0}
|
||||
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 = {}
|
||||
role_counts: dict[str, int] = {}
|
||||
|
||||
for card in cards:
|
||||
cat = card.category or "Other"
|
||||
|
||||
+4
-75
@@ -25,7 +25,6 @@ import json
|
||||
import logging
|
||||
import logging.handlers
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
@@ -43,9 +42,6 @@ DEFAULT_CONFIG = {
|
||||
"time_limit_hours": 11.5, # stop after this many hours (leave buffer for training)
|
||||
"pause_between_batches": 10, # seconds to pause between batches
|
||||
"auto_train": True, # trigger ML training after all batches
|
||||
"auto_update_weights": True, # run update_weights.py after sims
|
||||
"weights_min_games": 10, # minimum games required for weight update
|
||||
"weights_lr": 0.05, # learning rate for weight update
|
||||
"train_epochs": 100, # training epochs
|
||||
"train_lr": 0.001, # learning rate
|
||||
"train_batch_size": 256, # training batch size
|
||||
@@ -233,52 +229,6 @@ def wait_for_batch(base_url, batch_id, engine="deepseek", deadline=None):
|
||||
time.sleep(poll_interval)
|
||||
|
||||
|
||||
def run_update_weights(cfg):
|
||||
"""
|
||||
Run ml.scripts.update_weights to nudge learned_weights.json from sim
|
||||
decision logs. This feeds the deck builder's sim_insights injection.
|
||||
|
||||
Returns True on success, False on failure (non-fatal — overnight run
|
||||
continues regardless).
|
||||
"""
|
||||
log.info("")
|
||||
log.info("── Auto-update sim weights ─────────────────────────────────")
|
||||
log.info("Running update_weights.py to refresh learned_weights.json...")
|
||||
|
||||
cmd = [
|
||||
sys.executable, "-m", "ml.scripts.update_weights",
|
||||
"--min-games", str(cfg["weights_min_games"]),
|
||||
"--lr", str(cfg["weights_lr"]),
|
||||
]
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120, # 2 min ceiling — this should complete in seconds
|
||||
)
|
||||
if result.returncode == 0:
|
||||
# Print the last ~800 chars of the weight report to the log
|
||||
output = (result.stdout or "").strip()
|
||||
if output:
|
||||
for line in output.splitlines()[-20:]:
|
||||
log.info(f" {line}")
|
||||
log.info("Sim weights updated — deck builder will use new insights on next build.")
|
||||
return True
|
||||
else:
|
||||
log.warning(f"update_weights.py exited with code {result.returncode}")
|
||||
if result.stderr:
|
||||
log.warning(result.stderr.strip()[-300:])
|
||||
return False
|
||||
except subprocess.TimeoutExpired:
|
||||
log.warning("update_weights.py timed out after 120s — skipping")
|
||||
return False
|
||||
except Exception as e:
|
||||
log.warning(f"update_weights.py failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def start_training(base_url, epochs, lr, batch_size, patience):
|
||||
"""Trigger ML training and return immediately."""
|
||||
resp = api_post(base_url, "/api/ml/train", {
|
||||
@@ -414,7 +364,6 @@ def run_overnight(cfg):
|
||||
else:
|
||||
log.info(f" Mode: Run for {cfg['time_limit_hours']}h, {games_per_batch} games/batch")
|
||||
log.info(f" Engine: {engine}")
|
||||
log.info(f" Auto-update weights: {'Yes' if cfg['auto_update_weights'] else 'No'}")
|
||||
log.info(f" Auto-train after: {'Yes' if cfg['auto_train'] else 'No'}")
|
||||
log.info("")
|
||||
|
||||
@@ -491,17 +440,6 @@ def run_overnight(cfg):
|
||||
log.info(f" Elapsed time: {str(timedelta(seconds=int(total_elapsed)))}")
|
||||
log.info("")
|
||||
|
||||
# ── Auto-update sim weights ───────────────────────────────
|
||||
# Runs update_weights.py to nudge learned_weights.json from the
|
||||
# decision logs produced during this run. This feeds sim_insights
|
||||
# into the deck builder's Ollama prompts on the next deck build.
|
||||
# Runs before neural net training so both pipelines benefit from
|
||||
# the freshest possible data.
|
||||
if cfg["auto_update_weights"]:
|
||||
run_update_weights(cfg)
|
||||
else:
|
||||
log.info("Skipping sim weight update (--no-update-weights flag set)")
|
||||
|
||||
# ── Auto-train ────────────────────────────────────────────
|
||||
if cfg["auto_train"]:
|
||||
if decisions_after < 100:
|
||||
@@ -544,17 +482,17 @@ def main():
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
# Run DeepSeek sims for 11.5 hours, then update weights + train:
|
||||
# Run DeepSeek sims for 11.5 hours, then train:
|
||||
python overnight-run.py
|
||||
|
||||
# Run 20 batches of 50 games each, then update weights + train:
|
||||
# Run 20 batches of 50 games each, then train:
|
||||
python overnight-run.py --batches 20 --games 50
|
||||
|
||||
# Run for 6 hours with Java engine:
|
||||
python overnight-run.py --engine java --hours 6 --decks "Deck A" "Deck B" "Deck C"
|
||||
|
||||
# Just run sims, skip both weight update and training:
|
||||
python overnight-run.py --no-train --no-update-weights
|
||||
# Just run sims, no training:
|
||||
python overnight-run.py --no-train
|
||||
|
||||
# Custom training settings:
|
||||
python overnight-run.py --epochs 200 --lr 0.0005 --patience 20
|
||||
@@ -576,12 +514,6 @@ Examples:
|
||||
help="Deck names (auto-detected if not specified)")
|
||||
parser.add_argument("--no-train", action="store_true",
|
||||
help="Skip auto-training after sims")
|
||||
parser.add_argument("--no-update-weights", action="store_true",
|
||||
help="Skip updating learned_weights.json after sims")
|
||||
parser.add_argument("--weights-min-games", type=int, default=10,
|
||||
help="Minimum games required for weight update (default: 10)")
|
||||
parser.add_argument("--weights-lr", type=float, default=0.05,
|
||||
help="Learning rate for sim weight update (default: 0.05)")
|
||||
parser.add_argument("--epochs", type=int, default=100,
|
||||
help="Training epochs (default: 100)")
|
||||
parser.add_argument("--lr", type=float, default=0.001,
|
||||
@@ -604,9 +536,6 @@ Examples:
|
||||
cfg["time_limit_hours"] = args.hours
|
||||
cfg["pause_between_batches"] = args.pause
|
||||
cfg["auto_train"] = not args.no_train
|
||||
cfg["auto_update_weights"] = not args.no_update_weights
|
||||
cfg["weights_min_games"] = args.weights_min_games
|
||||
cfg["weights_lr"] = args.weights_lr
|
||||
cfg["train_epochs"] = args.epochs
|
||||
cfg["train_lr"] = args.lr
|
||||
cfg["train_batch_size"] = args.batch_size
|
||||
|
||||
@@ -102,7 +102,7 @@ def chat(
|
||||
return response.choices[0].message.content or ""
|
||||
|
||||
|
||||
# ── 1. Analyze synergies ─────────────────────────────────────────────
|
||||
# ── 1. Analyze synergies ─────────────────────────────────────────
|
||||
|
||||
def analyze_synergies(
|
||||
commander_name: str,
|
||||
@@ -145,7 +145,7 @@ Cards to evaluate:
|
||||
return []
|
||||
|
||||
|
||||
# ── 2. Suggest cards for a category ────────────────────────────────
|
||||
# ── 2. Suggest cards for a category ──────────────────────────────
|
||||
|
||||
def suggest_cards(
|
||||
commander_name: str,
|
||||
@@ -154,23 +154,13 @@ def suggest_cards(
|
||||
count: int = 10,
|
||||
exclude: Optional[List[str]] = None,
|
||||
strategy_notes: Optional[str] = None,
|
||||
sim_context: str = "",
|
||||
) -> List[str]:
|
||||
"""
|
||||
Ask the model to suggest cards for a specific deck category.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
sim_context:
|
||||
Optional natural-language insight string from get_sim_insights().
|
||||
When non-empty, appended to the prompt so the LLM can factor in
|
||||
statistically successful card traits from simulation data.
|
||||
|
||||
Returns a list of card names.
|
||||
"""
|
||||
exclude_str = f"\nDo NOT suggest these cards: {json.dumps(exclude)}" if exclude else ""
|
||||
strategy = f"\nStrategy notes: {strategy_notes}" if strategy_notes else ""
|
||||
sim_block = f"\n\n{sim_context}" if sim_context else ""
|
||||
|
||||
prompt = f"""You are a Magic: The Gathering Commander deck-building expert.
|
||||
|
||||
@@ -178,7 +168,7 @@ Commander: {commander_name}
|
||||
Color identity: {', '.join(color_identity)}
|
||||
Category: {category}
|
||||
{strategy}
|
||||
{exclude_str}{sim_block}
|
||||
{exclude_str}
|
||||
|
||||
Suggest exactly {count} cards for the {category} category that work well with this commander.
|
||||
Only suggest cards that are legal in Commander and match the color identity.
|
||||
@@ -210,7 +200,7 @@ Return ONLY a JSON array of card name strings, like: ["Card A", "Card B", ...]""
|
||||
return []
|
||||
|
||||
|
||||
# ── 3. Filter by color identity ────────────────────────────────────
|
||||
# ── 3. Filter by color identity ──────────────────────────────────
|
||||
|
||||
def filter_color_identity(
|
||||
card_names: List[str],
|
||||
@@ -252,28 +242,17 @@ Cards:
|
||||
return card_names
|
||||
|
||||
|
||||
# ── 4. Enforce deck ratios ───────────────────────────────────────────
|
||||
# ── 4. Enforce deck ratios ───────────────────────────────────────
|
||||
|
||||
def enforce_deck_ratios(
|
||||
cards_by_category: Dict[str, List[str]],
|
||||
target_ratios: Dict[str, int],
|
||||
commander_name: str,
|
||||
sim_context: str = "",
|
||||
) -> Dict[str, List[str]]:
|
||||
"""
|
||||
Ask the model to trim/expand categories to hit target counts.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
sim_context:
|
||||
Optional natural-language insight string from get_sim_insights().
|
||||
When non-empty, appended to the prompt so the LLM preferentially
|
||||
retains cards matching statistically high-value traits when trimming.
|
||||
|
||||
Returns adjusted dict of category -> card names.
|
||||
"""
|
||||
sim_block = f"\n\n{sim_context}" if sim_context else ""
|
||||
|
||||
prompt = f"""You are a Magic: The Gathering Commander deck-building expert.
|
||||
|
||||
Commander: {commander_name}
|
||||
@@ -286,7 +265,7 @@ Target card counts per category:
|
||||
|
||||
Adjust each category to match the target count. Remove the weakest cards from
|
||||
over-filled categories. For under-filled categories, suggest additional cards.
|
||||
The total across all categories must be exactly 99.{sim_block}
|
||||
The total across all categories must be exactly 99.
|
||||
|
||||
Return a JSON object with the same category keys, each mapping to an array of card name strings."""
|
||||
|
||||
@@ -305,7 +284,7 @@ Return a JSON object with the same category keys, each mapping to an array of ca
|
||||
return cards_by_category
|
||||
|
||||
|
||||
# ── 5. Assemble final deck JSON ─────────────────────────────────────────
|
||||
# ── 5. Assemble final deck JSON ──────────────────────────────────
|
||||
|
||||
def assemble_deck_json(
|
||||
commander_name: str,
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
"""
|
||||
sim_insights.py
|
||||
===============
|
||||
Translates learned simulation weights (learned_weights.json) into concise
|
||||
natural-language insight strings that can be injected into Ollama prompts.
|
||||
|
||||
This bridges the gap between the Monte Carlo engine's outcome-weighted
|
||||
reinforcement learning and the LLM-driven deck builder: keywords that
|
||||
statistically correlate with wins are surfaced to Ollama so it can
|
||||
prioritize those card traits when suggesting and trimming cards.
|
||||
|
||||
Usage::
|
||||
|
||||
from commander_ai_lab.deck_builder.api.sim_insights import get_sim_insights
|
||||
|
||||
context = get_sim_insights() # returns "" if no meaningful learning yet
|
||||
if context:
|
||||
prompt += f"\n\n{context}"
|
||||
|
||||
The function is intentionally safe to call when learned_weights.json does
|
||||
not yet exist (load_weights() falls back to AI_DEFAULT_WEIGHTS silently),
|
||||
and returns an empty string when no weight has drifted far enough from the
|
||||
default to be worth mentioning — so existing prompt behaviour is unchanged
|
||||
until there is real simulation data.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from commander_ai_lab.sim.rules import AI_DEFAULT_WEIGHTS, load_weights
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Maps weight keys → human-readable MTG concepts shown to the LLM.
|
||||
# Only keys the LLM can meaningfully act on are included.
|
||||
_KEY_TO_CONCEPT: dict[str, str] = {
|
||||
"kw_flying": "flying (evasion)",
|
||||
"kw_haste": "haste",
|
||||
"kw_deathtouch": "deathtouch",
|
||||
"kw_indestructible": "indestructible",
|
||||
"kw_doubleStrike": "double strike",
|
||||
"kw_hexproof": "hexproof",
|
||||
"kw_trample": "trample",
|
||||
"kw_lifelink": "lifelink",
|
||||
"kw_menace": "menace",
|
||||
"kw_ward": "ward",
|
||||
"kw_annihilator": "annihilator",
|
||||
"kw_cascade": "cascade",
|
||||
"spell_ramp": "mana ramp spells",
|
||||
"spell_destroy": "targeted removal",
|
||||
"spell_draw": "card draw",
|
||||
"spell_counter": "counterspells and board control",
|
||||
"trig_etb": "enters-the-battlefield triggers",
|
||||
"trig_dies": "death triggers",
|
||||
"trig_attack": "attack triggers",
|
||||
"card_ptBonus": "high power/toughness creatures",
|
||||
}
|
||||
|
||||
|
||||
def get_sim_insights(
|
||||
top_n: int = 6,
|
||||
threshold_delta: float = 0.05,
|
||||
weights_path: Optional[str] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Return a concise insight paragraph for injecting into LLM prompts.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
top_n:
|
||||
Maximum number of concepts to mention (avoids bloating the prompt).
|
||||
threshold_delta:
|
||||
Minimum absolute drift from the default weight before a concept
|
||||
is considered meaningful enough to report. 0.05 means a weight
|
||||
must have moved by at least 5% of its default before it matters.
|
||||
weights_path:
|
||||
Optional explicit path to learned_weights.json. If None, the
|
||||
default location (``src/.../sim/learned_weights.json``) is used.
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
A multi-line insight string ready to append to an LLM prompt, or
|
||||
an empty string if no meaningful learning is available yet.
|
||||
"""
|
||||
try:
|
||||
learned = load_weights(weights_path)
|
||||
except Exception as exc:
|
||||
logger.warning("sim_insights: could not load weights: %s", exc)
|
||||
return ""
|
||||
|
||||
deltas: dict[str, float] = {}
|
||||
for key, concept in _KEY_TO_CONCEPT.items():
|
||||
default_val = AI_DEFAULT_WEIGHTS.get(key, 0.0)
|
||||
learned_val = learned.get(key, default_val)
|
||||
delta = learned_val - default_val
|
||||
if abs(delta) >= threshold_delta:
|
||||
deltas[concept] = delta
|
||||
|
||||
if not deltas:
|
||||
# No meaningful drift yet — don't inject noise into prompts
|
||||
return ""
|
||||
|
||||
# Sort by magnitude, take top_n
|
||||
sorted_items = sorted(deltas.items(), key=lambda x: -abs(x[1]))[:top_n]
|
||||
boosted = [c for c, d in sorted_items if d > 0]
|
||||
penalized = [c for c, d in sorted_items if d < 0]
|
||||
|
||||
lines = [
|
||||
"[Simulation Insights] Data from thousands of Commander games in this lab suggests:",
|
||||
]
|
||||
if boosted:
|
||||
lines.append(f" - PRIORITIZE cards with: {', '.join(boosted)}")
|
||||
if penalized:
|
||||
lines.append(f" - DEPRIORITIZE cards with: {', '.join(penalized)}")
|
||||
lines.append(
|
||||
"Use this to guide your selections where multiple options are otherwise comparable."
|
||||
)
|
||||
|
||||
result = "\n".join(lines)
|
||||
logger.debug("sim_insights: injecting %d concept(s) into prompt", len(sorted_items))
|
||||
return result
|
||||
@@ -13,7 +13,6 @@ from typing import Dict, List, Optional
|
||||
|
||||
from ..api import edhrec, scryfall
|
||||
from ..api import ollama_client as ollama
|
||||
from ..api.sim_insights import get_sim_insights
|
||||
from ..core.collection_filter import filter_names_by_collection
|
||||
from ..core.models import BuildRequest, BuildResult, CardEntry, CommanderDeck, DeckRatios
|
||||
from ..core.rules_engine import check_ban_list, filter_by_color_identity, validate_deck
|
||||
@@ -38,7 +37,7 @@ def build_deck(request: BuildRequest) -> BuildResult:
|
||||
warnings: List[str] = []
|
||||
sources: List[str] = []
|
||||
|
||||
# ── Step 1: Resolve commander ────────────────────────────────────────────
|
||||
# ── Step 1: Resolve commander ────────────────────────────────
|
||||
logger.info(f"Step 1: Resolving commander '{request.commander_name}'")
|
||||
commander = scryfall.search_commander(request.commander_name)
|
||||
if not commander:
|
||||
@@ -51,17 +50,7 @@ def build_deck(request: BuildRequest) -> BuildResult:
|
||||
logger.info(f"Commander: {commander.name}, CI: {ci_list}")
|
||||
sources.append("scryfall")
|
||||
|
||||
# Load simulation insights once; injected into Ollama prompts in Steps 4 & 6.
|
||||
# Returns "" when learned_weights.json doesn't exist or has no meaningful drift
|
||||
# yet — so behaviour is identical to before until real sim data is available.
|
||||
sim_context = get_sim_insights()
|
||||
if sim_context:
|
||||
logger.info("Sim insights available — injecting into Ollama prompts")
|
||||
sources.append("sim_weights")
|
||||
else:
|
||||
logger.debug("No sim insights available (run update_weights.py after overnight sims)")
|
||||
|
||||
# ── Step 2: Fetch EDHrec recommendations ─────────────────────────────
|
||||
# ── Step 2: Fetch EDHrec recommendations ─────────────────────
|
||||
logger.info(f"Step 2: Fetching EDHrec data for '{commander.name}'")
|
||||
edhrec_cards = edhrec.get_recommended_cards(commander.name)
|
||||
if edhrec_cards:
|
||||
@@ -70,7 +59,7 @@ def build_deck(request: BuildRequest) -> BuildResult:
|
||||
else:
|
||||
warnings.append("EDHrec returned no data — relying on Scryfall + Ollama")
|
||||
|
||||
# ── Step 3: Fetch Scryfall candidates by category ────────────────────────
|
||||
# ── Step 3: Fetch Scryfall candidates by category ────────────
|
||||
logger.info("Step 3: Fetching Scryfall candidates by category")
|
||||
scryfall_candidates: Dict[str, List[CardEntry]] = {
|
||||
"ramp": scryfall.search_ramp(commander_ci),
|
||||
@@ -81,7 +70,7 @@ def build_deck(request: BuildRequest) -> BuildResult:
|
||||
for cat, cards in scryfall_candidates.items():
|
||||
logger.info(f" Scryfall {cat}: {len(cards)} candidates")
|
||||
|
||||
# ── Step 4: Ollama suggestions to fill gaps ────────────────────────────
|
||||
# ── Step 4: Ollama suggestions to fill gaps ──────────────────
|
||||
logger.info("Step 4: Ollama suggesting additional cards")
|
||||
|
||||
# Collect all names we already have
|
||||
@@ -90,9 +79,7 @@ def build_deck(request: BuildRequest) -> BuildResult:
|
||||
all_names.extend(c.name for c in cards)
|
||||
all_names = list(set(all_names))
|
||||
|
||||
# Ask Ollama to suggest cards for categories that need more.
|
||||
# sim_context is forwarded so the LLM factors in what traits have
|
||||
# been statistically successful in our own game simulations.
|
||||
# Ask Ollama to suggest cards for categories that need more
|
||||
categories_to_fill = ["synergy", "protection", "wincon"]
|
||||
ollama_suggestions: Dict[str, List[str]] = {}
|
||||
|
||||
@@ -104,7 +91,6 @@ def build_deck(request: BuildRequest) -> BuildResult:
|
||||
count=15,
|
||||
exclude=all_names,
|
||||
strategy_notes=request.strategy_notes,
|
||||
sim_context=sim_context,
|
||||
)
|
||||
ollama_suggestions[category] = suggested
|
||||
all_names.extend(suggested)
|
||||
@@ -112,7 +98,7 @@ def build_deck(request: BuildRequest) -> BuildResult:
|
||||
|
||||
sources.append("ollama")
|
||||
|
||||
# ── Step 5: Filter (color identity, collection, ban list) ────────────────
|
||||
# ── Step 5: Filter (color identity, collection, ban list) ────
|
||||
logger.info("Step 5: Filtering candidates")
|
||||
|
||||
# Build a unified name pool per category
|
||||
@@ -154,7 +140,7 @@ def build_deck(request: BuildRequest) -> BuildResult:
|
||||
n for n in cards_by_category[cat] if n not in BANNED_CARDS
|
||||
]
|
||||
|
||||
# ── Step 6: Ollama enforce ratios ────────────────────────────────────
|
||||
# ── Step 6: Ollama enforce ratios ────────────────────────────
|
||||
logger.info("Step 6: Ollama enforcing deck ratios")
|
||||
target_ratios = {
|
||||
"lands": request.ratios.lands,
|
||||
@@ -167,13 +153,10 @@ def build_deck(request: BuildRequest) -> BuildResult:
|
||||
"uncategorized": request.ratios.uncategorized,
|
||||
}
|
||||
|
||||
# sim_context is forwarded so Ollama preferentially retains cards
|
||||
# matching high-value sim traits when trimming over-filled categories.
|
||||
adjusted = ollama.enforce_deck_ratios(
|
||||
cards_by_category=cards_by_category,
|
||||
target_ratios=target_ratios,
|
||||
commander_name=commander.name,
|
||||
sim_context=sim_context,
|
||||
)
|
||||
|
||||
# Verify total is 99
|
||||
@@ -182,7 +165,7 @@ def build_deck(request: BuildRequest) -> BuildResult:
|
||||
warnings.append(f"Ollama returned {total} cards instead of 99 — adjusting")
|
||||
logger.warning(f"Ratio enforcement returned {total} cards")
|
||||
|
||||
# ── Step 7: Assemble final deck ────────────────────────────────────────
|
||||
# ── Step 7: Assemble final deck ──────────────────────────────
|
||||
logger.info("Step 7: Assembling final deck")
|
||||
|
||||
# Build a globally-deduplicated name list. enforce_deck_ratios can
|
||||
|
||||
+8
-23
@@ -58,10 +58,7 @@
|
||||
|
||||
<!-- Power Level Slider -->
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="coach-power-level">
|
||||
Target Power Level
|
||||
<span class="tip-icon tip-below" data-tip="Guides the LLM on how competitive suggestions should be. 1-3 = casual jank; 4-6 = upgraded precon; 7-8 = high power; 9-10 = cEDH with fast mana and infinite combos.">?</span>
|
||||
</label>
|
||||
<label class="form-label" for="coach-power-level">Target Power Level</label>
|
||||
<div class="coach-slider-row">
|
||||
<input type="range" id="coach-power-level" min="1" max="10" value="7" />
|
||||
<span class="coach-slider-value" id="coach-power-value">7</span>
|
||||
@@ -71,10 +68,7 @@
|
||||
|
||||
<!-- Meta Focus -->
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="coach-meta-focus">
|
||||
Meta Focus
|
||||
<span class="tip-icon tip-below" data-tip="The game plan the coach optimizes for. Aggro = fast damage; Control = answers and card advantage; Combo = tutors and win-con protection; Stax = slow everyone down.">?</span>
|
||||
</label>
|
||||
<label class="form-label" for="coach-meta-focus">Meta Focus</label>
|
||||
<select id="coach-meta-focus" class="form-select">
|
||||
<option value="">Any / Balanced</option>
|
||||
<option value="aggro">Aggro</option>
|
||||
@@ -87,10 +81,7 @@
|
||||
|
||||
<!-- Budget -->
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="coach-budget">
|
||||
Budget
|
||||
<span class="tip-icon tip-below" data-tip="Restricts suggestions to cards within a price range. Budget caps cards at ~$2 each; Medium at ~$10. No Limit allows any card including Reserved List staples.">?</span>
|
||||
</label>
|
||||
<label class="form-label" for="coach-budget">Budget</label>
|
||||
<select id="coach-budget" class="form-select">
|
||||
<option value="">No Limit</option>
|
||||
<option value="budget">Budget (< $2 per card)</option>
|
||||
@@ -101,10 +92,7 @@
|
||||
|
||||
<!-- Focus Areas -->
|
||||
<div class="form-group coach-field-wide">
|
||||
<label class="form-label">
|
||||
Focus Areas (optional)
|
||||
<span class="tip-icon tip-below" data-tip="Narrows the coach to specific deck categories. Leaving all unchecked triggers a full-deck general analysis across all categories.">?</span>
|
||||
</label>
|
||||
<label class="form-label">Focus Areas (optional)</label>
|
||||
<div class="coach-focus-tags">
|
||||
<label class="coach-tag"><input type="checkbox" value="ramp" /> Ramp</label>
|
||||
<label class="coach-tag"><input type="checkbox" value="card draw" /> Card Draw</label>
|
||||
@@ -133,7 +121,7 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Multi-turn Chat Panel -->
|
||||
<!-- ═══ Multi-turn Chat Panel ═══ -->
|
||||
<section class="panel coach-chat-section" id="coach-chat-section">
|
||||
<div class="panel-header coach-chat-header">
|
||||
<h2>Chat with Coach</h2>
|
||||
@@ -155,7 +143,7 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Cards-Like Search Panel -->
|
||||
<!-- ═══ Cards-Like Search Panel ═══ -->
|
||||
<section class="panel coach-cardslike-section" id="coach-cardslike-section">
|
||||
<div class="panel-header coach-panel-collapsible" id="cardslike-header">
|
||||
<h2>Find Similar Cards</h2>
|
||||
@@ -175,10 +163,7 @@
|
||||
<button class="coach-color-pip coach-pip-G" data-color="G" title="Green">G</button>
|
||||
</div>
|
||||
<div class="form-group coach-cardslike-topn">
|
||||
<label class="form-label">
|
||||
Results
|
||||
<span class="tip-icon tip-below" data-tip="How many similar cards to return. Uses vector embeddings to find cards with the most semantically similar oracle text and mechanics to your query card.">?</span>
|
||||
</label>
|
||||
<label class="form-label">Results</label>
|
||||
<input type="number" id="cardslike-topn" class="form-input" value="10" min="1" max="50" />
|
||||
</div>
|
||||
<button id="cardslike-search-btn" class="btn btn-accent btn-sm">Search</button>
|
||||
@@ -188,7 +173,7 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- One-Shot Results Section -->
|
||||
<!-- ═══ One-Shot Results Section ═══ -->
|
||||
<section id="coach-results" class="coach-results" style="display:none;">
|
||||
|
||||
<!-- Apply Suggestions Bar -->
|
||||
|
||||
+49
-33
@@ -62,13 +62,9 @@
|
||||
class="dg-input"
|
||||
id="dg-strategy"
|
||||
placeholder="e.g. zombie tokens, voltron, spellslinger..."
|
||||
title="Describe your deck strategy — the AI uses this to pick thematically aligned cards"
|
||||
/>
|
||||
<div class="dg-bracket-row">
|
||||
<label class="dg-label">
|
||||
Target Bracket
|
||||
<span class="tip-icon" data-tip="B1 = precon-level casual. B2 = upgraded with synergies. B3 = high power with strong combos. B4 = cEDH with maximum optimization, fast mana, and instant-speed interaction.">?</span>
|
||||
</label>
|
||||
<label class="dg-label">Target Bracket</label>
|
||||
<div class="dg-bracket-btns" id="dg-bracket-btns">
|
||||
<button class="dg-bracket-btn" data-bracket="1" title="Casual — precon level">B1</button>
|
||||
<button class="dg-bracket-btn" data-bracket="2" title="Upgraded Casual">B2</button>
|
||||
@@ -109,7 +105,7 @@
|
||||
min="0"
|
||||
step="25"
|
||||
/>
|
||||
<select class="dg-select" id="dg-budget-mode" title="Total Deck: caps the combined price of all 100 cards. Per Card Max: caps each individual card price.">
|
||||
<select class="dg-select" id="dg-budget-mode">
|
||||
<option value="total">Total Deck $</option>
|
||||
<option value="per_card">Per Card Max $</option>
|
||||
</select>
|
||||
@@ -124,34 +120,25 @@
|
||||
</div>
|
||||
<div class="dg-panel-body">
|
||||
<label class="dg-toggle-row">
|
||||
<span class="dg-toggle-label">
|
||||
Use my collection
|
||||
<span class="tip-icon" data-tip="Prioritizes cards you already own before pulling from the full card pool. Saves money and ensures the deck is ready to play. Requires an imported collection.">?</span>
|
||||
</span>
|
||||
<span class="dg-toggle-label">Use my collection</span>
|
||||
<input type="checkbox" class="dg-toggle-check" id="dg-use-collection" checked />
|
||||
<span class="dg-toggle-track"><span class="dg-toggle-thumb"></span></span>
|
||||
</label>
|
||||
<label class="dg-toggle-row">
|
||||
<span class="dg-toggle-label">
|
||||
Smart Substitution
|
||||
<span class="tip-icon" data-tip="When a suggested card isn't in your collection, automatically finds the closest functional replacement you do own. Keeps the deck playable without buying extra cards.">?</span>
|
||||
</span>
|
||||
<span class="dg-toggle-label">Smart Substitution</span>
|
||||
<input type="checkbox" class="dg-toggle-check" id="dg-substitution" checked />
|
||||
<span class="dg-toggle-track"><span class="dg-toggle-thumb"></span></span>
|
||||
</label>
|
||||
<div class="dg-model-row">
|
||||
<label class="dg-label">
|
||||
AI Model
|
||||
<span class="tip-icon" data-tip="The local LLM used to generate the deck list. Larger models produce better card selections but are slower. Requires Ollama running locally with the selected model pulled.">?</span>
|
||||
</label>
|
||||
<label class="dg-label">AI Model</label>
|
||||
<select class="dg-select" id="dg-model">
|
||||
<option value="gpt-oss:20b" selected>gpt-oss:20b (Local Ollama)</option>
|
||||
<option value="gpt-oss:20b" selected>gpt-oss:20b (Local Ollama)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Omit Cards (chips input) -->
|
||||
<!-- Omit Cards (chips input) -->
|
||||
<div class="dg-panel">
|
||||
<div class="dg-panel-header">
|
||||
<span class="dg-panel-title">Omit Cards</span>
|
||||
@@ -173,12 +160,12 @@
|
||||
<!-- Action Buttons -->
|
||||
<div class="dg-actions">
|
||||
<button class="dg-btn dg-btn-preview" id="dg-preview-btn" disabled>
|
||||
<span class="dg-btn-icon">⚡</span> Generate Deck
|
||||
<span class="dg-btn-icon">⚡</span> Generate Deck
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Conversation Log -->
|
||||
<div id="dg-conversation-log" class="dg-conversation-log">
|
||||
<!-- Conversation Log -->
|
||||
<div id="dg-conversation-log" class="dg-conversation-log">
|
||||
<div class="dg-log-entry dg-log-info"><span class="dg-log-ts">[Ready]</span> Waiting for deck generation...</div>
|
||||
</div>
|
||||
|
||||
@@ -188,7 +175,7 @@
|
||||
<section class="dg-col-results">
|
||||
<!-- Empty State -->
|
||||
<div class="dg-empty-state" id="dg-empty-state">
|
||||
<div class="dg-empty-icon">⚡</div>
|
||||
<div class="dg-empty-icon">⚡</div>
|
||||
<div class="dg-empty-title">V3 AI Deck Generator</div>
|
||||
<div class="dg-empty-text">Select a commander, set your bracket and strategy, then click "Generate Deck" to build a complete 100-card deck using Claude Opus with structured output.</div>
|
||||
</div>
|
||||
@@ -198,18 +185,47 @@
|
||||
<div class="dg-spinner"></div>
|
||||
<div class="dg-loading-text" id="dg-loading-text">Generating deck via Local AI...</div>
|
||||
</div>
|
||||
<div class="dg-progress-wrap" id="dg-progress-wrap" style="display:none">
|
||||
<div class="dg-progress-bar">
|
||||
<div class="dg-progress-fill" id="dg-progress-fill"></div>
|
||||
<div class="dg-progress-wrap" id="dg-progress-wrap" style="display:none">
|
||||
<div class="dg-progress-bar">
|
||||
<div class="dg-progress-fill" id="dg-progress-fill"></div>
|
||||
</div>
|
||||
<div class="dg-progress-meta">
|
||||
<span class="dg-progress-phase" id="dg-progress-phase"></span>
|
||||
<span class="dg-progress-elapsed" id="dg-progress-elapsed">0s elapsed</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dg-progress-meta">
|
||||
<span class="dg-progress-phase" id="dg-progress-phase"></span>
|
||||
<span class="dg-progress-elapsed" id="dg-progress-elapsed">0s elapsed</span>
|
||||
|
||||
<!-- Results -->
|
||||
<div class="dg-results" id="dg-results" style="display:none">
|
||||
|
||||
<!-- Strategy & Bracket Summary -->
|
||||
<div class="dg-strategy-bar" id="dg-strategy-bar"></div>
|
||||
|
||||
<!-- Stats Bar -->
|
||||
<div class="dg-stats-bar" id="dg-stats-bar"></div>
|
||||
|
||||
<!-- Substitution Summary -->
|
||||
<div class="dg-sub-summary" id="dg-sub-summary" style="display:none"></div>
|
||||
|
||||
<!-- Card Groups -->
|
||||
<div class="dg-card-groups" id="dg-card-groups"></div>
|
||||
|
||||
<!-- Export & Save Bar -->
|
||||
<div class="dg-save-bar" id="dg-save-bar">
|
||||
<button class="dg-btn dg-btn-save" id="dg-save-btn">
|
||||
<span class="dg-btn-icon">💾</span> Save to Deck Builder
|
||||
</button>
|
||||
<button class="dg-btn dg-btn-ghost" id="dg-regenerate-btn">
|
||||
<span class="dg-btn-icon">🔄</span> Regenerate
|
||||
</button>
|
||||
<div class="dg-export-group">
|
||||
<button class="dg-btn dg-btn-export" id="dg-export-csv">📊 CSV</button>
|
||||
<button class="dg-btn dg-btn-export" id="dg-export-dck">🎮 Forge .dck</button>
|
||||
<button class="dg-btn dg-btn-export" id="dg-export-moxfield">📋 Moxfield</button>
|
||||
<button class="dg-btn dg-btn-export" id="dg-export-shopping">🛒 Shopping List</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Results placeholder — rest generated dynamically by deckgenerator.js -->
|
||||
<div class="dg-results" id="dg-results" style="display:none"></div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
+12
-44
@@ -16,7 +16,7 @@
|
||||
<div class="page-header" style="display:flex;align-items:center;justify-content:space-between;">
|
||||
<div>
|
||||
<h1>Closed-Loop Distillation</h1>
|
||||
<p>Iterative supervised → PPO → quality gate → merge pipeline</p>
|
||||
<p>Iterative supervised → PPO → quality gate → merge pipeline</p>
|
||||
</div>
|
||||
<button id="btn-refresh" class="btn btn-secondary" onclick="refreshAll()" title="Refresh status">
|
||||
<span class="btn-icon" id="refresh-icon">↻</span> Refresh
|
||||
@@ -59,70 +59,43 @@
|
||||
<div class="train-controls">
|
||||
<div class="param-grid form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="param-iterations">
|
||||
Max Generations
|
||||
<span class="tip-icon" data-tip="How many distillation cycles to run. Each cycle: supervised train → PPO fine-tune → quality gate. Stops early if convergence is detected.">?</span>
|
||||
</label>
|
||||
<label class="form-label" for="param-iterations">Max Generations</label>
|
||||
<input type="number" id="param-iterations" class="form-input" value="10" min="1" max="100" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="param-conv-window">
|
||||
Convergence Window
|
||||
<span class="tip-icon" data-tip="Number of recent generations to check for plateau. If ELO gain stays below threshold across this many gens, training stops early to save time.">?</span>
|
||||
</label>
|
||||
<label class="form-label" for="param-conv-window">Convergence Window</label>
|
||||
<input type="number" id="param-conv-window" class="form-input" value="3" min="2" max="10" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="param-sup-epochs">
|
||||
Supervised Epochs
|
||||
<span class="tip-icon" data-tip="Training epochs for the supervised step each generation. Fewer is fine since each cycle trains on incrementally better data. 30 is a solid starting point.">?</span>
|
||||
</label>
|
||||
<label class="form-label" for="param-sup-epochs">Supervised Epochs</label>
|
||||
<input type="number" id="param-sup-epochs" class="form-input" value="30" min="5" max="200" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="param-sup-lr">
|
||||
Supervised LR
|
||||
<span class="tip-icon" data-tip="Learning rate for the supervised phase. 0.001 is a solid default. Lower it in later generations if the model is already strong.">?</span>
|
||||
</label>
|
||||
<label class="form-label" for="param-sup-lr">Supervised LR</label>
|
||||
<input type="number" id="param-sup-lr" class="form-input" value="0.001" step="0.0001" min="0.00001" max="0.1" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="param-grid form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="param-ppo-iters">
|
||||
PPO Iterations
|
||||
<span class="tip-icon" data-tip="Number of PPO update steps per generation. Each step samples game episodes, computes advantages, and updates policy weights.">?</span>
|
||||
</label>
|
||||
<label class="form-label" for="param-ppo-iters">PPO Iterations</label>
|
||||
<input type="number" id="param-ppo-iters" class="form-input" value="50" min="10" max="500" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="param-ppo-episodes">
|
||||
PPO Episodes/Iter
|
||||
<span class="tip-icon" data-tip="Game simulations per PPO update step. More episodes = more stable gradients but slower iterations. 64 balances speed and stability well.">?</span>
|
||||
</label>
|
||||
<label class="form-label" for="param-ppo-episodes">PPO Episodes/Iter</label>
|
||||
<input type="number" id="param-ppo-episodes" class="form-input" value="64" min="8" max="512" step="8" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="param-ppo-lr">
|
||||
PPO Learning Rate
|
||||
<span class="tip-icon" data-tip="PPO step size. Keep this lower than the supervised LR. 0.0003 is the standard RL default and works well here.">?</span>
|
||||
</label>
|
||||
<label class="form-label" for="param-ppo-lr">PPO Learning Rate</label>
|
||||
<input type="number" id="param-ppo-lr" class="form-input" value="0.0003" step="0.0001" min="0.00001" max="0.01" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="param-min-wr">
|
||||
Min Win Rate Gate
|
||||
<span class="tip-icon" data-tip="Quality gate threshold. A generation must beat the heuristic opponent at this win rate to be accepted and merged into the next cycle. 0.30 is a lenient starting gate.">?</span>
|
||||
</label>
|
||||
<label class="form-label" for="param-min-wr">Min Win Rate Gate</label>
|
||||
<input type="number" id="param-min-wr" class="form-input" value="0.30" step="0.05" min="0.1" max="0.9" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="param-grid form-row distill-bottom-params">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="param-opponent">
|
||||
Opponent
|
||||
<span class="tip-icon" data-tip="Who the agent trains against during PPO. Heuristic is recommended for a consistent challenge. Self-play can become unstable without a strong supervised baseline first.">?</span>
|
||||
</label>
|
||||
<label class="form-label" for="param-opponent">Opponent</label>
|
||||
<select id="param-opponent" class="form-select">
|
||||
<option value="heuristic">Heuristic</option>
|
||||
<option value="random">Random</option>
|
||||
@@ -130,10 +103,7 @@
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="param-playstyle">
|
||||
Playstyle
|
||||
<span class="tip-icon" data-tip="Deck archetype used for simulations in this distillation run. Midrange is the most balanced test bed. Match this to the archetype you want the model to master.">?</span>
|
||||
</label>
|
||||
<label class="form-label" for="param-playstyle">Playstyle</label>
|
||||
<select id="param-playstyle" class="form-select">
|
||||
<option value="midrange">Midrange</option>
|
||||
<option value="aggro">Aggro</option>
|
||||
@@ -194,9 +164,7 @@
|
||||
<!-- ELO Rating Chart -->
|
||||
<section class="panel" id="elo-panel">
|
||||
<div class="panel-header elo-panel-header">
|
||||
<h2>ELO Ratings
|
||||
<span class="tip-icon tip-below" data-tip="ELO scores measure relative strength. Baseline is 1200. Scores above that mean the model wins more than it loses. Tournament runs the top 10 most recent checkpoints head-to-head.">?</span>
|
||||
</h2>
|
||||
<h2>ELO Ratings</h2>
|
||||
<button id="btn-run-elo" class="btn btn-sm btn-secondary" onclick="runEloTournament()">
|
||||
<span class="btn-icon">🏆</span> Run Tournament
|
||||
</button>
|
||||
|
||||
@@ -864,92 +864,3 @@ body {
|
||||
.form-input:focus, .form-select:focus, .form-textarea:focus {
|
||||
box-shadow: 0 0 0 3px var(--accent-dim), inset 0 1px 3px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════
|
||||
Tooltip System (.tip-icon + data-tip)
|
||||
Shared across all pages — do not duplicate in page-specific CSS
|
||||
══════════════════════════════════════════════════════════════ */
|
||||
|
||||
.tip-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
border-radius: 50%;
|
||||
background: var(--surface-3);
|
||||
color: var(--text-secondary);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
cursor: default;
|
||||
position: relative;
|
||||
vertical-align: middle;
|
||||
margin-left: 4px;
|
||||
flex-shrink: 0;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.tip-icon:hover {
|
||||
background: var(--accent-dim);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.tip-icon::after {
|
||||
content: attr(data-tip);
|
||||
position: absolute;
|
||||
bottom: calc(100% + 8px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--surface-0, #1a1a2e);
|
||||
color: var(--text, #e0e0e0);
|
||||
border: 1px solid var(--border, #333);
|
||||
border-radius: var(--radius-md, 6px);
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 1.5;
|
||||
white-space: normal;
|
||||
width: 240px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
z-index: 1000;
|
||||
text-align: left;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.tip-icon::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: calc(100% + 3px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border: 5px solid transparent;
|
||||
border-top-color: var(--border, #333);
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
z-index: 1001;
|
||||
}
|
||||
|
||||
.tip-icon:hover::after,
|
||||
.tip-icon:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.tip-icon.tip-below::after {
|
||||
bottom: auto;
|
||||
top: calc(100% + 8px);
|
||||
}
|
||||
|
||||
.tip-icon.tip-below::before {
|
||||
bottom: auto;
|
||||
top: calc(100% + 3px);
|
||||
border-top-color: transparent;
|
||||
border-bottom-color: var(--border, #333);
|
||||
}
|
||||
+6
-25
@@ -28,27 +28,20 @@
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group sim-field-auto">
|
||||
<label class="form-label" for="sim-opponent-select">
|
||||
Opponent
|
||||
<span class="tip-icon tip-below" data-tip="Heuristic AI uses rule-based logic — fast and deterministic. DeepSeek AI routes decisions through your local Ollama LLM for smarter but slower play.">?</span>
|
||||
</label>
|
||||
<label class="form-label" for="sim-opponent-select">Opponent</label>
|
||||
<select id="sim-opponent-select" class="form-select">
|
||||
<option value="heuristic">Heuristic AI</option>
|
||||
<option value="deepseek">DeepSeek AI</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group sim-field-auto">
|
||||
<label class="form-label" for="sim-num-games">
|
||||
Games
|
||||
<span class="tip-icon tip-below" data-tip="Number of games to simulate. 10 is quick for a gut-check; 100+ gives statistically meaningful win rates. Each game is fully simulated end-to-end.">?</span>
|
||||
</label>
|
||||
<label class="form-label" for="sim-num-games">Games</label>
|
||||
<input type="number" id="sim-num-games" class="form-input sim-num-input" value="10" min="1" max="1000" />
|
||||
</div>
|
||||
<div class="form-group sim-field-auto sim-field-end">
|
||||
<label class="form-checkbox">
|
||||
<input type="checkbox" id="sim-record-logs" checked />
|
||||
Record turn logs
|
||||
<span class="tip-icon tip-below" data-tip="Saves per-turn decision snapshots to .jsonl files. Required for ML training data collection. Slightly slower — disable if you only need win rate stats.">?</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group sim-field-auto sim-field-end">
|
||||
@@ -68,31 +61,19 @@
|
||||
</div>
|
||||
<div class="deepseek-config-row">
|
||||
<div class="form-group sim-field-grow">
|
||||
<label class="form-label" for="ds-api-base">
|
||||
API Endpoint
|
||||
<span class="tip-icon tip-below" data-tip="Base URL of your Ollama server. Default is localhost:11434. Change this if Ollama is running on a different machine or WSL2 network address.">?</span>
|
||||
</label>
|
||||
<label class="form-label" for="ds-api-base">API Endpoint</label>
|
||||
<input type="text" id="ds-api-base" class="form-input" value="http://localhost:11434" placeholder="http://localhost:11434" />
|
||||
</div>
|
||||
<div class="form-group sim-field-auto">
|
||||
<label class="form-label" for="ds-model">
|
||||
Model
|
||||
<span class="tip-icon tip-below" data-tip="Ollama model tag to use for AI decisions. Leave blank to auto-detect the best available model. Examples: deepseek-r1:7b, llama3:8b, mistral.">?</span>
|
||||
</label>
|
||||
<label class="form-label" for="ds-model">Model</label>
|
||||
<input type="text" id="ds-model" class="form-input" value="" placeholder="Auto-detect" style="width:200px" />
|
||||
</div>
|
||||
<div class="form-group sim-field-auto">
|
||||
<label class="form-label" for="ds-temp">
|
||||
Temp
|
||||
<span class="tip-icon tip-below" data-tip="LLM sampling temperature. Lower = more deterministic decisions. 0.3 is a good balance — fully greedy (0.0) can get stuck in repetitive play patterns.">?</span>
|
||||
</label>
|
||||
<label class="form-label" for="ds-temp">Temp</label>
|
||||
<input type="number" id="ds-temp" class="form-input sim-num-input" value="0.3" min="0" max="2" step="0.1" />
|
||||
</div>
|
||||
<div class="form-group sim-field-auto">
|
||||
<label class="form-label" for="ds-timeout">
|
||||
Timeout (s)
|
||||
<span class="tip-icon tip-below" data-tip="Max seconds to wait for an LLM response per decision. If exceeded, falls back to heuristic AI automatically. Increase if your GPU is slow on cold inference.">?</span>
|
||||
</label>
|
||||
<label class="form-label" for="ds-timeout">Timeout (s)</label>
|
||||
<input type="number" id="ds-timeout" class="form-input sim-num-input" value="10" min="1" max="60" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user