diff --git a/frontend/commander-ai-lab-ui/src/api/coach.ts b/frontend/commander-ai-lab-ui/src/api/coach.ts index da10f80..c55415c 100644 --- a/frontend/commander-ai-lab-ui/src/api/coach.ts +++ b/frontend/commander-ai-lab-ui/src/api/coach.ts @@ -1,5 +1,8 @@ import { get, post } from './client' -import type { CoachStatus, CoachDeck, CoachSession } from '../types' +import type { + CoachStatus, CoachDeck, CoachSession, CoachSessionSummary, + CoachChatResponse, CoachApplyResult, CoachCardLikeResult +} from '../types' export async function getCoachStatus() { return get('/api/coach/status') @@ -9,16 +12,18 @@ export async function getCoachDecks() { return get('/api/coach/decks') } -export async function getCoachDeckReport(deckId: number) { +export async function getCoachDeckReport(deckId: string) { return get>(`/api/coach/decks/${deckId}/report`) } -export async function analyzeCoachDeck(deckId: number) { - return post>(`/api/coach/decks/${deckId}`) +export async function runCoachSession(deckId: string, goals?: Record) { + return post(`/api/coach/decks/${deckId}`, goals ? { goals } : undefined) } -export async function getCoachSessions() { - return get('/api/coach/sessions') +export async function getCoachSessions(deckId?: string) { + const params: Record = {} + if (deckId) params.deck_id = deckId + return get<{ sessions: CoachSessionSummary[] }>('/api/coach/sessions', params) } export async function getCoachSession(sessionId: string) { @@ -26,13 +31,101 @@ export async function getCoachSession(sessionId: string) { } export async function downloadEmbeddings() { - return post<{ status: string }>('/api/coach/embeddings/download') + return post<{ success: boolean; cards: number }>('/api/coach/embeddings/download') } -export async function searchEmbeddings(q: string, topK?: number) { - return get>('/api/coach/embeddings/search', { q, top_k: topK }) +export async function coachChat( + deckId: string, + messages: { role: string; content: string }[], + goals?: Record +) { + return post('/api/coach/chat', { + deck_id: deckId, + messages, + goals, + stream: false, + }) } -export async function generateReport(deckId: number) { - return post<{ report_id: string }>('/api/coach/reports/generate', { deck_id: deckId }) +export async function coachChatStream( + deckId: string, + messages: { role: string; content: string }[], + goals?: Record, + onChunk?: (text: string) => void, + signal?: AbortSignal +): Promise { + const res = await fetch('/api/coach/chat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + deck_id: deckId, + messages, + goals, + stream: true, + }), + signal, + }) + + if (!res.ok) { + const body = await res.text() + throw new Error(body || `HTTP ${res.status}`) + } + + const reader = res.body?.getReader() + if (!reader) throw new Error('No response body') + + const decoder = new TextDecoder() + let fullText = '' + let buffer = '' + + 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) { + if (line.startsWith('data: ')) { + const data = line.slice(6).trim() + if (data === '[DONE]') break + try { + const parsed = JSON.parse(data) + if (parsed.content) { + fullText += parsed.content + onChunk?.(fullText) + } + } catch { /* skip bad chunks */ } + } + } + } + + return fullText +} + +export async function applyCoachSuggestions( + sessionId: string, + deckId: number, + acceptedCuts: string[], + acceptedAdds: string[] +) { + return post('/api/coach/apply', { + session_id: sessionId, + deck_id: deckId, + accepted_cuts: acceptedCuts, + accepted_adds: acceptedAdds, + }) +} + +export async function searchCardsLike(card: string, colors?: string, topN = 10) { + const params: Record = { card, top_n: topN } + if (colors) params.colors = colors + return get<{ query: string; results: CoachCardLikeResult[] }>('/api/coach/cards-like', params) +} + +export async function generateReport(deckId?: number) { + return post<{ status: string; decksUpdated: string[]; count: number }>('/api/coach/reports/generate', + deckId ? { deck_id: deckId } : undefined + ) } diff --git a/frontend/commander-ai-lab-ui/src/pages/CoachPage.tsx b/frontend/commander-ai-lab-ui/src/pages/CoachPage.tsx index 6e81f06..d83098a 100644 --- a/frontend/commander-ai-lab-ui/src/pages/CoachPage.tsx +++ b/frontend/commander-ai-lab-ui/src/pages/CoachPage.tsx @@ -1,93 +1,659 @@ -import { useState, useEffect, useRef } from 'react' +import { useState, useEffect, useRef, useCallback } from 'react' import { - GraduationCap, MessageSquare, Download, - ChevronRight, Sparkles, AlertCircle, X + GraduationCap, MessageSquare, Send, Sparkles, AlertCircle, X, Check, + ChevronRight, ChevronDown, Download, Settings2, Search, Zap, + Minus, Plus, History, ArrowRightLeft, Lightbulb, DollarSign, Loader2, + ClipboardList, Brain, BarChart3 } from 'lucide-react' import { Spinner, StatusBadge, EmptyState } from '../components/common' import { coachApi } from '../api' -import type { CoachStatus, CoachDeck, CoachSession } from '../types' +import type { + CoachStatus, CoachDeck, CoachSession, CoachSessionSummary, + CoachMessage, CoachGoals, CoachApplyResult, CoachCardLikeResult +} from '../types' -// ── Chat Session View ───────────────────────────────────────── -function ChatView({ session, onClose }: { session: CoachSession; onClose: () => void }) { - const messagesEndRef = useRef(null) - - useEffect(() => { - messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) - }, [session.messages]) +// ════════════════════════════════════════════════════════════ +// Goals Config Panel +// ════════════════════════════════════════════════════════════ +function GoalsPanel({ + goals, onChange, collapsed, onToggle +}: { + goals: CoachGoals + onChange: (g: CoachGoals) => void + collapsed: boolean + onToggle: () => void +}) { + const strategies = ['aggro', 'control', 'combo', 'midrange', 'stax', 'voltron', 'aristocrats', 'tokens'] + const budgets = [ + { value: 'budget', label: 'Budget', desc: 'Under $5/card' }, + { value: 'medium', label: 'Medium', desc: 'Under $20/card' }, + { value: 'no-limit', label: 'No Limit', desc: 'Any price' }, + ] + const focusOptions = ['ramp', 'card draw', 'removal', 'protection', 'threats', 'mana base', 'combos', 'synergy'] return ( -
-
-
-

{session.deck_name}

-

{new Date(session.created_at).toLocaleString()}

+
+ + + {!collapsed && ( +
+ {/* Power Level Slider */} +
+ + onChange({ ...goals, targetPowerLevel: parseInt(e.target.value) })} + className="w-full accent-accent-teal" + /> +
+ CasualFocusedcEDH +
+
+ + {/* Strategy */} +
+ +
+ {strategies.map(s => ( + + ))} +
+
+ + {/* Budget */} +
+ +
+ {budgets.map(b => ( + + ))} +
+
+ + {/* Focus Areas */} +
+ +
+ {focusOptions.map(f => { + const active = (goals.focusAreas || []).includes(f) + return ( + + ) + })} +
+
-
+ ) +} + +// ════════════════════════════════════════════════════════════ +// Session View — Shows coaching results with apply functionality +// ════════════════════════════════════════════════════════════ +function SessionView({ + session, deckId, onClose, onApplied +}: { + session: CoachSession + deckId: number + onClose: () => void + onApplied?: () => void +}) { + const [selectedCuts, setSelectedCuts] = useState>(new Set()) + const [selectedAdds, setSelectedAdds] = useState>(new Set()) + const [applying, setApplying] = useState(false) + const [applyResult, setApplyResult] = useState(null) + + const toggleCut = (name: string) => { + const s = new Set(selectedCuts) + s.has(name) ? s.delete(name) : s.add(name) + setSelectedCuts(s) + } + const toggleAdd = (name: string) => { + const s = new Set(selectedAdds) + s.has(name) ? s.delete(name) : s.add(name) + setSelectedAdds(s) + } + + const handleApply = async () => { + if (selectedCuts.size === 0 && selectedAdds.size === 0) return + setApplying(true) + try { + const result = await coachApi.applyCoachSuggestions( + session.sessionId, deckId, + [...selectedCuts], [...selectedAdds] + ) + setApplyResult(result) + onApplied?.() + } catch (e) { + console.error('Apply failed:', e) + } + setApplying(false) + } + + return ( +
+
+
+

Coaching Session

+

+ {new Date(session.timestamp).toLocaleString()} · {session.modelUsed} + {session.promptTokens > 0 && ` · ${session.promptTokens + session.completionTokens} tokens`} +

+
+
-
- {session.messages.map((msg, i) => ( + + {/* Summary */} + {session.summary && ( +
+

+ Summary +

+

{session.summary}

+
+ )} + + {/* Heuristic Hints */} + {session.heuristicHints?.length > 0 && ( +
+

+ Strategic Tips +

+
    + {session.heuristicHints.map((h, i) => ( +
  • + • {h} +
  • + ))} +
+
+ )} + + {/* Mana Base Advice */} + {session.manaBaseAdvice && ( +
+

+ Mana Base +

+

{session.manaBaseAdvice}

+
+ )} + + {/* Suggested Cuts */} + {session.suggestedCuts?.length > 0 && ( +
+

+ Suggested Cuts ({session.suggestedCuts.length}) +

+
+ {session.suggestedCuts.map((cut, i) => ( +
toggleCut(cut.cardName)} + className={`flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${ + selectedCuts.has(cut.cardName) + ? 'bg-status-error/10 border-status-error/30' + : 'bg-bg-secondary border-border-primary hover:border-border-secondary' + }`} + > +
+ {selectedCuts.has(cut.cardName) && } +
+
+

{cut.cardName}

+

{cut.reason}

+ {cut.replacementOptions?.length > 0 && ( +

+ Replace with: {cut.replacementOptions.join(', ')} +

+ )} +
+
+ ))} +
+
+ )} + + {/* Suggested Adds */} + {session.suggestedAdds?.length > 0 && ( +
+

+ Suggested Adds ({session.suggestedAdds.length}) +

+
+ {session.suggestedAdds.map((add, i) => ( +
toggleAdd(add.cardName)} + className={`flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${ + selectedAdds.has(add.cardName) + ? 'bg-status-success/10 border-status-success/30' + : 'bg-bg-secondary border-border-primary hover:border-border-secondary' + }`} + > +
+ {selectedAdds.has(add.cardName) && } +
+
+
+

{add.cardName}

+ {add.role && ( + + {add.role} + + )} +
+

{add.reason}

+ {add.synergyWith?.length > 0 && ( +

+ Synergy: {add.synergyWith.join(', ')} +

+ )} +
+
+ ))} +
+
+ )} + + {/* Apply Button */} + {!applyResult && (selectedCuts.size > 0 || selectedAdds.size > 0) && ( +
+ +
+ )} + + {/* Apply Results */} + {applyResult && ( +
+

+ Changes Applied +

+

+ {applyResult.total_cuts > 0 && `Removed ${applyResult.total_cuts} card(s). `} + {applyResult.total_adds > 0 && `Added ${applyResult.total_adds} card(s). `} + {applyResult.errors.length > 0 && `${applyResult.errors.length} error(s).`} +

+ {applyResult.errors.length > 0 && ( +
    + {applyResult.errors.map((e, i) => ( +
  • {e.name}: {e.error}
  • + ))} +
+ )} +
+ )} + + {/* Raw Explanation */} + {session.rawTextExplanation && ( +
+ + Full analysis... + +

+ {session.rawTextExplanation} +

+
+ )} +
+ ) +} + +// ════════════════════════════════════════════════════════════ +// Cards Like Search +// ════════════════════════════════════════════════════════════ +function CardsLikePanel() { + const [query, setQuery] = useState('') + const [results, setResults] = useState([]) + const [searching, setSearching] = useState(false) + + const handleSearch = async () => { + if (!query.trim()) return + setSearching(true) + try { + const data = await coachApi.searchCardsLike(query.trim(), undefined, 12) + setResults(data.results) + } catch { /* ignore */ } + setSearching(false) + } + + return ( +
+
+
+ + setQuery(e.target.value)} + onKeyDown={e => e.key === 'Enter' && handleSearch()} + placeholder="Enter a card name..." + className="w-full pl-9 pr-3 py-2 bg-bg-secondary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue" + /> +
+ +
+ + {results.length > 0 && ( +
+ {results.map((card, i) => ( +
+
+
+

{card.name}

+ + {(card.similarity * 100).toFixed(0)}% + +
+

{card.types}

+ {card.text && ( +

{card.text}

+ )} +
+ {card.tcg_price != null && ( + + {card.tcg_price.toFixed(2)} + + )} + {card.owned_qty > 0 && ( + Owned: {card.owned_qty} + )} + MV: {card.mana_value} +
+
+ {card.image_url && ( + {card.name} + )} +
+ ))} +
+ )} +
+ ) +} + +// ════════════════════════════════════════════════════════════ +// Live Chat View +// ════════════════════════════════════════════════════════════ +function LiveChat({ + deckId, deckName, goals, onClose +}: { + deckId: string + deckName: string + goals: CoachGoals + onClose: () => void +}) { + const [messages, setMessages] = useState([]) + const [input, setInput] = useState('') + const [streaming, setStreaming] = useState(false) + const [streamText, setStreamText] = useState('') + const messagesEndRef = useRef(null) + const abortRef = useRef(null) + + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) + }, [messages, streamText]) + + const sendMessage = async () => { + if (!input.trim() || streaming) return + const userMsg: CoachMessage = { role: 'user', content: input.trim() } + const allMessages = [...messages, userMsg] + setMessages(allMessages) + setInput('') + setStreaming(true) + setStreamText('') + + const goalsDict = goals.targetPowerLevel || goals.metaFocus || goals.budget || (goals.focusAreas || []).length > 0 + ? { + targetPowerLevel: goals.targetPowerLevel, + metaFocus: goals.metaFocus, + budget: goals.budget, + focusAreas: goals.focusAreas, + } + : undefined + + try { + abortRef.current = new AbortController() + const fullText = await coachApi.coachChatStream( + deckId, + allMessages.map(m => ({ role: m.role, content: m.content })), + goalsDict, + (text) => setStreamText(text), + abortRef.current.signal + ) + + setMessages(prev => [...prev, { role: 'assistant', content: fullText }]) + setStreamText('') + } catch (e) { + if ((e as Error).name !== 'AbortError') { + // Fallback to non-streaming + try { + const resp = await coachApi.coachChat( + deckId, + allMessages.map(m => ({ role: m.role, content: m.content })), + goalsDict + ) + setMessages(prev => [...prev, { role: 'assistant', content: resp.content }]) + } catch (e2) { + setMessages(prev => [...prev, { + role: 'assistant', + content: `Error: ${(e2 as Error).message || 'Failed to get response'}` + }]) + } + } + setStreamText('') + } + setStreaming(false) + } + + return ( +
+ {/* Header */} +
+
+

+ + Chat: {deckName} +

+

+ {goals.metaFocus && `${goals.metaFocus} · `} + {goals.targetPowerLevel && `Power ${goals.targetPowerLevel} · `} + {goals.budget && `${goals.budget}`} +

+
+ +
+ + {/* Messages */} +
+ {messages.length === 0 && !streaming && ( +
+ +

Ask your coach anything about this deck.

+
+ {[ + 'What should I cut?', + 'How is my mana curve?', + 'Suggest win conditions', + 'Rate my removal suite', + ].map(q => ( + + ))} +
+
+ )} + + {messages.map((msg, i) => (
-
{msg.content}
))} + + {/* Streaming indicator */} + {streaming && ( +
+
+ {streamText || ( + + + Thinking... + + )} +
+
+ )}
+ + {/* Input */} +
+
+ setInput(e.target.value)} + onKeyDown={e => e.key === 'Enter' && !e.shiftKey && sendMessage()} + placeholder="Ask the coach..." + disabled={streaming} + className="flex-1 px-3 py-2 bg-bg-secondary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue disabled:opacity-50" + /> + +
+
) } -// ── Main Coach Page ─────────────────────────────────────────── +// ════════════════════════════════════════════════════════════ +// Main Coach Page +// ════════════════════════════════════════════════════════════ +type Tab = 'decks' | 'chat' | 'session' | 'history' | 'search' + export function CoachPage() { const [status, setStatus] = useState(null) const [decks, setDecks] = useState([]) - const [sessions, setSessions] = useState([]) + const [sessions, setSessions] = useState([]) const [loading, setLoading] = useState(true) - const [selectedSession, setSelectedSession] = useState(null) - const [generatingReport, setGeneratingReport] = useState(null) - const [downloadingEmbeddings, setDownloadingEmbeddings] = useState(false) - const [showSessions, setShowSessions] = useState(false) const [error, setError] = useState(null) - useEffect(() => { - async function load() { - try { - const [s, d, sess] = await Promise.all([ - coachApi.getCoachStatus(), - coachApi.getCoachDecks(), - coachApi.getCoachSessions(), - ]) - setStatus(s) - setDecks(d) - setSessions(sess) - } catch { /* ignore */ } - setLoading(false) - } - load() + // UI state + const [tab, setTab] = useState('decks') + const [selectedDeck, setSelectedDeck] = useState(null) + const [selectedSession, setSelectedSession] = useState(null) + const [goals, setGoals] = useState({}) + const [goalsCollapsed, setGoalsCollapsed] = useState(true) + const [generatingReport, setGeneratingReport] = useState(false) + const [runningSession, setRunningSession] = useState(null) + const [downloadingEmbeddings, setDownloadingEmbeddings] = useState(false) + + const loadData = useCallback(async () => { + try { + const [s, d, sess] = await Promise.all([ + coachApi.getCoachStatus(), + coachApi.getCoachDecks().catch(() => []), + coachApi.getCoachSessions().catch(() => ({ sessions: [] })), + ]) + setStatus(s) + setDecks(Array.isArray(d) ? d : []) + setSessions(Array.isArray(sess) ? sess : (sess as { sessions: CoachSessionSummary[] }).sessions || []) + } catch { /* ignore */ } + setLoading(false) }, []) - async function handleGenerateReport(deckId: number) { - setGeneratingReport(deckId) - setError(null) - try { - await coachApi.generateReport(deckId) - // Refresh decks - const d = await coachApi.getCoachDecks() - setDecks(d) - } catch (e) { - setError(e instanceof Error ? e.message : 'Report generation failed') - } - setGeneratingReport(null) - } + useEffect(() => { loadData() }, [loadData]) - async function handleDownloadEmbeddings() { + const handleDownloadEmbeddings = async () => { setDownloadingEmbeddings(true) try { await coachApi.downloadEmbeddings() @@ -97,48 +663,94 @@ export function CoachPage() { setDownloadingEmbeddings(false) } - async function viewSession(sessionId: string) { + const handleGenerateReports = async () => { + setGeneratingReport(true) + setError(null) + try { + await coachApi.generateReport() + await loadData() + } catch (e) { + setError(e instanceof Error ? e.message : 'Report generation failed') + } + setGeneratingReport(false) + } + + const handleRunSession = async (deck: CoachDeck) => { + setRunningSession(deck.deck_id) + setError(null) + try { + const deckSlug = deck.deck_name.toLowerCase().replace(/\s+/g, '-') + const goalsDict = goals.targetPowerLevel || goals.metaFocus || goals.budget || (goals.focusAreas || []).length > 0 + ? goals : undefined + const session = await coachApi.runCoachSession(deckSlug, goalsDict as Record) + setSelectedSession(session) + setSelectedDeck(deck) + setTab('session') + await loadData() + } catch (e) { + setError(e instanceof Error ? e.message : 'Session failed') + } + setRunningSession(null) + } + + const handleOpenChat = (deck: CoachDeck) => { + setSelectedDeck(deck) + setTab('chat') + } + + const handleViewSession = async (sessionId: string) => { try { const s = await coachApi.getCoachSession(sessionId) setSelectedSession(s) + // Find the matching deck + const matchDeck = decks.find(d => + d.deck_name.toLowerCase().replace(/\s+/g, '-') === s.deckId.toLowerCase() || + d.deck_name.toLowerCase() === s.deckId.toLowerCase() + ) + if (matchDeck) setSelectedDeck(matchDeck) + setTab('session') } catch { /* ignore */ } } if (loading) { - return
- } - - if (selectedSession) { return ( -
- setSelectedSession(null)} /> +
+
) } return (
-
-

- - Deck Coach -

-

AI-powered deck analysis and coaching

+ {/* Header */} +
+
+

+ + Deck Coach +

+

+ AI-powered deck analysis, coaching, and live chat +

+
- {/* Status */} -
+ {/* Status Bar */} +
- {!status?.embeddings_loaded && ( + {status?.deckReportsAvailable != null && status.deckReportsAvailable > 0 && ( + + )} + {!status?.embeddingsLoaded && ( @@ -147,70 +759,191 @@ export function CoachPage() { {error && (
- {error} + {error} +
)} - {/* Decks for coaching */} -
-

Your Decks

- {decks.length === 0 ? ( - - ) : ( -
- {decks.map(deck => ( -
-
-

{deck.deck_name}

-

{deck.commander}

-
-
- {deck.report_count} reports - {deck.last_report_date && Last: {new Date(deck.last_report_date).toLocaleDateString()}} -
-
- -
-
- ))} + {/* Tab Nav */} +
+ {([ + { id: 'decks', label: 'Decks', icon: ClipboardList }, + { id: 'history', label: 'Sessions', icon: History }, + { id: 'search', label: 'Cards Like', icon: Search }, + ] as { id: Tab; label: string; icon: typeof ClipboardList }[]).map(t => ( + + ))} + {tab === 'chat' && selectedDeck && ( +
+ + Chat +
+ )} + {tab === 'session' && selectedSession && ( +
+ + Results
)}
- {/* Sessions */} -
- - {showSessions && sessions.length > 0 && ( -
- {sessions.map(s => ( - - ))} + {/* Goals Panel (shown when on decks tab) */} + {tab === 'decks' && ( + setGoalsCollapsed(!goalsCollapsed)} + /> + )} + + {/* ── Decks Tab ─────────────────────────────────────── */} + {tab === 'decks' && ( +
+
+

Your Decks

+
- )} -
+ + {decks.length === 0 ? ( + + ) : ( +
+ {decks.map(deck => ( +
+
+

{deck.deck_name}

+

{deck.commander}

+
+
+ {deck.card_count} cards + {deck.has_report && ( + + Report + + )} +
+
+ + +
+
+ ))} +
+ )} +
+ )} + + {/* ── Chat Tab ──────────────────────────────────────── */} + {tab === 'chat' && selectedDeck && ( + setTab('decks')} + /> + )} + + {/* ── Session Results Tab ───────────────────────────── */} + {tab === 'session' && selectedSession && selectedDeck && ( + setTab('decks')} + onApplied={() => loadData()} + /> + )} + + {/* ── History Tab ───────────────────────────────────── */} + {tab === 'history' && ( +
+

Coaching Sessions

+ {sessions.length === 0 ? ( + + ) : ( +
+ {sessions.map(s => ( + + ))} +
+ )} +
+ )} + + {/* ── Cards Like Tab ────────────────────────────────── */} + {tab === 'search' && ( +
+
+

Cards Like This

+

+ Find similar cards using AI embeddings. Search by card name to discover alternatives. +

+
+ +
+ )}
) } diff --git a/frontend/commander-ai-lab-ui/src/types/index.ts b/frontend/commander-ai-lab-ui/src/types/index.ts index 825b96d..5e76930 100644 --- a/frontend/commander-ai-lab-ui/src/types/index.ts +++ b/frontend/commander-ai-lab-ui/src/types/index.ts @@ -345,31 +345,102 @@ export interface PreconDeck { // ── Coach ──────────────────────────────────────────────────── export interface CoachStatus { - llm_connected: boolean - active_model: string | null - embeddings_loaded: boolean - embedding_cards: number + llmConnected: boolean + llmModel: string | null + llmModels: string[] + embeddingsLoaded: boolean + embeddingCards: number + deckReportsAvailable: number + error: string | null } export interface CoachDeck { deck_id: number deck_name: string commander: string + card_count: number + has_report: boolean report_count: number last_report_date: string | null } -export interface CoachSession { - session_id: string - deck_name: string - created_at: string - messages: CoachMessage[] +export interface CoachGoals { + targetPowerLevel?: number | null + metaFocus?: string | null + budget?: string | null + focusAreas?: string[] } export interface CoachMessage { role: 'user' | 'assistant' content: string +} + +export interface CoachChatResponse { + content: string + model: string + prompt_tokens: number + completion_tokens: number +} + +export interface CoachSession { + sessionId: string + deckId: string timestamp: string + summary: string + suggestedCuts: CoachSuggestedCut[] + suggestedAdds: CoachSuggestedAdd[] + heuristicHints: string[] + manaBaseAdvice: string | null + rawTextExplanation: string + modelUsed: string + promptTokens: number + completionTokens: number + goals: CoachGoals | null +} + +export interface CoachSuggestedCut { + cardName: string + reason: string + replacementOptions: string[] + currentImpactScore: number +} + +export interface CoachSuggestedAdd { + cardName: string + role: string + reason: string + synergyWith: string[] + estimatedManaValue: number | null +} + +export interface CoachSessionSummary { + sessionId: string + deckId: string + timestamp: string + summary: string + cutsCount: number + addsCount: number +} + +export interface CoachApplyResult { + cuts: { name: string; status: string }[] + adds: { name: string; scryfall_id?: string; status: string }[] + errors: { name: string; error: string }[] + total_cuts: number + total_adds: number +} + +export interface CoachCardLikeResult { + name: string + similarity: number + types: string + mana_value: number + mana_cost: string + text: string + owned_qty: number + image_url: string | null + tcg_price: number | null } // ── ML Training ────────────────────────────────────────────── diff --git a/lab_api.py b/lab_api.py index 2a3fbdc..d410d3c 100644 --- a/lab_api.py +++ b/lab_api.py @@ -7021,10 +7021,41 @@ async def coach_status(): @app.get("/api/coach/decks") async def coach_list_decks(): - """List all decks with available reports.""" - if _coach_service is None: - raise HTTPException(500, "Coach service not initialized") - return {"decks": _coach_service.list_deck_reports()} + """List all decks available for coaching (from deck builder DB).""" + conn = _get_db_conn() + rows = conn.execute( + """SELECT d.id as deck_id, d.name as deck_name, d.commander, + COUNT(dc.id) as card_count + FROM decks d + LEFT JOIN deck_cards dc ON dc.deck_id = d.id + GROUP BY d.id + ORDER BY d.name""" + ).fetchall() + + # Also get report availability from coach service + report_ids = set() + if _coach_service: + report_ids = set(_coach_service.list_deck_reports()) + + decks = [] + for r in rows: + deck_name = r["deck_name"] + # Check if a report exists (by deck name slug match) + has_report = any( + deck_name.lower().replace(" ", "-") == rid.lower() or + deck_name.lower() == rid.lower() + for rid in report_ids + ) + decks.append({ + "deck_id": r["deck_id"], + "deck_name": r["deck_name"], + "commander": r["commander"] or "", + "card_count": r["card_count"], + "has_report": has_report, + "report_count": 1 if has_report else 0, + "last_report_date": None, + }) + return decks @app.get("/api/coach/decks/{deck_id}/report") @@ -7042,6 +7073,29 @@ class CoachRequestBody(BaseModel): goals: Optional[dict] = None +class CoachChatMessage(BaseModel): + role: str # "user" or "assistant" + content: str + +class CoachChatRequest(BaseModel): + deck_id: str + messages: list[dict] # conversation history [{role, content}] + goals: Optional[dict] = None + stream: Optional[bool] = False + +class CoachApplyRequest(BaseModel): + session_id: str + deck_id: int # numeric deck ID in the DB + accepted_cuts: list[str] = [] # card names to remove + accepted_adds: list[str] = [] # card names to add + +class CoachGoalsRequest(BaseModel): + target_power_level: Optional[int] = None # 1-10 + meta_focus: Optional[str] = None # aggro, control, combo, midrange, stax + budget: Optional[str] = None # budget, medium, no-limit + focus_areas: list[str] = [] # e.g., ["ramp", "card draw"] + + @app.post("/api/coach/decks/{deck_id}") async def coach_run_session(deck_id: str, body: CoachRequestBody = None): """Trigger a coaching session for a deck.""" @@ -7121,6 +7175,271 @@ async def coach_search_similar(card: str, colors: str = None, top_n: int = 10): return {"query": card, "matches": [m.to_dict() for m in matches]} +@app.post("/api/coach/chat") +async def coach_chat(body: CoachChatRequest): + """Multi-turn coaching chat. Sends conversation history to LLM and returns response.""" + if _coach_service is None: + raise HTTPException(500, "Coach service not initialized") + + # Load deck report for context + report = _coach_service.load_deck_report(body.deck_id) + + # Build system prompt with report context + from coach.prompt_template import build_system_prompt + from coach.models import CoachGoals + + goals = None + if body.goals: + try: + goals = CoachGoals(**body.goals) + except Exception: + goals = None + + system_prompt = "" + if report: + system_prompt = build_system_prompt(report, goals) + else: + system_prompt = ( + "You are an expert Magic: The Gathering Commander deck coach. " + "Answer questions about deck building, strategy, card choices, and game theory. " + "Be specific and actionable in your advice." + ) + + # Build messages array for LLM + messages = [{"role": "system", "content": system_prompt}] + for msg in body.messages: + messages.append({"role": msg.get("role", "user"), "content": msg.get("content", "")}) + + # Call LLM with multi-turn messages + try: + import json + from urllib.request import urlopen, Request as UrlRequest + from coach.config import LM_STUDIO_URL, LM_STUDIO_TIMEOUT + + model_name = _coach_llm._resolve_model() + llm_body = { + "model": model_name, + "messages": messages, + "temperature": 0.7, + "max_tokens": 4096, + } + + if body.stream: + # SSE streaming response + llm_body["stream"] = True + + async def generate(): + import asyncio + def _stream(): + req = UrlRequest( + f"{_coach_llm.base_url}/chat/completions", + data=json.dumps(llm_body).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST", + ) + import http.client + import urllib.parse + parsed = urllib.parse.urlparse(f"{_coach_llm.base_url}/chat/completions") + conn = http.client.HTTPConnection(parsed.hostname, parsed.port, timeout=LM_STUDIO_TIMEOUT) + conn.request("POST", parsed.path, body=json.dumps(llm_body).encode("utf-8"), + headers={"Content-Type": "application/json"}) + resp = conn.getresponse() + chunks = [] + while True: + line = resp.readline() + if not line: + break + line = line.decode("utf-8").strip() + if line.startswith("data: "): + data = line[6:] + if data == "[DONE]": + chunks.append("[DONE]") + break + chunks.append(data) + conn.close() + return chunks + + loop = asyncio.get_event_loop() + chunks = await loop.run_in_executor(None, _stream) + + for chunk_str in chunks: + if chunk_str == "[DONE]": + yield f"data: [DONE]\n\n" + break + try: + chunk = json.loads(chunk_str) + delta = chunk.get("choices", [{}])[0].get("delta", {}) + content = delta.get("content", "") + if content: + # Strip think tags from streaming chunks + yield f"data: {json.dumps({'content': content})}\n\n" + except json.JSONDecodeError: + continue + + return StreamingResponse(generate(), media_type="text/event-stream") + else: + # Non-streaming: regular call + req = UrlRequest( + f"{_coach_llm.base_url}/chat/completions", + data=json.dumps(llm_body).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST", + ) + + import asyncio + loop = asyncio.get_event_loop() + def _call(): + with urlopen(req, timeout=LM_STUDIO_TIMEOUT) as resp: + return json.loads(resp.read().decode("utf-8")) + + raw = await loop.run_in_executor(None, _call) + + content = raw.get("choices", [{}])[0].get("message", {}).get("content", "") + # Strip think tags + content = _coach_llm._strip_think_tags(content) + usage = raw.get("usage", {}) + + return { + "content": content, + "model": raw.get("model", ""), + "prompt_tokens": usage.get("prompt_tokens", 0), + "completion_tokens": usage.get("completion_tokens", 0), + } + + except Exception as e: + raise HTTPException(500, f"Chat failed: {e}") + + +@app.post("/api/coach/apply") +async def coach_apply_suggestions(body: CoachApplyRequest): + """Apply accepted coaching suggestions to a deck — remove cuts, add adds.""" + conn = _get_db_conn() + + # Verify deck exists + deck = conn.execute("SELECT * FROM decks WHERE id = ?", (body.deck_id,)).fetchone() + if not deck: + raise HTTPException(404, f"Deck {body.deck_id} not found") + + results = {"cuts": [], "adds": [], "errors": []} + + # Process cuts: remove cards by name + for card_name in body.accepted_cuts: + row = conn.execute( + "SELECT id FROM deck_cards WHERE deck_id = ? AND card_name = ? LIMIT 1", + (body.deck_id, card_name) + ).fetchone() + if row: + conn.execute("DELETE FROM deck_cards WHERE id = ?", (row["id"],)) + results["cuts"].append({"name": card_name, "status": "removed"}) + else: + # Try case-insensitive + row = conn.execute( + "SELECT id, card_name FROM deck_cards WHERE deck_id = ? AND LOWER(card_name) = LOWER(?) LIMIT 1", + (body.deck_id, card_name) + ).fetchone() + if row: + conn.execute("DELETE FROM deck_cards WHERE id = ?", (row["id"],)) + results["cuts"].append({"name": row["card_name"], "status": "removed"}) + else: + results["errors"].append({"name": card_name, "error": "Card not found in deck"}) + + # Process adds: look up in collection first, then Scryfall + for card_name in body.accepted_adds: + # Try to find in collection + ce = conn.execute( + "SELECT scryfall_id, name FROM collection_entries WHERE LOWER(name) = LOWER(?) LIMIT 1", + (card_name,) + ).fetchone() + + scryfall_id = None + resolved_name = card_name + + if ce: + scryfall_id = ce["scryfall_id"] + resolved_name = ce["name"] + else: + # Try Scryfall API lookup + try: + import urllib.parse + encoded = urllib.parse.quote(card_name) + scry_req = UrlRequest( + f"https://api.scryfall.com/cards/named?fuzzy={encoded}", + headers={"User-Agent": "commander-ai-lab/1.0"} + ) + with urlopen(scry_req, timeout=10) as resp: + card_data = json.loads(resp.read().decode("utf-8")) + scryfall_id = card_data.get("id") + resolved_name = card_data.get("name", card_name) + except Exception: + pass + + if scryfall_id: + # Check if already in deck + existing = conn.execute( + "SELECT id FROM deck_cards WHERE deck_id = ? AND scryfall_id = ?", + (body.deck_id, scryfall_id) + ).fetchone() + if not existing: + conn.execute( + "INSERT INTO deck_cards (deck_id, scryfall_id, card_name, quantity) VALUES (?, ?, ?, 1)", + (body.deck_id, scryfall_id, resolved_name) + ) + results["adds"].append({"name": resolved_name, "scryfall_id": scryfall_id, "status": "added"}) + else: + results["adds"].append({"name": resolved_name, "status": "already_in_deck"}) + else: + results["errors"].append({"name": card_name, "error": "Could not resolve card"}) + + conn.execute("UPDATE decks SET updated_at = datetime('now') WHERE id = ?", (body.deck_id,)) + conn.commit() + + results["total_cuts"] = len(results["cuts"]) + results["total_adds"] = len([a for a in results["adds"] if a["status"] == "added"]) + return results + + +@app.get("/api/coach/cards-like") +async def coach_cards_like(card: str, colors: str = None, top_n: int = 10): + """Find cards similar to a given card using embeddings. UI-friendly version.""" + if _coach_embeddings is None or not _coach_embeddings.loaded: + raise HTTPException(503, "Embeddings not loaded") + + color_filter = list(colors.upper()) if colors else None + matches = _coach_embeddings.search_similar( + query_card=card, color_filter=color_filter, top_n=top_n + ) + + conn = _get_db_conn() + results = [] + for m in matches: + # Check if card is in collection + owned = conn.execute( + "SELECT SUM(quantity) as qty FROM collection_entries WHERE LOWER(name) = LOWER(?)", + (m.name,) + ).fetchone() + owned_qty = (owned["qty"] or 0) if owned else 0 + + # Get image URL and price + ce = conn.execute( + "SELECT image_url, tcg_price FROM collection_entries WHERE LOWER(name) = LOWER(?) LIMIT 1", + (m.name,) + ).fetchone() + + results.append({ + "name": m.name, + "similarity": round(m.similarity, 4), + "types": m.types, + "mana_value": m.mana_value, + "mana_cost": m.mana_cost, + "text": m.text[:200] if m.text else "", + "owned_qty": owned_qty, + "image_url": ce["image_url"] if ce else None, + "tcg_price": ce["tcg_price"] if ce else None, + }) + + return {"query": card, "results": results} + + @app.post("/api/coach/reports/generate") async def coach_generate_reports(): """Rebuild all deck reports from batch result JSONs in results/."""