* 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:
1 parent
8df5900ff9
commit
83502ce726
5 files changed
+448
-159
No files matched your search
+164
-19
@@ -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
@@ -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."""
|
||||
|
||||
@@ -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
@@ -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">⚡</span> Run Coach
|
||||
</button>
|
||||
<button id="coach-quick-btn" class="btn btn-secondary" disabled>⚡ Quick Digest</button>
|
||||
<button id="coach-run-btn" class="btn btn-primary" disabled>🔬 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>⚡ 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">
|
||||
|
||||
+135
-136
@@ -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(() => {});
|
||||
@@ -55,7 +57,7 @@
|
||||
function _positionPreview(evt) {
|
||||
if (!_previewEl) return;
|
||||
_previewEl.style.left = Math.max(0, Math.min(evt.clientX + 20, window.innerWidth - 260)) + 'px';
|
||||
_previewEl.style.top = Math.max(0, Math.min(evt.clientY - 150, window.innerHeight - 370)) + 'px';
|
||||
_previewEl.style.top = Math.max(0, Math.min(evt.clientY - 150, window.innerHeight - 370)) + 'px';
|
||||
}
|
||||
|
||||
function _hideCardPreview() {
|
||||
@@ -65,17 +67,18 @@
|
||||
function _attachCardHover(container) {
|
||||
container.querySelectorAll('[data-card-name]').forEach(td => {
|
||||
td.addEventListener('mouseenter', e => _showCardPreview(td.dataset.cardName, e));
|
||||
td.addEventListener('mousemove', e => _positionPreview(e));
|
||||
td.addEventListener('mousemove', e => _positionPreview(e));
|
||||
td.addEventListener('mouseleave', _hideCardPreview);
|
||||
});
|
||||
}
|
||||
|
||||
// ── 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);
|
||||
_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) {
|
||||
@@ -580,18 +627,21 @@
|
||||
summaryText.textContent = session.summary || '(No summary provided)';
|
||||
|
||||
metaRow.innerHTML = '';
|
||||
if (session.modelUsed) addMetaChip('Model', session.modelUsed);
|
||||
if (session.promptTokens) addMetaChip('Prompt', session.promptTokens.toLocaleString() + ' tokens');
|
||||
if (session.modelUsed) addMetaChip('Model', session.modelUsed);
|
||||
if (session.promptTokens) addMetaChip('Prompt', session.promptTokens.toLocaleString() + ' tokens');
|
||||
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.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,27 +942,24 @@
|
||||
'<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);
|
||||
_attachCardHover(div);
|
||||
}
|
||||
} catch (e) {
|
||||
cardslikeResults.innerHTML = '<div class="coach-cardslike-empty">Error: ' + escHtml(e.message) + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return '<span class="coach-impact coach-impact-neutral">' + fixed + '</span>';
|
||||
}
|
||||
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) {
|
||||
|
||||
Reference in new issue
Block a user