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