fix: coach_apply rollback on partial write, 4-space reindent; ws_game hoist inline imports
This commit is contained in:
1 parent
61fa37dc48
commit
81fe5108d2
2 files changed
+442
-444
No files matched your search
+439
-435
@@ -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
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user