Compare commits

...
3 changed files with 177 additions and 15 deletions

No files matched your search

@@ -102,7 +102,7 @@ def chat(
return response.choices[0].message.content or "" return response.choices[0].message.content or ""
# ── 1. Analyze synergies ───────────────────────────────────────── # ── 1. Analyze synergies ─────────────────────────────────────────────
def analyze_synergies( def analyze_synergies(
commander_name: str, commander_name: str,
@@ -145,7 +145,7 @@ Cards to evaluate:
return [] return []
# ── 2. Suggest cards for a category ────────────────────────────── # ── 2. Suggest cards for a category ────────────────────────────────
def suggest_cards( def suggest_cards(
commander_name: str, commander_name: str,
@@ -154,13 +154,23 @@ def suggest_cards(
count: int = 10, count: int = 10,
exclude: Optional[List[str]] = None, exclude: Optional[List[str]] = None,
strategy_notes: Optional[str] = None, strategy_notes: Optional[str] = None,
sim_context: str = "",
) -> List[str]: ) -> List[str]:
""" """
Ask the model to suggest cards for a specific deck category. 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. Returns a list of card names.
""" """
exclude_str = f"\nDo NOT suggest these cards: {json.dumps(exclude)}" if exclude else "" exclude_str = f"\nDo NOT suggest these cards: {json.dumps(exclude)}" if exclude else ""
strategy = f"\nStrategy notes: {strategy_notes}" if strategy_notes 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. prompt = f"""You are a Magic: The Gathering Commander deck-building expert.
@@ -168,7 +178,7 @@ Commander: {commander_name}
Color identity: {', '.join(color_identity)} Color identity: {', '.join(color_identity)}
Category: {category} Category: {category}
{strategy} {strategy}
{exclude_str} {exclude_str}{sim_block}
Suggest exactly {count} cards for the {category} category that work well with this commander. 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. Only suggest cards that are legal in Commander and match the color identity.
@@ -200,7 +210,7 @@ Return ONLY a JSON array of card name strings, like: ["Card A", "Card B", ...]""
return [] return []
# ── 3. Filter by color identity ────────────────────────────────── # ── 3. Filter by color identity ────────────────────────────────────
def filter_color_identity( def filter_color_identity(
card_names: List[str], card_names: List[str],
@@ -242,17 +252,28 @@ Cards:
return card_names return card_names
# ── 4. Enforce deck ratios ─────────────────────────────────────── # ── 4. Enforce deck ratios ───────────────────────────────────────────
def enforce_deck_ratios( def enforce_deck_ratios(
cards_by_category: Dict[str, List[str]], cards_by_category: Dict[str, List[str]],
target_ratios: Dict[str, int], target_ratios: Dict[str, int],
commander_name: str, commander_name: str,
sim_context: str = "",
) -> Dict[str, List[str]]: ) -> Dict[str, List[str]]:
""" """
Ask the model to trim/expand categories to hit target counts. 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. 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. prompt = f"""You are a Magic: The Gathering Commander deck-building expert.
Commander: {commander_name} Commander: {commander_name}
@@ -265,7 +286,7 @@ Target card counts per category:
Adjust each category to match the target count. Remove the weakest cards from Adjust each category to match the target count. Remove the weakest cards from
over-filled categories. For under-filled categories, suggest additional cards. over-filled categories. For under-filled categories, suggest additional cards.
The total across all categories must be exactly 99. The total across all categories must be exactly 99.{sim_block}
Return a JSON object with the same category keys, each mapping to an array of card name strings.""" Return a JSON object with the same category keys, each mapping to an array of card name strings."""
@@ -284,7 +305,7 @@ Return a JSON object with the same category keys, each mapping to an array of ca
return cards_by_category return cards_by_category
# ── 5. Assemble final deck JSON ────────────────────────────────── # ── 5. Assemble final deck JSON ─────────────────────────────────────────
def assemble_deck_json( def assemble_deck_json(
commander_name: str, commander_name: str,
@@ -0,0 +1,124 @@
"""
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,6 +13,7 @@ from typing import Dict, List, Optional
from ..api import edhrec, scryfall from ..api import edhrec, scryfall
from ..api import ollama_client as ollama 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.collection_filter import filter_names_by_collection
from ..core.models import BuildRequest, BuildResult, CardEntry, CommanderDeck, DeckRatios from ..core.models import BuildRequest, BuildResult, CardEntry, CommanderDeck, DeckRatios
from ..core.rules_engine import check_ban_list, filter_by_color_identity, validate_deck from ..core.rules_engine import check_ban_list, filter_by_color_identity, validate_deck
@@ -37,7 +38,7 @@ def build_deck(request: BuildRequest) -> BuildResult:
warnings: List[str] = [] warnings: List[str] = []
sources: List[str] = [] sources: List[str] = []
# ── Step 1: Resolve commander ──────────────────────────────── # ── Step 1: Resolve commander ────────────────────────────────────────────
logger.info(f"Step 1: Resolving commander '{request.commander_name}'") logger.info(f"Step 1: Resolving commander '{request.commander_name}'")
commander = scryfall.search_commander(request.commander_name) commander = scryfall.search_commander(request.commander_name)
if not commander: if not commander:
@@ -50,7 +51,17 @@ def build_deck(request: BuildRequest) -> BuildResult:
logger.info(f"Commander: {commander.name}, CI: {ci_list}") logger.info(f"Commander: {commander.name}, CI: {ci_list}")
sources.append("scryfall") sources.append("scryfall")
# ── Step 2: Fetch EDHrec recommendations ───────────────────── # 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 ─────────────────────────────
logger.info(f"Step 2: Fetching EDHrec data for '{commander.name}'") logger.info(f"Step 2: Fetching EDHrec data for '{commander.name}'")
edhrec_cards = edhrec.get_recommended_cards(commander.name) edhrec_cards = edhrec.get_recommended_cards(commander.name)
if edhrec_cards: if edhrec_cards:
@@ -59,7 +70,7 @@ def build_deck(request: BuildRequest) -> BuildResult:
else: else:
warnings.append("EDHrec returned no data — relying on Scryfall + Ollama") 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") logger.info("Step 3: Fetching Scryfall candidates by category")
scryfall_candidates: Dict[str, List[CardEntry]] = { scryfall_candidates: Dict[str, List[CardEntry]] = {
"ramp": scryfall.search_ramp(commander_ci), "ramp": scryfall.search_ramp(commander_ci),
@@ -70,7 +81,7 @@ def build_deck(request: BuildRequest) -> BuildResult:
for cat, cards in scryfall_candidates.items(): for cat, cards in scryfall_candidates.items():
logger.info(f" Scryfall {cat}: {len(cards)} candidates") 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") logger.info("Step 4: Ollama suggesting additional cards")
# Collect all names we already have # Collect all names we already have
@@ -79,7 +90,9 @@ def build_deck(request: BuildRequest) -> BuildResult:
all_names.extend(c.name for c in cards) all_names.extend(c.name for c in cards)
all_names = list(set(all_names)) all_names = list(set(all_names))
# Ask Ollama to suggest cards for categories that need more # 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.
categories_to_fill = ["synergy", "protection", "wincon"] categories_to_fill = ["synergy", "protection", "wincon"]
ollama_suggestions: Dict[str, List[str]] = {} ollama_suggestions: Dict[str, List[str]] = {}
@@ -91,6 +104,7 @@ def build_deck(request: BuildRequest) -> BuildResult:
count=15, count=15,
exclude=all_names, exclude=all_names,
strategy_notes=request.strategy_notes, strategy_notes=request.strategy_notes,
sim_context=sim_context,
) )
ollama_suggestions[category] = suggested ollama_suggestions[category] = suggested
all_names.extend(suggested) all_names.extend(suggested)
@@ -98,7 +112,7 @@ def build_deck(request: BuildRequest) -> BuildResult:
sources.append("ollama") 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") logger.info("Step 5: Filtering candidates")
# Build a unified name pool per category # Build a unified name pool per category
@@ -140,7 +154,7 @@ def build_deck(request: BuildRequest) -> BuildResult:
n for n in cards_by_category[cat] if n not in BANNED_CARDS 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") logger.info("Step 6: Ollama enforcing deck ratios")
target_ratios = { target_ratios = {
"lands": request.ratios.lands, "lands": request.ratios.lands,
@@ -153,10 +167,13 @@ def build_deck(request: BuildRequest) -> BuildResult:
"uncategorized": request.ratios.uncategorized, "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( adjusted = ollama.enforce_deck_ratios(
cards_by_category=cards_by_category, cards_by_category=cards_by_category,
target_ratios=target_ratios, target_ratios=target_ratios,
commander_name=commander.name, commander_name=commander.name,
sim_context=sim_context,
) )
# Verify total is 99 # Verify total is 99
@@ -165,7 +182,7 @@ def build_deck(request: BuildRequest) -> BuildResult:
warnings.append(f"Ollama returned {total} cards instead of 99 — adjusting") warnings.append(f"Ollama returned {total} cards instead of 99 — adjusting")
logger.warning(f"Ratio enforcement returned {total} cards") 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") logger.info("Step 7: Assembling final deck")
# Build a globally-deduplicated name list. enforce_deck_ratios can # Build a globally-deduplicated name list. enforce_deck_ratios can