fix: coach_apply rollback on partial write, 4-space reindent; ws_game hoist inline imports

This commit is contained in:
KoalaTrapLord committed 2026-03-27 20:24:55 -04:00
1 parent 61fa37dc48
commit 81fe5108d2
2 files changed
+442 -444

No files matched your search

+439 -435
View File
@@ -60,505 +60,509 @@ _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
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()
"""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.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")
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.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: 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: 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")
_coach_service = None
log_coach.error(f" Coach: Failed to initialize: {e}\n")
_coach_service = None
@router.get("/api/coach/status")
async def coach_status():
if _coach_service is None:
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()
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 (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
"""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"""
)
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
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):
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):
if _coach_service is None:
raise HTTPException(500, "Coach service not initialized")
if _coach_embeddings and not _coach_embeddings.loaded:
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
try:
_coach_embeddings.load(force_download=True)
fallback_report = await _build_deck_report_from_db(deck_id)
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
log_coach.error(f" Coach: Fallback report build failed for '{deck_id}': {e}")
try:
goals = CoachGoals(**body.goals)
except Exception:
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}")
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}")
@router.post("/api/coach/decks/{deck_id}/quick")
async def coach_run_quick_digest(deck_id: str, body: CoachRequestBody = None):
"""Fast ~5s quick digest: summary, top 3 upgrades, commander dependency."""
if _coach_service is None:
raise HTTPException(500, "Coach service not initialized")
goals = None
if body and body.goals:
from coach.models import CoachGoals
"""Fast ~5s quick digest: summary, top 3 upgrades, commander dependency."""
if _coach_service is None:
raise HTTPException(500, "Coach service not initialized")
goals = None
if body and body.goals:
from coach.models import CoachGoals
try:
goals = CoachGoals(**body.goals)
except Exception:
goals = None
fallback_report = None
try:
goals = CoachGoals(**body.goals)
except Exception:
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: Quick fallback failed for '{deck_id}': {e}")
try:
session = await _coach_service.run_quick_digest(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"Quick digest failed: {e}")
fallback_report = await _build_deck_report_from_db(deck_id)
except Exception as e:
log_coach.error(f" Coach: Quick fallback failed for '{deck_id}': {e}")
try:
session = await _coach_service.run_quick_digest(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"Quick digest failed: {e}")
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),)
"""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,)
)
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)
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),
)
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):
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):
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():
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):
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. 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:
"""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:
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,
}
except Exception as e:
raise HTTPException(502, f"Anthropic API call failed: {e}")
# ── Local Ollama provider path (async httpx) ──
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 (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,
}
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}")
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 (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
"""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": []}
try:
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()
except Exception as e:
try:
await async_execute("ROLLBACK")
except Exception:
pass
raise HTTPException(500, f"Apply failed and was rolled back: {e}")
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 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,)
)
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}
"""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,)
)
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():
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}")
+3 -9
View File
@@ -15,7 +15,9 @@ Unity connects to the WebSocket endpoint and receives:
"""
from __future__ import annotations
import copy
import logging
import time
import uuid
from typing import Optional
@@ -69,11 +71,8 @@ async def game_ws(websocket: WebSocket, game_id: str):
elif msg_type == "request_snapshot":
if tracker.has_baseline:
# Re-send current state as full snapshot
import copy
snap = copy.deepcopy(tracker._last_snapshot)
tracker._seq += 1
import time
await websocket.send_json({
"type": "snapshot",
"seq": tracker._seq,
@@ -128,12 +127,7 @@ class GameOverEvent(BaseModel):
@router.post("/api/ws/game/{game_id}/push")
async def push_game_state(game_id: str, req: PushStateRequest):
"""Push a game state update to all connected Unity clients.
Called by the Forge IPC bridge or policy server after each
state change. The delta engine diffs against the previous
state and broadcasts only what changed.
"""
"""Push a game state update to all connected Unity clients."""
tracker = get_tracker(game_id)
snapshot = req.dict()
delta_msg = tracker.update(snapshot)