Files
commander-ai-lab/src/commander_ai_lab/deck_builder/api/sim_insights.py
T

125 lines
4.4 KiB
Python

"""
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