feat: Phase 3/5 deep analysis + Quick Digest endpoint (issue #108) (#119)

* feat(coach): Phase 3/5 JSON schema + QUICK_DIGEST prompt (issue #108)

- Append removalCoverage, rampQuality, drawEngineProfile, winConditions,
  antiSynergyFlags, podPresence, tempoAssessment, metaMatchups to schema
- Add 8 new RULES for Phase 3/5 analysis
- Add QUICK_DIGEST_SYSTEM_PROMPT constant
- Add build_quick_system_prompt() function

* feat(coach): Phase 3/5 JSON parsing + run_quick_digest() (issue #108)

* feat(routes): add POST /api/coach/decks/{deck_id}/quick endpoint (issue #108)

* feat(coach): Phase 3/5 parsing + run_quick_digest() (issue #108)

* feat(coach): Phase 3/5 parsing + run_quick_digest() (issue #108)

- Import RemovalCoverage, RampQuality, DrawEngineProfile, WinCondition,
  AntiSynergyFlag, PodPresence, TempoAssessment, MetaMatchup
- Add Phase 3/5 blocks to _populate_session_from_json()
- Add run_quick_digest() method

* feat(ui): Top Priority card + Quick/Deep buttons in coach.html (issue #108)

* feat(coach): Phase 3/5 parsing + run_quick_digest() (issue #108)

* feat(ui): wire Quick Digest button + displayTopPriority() (issue #108)
This commit is contained in:
KoalaTrapLord authored and GitHub committed 2026-03-27 07:51:01 -04:00
1 parent 8df5900ff9
commit 83502ce726
5 files changed
+439 -150

No files matched your search

+164 -19
View File
@@ -25,6 +25,9 @@ from .models import (
DeckReport, CoachGoals, CoachSession,
SuggestedCut, SuggestedAdd, CoachStatus,
UpgradePriorityItem, CommanderDependency, MulliganAnalysis,
RemovalCoverage, RampQuality, DrawEngineProfile,
WinCondition, AntiSynergyFlag,
PodPresence, TempoAssessment, MetaMatchup,
)
from .llm_client import LLMClient
from .embeddings import MTGEmbeddingIndex
@@ -62,7 +65,7 @@ class CoachService:
self.embeddings = embeddings
ensure_dirs()
# ── Deck Report I/O ────────────────────────────────────────
# ── Deck Report I/O ────────────────────────────────────
def load_deck_report(self, deck_id: str) -> Optional[DeckReport]:
"""Load a deck report from disk."""
@@ -89,7 +92,7 @@ class CoachService:
return []
return [f.stem for f in DECK_REPORTS_DIR.glob("*.json")]
# ── Coach Session I/O ──────────────────────────────────────
# ── Coach Session I/O ──────────────────────────────
def save_session(self, session: CoachSession) -> Path:
"""Persist a coaching session to disk and SQLite."""
@@ -184,7 +187,7 @@ class CoachService:
continue
return sessions
# ── Main Coaching Pipeline ───────────────────────────────────
# ── Main Coaching Pipeline ───────────────────────────────
async def run_coaching_session(
self,
@@ -202,7 +205,6 @@ class CoachService:
6. Parse response
7. Persist session
"""
# 0. Auto-load embeddings if not loaded
if not self.embeddings.loaded:
logger.info("Embeddings not loaded — attempting auto-load...")
try:
@@ -214,7 +216,6 @@ class CoachService:
except Exception as e:
logger.warning("Embeddings auto-load failed: %s", e)
# 1. Load deck report
report = self.load_deck_report(deck_id)
if report is None and fallback_report is not None:
report = fallback_report
@@ -222,10 +223,8 @@ class CoachService:
if report is None:
raise ValueError(f"Deck report not found for: {deck_id}")
# 2. Get deck card names (for exclusion from candidates)
deck_card_names = [c.name for c in report.cards]
# 3. Find replacement candidates for underperformers
candidates: Dict[str, List[dict]] = {}
underperformers = report.underperformers[:MAX_UNDERPERFORMERS]
if not underperformers:
@@ -254,11 +253,9 @@ class CoachService:
else:
logger.warning("Embeddings not loaded — skipping candidate search")
# 4. Build prompts
system_prompt = build_system_prompt(report, goals)
user_prompt = build_user_prompt(report, candidates)
# 5. Call LLM
logger.info("Calling LLM for deck: %s (provider=%s)", deck_id, COACH_PROVIDER)
if COACH_PROVIDER == "anthropic":
@@ -266,8 +263,6 @@ class CoachService:
anthropic_key = ANTHROPIC_API_KEY
if not anthropic_key:
raise ConnectionError("Anthropic API key not configured. Set ANTHROPIC_API_KEY env var.")
# Use streaming to avoid the Anthropic SDK 10-minute non-streaming timeout.
aclient = _anthropic.AsyncAnthropic(api_key=anthropic_key)
async with aclient.messages.stream(
model=ANTHROPIC_MODEL,
@@ -279,8 +274,6 @@ class CoachService:
content = await stream.get_final_text()
final_msg = await stream.get_final_message()
usage = getattr(final_msg, "usage", None)
# Build an LLMResponse-compatible object
from types import SimpleNamespace
llm_response = SimpleNamespace(
content=content,
@@ -303,7 +296,6 @@ class CoachService:
else:
llm_response = await self.llm.chat(system_prompt, user_prompt)
# 6. Parse response into CoachSession
session_id = f"sess-{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}-{uuid.uuid4().hex[:6]}"
session = CoachSession(
sessionId=session_id,
@@ -321,11 +313,91 @@ class CoachService:
session.rawTextExplanation = llm_response.content
session.summary = "LLM returned non-JSON response. See raw explanation."
# 7. Persist
self.save_session(session)
logger.info("Coaching session saved: %s", session_id)
return session
# ── Quick Digest ──────────────────────────────────────────
async def run_quick_digest(
self,
deck_id: str,
goals: Optional[CoachGoals] = None,
fallback_report: Optional[DeckReport] = None,
) -> CoachSession:
"""Fast ~400-600 token call: summary + top 3 upgradePriority + commanderDependency."""
report = self.load_deck_report(deck_id)
if report is None and fallback_report is not None:
report = fallback_report
if report is None:
raise ValueError(f"Deck report not found for: {deck_id}")
from .prompt_template import build_quick_system_prompt, build_user_prompt
system_prompt = build_quick_system_prompt(report, goals)
user_prompt = build_user_prompt(report, {}) # no candidates for speed
logger.info("Quick digest for deck: %s (provider=%s)", deck_id, COACH_PROVIDER)
if COACH_PROVIDER == "anthropic":
import anthropic as _anthropic
anthropic_key = ANTHROPIC_API_KEY
if not anthropic_key:
raise ConnectionError("Anthropic API key not configured.")
aclient = _anthropic.AsyncAnthropic(api_key=anthropic_key)
async with aclient.messages.stream(
model=ANTHROPIC_MODEL,
system=system_prompt,
messages=[{"role": "user", "content": user_prompt}],
max_tokens=1024,
temperature=0.5,
) as stream:
content = await stream.get_final_text()
final_msg = await stream.get_final_message()
usage = getattr(final_msg, "usage", None)
from types import SimpleNamespace
llm_response = SimpleNamespace(
content=content,
model=final_msg.model,
prompt_tokens=usage.input_tokens if usage else 0,
completion_tokens=usage.output_tokens if usage else 0,
parsed_json=None,
)
try:
cleaned = re.sub(r'^```(?:json)?\s*', '', content.strip())
cleaned = re.sub(r'\s*```$', '', cleaned.strip())
llm_response.parsed_json = json.loads(cleaned)
except (json.JSONDecodeError, ValueError):
match = re.search(r'\{[\s\S]*\}', content)
if match:
try:
llm_response.parsed_json = json.loads(match.group(0))
except json.JSONDecodeError:
pass
else:
llm_response = await self.llm.chat(system_prompt, user_prompt)
session_id = f"quick-{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}-{uuid.uuid4().hex[:6]}"
session = CoachSession(
sessionId=session_id,
deckId=deck_id,
timestamp=datetime.now(timezone.utc).isoformat(),
modelUsed=llm_response.model,
promptTokens=llm_response.prompt_tokens,
completionTokens=llm_response.completion_tokens,
goals=goals,
isQuickDigest=True,
)
if llm_response.parsed_json:
self._populate_session_from_json(session, llm_response.parsed_json)
else:
session.rawTextExplanation = llm_response.content
session.summary = "Quick digest returned non-JSON. See raw explanation."
self.save_session(session)
return session
# ── JSON Parser ───────────────────────────────────────────
def _populate_session_from_json(self, session: CoachSession, data: dict):
"""Parse LLM JSON response into CoachSession fields."""
session.summary = data.get("summary", "")
@@ -352,7 +424,7 @@ class CoachService:
synergyWith=add_data.get("synergyWith", []),
))
# Phase 2: Parse upgrade priority
# Phase 2
for item in data.get("upgradePriority", []):
if isinstance(item, dict):
session.upgradePriority.append(UpgradePriorityItem(
@@ -363,7 +435,6 @@ class CoachService:
expectedImpact=item.get("expectedImpact", ""),
))
# Phase 2: Parse commander dependency
cd = data.get("commanderDependency")
if isinstance(cd, dict):
session.commanderDependency = CommanderDependency(
@@ -372,7 +443,6 @@ class CoachService:
recoveryPlan=cd.get("recoveryPlan", ""),
)
# Phase 2: Parse mulligan analysis
ma = data.get("mulliganAnalysis")
if isinstance(ma, dict):
session.mulliganAnalysis = MulliganAnalysis(
@@ -381,7 +451,82 @@ class CoachService:
recommendation=ma.get("recommendation", ""),
)
# ── Status Check ───────────────────────────────────────────
# Phase 3: Removal coverage
rc = data.get("removalCoverage")
if isinstance(rc, dict):
session.removalCoverage = RemovalCoverage(**{
k: rc.get(k, "" if k != "gaps" else [])
for k in ["creatures", "artifacts", "enchantments", "planeswalkers", "lands", "massRemoval", "gaps"]
})
# Phase 3: Ramp quality
rq = data.get("rampQuality")
if isinstance(rq, dict):
session.rampQuality = RampQuality(**{
k: rq.get(k, "") for k in ["manaRocks", "landFetch", "manaDorks", "costReducers", "fragility", "canReachFourByTurnThree"]
})
# Phase 3: Draw engine profile
dep = data.get("drawEngineProfile")
if isinstance(dep, dict):
session.drawEngineProfile = DrawEngineProfile(
burstDraw=dep.get("burstDraw", []),
repeatableEngines=dep.get("repeatableEngines", []),
assessment=dep.get("assessment", ""),
sustainability=dep.get("sustainability", ""),
)
# Phase 3: Win conditions
for wc in data.get("winConditions", []):
if isinstance(wc, dict):
session.winConditions.append(WinCondition(
name=wc.get("name", ""),
cards=wc.get("cards", []),
independence=wc.get("independence", ""),
description=wc.get("description", ""),
))
# Phase 3: Anti-synergy flags
for af in data.get("antiSynergyFlags", []):
if isinstance(af, dict):
session.antiSynergyFlags.append(AntiSynergyFlag(
cards=af.get("cards", []),
conflict=af.get("conflict", ""),
severity=af.get("severity", ""),
))
# Phase 5: Pod presence
pp = data.get("podPresence")
if isinstance(pp, dict):
session.podPresence = PodPresence(
threatLevel=pp.get("threatLevel", ""),
politicalTools=pp.get("politicalTools", []),
adversarialRating=pp.get("adversarialRating", ""),
recommendation=pp.get("recommendation", ""),
)
# Phase 5: Tempo assessment
ta = data.get("tempoAssessment")
if isinstance(ta, dict):
session.tempoAssessment = TempoAssessment(
peakTurn=ta.get("peakTurn", 0),
profile=ta.get("profile", ""),
survivalWindow=ta.get("survivalWindow", ""),
assessment=ta.get("assessment", ""),
)
# Phase 5: Meta matchups
for mm in data.get("metaMatchups", []):
if isinstance(mm, dict):
session.metaMatchups.append(MetaMatchup(
archetype=mm.get("archetype", ""),
assessment=mm.get("assessment", ""),
keyThreats=mm.get("keyThreats", []),
keyAnswers=mm.get("keyAnswers", []),
overallRating=mm.get("overallRating", ""),
))
# ── Status Check ─────────────────────────────────────────
def get_status(self) -> CoachStatus:
"""Check the health of all coach subsystems."""
+112 -1
View File
@@ -40,6 +40,14 @@ RULES:
- Assess how dependent the deck is on the commander being in play.
- Evaluate opening hand quality and identify cards that cause bad mulligans.
- Rank your suggested swaps by priority so the user knows what to change first.
- Categorize the removal package by permanent type and flag coverage gaps.
- Evaluate the ramp package for fragility (creature/artifact-heavy vs land-based).
- Distinguish one-time draw from repeatable draw engines.
- Identify the top 3 win conditions and rate each for independence.
- Flag internal anti-synergies or card conflicts.
- Assess pod threat perception: does this deck draw too much hate too early?
- Evaluate when the deck comes online (tempo profile) and whether that's viable.
- Reason about matchups vs fast combo, stax/prison, and control archetypes.
{goals_section}
You MUST respond ONLY with valid JSON matching this exact structure:
@@ -102,11 +110,103 @@ You MUST respond ONLY with valid JSON matching this exact structure:
"Detailed strategic tip explaining the reasoning and data behind the suggestion"
],
"manaBaseAdvice": "Specific mana base improvement suggestion including land count, color fixing, and utility lands, or null if mana base is solid",
"rawTextExplanation": "3-5 paragraph in-depth analysis covering: (1) overall deck strategy assessment and how well the deck executes its game plan based on simulation data, (2) mana curve and resource analysis with specific numbers including avgTurnCast for key cards, (3) card synergy evaluation highlighting the strongest and weakest interactions using synergyScore data, (4) meta considerations and matchup-specific insights referencing perArchetypeWinRates, and (5) prioritized list of improvements ranked by expected impact on win rate"
"rawTextExplanation": "3-5 paragraph in-depth analysis covering: (1) overall deck strategy assessment and how well the deck executes its game plan based on simulation data, (2) mana curve and resource analysis with specific numbers including avgTurnCast for key cards, (3) card synergy evaluation highlighting the strongest and weakest interactions using synergyScore data, (4) meta considerations and matchup-specific insights referencing perArchetypeWinRates, and (5) prioritized list of improvements ranked by expected impact on win rate",
"removalCoverage": {{
"creatures": "Assessment of creature removal",
"artifacts": "Assessment of artifact removal",
"enchantments": "Assessment of enchantment removal",
"planeswalkers": "Assessment of planeswalker removal",
"lands": "Assessment of land destruction",
"massRemoval": "Assessment of board wipes",
"gaps": ["Permanent types with zero or weak coverage"]
}},
"rampQuality": {{
"manaRocks": "2-mana rocks, signets, etc.",
"landFetch": "Cultivate, Kodama's Reach, etc.",
"manaDorks": "Llanowar Elves, Birds, etc.",
"costReducers": "Medallions, Helm of Awakening, etc.",
"fragility": "Overall fragility assessment",
"canReachFourByTurnThree": "yes/no with reasoning"
}},
"drawEngineProfile": {{
"burstDraw": ["One-time draw spells"],
"repeatableEngines": ["Sustained draw engines"],
"assessment": "Overall draw quality",
"sustainability": "Whether draw is sustainable or burst-only"
}},
"winConditions": [
{{
"name": "Name of win condition",
"cards": ["Key cards involved"],
"independence": "high|medium|low",
"description": "How this win con functions"
}}
],
"antiSynergyFlags": [
{{
"cards": ["Card A", "Card B"],
"conflict": "Description of conflict",
"severity": "high|medium|low"
}}
],
"podPresence": {{
"threatLevel": "early|medium|late|low",
"politicalTools": ["Pillowfort or group effect cards"],
"adversarialRating": "low|medium|high",
"recommendation": "How to manage threat perception in a pod"
}},
"tempoAssessment": {{
"peakTurn": 5,
"profile": "early|mid|late",
"survivalWindow": "Assessment of whether the deck survives to its power window",
"assessment": "Overall tempo evaluation"
}},
"metaMatchups": [
{{
"archetype": "fast-combo|stax|control|aggro|midrange",
"assessment": "How the deck fares against this archetype",
"keyThreats": ["Cards/strategies that threaten this deck"],
"keyAnswers": ["Cards in the deck that answer this archetype"],
"overallRating": "favored|even|unfavored"
}}
]
}}
Do NOT include any text outside the JSON object. No markdown fences, no preamble."""
# ══════════════════════════════════════════════════════════════
# Quick Digest Prompt
# ══════════════════════════════════════════════════════════════
QUICK_DIGEST_SYSTEM_PROMPT = """You are an expert Magic: The Gathering Commander deck coach.
Provide a FAST summary analysis of this deck. Be concise.
RULES:
- NEVER suggest cutting the commander ({commander}).
- Only suggest cards within the deck's color identity: {color_identity}.
{goals_section}
You MUST respond ONLY with valid JSON matching this exact structure:
{{
"summary": "3-sentence deck overview: what it does, main strength, main weakness.",
"upgradePriority": [
{{
"rank": 1,
"cut": "Card to remove",
"add": "Card to add instead",
"reasoning": "Why this is a high-impact swap",
"expectedImpact": "high|medium|low"
}}
],
"commanderDependency": {{
"score": 5,
"dependentCards": ["Card that is nearly dead without commander"],
"recoveryPlan": "Brief recovery strategy"
}}
}}
Do NOT include any text outside the JSON object. No markdown fences, no preamble."""
# ══════════════════════════════════════════════════════════════
# User Prompt Builder
# ══════════════════════════════════════════════════════════════
@@ -290,6 +390,17 @@ def build_system_prompt(report: DeckReport,
)
def build_quick_system_prompt(report: DeckReport,
goals: Optional[CoachGoals] = None) -> str:
"""Build the quick digest system prompt (~400-600 token target)."""
color_str = "/".join(report.colorIdentity) if report.colorIdentity else "any"
return QUICK_DIGEST_SYSTEM_PROMPT.format(
commander=report.commander,
color_identity=color_str,
goals_section=_format_goals_section(goals),
)
def build_user_prompt(report: DeckReport,
candidates: Dict[str, List[dict]] = None) -> str:
"""Build the user prompt with full deck report data."""
+29
View File
@@ -6,6 +6,7 @@ LLM-powered deck coaching endpoints:
GET /api/coach/decks
GET /api/coach/decks/{deck_id}/report
POST /api/coach/decks/{deck_id}
POST /api/coach/decks/{deck_id}/quick
GET /api/coach/sessions
GET /api/coach/sessions/{session_id}
POST /api/coach/embeddings/download
@@ -194,6 +195,34 @@ async def coach_run_session(deck_id: str, body: CoachRequestBody = None):
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
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}")
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
+8 -3
View File
@@ -120,9 +120,8 @@
<!-- Action Buttons Row -->
<div class="coach-run-row">
<button id="coach-run-btn" class="btn btn-primary btn-lg" disabled>
<span class="coach-run-icon">&#9889;</span> Run Coach
</button>
<button id="coach-quick-btn" class="btn btn-secondary" disabled>&#9889; Quick Digest</button>
<button id="coach-run-btn" class="btn btn-primary" disabled>&#128300; Full Analysis</button>
<span class="coach-run-hint" id="coach-run-hint">Select a deck to begin</span>
</div>
@@ -206,6 +205,12 @@
</div>
</div>
<!-- Top Priority Swap (Phase 2 UI) -->
<div id="coach-top-priority" class="coach-card" style="display:none; border-left: 4px solid var(--accent);">
<h3>&#9889; Top Priority Swap</h3>
<div id="coach-top-priority-content"></div>
</div>
<!-- Summary Card -->
<div class="panel" id="coach-summary-card">
<div class="panel-header">
+126 -127
View File
@@ -1,8 +1,9 @@
/**
* Commander AI Lab — Deck Coach UI v28
* Commander AI Lab — Deck Coach UI v29
* ═════════════════════════════════════
* Multi-turn chat with SSE streaming, apply suggestions,
* cards-like search, enhanced session history.
* v29: Quick Digest button + Top Priority Swap card.
*/
(function () {
@@ -10,7 +11,7 @@
const API = window.location.origin;
// ── State ────────────────────────────────────────────────
// ── State ────────────────────────────────────────────
let chatHistory = []; // [{role, content}]
let isStreaming = false;
let currentSessionId = null;
@@ -43,7 +44,8 @@
.then(r => r.ok ? r.json() : null)
.then(data => {
if (!data) return;
const url = (data.image_uris && data.image_uris.normal) || (data.card_faces && data.card_faces[0] && data.card_faces[0].image_uris && data.card_faces[0].image_uris.normal) || '';
const url = (data.image_uris && data.image_uris.normal) ||
(data.card_faces && data.card_faces[0] && data.card_faces[0].image_uris && data.card_faces[0].image_uris.normal) || '';
_scryfallImgCache[cardName] = url;
if (el.style.display !== 'none') img.src = url;
}).catch(() => {});
@@ -70,12 +72,13 @@
});
}
// ── DOM References ──────────────────────────────────────
// ── DOM References ────────────────────────────────────
const deckSelect = document.getElementById('coach-deck-select');
const powerSlider = document.getElementById('coach-power-level');
const powerValue = document.getElementById('coach-power-value');
const metaFocus = document.getElementById('coach-meta-focus');
const budgetSelect = document.getElementById('coach-budget');
const quickBtn = document.getElementById('coach-quick-btn');
const runBtn = document.getElementById('coach-run-btn');
const runHint = document.getElementById('coach-run-hint');
const progressEl = document.getElementById('coach-progress');
@@ -106,6 +109,8 @@
const rawText = document.getElementById('coach-raw-text');
const sessionsList = document.getElementById('coach-sessions-list');
const genReportsBtn = document.getElementById('generate-reports-btn');
const topPriorityCard = document.getElementById('coach-top-priority');
const topPriorityContent = document.getElementById('coach-top-priority-content');
// Chat
const chatMessages = document.getElementById('coach-chat-messages');
@@ -127,7 +132,7 @@
const applyDeckSelect = document.getElementById('coach-apply-deck-select');
const applyCountEl = document.getElementById('coach-apply-count');
// ── Init ────────────────────────────────────────────────
// ── Init ──────────────────────────────────────────────
init();
@@ -138,11 +143,13 @@
deckSelect.addEventListener('change', () => {
const hasDeck = deckSelect.value !== '';
quickBtn.disabled = !hasDeck;
runBtn.disabled = !hasDeck;
chatSendBtn.disabled = !hasDeck;
runHint.textContent = hasDeck ? 'Ready to analyze' : 'Select a deck to begin';
});
quickBtn.addEventListener('click', runQuickDigest);
runBtn.addEventListener('click', runCoach);
genReportsBtn.addEventListener('click', generateReports);
@@ -198,7 +205,7 @@
]);
}
// ── Goals Helper ────────────────────────────────────────
// ── Goals Helper ────────────────────────────────────
function getGoals() {
const goals = {};
@@ -214,7 +221,7 @@
return goals;
}
// ── Status Check ────────────────────────────────────────
// ── Status Check ────────────────────────────────────
async function checkStatus() {
try {
@@ -254,18 +261,15 @@
valueEl.textContent = text;
}
// ── Load Decks ──────────────────────────────────────────
// ── Load Decks ───────────────────────────────────────
async function loadDecks() {
try {
const res = await fetch(API + '/api/coach/decks');
const data = await res.json();
// Backend returns a bare array of objects, not {decks: [...]}
const decks = Array.isArray(data) ? data : (data.decks || []);
while (deckSelect.options.length > 1) {
deckSelect.remove(1);
}
while (deckSelect.options.length > 1) deckSelect.remove(1);
if (decks.length === 0) {
const opt = document.createElement('option');
@@ -275,7 +279,6 @@
return;
}
// Sort by deck name
decks.sort((a, b) => {
const na = (typeof a === 'string' ? a : a.deck_name || '').toLowerCase();
const nb = (typeof b === 'string' ? b : b.deck_name || '').toLowerCase();
@@ -285,11 +288,9 @@
for (const deck of decks) {
const opt = document.createElement('option');
if (typeof deck === 'string') {
// Legacy: bare string ID
opt.value = deck;
opt.textContent = deck;
} else {
// Object format: {deck_id, deck_name, commander, has_report, ...}
const slug = deck.deck_name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
opt.value = slug;
opt.textContent = deck.deck_name + (deck.commander ? ' (' + deck.commander + ')' : '');
@@ -310,9 +311,7 @@
const data = await res.json();
dbDecks = data.decks || [];
while (applyDeckSelect.options.length > 1) {
applyDeckSelect.remove(1);
}
while (applyDeckSelect.options.length > 1) applyDeckSelect.remove(1);
for (const deck of dbDecks) {
const opt = document.createElement('option');
@@ -346,11 +345,14 @@
const timeStr = s.timestamp ? formatTime(s.timestamp) : '';
const isExpanded = expandedSessions[s.sessionId];
const quickBadge = s.sessionId && s.sessionId.startsWith('quick-')
? '<span class="coach-badge-quick" title="Quick Digest">\u26A1 Quick</span>'
: '';
item.innerHTML =
'<div class="coach-session-header" data-sid="' + escHtml(s.sessionId) + '">' +
'<span class="coach-session-expand-arrow">' + (isExpanded ? '\u25BC' : '\u25B6') + '</span>' +
'<span class="coach-session-deck">' + escHtml(s.deckId) + '</span>' +
'<span class="coach-session-deck">' + escHtml(s.deckId) + quickBadge + '</span>' +
'<span class="coach-session-summary">' + escHtml(s.summary || '(no summary)') + '</span>' +
'<span class="coach-session-meta">' +
'<span class="coach-session-cuts">-' + (s.cutsCount || 0) + '</span>' +
@@ -362,14 +364,12 @@
'<div class="coach-session-detail-loading">Loading...</div>' +
'</div>';
// Click handler for expand/collapse
item.querySelector('.coach-session-header').addEventListener('click', function () {
toggleSession(s.sessionId);
});
sessionsList.appendChild(item);
// If previously expanded, load detail
if (isExpanded && sessionCache[s.sessionId]) {
renderSessionDetail(s.sessionId, sessionCache[s.sessionId]);
}
@@ -383,23 +383,19 @@
const detailEl = document.getElementById('session-detail-' + sessionId);
const headerEl = detailEl?.previousElementSibling;
const arrowEl = headerEl?.querySelector('.coach-session-expand-arrow');
if (!detailEl) return;
const wasHidden = detailEl.style.display === 'none';
if (wasHidden) {
detailEl.style.display = 'block';
expandedSessions[sessionId] = true;
if (arrowEl) arrowEl.textContent = '\u25BC';
if (!sessionCache[sessionId]) {
try {
const res = await fetch(API + '/api/coach/sessions/' + encodeURIComponent(sessionId));
if (!res.ok) throw new Error('Session not found');
const session = await res.json();
sessionCache[sessionId] = session;
renderSessionDetail(sessionId, session);
sessionCache[sessionId] = await res.json();
renderSessionDetail(sessionId, sessionCache[sessionId]);
} catch (e) {
detailEl.innerHTML = '<div class="coach-session-detail-error">Failed to load session details</div>';
}
@@ -421,45 +417,30 @@
const adds = session.suggestedAdds || [];
let html = '<div class="coach-session-detail-inner">';
// Summary
if (session.summary) {
html += '<p class="coach-session-detail-summary">' + escHtml(session.summary) + '</p>';
}
// Cuts
if (cuts.length > 0) {
html += '<div class="coach-session-detail-group">';
html += '<span class="coach-session-detail-label">Cuts:</span>';
html += '<div class="coach-session-pills">';
html += '<div class="coach-session-detail-group"><span class="coach-session-detail-label">Cuts:</span><div class="coach-session-pills">';
for (const cut of cuts) {
html += '<span class="coach-pill coach-pill-cut" data-card-name="' + escHtml(cut.cardName) + '" title="' + escHtml(cut.reason || '') + '">' + escHtml(cut.cardName) + '</span>';
}
html += '</div></div>';
}
// Adds
if (adds.length > 0) {
html += '<div class="coach-session-detail-group">';
html += '<span class="coach-session-detail-label">Adds:</span>';
html += '<div class="coach-session-pills">';
html += '<div class="coach-session-detail-group"><span class="coach-session-detail-label">Adds:</span><div class="coach-session-pills">';
for (const add of adds) {
html += '<span class="coach-pill coach-pill-add" data-card-name="' + escHtml(add.cardName) + '" title="' + escHtml(add.reason || '') + '">' + escHtml(add.cardName) + '</span>';
}
html += '</div></div>';
}
// Load in Chat button
html += '<div class="coach-session-detail-actions">';
html += '<button class="btn btn-ghost btn-sm coach-load-chat-btn" data-sid="' + escHtml(sessionId) + '">Load in Chat</button>';
html += '<button class="btn btn-ghost btn-sm coach-view-full-btn" data-sid="' + escHtml(sessionId) + '">View Full Results</button>';
html += '</div>';
html += '</div>';
html += '</div></div>';
detailEl.innerHTML = html;
_attachCardHover(detailEl);
// Bind buttons
detailEl.querySelector('.coach-load-chat-btn')?.addEventListener('click', (e) => {
e.stopPropagation();
loadSessionInChat(sessionId);
@@ -474,10 +455,7 @@
function loadSessionInChat(sessionId) {
const session = sessionCache[sessionId];
if (!session) return;
clearChat();
// Set deck selector if possible
if (session.deckId) {
for (let i = 0; i < deckSelect.options.length; i++) {
if (deckSelect.options[i].value === session.deckId) {
@@ -487,30 +465,24 @@
}
}
}
// Pre-fill chat with a summary context
const contextMsg = 'Based on a previous coaching session for this deck, here was the analysis:\n\n' +
(session.summary || '') + '\n\n' +
'Suggested cuts: ' + (session.suggestedCuts || []).map(c => c.cardName).join(', ') + '\n' +
'Suggested adds: ' + (session.suggestedAdds || []).map(a => a.cardName).join(', ');
chatHistory.push({ role: 'assistant', content: contextMsg });
appendChatBubble('assistant', contextMsg);
chatInput.focus();
showToast('Session loaded into chat', 'success');
}
// ── Generate Reports ────────────────────────────────────
// ── Generate Reports ──────────────────────────────────
async function generateReports() {
genReportsBtn.disabled = true;
genReportsBtn.textContent = 'Generating...';
try {
const res = await fetch(API + '/api/coach/reports/generate', { method: 'POST' });
const data = await res.json();
if (data.count > 0) {
genReportsBtn.textContent = data.count + ' reports generated';
await checkStatus();
@@ -528,14 +500,57 @@
}
}
// ── Run Coach (one-shot) ────────────────────────────────
// ── Quick Digest ───────────────────────────────────────
async function runQuickDigest() {
const deckId = deckSelect.value;
if (!deckId) return;
const goals = getGoals();
quickBtn.disabled = true;
runBtn.disabled = true;
progressEl.style.display = 'flex';
progressText.textContent = '\u26A1 Quick Digest running (~5s)...';
resultsEl.style.display = 'none';
const prevError = document.querySelector('.coach-error');
if (prevError) prevError.remove();
try {
const res = await fetch(API + '/api/coach/decks/' + encodeURIComponent(deckId) + '/quick', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ goals }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: 'Unknown error' }));
throw new Error(err.detail || 'Quick digest failed (' + res.status + ')');
}
const session = await res.json();
currentSessionId = session.sessionId || null;
displaySession(session);
loadSessions();
showToast('Quick Digest complete!', 'success');
} catch (e) {
const errDiv = document.createElement('div');
errDiv.className = 'coach-error';
errDiv.textContent = 'Quick digest error: ' + e.message;
document.querySelector('.coach-config').appendChild(errDiv);
} finally {
quickBtn.disabled = false;
runBtn.disabled = false;
progressEl.style.display = 'none';
}
}
// ── Full Analysis ───────────────────────────────────────
async function runCoach() {
const deckId = deckSelect.value;
if (!deckId) return;
const goals = getGoals();
quickBtn.disabled = true;
runBtn.disabled = true;
progressEl.style.display = 'flex';
progressText.textContent = 'Analyzing deck and consulting LLM... This may take 30-60 seconds.';
@@ -548,14 +563,12 @@
const res = await fetch(API + '/api/coach/decks/' + encodeURIComponent(deckId), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ goals: goals }),
body: JSON.stringify({ goals }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: 'Unknown error' }));
throw new Error(err.detail || 'Coach request failed (' + res.status + ')');
}
const session = await res.json();
currentSessionId = session.sessionId || null;
displaySession(session);
@@ -566,11 +579,45 @@
errDiv.textContent = 'Coach error: ' + e.message;
document.querySelector('.coach-config').appendChild(errDiv);
} finally {
quickBtn.disabled = false;
runBtn.disabled = false;
progressEl.style.display = 'none';
}
}
// ── Top Priority Swap ──────────────────────────────────
function displayTopPriority(upgradePriority) {
if (!topPriorityCard || !topPriorityContent) return;
if (!upgradePriority || upgradePriority.length === 0) {
topPriorityCard.style.display = 'none';
return;
}
// Show rank-1 item (or first if rank field missing)
const top = upgradePriority.find(u => u.rank === 1) || upgradePriority[0];
const impactClass = {
high: 'coach-impact-negative',
medium: 'coach-impact-neutral',
low: 'coach-impact-positive'
}[top.expectedImpact] || 'coach-impact-neutral';
topPriorityContent.innerHTML =
'<div class="coach-priority-swap">' +
'<div class="coach-priority-row">' +
'<span class="coach-priority-label">Cut</span>' +
'<span class="coach-priority-card coach-pill-cut" data-card-name="' + escHtml(top.cut) + '">' + escHtml(top.cut) + '</span>' +
'<span class="coach-priority-arrow">\u2192</span>' +
'<span class="coach-priority-label">Add</span>' +
'<span class="coach-priority-card coach-pill-add" data-card-name="' + escHtml(top.add) + '">' + escHtml(top.add) + '</span>' +
'<span class="coach-impact ' + impactClass + '">' + escHtml(top.expectedImpact || '') + '</span>' +
'</div>' +
(top.reasoning ? '<p class="coach-priority-reasoning">' + escHtml(top.reasoning) + '</p>' : '') +
'</div>';
_attachCardHover(topPriorityContent);
topPriorityCard.style.display = 'block';
}
// ── Display Session Results ──────────────────────────────
function displaySession(session) {
@@ -585,13 +632,16 @@
if (session.completionTokens) addMetaChip('Completion', session.completionTokens.toLocaleString() + ' tokens');
if (session.deckId) addMetaChip('Deck', session.deckId);
if (session.timestamp) addMetaChip('Time', formatTime(session.timestamp));
if (session.isQuickDigest) addMetaChip('Mode', '\u26A1 Quick Digest');
// Top Priority Swap
displayTopPriority(session.upgradePriority || []);
// Cuts table
const cuts = session.suggestedCuts || [];
cutsCount.textContent = cuts.length;
cutsBody.innerHTML = '';
cutsAllCheck.checked = false;
for (const cut of cuts) {
const tr = document.createElement('tr');
tr.innerHTML =
@@ -613,7 +663,6 @@
addsCount.textContent = adds.length;
addsBody.innerHTML = '';
addsAllCheck.checked = false;
for (const add of adds) {
const tr = document.createElement('tr');
tr.innerHTML =
@@ -652,10 +701,8 @@
manaCard.style.display = 'none';
}
// Raw text
rawText.textContent = session.rawTextExplanation || session.summary || '(empty)';
_attachCardHover(cutsBody);
_attachCardHover(addsBody);
updateApplyCount();
@@ -666,31 +713,23 @@
function updateApplyCount() {
const cutChecked = cutsBody.querySelectorAll('input[type="checkbox"]:checked');
const addChecked = addsBody.querySelectorAll('input[type="checkbox"]:checked');
const total = cutChecked.length + addChecked.length;
applyCountEl.textContent = cutChecked.length + ' cuts, ' + addChecked.length + ' adds selected';
applyBtn.disabled = total === 0 || !applyDeckSelect.value;
applyBtn.disabled = (cutChecked.length + addChecked.length) === 0 || !applyDeckSelect.value;
}
applyDeckSelect.addEventListener('change', updateApplyCount);
async function applySuggestions() {
const deckId = parseInt(applyDeckSelect.value);
if (!deckId) {
showToast('Select a deck to apply suggestions to', 'warning');
return;
}
if (!deckId) { showToast('Select a deck to apply suggestions to', 'warning'); return; }
const acceptedCuts = [];
cutsBody.querySelectorAll('input[type="checkbox"]:checked').forEach(cb => {
const name = cb.dataset.card;
if (name) acceptedCuts.push(name);
if (cb.dataset.card) acceptedCuts.push(cb.dataset.card);
});
const acceptedAdds = [];
addsBody.querySelectorAll('input[type="checkbox"]:checked').forEach(cb => {
const name = cb.dataset.card;
if (name) acceptedAdds.push(name);
if (cb.dataset.card) acceptedAdds.push(cb.dataset.card);
});
if (acceptedCuts.length === 0 && acceptedAdds.length === 0) {
@@ -712,17 +751,14 @@
accepted_adds: acceptedAdds,
}),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: 'Apply failed' }));
throw new Error(err.detail || 'Apply failed');
}
const result = await res.json();
const msg = 'Applied: ' + result.total_cuts + ' cuts, ' + result.total_adds + ' adds' +
(result.errors.length > 0 ? ' (' + result.errors.length + ' errors)' : '');
showToast(msg, result.errors.length > 0 ? 'warning' : 'success');
} catch (e) {
showToast('Apply error: ' + e.message, 'error');
} finally {
@@ -732,19 +768,17 @@
}
}
// ── Multi-turn Chat ─────────────────────────────────────
// ── Multi-turn Chat ────────────────────────────────────
async function sendChatMessage() {
const text = chatInput.value.trim();
if (!text || !deckSelect.value || isStreaming) return;
// Add user message
chatHistory.push({ role: 'user', content: text });
appendChatBubble('user', text);
chatInput.value = '';
chatSendBtn.disabled = true;
// Show typing indicator
const typingEl = showTypingIndicator();
isStreaming = true;
@@ -759,20 +793,16 @@
stream: true,
}),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: 'Chat failed' }));
throw new Error(err.detail || 'Chat failed (' + res.status + ')');
}
// Remove typing indicator, add assistant bubble
typingEl.remove();
const bubbleEl = appendChatBubble('assistant', '');
const contentEl = bubbleEl.querySelector('.coach-chat-bubble-content');
let fullContent = '';
// Read SSE stream
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
@@ -780,18 +810,14 @@
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed.startsWith('data: ')) continue;
const payload = trimmed.slice(6);
if (payload === '[DONE]') break;
try {
const chunk = JSON.parse(payload);
if (chunk.content) {
@@ -799,17 +825,13 @@
contentEl.innerHTML = formatMarkdown(fullContent);
scrollChatToBottom();
}
} catch (e) {
// skip malformed chunks
}
} catch (_) {}
}
}
// Finalize
chatHistory.push({ role: 'assistant', content: fullContent });
contentEl.innerHTML = formatMarkdown(fullContent);
scrollChatToBottom();
} catch (e) {
typingEl.remove();
appendChatBubble('system', 'Error: ' + e.message);
@@ -820,18 +842,14 @@
}
function appendChatBubble(role, content) {
// Remove welcome message
const welcome = chatMessages.querySelector('.coach-chat-welcome');
if (welcome) welcome.remove();
const bubble = document.createElement('div');
bubble.className = 'coach-chat-bubble coach-chat-' + role;
const label = role === 'user' ? 'You' : role === 'assistant' ? 'Coach' : 'System';
bubble.innerHTML =
'<div class="coach-chat-bubble-label">' + label + '</div>' +
'<div class="coach-chat-bubble-content">' + (content ? formatMarkdown(content) : '') + '</div>';
chatMessages.appendChild(bubble);
scrollChatToBottom();
return bubble;
@@ -848,9 +866,7 @@
return el;
}
function scrollChatToBottom() {
chatMessages.scrollTop = chatMessages.scrollHeight;
}
function scrollChatToBottom() { chatMessages.scrollTop = chatMessages.scrollHeight; }
function clearChat() {
chatHistory = [];
@@ -863,7 +879,6 @@
function formatMarkdown(text) {
if (!text) return '';
// Basic markdown: bold, italic, code, line breaks
return escHtml(text)
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
@@ -871,7 +886,7 @@
.replace(/\n/g, '<br>');
}
// ── Cards-Like Search ───────────────────────────────────
// ── Cards-Like Search ──────────────────────────────────
async function searchCardsLike() {
const card = cardslikeInput.value.trim();
@@ -881,16 +896,13 @@
document.querySelectorAll('.coach-cardslike-colors .coach-color-pip.active').forEach(btn => {
activeColors.push(btn.dataset.color);
});
const topN = parseInt(cardslikeTopn.value) || 10;
cardslikeResults.innerHTML = '<div class="coach-cardslike-loading"><div class="spinner"></div> Searching embeddings...</div>';
try {
let url = API + '/api/coach/cards-like?card=' + encodeURIComponent(card) + '&top_n=' + topN;
if (activeColors.length > 0) {
url += '&colors=' + activeColors.join('');
}
if (activeColors.length > 0) url += '&colors=' + activeColors.join('');
const res = await fetch(url);
if (!res.ok) {
@@ -910,13 +922,11 @@
for (const card of results) {
const div = document.createElement('div');
div.className = 'coach-cardslike-card';
const simPct = Math.round(card.similarity * 100);
const ownedBadge = card.owned_qty > 0
? '<span class="coach-cl-owned">' + card.owned_qty + ' owned</span>'
: '<span class="coach-cl-unowned">not owned</span>';
const price = card.tcg_price ? '$' + Number(card.tcg_price).toFixed(2) : '';
div.innerHTML =
'<div class="coach-cl-top">' +
'<span class="coach-cl-name" data-card-name="' + escHtml(card.name) + '">' + escHtml(card.name) + '</span>' +
@@ -932,13 +942,11 @@
'<div class="coach-cl-actions">' +
'<button class="btn btn-ghost btn-xs coach-cl-chat-btn">Add to Chat</button>' +
'</div>';
div.querySelector('.coach-cl-chat-btn').addEventListener('click', () => {
chatInput.value += (chatInput.value ? ' ' : '') + card.name;
chatInput.focus();
showToast(card.name + ' added to chat input', 'success');
});
cardslikeResults.appendChild(div);
_attachCardHover(div);
}
@@ -947,12 +955,11 @@
}
}
// Color pip toggle
document.querySelectorAll('.coach-cardslike-colors .coach-color-pip').forEach(btn => {
btn.addEventListener('click', () => btn.classList.toggle('active'));
});
// ── Toast ───────────────────────────────────────────────
// ── Toast ─────────────────────────────────────────────
function showToast(message, type) {
const container = document.getElementById('coach-toast-container');
@@ -960,7 +967,6 @@
toast.className = 'coach-toast coach-toast-' + (type || 'info');
toast.textContent = message;
container.appendChild(toast);
setTimeout(() => toast.classList.add('coach-toast-show'), 10);
setTimeout(() => {
toast.classList.remove('coach-toast-show');
@@ -968,7 +974,7 @@
}, 4000);
}
// ── Helpers ─────────────────────────────────────────────
// ── Helpers ───────────────────────────────────────────
function addMetaChip(label, value) {
const chip = document.createElement('span');
@@ -978,26 +984,19 @@
}
function formatImpact(score) {
if (score === undefined || score === null) {
if (score === undefined || score === null)
return '<span class="coach-impact coach-impact-neutral">\u2014</span>';
}
const fixed = score.toFixed(3);
if (score < -0.01) {
return '<span class="coach-impact coach-impact-negative">' + fixed + '</span>';
} else if (score > 0.01) {
return '<span class="coach-impact coach-impact-positive">+' + fixed + '</span>';
} else {
if (score < -0.01) return '<span class="coach-impact coach-impact-negative">' + fixed + '</span>';
if (score > 0.01) return '<span class="coach-impact coach-impact-positive">+' + fixed + '</span>';
return '<span class="coach-impact coach-impact-neutral">' + fixed + '</span>';
}
}
function formatTime(isoStr) {
try {
const d = new Date(isoStr);
return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} catch {
return isoStr;
}
} catch { return isoStr; }
}
function escHtml(str) {