Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c0aa77d2a | ||
|
|
21341ad387 | ||
|
|
0dbbd9786e |
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
|
||||||
|
|||||||
Reference in new issue
Block a user