Compare commits
63
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3e35c73cd | ||
|
|
8dd9623417 | ||
|
|
817b71f69d | ||
|
|
e71cac9dd1 | ||
|
|
cb38081a8b | ||
|
|
cd9d2eebd8 | ||
|
|
e6f18d4a61 | ||
|
|
4e90337f54 | ||
|
|
a01a70f725 | ||
|
|
4da58681a4 | ||
|
|
224b4b5d5b | ||
|
|
1a34f00574 | ||
|
|
8ec7db1612 | ||
|
|
e0a75e2d04 | ||
|
|
15bd6f42bb | ||
|
|
0aa57ab7d2 | ||
|
|
9f5eb4a99f | ||
|
|
a9f18f53da | ||
|
|
c9e932ef43 | ||
|
|
cb99388e3c | ||
|
|
2e316548e7 | ||
|
|
b46e1e0877 | ||
|
|
e17b877dcc | ||
|
|
6ce21908d3 | ||
|
|
15495483f3 | ||
|
|
540fbbf1b3 | ||
|
|
a5cc7736b4 | ||
|
|
8c6248cf84 | ||
|
|
22d63fd15e | ||
|
|
2dc0ba2d25 | ||
|
|
8f180820f9 | ||
|
|
b283418879 | ||
|
|
61be42ea6d | ||
|
|
3906b93c6a | ||
|
|
af2a71d1a5 | ||
|
|
cf7c872e12 | ||
|
|
c9227dac70 | ||
|
|
34b1ea2e30 | ||
|
|
1c6454dce5 | ||
|
|
464dd732d5 | ||
|
|
f481243c4e | ||
|
|
f49b6cbea5 | ||
|
|
71f316a220 | ||
|
|
9556f9d7d0 | ||
|
|
9de0965c78 | ||
|
|
38bfa51d85 | ||
|
|
c58f561df4 | ||
|
|
6cf1e58018 | ||
|
|
1382d25e46 | ||
|
|
d8f7105471 | ||
|
|
071088715f | ||
|
|
95429d9f35 | ||
|
|
55379a98ef | ||
|
|
173dee6ccc | ||
|
|
34dee24399 | ||
|
|
88f1022d0e | ||
|
|
737863c55d | ||
|
|
e710287c35 | ||
|
|
2e3c1ec0f1 | ||
|
|
21f032d32e | ||
|
|
2881309d41 | ||
|
|
e162d50608 | ||
|
|
49bab7fb0a |
No files matched your search
+7
-7
@@ -6,13 +6,13 @@
|
||||
# Get yours at: https://app.ximilar.com/
|
||||
XIMILAR_API_KEY=your-ximilar-api-key-here
|
||||
|
||||
# Perplexity API Key (AI deck research & generation)
|
||||
# Get yours at: https://www.perplexity.ai/settings/api
|
||||
PPLX_API_KEY=your-perplexity-api-key-here
|
||||
# Perplexity model for deck research & generation (sonar / sonar-pro / sonar-deep-research)
|
||||
PPLX_MODEL=sonar-pro
|
||||
# Coach chat provider: "local" (Ollama) or "perplexity" (Perplexity API)
|
||||
COACH_PROVIDER=local
|
||||
# Anthropic API Key (AI deck generation & coaching with Claude Opus)
|
||||
# Get yours at: https://console.anthropic.com/settings/keys
|
||||
ANTHROPIC_API_KEY=your-anthropic-api-key-here
|
||||
# Anthropic model for deck generation and coaching (Claude Opus recommended)
|
||||
ANTHROPIC_MODEL=claude-opus-4-20250514
|
||||
# Coach chat provider: "local" (Ollama) or "anthropic" (Claude API)
|
||||
COACH_PROVIDER=anthropic
|
||||
|
||||
# CORS allowed origins (comma-separated)
|
||||
# Default: http://localhost:5173,http://localhost:8080
|
||||
|
||||
@@ -1 +1 @@
|
||||
# Coach clients — Perplexity, LM Studio
|
||||
# Coach clients — Anthropic Claude, LM Studio
|
||||
@@ -0,0 +1,404 @@
|
||||
"""
|
||||
Commander AI Lab — Anthropic Claude Structured Output Client
|
||||
════════════════════════════════════════════════════════════
|
||||
Uses the anthropic SDK to call Claude's messages endpoint
|
||||
with structured JSON output enforced via system prompt.
|
||||
Supports both sync and native async operation.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
import anthropic
|
||||
|
||||
logger = logging.getLogger("coach.anthropic")
|
||||
|
||||
# ── Anthropic Models ───────────────────────────────────────────
|
||||
CLAUDE_OPUS = "claude-opus-4-0-20250514"
|
||||
CLAUDE_SONNET = "claude-sonnet-4-5"
|
||||
CLAUDE_HAIKU = "claude-haiku-4-5"
|
||||
|
||||
|
||||
class AnthropicResponse:
|
||||
"""Parsed response container from Anthropic API.
|
||||
|
||||
Drop-in replacement for PerplexityResponse — same attribute shape
|
||||
so all existing call-sites work without changes.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
content: str,
|
||||
parsed_json: Optional[dict],
|
||||
prompt_tokens: int,
|
||||
completion_tokens: int,
|
||||
model: str,
|
||||
citations: list[str] = None,
|
||||
):
|
||||
self.content = content
|
||||
self.parsed_json = parsed_json
|
||||
self.prompt_tokens = prompt_tokens
|
||||
self.completion_tokens = completion_tokens
|
||||
self.model = model
|
||||
self.citations = citations or [] # Anthropic has no citations; kept for compat
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
return self.parsed_json is not None
|
||||
|
||||
|
||||
class AnthropicClient:
|
||||
"""
|
||||
Anthropic Claude API client.
|
||||
|
||||
Drop-in replacement for PerplexityClient.
|
||||
|
||||
Usage:
|
||||
client = AnthropicClient(api_key="sk-ant-...")
|
||||
resp = client.chat_structured(
|
||||
system_prompt="You are a deck builder.",
|
||||
user_prompt="Build a deck for...",
|
||||
json_schema=deck_schema,
|
||||
schema_name="DeckList",
|
||||
)
|
||||
if resp.ok:
|
||||
deck = resp.parsed_json
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: str,
|
||||
model: str = CLAUDE_OPUS,
|
||||
timeout: int = 120,
|
||||
base_url: str = None,
|
||||
):
|
||||
if not api_key:
|
||||
raise ValueError("Anthropic API key is required")
|
||||
self.api_key = api_key
|
||||
self.model = model
|
||||
self.timeout = timeout
|
||||
self._client = anthropic.Anthropic(
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
timeout=float(timeout),
|
||||
)
|
||||
self._async_client = anthropic.AsyncAnthropic(
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
timeout=float(timeout),
|
||||
)
|
||||
|
||||
def chat_structured(
|
||||
self,
|
||||
system_prompt: str,
|
||||
user_prompt: str,
|
||||
json_schema: dict = None,
|
||||
schema_name: str = "response",
|
||||
model: str = None,
|
||||
temperature: float = 0.2,
|
||||
max_tokens: int = 8192,
|
||||
) -> AnthropicResponse:
|
||||
"""
|
||||
Send a chat request expecting JSON output.
|
||||
|
||||
The system_prompt should instruct the model to return JSON matching
|
||||
the expected schema. Claude Opus follows JSON instructions reliably.
|
||||
|
||||
Args:
|
||||
system_prompt: System message (should instruct JSON output)
|
||||
user_prompt: User message
|
||||
json_schema: (optional) Kept for call-site compatibility; appended
|
||||
as a schema hint in the system prompt when provided
|
||||
schema_name: (optional) Label used in the schema hint
|
||||
model: Override model
|
||||
temperature: Sampling temperature (0.0-1.0)
|
||||
max_tokens: Max output tokens
|
||||
|
||||
Returns:
|
||||
AnthropicResponse with parsed_json extracted from model output
|
||||
"""
|
||||
use_model = model or self.model
|
||||
effective_system = self._build_system(system_prompt, json_schema, schema_name)
|
||||
|
||||
try:
|
||||
response = self._client.messages.create(
|
||||
model=use_model,
|
||||
system=effective_system,
|
||||
messages=[{"role": "user", "content": user_prompt}],
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
)
|
||||
return self._parse_response(response, use_model)
|
||||
except Exception as e:
|
||||
logger.error("[anthropic] API call failed: %s", e)
|
||||
raise
|
||||
|
||||
def chat_plain(
|
||||
self,
|
||||
system_prompt: str,
|
||||
user_prompt: str,
|
||||
model: str = None,
|
||||
temperature: float = 0.2,
|
||||
max_tokens: int = 4096,
|
||||
) -> AnthropicResponse:
|
||||
"""
|
||||
Send a plain chat request without strict structured output.
|
||||
Used for substitution fallback queries.
|
||||
"""
|
||||
use_model = model or self.model
|
||||
|
||||
try:
|
||||
response = self._client.messages.create(
|
||||
model=use_model,
|
||||
system=system_prompt,
|
||||
messages=[{"role": "user", "content": user_prompt}],
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
)
|
||||
return self._parse_response(response, use_model)
|
||||
except Exception as e:
|
||||
logger.error("[anthropic] Plain chat failed: %s", e)
|
||||
raise
|
||||
|
||||
async def achat_structured(
|
||||
self,
|
||||
system_prompt: str,
|
||||
user_prompt: str,
|
||||
json_schema: dict = None,
|
||||
schema_name: str = "response",
|
||||
model: str = None,
|
||||
temperature: float = 0.2,
|
||||
max_tokens: int = 8192,
|
||||
) -> AnthropicResponse:
|
||||
"""Native async structured chat — no run_in_executor needed."""
|
||||
use_model = model or self.model
|
||||
effective_system = self._build_system(system_prompt, json_schema, schema_name)
|
||||
|
||||
try:
|
||||
response = await self._async_client.messages.create(
|
||||
model=use_model,
|
||||
system=effective_system,
|
||||
messages=[{"role": "user", "content": user_prompt}],
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
)
|
||||
return self._parse_response(response, use_model)
|
||||
except Exception as e:
|
||||
logger.error("[anthropic] Async structured chat failed: %s", e)
|
||||
raise
|
||||
|
||||
async def achat_plain(
|
||||
self,
|
||||
system_prompt: str,
|
||||
user_prompt: str,
|
||||
model: str = None,
|
||||
temperature: float = 0.2,
|
||||
max_tokens: int = 4096,
|
||||
) -> AnthropicResponse:
|
||||
"""Native async plain chat."""
|
||||
use_model = model or self.model
|
||||
|
||||
try:
|
||||
response = await self._async_client.messages.create(
|
||||
model=use_model,
|
||||
system=system_prompt,
|
||||
messages=[{"role": "user", "content": user_prompt}],
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
)
|
||||
return self._parse_response(response, use_model)
|
||||
except Exception as e:
|
||||
logger.error("[anthropic] Async plain chat failed: %s", e)
|
||||
raise
|
||||
|
||||
def check_status(self) -> dict:
|
||||
"""Quick health check — try a minimal API call."""
|
||||
try:
|
||||
response = self._client.messages.create(
|
||||
model=self.model,
|
||||
system="Reply with the word OK.",
|
||||
messages=[{"role": "user", "content": "Status check."}],
|
||||
max_tokens=10,
|
||||
)
|
||||
content = response.content[0].text if response.content else ""
|
||||
return {
|
||||
"connected": True,
|
||||
"model": self.model,
|
||||
"response": content,
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"connected": False,
|
||||
"model": self.model,
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
# ── Internal helpers ──────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _build_system(system_prompt: str, json_schema: dict, schema_name: str) -> str:
|
||||
"""Append JSON schema hint to system prompt when a schema is provided."""
|
||||
if not json_schema:
|
||||
return system_prompt
|
||||
schema_str = json.dumps(json_schema, indent=2)
|
||||
return (
|
||||
f"{system_prompt}\n\n"
|
||||
f"You MUST respond with valid JSON only — no markdown fences, no prose.\n"
|
||||
f"The response must conform to the following JSON schema ({schema_name}):\n"
|
||||
f"{schema_str}"
|
||||
)
|
||||
|
||||
def _parse_response(self, response, use_model: str) -> AnthropicResponse:
|
||||
"""Extract content and attempt JSON parsing from an Anthropic response."""
|
||||
content = ""
|
||||
if response.content:
|
||||
content = response.content[0].text or ""
|
||||
|
||||
usage = response.usage
|
||||
prompt_tokens = usage.input_tokens if usage else 0
|
||||
completion_tokens = usage.output_tokens if usage else 0
|
||||
|
||||
parsed = None
|
||||
try:
|
||||
parsed = json.loads(content)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
parsed = self._extract_json(content)
|
||||
|
||||
logger.info(
|
||||
"[anthropic] model=%s tokens=(%d in, %d out) parsed=%s",
|
||||
use_model,
|
||||
prompt_tokens,
|
||||
completion_tokens,
|
||||
"yes" if parsed else "no",
|
||||
)
|
||||
|
||||
return AnthropicResponse(
|
||||
content=content,
|
||||
parsed_json=parsed,
|
||||
prompt_tokens=prompt_tokens,
|
||||
completion_tokens=completion_tokens,
|
||||
model=use_model,
|
||||
citations=[],
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _extract_json(text: str) -> Optional[dict]:
|
||||
"""Best-effort JSON extraction from possibly noisy text."""
|
||||
try:
|
||||
return json.loads(text.strip())
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
# Markdown fences
|
||||
fence_match = re.search(r'```(?:json)?\s*([\s\S]*?)```', text)
|
||||
if fence_match:
|
||||
try:
|
||||
return json.loads(fence_match.group(1).strip())
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
# Brace counting
|
||||
start = text.find('{')
|
||||
if start == -1:
|
||||
return None
|
||||
depth = 0
|
||||
in_string = False
|
||||
escape_next = False
|
||||
for i in range(start, len(text)):
|
||||
ch = text[i]
|
||||
if escape_next:
|
||||
escape_next = False
|
||||
continue
|
||||
if ch == '\\' and in_string:
|
||||
escape_next = True
|
||||
continue
|
||||
if ch == '"':
|
||||
in_string = not in_string
|
||||
continue
|
||||
if in_string:
|
||||
continue
|
||||
if ch == '{':
|
||||
depth += 1
|
||||
elif ch == '}':
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
try:
|
||||
return json.loads(text[start:i + 1])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return None
|
||||
|
||||
# Truncated JSON — attempt repair
|
||||
return AnthropicClient._repair_truncated_json(text[start:])
|
||||
|
||||
@staticmethod
|
||||
def _repair_truncated_json(text: str) -> Optional[dict]:
|
||||
"""
|
||||
Attempt to repair truncated JSON from max_tokens cutoff.
|
||||
Strategy: find the last complete nested object, then close
|
||||
all open brackets/braces.
|
||||
"""
|
||||
logger.info("Attempting truncated JSON repair (%d chars)...", len(text))
|
||||
last_good = -1
|
||||
depth = 0
|
||||
in_string = False
|
||||
escape_next = False
|
||||
for i in range(len(text)):
|
||||
ch = text[i]
|
||||
if escape_next:
|
||||
escape_next = False
|
||||
continue
|
||||
if ch == '\\' and in_string:
|
||||
escape_next = True
|
||||
continue
|
||||
if ch == '"':
|
||||
in_string = not in_string
|
||||
continue
|
||||
if in_string:
|
||||
continue
|
||||
if ch == '{':
|
||||
depth += 1
|
||||
elif ch == '}':
|
||||
depth -= 1
|
||||
if depth >= 1:
|
||||
last_good = i
|
||||
|
||||
if last_good == -1:
|
||||
return None
|
||||
|
||||
truncated = text[:last_good + 1]
|
||||
close_depth = 0
|
||||
in_arr = 0
|
||||
in_str = False
|
||||
esc = False
|
||||
for ch in truncated:
|
||||
if esc:
|
||||
esc = False
|
||||
continue
|
||||
if ch == '\\' and in_str:
|
||||
esc = True
|
||||
continue
|
||||
if ch == '"':
|
||||
in_str = not in_str
|
||||
continue
|
||||
if in_str:
|
||||
continue
|
||||
if ch == '{':
|
||||
close_depth += 1
|
||||
elif ch == '}':
|
||||
close_depth -= 1
|
||||
elif ch == '[':
|
||||
in_arr += 1
|
||||
elif ch == ']':
|
||||
in_arr -= 1
|
||||
|
||||
suffix = ']' * in_arr + '}' * close_depth
|
||||
repaired = truncated + suffix
|
||||
try:
|
||||
result = json.loads(repaired)
|
||||
card_count = len(result.get('cards', []))
|
||||
logger.info("JSON repair succeeded: recovered %d cards", card_count)
|
||||
return result
|
||||
except (json.JSONDecodeError, TypeError) as e:
|
||||
logger.warning("JSON repair failed: %s", e)
|
||||
return None
|
||||
+50
-12
@@ -18,8 +18,8 @@ from typing import Optional, List, Dict
|
||||
from .config import (
|
||||
DECK_REPORTS_DIR, COACH_SESSIONS_DIR,
|
||||
MAX_UNDERPERFORMERS, MAX_CANDIDATES_PER_UNDERPERFORMER,
|
||||
COACH_PROVIDER, PPLX_MODEL,
|
||||
UNDERPERFORMER_IMPACT_THRESHOLD, ensure_dirs,
|
||||
COACH_PROVIDER, ANTHROPIC_API_KEY, ANTHROPIC_MODEL,
|
||||
UNDERPERFORMER_IMPACT_THRESHOLD, DEFAULT_MAX_TOKENS, ensure_dirs,
|
||||
)
|
||||
from .models import (
|
||||
DeckReport, CoachGoals, CoachSession,
|
||||
@@ -273,9 +273,47 @@ class CoachService:
|
||||
system_prompt = build_system_prompt(report, goals)
|
||||
user_prompt = build_user_prompt(report, candidates)
|
||||
|
||||
# 5. Call LLM
|
||||
logger.info("Calling LLM for deck: %s", deck_id)
|
||||
llm_response = await self.llm.chat(system_prompt, user_prompt)
|
||||
# 5. Call LLM (Anthropic or local Ollama)
|
||||
logger.info("Calling LLM for deck: %s (provider=%s)", deck_id, COACH_PROVIDER)
|
||||
if COACH_PROVIDER == "anthropic":
|
||||
import anthropic
|
||||
anthropic_key = ANTHROPIC_API_KEY
|
||||
if not anthropic_key:
|
||||
raise ConnectionError("Anthropic API key not configured. Set ANTHROPIC_API_KEY env var.")
|
||||
aclient = anthropic.AsyncAnthropic(api_key=anthropic_key)
|
||||
resp = await aclient.messages.create(
|
||||
model=ANTHROPIC_MODEL,
|
||||
system=system_prompt,
|
||||
messages=[{"role": "user", "content": user_prompt}],
|
||||
max_tokens=DEFAULT_MAX_TOKENS,
|
||||
temperature=0.7,
|
||||
)
|
||||
content = resp.content[0].text if resp.content else ""
|
||||
usage = resp.usage
|
||||
|
||||
# Build an LLMResponse-compatible object
|
||||
from types import SimpleNamespace
|
||||
llm_response = SimpleNamespace(
|
||||
content=content,
|
||||
model=resp.model,
|
||||
prompt_tokens=usage.input_tokens if usage else 0,
|
||||
completion_tokens=usage.output_tokens if usage else 0,
|
||||
parsed_json=None,
|
||||
)
|
||||
try:
|
||||
cleaned = re.sub(r'^```(?:json)?\s*', '', content.strip())
|
||||
cleaned = re.sub(r'\s*```$', '', cleaned.strip())
|
||||
llm_response.parsed_json = json.loads(cleaned)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
# Try to find JSON object in the text
|
||||
match = re.search(r'\{[\s\S]*\}', content)
|
||||
if match:
|
||||
try:
|
||||
llm_response.parsed_json = json.loads(match.group(0))
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
else:
|
||||
llm_response = await self.llm.chat(system_prompt, user_prompt)
|
||||
|
||||
# 6. Parse response into CoachSession
|
||||
session_id = f"sess-{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}-{uuid.uuid4().hex[:6]}"
|
||||
@@ -338,18 +376,18 @@ class CoachService:
|
||||
"""Check the health of all coach subsystems."""
|
||||
status = CoachStatus()
|
||||
|
||||
# Check LLM / Perplexity provider
|
||||
if COACH_PROVIDER == "perplexity":
|
||||
pplx_key = os.environ.get("PPLX_API_KEY", "")
|
||||
if pplx_key:
|
||||
# Check LLM / Anthropic provider
|
||||
if COACH_PROVIDER == "anthropic":
|
||||
anthropic_key = ANTHROPIC_API_KEY
|
||||
if anthropic_key:
|
||||
status.llmConnected = True
|
||||
status.llmModel = f"Perplexity ({PPLX_MODEL})"
|
||||
status.llmModels = [PPLX_MODEL]
|
||||
status.llmModel = f"Claude Opus ({ANTHROPIC_MODEL})"
|
||||
status.llmModels = [ANTHROPIC_MODEL]
|
||||
else:
|
||||
status.llmConnected = False
|
||||
status.llmModel = None
|
||||
status.llmModels = []
|
||||
status.error = "Perplexity API key not configured. Set PPLX_API_KEY env var."
|
||||
status.error = "Anthropic API key not configured. Set ANTHROPIC_API_KEY env var."
|
||||
else:
|
||||
llm_status = self.llm.check_connection()
|
||||
status.llmConnected = llm_status.get("connected", False)
|
||||
|
||||
+28
-29
@@ -1,6 +1,6 @@
|
||||
"""
|
||||
Commander AI Lab — Coach Configuration
|
||||
═══════════════════════════════════════
|
||||
────────────────────────────────────────
|
||||
Central config for Ollama, embeddings, and coach service paths.
|
||||
All paths are relative to the commander-ai-lab root directory.
|
||||
"""
|
||||
@@ -8,61 +8,60 @@ All paths are relative to the commander-ai-lab root directory.
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# ── Base Paths ──────────────────────────────────────────────
|
||||
# Resolve relative to wherever the lab is installed
|
||||
# ── Base Paths ───────────────────────────────────────────────────────
|
||||
LAB_ROOT = Path(os.environ.get("COMMANDER_LAB_ROOT", Path(__file__).parent.parent))
|
||||
|
||||
# ── Ollama (OpenAI-compatible API) ──────────────────────
|
||||
# ── Ollama (OpenAI-compatible API) ────────────────────────────
|
||||
LLM_URL = os.environ.get("LLM_URL", "http://localhost:11434/v1")
|
||||
LLM_MODEL = os.environ.get("LLM_MODEL", "gpt-oss:20b")
|
||||
LLM_TIMEOUT = int(os.environ.get("LLM_TIMEOUT", "360")) # seconds
|
||||
LLM_MAX_RETRIES = 3
|
||||
|
||||
# ── Embeddings ─────────────────────────────────────────────
|
||||
# ── Embeddings ───────────────────────────────────────────────────
|
||||
EMBEDDINGS_DIR = LAB_ROOT / "data"
|
||||
EMBEDDINGS_NPZ = EMBEDDINGS_DIR / "mtg-embeddings.npz"
|
||||
EMBEDDINGS_PARQUET = EMBEDDINGS_DIR / "mtg-embeddings.parquet"
|
||||
|
||||
# ── Deck Reports ───────────────────────────────────────────
|
||||
# ── Deck Reports ────────────────────────────────────────────────
|
||||
DECK_REPORTS_DIR = LAB_ROOT / "deck-reports"
|
||||
COACH_SESSIONS_DIR = LAB_ROOT / "coach-sessions"
|
||||
|
||||
# ── Prompt Limits ──────────────────────────────────────────
|
||||
# ── Prompt Limits ───────────────────────────────────────────────
|
||||
MAX_PROMPT_TOKENS = 2048
|
||||
MAX_CANDIDATES_PER_UNDERPERFORMER = 5
|
||||
MAX_UNDERPERFORMERS = 4
|
||||
MAX_CANDIDATES_PER_UNDERPERFORMER = 8
|
||||
MAX_UNDERPERFORMERS = 8
|
||||
UNDERPERFORMER_IMPACT_THRESHOLD = -0.05 # impactScore below this = underperformer
|
||||
|
||||
# ── LLM Generation Settings (Coach — Ollama) ──────────────
|
||||
# ── LLM Generation Settings (Coach — Ollama fallback) ─────────────
|
||||
DEFAULT_TEMPERATURE = 0.7
|
||||
# DeepSeek-R1 uses <think> reasoning tokens before the JSON output,
|
||||
# so we need a generous limit to avoid truncation
|
||||
DEFAULT_MAX_TOKENS = 8192
|
||||
DEFAULT_MAX_TOKENS = 16384
|
||||
|
||||
# ── Perplexity Deck Generation Settings ───────────────────────
|
||||
# Provider for deck generation: "perplexity" (V3) or "local" (V2 collection-based)
|
||||
DECK_GEN_PROVIDER = os.environ.get("DECK_GEN_PROVIDER", "local")
|
||||
# Model for deck generation (sonar = fast/$0.004, sonar-pro = deep/$0.04)
|
||||
DECK_GEN_MODEL = os.environ.get("DECK_GEN_MODEL", "gpt-oss:20b")
|
||||
# Perplexity cloud model (sonar / sonar-pro / sonar-deep-research)
|
||||
PPLX_MODEL = os.environ.get("PPLX_MODEL", "sonar-pro")
|
||||
# Coach chat provider: "local" (Ollama) or "perplexity" (Perplexity API)
|
||||
COACH_PROVIDER = os.environ.get("COACH_PROVIDER", "local")
|
||||
# ── Anthropic Claude Deck Generation Settings ──────────────────────
|
||||
# Provider for deck generation: "anthropic" (Claude) or "local" (Ollama)
|
||||
DECK_GEN_PROVIDER = os.environ.get("DECK_GEN_PROVIDER", "anthropic")
|
||||
# Model used when DECK_GEN_PROVIDER="anthropic"
|
||||
DECK_GEN_MODEL = os.environ.get("DECK_GEN_MODEL", "claude-opus-4-20250514")
|
||||
# Anthropic API key
|
||||
ANTHROPIC_API_KEY = os.environ.get("ANTHROPIC_API_KEY", "")
|
||||
# Anthropic model for deck generation and coaching
|
||||
ANTHROPIC_MODEL = os.environ.get("ANTHROPIC_MODEL", "claude-opus-4-20250514")
|
||||
# Coach chat provider: "anthropic" (Claude API) or "local" (Ollama)
|
||||
COACH_PROVIDER = os.environ.get("COACH_PROVIDER", "anthropic")
|
||||
DECK_GEN_BASE_URL = os.environ.get("DECK_GEN_BASE_URL", "http://localhost:11434/v1")
|
||||
DECK_GEN_TEMPERATURE = float(os.environ.get("DECK_GEN_TEMPERATURE", "0.2"))
|
||||
DECK_GEN_MAX_TOKENS = int(os.environ.get("DECK_GEN_MAX_TOKENS", "16384"))
|
||||
|
||||
# ── Smart Substitution Settings ───────────────────────────────
|
||||
# Minimum embedding similarity to accept a substitute without Perplexity fallback
|
||||
# ── Smart Substitution Settings ───────────────────────────────────
|
||||
# Minimum embedding similarity to accept a substitute without Anthropic fallback
|
||||
SUBSTITUTION_MIN_SIMILARITY = float(os.environ.get("SUBSTITUTION_MIN_SIMILARITY", "0.75"))
|
||||
# Maximum number of alternatives to suggest per missing card
|
||||
SUBSTITUTION_MAX_ALTERNATIVES = int(os.environ.get("SUBSTITUTION_MAX_ALTERNATIVES", "5"))
|
||||
# Model for substitution fallback (sonar is fine — small focused queries)
|
||||
SUBSTITUTION_MODEL = os.environ.get("SUBSTITUTION_MODEL", "gpt-oss:20b")
|
||||
# Enable Perplexity fallback for low-confidence embedding matches
|
||||
SUBSTITUTION_USE_PPLX_FALLBACK = os.environ.get("SUBSTITUTION_USE_PPLX_FALLBACK", "true").lower() == "true"
|
||||
# Model for substitution fallback (claude-haiku-4-5 is fast + cheap for focused queries)
|
||||
SUBSTITUTION_MODEL = os.environ.get("SUBSTITUTION_MODEL", "claude-haiku-4-5")
|
||||
# Enable Anthropic fallback for low-confidence embedding matches
|
||||
SUBSTITUTION_USE_ANTHROPIC_FALLBACK = os.environ.get("SUBSTITUTION_USE_ANTHROPIC_FALLBACK", "true").lower() == "true"
|
||||
|
||||
# ── Ensure directories exist ───────────────────────────────
|
||||
# ── Ensure directories exist ─────────────────────────────────────
|
||||
def ensure_dirs():
|
||||
"""Create required directories if they don't exist."""
|
||||
EMBEDDINGS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -21,7 +21,6 @@ import json
|
||||
import logging
|
||||
import re
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Tuple
|
||||
from urllib.request import urlopen, Request
|
||||
@@ -331,7 +330,6 @@ class MTGEmbeddingIndex:
|
||||
d = np.load(str(CUSTOM_CACHE_PATH), allow_pickle=True)
|
||||
for n in d["names"]:
|
||||
existing_names.add(n.lower())
|
||||
existing = dict(d)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
+127
-130
@@ -1,22 +1,25 @@
|
||||
"""
|
||||
Commander AI Lab — Ollama Client
|
||||
Commander AI Lab — Ollama LLM Client
|
||||
════════════════════════════════════
|
||||
Async HTTP client targeting Ollama's OpenAI-compatible
|
||||
/v1/chat/completions endpoint. Includes retry logic for
|
||||
non-JSON responses and configurable timeout.
|
||||
Fully async HTTP client targeting Ollama's OpenAI-compatible
|
||||
/v1/chat/completions endpoint. Every network call uses
|
||||
httpx.AsyncClient so it NEVER blocks the FastAPI event loop.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import asyncio
|
||||
from typing import Optional
|
||||
from urllib.request import urlopen, Request
|
||||
from urllib.error import URLError
|
||||
|
||||
import httpx
|
||||
|
||||
from .config import (
|
||||
LLM_URL, LLM_MODEL,
|
||||
LLM_TIMEOUT, LLM_MAX_RETRIES,
|
||||
DEFAULT_TEMPERATURE, DEFAULT_MAX_TOKENS,
|
||||
LLM_URL,
|
||||
LLM_MODEL,
|
||||
LLM_TIMEOUT,
|
||||
LLM_MAX_RETRIES,
|
||||
DEFAULT_TEMPERATURE,
|
||||
DEFAULT_MAX_TOKENS,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("coach.llm")
|
||||
@@ -24,9 +27,15 @@ logger = logging.getLogger("coach.llm")
|
||||
|
||||
class LLMResponse:
|
||||
"""Parsed LLM response container."""
|
||||
def __init__(self, content: str, parsed_json: Optional[dict],
|
||||
prompt_tokens: int, completion_tokens: int,
|
||||
model: str):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
content: str,
|
||||
parsed_json: Optional[dict],
|
||||
prompt_tokens: int,
|
||||
completion_tokens: int,
|
||||
model: str,
|
||||
):
|
||||
self.content = content
|
||||
self.parsed_json = parsed_json
|
||||
self.prompt_tokens = prompt_tokens
|
||||
@@ -36,7 +45,7 @@ class LLMResponse:
|
||||
|
||||
class LLMClient:
|
||||
"""
|
||||
Client for Ollama's OpenAI-compatible API.
|
||||
Fully async client for Ollama's OpenAI-compatible API.
|
||||
|
||||
Usage:
|
||||
client = LLMClient()
|
||||
@@ -45,19 +54,25 @@ class LLMClient:
|
||||
suggestions = response.parsed_json
|
||||
"""
|
||||
|
||||
def __init__(self, base_url: str = None, model: str = None,
|
||||
timeout: int = None):
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str = None,
|
||||
model: str = None,
|
||||
timeout: int = None,
|
||||
):
|
||||
self.base_url = (base_url or LLM_URL).rstrip("/")
|
||||
self.model = model or LLM_MODEL
|
||||
self.timeout = timeout or LLM_TIMEOUT
|
||||
self._resolved_model = None # actual model name from /models endpoint
|
||||
self._resolved_model: Optional[str] = None
|
||||
|
||||
def _resolve_model(self) -> str:
|
||||
# ── Model resolution ───────────────────────────────────────
|
||||
|
||||
async def _resolve_model(self) -> str:
|
||||
"""Get the actual loaded model name from Ollama /models endpoint."""
|
||||
if self._resolved_model:
|
||||
return self._resolved_model
|
||||
try:
|
||||
status = self.check_connection()
|
||||
status = await self.check_connection()
|
||||
if status.get("connected") and status.get("active_model"):
|
||||
self._resolved_model = status["active_model"]
|
||||
logger.info("Resolved Ollama model: %s", self._resolved_model)
|
||||
@@ -66,11 +81,18 @@ class LLMClient:
|
||||
pass
|
||||
return self.model
|
||||
|
||||
def _build_request_body(self, system_prompt: str, user_prompt: str,
|
||||
temperature: float, max_tokens: int,
|
||||
force_json: bool) -> dict:
|
||||
model_name = self._resolve_model()
|
||||
body = {
|
||||
# ── Request building ──────────────────────────────────────
|
||||
|
||||
async def _build_request_body(
|
||||
self,
|
||||
system_prompt: str,
|
||||
user_prompt: str,
|
||||
temperature: float,
|
||||
max_tokens: int,
|
||||
force_json: bool,
|
||||
) -> dict:
|
||||
model_name = await self._resolve_model()
|
||||
body: dict = {
|
||||
"model": model_name,
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
@@ -79,32 +101,27 @@ class LLMClient:
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_tokens,
|
||||
}
|
||||
# Only request JSON format if using a model that supports it
|
||||
# Some models (esp. smaller ones) don't support response_format
|
||||
if force_json:
|
||||
body["response_format"] = {"type": "json_object"}
|
||||
return body
|
||||
|
||||
# ── Response parsing ──────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _strip_think_tags(text: str) -> str:
|
||||
"""Remove DeepSeek-R1 <think>...</think> reasoning blocks from LLM output."""
|
||||
"""Remove DeepSeek-R1 <think>...</think> reasoning blocks."""
|
||||
import re
|
||||
# Remove all <think>...</think> blocks (greedy, handles multiline)
|
||||
cleaned = re.sub(r'<think>.*?</think>', '', text, flags=re.DOTALL)
|
||||
return cleaned.strip()
|
||||
return re.sub(r'<think>.*?</think>', '', text, flags=re.DOTALL).strip()
|
||||
|
||||
@staticmethod
|
||||
def _extract_json_object(text: str) -> Optional[dict]:
|
||||
"""Find and parse the first complete JSON object in text.
|
||||
Uses brace-counting to handle nested objects reliably."""
|
||||
"""Find and parse the first complete JSON object via brace-counting."""
|
||||
start = text.find('{')
|
||||
if start == -1:
|
||||
return None
|
||||
|
||||
depth = 0
|
||||
in_string = False
|
||||
escape_next = False
|
||||
|
||||
for i in range(start, len(text)):
|
||||
ch = text[i]
|
||||
if escape_next:
|
||||
@@ -124,66 +141,56 @@ class LLMClient:
|
||||
elif ch == '}':
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
candidate = text[start:i+1]
|
||||
candidate = text[start : i + 1]
|
||||
try:
|
||||
return json.loads(candidate)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
# Malformed — try next opening brace
|
||||
next_start = text.find('{', start + 1)
|
||||
if next_start != -1 and next_start < i:
|
||||
start = next_start
|
||||
depth = 1 # we're already past this brace
|
||||
return None
|
||||
depth = 1
|
||||
return None
|
||||
return None
|
||||
|
||||
def _parse_response(self, raw: dict) -> LLMResponse:
|
||||
"""Extract content and usage from OpenAI-format response.
|
||||
Handles DeepSeek-R1 <think> tags, markdown fences, and raw JSON."""
|
||||
"""Extract content and usage from an OpenAI-format response."""
|
||||
choice = raw.get("choices", [{}])[0]
|
||||
content = choice.get("message", {}).get("content", "")
|
||||
usage = raw.get("usage", {})
|
||||
|
||||
# Step 1: Strip <think>...</think> blocks (DeepSeek-R1)
|
||||
cleaned = self._strip_think_tags(content)
|
||||
|
||||
# Step 2: Try direct JSON parse on cleaned content
|
||||
parsed = None
|
||||
parsed: Optional[dict] = None
|
||||
# 1. Direct JSON parse
|
||||
try:
|
||||
parsed = json.loads(cleaned)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
# Step 3: Try extracting from markdown fences
|
||||
# 2. Markdown fences
|
||||
if parsed is None and "```json" in cleaned:
|
||||
try:
|
||||
start = cleaned.index("```json") + 7
|
||||
end = cleaned.index("```", start)
|
||||
parsed = json.loads(cleaned[start:end].strip())
|
||||
s = cleaned.index("```json") + 7
|
||||
e = cleaned.index("```", s)
|
||||
parsed = json.loads(cleaned[s:e].strip())
|
||||
except (json.JSONDecodeError, ValueError, IndexError):
|
||||
pass
|
||||
if parsed is None and "```" in cleaned:
|
||||
try:
|
||||
start = cleaned.index("```") + 3
|
||||
end = cleaned.index("```", start)
|
||||
parsed = json.loads(cleaned[start:end].strip())
|
||||
s = cleaned.index("```") + 3
|
||||
e = cleaned.index("```", s)
|
||||
parsed = json.loads(cleaned[s:e].strip())
|
||||
except (json.JSONDecodeError, ValueError, IndexError):
|
||||
pass
|
||||
|
||||
# Step 4: Brute-force — find the first valid JSON object in the text
|
||||
# 3. Brace-counting extraction
|
||||
if parsed is None:
|
||||
parsed = self._extract_json_object(cleaned)
|
||||
|
||||
# Step 5: If still nothing, try on the original content (in case
|
||||
# think-tag stripping was too aggressive)
|
||||
# 4. Fallback on original content
|
||||
if parsed is None and content != cleaned:
|
||||
parsed = self._extract_json_object(content)
|
||||
|
||||
if parsed is not None:
|
||||
logger.info("Successfully parsed JSON from LLM response")
|
||||
else:
|
||||
logger.warning("Could not extract JSON from LLM response (%d chars)",
|
||||
len(content))
|
||||
|
||||
logger.warning(
|
||||
"Could not extract JSON from LLM response (%d chars)", len(content)
|
||||
)
|
||||
return LLMResponse(
|
||||
content=content,
|
||||
parsed_json=parsed,
|
||||
@@ -192,94 +199,84 @@ class LLMClient:
|
||||
model=raw.get("model", self.model),
|
||||
)
|
||||
|
||||
def chat_sync(self, system_prompt: str, user_prompt: str,
|
||||
temperature: float = None, max_tokens: int = None,
|
||||
force_json: bool = True) -> LLMResponse:
|
||||
# ── Public API ─────────────────────────────────────────────
|
||||
|
||||
async def chat(
|
||||
self,
|
||||
system_prompt: str,
|
||||
user_prompt: str,
|
||||
temperature: float = None,
|
||||
max_tokens: int = None,
|
||||
force_json: bool = True,
|
||||
) -> LLMResponse:
|
||||
"""
|
||||
Synchronous chat call to Ollama.
|
||||
Retries up to LLM_MAX_RETRIES on failure or non-JSON response.
|
||||
Async chat call to Ollama. Retries up to LLM_MAX_RETRIES.
|
||||
Never blocks the event loop — uses httpx.AsyncClient throughout.
|
||||
"""
|
||||
temp = temperature if temperature is not None else DEFAULT_TEMPERATURE
|
||||
tokens = max_tokens if max_tokens is not None else DEFAULT_MAX_TOKENS
|
||||
body = self._build_request_body(system_prompt, user_prompt,
|
||||
temp, tokens, force_json)
|
||||
|
||||
last_error = None
|
||||
body = await self._build_request_body(
|
||||
system_prompt, user_prompt, temp, tokens, force_json
|
||||
)
|
||||
last_error: Optional[Exception] = None
|
||||
for attempt in range(1, LLM_MAX_RETRIES + 1):
|
||||
try:
|
||||
logger.info("LLM call attempt %d/%d", attempt, LLM_MAX_RETRIES)
|
||||
req = Request(
|
||||
f"{self.base_url}/chat/completions",
|
||||
data=json.dumps(body).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
with urlopen(req, timeout=self.timeout) as resp:
|
||||
raw = json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(float(self.timeout))
|
||||
) as client:
|
||||
resp = await client.post(
|
||||
f"{self.base_url}/chat/completions",
|
||||
json=body,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
raw = resp.json()
|
||||
result = self._parse_response(raw)
|
||||
|
||||
# If we need JSON and didn't get it, retry
|
||||
if force_json and result.parsed_json is None:
|
||||
logger.warning("Attempt %d: LLM returned non-JSON, retrying", attempt)
|
||||
logger.warning(
|
||||
"Attempt %d: LLM returned non-JSON, retrying", attempt
|
||||
)
|
||||
last_error = ValueError("Non-JSON response from LLM")
|
||||
continue
|
||||
|
||||
return result
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.warning("Attempt %d HTTP error %s", attempt, e.response.status_code)
|
||||
last_error = e
|
||||
# 400 with json format → retry without response_format constraint
|
||||
if e.response.status_code == 400 and force_json:
|
||||
logger.warning("Got 400 — retrying without response_format")
|
||||
force_json = False
|
||||
body = await self._build_request_body(
|
||||
system_prompt, user_prompt, temp, tokens, False
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Attempt %d failed: %s", attempt, e)
|
||||
last_error = e
|
||||
|
||||
# If 400 and we used json response_format, retry without it
|
||||
error_str = str(e)
|
||||
if "400" in error_str and force_json:
|
||||
logger.warning("Got 400 — retrying without response_format constraint")
|
||||
force_json = False
|
||||
body = self._build_request_body(
|
||||
system_prompt, user_prompt, temp, tokens, False)
|
||||
|
||||
if attempt < LLM_MAX_RETRIES:
|
||||
import time
|
||||
time.sleep(2 * attempt) # Simple backoff
|
||||
|
||||
if attempt < LLM_MAX_RETRIES:
|
||||
import asyncio
|
||||
await asyncio.sleep(2 * attempt)
|
||||
raise ConnectionError(
|
||||
f"LLM call failed after {LLM_MAX_RETRIES} attempts: {last_error}"
|
||||
)
|
||||
|
||||
async def chat(self, system_prompt: str, user_prompt: str,
|
||||
temperature: float = None, max_tokens: int = None,
|
||||
force_json: bool = True) -> LLMResponse:
|
||||
"""
|
||||
Async chat call — runs the sync call in a thread executor
|
||||
so it doesn't block the FastAPI event loop.
|
||||
"""
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.chat_sync(system_prompt, user_prompt,
|
||||
temperature, max_tokens, force_json)
|
||||
)
|
||||
|
||||
def check_connection(self) -> dict:
|
||||
"""
|
||||
Check if Ollama is reachable. Returns status dict.
|
||||
"""
|
||||
async def check_connection(self) -> dict:
|
||||
"""Check if Ollama is reachable. Returns status dict."""
|
||||
try:
|
||||
req = Request(
|
||||
f"{self.base_url}/models",
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="GET",
|
||||
)
|
||||
with urlopen(req, timeout=5) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
models = data.get("data", [])
|
||||
model_ids = [m.get("id", "unknown") for m in models]
|
||||
return {
|
||||
"connected": True,
|
||||
"models": model_ids,
|
||||
"active_model": model_ids[0] if model_ids else self.model,
|
||||
}
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(5.0)) as client:
|
||||
resp = await client.get(
|
||||
f"{self.base_url}/models",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
models = data.get("data", [])
|
||||
model_ids = [m.get("id", "unknown") for m in models]
|
||||
return {
|
||||
"connected": True,
|
||||
"models": model_ids,
|
||||
"active_model": model_ids[0] if model_ids else self.model,
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"connected": False,
|
||||
|
||||
@@ -5,7 +5,6 @@ Pydantic v2 models for deck reports, coach requests/responses,
|
||||
and coaching session persistence.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional, Dict
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
+91
-30
@@ -6,7 +6,6 @@ Injects deck report data, underperformer analysis, and
|
||||
candidate replacement cards into a structured template.
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import List, Dict, Optional
|
||||
|
||||
from .models import DeckReport, CoachGoals, CardPerformance
|
||||
@@ -27,36 +26,61 @@ RULES:
|
||||
- Base suggestions on the simulation data provided, not general "goodstuff" advice.
|
||||
- Consider mana curve, role balance (ramp, draw, removal, threats), and synergy.
|
||||
- When suggesting replacements, prefer cards that fill the same functional role.
|
||||
- IMPORTANT: Suggest 3-5 DIFFERENT cards to cut and 3-5 DIFFERENT cards to add.
|
||||
- IMPORTANT: Suggest 5-8 DIFFERENT cards to cut and 5-8 DIFFERENT cards to add.
|
||||
- Each suggested add MUST be a different card — never suggest the same card twice.
|
||||
- Vary your suggestions across different functional roles (ramp, draw, removal, threats, utility).
|
||||
- If the deck is missing a functional role (e.g., no card draw, no removal), prioritize adding cards for that role.
|
||||
- If candidate replacements are provided from embeddings search, prefer those over generic suggestions.
|
||||
- For each suggested cut, provide a DETAILED reason citing specific stats (impact score, cast rate, dead card rate, clunkiness, synergy score).
|
||||
- For each suggested add, explain the SPECIFIC synergy with existing cards in the deck.
|
||||
- When reasoning about cuts, also consider keptInOpeningHandRate (low = bad in opening hands) and avgTurnCast (high = too slow for the curve).
|
||||
- When evaluating synergy, use the synergyScore: cards below 0.1 have weak co-occurrence with the rest of the deck.
|
||||
- Use perArchetypeWinRates to identify which matchup types this deck struggles with and tailor suggestions accordingly.
|
||||
- Provide at least 5 heuristic hints covering different strategic dimensions.
|
||||
{goals_section}
|
||||
|
||||
You MUST respond ONLY with valid JSON matching this exact structure:
|
||||
{{
|
||||
"summary": "2-3 sentence overview of the deck's strengths and weaknesses",
|
||||
"summary": "4-6 sentence detailed overview of the deck's strengths, weaknesses, key synergies, and strategic identity. Reference specific win rates and simulation data.",
|
||||
"suggestedCuts": [
|
||||
{{
|
||||
"cardName": "Card To Remove",
|
||||
"reason": "Why this card underperforms",
|
||||
"replacementOptions": ["Better Card 1", "Better Card 2"]
|
||||
"reason": "Detailed explanation citing specific simulation stats (impact score, cast rate, dead card rate, clunkiness score, synergy score, avg turn cast) and why this card underperforms in this specific deck context",
|
||||
"replacementOptions": ["Better Card 1", "Better Card 2"],
|
||||
"functionalRole": "ramp|draw|removal|threat|utility|combo|protection",
|
||||
"estimatedImpact": "How cutting this card is expected to improve the deck"
|
||||
}}
|
||||
],
|
||||
"suggestedAdds": [
|
||||
{{
|
||||
"cardName": "Card To Add",
|
||||
"role": "ramp|draw|removal|threat|utility|combo|protection",
|
||||
"reason": "Why this card improves the deck",
|
||||
"synergyWith": ["Card In Deck 1", "Card In Deck 2"]
|
||||
"reason": "Detailed explanation of why this card improves the deck, referencing specific weaknesses it addresses",
|
||||
"synergyWith": ["Card In Deck 1", "Card In Deck 2"],
|
||||
"estimatedManaCost": "The mana value of the suggested card",
|
||||
"strategicRationale": "How this card fits into the overall game plan and addresses a specific gap"
|
||||
}}
|
||||
],
|
||||
"heuristicHints": [
|
||||
"Concise strategic tip based on data"
|
||||
"manaCurveAnalysis": {{
|
||||
"currentAssessment": "Detailed analysis of the current mana curve distribution and its implications",
|
||||
"recommendations": "Specific suggestions for curve adjustments with reasoning",
|
||||
"idealRange": "What the curve should look like for this deck's strategy"
|
||||
}},
|
||||
"roleBreakdown": {{
|
||||
"ramp": "Assessment of ramp package - count, quality, and suggestions",
|
||||
"draw": "Assessment of card draw package",
|
||||
"removal": "Assessment of removal package",
|
||||
"threats": "Assessment of win conditions and threats",
|
||||
"protection": "Assessment of protection and interaction"
|
||||
}},
|
||||
"synergyAnalysis": [
|
||||
"Description of a key synergy chain in the deck and how well it performed in simulation"
|
||||
],
|
||||
"manaBaseAdvice": "Specific mana base improvement suggestion or null",
|
||||
"rawTextExplanation": "Detailed paragraph explaining the analysis and reasoning"
|
||||
"heuristicHints": [
|
||||
"Detailed strategic tip explaining the reasoning and data behind the suggestion"
|
||||
],
|
||||
"manaBaseAdvice": "Specific mana base improvement suggestion including land count, color fixing, and utility lands, or null if mana base is solid",
|
||||
"rawTextExplanation": "3-5 paragraph in-depth analysis covering: (1) overall deck strategy assessment and how well the deck executes its game plan based on simulation data, (2) mana curve and resource analysis with specific numbers including avgTurnCast for key cards, (3) card synergy evaluation highlighting the strongest and weakest interactions using synergyScore data, (4) meta considerations and matchup-specific insights referencing perArchetypeWinRates, and (5) prioritized list of improvements ranked by expected impact on win rate"
|
||||
}}
|
||||
|
||||
Do NOT include any text outside the JSON object. No markdown fences, no preamble."""
|
||||
@@ -82,6 +106,9 @@ Avg Game Length: {avg_game_length:.1f} turns
|
||||
### Matchup Data
|
||||
{matchup_section}
|
||||
|
||||
### Archetype Win Rates
|
||||
{archetype_section}
|
||||
|
||||
### Top Performing Cards (by Impact Score)
|
||||
{top_cards}
|
||||
|
||||
@@ -92,6 +119,7 @@ Avg Game Length: {avg_game_length:.1f} turns
|
||||
{candidate_section}
|
||||
|
||||
{combo_section}
|
||||
|
||||
Based on this simulation data, provide your coaching suggestions."""
|
||||
|
||||
|
||||
@@ -107,14 +135,18 @@ def _format_goals_section(goals: Optional[CoachGoals]) -> str:
|
||||
lines.append("- Keep suggestions casual and fun-focused.")
|
||||
elif goals.targetPowerLevel >= 8:
|
||||
lines.append("- Prioritize efficiency and competitive staples.")
|
||||
|
||||
if goals.metaFocus:
|
||||
lines.append(f"- Optimize for a {goals.metaFocus} strategy.")
|
||||
|
||||
if goals.budget:
|
||||
budget_map = {"budget": "under $5 per card", "medium": "under $20 per card",
|
||||
"no-limit": "no budget restrictions"}
|
||||
lines.append(f"- Budget constraint: {budget_map.get(goals.budget, goals.budget)}.")
|
||||
|
||||
if goals.focusAreas:
|
||||
lines.append(f"- Focus improvement on: {', '.join(goals.focusAreas)}.")
|
||||
|
||||
return "\n".join(lines) if lines else "- Aim for a balanced, well-rounded deck."
|
||||
|
||||
|
||||
@@ -126,28 +158,38 @@ def _format_curve(buckets: List[int]) -> str:
|
||||
|
||||
|
||||
def _format_top_cards(cards: List[CardPerformance], top_n: int = 10) -> str:
|
||||
"""Format the best performing cards."""
|
||||
"""Format the best performing cards with extended stats."""
|
||||
sorted_cards = sorted(cards, key=lambda c: c.impactScore, reverse=True)
|
||||
lines = []
|
||||
for c in sorted_cards[:top_n]:
|
||||
tags = f" [{', '.join(c.tags)}]" if c.tags else ""
|
||||
lines.append(f"- {c.name}: impact={c.impactScore:.3f}, "
|
||||
f"castRate={c.castRate:.1%}, drawnRate={c.drawnRate:.1%}{tags}")
|
||||
turn_str = f", avgTurnCast={c.avgTurnCast:.1f}" if c.avgTurnCast is not None else ""
|
||||
lines.append(
|
||||
f"- {c.name}: impact={c.impactScore:.3f}, "
|
||||
f"castRate={c.castRate:.1%}, drawnRate={c.drawnRate:.1%}, "
|
||||
f"synergy={c.synergyScore:.3f}{turn_str}{tags}"
|
||||
)
|
||||
return "\n".join(lines) if lines else "No card performance data available."
|
||||
|
||||
|
||||
def _format_underperformers(cards: List[CardPerformance],
|
||||
underperformer_names: List[str]) -> str:
|
||||
"""Format underperforming cards with their stats."""
|
||||
underperformer_names: List[str]) -> str:
|
||||
"""Format underperforming cards with full available stats."""
|
||||
card_map = {c.name: c for c in cards}
|
||||
lines = []
|
||||
for name in underperformer_names[:MAX_UNDERPERFORMERS]:
|
||||
c = card_map.get(name)
|
||||
if c:
|
||||
lines.append(f"- {c.name}: impact={c.impactScore:.3f}, "
|
||||
f"deadCardRate={c.deadCardRate:.1%}, "
|
||||
f"castRate={c.castRate:.1%}, "
|
||||
f"clunkiness={c.clunkinessScore:.3f}")
|
||||
turn_str = f", avgTurnCast={c.avgTurnCast:.1f}" if c.avgTurnCast is not None else ""
|
||||
lines.append(
|
||||
f"- {c.name}: impact={c.impactScore:.3f}, "
|
||||
f"deadCardRate={c.deadCardRate:.1%}, "
|
||||
f"castRate={c.castRate:.1%}, "
|
||||
f"clunkiness={c.clunkinessScore:.3f}, "
|
||||
f"synergy={c.synergyScore:.3f}, "
|
||||
f"keptInOpeningHand={c.keptInOpeningHandRate:.1%}"
|
||||
f"{turn_str}"
|
||||
)
|
||||
else:
|
||||
lines.append(f"- {name}: (no detailed stats)")
|
||||
return "\n".join(lines) if lines else "No clear underperformers identified."
|
||||
@@ -156,11 +198,13 @@ def _format_underperformers(cards: List[CardPerformance],
|
||||
def _format_candidates(candidates: Dict[str, List[dict]]) -> str:
|
||||
"""Format replacement candidates grouped by the card they'd replace."""
|
||||
if not candidates:
|
||||
return ("No embedding-based candidates available. "
|
||||
"Use your MTG knowledge to suggest cards that fit the deck's "
|
||||
"color identity, strategy, and missing functional roles. "
|
||||
"Suggest at least 3 different cards across different roles "
|
||||
"(ramp, draw, removal, threats, utility).")
|
||||
return (
|
||||
"No embedding-based candidates available. "
|
||||
"Use your MTG knowledge to suggest cards that fit the deck's "
|
||||
"color identity, strategy, and missing functional roles. "
|
||||
"Suggest at least 3 different cards across different roles "
|
||||
"(ramp, draw, removal, threats, utility)."
|
||||
)
|
||||
|
||||
lines = []
|
||||
for underperformer, replacements in list(candidates.items())[:MAX_UNDERPERFORMERS]:
|
||||
@@ -174,19 +218,33 @@ def _format_candidates(candidates: Dict[str, List[dict]]) -> str:
|
||||
lines.append(f" - {name} (MV:{mv}, {types}) [similarity: {sim:.3f}]")
|
||||
if text_preview:
|
||||
lines.append(f" Oracle: {text_preview}")
|
||||
|
||||
lines.append("\nChoose from these candidates when possible, but also suggest ")
|
||||
lines.append("cards NOT in this list if they better address the deck's weaknesses.")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _format_matchups(report: DeckReport) -> str:
|
||||
"""Format matchup data."""
|
||||
"""Format per-opponent matchup data."""
|
||||
if not report.matchups:
|
||||
return "No matchup data available."
|
||||
lines = []
|
||||
for m in report.matchups:
|
||||
lines.append(f"- vs {m.opponentDeck}: {m.winRate:.1%} "
|
||||
f"({m.gamesPlayed} games)")
|
||||
lines.append(
|
||||
f"- vs {m.opponentDeck}: {m.winRate:.1%} "
|
||||
f"({m.gamesPlayed} games)"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _format_archetypes(report: DeckReport) -> str:
|
||||
"""Format per-archetype win rates from DeckMeta."""
|
||||
rates = report.meta.perArchetypeWinRates
|
||||
if not rates:
|
||||
return "No archetype breakdown available."
|
||||
lines = []
|
||||
for archetype, wr in sorted(rates.items(), key=lambda x: x[1]):
|
||||
lines.append(f"- vs {archetype}: {wr:.1%}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@@ -197,8 +255,10 @@ def _format_combos(report: DeckReport) -> str:
|
||||
lines = ["### Known Combos"]
|
||||
for combo in report.knownCombos:
|
||||
cards = " + ".join(combo.cardNames)
|
||||
lines.append(f"- {cards}: winRate={combo.winRateWhenAssembled:.1%}, "
|
||||
f"assemblyRate={combo.assemblyRate:.1%}")
|
||||
lines.append(
|
||||
f"- {cards}: winRate={combo.winRateWhenAssembled:.1%}, "
|
||||
f"assemblyRate={combo.assemblyRate:.1%}"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@@ -239,6 +299,7 @@ def build_user_prompt(report: DeckReport,
|
||||
type_summary=", ".join(type_parts),
|
||||
role_summary=", ".join(role_parts),
|
||||
matchup_section=_format_matchups(report),
|
||||
archetype_section=_format_archetypes(report),
|
||||
top_cards=_format_top_cards(report.cards),
|
||||
underperformer_section=_format_underperformers(
|
||||
report.cards, report.underperformers),
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"""
|
||||
Commander AI Lab — Deck Generation User Prompt Builder
|
||||
═══════════════════════════════════════════════════════
|
||||
Constructs the user message for Perplexity deck generation
|
||||
Constructs the user message for Claude Opus deck generation
|
||||
from a DeckGenV3Request.
|
||||
"""
|
||||
|
||||
from typing import List, Optional
|
||||
from typing import List
|
||||
|
||||
|
||||
def build_user_prompt(
|
||||
@@ -36,8 +36,8 @@ def build_user_prompt(
|
||||
ci_str = ", ".join(color_identity) if color_identity else "Unknown"
|
||||
|
||||
lines = [
|
||||
f"Build a complete 100-card Commander deck.",
|
||||
f"",
|
||||
"Build a complete 100-card Commander deck.",
|
||||
"",
|
||||
f"COMMANDER: {commander}",
|
||||
]
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Commander AI Lab — Substitution Prompt Templates
|
||||
═════════════════════════════════════════════════
|
||||
Prompts for the Perplexity fallback substitution pass.
|
||||
Prompts for the Claude Opus substitution pass.
|
||||
"""
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Commander AI Lab — Deck Generation JSON Schemas
|
||||
═════════════════════════════════════════════════
|
||||
Pydantic models and JSON Schema dicts for Perplexity
|
||||
Pydantic models and JSON Schema dicts for Claude Opus
|
||||
structured output deck generation.
|
||||
"""
|
||||
|
||||
@@ -58,7 +58,7 @@ class GeneratedDeckList(BaseModel):
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# JSON Schema dicts (for Perplexity response_format)
|
||||
# JSON Schema dicts (for Claude Opus response_format)
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
DECK_CARD_SCHEMA = {
|
||||
|
||||
@@ -18,7 +18,7 @@ class CardAlternative(BaseModel):
|
||||
name: str = Field(description="Exact card name")
|
||||
similarity_score: float = Field(default=0.0, description="Cosine similarity score (0-1)")
|
||||
reason: str = Field(default="", description="Why this card is a good substitute")
|
||||
source: str = Field(default="embedding", description="How it was found: 'embedding' or 'perplexity'")
|
||||
source: str = Field(default="embedding", description="How it was found: 'embedding' or 'anthropic'")
|
||||
role_overlap: List[str] = Field(default_factory=list, description="Shared functional roles")
|
||||
cmc_delta: float = Field(default=0.0, description="CMC difference from original")
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Commander AI Lab — V3 Deck Generator Service
|
||||
═════════════════════════════════════════════
|
||||
Generates Commander decks using Perplexity Sonar
|
||||
Generates Commander decks using Anthropic Claude
|
||||
structured JSON output, then runs ownership check
|
||||
and smart substitution.
|
||||
"""
|
||||
@@ -13,7 +13,7 @@ import sqlite3
|
||||
from typing import List, Optional, Tuple
|
||||
from urllib.request import urlopen, Request
|
||||
|
||||
from ..clients.perplexity_client import PerplexityClient
|
||||
from ..clients.anthropic_client import AnthropicClient
|
||||
from ..schemas.deck_schema import DECK_LIST_SCHEMA, GeneratedDeckList
|
||||
from ..schemas.substitution_schema import (
|
||||
CardAlternative, DeckCardWithStatus, SubstitutionBatchResult,
|
||||
@@ -26,7 +26,7 @@ from ..prompts.substitution_prompt import (
|
||||
from ..config import (
|
||||
DECK_GEN_MODEL, DECK_GEN_TEMPERATURE, DECK_GEN_MAX_TOKENS,
|
||||
SUBSTITUTION_MIN_SIMILARITY, SUBSTITUTION_MAX_ALTERNATIVES,
|
||||
SUBSTITUTION_MODEL, SUBSTITUTION_USE_PPLX_FALLBACK,
|
||||
SUBSTITUTION_MODEL, SUBSTITUTION_USE_ANTHROPIC_FALLBACK,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("coach.deckgen")
|
||||
@@ -34,27 +34,27 @@ logger = logging.getLogger("coach.deckgen")
|
||||
|
||||
class DeckGeneratorV3:
|
||||
"""
|
||||
V3 Deck Generator using Perplexity structured output.
|
||||
V3 Deck Generator using Anthropic Claude structured output.
|
||||
|
||||
Pipeline:
|
||||
1. Resolve commander via Scryfall
|
||||
2. Build collection summary
|
||||
3. Call Perplexity with structured JSON schema
|
||||
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 + Perplexity fallback)
|
||||
6. Run Smart Substitution (embedding + Anthropic Claude fallback)
|
||||
7. Return finalized deck with substitution data
|
||||
"""
|
||||
|
||||
def __init__(self, pplx_client: PerplexityClient, db_conn_factory,
|
||||
def __init__(self, anthropic_client: AnthropicClient, db_conn_factory,
|
||||
embedding_index=None):
|
||||
"""
|
||||
Args:
|
||||
pplx_client: Configured PerplexityClient instance
|
||||
anthropic_client: Configured AnthropicClient instance
|
||||
db_conn_factory: Callable that returns sqlite3.Connection
|
||||
embedding_index: Optional MTGEmbeddingIndex for substitution
|
||||
"""
|
||||
self.pplx = pplx_client
|
||||
self.anthropic = anthropic_client
|
||||
self.db_conn_factory = db_conn_factory
|
||||
self.embedding_index = embedding_index
|
||||
|
||||
@@ -176,7 +176,7 @@ class DeckGeneratorV3:
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
# ── Step 3: Generate Deck via Perplexity ─────────────────
|
||||
# ── Step 3: Generate Deck via Anthropic Claude ─────────────────
|
||||
def generate_deck(
|
||||
self,
|
||||
commander_name: str,
|
||||
@@ -193,7 +193,7 @@ class DeckGeneratorV3:
|
||||
|
||||
Returns dict with:
|
||||
- commander: dict with Scryfall data
|
||||
- raw_deck: parsed GeneratedDeckList from Perplexity
|
||||
- raw_deck: parsed GeneratedDeckList from Anthropic Claude
|
||||
- cards: list of DeckCardWithStatus (after ownership check)
|
||||
- stats: deck statistics
|
||||
- tokens_used: {prompt, completion}
|
||||
@@ -223,11 +223,11 @@ class DeckGeneratorV3:
|
||||
collection_summary=collection_block,
|
||||
)
|
||||
|
||||
# Step 4: Call Perplexity with structured output
|
||||
# Step 4: Call Anthropic Claude with structured output
|
||||
use_model = model or DECK_GEN_MODEL
|
||||
logger.info("Calling Perplexity (%s) for deck generation...", use_model)
|
||||
logger.info("Calling Anthropic Claude (%s) for deck generation...", use_model)
|
||||
|
||||
response = self.pplx.chat_structured(
|
||||
response = self.anthropic.chat_structured(
|
||||
system_prompt=SYSTEM_PROMPT,
|
||||
user_prompt=user_prompt,
|
||||
json_schema=DECK_LIST_SCHEMA,
|
||||
@@ -239,7 +239,7 @@ class DeckGeneratorV3:
|
||||
|
||||
if not response.ok:
|
||||
raise ValueError(
|
||||
f"Perplexity returned invalid response: {response.content[:300]}"
|
||||
f"Anthropic Claude returned invalid response: {response.content[:300]}"
|
||||
)
|
||||
|
||||
raw_deck = response.parsed_json
|
||||
@@ -411,7 +411,7 @@ class DeckGeneratorV3:
|
||||
Pipeline:
|
||||
a) Identify missing cards
|
||||
b) For each: try embedding similarity (free, fast)
|
||||
c) For low-confidence matches: batch Perplexity fallback
|
||||
c) For low-confidence matches: batch Anthropic Claude fallback
|
||||
d) Auto-select best substitute per card
|
||||
|
||||
Returns SubstitutionBatchResult with all cards updated.
|
||||
@@ -443,7 +443,7 @@ class DeckGeneratorV3:
|
||||
available_pool = [n for n in owned_pool if n.lower() not in deck_names]
|
||||
|
||||
# a) Embedding pass
|
||||
needs_pplx_fallback = []
|
||||
needs_anthropic_fallback = []
|
||||
for card in missing:
|
||||
embedding_alts = self._embedding_substitution(
|
||||
card, color_identity, available_pool
|
||||
@@ -459,12 +459,12 @@ class DeckGeneratorV3:
|
||||
available_pool = [n for n in available_pool
|
||||
if n.lower() != card.selected_substitute.lower()]
|
||||
else:
|
||||
needs_pplx_fallback.append(card)
|
||||
needs_anthropic_fallback.append(card)
|
||||
|
||||
# b) Perplexity fallback for low-confidence matches
|
||||
if needs_pplx_fallback and SUBSTITUTION_USE_PPLX_FALLBACK:
|
||||
self._pplx_substitution(
|
||||
needs_pplx_fallback, available_pool,
|
||||
# b) Anthropic Claude fallback for low-confidence matches
|
||||
if needs_anthropic_fallback and SUBSTITUTION_USE_ANTHROPIC_FALLBACK:
|
||||
self._anthropic_substitution(
|
||||
needs_anthropic_fallback, available_pool,
|
||||
commander, strategy
|
||||
)
|
||||
|
||||
@@ -522,7 +522,7 @@ class DeckGeneratorV3:
|
||||
|
||||
return alternatives
|
||||
|
||||
def _pplx_substitution(
|
||||
def _anthropic_substitution(
|
||||
self,
|
||||
missing_cards: List[DeckCardWithStatus],
|
||||
available_pool: List[str],
|
||||
@@ -530,7 +530,7 @@ class DeckGeneratorV3:
|
||||
strategy: str,
|
||||
):
|
||||
"""
|
||||
Batch Perplexity fallback for cards with low embedding confidence.
|
||||
Batch Anthropic Claude fallback for cards with low embedding confidence.
|
||||
Mutates the cards in-place with substitution data.
|
||||
"""
|
||||
if not available_pool:
|
||||
@@ -557,7 +557,7 @@ class DeckGeneratorV3:
|
||||
|
||||
try:
|
||||
from ..schemas.substitution_schema import SUBSTITUTION_RESPONSE_SCHEMA
|
||||
response = self.pplx.chat_structured(
|
||||
response = self.anthropic.chat_structured(
|
||||
system_prompt=SUBSTITUTION_SYSTEM,
|
||||
user_prompt=user_prompt,
|
||||
json_schema=SUBSTITUTION_RESPONSE_SCHEMA,
|
||||
@@ -568,11 +568,11 @@ class DeckGeneratorV3:
|
||||
)
|
||||
|
||||
if not response.ok:
|
||||
logger.warning("Perplexity substitution fallback failed — non-JSON response")
|
||||
logger.warning("Anthropic Claude substitution fallback failed — non-JSON response")
|
||||
return
|
||||
|
||||
subs = response.parsed_json.get("substitutions", [])
|
||||
logger.info("Perplexity suggested %d substitutions", len(subs))
|
||||
logger.info("Anthropic Claude suggested %d substitutions", len(subs))
|
||||
|
||||
# Apply substitutions
|
||||
card_by_name = {c.name.lower(): c for c in missing_cards}
|
||||
@@ -584,20 +584,20 @@ class DeckGeneratorV3:
|
||||
if orig in card_by_name and substitute:
|
||||
card = card_by_name[orig]
|
||||
# Add as top alternative
|
||||
pplx_alt = CardAlternative(
|
||||
anthropic_alt = CardAlternative(
|
||||
name=substitute,
|
||||
similarity_score=0.80, # Nominal score for Perplexity suggestions
|
||||
similarity_score=0.80, # Nominal score for Anthropic Claude suggestions
|
||||
reason=reason,
|
||||
source="perplexity",
|
||||
source="Anthropic Claude",
|
||||
role_overlap=sub.get("role_overlap", []),
|
||||
)
|
||||
# Insert at front
|
||||
card.alternatives.insert(0, pplx_alt)
|
||||
card.alternatives.insert(0, anthropic_alt)
|
||||
card.selected_substitute = substitute
|
||||
card.status = "substituted"
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Perplexity substitution fallback error: %s", e)
|
||||
logger.error("Anthropic Claude substitution fallback error: %s", e)
|
||||
|
||||
# ── Stats ────────────────────────────────────────────────
|
||||
@staticmethod
|
||||
|
||||
@@ -137,7 +137,7 @@ if (-not (Test-Path $envFile)) {
|
||||
# Uncomment and fill in the values you need:
|
||||
|
||||
# XIMILAR_API_KEY=your_ximilar_key_here
|
||||
# PPLX_API_KEY=your_perplexity_key_here
|
||||
# ANTHROPIC_API_KEY=your_anthropic_api_key_here
|
||||
# FORGE_JAR=C:\Path\To\forge-gui-desktop.jar
|
||||
# FORGE_DIR=C:\Path\To\Forge
|
||||
# LAB_PORT=8080
|
||||
|
||||
+13
-4
@@ -36,11 +36,12 @@ from routes.deepseek import router as deepseek_router
|
||||
from routes.deckgen import router as deckgen_router
|
||||
from routes.coach import router as coach_router, init_coach_service
|
||||
from routes.ml import router as ml_router
|
||||
from routes.ws_game import router as ws_game_router
|
||||
|
||||
log = logging.getLogger("commander_ai_lab.api")
|
||||
|
||||
_DEFAULT_ORIGINS = "http://localhost:5173,http://localhost:3000,http://localhost:8080"
|
||||
# ── CORS origins (env-configurable) ────────────────────────────
|
||||
_DEFAULT_ORIGINS = "http://localhost:5173,http://localhost:8080"
|
||||
_allowed = os.environ.get("ALLOWED_ORIGINS", _DEFAULT_ORIGINS)
|
||||
allowed_origins = [o.strip() for o in _allowed.split(",") if o.strip()]
|
||||
|
||||
@@ -63,6 +64,7 @@ app.include_router(scanner_router)
|
||||
app.include_router(deepseek_router)
|
||||
app.include_router(deckgen_router)
|
||||
app.include_router(coach_router)
|
||||
app.include_router(ws_game_router)
|
||||
app.include_router(ml_router)
|
||||
|
||||
|
||||
@@ -84,6 +86,13 @@ async def _on_startup():
|
||||
if not COMMANDER_META:
|
||||
load_commander_meta()
|
||||
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def _on_shutdown():
|
||||
"""Close the async SQLite connection pool."""
|
||||
from services.async_db import async_close_db
|
||||
await async_close_db()
|
||||
|
||||
# ── Static UI ──────────────────────────────────────────────────
|
||||
_legacy_ui_dir = Path(__file__).parent / "ui"
|
||||
_spa_dir = Path(__file__).parent / "frontend" / "commander-ai-lab-ui" / "dist"
|
||||
@@ -112,7 +121,7 @@ def _parse_args():
|
||||
p.add_argument("--precon-dir", default=os.environ.get("PRECON_DIR", ""))
|
||||
p.add_argument("--port", type=int, default=int(os.environ.get("LAB_PORT", "8080")))
|
||||
p.add_argument("--ximilar-key", default=os.environ.get("XIMILAR_API_KEY", ""))
|
||||
p.add_argument("--pplx-key", default=os.environ.get("PPLX_API_KEY", ""))
|
||||
p.add_argument("--anthropic-key", default=os.environ.get("ANTHROPIC_API_KEY", ""))
|
||||
p.add_argument("--verbose", "-v", action="store_true")
|
||||
return p.parse_args()
|
||||
|
||||
@@ -280,7 +289,7 @@ def main():
|
||||
CFG.precon_dir = args.precon_dir or _resolve_precon_dir()
|
||||
CFG.port = args.port
|
||||
CFG.ximilar_api_key = args.ximilar_key
|
||||
CFG.pplx_api_key = args.pplx_key
|
||||
CFG.anthropic_api_key = args.anthropic_key
|
||||
|
||||
from services.forge_runner import get_java17
|
||||
j17 = get_java17()
|
||||
@@ -294,7 +303,7 @@ def main():
|
||||
log.info(f" Lab JAR: {CFG.lab_jar or 'NOT SET'}")
|
||||
log.info(f" Results: {CFG.results_dir}")
|
||||
log.info(f" Ximilar: {'configured' if CFG.ximilar_api_key else 'NOT SET'}")
|
||||
log.info(f" Perplexity: {'configured' if CFG.pplx_api_key else 'NOT SET'}")
|
||||
log.info(f" Anthropic: {'configured' if CFG.anthropic_api_key else 'NOT SET'}")
|
||||
log.info(f" Java 17: {j17}")
|
||||
|
||||
load_commander_meta()
|
||||
|
||||
+25
-1
@@ -16,6 +16,7 @@ Reuses `run_match`, `LearnedPolicy`, `HeuristicPolicy`,
|
||||
Usage:
|
||||
python -m ml.eval.elo_tracker
|
||||
python -m ml.eval.elo_tracker --episodes 50 --output data/elo_history.json
|
||||
python -m ml.eval.elo_tracker --top-n 10
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -285,12 +286,17 @@ def run_generation_tournament(
|
||||
episodes_per_matchup: int = 50,
|
||||
playstyle: str = "midrange",
|
||||
k_factor: float = DEFAULT_K,
|
||||
top_n: int = 10,
|
||||
) -> EloResult:
|
||||
"""
|
||||
Discover generation checkpoints and run an ELO tournament.
|
||||
|
||||
Looks for `gen-*/best_policy.pt` under checkpoint_dir.
|
||||
Also includes heuristic and random baselines.
|
||||
|
||||
Args:
|
||||
top_n: Only include the N most recent gen-* checkpoints.
|
||||
0 means include all (unbounded). Default is 10.
|
||||
"""
|
||||
policies: Dict[str, object] = {
|
||||
"heuristic": HeuristicPolicy(),
|
||||
@@ -299,7 +305,20 @@ def run_generation_tournament(
|
||||
|
||||
ckpt_path = Path(checkpoint_dir)
|
||||
if ckpt_path.exists():
|
||||
for gen_dir in sorted(ckpt_path.glob("gen-*")):
|
||||
gen_dirs = sorted(ckpt_path.glob("gen-*"))
|
||||
|
||||
# Apply top_n cutoff — keep only the latest N generation dirs
|
||||
if top_n > 0 and len(gen_dirs) > top_n:
|
||||
skipped = len(gen_dirs) - top_n
|
||||
logger.info(
|
||||
"top_n=%d: skipping %d older generation(s), using %s … %s",
|
||||
top_n, skipped,
|
||||
gen_dirs[-top_n].name,
|
||||
gen_dirs[-1].name,
|
||||
)
|
||||
gen_dirs = gen_dirs[-top_n:]
|
||||
|
||||
for gen_dir in gen_dirs:
|
||||
model_path = gen_dir / "best_policy.pt"
|
||||
if model_path.exists():
|
||||
name = gen_dir.name # e.g., "gen-001"
|
||||
@@ -363,6 +382,10 @@ def main():
|
||||
"--k-factor", type=float, default=32,
|
||||
help="ELO K-factor (default: 32)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--top-n", type=int, default=10,
|
||||
help="Only include the N most recent gen checkpoints (0 = all, default: 10)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
@@ -373,6 +396,7 @@ def main():
|
||||
episodes_per_matchup=args.episodes,
|
||||
playstyle=args.playstyle,
|
||||
k_factor=args.k_factor,
|
||||
top_n=args.top_n,
|
||||
)
|
||||
|
||||
# Print results
|
||||
|
||||
@@ -567,7 +567,7 @@ def collect_rollouts(
|
||||
total_losses = 0
|
||||
total_draws = 0
|
||||
warnings.warn(_TRAINING_DEPRECATION_MSG, DeprecationWarning, stacklevel=2)
|
||||
total_steps = 0
|
||||
total_steps = 0
|
||||
total_rewards = 0.0
|
||||
|
||||
for ep in range(num_episodes):
|
||||
|
||||
+75
-4
@@ -25,6 +25,7 @@ import json
|
||||
import logging
|
||||
import logging.handlers
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
@@ -42,6 +43,9 @@ 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
|
||||
@@ -229,6 +233,52 @@ 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", {
|
||||
@@ -364,6 +414,7 @@ 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("")
|
||||
|
||||
@@ -440,6 +491,17 @@ 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:
|
||||
@@ -482,17 +544,17 @@ def main():
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
# Run DeepSeek sims for 11.5 hours, then train:
|
||||
# Run DeepSeek sims for 11.5 hours, then update weights + train:
|
||||
python overnight-run.py
|
||||
|
||||
# Run 20 batches of 50 games each, then train:
|
||||
# Run 20 batches of 50 games each, then update weights + 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, no training:
|
||||
python overnight-run.py --no-train
|
||||
# Just run sims, skip both weight update and training:
|
||||
python overnight-run.py --no-train --no-update-weights
|
||||
|
||||
# Custom training settings:
|
||||
python overnight-run.py --epochs 200 --lr 0.0005 --patience 20
|
||||
@@ -514,6 +576,12 @@ 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,
|
||||
@@ -536,6 +604,9 @@ 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
|
||||
|
||||
@@ -7,6 +7,11 @@ uvicorn[standard]>=0.23.0
|
||||
pydantic>=2.0.0
|
||||
httpx>=0.24.0
|
||||
websockets>=11.0
|
||||
openai>=1.0.0
|
||||
aiosqlite>=0.19.0
|
||||
|
||||
# Anthropic Claude API
|
||||
anthropic>=0.40.0
|
||||
|
||||
# Deck Builder dependencies
|
||||
requests>=2.31.0
|
||||
|
||||
+425
-618
File diff suppressed because it is too large.
Load diff
@@ -406,6 +406,11 @@ async def update_collection_card(cardId: int, body: dict):
|
||||
if finish_val not in ("NORMAL", "FOIL", "ETCHED"):
|
||||
raise HTTPException(400, "finish must be NORMAL, FOIL, or ETCHED")
|
||||
updates["finish"] = finish_val
|
||||
if "quantity" in body:
|
||||
qty_val = int(body["quantity"])
|
||||
if qty_val < 0:
|
||||
raise HTTPException(400, "quantity must be >= 0")
|
||||
updates["quantity"] = qty_val
|
||||
|
||||
if not updates:
|
||||
return _add_image_url(_row_to_dict(row))
|
||||
|
||||
+45
-37
@@ -1,14 +1,14 @@
|
||||
"""
|
||||
routes/deckgen.py
|
||||
=================
|
||||
Deck generation endpoints (classic, Perplexity V1, V3):
|
||||
Deck generation endpoints (classic, Anthropic Claude V1, V3):
|
||||
GET /api/deck-generator/config
|
||||
POST /api/deck-generator/preview
|
||||
POST /api/deck-generator/commit
|
||||
GET /api/deck-generator/commander-search
|
||||
POST /api/deck-research
|
||||
POST /api/deck-generate
|
||||
GET /api/pplx/status
|
||||
GET /api/anthropic/status
|
||||
GET /api/deck/v3/status
|
||||
POST /api/deck/v3/generate
|
||||
POST /api/deck/v3/commit
|
||||
@@ -724,7 +724,7 @@ async def deck_generator_commander_search(q: str = ""):
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Perplexity API — AI Deck Research & Generation
|
||||
# Anthropic Claude API — AI Deck Research & Generation
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
@@ -847,54 +847,62 @@ def _build_collection_summary(color_identity: list[str] | None = None) -> dict:
|
||||
}
|
||||
|
||||
|
||||
async def _call_pplx_api(messages: list[dict], max_tokens: int = 4096, temperature: float = 0.2) -> str:
|
||||
"""Call Perplexity API chat/completions endpoint (non-blocking).
|
||||
async def _call_anthropic_api(messages: list[dict], max_tokens: int = 4096, temperature: float = 0.2) -> str:
|
||||
"""Call Anthropic Claude API messages endpoint (non-blocking).
|
||||
Uses httpx.AsyncClient so the uvicorn event loop is never stalled.
|
||||
Returns the assistant message content.
|
||||
"""
|
||||
from coach.config import DECK_GEN_PROVIDER, DECK_GEN_BASE_URL, DECK_GEN_MODEL, PPLX_MODEL
|
||||
from coach.config import DECK_GEN_PROVIDER, DECK_GEN_BASE_URL, DECK_GEN_MODEL, ANTHROPIC_MODEL
|
||||
|
||||
if DECK_GEN_PROVIDER == 'local':
|
||||
base_url = DECK_GEN_BASE_URL
|
||||
model = DECK_GEN_MODEL
|
||||
headers = {'Content-Type': 'application/json'}
|
||||
else:
|
||||
if not CFG.pplx_api_key:
|
||||
raise HTTPException(400, 'Perplexity API key not configured. Set PPLX_API_KEY env var or --pplx-key.')
|
||||
base_url = 'https://api.perplexity.ai'
|
||||
model = PPLX_MODEL
|
||||
headers = {
|
||||
if not CFG.anthropic_api_key:
|
||||
raise HTTPException(400, 'Anthropic Claude API key not configured. Set anthropic_api_key env var or --anthropic-key.')
|
||||
base_url = 'https://api.anthropic.com'
|
||||
model = ANTHROPIC_MODEL
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': f'Bearer {CFG.pplx_api_key}',
|
||||
}
|
||||
'x-api-key': CFG.anthropic_api_key, 'anthropic-version': '2023-06-01',
|
||||
}
|
||||
# Separate system messages for Anthropic API format
|
||||
system_msgs = [m['content'] for m in messages if m.get('role') == 'system']
|
||||
user_msgs = [m for m in messages if m.get('role') != 'system']
|
||||
system_text = '\n'.join(system_msgs) if system_msgs else None
|
||||
|
||||
|
||||
payload = {
|
||||
'model': model,
|
||||
'messages': messages,
|
||||
'messages': user_msgs,
|
||||
'max_tokens': max_tokens,
|
||||
'temperature': temperature,
|
||||
}
|
||||
if system_text:
|
||||
payload['system'] = system_text
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||
resp = await client.post(
|
||||
f'{base_url}/chat/completions',
|
||||
f'{base_url}/v1/messages',
|
||||
json=payload,
|
||||
headers=headers,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except httpx.HTTPStatusError as e:
|
||||
raise HTTPException(502, f'Perplexity API returned {e.response.status_code}: {e.response.text[:200]}')
|
||||
raise HTTPException(502, f'Anthropic Claude API returned {e.response.status_code}: {e.response.text[:200]}')
|
||||
except httpx.RequestError as e:
|
||||
raise HTTPException(502, f'Perplexity API call failed: {e}')
|
||||
raise HTTPException(502, f'Anthropic Claude API call failed: {e}')
|
||||
|
||||
choices = data.get('choices', [])
|
||||
if not choices:
|
||||
raise HTTPException(502, 'Empty response from Perplexity API')
|
||||
content = choices[0].get('message', {}).get('content', '')
|
||||
content_blocks = data.get('content', [])
|
||||
if not content_blocks:
|
||||
raise HTTPException(502, 'Empty response from Anthropic Claude API')
|
||||
content = content_blocks[0].get('text', '')
|
||||
usage = data.get('usage', {})
|
||||
log_pplx.debug(
|
||||
f'tokens: prompt={usage.get("prompt_tokens", "?")}, '
|
||||
f'completion={usage.get("completion_tokens", "?")}, '
|
||||
f'tokens: prompt={usage.get("input_tokens", "?")}, '
|
||||
f'completion={usage.get("output_tokens", "?")}, '
|
||||
f'model={data.get("model", "?")}'
|
||||
)
|
||||
return content
|
||||
@@ -1032,7 +1040,7 @@ def _fill_basic_lands(cards: list[dict], color_identity: list[str] | None = None
|
||||
|
||||
@router.post('/api/deck-research')
|
||||
async def deck_research(req: DeckResearchRequest):
|
||||
"""Analyze an existing deck using Perplexity AI and suggest improvements."""
|
||||
"""Analyze an existing deck using Anthropic Claude and suggest improvements."""
|
||||
conn = _get_db_conn()
|
||||
|
||||
# Load deck info
|
||||
@@ -1157,7 +1165,7 @@ DECKLIST ({len(card_rows)} cards):
|
||||
|
||||
Analyze EVERYTHING: the deck's identity, strategy, archetype, bracket level (1-4 per Commander Rules Committee), ALL synergy packages between cards, ALL win conditions, game plan by phase (early/mid/late), mana base health, every role gap. Suggest specific cuts with severity and specific adds with priority and synergy tags. For adds, prioritize cards from the COLLECTION SUMMARY when available."""
|
||||
|
||||
content = await _call_pplx_api([
|
||||
content = await _call_anthropic_api([
|
||||
{'role': 'system', 'content': system_msg},
|
||||
{'role': 'user', 'content': user_msg},
|
||||
], max_tokens=8192)
|
||||
@@ -1187,7 +1195,7 @@ Analyze EVERYTHING: the deck's identity, strategy, archetype, bracket level (1-4
|
||||
|
||||
@router.post('/api/deck-generate')
|
||||
async def deck_generate_ai(req: DeckGenerateAIRequest):
|
||||
"""Generate a full 100-card Commander deck using Perplexity AI."""
|
||||
"""Generate a full 100-card Commander deck using Anthropic Claude."""
|
||||
commander = req.commander.strip()
|
||||
if not commander:
|
||||
raise HTTPException(400, 'Commander name is required')
|
||||
@@ -1267,7 +1275,7 @@ COLOR IDENTITY: {', '.join(color_identity) if color_identity else 'Unknown'}
|
||||
|
||||
Build the deck as JSON. Prioritize collection cards when they fit the strategy."""
|
||||
|
||||
content = await _call_pplx_api([
|
||||
content = await _call_anthropic_api([
|
||||
{'role': 'system', 'content': system_msg},
|
||||
{'role': 'user', 'content': user_msg},
|
||||
], max_tokens=8192, temperature=0.3)
|
||||
@@ -1294,16 +1302,16 @@ Build the deck as JSON. Prioritize collection cards when they fit the strategy."
|
||||
return result
|
||||
|
||||
|
||||
@router.get('/api/pplx/status')
|
||||
async def pplx_status():
|
||||
"""Check if Perplexity API is configured."""
|
||||
@router.get('/api/anthropic/status')
|
||||
async def anthropic_status():
|
||||
"""Check if Anthropic Claude API is configured."""
|
||||
return {
|
||||
'configured': bool(CFG.pplx_api_key),
|
||||
'configured': bool(CFG.anthropic_api_key),
|
||||
}
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# V3 Deck Generator (Perplexity Structured Output)
|
||||
# V3 Deck Generator (Anthropic Claude Structured Output)
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
@router.get('/api/deck/v3/status')
|
||||
@@ -1311,8 +1319,8 @@ async def deck_gen_v3_status():
|
||||
"""Check V3 deck generator status."""
|
||||
return {
|
||||
'initialized': _coach._deck_gen_v3 is not None,
|
||||
'pplx_configured': bool(CFG.pplx_api_key),
|
||||
'model': getattr(_coach._deck_gen_v3, 'model', getattr(getattr(_coach._deck_gen_v3, 'pplx', None), 'model', 'ollama/gpt-oss:20b')) if _coach._deck_gen_v3 else None,
|
||||
'anthropic_configured': bool(CFG.anthropic_api_key),
|
||||
'model': getattr(_coach._deck_gen_v3, 'model', getattr(getattr(_coach._deck_gen_v3, 'anthropic_client', None), 'model', 'ollama/gpt-oss:20b')) if _coach._deck_gen_v3 else None,
|
||||
'embeddings_loaded': False,
|
||||
'embedding_cards': 0,
|
||||
'error': _coach._deck_gen_v3_error,
|
||||
@@ -1322,14 +1330,14 @@ async def deck_gen_v3_status():
|
||||
@router.post('/api/deck/v3/generate')
|
||||
async def deck_gen_v3_generate(req: DeckGenV3Request):
|
||||
"""
|
||||
V3 Deck Generation — Perplexity structured output with Smart Substitution.
|
||||
V3 Deck Generation — Anthropic Claude Structured Output with Smart Substitution.
|
||||
|
||||
Pipeline:
|
||||
1. Resolve commander via Scryfall
|
||||
2. Build collection summary from DB
|
||||
3. Call Perplexity Sonar with JSON schema enforcement
|
||||
3. Call Anthropic Claude with JSON schema enforcement
|
||||
4. Cross-reference cards with collection for ownership
|
||||
5. Run Smart Substitution (embedding + Perplexity fallback)
|
||||
5. Run Smart Substitution (embedding + Anthropic fallback)
|
||||
6. Return complete deck with substitution data
|
||||
"""
|
||||
if _coach._deck_gen_v3 is None:
|
||||
|
||||
+2
-2
@@ -66,8 +66,8 @@ async def start_batch(req: StartRequest, background_tasks: BackgroundTasks):
|
||||
raise HTTPException(400, f"All {len(req.decks)} deck slots must be filled")
|
||||
if req.numGames < 1 or req.numGames > 10000:
|
||||
raise HTTPException(400, "numGames must be 1-10000")
|
||||
if req.threads < 1 or req.threads > 16:
|
||||
raise HTTPException(400, "threads must be 1-16")
|
||||
if req.threads < 1 or req.threads > 32:
|
||||
raise HTTPException(400, "threads must be 1-32")
|
||||
|
||||
if not os.path.exists(CFG.forge_jar):
|
||||
raise HTTPException(500, f"Forge JAR not found: {CFG.forge_jar}")
|
||||
|
||||
+15
-6
@@ -1097,7 +1097,7 @@ _elo_tournament_state = _EloTournamentState()
|
||||
_elo_tournament_lock = threading.Lock()
|
||||
|
||||
|
||||
def _run_elo_tournament_pipeline(episodes: int, playstyle: str):
|
||||
def _run_elo_tournament_pipeline(episodes: int, playstyle: str, top_n: int):
|
||||
"""Run ELO tournament in a background thread."""
|
||||
try:
|
||||
import sys
|
||||
@@ -1108,7 +1108,7 @@ def _run_elo_tournament_pipeline(episodes: int, playstyle: str):
|
||||
with _elo_tournament_lock:
|
||||
_elo_tournament_state.running = True
|
||||
_elo_tournament_state.phase = "running"
|
||||
_elo_tournament_state.message = "Running ELO tournament..."
|
||||
_elo_tournament_state.message = f"Running ELO tournament (top {top_n} generations)..."
|
||||
_elo_tournament_state.error = None
|
||||
_elo_tournament_state.result = None
|
||||
|
||||
@@ -1121,6 +1121,7 @@ def _run_elo_tournament_pipeline(episodes: int, playstyle: str):
|
||||
checkpoint_dir=ckpt_dir,
|
||||
episodes_per_matchup=episodes,
|
||||
playstyle=playstyle,
|
||||
top_n=top_n,
|
||||
)
|
||||
|
||||
# Save to ELO history
|
||||
@@ -1155,29 +1156,37 @@ def _run_elo_tournament_pipeline(episodes: int, playstyle: str):
|
||||
|
||||
@router.post("/api/ml/elo/tournament")
|
||||
async def ml_start_elo_tournament(request: FastAPIRequest):
|
||||
"""Start an ELO-rated tournament across all generation checkpoints."""
|
||||
"""Start an ELO-rated tournament across generation checkpoints.
|
||||
|
||||
Body params:
|
||||
episodes (int): Games per matchup pair (default 50)
|
||||
playstyle (str): Game playstyle (default midrange)
|
||||
topN (int): Max number of most-recent gen checkpoints to include
|
||||
(default 10, 0 = unlimited)
|
||||
"""
|
||||
if _elo_tournament_state.running:
|
||||
raise HTTPException(409, "ELO tournament already in progress")
|
||||
|
||||
body = await request.json() if await request.body() else {}
|
||||
episodes = body.get("episodes", 50)
|
||||
playstyle = body.get("playstyle", "midrange")
|
||||
top_n = body.get("topN", 10)
|
||||
|
||||
with _elo_tournament_lock:
|
||||
_elo_tournament_state.running = True
|
||||
_elo_tournament_state.phase = "starting"
|
||||
_elo_tournament_state.message = "Initializing ELO tournament..."
|
||||
_elo_tournament_state.message = f"Initializing ELO tournament (top {top_n} generations)..."
|
||||
_elo_tournament_state.result = None
|
||||
_elo_tournament_state.error = None
|
||||
|
||||
thread = threading.Thread(
|
||||
target=_run_elo_tournament_pipeline,
|
||||
args=(episodes, playstyle),
|
||||
args=(episodes, playstyle, top_n),
|
||||
daemon=True,
|
||||
)
|
||||
thread.start()
|
||||
|
||||
return {"status": "started", "episodes": episodes}
|
||||
return {"status": "started", "episodes": episodes, "top_n": top_n}
|
||||
|
||||
|
||||
@router.get("/api/ml/elo/tournament/status")
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
"""WebSocket route for real-time game state streaming.
|
||||
|
||||
Endpoints:
|
||||
WS /ws/game/{game_id} — Stream state deltas to Unity
|
||||
POST /api/ws/game/{game_id}/push — Push a state update (called by Forge IPC)
|
||||
POST /api/ws/game/{game_id}/decision — Push AI decision event
|
||||
POST /api/ws/game/{game_id}/end — Signal game over
|
||||
GET /api/ws/stats — Connection statistics
|
||||
|
||||
Unity connects to the WebSocket endpoint and receives:
|
||||
1. An initial full snapshot on first state push
|
||||
2. Subsequent delta-only messages as state changes
|
||||
3. Decision events when the AI acts
|
||||
4. A game_over event when the game ends
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ws.connection_manager import game_manager
|
||||
from ws.game_state import get_tracker, remove_tracker
|
||||
|
||||
log = logging.getLogger("routes.ws_game")
|
||||
|
||||
router = APIRouter(tags=["websocket"])
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────
|
||||
# WebSocket endpoint — Unity connects here
|
||||
# ────────────────────────────────────────────────────────────
|
||||
|
||||
@router.websocket("/ws/game/{game_id}")
|
||||
async def game_ws(websocket: WebSocket, game_id: str):
|
||||
"""WebSocket endpoint for Unity game-state streaming.
|
||||
|
||||
Query params:
|
||||
client_id — optional unique identifier for this client
|
||||
|
||||
The connection stays open for the duration of the game.
|
||||
The server pushes messages; the client can send:
|
||||
{"type": "ping"} — keep-alive
|
||||
{"type": "request_snapshot"} — request a full re-sync
|
||||
"""
|
||||
client_id = (
|
||||
websocket.query_params.get("client_id")
|
||||
or f"unity-{uuid.uuid4().hex[:8]}"
|
||||
)
|
||||
|
||||
client = await game_manager.connect(websocket, game_id, client_id)
|
||||
|
||||
# If the tracker already has a baseline, send the current state
|
||||
tracker = get_tracker(game_id)
|
||||
if tracker.has_baseline:
|
||||
snapshot_msg = tracker.update(tracker._last_snapshot)
|
||||
await websocket.send_json(snapshot_msg)
|
||||
|
||||
try:
|
||||
while True:
|
||||
data = await websocket.receive_json()
|
||||
msg_type = data.get("type", "")
|
||||
|
||||
if msg_type == "ping":
|
||||
await websocket.send_json({"type": "pong"})
|
||||
|
||||
elif msg_type == "request_snapshot":
|
||||
if tracker.has_baseline:
|
||||
# Re-send current state as full snapshot
|
||||
import copy
|
||||
snap = copy.deepcopy(tracker._last_snapshot)
|
||||
tracker._seq += 1
|
||||
import time
|
||||
await websocket.send_json({
|
||||
"type": "snapshot",
|
||||
"seq": tracker._seq,
|
||||
"game_id": game_id,
|
||||
"timestamp": time.time(),
|
||||
"state": snap,
|
||||
})
|
||||
else:
|
||||
await websocket.send_json({
|
||||
"type": "error",
|
||||
"message": "No game state available yet",
|
||||
})
|
||||
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
except Exception as e:
|
||||
log.warning("WS error game=%s client=%s: %s", game_id, client_id, e)
|
||||
finally:
|
||||
await game_manager.disconnect(game_id, client_id)
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────
|
||||
# REST endpoints — called by Forge IPC / policy server
|
||||
# ────────────────────────────────────────────────────────────
|
||||
|
||||
class PushStateRequest(BaseModel):
|
||||
"""Game state snapshot pushed from Forge."""
|
||||
game_id: str = ""
|
||||
turn: int = 0
|
||||
phase: str = ""
|
||||
active_player: int = 0
|
||||
players: list = Field(default_factory=list)
|
||||
stack: list = Field(default_factory=list)
|
||||
legal_actions: list = Field(default_factory=list)
|
||||
|
||||
|
||||
class DecisionEvent(BaseModel):
|
||||
"""AI decision event pushed after policy inference."""
|
||||
action: str
|
||||
action_index: int = 0
|
||||
confidence: float = 0.0
|
||||
probabilities: dict = Field(default_factory=dict)
|
||||
inference_ms: float = 0.0
|
||||
|
||||
|
||||
class GameOverEvent(BaseModel):
|
||||
"""Game-over event."""
|
||||
winner_seat: int = -1
|
||||
reason: str = ""
|
||||
turns_played: int = 0
|
||||
|
||||
|
||||
@router.post("/api/ws/game/{game_id}/push")
|
||||
async def push_game_state(game_id: str, req: PushStateRequest):
|
||||
"""Push a game state update to all connected Unity clients.
|
||||
|
||||
Called by the Forge IPC bridge or policy server after each
|
||||
state change. The delta engine diffs against the previous
|
||||
state and broadcasts only what changed.
|
||||
"""
|
||||
tracker = get_tracker(game_id)
|
||||
snapshot = req.dict()
|
||||
delta_msg = tracker.update(snapshot)
|
||||
sent = await game_manager.broadcast_to_game(game_id, delta_msg)
|
||||
return {
|
||||
"status": "ok",
|
||||
"type": delta_msg["type"],
|
||||
"seq": delta_msg["seq"],
|
||||
"clients_notified": sent,
|
||||
"changes": len(delta_msg.get("changes", [])),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/api/ws/game/{game_id}/decision")
|
||||
async def push_decision(game_id: str, event: DecisionEvent):
|
||||
"""Push an AI decision event to connected Unity clients."""
|
||||
tracker = get_tracker(game_id)
|
||||
msg = tracker.make_decision_event(
|
||||
action=event.action,
|
||||
action_index=event.action_index,
|
||||
confidence=event.confidence,
|
||||
probabilities=event.probabilities,
|
||||
inference_ms=event.inference_ms,
|
||||
)
|
||||
sent = await game_manager.broadcast_to_game(game_id, msg)
|
||||
return {"status": "ok", "clients_notified": sent}
|
||||
|
||||
|
||||
@router.post("/api/ws/game/{game_id}/end")
|
||||
async def push_game_over(game_id: str, event: GameOverEvent):
|
||||
"""Signal game over to all connected clients and clean up."""
|
||||
tracker = get_tracker(game_id)
|
||||
msg = tracker.make_game_over_event(
|
||||
winner_seat=event.winner_seat,
|
||||
reason=event.reason,
|
||||
turns_played=event.turns_played,
|
||||
)
|
||||
sent = await game_manager.broadcast_to_game(game_id, msg)
|
||||
remove_tracker(game_id)
|
||||
return {"status": "ok", "clients_notified": sent}
|
||||
|
||||
|
||||
@router.get("/api/ws/stats")
|
||||
async def ws_stats():
|
||||
"""Return WebSocket connection statistics."""
|
||||
return game_manager.stats()
|
||||
@@ -0,0 +1,220 @@
|
||||
"""Async SQLite wrapper for FastAPI — eliminates event-loop blocking.
|
||||
|
||||
Drop-in async replacements for the sync helpers in services/database.py.
|
||||
Uses aiosqlite under the hood so every DB call yields back to the
|
||||
event loop instead of freezing it.
|
||||
|
||||
Usage in route handlers:
|
||||
from services.async_db import async_get_db, async_execute, async_fetchall
|
||||
|
||||
rows = await async_fetchall("SELECT * FROM decks")
|
||||
row = await async_fetchone("SELECT * FROM decks WHERE id = ?", (1,))
|
||||
await async_execute("INSERT INTO ...", params)
|
||||
await async_execute_commit("DELETE FROM ...", params)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
import aiosqlite
|
||||
|
||||
log = logging.getLogger("commander_ai_lab.async_db")
|
||||
|
||||
# Re-use the same DB path as the sync module
|
||||
COLLECTION_DB_PATH = Path(__file__).parent.parent / "collection.db"
|
||||
|
||||
# Module-level connection (lazy-init, one per process)
|
||||
_aconn: Optional[aiosqlite.Connection] = None
|
||||
|
||||
|
||||
async def async_get_db() -> aiosqlite.Connection:
|
||||
"""Return (or create) the module-level async SQLite connection."""
|
||||
global _aconn
|
||||
if _aconn is None:
|
||||
_aconn = await aiosqlite.connect(str(COLLECTION_DB_PATH))
|
||||
_aconn.row_factory = aiosqlite.Row
|
||||
await _aconn.execute("PRAGMA journal_mode=WAL")
|
||||
log.info("Async SQLite connection opened: %s", COLLECTION_DB_PATH)
|
||||
return _aconn
|
||||
|
||||
|
||||
async def async_close_db() -> None:
|
||||
"""Close the module-level connection (call on shutdown)."""
|
||||
global _aconn
|
||||
if _aconn is not None:
|
||||
await _aconn.close()
|
||||
_aconn = None
|
||||
log.info("Async SQLite connection closed.")
|
||||
|
||||
|
||||
# ── Convenience helpers ─────────────────────────────────────────
|
||||
|
||||
async def async_fetchall(
|
||||
sql: str, params: tuple = (), *, conn: aiosqlite.Connection = None
|
||||
) -> list[dict]:
|
||||
"""Execute a SELECT and return all rows as dicts."""
|
||||
db = conn or await async_get_db()
|
||||
async with db.execute(sql, params) as cursor:
|
||||
columns = [d[0] for d in cursor.description]
|
||||
rows = await cursor.fetchall()
|
||||
return [dict(zip(columns, row)) for row in rows]
|
||||
|
||||
|
||||
async def async_fetchone(
|
||||
sql: str, params: tuple = (), *, conn: aiosqlite.Connection = None
|
||||
) -> Optional[dict]:
|
||||
"""Execute a SELECT and return one row as a dict (or None)."""
|
||||
db = conn or await async_get_db()
|
||||
async with db.execute(sql, params) as cursor:
|
||||
columns = [d[0] for d in cursor.description]
|
||||
row = await cursor.fetchone()
|
||||
return dict(zip(columns, row)) if row else None
|
||||
|
||||
|
||||
async def async_execute(
|
||||
sql: str, params: tuple = (), *, conn: aiosqlite.Connection = None
|
||||
) -> int:
|
||||
"""Execute a write statement. Returns lastrowid."""
|
||||
db = conn or await async_get_db()
|
||||
cursor = await db.execute(sql, params)
|
||||
return cursor.lastrowid
|
||||
|
||||
|
||||
async def async_execute_commit(
|
||||
sql: str, params: tuple = (), *, conn: aiosqlite.Connection = None
|
||||
) -> int:
|
||||
"""Execute a write statement and commit. Returns lastrowid."""
|
||||
db = conn or await async_get_db()
|
||||
cursor = await db.execute(sql, params)
|
||||
await db.commit()
|
||||
return cursor.lastrowid
|
||||
|
||||
|
||||
async def async_executemany_commit(
|
||||
sql: str, params_seq, *, conn: aiosqlite.Connection = None
|
||||
) -> None:
|
||||
"""Execute many and commit."""
|
||||
db = conn or await async_get_db()
|
||||
await db.executemany(sql, params_seq)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def async_commit(*, conn: aiosqlite.Connection = None) -> None:
|
||||
"""Commit the current transaction."""
|
||||
db = conn or await async_get_db()
|
||||
await db.commit()
|
||||
|
||||
|
||||
# ── JSON field helpers (mirrors database._row_to_dict) ──────────
|
||||
|
||||
_JSON_FIELDS = ("category", "color_identity", "subtypes", "keywords")
|
||||
|
||||
|
||||
def row_to_dict_parsed(row: dict) -> dict:
|
||||
"""Parse JSON string columns into Python lists (same as database._row_to_dict)."""
|
||||
if row is None:
|
||||
return None
|
||||
d = dict(row)
|
||||
for f in _JSON_FIELDS:
|
||||
if f in d and isinstance(d[f], str):
|
||||
try:
|
||||
d[f] = json.loads(d[f])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
d[f] = []
|
||||
return d
|
||||
|
||||
|
||||
# ── Coach session async helpers ─────────────────────────────────
|
||||
|
||||
async def async_save_coach_session(session_data: dict) -> int:
|
||||
"""Insert or update a coach session. Returns row id."""
|
||||
db = await async_get_db()
|
||||
existing = await async_fetchone(
|
||||
"SELECT id FROM coach_sessions WHERE session_id = ?",
|
||||
(session_data["session_id"],),
|
||||
)
|
||||
if existing:
|
||||
await db.execute(
|
||||
"""UPDATE coach_sessions SET
|
||||
deck_id=?, timestamp=?, model_used=?, prompt_tokens=?,
|
||||
completion_tokens=?, summary=?, goals_json=?, cuts_json=?,
|
||||
adds_json=?, heuristic_hints_json=?, mana_base_advice=?,
|
||||
raw_text=?
|
||||
WHERE session_id=?""",
|
||||
(
|
||||
session_data.get("deck_id", ""),
|
||||
session_data.get("timestamp", ""),
|
||||
session_data.get("model_used", ""),
|
||||
session_data.get("prompt_tokens", 0),
|
||||
session_data.get("completion_tokens", 0),
|
||||
session_data.get("summary", ""),
|
||||
session_data.get("goals_json", "{}"),
|
||||
session_data.get("cuts_json", "[]"),
|
||||
session_data.get("adds_json", "[]"),
|
||||
session_data.get("heuristic_hints_json", "[]"),
|
||||
session_data.get("mana_base_advice", ""),
|
||||
session_data.get("raw_text", ""),
|
||||
session_data["session_id"],
|
||||
),
|
||||
)
|
||||
await db.commit()
|
||||
return existing["id"]
|
||||
else:
|
||||
cursor = await db.execute(
|
||||
"""INSERT INTO coach_sessions
|
||||
(session_id, deck_id, timestamp, model_used, prompt_tokens,
|
||||
completion_tokens, summary, goals_json, cuts_json, adds_json,
|
||||
heuristic_hints_json, mana_base_advice, raw_text)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(
|
||||
session_data["session_id"],
|
||||
session_data.get("deck_id", ""),
|
||||
session_data.get("timestamp", ""),
|
||||
session_data.get("model_used", ""),
|
||||
session_data.get("prompt_tokens", 0),
|
||||
session_data.get("completion_tokens", 0),
|
||||
session_data.get("summary", ""),
|
||||
session_data.get("goals_json", "{}"),
|
||||
session_data.get("cuts_json", "[]"),
|
||||
session_data.get("adds_json", "[]"),
|
||||
session_data.get("heuristic_hints_json", "[]"),
|
||||
session_data.get("mana_base_advice", ""),
|
||||
session_data.get("raw_text", ""),
|
||||
),
|
||||
)
|
||||
await db.commit()
|
||||
return cursor.lastrowid
|
||||
|
||||
|
||||
async def async_load_coach_session(session_id: str) -> Optional[dict]:
|
||||
"""Load a single coach session by session_id."""
|
||||
return await async_fetchone(
|
||||
"SELECT * FROM coach_sessions WHERE session_id = ?", (session_id,)
|
||||
)
|
||||
|
||||
|
||||
async def async_list_coach_sessions(deck_id: str = None) -> list[dict]:
|
||||
"""List coach sessions, optionally filtered by deck_id."""
|
||||
if deck_id:
|
||||
return await async_fetchall(
|
||||
"SELECT session_id, deck_id, timestamp, summary, created_at "
|
||||
"FROM coach_sessions WHERE deck_id = ? ORDER BY timestamp DESC",
|
||||
(deck_id,),
|
||||
)
|
||||
return await async_fetchall(
|
||||
"SELECT session_id, deck_id, timestamp, summary, created_at "
|
||||
"FROM coach_sessions ORDER BY timestamp DESC"
|
||||
)
|
||||
|
||||
|
||||
async def async_delete_coach_session(session_id: str) -> bool:
|
||||
"""Delete a coach session. Returns True if a row was deleted."""
|
||||
db = await async_get_db()
|
||||
cursor = await db.execute(
|
||||
"DELETE FROM coach_sessions WHERE session_id = ?", (session_id,)
|
||||
)
|
||||
await db.commit()
|
||||
return cursor.rowcount > 0
|
||||
@@ -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,13 +154,23 @@ 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.
|
||||
|
||||
@@ -168,7 +178,7 @@ Commander: {commander_name}
|
||||
Color identity: {', '.join(color_identity)}
|
||||
Category: {category}
|
||||
{strategy}
|
||||
{exclude_str}
|
||||
{exclude_str}{sim_block}
|
||||
|
||||
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.
|
||||
@@ -200,7 +210,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],
|
||||
@@ -242,17 +252,28 @@ 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}
|
||||
@@ -265,7 +286,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.
|
||||
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."""
|
||||
|
||||
@@ -284,7 +305,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,
|
||||
|
||||
@@ -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 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
|
||||
@@ -37,7 +38,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:
|
||||
@@ -50,7 +51,17 @@ def build_deck(request: BuildRequest) -> BuildResult:
|
||||
logger.info(f"Commander: {commander.name}, CI: {ci_list}")
|
||||
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}'")
|
||||
edhrec_cards = edhrec.get_recommended_cards(commander.name)
|
||||
if edhrec_cards:
|
||||
@@ -59,7 +70,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),
|
||||
@@ -70,7 +81,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
|
||||
@@ -79,7 +90,9 @@ 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
|
||||
# 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"]
|
||||
ollama_suggestions: Dict[str, List[str]] = {}
|
||||
|
||||
@@ -91,6 +104,7 @@ 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)
|
||||
@@ -98,7 +112,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
|
||||
@@ -140,7 +154,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,
|
||||
@@ -153,10 +167,13 @@ 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
|
||||
@@ -165,25 +182,52 @@ 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")
|
||||
|
||||
# Convert to CardEntry list via Scryfall batch lookup
|
||||
all_card_names = []
|
||||
for names in adjusted.values():
|
||||
all_card_names.extend(names)
|
||||
# Build a globally-deduplicated name list. enforce_deck_ratios can
|
||||
# return the same card name under multiple category keys (e.g. a
|
||||
# protection card that also appears in synergy). Without this step
|
||||
# get_cards_by_names produces duplicate CardEntry objects that
|
||||
# immediately fail the CommanderDeck singleton validator.
|
||||
seen_names: dict[str, str] = {} # name -> first category that claimed it
|
||||
for cat, names in adjusted.items():
|
||||
for name in names:
|
||||
if name not in seen_names:
|
||||
seen_names[name] = cat
|
||||
else:
|
||||
logger.warning(
|
||||
"Duplicate card '%s' found in categories '%s' and '%s' — "
|
||||
"keeping first assignment, dropping second",
|
||||
name, seen_names[name], cat,
|
||||
)
|
||||
|
||||
if len(seen_names) < sum(len(v) for v in adjusted.values()):
|
||||
warnings.append(
|
||||
"enforce_deck_ratios returned duplicate card names across categories; "
|
||||
"duplicates were silently dropped before singleton validation."
|
||||
)
|
||||
|
||||
all_card_names = list(seen_names.keys())
|
||||
|
||||
card_entries = scryfall.get_cards_by_names(all_card_names)
|
||||
|
||||
# Map back categories
|
||||
name_to_cat: Dict[str, str] = {}
|
||||
for cat, names in adjusted.items():
|
||||
for name in names:
|
||||
name_to_cat[name] = cat
|
||||
|
||||
# Safety-net: deduplicate CardEntry list by name in case Scryfall
|
||||
# returns multiple printings for the same name.
|
||||
seen_entry_names: set[str] = set()
|
||||
unique_entries: List[CardEntry] = []
|
||||
for entry in card_entries:
|
||||
if entry.name in name_to_cat:
|
||||
entry.category = name_to_cat[entry.name]
|
||||
if entry.name not in seen_entry_names:
|
||||
seen_entry_names.add(entry.name)
|
||||
unique_entries.append(entry)
|
||||
else:
|
||||
logger.warning("Scryfall returned duplicate entry for '%s' — skipping", entry.name)
|
||||
card_entries = unique_entries
|
||||
|
||||
# Map back categories
|
||||
for entry in card_entries:
|
||||
if entry.name in seen_names:
|
||||
entry.category = seen_names[entry.name]
|
||||
|
||||
# Build the deck
|
||||
elapsed = time.time() - start
|
||||
|
||||
+42
-44
@@ -1,112 +1,110 @@
|
||||
@echo off
|
||||
REM ═══════════════════════════════════════════════════════════
|
||||
REM Commander AI Lab — One-Click Launcher (Windows)
|
||||
REM Commander AI Lab — One-Click Launcher (Windows)
|
||||
REM ═══════════════════════════════════════════════════════════
|
||||
REM
|
||||
REM Starts the Python FastAPI server + serves the web UI.
|
||||
REM Prerequisites:
|
||||
REM - Python 3.10+ with: pip install fastapi uvicorn
|
||||
REM - Java 17+
|
||||
REM - commander-ai-lab built: mvn package "-DskipTests"
|
||||
REM Starts the Python FastAPI server + serves the web UI.
|
||||
REM Prerequisites:
|
||||
REM - Python 3.10+ with: pip install fastapi uvicorn
|
||||
REM - Java 17+
|
||||
REM - commander-ai-lab built: mvn package "-DskipTests"
|
||||
REM
|
||||
REM Edit the paths below to match your system.
|
||||
REM Edit the paths below to match your system.
|
||||
REM ═══════════════════════════════════════════════════════════
|
||||
|
||||
REM ── Your Forge paths (EDIT THESE) ──────────────────────────
|
||||
set FORGE_JAR=D:\ForgeCommander\forge-repo\forge-gui-desktop\target\forge-gui-desktop-2.0.12-SNAPSHOT-jar-with-dependencies.jar
|
||||
set FORGE_DIR=D:\ForgeCommander\forge-repo\forge-gui
|
||||
set FORGE_DIR=D:\ForgeCommander\forge-repo
|
||||
set FORGE_DECKS_DIR=%APPDATA%\Forge\decks\commander
|
||||
|
||||
REM ── Lab JAR (auto-detected from target/) ───────────────────
|
||||
REM ── Lab JAR (auto-detected from target/) ─────────────────────
|
||||
set LAB_PORT=8080
|
||||
|
||||
REM ── API Keys (set via environment variables or .env file) ──
|
||||
REM If not already set in your environment, create a .env file
|
||||
REM in this directory (see .env.example) or set them manually:
|
||||
REM set XIMILAR_API_KEY=your-ximilar-key-here
|
||||
REM set PPLX_API_KEY=your-perplexity-key-here
|
||||
REM If not already set in your environment, create a .env file
|
||||
REM in this directory (see .env.example) or set them manually:
|
||||
REM set XIMILAR_API_KEY=your-ximilar-key-here
|
||||
REM set ANTHROPIC_API_KEY=your-anthropic-api-key-here
|
||||
REM
|
||||
REM Load from .env file if it exists and vars aren't already set
|
||||
REM Load from .env file if it exists and vars aren't already set
|
||||
if not defined XIMILAR_API_KEY (
|
||||
if exist "%~dp0.env" (
|
||||
for /f "usebackq tokens=1,* delims==" %%A in ("%~dp0.env") do (
|
||||
if "%%A"=="XIMILAR_API_KEY" set XIMILAR_API_KEY=%%B
|
||||
if "%%A"=="PPLX_API_KEY" set PPLX_API_KEY=%%B
|
||||
if "%%A"=="ANTHROPIC_API_KEY" set ANTHROPIC_API_KEY=%%B
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
echo.
|
||||
echo ╔══════════════════════════════════════════════════╗
|
||||
echo ║ Commander AI Lab — Launcher ║
|
||||
echo ╚══════════════════════════════════════════════════╝
|
||||
echo ╔══════════════════════════════════════════════════╗
|
||||
echo ║ Commander AI Lab — Launcher ║
|
||||
echo ╚══════════════════════════════════════════════════╝
|
||||
echo.
|
||||
echo Forge JAR: %FORGE_JAR%
|
||||
echo Forge Dir: %FORGE_DIR%
|
||||
echo Decks Dir: %FORGE_DECKS_DIR%
|
||||
echo Port: %LAB_PORT%
|
||||
echo Ximilar: Configured
|
||||
if defined PPLX_API_KEY (
|
||||
echo Perplexity: Configured
|
||||
echo Forge JAR: %FORGE_JAR%
|
||||
echo Forge Dir: %FORGE_DIR%
|
||||
echo Decks Dir: %FORGE_DECKS_DIR%
|
||||
echo Port: %LAB_PORT%
|
||||
echo Ximilar: Configured
|
||||
if defined ANTHROPIC_API_KEY (
|
||||
echo Claude Opus: Configured
|
||||
) else (
|
||||
echo Perplexity: Not configured ^(set PPLX_API_KEY env var for AI features^)
|
||||
echo Claude Opus: Not configured ^(set ANTHROPIC_API_KEY env var for AI features^)
|
||||
)
|
||||
echo.
|
||||
|
||||
REM ── Check prerequisites ────────────────────────────────────
|
||||
where python >nul 2>&1
|
||||
if %ERRORLEVEL% neq 0 (
|
||||
echo ERROR: Python not found in PATH
|
||||
echo ERROR: Python not found in PATH
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
where java >nul 2>&1
|
||||
if %ERRORLEVEL% neq 0 (
|
||||
echo ERROR: Java not found in PATH
|
||||
echo ERROR: Java not found in PATH
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
if not exist "%FORGE_JAR%" (
|
||||
echo ERROR: Forge JAR not found at: %FORGE_JAR%
|
||||
echo Run Forge build first:
|
||||
echo cd D:\ForgeCommander\forge-repo
|
||||
echo mvn package -pl forge-gui-desktop "-DskipTests" "-Drevision=2.0.12-SNAPSHOT" -am
|
||||
echo ERROR: Forge JAR not found at: %FORGE_JAR%
|
||||
echo Run Forge build first:
|
||||
echo cd D:\ForgeCommander\forge-repo
|
||||
echo mvn package -pl forge-gui-desktop "-DskipTests" "-Drevision=2.0.12-SNAPSHOT" -am
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
REM ── Check if lab_api.py exists ─────────────────────────────
|
||||
if not exist "%~dp0lab_api.py" (
|
||||
echo ERROR: lab_api.py not found in %~dp0
|
||||
echo ERROR: lab_api.py not found in %~dp0
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
REM ── Install Python deps if needed ──────────────────────────
|
||||
REM ── Install Python deps if needed ───────────────────────────
|
||||
python -c "import fastapi" >nul 2>&1
|
||||
if %ERRORLEVEL% neq 0 (
|
||||
echo Installing FastAPI + Uvicorn...
|
||||
echo Installing FastAPI + Uvicorn...
|
||||
pip install fastapi uvicorn
|
||||
)
|
||||
|
||||
REM ── Launch the server ──────────────────────────────────────
|
||||
REM ── Launch the server ───────────────────────────────────────
|
||||
echo.
|
||||
echo Starting Commander AI Lab server...
|
||||
echo Web UI: http://localhost:%LAB_PORT%
|
||||
echo API: http://localhost:%LAB_PORT%/docs
|
||||
echo Press Ctrl+C to stop.
|
||||
echo Starting Commander AI Lab server...
|
||||
echo Web UI: http://localhost:%LAB_PORT%
|
||||
echo API: http://localhost:%LAB_PORT%/docs
|
||||
echo Press Ctrl+C to stop.
|
||||
echo.
|
||||
|
||||
if defined PPLX_API_KEY (
|
||||
if defined ANTHROPIC_API_KEY (
|
||||
python "%~dp0lab_api.py" ^
|
||||
--forge-jar "%FORGE_JAR%" ^
|
||||
--forge-dir "%FORGE_DIR%" ^
|
||||
--forge-decks-dir "%FORGE_DECKS_DIR%" ^
|
||||
--port %LAB_PORT% ^
|
||||
--ximilar-key "%XIMILAR_API_KEY%" ^
|
||||
--pplx-key "%PPLX_API_KEY%"
|
||||
--anthropic-key "%ANTHROPIC_API_KEY%"
|
||||
) else (
|
||||
python "%~dp0lab_api.py" ^
|
||||
--forge-jar "%FORGE_JAR%" ^
|
||||
|
||||
@@ -49,9 +49,9 @@ def _make_conn() -> sqlite3.Connection:
|
||||
|
||||
|
||||
def _make_generator(conn: sqlite3.Connection) -> DeckGeneratorV3:
|
||||
"""Build a DeckGeneratorV3 with a mock Perplexity client."""
|
||||
"""Build a DeckGeneratorV3 with a mock Anthropic Claude Opus client."""
|
||||
return DeckGeneratorV3(
|
||||
pplx_client=MagicMock(),
|
||||
anthropic_client=MagicMock(),
|
||||
db_conn_factory=lambda: conn,
|
||||
)
|
||||
|
||||
|
||||
+3
-2
@@ -165,8 +165,9 @@ const AiLab = (() => {
|
||||
<option value="2">2</option>
|
||||
<option value="4" selected>4</option>
|
||||
<option value="8">8</option>
|
||||
<option value="12">12</option>
|
||||
<option value="16">16</option>
|
||||
<option value="16">16</option>
|
||||
<option value="24">24</option>
|
||||
<option value="32">32</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="lab-control-group">
|
||||
|
||||
+5
-1
@@ -1132,7 +1132,11 @@
|
||||
height: auto;
|
||||
border-radius: 10px;
|
||||
}
|
||||
td[data-card-name] .coach-card-name {
|
||||
td[data-card-name] .coach-card-name,
|
||||
[data-card-name].coach-replacement-chip,
|
||||
[data-card-name].coach-synergy-chip,
|
||||
[data-card-name].coach-pill,
|
||||
[data-card-name].coach-cl-name {
|
||||
cursor: pointer;
|
||||
border-bottom: 1px dashed var(--text-secondary, #888);
|
||||
}
|
||||
+24
-9
@@ -23,7 +23,7 @@
|
||||
<div class="coach-status-items">
|
||||
<div class="coach-status-item" id="status-llm">
|
||||
<span class="status-dot status-unknown"></span>
|
||||
<span class="status-label">Perplexity</span>
|
||||
<span class="status-label">Claude Opus</span>
|
||||
<span class="status-value" id="status-llm-value">Checking...</span>
|
||||
</div>
|
||||
<div class="coach-status-item" id="status-embeddings">
|
||||
@@ -58,7 +58,10 @@
|
||||
|
||||
<!-- Power Level Slider -->
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="coach-power-level">Target Power Level</label>
|
||||
<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>
|
||||
<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>
|
||||
@@ -68,7 +71,10 @@
|
||||
|
||||
<!-- Meta Focus -->
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="coach-meta-focus">Meta Focus</label>
|
||||
<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>
|
||||
<select id="coach-meta-focus" class="form-select">
|
||||
<option value="">Any / Balanced</option>
|
||||
<option value="aggro">Aggro</option>
|
||||
@@ -81,7 +87,10 @@
|
||||
|
||||
<!-- Budget -->
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="coach-budget">Budget</label>
|
||||
<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>
|
||||
<select id="coach-budget" class="form-select">
|
||||
<option value="">No Limit</option>
|
||||
<option value="budget">Budget (< $2 per card)</option>
|
||||
@@ -92,7 +101,10 @@
|
||||
|
||||
<!-- Focus Areas -->
|
||||
<div class="form-group coach-field-wide">
|
||||
<label class="form-label">Focus Areas (optional)</label>
|
||||
<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>
|
||||
<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>
|
||||
@@ -121,7 +133,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>
|
||||
@@ -143,7 +155,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>
|
||||
@@ -163,7 +175,10 @@
|
||||
<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</label>
|
||||
<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>
|
||||
<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>
|
||||
@@ -173,7 +188,7 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ═══ One-Shot Results Section ═══ -->
|
||||
<!-- One-Shot Results Section -->
|
||||
<section id="coach-results" class="coach-results" style="display:none;">
|
||||
|
||||
<!-- Apply Suggestions Bar -->
|
||||
|
||||
+8
-6
@@ -63,7 +63,7 @@
|
||||
}
|
||||
|
||||
function _attachCardHover(container) {
|
||||
container.querySelectorAll('td[data-card-name]').forEach(td => {
|
||||
container.querySelectorAll('[data-card-name]').forEach(td => {
|
||||
td.addEventListener('mouseenter', e => _showCardPreview(td.dataset.cardName, e));
|
||||
td.addEventListener('mousemove', e => _positionPreview(e));
|
||||
td.addEventListener('mouseleave', _hideCardPreview);
|
||||
@@ -433,7 +433,7 @@
|
||||
html += '<span class="coach-session-detail-label">Cuts:</span>';
|
||||
html += '<div class="coach-session-pills">';
|
||||
for (const cut of cuts) {
|
||||
html += '<span class="coach-pill coach-pill-cut" title="' + escHtml(cut.reason || '') + '">' + escHtml(cut.cardName) + '</span>';
|
||||
html += '<span class="coach-pill coach-pill-cut" data-card-name="' + escHtml(cut.cardName) + '" title="' + escHtml(cut.reason || '') + '">' + escHtml(cut.cardName) + '</span>';
|
||||
}
|
||||
html += '</div></div>';
|
||||
}
|
||||
@@ -444,7 +444,7 @@
|
||||
html += '<span class="coach-session-detail-label">Adds:</span>';
|
||||
html += '<div class="coach-session-pills">';
|
||||
for (const add of adds) {
|
||||
html += '<span class="coach-pill coach-pill-add" title="' + escHtml(add.reason || '') + '">' + escHtml(add.cardName) + '</span>';
|
||||
html += '<span class="coach-pill coach-pill-add" data-card-name="' + escHtml(add.cardName) + '" title="' + escHtml(add.reason || '') + '">' + escHtml(add.cardName) + '</span>';
|
||||
}
|
||||
html += '</div></div>';
|
||||
}
|
||||
@@ -457,6 +457,7 @@
|
||||
|
||||
html += '</div>';
|
||||
detailEl.innerHTML = html;
|
||||
_attachCardHover(detailEl);
|
||||
|
||||
// Bind buttons
|
||||
detailEl.querySelector('.coach-load-chat-btn')?.addEventListener('click', (e) => {
|
||||
@@ -599,7 +600,7 @@
|
||||
'<td>' + escHtml(cut.reason) + '</td>' +
|
||||
'<td><div class="coach-replacements">' +
|
||||
(cut.replacementOptions || []).map(r =>
|
||||
'<span class="coach-replacement-chip">' + escHtml(r) + '</span>'
|
||||
'<span class="coach-replacement-chip" data-card-name="' + escHtml(r) + '">' + escHtml(r) + '</span>'
|
||||
).join('') +
|
||||
'</div></td>' +
|
||||
'<td>' + formatImpact(cut.currentImpactScore) + '</td>';
|
||||
@@ -622,7 +623,7 @@
|
||||
'<td>' + escHtml(add.reason) + '</td>' +
|
||||
'<td><div class="coach-replacements">' +
|
||||
(add.synergyWith || []).map(s =>
|
||||
'<span class="coach-synergy-chip">' + escHtml(s) + '</span>'
|
||||
'<span class="coach-synergy-chip" data-card-name="' + escHtml(s) + '">' + escHtml(s) + '</span>'
|
||||
).join('') +
|
||||
'</div></td>';
|
||||
tr.querySelector('.coach-card-name').parentElement.dataset.cardName = add.cardName;
|
||||
@@ -918,7 +919,7 @@
|
||||
|
||||
div.innerHTML =
|
||||
'<div class="coach-cl-top">' +
|
||||
'<span class="coach-cl-name">' + escHtml(card.name) + '</span>' +
|
||||
'<span class="coach-cl-name" data-card-name="' + escHtml(card.name) + '">' + escHtml(card.name) + '</span>' +
|
||||
'<span class="coach-cl-sim">' + simPct + '%</span>' +
|
||||
'</div>' +
|
||||
'<div class="coach-cl-meta">' +
|
||||
@@ -939,6 +940,7 @@
|
||||
});
|
||||
|
||||
cardslikeResults.appendChild(div);
|
||||
_attachCardHover(div);
|
||||
}
|
||||
} catch (e) {
|
||||
cardslikeResults.innerHTML = '<div class="coach-cardslike-empty">Error: ' + escHtml(e.message) + '</div>';
|
||||
|
||||
@@ -3147,3 +3147,88 @@
|
||||
.coll-col-reset:hover {
|
||||
background: rgba(99,102,241,0.25);
|
||||
}
|
||||
|
||||
|
||||
/* ── Bulk Action Bar ──────────────────────────────────────────── */
|
||||
.coll-bulk-bar {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
background: var(--bg-card, #1e1e1e);
|
||||
border-top: 1px solid var(--border, #30363d);
|
||||
border-radius: 0 0 8px 8px;
|
||||
z-index: 50;
|
||||
flex-wrap: wrap;
|
||||
box-shadow: 0 -2px 8px rgba(0,0,0,0.3);
|
||||
}
|
||||
.coll-bulk-count {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--accent, #6366f1);
|
||||
min-width: 80px;
|
||||
}
|
||||
.coll-bulk-sep {
|
||||
color: var(--text-secondary, #8b949e);
|
||||
padding: 0 2px;
|
||||
}
|
||||
.coll-bulk-label {
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-secondary, #8b949e);
|
||||
}
|
||||
.coll-bulk-btn {
|
||||
background: rgba(99,102,241,0.15);
|
||||
border: 1px solid rgba(99,102,241,0.35);
|
||||
color: var(--text, #e6edf3);
|
||||
border-radius: 5px;
|
||||
padding: 4px 10px;
|
||||
font-size: 0.82rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.coll-bulk-btn:hover:not(:disabled) {
|
||||
background: rgba(99,102,241,0.3);
|
||||
}
|
||||
.coll-bulk-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.coll-bulk-set-btn {
|
||||
background: rgba(99,102,241,0.25);
|
||||
font-weight: 600;
|
||||
}
|
||||
.coll-bulk-qty-input {
|
||||
width: 56px;
|
||||
background: var(--bg, #0d1117);
|
||||
border: 1px solid var(--border, #30363d);
|
||||
color: var(--text, #e6edf3);
|
||||
border-radius: 5px;
|
||||
padding: 3px 6px;
|
||||
font-size: 0.82rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ── Select Checkbox Column ─────────────────────────────────── */
|
||||
.coll-th-select {
|
||||
width: 32px;
|
||||
min-width: 32px;
|
||||
text-align: center;
|
||||
padding: 4px;
|
||||
}
|
||||
.coll-td-select {
|
||||
width: 32px;
|
||||
min-width: 32px;
|
||||
text-align: center;
|
||||
padding: 4px;
|
||||
}
|
||||
.coll-row-check {
|
||||
cursor: pointer;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
accent-color: var(--accent, #6366f1);
|
||||
}
|
||||
.coll-row.selected-row {
|
||||
background: rgba(99,102,241,0.08);
|
||||
}
|
||||
+114
-1
@@ -164,6 +164,7 @@ const Collection = (() => {
|
||||
importHeaders: [],
|
||||
importMissingFinishNormal: true,
|
||||
exportOpen: false,
|
||||
selectedRows: new Set(),
|
||||
preferences: {
|
||||
pageSize: 50,
|
||||
sortField: 'name',
|
||||
@@ -662,6 +663,18 @@ const Collection = (() => {
|
||||
</table>
|
||||
</div>
|
||||
<div class="coll-pagination" id="coll-pagination"></div>
|
||||
<!-- Bulk Action Bar -->
|
||||
<div id="coll-bulk-bar" class="coll-bulk-bar" style="display:none">
|
||||
<span id="coll-bulk-count" class="coll-bulk-count">0 selected</span>
|
||||
<button class="coll-bulk-btn" onclick="Collection.selectAllRows()">Select All</button>
|
||||
<button class="coll-bulk-btn" onclick="Collection.clearSelection()">Clear</button>
|
||||
<span class="coll-bulk-sep">|</span>
|
||||
<span class="coll-bulk-label">Qty:</span>
|
||||
<button class="coll-bulk-btn" onclick="Collection.bulkAdjustQuantity(-1)" title="Subtract 1">−</button>
|
||||
<button class="coll-bulk-btn" onclick="Collection.bulkAdjustQuantity(1)" title="Add 1">+</button>
|
||||
<input type="number" id="coll-bulk-qty-input" class="coll-bulk-qty-input" min="0" value="1" placeholder="n">
|
||||
<button class="coll-bulk-btn coll-bulk-set-btn" onclick="Collection.bulkSetQuantity()">Set</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -1077,7 +1090,7 @@ const Collection = (() => {
|
||||
return `<th class="coll-th coll-th-${col.key} coll-th-sortable${activeClass}"
|
||||
onclick="Collection.setSort('${col.key}')">${col.label}${arrow}</th>`;
|
||||
}).join('');
|
||||
return `<thead><tr>${headers}</tr></thead>`;
|
||||
return `<thead><tr>$<th class="coll-th-select"><input type="checkbox" id="coll-select-all" onclick="Collection.toggleSelectAll(this.checked)" title="Select all"></th>{headers}</tr></thead>`;
|
||||
}
|
||||
|
||||
// ── Table Body ──────────────────────────────────────────
|
||||
@@ -1127,6 +1140,7 @@ const Collection = (() => {
|
||||
const edhrecRank = (card.edhrecRank || card.edhrec_rank) ? `#${card.edhrecRank || card.edhrec_rank}` : '—';
|
||||
|
||||
return `<tr class="coll-row" onclick="Collection.openDrawer(${card.id})" data-id="${card.id}" data-img-url="${card.imageUrl || ''}">
|
||||
<td class="coll-td coll-td-select" onclick="event.stopPropagation()"><input type="checkbox" class="coll-row-check" data-id="${card.id}" ${state.selectedRows.has(card.id) ? 'checked' : ''} onchange="Collection.toggleRowSelect(${card.id}, this.checked)"></td>
|
||||
<td class="coll-td coll-td-quantity">${escapeHtml(card.quantity ?? 1)}</td>
|
||||
<td class="coll-td coll-td-name">
|
||||
<span class="coll-card-name">${escapeHtml(card.name)}</span>
|
||||
@@ -2477,6 +2491,98 @@ const Collection = (() => {
|
||||
window.location.href = '/index.html';
|
||||
}
|
||||
|
||||
// ── Bulk Row Selection & Quantity Edit ─────────────────────
|
||||
function toggleRowSelect(cardId, checked) {
|
||||
if (checked) {
|
||||
state.selectedRows.add(cardId);
|
||||
} else {
|
||||
state.selectedRows.delete(cardId);
|
||||
}
|
||||
updateBulkBar();
|
||||
}
|
||||
|
||||
function toggleSelectAll(checked) {
|
||||
if (checked) {
|
||||
state.items.forEach(c => state.selectedRows.add(c.id));
|
||||
} else {
|
||||
state.selectedRows.clear();
|
||||
}
|
||||
// Update all checkboxes
|
||||
document.querySelectorAll('.coll-row-check').forEach(cb => {
|
||||
cb.checked = checked;
|
||||
});
|
||||
updateBulkBar();
|
||||
}
|
||||
|
||||
function selectAllRows() {
|
||||
state.items.forEach(c => state.selectedRows.add(c.id));
|
||||
document.querySelectorAll('.coll-row-check').forEach(cb => cb.checked = true);
|
||||
const selectAllCb = document.getElementById('coll-select-all');
|
||||
if (selectAllCb) selectAllCb.checked = true;
|
||||
updateBulkBar();
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
state.selectedRows.clear();
|
||||
document.querySelectorAll('.coll-row-check').forEach(cb => cb.checked = false);
|
||||
const selectAllCb = document.getElementById('coll-select-all');
|
||||
if (selectAllCb) selectAllCb.checked = false;
|
||||
updateBulkBar();
|
||||
}
|
||||
|
||||
function updateBulkBar() {
|
||||
const bar = document.getElementById('coll-bulk-bar');
|
||||
const countEl = document.getElementById('coll-bulk-count');
|
||||
const n = state.selectedRows.size;
|
||||
if (bar) bar.style.display = n > 0 ? 'flex' : 'none';
|
||||
if (countEl) countEl.textContent = `${n} selected`;
|
||||
}
|
||||
|
||||
async function bulkAdjustQuantity(delta) {
|
||||
if (state.selectedRows.size === 0) return;
|
||||
const ids = [...state.selectedRows];
|
||||
const btn = event.target;
|
||||
if (btn) btn.disabled = true;
|
||||
let successCount = 0;
|
||||
for (const id of ids) {
|
||||
const card = state.items.find(c => c.id === id);
|
||||
if (!card) continue;
|
||||
const newQty = Math.max(0, (card.quantity ?? 1) + delta);
|
||||
try {
|
||||
await patchCard(id, { quantity: newQty });
|
||||
card.quantity = newQty;
|
||||
successCount++;
|
||||
} catch (e) {
|
||||
console.warn('[Bulk] Failed to update card', id, e);
|
||||
}
|
||||
}
|
||||
if (btn) btn.disabled = false;
|
||||
renderTable();
|
||||
applyColumnVisibility();
|
||||
}
|
||||
|
||||
async function bulkSetQuantity() {
|
||||
if (state.selectedRows.size === 0) return;
|
||||
const input = document.getElementById('coll-bulk-qty-input');
|
||||
const newQty = input ? Math.max(0, parseInt(input.value, 10) || 0) : 0;
|
||||
const ids = [...state.selectedRows];
|
||||
const btn = document.querySelector('.coll-bulk-set-btn');
|
||||
if (btn) btn.disabled = true;
|
||||
for (const id of ids) {
|
||||
const card = state.items.find(c => c.id === id);
|
||||
if (!card) continue;
|
||||
try {
|
||||
await patchCard(id, { quantity: newQty });
|
||||
card.quantity = newQty;
|
||||
} catch (e) {
|
||||
console.warn('[Bulk] Failed to set card', id, e);
|
||||
}
|
||||
}
|
||||
if (btn) btn.disabled = false;
|
||||
renderTable();
|
||||
applyColumnVisibility();
|
||||
}
|
||||
|
||||
// ── Event Binding ────────────────────────────────────────
|
||||
|
||||
function bindEvents() {
|
||||
@@ -3081,5 +3187,12 @@ const Collection = (() => {
|
||||
openColumnPanel,
|
||||
closeColumnPanel,
|
||||
resetColumns,
|
||||
// Bulk selection & quantity
|
||||
toggleRowSelect,
|
||||
toggleSelectAll,
|
||||
selectAllRows,
|
||||
clearSelection,
|
||||
bulkAdjustQuantity,
|
||||
bulkSetQuantity,
|
||||
};
|
||||
})();
|
||||
+2
-2
@@ -284,7 +284,7 @@
|
||||
</div>
|
||||
<div class="deck-ai-loading" id="ai-research-loading" style="display:none">
|
||||
<div class="deck-ai-spinner"></div>
|
||||
<span>Analyzing with Perplexity AI...</span>
|
||||
<span>Analyzing with Claude Opus...</span>
|
||||
</div>
|
||||
<div class="deck-ai-results" id="ai-research-results" style="display:none"></div>
|
||||
</div>
|
||||
@@ -329,7 +329,7 @@
|
||||
</div>
|
||||
<div class="deck-ai-loading" id="ai-gen-loading" style="display:none">
|
||||
<div class="deck-ai-spinner"></div>
|
||||
<span>Generating deck with Perplexity AI...</span>
|
||||
<span>Generating deck with Claude Opus...</span>
|
||||
</div>
|
||||
<div class="deck-ai-results" id="ai-gen-results" style="display:none"></div>
|
||||
</div>
|
||||
|
||||
+34
-50
@@ -62,9 +62,13 @@
|
||||
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</label>
|
||||
<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>
|
||||
<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>
|
||||
@@ -105,7 +109,7 @@
|
||||
min="0"
|
||||
step="25"
|
||||
/>
|
||||
<select class="dg-select" id="dg-budget-mode">
|
||||
<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.">
|
||||
<option value="total">Total Deck $</option>
|
||||
<option value="per_card">Per Card Max $</option>
|
||||
</select>
|
||||
@@ -120,25 +124,34 @@
|
||||
</div>
|
||||
<div class="dg-panel-body">
|
||||
<label class="dg-toggle-row">
|
||||
<span class="dg-toggle-label">Use my collection</span>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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</label>
|
||||
<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>
|
||||
<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>
|
||||
@@ -160,12 +173,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>
|
||||
|
||||
@@ -175,9 +188,9 @@
|
||||
<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 Perplexity AI with structured output.</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>
|
||||
|
||||
<!-- Loading State -->
|
||||
@@ -185,47 +198,18 @@
|
||||
<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>
|
||||
<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 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>
|
||||
|
||||
<!-- 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 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>
|
||||
|
||||
<!-- Results placeholder — rest generated dynamically by deckgenerator.js -->
|
||||
<div class="dg-results" id="dg-results" style="display:none"></div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
+44
-12
@@ -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,43 +59,70 @@
|
||||
<div class="train-controls">
|
||||
<div class="param-grid form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="param-iterations">Max Generations</label>
|
||||
<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>
|
||||
<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</label>
|
||||
<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>
|
||||
<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</label>
|
||||
<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>
|
||||
<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</label>
|
||||
<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>
|
||||
<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</label>
|
||||
<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>
|
||||
<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</label>
|
||||
<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>
|
||||
<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</label>
|
||||
<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>
|
||||
<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</label>
|
||||
<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>
|
||||
<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</label>
|
||||
<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>
|
||||
<select id="param-opponent" class="form-select">
|
||||
<option value="heuristic">Heuristic</option>
|
||||
<option value="random">Random</option>
|
||||
@@ -103,7 +130,10 @@
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="param-playstyle">Playstyle</label>
|
||||
<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>
|
||||
<select id="param-playstyle" class="form-select">
|
||||
<option value="midrange">Midrange</option>
|
||||
<option value="aggro">Aggro</option>
|
||||
@@ -164,7 +194,9 @@
|
||||
<!-- ELO Rating Chart -->
|
||||
<section class="panel" id="elo-panel">
|
||||
<div class="panel-header elo-panel-header">
|
||||
<h2>ELO Ratings</h2>
|
||||
<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>
|
||||
<button id="btn-run-elo" class="btn btn-sm btn-secondary" onclick="runEloTournament()">
|
||||
<span class="btn-icon">🏆</span> Run Tournament
|
||||
</button>
|
||||
|
||||
@@ -864,3 +864,92 @@ 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);
|
||||
}
|
||||
+25
-6
@@ -28,20 +28,27 @@
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group sim-field-auto">
|
||||
<label class="form-label" for="sim-opponent-select">Opponent</label>
|
||||
<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>
|
||||
<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</label>
|
||||
<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>
|
||||
<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">
|
||||
@@ -61,19 +68,31 @@
|
||||
</div>
|
||||
<div class="deepseek-config-row">
|
||||
<div class="form-group sim-field-grow">
|
||||
<label class="form-label" for="ds-api-base">API Endpoint</label>
|
||||
<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>
|
||||
<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</label>
|
||||
<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>
|
||||
<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</label>
|
||||
<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>
|
||||
<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)</label>
|
||||
<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>
|
||||
<input type="number" id="ds-timeout" class="form-input sim-num-input" value="10" min="1" max="60" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+179
@@ -358,3 +358,182 @@
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════
|
||||
Intro / Getting Started Banner
|
||||
══════════════════════════════════════════════════════════════ */
|
||||
|
||||
.intro-banner {
|
||||
background: color-mix(in srgb, var(--accent) 8%, var(--surface-1));
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 30%, var(--border));
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--sp-4) var(--sp-5);
|
||||
margin-bottom: var(--sp-5);
|
||||
}
|
||||
|
||||
.intro-banner.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.intro-banner-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
margin-bottom: var(--sp-3);
|
||||
font-size: 15px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.intro-banner-icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.intro-banner-header strong {
|
||||
flex: 1;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.intro-banner-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
line-height: 1;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.intro-banner-close:hover {
|
||||
color: var(--text);
|
||||
background: var(--surface-3);
|
||||
}
|
||||
|
||||
.intro-banner-lead {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 var(--sp-3) 0;
|
||||
}
|
||||
|
||||
.intro-banner-steps {
|
||||
margin: 0 0 var(--sp-4) 0;
|
||||
padding-left: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.intro-banner-steps li {
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.intro-banner-steps li strong {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.intro-banner-steps li em {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.intro-banner-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-top: var(--sp-3);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════
|
||||
Tooltip System (.tip-icon + data-tip)
|
||||
══════════════════════════════════════════════════════════════ */
|
||||
|
||||
.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;
|
||||
/* prevent tooltip from triggering form-label hover states */
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.tip-icon:hover {
|
||||
background: var(--accent-dim);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* The tooltip bubble itself */
|
||||
.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;
|
||||
/* reset inherited label styles */
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
/* Arrow pointing down */
|
||||
.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;
|
||||
}
|
||||
|
||||
/* Flip tooltip below when near the top of the viewport */
|
||||
.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);
|
||||
}
|
||||
+71
-13
@@ -18,9 +18,35 @@
|
||||
<h1>ML Training Dashboard</h1>
|
||||
<p>Train, evaluate, and deploy the AI opponent policy network</p>
|
||||
</div>
|
||||
<button id="btn-refresh-data" class="btn btn-secondary" onclick="refreshAll()" title="Refresh training data status">
|
||||
<span class="btn-icon" id="refresh-icon">↻</span> Refresh
|
||||
</button>
|
||||
<div style="display:flex;gap:8px;align-items:center;">
|
||||
<button class="btn btn-ghost btn-sm" onclick="showIntroBanner()" title="Show getting started guide">❓ Help</button>
|
||||
<button id="btn-refresh-data" class="btn btn-secondary" onclick="refreshAll()" title="Refresh training data status">
|
||||
<span class="btn-icon" id="refresh-icon">↻</span> Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Intro / Getting Started Banner -->
|
||||
<div id="training-intro-banner" class="intro-banner hidden">
|
||||
<div class="intro-banner-header">
|
||||
<span class="intro-banner-icon">🎓</span>
|
||||
<strong>Getting Started with ML Training</strong>
|
||||
<button class="intro-banner-close" onclick="dismissIntroBanner()" title="Dismiss">✕</button>
|
||||
</div>
|
||||
<p class="intro-banner-lead">This page trains a neural network to play Commander decisions. Follow these steps in order on your first run:</p>
|
||||
<ol class="intro-banner-steps">
|
||||
<li><strong>Run batch simulations</strong> with ML logging enabled (from the Simulator page) to generate decision log files.</li>
|
||||
<li><strong>Start Training</strong> below — leave “Rebuild dataset” checked so it parses your logs into a dataset automatically.</li>
|
||||
<li>Check <strong>Evaluation Results</strong> after training. Aim for >60% test accuracy before moving on.</li>
|
||||
<li><em>(Optional)</em> Run <strong>PPO reinforcement learning</strong> to fine-tune the policy via self-play. Only recommended after a solid supervised baseline.</li>
|
||||
<li>Run a <strong>Tournament</strong> to benchmark your model against heuristic and random agents and get a win-rate score.</li>
|
||||
</ol>
|
||||
<div class="intro-banner-footer">
|
||||
<label class="form-checkbox" style="font-size:12px;">
|
||||
<input type="checkbox" id="intro-dont-show" /> Don’t show this again
|
||||
</label>
|
||||
<button class="btn btn-primary btn-sm" onclick="dismissIntroBanner()">Got it</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status Overview Cards -->
|
||||
@@ -59,19 +85,31 @@
|
||||
<div class="train-controls">
|
||||
<div class="param-grid form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="param-epochs">Epochs</label>
|
||||
<label class="form-label" for="param-epochs">
|
||||
Epochs
|
||||
<span class="tip-icon" data-tip="Number of full passes over the training dataset. More epochs improve fit but risk overfitting. 50 is a safe starting point.">?</span>
|
||||
</label>
|
||||
<input type="number" id="param-epochs" class="form-input" value="50" min="1" max="500" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="param-lr">Learning Rate</label>
|
||||
<label class="form-label" for="param-lr">
|
||||
Learning Rate
|
||||
<span class="tip-icon" data-tip="Controls how large each weight update is. Too high causes instability; too low is very slow. 0.001 is a reliable default.">?</span>
|
||||
</label>
|
||||
<input type="number" id="param-lr" class="form-input" value="0.001" step="0.0001" min="0.00001" max="0.1" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="param-batch">Batch Size</label>
|
||||
<label class="form-label" for="param-batch">
|
||||
Batch Size
|
||||
<span class="tip-icon" data-tip="Samples processed per gradient update. Larger batches are faster on GPU but use more VRAM. 256 is recommended for most setups.">?</span>
|
||||
</label>
|
||||
<input type="number" id="param-batch" class="form-input" value="256" min="16" max="2048" step="16" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="param-patience">Early Stop Patience</label>
|
||||
<label class="form-label" for="param-patience">
|
||||
Early Stop Patience
|
||||
<span class="tip-icon" data-tip="Stops training if validation loss doesn't improve for this many epochs in a row. Prevents wasted compute. 10 is a sensible default.">?</span>
|
||||
</label>
|
||||
<input type="number" id="param-patience" class="form-input" value="10" min="3" max="100" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -79,6 +117,7 @@
|
||||
<label class="form-checkbox checkbox-label">
|
||||
<input type="checkbox" id="param-rebuild" checked />
|
||||
Rebuild dataset from decision logs
|
||||
<span class="tip-icon" data-tip="Re-parses all .jsonl decision log files into a fresh dataset. Uncheck to reuse the last built dataset and save time on re-runs.">?</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="train-actions">
|
||||
@@ -132,19 +171,31 @@
|
||||
<div class="train-controls">
|
||||
<div class="param-grid form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="ppo-iterations">Iterations</label>
|
||||
<label class="form-label" for="ppo-iterations">
|
||||
Iterations
|
||||
<span class="tip-icon" data-tip="Each iteration runs N episodes, computes returns, and updates the policy. 100 iterations is a typical starting point.">?</span>
|
||||
</label>
|
||||
<input type="number" id="ppo-iterations" class="form-input" value="100" min="1" max="1000" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="ppo-episodes">Episodes/Iter</label>
|
||||
<label class="form-label" for="ppo-episodes">
|
||||
Episodes/Iter
|
||||
<span class="tip-icon" data-tip="Number of full game simulations per PPO update step. More episodes produce more stable gradients but slow down each iteration.">?</span>
|
||||
</label>
|
||||
<input type="number" id="ppo-episodes" class="form-input" value="64" min="8" max="512" step="8" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="ppo-lr">Learning Rate</label>
|
||||
<label class="form-label" for="ppo-lr">
|
||||
Learning Rate
|
||||
<span class="tip-icon" data-tip="PPO learning rate. Lower than supervised training is usually better — 0.0003 is a standard RL starting value.">?</span>
|
||||
</label>
|
||||
<input type="number" id="ppo-lr" class="form-input" value="0.0003" step="0.0001" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="ppo-opponent">Opponent</label>
|
||||
<label class="form-label" for="ppo-opponent">
|
||||
Opponent
|
||||
<span class="tip-icon" data-tip="Who the agent plays against during training. Heuristic is the best first opponent. Self-Play is more advanced and can be unstable early on.">?</span>
|
||||
</label>
|
||||
<select id="ppo-opponent" class="form-select">
|
||||
<option value="heuristic">Heuristic</option>
|
||||
<option value="random">Random</option>
|
||||
@@ -156,6 +207,7 @@
|
||||
<label class="form-checkbox checkbox-label">
|
||||
<input type="checkbox" id="ppo-load-supervised" />
|
||||
Initialize from supervised checkpoint (warm start)
|
||||
<span class="tip-icon" data-tip="Strongly recommended. Starts PPO from your best supervised model instead of random weights. PPO from scratch is very slow and unstable.">?</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="train-actions">
|
||||
@@ -184,11 +236,17 @@
|
||||
<div class="train-controls">
|
||||
<div class="param-grid form-row tourney-param-grid">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="tourney-episodes">Games per Matchup</label>
|
||||
<label class="form-label" for="tourney-episodes">
|
||||
Games per Matchup
|
||||
<span class="tip-icon" data-tip="Number of games played per policy pair. Use at least 50–100 games for a statistically reliable win rate.">?</span>
|
||||
</label>
|
||||
<input type="number" id="tourney-episodes" class="form-input" value="50" min="10" max="500" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="tourney-style">Playstyle</label>
|
||||
<label class="form-label" for="tourney-style">
|
||||
Playstyle
|
||||
<span class="tip-icon" data-tip="The deck archetype used during tournament games. Try Midrange first — it's the most balanced and representative of typical Commander gameplay.">?</span>
|
||||
</label>
|
||||
<select id="tourney-style" class="form-select">
|
||||
<option value="midrange">Midrange</option>
|
||||
<option value="aggro">Aggro</option>
|
||||
|
||||
@@ -19,6 +19,8 @@ let isTraining = false;
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
refreshDataStatus();
|
||||
refreshTrainingStatus();
|
||||
initIntroBanner();
|
||||
initTooltipFlip();
|
||||
});
|
||||
|
||||
async function refreshAll() {
|
||||
@@ -36,6 +38,51 @@ async function refreshAll() {
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// Intro Banner
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
const INTRO_STORAGE_KEY = 'trainingIntroSeen';
|
||||
|
||||
function initIntroBanner() {
|
||||
const seen = localStorage.getItem(INTRO_STORAGE_KEY);
|
||||
if (!seen) {
|
||||
showIntroBanner();
|
||||
}
|
||||
}
|
||||
|
||||
function showIntroBanner() {
|
||||
const banner = document.getElementById('training-intro-banner');
|
||||
if (banner) banner.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function dismissIntroBanner() {
|
||||
const banner = document.getElementById('training-intro-banner');
|
||||
const dontShow = document.getElementById('intro-dont-show');
|
||||
if (banner) banner.classList.add('hidden');
|
||||
if (dontShow && dontShow.checked) {
|
||||
localStorage.setItem(INTRO_STORAGE_KEY, '1');
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// Tooltip flip — prevent overflow at top
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
function initTooltipFlip() {
|
||||
document.querySelectorAll('.tip-icon').forEach(el => {
|
||||
el.addEventListener('mouseenter', () => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
// If less than 120px above the element, flip tooltip below
|
||||
if (rect.top < 120) {
|
||||
el.classList.add('tip-below');
|
||||
} else {
|
||||
el.classList.remove('tip-below');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// Data Status
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""WebSocket module for real-time game state streaming to Unity."""
|
||||
@@ -0,0 +1,165 @@
|
||||
"""WebSocket connection manager for game-state streaming.
|
||||
|
||||
Manages per-game-session WebSocket connections so the server can
|
||||
push state deltas to every connected Unity client in real time.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List, Optional, Set
|
||||
|
||||
from fastapi import WebSocket, WebSocketDisconnect
|
||||
|
||||
log = logging.getLogger("ws.connection_manager")
|
||||
|
||||
|
||||
@dataclass
|
||||
class ClientInfo:
|
||||
"""Metadata for a single connected WebSocket client."""
|
||||
ws: WebSocket
|
||||
client_id: str
|
||||
game_id: str
|
||||
connected_at: float = field(default_factory=time.time)
|
||||
last_ping: float = field(default_factory=time.time)
|
||||
messages_sent: int = 0
|
||||
|
||||
|
||||
class ConnectionManager:
|
||||
"""Manages WebSocket connections grouped by game_id.
|
||||
|
||||
Usage:
|
||||
manager = ConnectionManager()
|
||||
await manager.connect(websocket, game_id, client_id)
|
||||
await manager.broadcast_to_game(game_id, payload)
|
||||
manager.disconnect(websocket, game_id)
|
||||
"""
|
||||
|
||||
def __init__(self, heartbeat_interval: float = 30.0):
|
||||
# game_id -> set of ClientInfo
|
||||
self._games: Dict[str, Dict[str, ClientInfo]] = {}
|
||||
self._heartbeat_interval = heartbeat_interval
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
# ── Connect / Disconnect ───────────────────────────────
|
||||
|
||||
async def connect(
|
||||
self, ws: WebSocket, game_id: str, client_id: str
|
||||
) -> ClientInfo:
|
||||
"""Accept and register a WebSocket connection."""
|
||||
await ws.accept()
|
||||
client = ClientInfo(ws=ws, client_id=client_id, game_id=game_id)
|
||||
async with self._lock:
|
||||
if game_id not in self._games:
|
||||
self._games[game_id] = {}
|
||||
self._games[game_id][client_id] = client
|
||||
log.info(
|
||||
"WS connected: client=%s game=%s (total=%d)",
|
||||
client_id, game_id, len(self._games[game_id]),
|
||||
)
|
||||
return client
|
||||
|
||||
async def disconnect(self, game_id: str, client_id: str) -> None:
|
||||
"""Remove a client from the game session."""
|
||||
async with self._lock:
|
||||
game = self._games.get(game_id)
|
||||
if game and client_id in game:
|
||||
del game[client_id]
|
||||
if not game:
|
||||
del self._games[game_id]
|
||||
log.info("WS disconnected: client=%s game=%s", client_id, game_id)
|
||||
|
||||
# ── Broadcasting ───────────────────────────────────────
|
||||
|
||||
async def broadcast_to_game(
|
||||
self, game_id: str, payload: dict
|
||||
) -> int:
|
||||
"""Send a JSON payload to every client in a game session.
|
||||
|
||||
Returns the number of clients that received the message.
|
||||
Silently removes clients whose connections are broken.
|
||||
"""
|
||||
async with self._lock:
|
||||
game = self._games.get(game_id)
|
||||
if not game:
|
||||
return 0
|
||||
clients = list(game.values())
|
||||
|
||||
sent = 0
|
||||
dead: List[str] = []
|
||||
for client in clients:
|
||||
try:
|
||||
await client.ws.send_json(payload)
|
||||
client.messages_sent += 1
|
||||
sent += 1
|
||||
except Exception:
|
||||
dead.append(client.client_id)
|
||||
|
||||
# Clean up broken connections
|
||||
if dead:
|
||||
async with self._lock:
|
||||
game = self._games.get(game_id)
|
||||
if game:
|
||||
for cid in dead:
|
||||
game.pop(cid, None)
|
||||
if not game:
|
||||
del self._games[game_id]
|
||||
log.warning(
|
||||
"Removed %d dead connection(s) from game %s",
|
||||
len(dead), game_id,
|
||||
)
|
||||
return sent
|
||||
|
||||
async def send_to_client(
|
||||
self, game_id: str, client_id: str, payload: dict
|
||||
) -> bool:
|
||||
"""Send a JSON payload to a specific client."""
|
||||
async with self._lock:
|
||||
game = self._games.get(game_id)
|
||||
if not game:
|
||||
return False
|
||||
client = game.get(client_id)
|
||||
if not client:
|
||||
return False
|
||||
try:
|
||||
await client.ws.send_json(payload)
|
||||
client.messages_sent += 1
|
||||
return True
|
||||
except Exception:
|
||||
await self.disconnect(game_id, client_id)
|
||||
return False
|
||||
|
||||
# ── Queries ────────────────────────────────────────────
|
||||
|
||||
def get_game_clients(self, game_id: str) -> List[str]:
|
||||
"""Return client IDs connected to a game."""
|
||||
game = self._games.get(game_id, {})
|
||||
return list(game.keys())
|
||||
|
||||
def active_games(self) -> List[str]:
|
||||
"""Return all game IDs with active connections."""
|
||||
return list(self._games.keys())
|
||||
|
||||
@property
|
||||
def total_connections(self) -> int:
|
||||
return sum(len(g) for g in self._games.values())
|
||||
|
||||
def stats(self) -> dict:
|
||||
"""Return connection statistics."""
|
||||
return {
|
||||
"active_games": len(self._games),
|
||||
"total_connections": self.total_connections,
|
||||
"games": {
|
||||
gid: {
|
||||
"clients": len(clients),
|
||||
"client_ids": list(clients.keys()),
|
||||
}
|
||||
for gid, clients in self._games.items()
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# Module-level singleton
|
||||
game_manager = ConnectionManager()
|
||||
@@ -0,0 +1,211 @@
|
||||
"""Game-state delta engine.
|
||||
|
||||
Compares successive game-state snapshots and produces minimal
|
||||
JSON deltas so that Unity only receives what changed — not
|
||||
the full 4-player board every tick.
|
||||
|
||||
Delta format sent over WebSocket:
|
||||
{
|
||||
"type": "delta" | "snapshot" | "decision" | "game_over",
|
||||
"seq": <monotonic int>,
|
||||
"game_id": "...",
|
||||
"turn": 5,
|
||||
"phase": "main_1",
|
||||
"timestamp": 1711400000.0,
|
||||
"changes": [
|
||||
{"path": "players.0.life", "op": "replace", "value": 37},
|
||||
{"path": "players.1.battlefield", "op": "add", "value": {"name": "Sol Ring", ...}},
|
||||
{"path": "stack", "op": "replace", "value": [...]},
|
||||
]
|
||||
}
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
log = logging.getLogger("ws.game_state")
|
||||
|
||||
|
||||
def _deep_diff(
|
||||
old: Any, new: Any, path: str = ""
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Recursively diff two JSON-serialisable structures.
|
||||
|
||||
Returns a list of RFC-6902-style patch operations:
|
||||
{"op": "replace"|"add"|"remove", "path": "...", "value": ...}
|
||||
"""
|
||||
changes: List[Dict[str, Any]] = []
|
||||
|
||||
if old is None and new is not None:
|
||||
return [{"op": "add", "path": path, "value": new}]
|
||||
if old is not None and new is None:
|
||||
return [{"op": "remove", "path": path}]
|
||||
if type(old) != type(new):
|
||||
return [{"op": "replace", "path": path, "value": new}]
|
||||
|
||||
if isinstance(new, dict):
|
||||
all_keys = set(list(old.keys()) + list(new.keys()))
|
||||
for key in sorted(all_keys):
|
||||
child_path = f"{path}.{key}" if path else key
|
||||
if key not in old:
|
||||
changes.append({"op": "add", "path": child_path, "value": new[key]})
|
||||
elif key not in new:
|
||||
changes.append({"op": "remove", "path": child_path})
|
||||
else:
|
||||
changes.extend(_deep_diff(old[key], new[key], child_path))
|
||||
return changes
|
||||
|
||||
if isinstance(new, list):
|
||||
# For lists (battlefield, hand, graveyard, stack) we do a
|
||||
# simple length+element comparison. Zone lists change
|
||||
# frequently and a full LCS diff is overkill for game state.
|
||||
if old == new:
|
||||
return []
|
||||
# If the list is short or changed significantly, send whole list
|
||||
if len(old) != len(new) or _list_diff_ratio(old, new) > 0.5:
|
||||
return [{"op": "replace", "path": path, "value": new}]
|
||||
# Element-wise diff for same-length lists with few changes
|
||||
for i, (o, n) in enumerate(zip(old, new)):
|
||||
child_path = f"{path}.{i}"
|
||||
changes.extend(_deep_diff(o, n, child_path))
|
||||
return changes
|
||||
|
||||
# Scalar comparison
|
||||
if old != new:
|
||||
return [{"op": "replace", "path": path, "value": new}]
|
||||
return []
|
||||
|
||||
|
||||
def _list_diff_ratio(old: list, new: list) -> float:
|
||||
"""Fraction of elements that differ between two same-length lists."""
|
||||
if not old:
|
||||
return 0.0
|
||||
diffs = sum(1 for a, b in zip(old, new) if a != b)
|
||||
return diffs / len(old)
|
||||
|
||||
|
||||
class GameStateTracker:
|
||||
"""Tracks per-game state and emits deltas on each update.
|
||||
|
||||
Usage:
|
||||
tracker = GameStateTracker(game_id)
|
||||
delta = tracker.update(new_snapshot)
|
||||
# delta is a dict ready to send via WebSocket
|
||||
"""
|
||||
|
||||
def __init__(self, game_id: str):
|
||||
self.game_id = game_id
|
||||
self._last_snapshot: Optional[Dict] = None
|
||||
self._seq: int = 0
|
||||
self._created_at: float = time.time()
|
||||
|
||||
def update(self, snapshot: Dict) -> Dict:
|
||||
"""Compare snapshot with previous state, return delta message.
|
||||
|
||||
First call returns a full snapshot message (type="snapshot").
|
||||
Subsequent calls return delta messages (type="delta").
|
||||
"""
|
||||
self._seq += 1
|
||||
now = time.time()
|
||||
|
||||
if self._last_snapshot is None:
|
||||
# First update — send full snapshot
|
||||
self._last_snapshot = copy.deepcopy(snapshot)
|
||||
return {
|
||||
"type": "snapshot",
|
||||
"seq": self._seq,
|
||||
"game_id": self.game_id,
|
||||
"turn": snapshot.get("turn", 0),
|
||||
"phase": snapshot.get("phase", ""),
|
||||
"timestamp": now,
|
||||
"state": snapshot,
|
||||
}
|
||||
|
||||
# Compute delta
|
||||
changes = _deep_diff(self._last_snapshot, snapshot)
|
||||
self._last_snapshot = copy.deepcopy(snapshot)
|
||||
|
||||
if not changes:
|
||||
return {
|
||||
"type": "heartbeat",
|
||||
"seq": self._seq,
|
||||
"game_id": self.game_id,
|
||||
"timestamp": now,
|
||||
}
|
||||
|
||||
return {
|
||||
"type": "delta",
|
||||
"seq": self._seq,
|
||||
"game_id": self.game_id,
|
||||
"turn": snapshot.get("turn", 0),
|
||||
"phase": snapshot.get("phase", ""),
|
||||
"timestamp": now,
|
||||
"changes": changes,
|
||||
}
|
||||
|
||||
def make_decision_event(
|
||||
self,
|
||||
action: str,
|
||||
action_index: int,
|
||||
confidence: float,
|
||||
probabilities: Optional[Dict[str, float]] = None,
|
||||
inference_ms: float = 0.0,
|
||||
) -> Dict:
|
||||
"""Build a decision event message for the Unity client."""
|
||||
self._seq += 1
|
||||
return {
|
||||
"type": "decision",
|
||||
"seq": self._seq,
|
||||
"game_id": self.game_id,
|
||||
"timestamp": time.time(),
|
||||
"action": action,
|
||||
"action_index": action_index,
|
||||
"confidence": confidence,
|
||||
"probabilities": probabilities or {},
|
||||
"inference_ms": inference_ms,
|
||||
}
|
||||
|
||||
def make_game_over_event(
|
||||
self,
|
||||
winner_seat: int,
|
||||
reason: str = "",
|
||||
turns_played: int = 0,
|
||||
) -> Dict:
|
||||
"""Build a game-over event message."""
|
||||
self._seq += 1
|
||||
return {
|
||||
"type": "game_over",
|
||||
"seq": self._seq,
|
||||
"game_id": self.game_id,
|
||||
"timestamp": time.time(),
|
||||
"winner_seat": winner_seat,
|
||||
"reason": reason,
|
||||
"turns_played": turns_played,
|
||||
}
|
||||
|
||||
@property
|
||||
def sequence(self) -> int:
|
||||
return self._seq
|
||||
|
||||
@property
|
||||
def has_baseline(self) -> bool:
|
||||
return self._last_snapshot is not None
|
||||
|
||||
|
||||
# Per-game tracker registry
|
||||
_trackers: Dict[str, GameStateTracker] = {}
|
||||
|
||||
|
||||
def get_tracker(game_id: str) -> GameStateTracker:
|
||||
"""Get or create a state tracker for a game."""
|
||||
if game_id not in _trackers:
|
||||
_trackers[game_id] = GameStateTracker(game_id)
|
||||
return _trackers[game_id]
|
||||
|
||||
|
||||
def remove_tracker(game_id: str) -> None:
|
||||
"""Remove a game tracker (called on game end)."""
|
||||
_trackers.pop(game_id, None)
|
||||
Reference in new issue
Block a user