diff --git a/coach/llm_client.py b/coach/llm_client.py
index 30ee6ab..dec72d9 100644
--- a/coach/llm_client.py
+++ b/coach/llm_client.py
@@ -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 ... reasoning blocks from LLM output."""
+ """Remove DeepSeek-R1 ... reasoning blocks."""
import re
- # Remove all ... blocks (greedy, handles multiline)
- cleaned = re.sub(r'.*?', '', text, flags=re.DOTALL)
- return cleaned.strip()
+ return re.sub(r'.*?', '', 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 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 ... 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,
diff --git a/lab_api.py b/lab_api.py
index 60ecbbf..0db94cc 100644
--- a/lab_api.py
+++ b/lab_api.py
@@ -84,6 +84,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"
diff --git a/requirements.txt b/requirements.txt
index ab16598..c5a133f 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -8,6 +8,7 @@ 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
diff --git a/routes/coach.py b/routes/coach.py
index 3e537ec..89ed238 100644
--- a/routes/coach.py
+++ b/routes/coach.py
@@ -1,6 +1,6 @@
"""
routes/coach.py
-===============
+================
LLM-powered deck coaching endpoints:
GET /api/coach/status
GET /api/coach/decks
@@ -19,6 +19,9 @@ Globals:
_coach_service, _coach_embeddings, _coach_llm
_deck_gen_v3, _deck_gen_v3_error
init_coach_service(), _build_deck_report_from_db()
+
+Async-safety: every DB and HTTP call in this module uses
+async helpers so nothing blocks the FastAPI event loop.
"""
from __future__ import annotations
@@ -34,672 +37,496 @@ from fastapi.responses import StreamingResponse
from models.requests import CoachRequestBody, CoachChatMessage, CoachChatRequest, CoachApplyRequest, CoachGoalsRequest
from models.state import CFG
-from services.database import _get_db_conn
+from services.async_db import (
+ async_get_db, async_fetchall, async_fetchone,
+ async_execute, async_execute_commit, async_commit,
+)
from services.deck_service import _compute_deck_analysis
from services.logging import log_coach, log_deckgen
import httpx
+
from coach.config import COACH_PROVIDER, ANTHROPIC_API_KEY, ANTHROPIC_MODEL, DECK_GEN_PROVIDER, DECK_GEN_MODEL
LLM_TIMEOUT = 120 # seconds
router = APIRouter(tags=["coach"])
-
-# ════════════════════════════════════════════════════════════
-# Coach Service (LLM-powered deck coaching)
-# ════════════════════════════════════════════════════════════
-
# Global coach instances (initialized at startup)
_coach_service = None
_coach_embeddings = None
_coach_llm = None
-_deck_gen_v3 = None # V3 Deck Generator (Anthropic Claude structured output)
-_deck_gen_v3_error = None # Error message if V3 init failed
+_deck_gen_v3 = None
+_deck_gen_v3_error = None
def init_coach_service():
- """Initialize the coach service with LLM client and embeddings."""
- global _coach_service, _coach_embeddings, _coach_llm, _deck_gen_v3, _deck_gen_v3_error
+ """Initialize the coach service with LLM client and embeddings."""
+ global _coach_service, _coach_embeddings, _coach_llm, _deck_gen_v3, _deck_gen_v3_error
+ try:
+ from coach.llm_client import LLMClient
+ from coach.embeddings import MTGEmbeddingIndex
+ from coach.coach_service import CoachService
+ from coach.config import ensure_dirs
+ from services.database import _get_db_conn
+ ensure_dirs()
+ _coach_llm = LLMClient()
+ _coach_embeddings = MTGEmbeddingIndex()
try:
- from coach.llm_client import LLMClient
- from coach.embeddings import MTGEmbeddingIndex
- from coach.coach_service import CoachService
- from coach.config import ensure_dirs
-
- ensure_dirs()
-
- _coach_llm = LLMClient()
- _coach_embeddings = MTGEmbeddingIndex()
-
- # Try to load embeddings (non-blocking — download happens on first use)
- try:
- from coach.config import EMBEDDINGS_NPZ
- if EMBEDDINGS_NPZ.exists():
- _coach_embeddings.load()
- log_coach.info(f" Coach: Embeddings loaded ({_coach_embeddings.card_count} cards)")
- else:
- log_coach.warning(" Coach: Embeddings not yet downloaded (will download on first use)")
- except Exception as e:
- log_coach.error(f" Coach: Embeddings load failed: {e}")
-
- _coach_service = CoachService(_coach_llm, _coach_embeddings)
-
- # Log coach LLM provider
- if COACH_PROVIDER == "anthropic":
- log_coach.info(f" Coach LLM: Anthropic Claude ({ANTHROPIC_MODEL})")
- else:
- # Only probe Ollama when actually using it
- llm_status = _coach_llm.check_connection()
- if llm_status.get("connected"):
- log_coach.info(f" Coach LLM: Connected ({llm_status.get('active_model', 'unknown')})")
- else:
- log_coach.warning(f" Coach LLM: Not connected (start Ollama on localhost:11434)")
-
- # Initialize V3 Deck Generator
- _deck_gen_v3_error = None
- try:
- from src.commander_ai_lab.deck_builder.adapter import DeckBuilderAdapter
- if DECK_GEN_PROVIDER == "anthropic":
- _deck_gen_v3 = DeckBuilderAdapter(
- db_conn_factory=_get_db_conn,
- model=DECK_GEN_MODEL,
- )
- log_deckgen.info(f" Deck Gen V3: Initialized (Anthropic Claude {DECK_GEN_MODEL})")
- else:
- from coach.config import LLM_MODEL
- _deck_gen_v3 = DeckBuilderAdapter(
- db_conn_factory=_get_db_conn,
- model=LLM_MODEL,
- )
- log_deckgen.info(f" Deck Gen V3: Initialized (Ollama {LLM_MODEL})")
- except ImportError as e:
- _deck_gen_v3_error = f"Missing dependency: {e}"
- log_deckgen.error(f" Deck Gen V3: {_deck_gen_v3_error}")
- _deck_gen_v3 = None
- except Exception as e:
- _deck_gen_v3_error = str(e)
- log_deckgen.error(f" Deck Gen V3: Failed to initialize: {e}")
- _deck_gen_v3 = None
-
+ from coach.config import EMBEDDINGS_NPZ
+ if EMBEDDINGS_NPZ.exists():
+ _coach_embeddings.load()
+ log_coach.info(f" Coach: Embeddings loaded ({_coach_embeddings.card_count} cards)")
+ else:
+ log_coach.warning(" Coach: Embeddings not yet downloaded")
except Exception as e:
- log_coach.error(f" Coach: Failed to initialize: {e}\n{traceback.format_exc()}")
- _coach_service = None
+ log_coach.error(f" Coach: Embeddings load failed: {e}")
+ _coach_service = CoachService(_coach_llm, _coach_embeddings)
+ if COACH_PROVIDER == "anthropic":
+ log_coach.info(f" Coach LLM: Anthropic Claude ({ANTHROPIC_MODEL})")
+ else:
+ llm_status = _coach_llm.check_connection_sync()
+ if llm_status.get("connected"):
+ log_coach.info(f" Coach LLM: Connected ({llm_status.get('active_model', 'unknown')})")
+ else:
+ log_coach.warning(" Coach LLM: Not connected (start Ollama on localhost:11434)")
+ _deck_gen_v3_error = None
+ try:
+ from src.commander_ai_lab.deck_builder.adapter import DeckBuilderAdapter
+ if DECK_GEN_PROVIDER == "anthropic":
+ _deck_gen_v3 = DeckBuilderAdapter(db_conn_factory=_get_db_conn, model=DECK_GEN_MODEL)
+ log_deckgen.info(f" Deck Gen V3: Initialized (Anthropic Claude {DECK_GEN_MODEL})")
+ else:
+ from coach.config import LLM_MODEL
+ _deck_gen_v3 = DeckBuilderAdapter(db_conn_factory=_get_db_conn, model=LLM_MODEL)
+ log_deckgen.info(f" Deck Gen V3: Initialized (Ollama {LLM_MODEL})")
+ except ImportError as e:
+ _deck_gen_v3_error = f"Missing dependency: {e}"
+ log_deckgen.error(f" Deck Gen V3: {_deck_gen_v3_error}")
+ _deck_gen_v3 = None
+ except Exception as e:
+ _deck_gen_v3_error = str(e)
+ log_deckgen.error(f" Deck Gen V3: Failed to initialize: {e}")
+ _deck_gen_v3 = None
+ except Exception as e:
+ log_coach.error(f" Coach: Failed to initialize: {e}\n{traceback.format_exc()}")
+ _coach_service = None
@router.get("/api/coach/status")
async def coach_status():
- """Check coach subsystem health."""
- if _coach_service is None:
- return {"llmConnected": False, "embeddingsLoaded": False,
- "embeddingCards": 0, "deckReportsAvailable": 0,
- "error": "Coach service not initialized"}
- status = _coach_service.get_status()
- return status.model_dump()
+ if _coach_service is None:
+ return {"llmConnected": False, "embeddingsLoaded": False, "embeddingCards": 0, "deckReportsAvailable": 0, "error": "Coach service not initialized"}
+ status = _coach_service.get_status()
+ return status.model_dump()
@router.get("/api/coach/decks")
async def coach_list_decks():
- """List all decks available for coaching (from deck builder DB)."""
- conn = _get_db_conn()
- rows = conn.execute(
- """SELECT d.id as deck_id, d.name as deck_name, d.commander_name,
- COUNT(dc.id) as card_count
- FROM decks d
- LEFT JOIN deck_cards dc ON dc.deck_id = d.id
- GROUP BY d.id
- ORDER BY d.name"""
- ).fetchall()
-
- # Also get report availability from coach service
- report_ids = set()
- if _coach_service:
- report_ids = set(_coach_service.list_deck_reports())
-
- decks = []
- for r in rows:
- deck_name = r["deck_name"]
- has_report = any(
- deck_name.lower().replace(" ", "-") == rid.lower() or
- deck_name.lower() == rid.lower()
- for rid in report_ids
- )
- decks.append({
- "deck_id": r["deck_id"],
- "deck_name": r["deck_name"],
- "commander": r["commander_name"] or "",
- "card_count": r["card_count"],
- "has_report": has_report,
- "report_count": 1 if has_report else 0,
- "last_report_date": None,
- })
- return decks
+ """List all decks available for coaching (async DB)."""
+ rows = await async_fetchall(
+ """SELECT d.id as deck_id, d.name as deck_name, d.commander_name,
+ COUNT(dc.id) as card_count
+ FROM decks d LEFT JOIN deck_cards dc ON dc.deck_id = d.id
+ GROUP BY d.id ORDER BY d.name"""
+ )
+ report_ids = set()
+ if _coach_service:
+ report_ids = set(_coach_service.list_deck_reports())
+ decks = []
+ for r in rows:
+ deck_name = r["deck_name"]
+ has_report = any(
+ deck_name.lower().replace(" ", "-") == rid.lower()
+ or deck_name.lower() == rid.lower()
+ for rid in report_ids
+ )
+ decks.append({
+ "deck_id": r["deck_id"], "deck_name": r["deck_name"],
+ "commander": r["commander_name"] or "",
+ "card_count": r["card_count"], "has_report": has_report,
+ "report_count": 1 if has_report else 0, "last_report_date": None,
+ })
+ return decks
@router.get("/api/coach/decks/{deck_id}/report")
async def coach_get_report(deck_id: str):
- """Get the latest DeckReport for a deck."""
- if _coach_service is None:
- raise HTTPException(500, "Coach service not initialized")
- report = _coach_service.load_deck_report(deck_id)
- if report is None:
- raise HTTPException(404, f"Deck report not found: {deck_id}")
- return report.model_dump()
+ if _coach_service is None:
+ raise HTTPException(500, "Coach service not initialized")
+ report = _coach_service.load_deck_report(deck_id)
+ if report is None:
+ raise HTTPException(404, f"Deck report not found: {deck_id}")
+ return report.model_dump()
@router.post("/api/coach/decks/{deck_id}")
async def coach_run_session(deck_id: str, body: CoachRequestBody = None):
- """Trigger a coaching session for a deck."""
- if _coach_service is None:
- raise HTTPException(500, "Coach service not initialized")
-
- if _coach_embeddings and not _coach_embeddings.loaded:
- try:
- _coach_embeddings.load(force_download=True)
- except Exception as e:
- log_coach.error(f" Coach: Embeddings download failed: {e}")
-
- goals = None
- if body and body.goals:
- from coach.models import CoachGoals
- try:
- goals = CoachGoals(**body.goals)
- except Exception:
- goals = None
-
- fallback_report = None
+ if _coach_service is None:
+ raise HTTPException(500, "Coach service not initialized")
+ if _coach_embeddings and not _coach_embeddings.loaded:
try:
- fallback_report = _build_deck_report_from_db(deck_id)
+ _coach_embeddings.load(force_download=True)
except Exception as e:
- log_coach.error(f" Coach: Fallback report build failed for '{deck_id}': {e}")
-
+ log_coach.error(f" Coach: Embeddings download failed: {e}")
+ goals = None
+ if body and body.goals:
+ from coach.models import CoachGoals
try:
- session = await _coach_service.run_coaching_session(deck_id, goals, fallback_report=fallback_report)
- return session.model_dump()
- except ValueError as e:
- raise HTTPException(404, str(e))
- except ConnectionError as e:
- raise HTTPException(503, f"LLM connection failed: {e}")
- except Exception as e:
- raise HTTPException(500, f"Coach session failed: {e}")
-
-
-def _build_deck_report_from_db(deck_slug: str):
- """
- Build a lightweight DeckReport from the deck builder DB when no
- simulation report exists. Allows the coach to analyze deck composition
- even without simulation data.
- """
- from coach.models import DeckReport, CardPerformance, DeckStructure
- conn = _get_db_conn()
-
- import re
- if not deck_slug or len(deck_slug) > 200:
- return None
- deck_slug = re.sub(r'[^a-zA-Z0-9 _-]', '', deck_slug)
- if not deck_slug:
- return None
-
- slug_lower = deck_slug.lower()
- slug_hyphenated = slug_lower.replace(' ', '-')
- clean_slug = re.sub(r'[^a-z0-9]+', '-', slug_lower).strip('-')
-
- if slug_lower.isdigit():
- matched_deck = conn.execute(
- "SELECT id, name, commander_name, color_identity FROM decks WHERE id = ?",
- (int(slug_lower),)
- ).fetchone()
- else:
- matched_deck = conn.execute(
- """SELECT id, name, commander_name, color_identity FROM decks
- WHERE LOWER(name) = ?
- OR LOWER(REPLACE(name, ' ', '-')) = ?
- OR LOWER(REPLACE(name, ' ', '-')) = ?
- LIMIT 1""",
- (slug_lower, slug_hyphenated, clean_slug)
- ).fetchone()
-
- if matched_deck is None:
- return None
-
- deck_id = matched_deck["id"]
- deck_name = matched_deck["name"]
- commander = matched_deck["commander_name"] or ""
-
- ci_raw = matched_deck["color_identity"] or "[]"
- try:
- color_identity = json.loads(ci_raw) if isinstance(ci_raw, str) else ci_raw
+ goals = CoachGoals(**body.goals)
except Exception:
- color_identity = []
+ goals = None
+ fallback_report = None
+ try:
+ fallback_report = await _build_deck_report_from_db(deck_id)
+ except Exception as e:
+ log_coach.error(f" Coach: Fallback report build failed for '{deck_id}': {e}")
+ try:
+ session = await _coach_service.run_coaching_session(deck_id, goals, fallback_report=fallback_report)
+ return session.model_dump()
+ except ValueError as e:
+ raise HTTPException(404, str(e))
+ except ConnectionError as e:
+ raise HTTPException(503, f"LLM connection failed: {e}")
+ except Exception as e:
+ raise HTTPException(500, f"Coach session failed: {e}")
- card_rows = conn.execute(
- """SELECT dc.card_name, dc.quantity, dc.is_commander, dc.role_tag,
- ce.type_line, ce.cmc, ce.oracle_text
- FROM deck_cards dc
- LEFT JOIN collection_entries ce ON LOWER(dc.card_name) = LOWER(ce.name)
- WHERE dc.deck_id = ?""",
- (deck_id,)
- ).fetchall()
- cards = []
- type_counts = {}
- cmc_buckets = [0] * 8
- land_count = 0
-
- for cr in card_rows:
- card_name = cr["card_name"] or ""
- type_line = cr["type_line"] or ""
- cmc = cr["cmc"] or 0
- qty = cr["quantity"] or 1
- role_tag = cr["role_tag"] or ""
-
- tags = []
- if role_tag:
- tags.append(role_tag)
- if "Land" in type_line:
- tags.append("land")
- land_count += qty
- elif "Creature" in type_line:
- tags.append("creature")
- elif "Instant" in type_line:
- tags.append("instant")
- elif "Sorcery" in type_line:
- tags.append("sorcery")
- elif "Artifact" in type_line:
- tags.append("artifact")
- elif "Enchantment" in type_line:
- tags.append("enchantment")
- elif "Planeswalker" in type_line:
- tags.append("planeswalker")
-
- for t in ["Creature", "Instant", "Sorcery", "Artifact", "Enchantment", "Planeswalker", "Land"]:
- if t in type_line:
- type_counts[t] = type_counts.get(t, 0) + qty
-
- bucket = min(int(cmc), 7)
- cmc_buckets[bucket] += qty
-
- cards.append(CardPerformance(
- name=card_name,
- drawnRate=0.0,
- castRate=0.0,
- impactScore=0.0,
- tags=tags,
- ))
-
- slug = deck_slug
- return DeckReport(
- deckId=slug,
- commander=commander,
- colorIdentity=color_identity,
- cards=cards,
- structure=DeckStructure(
- landCount=land_count,
- curveBuckets=cmc_buckets,
- cardTypeCounts=type_counts,
- ),
+async def _build_deck_report_from_db(deck_slug: str):
+ """Build a lightweight DeckReport from DB (async). Allows coaching without simulation data."""
+ from coach.models import DeckReport, CardPerformance, DeckStructure
+ import re
+ if not deck_slug or len(deck_slug) > 200:
+ return None
+ deck_slug = re.sub(r'[^a-zA-Z0-9 _-]', '', deck_slug)
+ if not deck_slug:
+ return None
+ slug_lower = deck_slug.lower()
+ slug_hyphenated = slug_lower.replace(' ', '-')
+ clean_slug = re.sub(r'[^a-z0-9]+', '-', slug_lower).strip('-')
+ if slug_lower.isdigit():
+ matched_deck = await async_fetchone(
+ "SELECT id, name, commander_name, color_identity FROM decks WHERE id = ?",
+ (int(slug_lower),)
)
+ else:
+ matched_deck = await async_fetchone(
+ """SELECT id, name, commander_name, color_identity FROM decks
+ WHERE LOWER(name) = ? OR LOWER(REPLACE(name, ' ', '-')) = ?
+ OR LOWER(REPLACE(name, ' ', '-')) = ? LIMIT 1""",
+ (slug_lower, slug_hyphenated, clean_slug)
+ )
+ if matched_deck is None:
+ return None
+ deck_id = matched_deck["id"]
+ deck_name = matched_deck["name"]
+ commander = matched_deck["commander_name"] or ""
+ ci_raw = matched_deck["color_identity"] or "[]"
+ try:
+ color_identity = json.loads(ci_raw) if isinstance(ci_raw, str) else ci_raw
+ except Exception:
+ color_identity = []
+ card_rows = await async_fetchall(
+ """SELECT dc.card_name, dc.quantity, dc.is_commander, dc.role_tag,
+ ce.type_line, ce.cmc, ce.oracle_text
+ FROM deck_cards dc
+ LEFT JOIN collection_entries ce ON LOWER(dc.card_name) = LOWER(ce.name)
+ WHERE dc.deck_id = ?""",
+ (deck_id,)
+ )
+ cards = []
+ type_counts = {}
+ cmc_buckets = [0] * 8
+ land_count = 0
+ for cr in card_rows:
+ card_name = cr["card_name"] or ""
+ type_line = cr["type_line"] or ""
+ cmc = cr["cmc"] or 0
+ qty = cr["quantity"] or 1
+ role_tag = cr["role_tag"] or ""
+ tags = []
+ if role_tag:
+ tags.append(role_tag)
+ if "Land" in type_line:
+ tags.append("land")
+ land_count += qty
+ elif "Creature" in type_line:
+ tags.append("creature")
+ elif "Instant" in type_line:
+ tags.append("instant")
+ elif "Sorcery" in type_line:
+ tags.append("sorcery")
+ elif "Artifact" in type_line:
+ tags.append("artifact")
+ elif "Enchantment" in type_line:
+ tags.append("enchantment")
+ elif "Planeswalker" in type_line:
+ tags.append("planeswalker")
+ for t in ["Creature", "Instant", "Sorcery", "Artifact", "Enchantment", "Planeswalker", "Land"]:
+ if t in type_line:
+ type_counts[t] = type_counts.get(t, 0) + qty
+ bucket = min(int(cmc), 7)
+ cmc_buckets[bucket] += qty
+ cards.append(CardPerformance(name=card_name, drawnRate=0.0, castRate=0.0, impactScore=0.0, tags=tags))
+ return DeckReport(
+ deckId=deck_slug, commander=commander, colorIdentity=color_identity,
+ cards=cards, structure=DeckStructure(landCount=land_count, curveBuckets=cmc_buckets, cardTypeCounts=type_counts),
+ )
@router.get("/api/coach/sessions")
async def coach_list_sessions(deck_id: str = None):
- """List all coaching sessions, optionally filtered by deck."""
- if _coach_service is None:
- raise HTTPException(500, "Coach service not initialized")
- return {"sessions": _coach_service.list_sessions(deck_id)}
+ if _coach_service is None:
+ raise HTTPException(500, "Coach service not initialized")
+ return {"sessions": _coach_service.list_sessions(deck_id)}
@router.get("/api/coach/sessions/{session_id}")
async def coach_get_session(session_id: str):
- """Get a specific coaching session."""
- if _coach_service is None:
- raise HTTPException(500, "Coach service not initialized")
- session = _coach_service.load_session(session_id)
- if session is None:
- raise HTTPException(404, f"Session not found: {session_id}")
- return session.model_dump()
+ if _coach_service is None:
+ raise HTTPException(500, "Coach service not initialized")
+ session = _coach_service.load_session(session_id)
+ if session is None:
+ raise HTTPException(404, f"Session not found: {session_id}")
+ return session.model_dump()
@router.post("/api/coach/embeddings/download")
async def coach_download_embeddings():
- """Download and convert MTG card embeddings from HuggingFace."""
- if _coach_embeddings is None:
- raise HTTPException(500, "Coach service not initialized")
- try:
- _coach_embeddings.load(force_download=True)
- return {
- "success": True,
- "cards": _coach_embeddings.card_count,
- "message": f"Loaded {_coach_embeddings.card_count} card embeddings"
- }
- except Exception as e:
- raise HTTPException(500, f"Failed to download embeddings: {e}")
+ if _coach_embeddings is None:
+ raise HTTPException(500, "Coach service not initialized")
+ try:
+ _coach_embeddings.load(force_download=True)
+ return {"success": True, "cards": _coach_embeddings.card_count,
+ "message": f"Loaded {_coach_embeddings.card_count} card embeddings"}
+ except Exception as e:
+ raise HTTPException(500, f"Failed to download embeddings: {e}")
@router.get("/api/coach/embeddings/search")
async def coach_search_similar(card: str, colors: str = None, top_n: int = 10):
- """Search for similar cards by name."""
- if _coach_embeddings is None or not _coach_embeddings.loaded:
- raise HTTPException(503, "Embeddings not loaded")
- color_filter = list(colors.upper()) if colors else None
- matches = _coach_embeddings.search_similar(
- query_card=card, color_filter=color_filter, top_n=top_n
- )
- return {"query": card, "matches": [m.to_dict() for m in matches]}
+ if _coach_embeddings is None or not _coach_embeddings.loaded:
+ raise HTTPException(503, "Embeddings not loaded")
+ color_filter = list(colors.upper()) if colors else None
+ matches = _coach_embeddings.search_similar(query_card=card, color_filter=color_filter, top_n=top_n)
+ return {"query": card, "matches": [m.to_dict() for m in matches]}
@router.post("/api/coach/chat")
async def coach_chat(body: CoachChatRequest):
- """Multi-turn coaching chat. Sends conversation history to LLM and returns response."""
- if _coach_service is None:
- raise HTTPException(500, "Coach service not initialized")
-
- report = _coach_service.load_deck_report(body.deck_id)
-
- from coach.prompt_template import build_system_prompt
- from coach.models import CoachGoals
-
- goals = None
- if body.goals:
- try:
- goals = CoachGoals(**body.goals)
- except Exception:
- goals = None
-
- system_prompt = ""
- if report:
- system_prompt = build_system_prompt(report, goals)
- else:
- system_prompt = (
- "You are an expert Magic: The Gathering Commander deck coach. "
- "Answer questions about deck building, strategy, card choices, and game theory. "
- "Be specific and actionable in your advice."
- )
-
- messages = [{"role": "system", "content": system_prompt}]
- for msg in body.messages:
- messages.append({"role": msg.get("role", "user"), "content": msg.get("content", "")})
-
- # ── Anthropic Claude provider path ─────────────────────────────────────────
- if COACH_PROVIDER == 'Anthropic Claude':
- if not CFG.anthropic_api_key:
- raise HTTPException(400, 'Anthropic Claude API key not configured. Set anthropic_api_key env var.')
- anthropic_payload = {
- 'model': ANTHROPIC_MODEL,
- 'messages': messages,
- 'max_tokens': 4096,
- 'temperature': 0.7,
- }
- anthropic_headers = {
- 'Content-Type': 'application/json',
- 'Authorization': f'Bearer {CFG.anthropic_api_key}',
- }
- if body.stream:
- async def anthropic_stream():
- anthropic_payload['stream'] = True
- async with httpx.AsyncClient(timeout=120.0) as client:
- async with client.stream('POST', 'https://api.Anthropic Claude.ai/chat/completions',
- json=anthropic_payload, headers=anthropic_headers) as resp:
- async for line in resp.aiter_lines():
- if line.startswith('data: '):
- data = line[6:]
- if data == '[DONE]':
- yield 'data: [DONE]\n\n'
- break
- try:
- chunk = json.loads(data)
- content = chunk.get('choices', [{}])[0].get('delta', {}).get('content', '')
- if content:
- yield f'data: {json.dumps({"content": content})}\n\n'
- except json.JSONDecodeError:
- continue
- return StreamingResponse(anthropic_stream(), media_type='text/event-stream')
- else:
- try:
- async with httpx.AsyncClient(timeout=120.0) as client:
- resp = await client.post(
- 'https://api.Anthropic Claude.ai/chat/completions',
- json=anthropic_payload, headers=anthropic_headers,
- )
- resp.raise_for_status()
- raw = resp.json()
- content = raw.get('choices', [{}])[0].get('message', {}).get('content', '')
- usage = raw.get('usage', {})
- return {
- 'content': content,
- 'model': raw.get('model', ''),
- 'prompt_tokens': usage.get('prompt_tokens', 0),
- 'completion_tokens': usage.get('completion_tokens', 0),
- }
- except httpx.HTTPStatusError as e:
- raise HTTPException(502, f'Anthropic Claude API returned {e.response.status_code}')
- except httpx.RequestError as e:
- raise HTTPException(502, f'Anthropic Claude API call failed: {e}')
-
- # ── Local Ollama provider path ───────────────────────────────────
+ """Multi-turn coaching chat. Fully async - no event loop blocking."""
+ if _coach_service is None:
+ raise HTTPException(500, "Coach service not initialized")
+ report = _coach_service.load_deck_report(body.deck_id)
+ from coach.prompt_template import build_system_prompt
+ from coach.models import CoachGoals
+ goals = None
+ if body.goals:
try:
- import json
- from urllib.request import urlopen, Request as UrlRequest
-
- model_name = _coach_llm._resolve_model()
- llm_body = {
- "model": model_name,
- "messages": messages,
- "temperature": 0.7,
- "max_tokens": 4096,
+ goals = CoachGoals(**body.goals)
+ except Exception:
+ goals = None
+ system_prompt = ""
+ if report:
+ system_prompt = build_system_prompt(report, goals)
+ else:
+ system_prompt = (
+ "You are an expert Magic: The Gathering Commander deck coach. "
+ "Answer questions about deck building, strategy, card choices, and game theory. "
+ "Be specific and actionable in your advice."
+ )
+ messages = [{"role": "system", "content": system_prompt}]
+ for msg in body.messages:
+ messages.append({"role": msg.get("role", "user"), "content": msg.get("content", "")})
+ # ── Anthropic Claude provider path (async httpx) ──
+ if COACH_PROVIDER == 'anthropic':
+ if not ANTHROPIC_API_KEY:
+ raise HTTPException(400, 'Anthropic API key not configured.')
+ import anthropic
+ aclient = anthropic.AsyncAnthropic(api_key=ANTHROPIC_API_KEY)
+ if body.stream:
+ async def anthropic_stream():
+ async with aclient.messages.stream(
+ model=ANTHROPIC_MODEL, system=system_prompt,
+ messages=[{"role": m["role"], "content": m["content"]} for m in messages if m["role"] != "system"],
+ max_tokens=4096, temperature=0.7,
+ ) as stream:
+ async for text in stream.text_stream:
+ yield f"data: {json.dumps({'content': text})}\n\n"
+ yield "data: [DONE]\n\n"
+ return StreamingResponse(anthropic_stream(), media_type="text/event-stream")
+ else:
+ try:
+ resp = await aclient.messages.create(
+ model=ANTHROPIC_MODEL, system=system_prompt,
+ messages=[{"role": m["role"], "content": m["content"]} for m in messages if m["role"] != "system"],
+ max_tokens=4096, temperature=0.7,
+ )
+ content = resp.content[0].text if resp.content else ""
+ usage = resp.usage
+ return {
+ "content": content, "model": resp.model,
+ "prompt_tokens": usage.input_tokens if usage else 0,
+ "completion_tokens": usage.output_tokens if usage else 0,
}
-
- if body.stream:
- llm_body["stream"] = True
-
- async def generate():
- import asyncio
- def _stream():
- import http.client
- import urllib.parse
- parsed = urllib.parse.urlparse(f"{_coach_llm.base_url}/chat/completions")
- conn = http.client.HTTPConnection(parsed.hostname, parsed.port, timeout=LLM_TIMEOUT)
- conn.request("POST", parsed.path, body=json.dumps(llm_body).encode("utf-8"),
- headers={"Content-Type": "application/json"})
- resp = conn.getresponse()
- chunks = []
- while True:
- line = resp.readline()
- if not line:
- break
- line = line.decode("utf-8").strip()
- if line.startswith("data: "):
- data = line[6:]
- if data == "[DONE]":
- chunks.append("[DONE]")
- break
- chunks.append(data)
- conn.close()
- return chunks
-
- loop = asyncio.get_event_loop()
- chunks = await loop.run_in_executor(None, _stream)
-
- for chunk_str in chunks:
- if chunk_str == "[DONE]":
- yield f"data: [DONE]\n\n"
- break
- try:
- chunk = json.loads(chunk_str)
- delta = chunk.get("choices", [{}])[0].get("delta", {})
- content = delta.get("content", "")
- if content:
- yield f"data: {json.dumps({'content': content})}\n\n"
- except json.JSONDecodeError:
- continue
-
- return StreamingResponse(generate(), media_type="text/event-stream")
- else:
- req = UrlRequest(
- f"{_coach_llm.base_url}/chat/completions",
- data=json.dumps(llm_body).encode("utf-8"),
- headers={"Content-Type": "application/json"},
- method="POST",
- )
-
- import asyncio
- loop = asyncio.get_event_loop()
- def _call():
- with urlopen(req, timeout=LLM_TIMEOUT) as resp:
- return json.loads(resp.read().decode("utf-8"))
-
- raw = await loop.run_in_executor(None, _call)
-
- content = raw.get("choices", [{}])[0].get("message", {}).get("content", "")
- content = _coach_llm._strip_think_tags(content)
- usage = raw.get("usage", {})
-
- return {
- "content": content,
- "model": raw.get("model", ""),
- "prompt_tokens": usage.get("prompt_tokens", 0),
- "completion_tokens": usage.get("completion_tokens", 0),
- }
-
- except Exception as e:
- raise HTTPException(500, f"Chat failed: {e}")
+ except Exception as e:
+ raise HTTPException(502, f"Anthropic API call failed: {e}")
+ # ── Local Ollama provider path (async httpx) ──
+ try:
+ model_name = await _coach_llm._resolve_model()
+ llm_body = {"model": model_name, "messages": messages, "temperature": 0.7, "max_tokens": 4096}
+ if body.stream:
+ llm_body["stream"] = True
+ async def generate():
+ async with httpx.AsyncClient(timeout=httpx.Timeout(float(LLM_TIMEOUT))) as client:
+ async with client.stream("POST", f"{_coach_llm.base_url}/chat/completions",
+ json=llm_body, headers={"Content-Type": "application/json"}) as resp:
+ async for line in resp.aiter_lines():
+ if line.startswith("data: "):
+ data = line[6:]
+ if data == "[DONE]":
+ yield "data: [DONE]\n\n"
+ break
+ try:
+ chunk = json.loads(data)
+ content = chunk.get("choices", [{}])[0].get("delta", {}).get("content", "")
+ if content:
+ yield f"data: {json.dumps({'content': content})}\n\n"
+ except json.JSONDecodeError:
+ continue
+ return StreamingResponse(generate(), media_type="text/event-stream")
+ else:
+ async with httpx.AsyncClient(timeout=httpx.Timeout(float(LLM_TIMEOUT))) as client:
+ resp = await client.post(
+ f"{_coach_llm.base_url}/chat/completions",
+ json=llm_body, headers={"Content-Type": "application/json"},
+ )
+ resp.raise_for_status()
+ raw = resp.json()
+ content = raw.get("choices", [{}])[0].get("message", {}).get("content", "")
+ content = _coach_llm._strip_think_tags(content)
+ usage = raw.get("usage", {})
+ return {
+ "content": content, "model": raw.get("model", ""),
+ "prompt_tokens": usage.get("prompt_tokens", 0),
+ "completion_tokens": usage.get("completion_tokens", 0),
+ }
+ except Exception as e:
+ raise HTTPException(500, f"Chat failed: {e}")
@router.post("/api/coach/apply")
async def coach_apply_suggestions(body: CoachApplyRequest):
- """Apply accepted coaching suggestions to a deck — remove cuts, add adds."""
- conn = _get_db_conn()
-
- deck = conn.execute("SELECT * FROM decks WHERE id = ?", (body.deck_id,)).fetchone()
- if not deck:
- raise HTTPException(404, f"Deck {body.deck_id} not found")
-
- results = {"cuts": [], "adds": [], "errors": []}
-
- for card_name in body.accepted_cuts:
- row = conn.execute(
- "SELECT id FROM deck_cards WHERE deck_id = ? AND card_name = ? LIMIT 1",
- (body.deck_id, card_name)
- ).fetchone()
- if row:
- conn.execute("DELETE FROM deck_cards WHERE id = ?", (row["id"],))
- results["cuts"].append({"name": card_name, "status": "removed"})
- else:
- row = conn.execute(
- "SELECT id, card_name FROM deck_cards WHERE deck_id = ? AND LOWER(card_name) = LOWER(?) LIMIT 1",
- (body.deck_id, card_name)
- ).fetchone()
- if row:
- conn.execute("DELETE FROM deck_cards WHERE id = ?", (row["id"],))
- results["cuts"].append({"name": row["card_name"], "status": "removed"})
- else:
- results["errors"].append({"name": card_name, "error": "Card not found in deck"})
-
- for card_name in body.accepted_adds:
- ce = conn.execute(
- "SELECT scryfall_id, name FROM collection_entries WHERE LOWER(name) = LOWER(?) LIMIT 1",
- (card_name,)
- ).fetchone()
-
- scryfall_id = None
- resolved_name = card_name
-
- if ce:
- scryfall_id = ce["scryfall_id"]
- resolved_name = ce["name"]
- else:
- try:
- import urllib.parse
- from urllib.request import urlopen, Request as UrlRequest
- encoded = urllib.parse.quote(card_name)
- scry_req = UrlRequest(
- f"https://api.scryfall.com/cards/named?fuzzy={encoded}",
- headers={"User-Agent": "commander-ai-lab/1.0"}
- )
- with urlopen(scry_req, timeout=10) as resp:
- card_data = json.loads(resp.read().decode("utf-8"))
- scryfall_id = card_data.get("id")
- resolved_name = card_data.get("name", card_name)
- except Exception:
- pass
-
- if scryfall_id:
- existing = conn.execute(
- "SELECT id FROM deck_cards WHERE deck_id = ? AND scryfall_id = ?",
- (body.deck_id, scryfall_id)
- ).fetchone()
- if not existing:
- conn.execute(
- "INSERT INTO deck_cards (deck_id, scryfall_id, card_name, quantity) VALUES (?, ?, ?, 1)",
- (body.deck_id, scryfall_id, resolved_name)
- )
- results["adds"].append({"name": resolved_name, "scryfall_id": scryfall_id, "status": "added"})
- else:
- results["adds"].append({"name": resolved_name, "status": "already_in_deck"})
- else:
- results["errors"].append({"name": card_name, "error": "Could not resolve card"})
-
- conn.execute("UPDATE decks SET updated_at = datetime('now') WHERE id = ?", (body.deck_id,))
- conn.commit()
-
- results["total_cuts"] = len(results["cuts"])
- results["total_adds"] = len([a for a in results["adds"] if a["status"] == "added"])
- return results
+ """Apply accepted coaching suggestions to a deck (async DB + async HTTP)."""
+ db = await async_get_db()
+ deck = await async_fetchone("SELECT * FROM decks WHERE id = ?", (body.deck_id,))
+ if not deck:
+ raise HTTPException(404, f"Deck {body.deck_id} not found")
+ results = {"cuts": [], "adds": [], "errors": []}
+ for card_name in body.accepted_cuts:
+ row = await async_fetchone(
+ "SELECT id FROM deck_cards WHERE deck_id = ? AND card_name = ? LIMIT 1",
+ (body.deck_id, card_name)
+ )
+ if row:
+ await async_execute("DELETE FROM deck_cards WHERE id = ?", (row["id"],))
+ results["cuts"].append({"name": card_name, "status": "removed"})
+ else:
+ row = await async_fetchone(
+ "SELECT id, card_name FROM deck_cards WHERE deck_id = ? AND LOWER(card_name) = LOWER(?) LIMIT 1",
+ (body.deck_id, card_name)
+ )
+ if row:
+ await async_execute("DELETE FROM deck_cards WHERE id = ?", (row["id"],))
+ results["cuts"].append({"name": row["card_name"], "status": "removed"})
+ else:
+ results["errors"].append({"name": card_name, "error": "Card not found in deck"})
+ for card_name in body.accepted_adds:
+ ce = await async_fetchone(
+ "SELECT scryfall_id, name FROM collection_entries WHERE LOWER(name) = LOWER(?) LIMIT 1",
+ (card_name,)
+ )
+ scryfall_id = None
+ resolved_name = card_name
+ if ce:
+ scryfall_id = ce["scryfall_id"]
+ resolved_name = ce["name"]
+ else:
+ try:
+ import urllib.parse
+ encoded = urllib.parse.quote(card_name)
+ async with httpx.AsyncClient(timeout=10.0) as client:
+ resp = await client.get(
+ f"https://api.scryfall.com/cards/named?fuzzy={encoded}",
+ headers={"User-Agent": "commander-ai-lab/1.0"}
+ )
+ resp.raise_for_status()
+ card_data = resp.json()
+ scryfall_id = card_data.get("id")
+ resolved_name = card_data.get("name", card_name)
+ except Exception:
+ pass
+ if scryfall_id:
+ existing = await async_fetchone(
+ "SELECT id FROM deck_cards WHERE deck_id = ? AND scryfall_id = ?",
+ (body.deck_id, scryfall_id)
+ )
+ if not existing:
+ await async_execute(
+ "INSERT INTO deck_cards (deck_id, scryfall_id, card_name, quantity) VALUES (?, ?, ?, 1)",
+ (body.deck_id, scryfall_id, resolved_name)
+ )
+ results["adds"].append({"name": resolved_name, "scryfall_id": scryfall_id, "status": "added"})
+ else:
+ results["adds"].append({"name": resolved_name, "status": "already_in_deck"})
+ else:
+ results["errors"].append({"name": card_name, "error": "Could not resolve card"})
+ await async_execute("UPDATE decks SET updated_at = datetime('now') WHERE id = ?", (body.deck_id,))
+ await async_commit()
+ results["total_cuts"] = len(results["cuts"])
+ results["total_adds"] = len([a for a in results["adds"] if a["status"] == "added"])
+ return results
@router.get("/api/coach/cards-like")
async def coach_cards_like(card: str, colors: str = None, top_n: int = 10):
- """Find cards similar to a given card using embeddings. UI-friendly version."""
- if _coach_embeddings is None or not _coach_embeddings.loaded:
- raise HTTPException(503, "Embeddings not loaded")
-
- color_filter = list(colors.upper()) if colors else None
- matches = _coach_embeddings.search_similar(
- query_card=card, color_filter=color_filter, top_n=top_n
+ """Find similar cards using embeddings (async DB for ownership check)."""
+ if _coach_embeddings is None or not _coach_embeddings.loaded:
+ raise HTTPException(503, "Embeddings not loaded")
+ color_filter = list(colors.upper()) if colors else None
+ matches = _coach_embeddings.search_similar(query_card=card, color_filter=color_filter, top_n=top_n)
+ results = []
+ for m in matches:
+ owned = await async_fetchone(
+ "SELECT SUM(quantity) as qty FROM collection_entries WHERE LOWER(name) = LOWER(?)",
+ (m.name,)
)
-
- conn = _get_db_conn()
- results = []
- for m in matches:
- owned = conn.execute(
- "SELECT SUM(quantity) as qty FROM collection_entries WHERE LOWER(name) = LOWER(?)",
- (m.name,)
- ).fetchone()
- owned_qty = (owned["qty"] or 0) if owned else 0
-
- ce = conn.execute(
- "SELECT scryfall_id, tcg_price FROM collection_entries WHERE LOWER(name) = LOWER(?) LIMIT 1",
- (m.name,)
- ).fetchone()
-
- results.append({
- "name": m.name,
- "similarity": round(m.similarity, 4),
- "types": m.types,
- "mana_value": m.mana_value,
- "mana_cost": m.mana_cost,
- "text": m.text[:200] if m.text else "",
- "owned_qty": owned_qty,
- "image_url": f"https://api.scryfall.com/cards/{ce['scryfall_id']}?format=image&version=normal" if ce and ce["scryfall_id"] else None,
- "tcg_price": ce["tcg_price"] if ce else None,
- })
-
- return {"query": card, "results": results}
+ owned_qty = (owned["qty"] or 0) if owned else 0
+ ce = await async_fetchone(
+ "SELECT scryfall_id, tcg_price FROM collection_entries WHERE LOWER(name) = LOWER(?) LIMIT 1",
+ (m.name,)
+ )
+ results.append({
+ "name": m.name, "similarity": round(m.similarity, 4),
+ "types": m.types, "mana_value": m.mana_value,
+ "mana_cost": m.mana_cost, "text": m.text[:200] if m.text else "",
+ "owned_qty": owned_qty,
+ "image_url": f"https://api.scryfall.com/cards/{ce['scryfall_id']}?format=image&version=normal" if ce and ce["scryfall_id"] else None,
+ "tcg_price": ce["tcg_price"] if ce else None,
+ })
+ return {"query": card, "results": results}
@router.post("/api/coach/reports/generate")
async def coach_generate_reports():
- """Rebuild all deck reports from batch result JSONs in results/."""
- try:
- from coach.report_generator import generate_deck_reports
- lab_root = Path(__file__).parent.parent
- results_dir = str(lab_root / CFG.results_dir)
- reports_dir = str(lab_root / "deck-reports")
- updated = generate_deck_reports(results_dir, reports_dir)
- return {
- "status": "ok",
- "decksUpdated": updated,
- "count": len(updated),
- "message": f"Generated reports for {len(updated)} decks" if updated else "No batch results found",
- }
- except Exception as e:
- raise HTTPException(500, f"Report generation failed: {e}")
+ try:
+ from coach.report_generator import generate_deck_reports
+ lab_root = Path(__file__).parent.parent
+ results_dir = str(lab_root / CFG.results_dir)
+ reports_dir = str(lab_root / "deck-reports")
+ updated = generate_deck_reports(results_dir, reports_dir)
+ return {
+ "status": "ok", "decksUpdated": updated, "count": len(updated),
+ "message": f"Generated reports for {len(updated)} decks" if updated else "No batch results found",
+ }
+ except Exception as e:
+ raise HTTPException(500, f"Report generation failed: {e}")
diff --git a/services/async_db.py b/services/async_db.py
new file mode 100644
index 0000000..73c7551
--- /dev/null
+++ b/services/async_db.py
@@ -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