Compare commits

..
Author SHA1 Message Date
KoalaTrapLord 946a4a2bef test(rag): add tests/test_rag_store.py with full RAG pipeline coverage (closes #130) 2026-03-27 12:36:47 -04:00
KoalaTrapLord 08f34806d6 fix(deps): add rag and coach-embeddings optional-dependencies to pyproject.toml (closes #129) 2026-03-27 12:20:35 -04:00
KoalaTrapLord 281c839163 fix(rag): add ensure_rag_store() to lifespan handler so RAG builds under uvicorn ASGI mode (closes #125) 2026-03-27 12:20:08 -04:00
KoalaTrapLord 96e51f59ae fix(rag): batch_size param, failure tracking, sidecar build_meta, mana_cost in doc (closes #124, #126, #127, #128) 2026-03-27 12:13:36 -04:00
KoalaTrapLord 1d756bca49 fix(rag): use correct rag_store function names in routes/rag.py + wire batch_size (closes #123, #124) 2026-03-27 12:03:55 -04:00
KoalaTrapLord 61ac349445 fix: re-encode requirements.txt as UTF-8 (fixes pip install failure) 2026-03-27 11:14:34 -04:00
KoalaTrapLord 06ce5cc91a fix(rules): add nonbasic land guard before creature fallback (fixes #118) (#122) 2026-03-27 10:22:05 -04:00
KoalaTrapLord fc2e9d88d5 feat(rag): wire ensure_rag_store into main() startup (#121) 2026-03-27 09:13:03 -04:00
KoalaTrapLord 4b0cd34eda feat(rag): Phase 2 — ChromaDB vector index over bulk card DB (#120)
* feat(rag): Phase 2 — ChromaDB vector index over bulk card DB

* feat(rag): add routes/rag.py with build, search, status endpoints

* Update lab_api.pyfeat(rag): register rag_router in lab_api.py, remove old inline rag_status

* Update requirements.txtfeat(rag): add chromadb>=0.5.0 to requirements.txtfeat(rag): add chromadb to requirements.txt
2026-03-27 08:39:02 -04:00
KoalaTrapLord 83502ce726 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)
2026-03-27 07:51:01 -04:00
KoalaTrapLord 8df5900ff9 feat(rag): Phase 1 — Scryfall bulk offline card database (SQLite FTS5) (#117)
* feat(rag): add scryfall_bulk.py — offline SQLite FTS5 card database (RAG Phase 1)

* feat(rag): wire ensure_bulk_db() into startup and add /api/rag/status endpoint (RAG Phase 1)

* fix: migrate on_event to lifespan handler, fix /api/rag/status route ordering
2026-03-27 07:16:55 -04:00
KoalaTrapLord 864cefb8d1 feat(coach): Phase 5 - add PodPresence, TempoAssessment, MetaMatchup models + isQuickDigest flag (#108) 2026-03-27 04:22:23 -04:00
KoalaTrapLord 6c03e56794 fix(#116): dual card_records fallback JOIN, card_type/card_category fields, simulator land detection
- deck_service._compute_deck_analysis: add LEFT JOIN card_records with COALESCE on all fields
- deck_service: add _classify_card_category() with oracle-text keyword matching
- deckbuilder.GET /api/decks/{id}/cards: add card_records fallback JOIN + COALESCE, emit card_type/card_category per card
- deepseek._fetch_deck_card_data: add card_records fallback JOIN so lands get correct type_line before enrich_card()

Closes #116
2026-03-27 04:10:17 -04:00
KoalaTrapLord 4b92a5f364 Update models.pyfeat(coach): Phase 3 - add RemovalCoverage, RampQuality, DrawEngineProfile, WinCondition, AntiSynergyFlag models (#108) 2026-03-27 04:01:25 -04:00
KoalaTrapLord da0193eb11 Update coach_service.pyfeat(coach): Phase 2 - parse upgradePriority, commanderDependency, mulliganAnalysis in coach_service (#108)feat(coach): Phase 2 - parse new fields in coach_service (#108) 2026-03-27 03:45:32 -04:00
KoalaTrapLord 71e0db4985 feat(coach): Phase 2 - add upgradePriority, commanderDependency, mulliganAnalysis to prompt schema (#108) 2026-03-27 03:32:03 -04:00
KoalaTrapLord 825946ae75 fix(deckgen): Phase 1 - non-destructive smart substitution + 99-card guarantee (#111)
- Add SubstitutionMethod enum to track how each card was resolved
- smart_substitute() now always returns a valid card string (never None/drops)
  fallback chain: exact -> fuzzy -> functional analog -> original kept -> basic land
- apply_substitutions() loop no longer silently drops cards on failure
- _pad_to_99() circuit breaker added before BuildResult validation
- Pre-validation count logging per category for fast regression diagnosis
- Startup assertion: sum(SLOT_BUDGET) == 99 raises on app boot if misconfigured
- Prompt injection: slot budget + exact card count requirement sent to LLM
- run_substitution() anthropic fallback failure now pads with basic land instead of leaving slot missing
- SubstitutionBatchResult gains padded_slots field (> 0 = circuit breaker fired)
2026-03-27 03:27:52 -04:00
KoalaTrapLord bd4df0c263 Update models.pyfeat(coach): Phase 2 - add UpgradePriority, CommanderDependency, MulliganAnalysis models (#108) 2026-03-27 02:52:01 -04:00
KoalaTrapLord 113b5c63f0 docs: rewrite README to reflect full current feature set (#115) 2026-03-27 02:51:12 -04:00
21 changed files with 3080 additions and 898 deletions

No files matched your search

+192 -20
View File
@@ -24,6 +24,10 @@ from .config import (
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
@@ -61,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."""
@@ -71,11 +75,9 @@ class CoachService:
if re.sub(r'[^a-z0-9]', '', f.stem.lower()) == re.sub(r'[^a-z0-9]', '', deck_id.lower()):
report_path = f
break
if not report_path.exists():
logger.warning("Deck report not found: %s", deck_id)
return None
try:
with open(report_path, "r") as f:
data = json.load(f)
@@ -90,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."""
@@ -185,7 +187,7 @@ class CoachService:
continue
return sessions
# ── Main Coaching Pipeline ─────────────────────────────────
# ── Main Coaching Pipeline ───────────────────────────────
async def run_coaching_session(
self,
@@ -203,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:
@@ -215,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
@@ -223,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:
@@ -255,19 +253,16 @@ 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":
import anthropic as _anthropic
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,
@@ -278,10 +273,7 @@ class CoachService:
) as stream:
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,
@@ -304,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,
@@ -322,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", "")
@@ -353,7 +424,109 @@ class CoachService:
synergyWith=add_data.get("synergyWith", []),
))
# ── Status Check ───────────────────────────────────────────
# Phase 2
for item in data.get("upgradePriority", []):
if isinstance(item, dict):
session.upgradePriority.append(UpgradePriorityItem(
rank=item.get("rank", 0),
cut=item.get("cut", ""),
add=item.get("add", ""),
reasoning=item.get("reasoning", ""),
expectedImpact=item.get("expectedImpact", ""),
))
cd = data.get("commanderDependency")
if isinstance(cd, dict):
session.commanderDependency = CommanderDependency(
score=cd.get("score", 5),
dependentCards=cd.get("dependentCards", []),
recoveryPlan=cd.get("recoveryPlan", ""),
)
ma = data.get("mulliganAnalysis")
if isinstance(ma, dict):
session.mulliganAnalysis = MulliganAnalysis(
estimatedKeepRate=ma.get("estimatedKeepRate", ""),
worstOffenders=ma.get("worstOffenders", []),
recommendation=ma.get("recommendation", ""),
)
# 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."""
@@ -381,5 +554,4 @@ class CoachService:
status.embeddingsLoaded = self.embeddings.loaded
status.embeddingCards = self.embeddings.card_count
status.deckReportsAvailable = len(self.list_deck_reports())
return status
+140 -16
View File
@@ -4,11 +4,9 @@ Commander AI Lab — Coach Data Models
Pydantic v2 models for deck reports, coach requests/responses,
and coaching session persistence.
"""
from typing import List, Optional, Dict
from pydantic import BaseModel, Field
# ══════════════════════════════════════════════════════════════
# Deck Report Models (mirrors Java DeckReport)
# ══════════════════════════════════════════════════════════════
@@ -16,16 +14,16 @@ from pydantic import BaseModel, Field
class CardPerformance(BaseModel):
"""Per-card aggregated stats from simulation data."""
name: str
drawnRate: float = 0.0 # Fraction of games where card was drawn
castRate: float = 0.0 # Fraction of games where drawn AND cast
drawnRate: float = 0.0
castRate: float = 0.0
keptInOpeningHandRate: float = 0.0
deadCardRate: float = 0.0 # Drawn but never cast/used
impactScore: float = 0.0 # (winRateWhenCast - overallWinRate) * castRate
synergyScore: float = 0.0 # Co-occurrence uplift with other deck cards
clunkinessScore: float = 0.0 # deadCardRate * (1 - castRate)
deadCardRate: float = 0.0
impactScore: float = 0.0
synergyScore: float = 0.0
clunkinessScore: float = 0.0
avgTurnCast: Optional[float] = None
avgDamageDealt: float = 0.0
tags: List[str] = Field(default_factory=list) # ramp, draw, removal, etc.
tags: List[str] = Field(default_factory=list)
class MatchupRecord(BaseModel):
@@ -47,7 +45,7 @@ class DeckMeta(BaseModel):
class DeckStructure(BaseModel):
"""Deck composition breakdown."""
landCount: int = 0
curveBuckets: List[int] = Field(default_factory=lambda: [0]*8) # CMC 0-7+
curveBuckets: List[int] = Field(default_factory=lambda: [0]*8)
cardTypeCounts: Dict[str, int] = Field(default_factory=dict)
functionalCounts: Dict[str, int] = Field(default_factory=dict)
@@ -56,7 +54,7 @@ class ComboRecord(BaseModel):
"""Known combo with performance data."""
cardNames: List[str]
winRateWhenAssembled: float = 0.0
assemblyRate: float = 0.0 # Fraction of games where all pieces drawn
assemblyRate: float = 0.0
class DeckReport(BaseModel):
@@ -83,10 +81,10 @@ class DeckReport(BaseModel):
class CoachGoals(BaseModel):
"""User-specified coaching goals."""
targetPowerLevel: Optional[int] = None # 1-10
metaFocus: Optional[str] = None # aggro, control, combo, midrange, stax
budget: Optional[str] = None # budget, medium, no-limit
focusAreas: List[str] = Field(default_factory=list) # e.g., ["ramp", "card draw"]
targetPowerLevel: Optional[int] = None
metaFocus: Optional[str] = None
budget: Optional[str] = None
focusAreas: List[str] = Field(default_factory=list)
class CoachRequest(BaseModel):
@@ -106,12 +104,120 @@ class SuggestedCut(BaseModel):
class SuggestedAdd(BaseModel):
"""A card the coach suggests adding."""
cardName: str
role: str = "" # ramp, draw, removal, finisher, etc.
role: str = ""
reason: str = ""
synergyWith: List[str] = Field(default_factory=list)
estimatedManaValue: Optional[float] = None
# ══════════════════════════════════════════════════════════════
# Phase 2: Structured Analysis Models
# ══════════════════════════════════════════════════════════════
class UpgradePriorityItem(BaseModel):
"""A single prioritized cut/add swap ranked by expected impact."""
rank: int
cut: str
add: str
reasoning: str = ""
expectedImpact: str = ""
class CommanderDependency(BaseModel):
"""How reliant the deck is on having the commander in play."""
score: int = 5
dependentCards: List[str] = Field(default_factory=list)
recoveryPlan: str = ""
class MulliganAnalysis(BaseModel):
"""Opening hand keepability assessment."""
estimatedKeepRate: str = ""
worstOffenders: List[str] = Field(default_factory=list)
recommendation: str = ""
# ══════════════════════════════════════════════════════════════
# Phase 3: Qualitative Analysis Models
# ══════════════════════════════════════════════════════════════
class RemovalCoverage(BaseModel):
"""Categorized removal package by permanent type."""
creatures: str = ""
artifacts: str = ""
enchantments: str = ""
planeswalkers: str = ""
lands: str = ""
massRemoval: str = ""
gaps: List[str] = Field(default_factory=list)
class RampQuality(BaseModel):
"""Ramp package breakdown by subtype and fragility."""
manaRocks: str = ""
landFetch: str = ""
manaDorks: str = ""
costReducers: str = ""
fragility: str = ""
canReachFourByTurnThree: str = ""
class DrawEngineProfile(BaseModel):
"""Card draw sustainability analysis."""
burstDraw: List[str] = Field(default_factory=list)
repeatableEngines: List[str] = Field(default_factory=list)
assessment: str = ""
sustainability: str = ""
class WinCondition(BaseModel):
"""A named win condition with independence rating."""
name: str
cards: List[str] = Field(default_factory=list)
independence: str = ""
description: str = ""
class AntiSynergyFlag(BaseModel):
"""An internal conflict between cards in the deck."""
cards: List[str] = Field(default_factory=list)
conflict: str = ""
severity: str = ""
# ══════════════════════════════════════════════════════════════
# Phase 5: Pod Politics & Meta Awareness Models
# ══════════════════════════════════════════════════════════════
class PodPresence(BaseModel):
"""Commander pod politics and threat assessment."""
threatLevel: str = "" # early/medium/late/low
politicalTools: List[str] = Field(default_factory=list)
adversarialRating: str = "" # low/medium/high
recommendation: str = ""
class TempoAssessment(BaseModel):
"""When the deck 'comes online' and whether that's fast enough."""
peakTurn: int = 0
profile: str = "" # early/mid/late
survivalWindow: str = "" # Assessment of whether it survives to peak
assessment: str = ""
class MetaMatchup(BaseModel):
"""Strategic reasoning about deck vs a meta archetype."""
archetype: str # fast-combo, stax, control, aggro, etc.
assessment: str = ""
keyThreats: List[str] = Field(default_factory=list)
keyAnswers: List[str] = Field(default_factory=list)
overallRating: str = "" # favored/even/unfavored
# ══════════════════════════════════════════════════════════════
# Coach Session (persisted result)
# ══════════════════════════════════════════════════════════════
class CoachSession(BaseModel):
"""
Complete coaching session result.
@@ -130,6 +236,24 @@ class CoachSession(BaseModel):
promptTokens: int = 0
completionTokens: int = 0
goals: Optional[CoachGoals] = None
isQuickDigest: bool = False # True for quick digest, False for full analysis
# Phase 2: Structured analysis fields
upgradePriority: List[UpgradePriorityItem] = Field(default_factory=list)
commanderDependency: Optional[CommanderDependency] = None
mulliganAnalysis: Optional[MulliganAnalysis] = None
# Phase 3: Qualitative analysis fields
removalCoverage: Optional[RemovalCoverage] = None
rampQuality: Optional[RampQuality] = None
drawEngineProfile: Optional[DrawEngineProfile] = None
winConditions: List[WinCondition] = Field(default_factory=list)
antiSynergyFlags: List[AntiSynergyFlag] = Field(default_factory=list)
# Phase 5: Pod politics & meta
podPresence: Optional[PodPresence] = None
tempoAssessment: Optional[TempoAssessment] = None
metaMatchups: List[MetaMatchup] = Field(default_factory=list)
class ApplyRequest(BaseModel):
+275 -153
View File
@@ -37,6 +37,17 @@ RULES:
- When evaluating synergy, use the synergyScore: cards below 0.1 have weak co-occurrence with the rest of the deck.
- Use perArchetypeWinRates to identify which matchup types this deck struggles with and tailor suggestions accordingly.
- Provide at least 5 heuristic hints covering different strategic dimensions.
- 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:
@@ -61,6 +72,25 @@ You MUST respond ONLY with valid JSON matching this exact structure:
"strategicRationale": "How this card fits into the overall game plan and addresses a specific gap"
}}
],
"upgradePriority": [
{{
"rank": 1,
"cut": "Card to remove",
"add": "Card to add instead",
"reasoning": "Why this is the single highest-impact swap",
"expectedImpact": "high|medium|low"
}}
],
"commanderDependency": {{
"score": 5,
"dependentCards": ["Card that is nearly dead without commander"],
"recoveryPlan": "Strategy if the commander gets removed or tucked repeatedly"
}},
"mulliganAnalysis": {{
"estimatedKeepRate": "Percentage estimate of keepable 7-card hands, e.g. ~70%",
"worstOffenders": ["Card most responsible for unkeepable hands"],
"recommendation": "What to change to improve opening hand quality"
}},
"manaCurveAnalysis": {{
"currentAssessment": "Detailed analysis of the current mana curve distribution and its implications",
"recommendations": "Specific suggestions for curve adjustments with reasoning",
@@ -80,11 +110,102 @@ 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
@@ -122,144 +243,136 @@ Avg Game Length: {avg_game_length:.1f} turns
Based on this simulation data, provide your coaching suggestions."""
def _format_goals_section(goals: Optional[CoachGoals]) -> str:
"""Format goals into system prompt rules."""
if not goals:
return "- Aim for a balanced, well-rounded deck."
lines = []
if goals.targetPowerLevel is not None:
lines.append(f"- Target power level: {goals.targetPowerLevel}/10.")
if goals.targetPowerLevel <= 4:
lines.append("- Keep suggestions casual and fun-focused.")
elif goals.targetPowerLevel >= 8:
lines.append("- Prioritize efficiency and competitive staples.")
if goals.metaFocus:
lines.append(f"- Optimize for a {goals.metaFocus} strategy.")
if goals.budget:
budget_map = {"budget": "under $5 per card", "medium": "under $20 per card",
"no-limit": "no budget restrictions"}
lines.append(f"- Budget constraint: {budget_map.get(goals.budget, goals.budget)}.")
if goals.focusAreas:
lines.append(f"- Focus improvement on: {', '.join(goals.focusAreas)}.")
return "\n".join(lines) if lines else "- Aim for a balanced, well-rounded deck."
"""Format goals into system prompt rules."""
if not goals:
return "- Aim for a balanced, well-rounded deck."
lines = []
if goals.targetPowerLevel is not None:
lines.append(f"- Target power level: {goals.targetPowerLevel}/10.")
if goals.targetPowerLevel <= 4:
lines.append("- Keep suggestions casual and fun-focused.")
elif goals.targetPowerLevel >= 8:
lines.append("- Prioritize efficiency and competitive staples.")
if goals.metaFocus:
lines.append(f"- Optimize for a {goals.metaFocus} strategy.")
if goals.budget:
budget_map = {"budget": "under $5 per card", "medium": "under $20 per card",
"no-limit": "no budget restrictions"}
lines.append(f"- Budget constraint: {budget_map.get(goals.budget, goals.budget)}.")
if goals.focusAreas:
lines.append(f"- Focus improvement on: {', '.join(goals.focusAreas)}.")
return "\n".join(lines) if lines else "- Aim for a balanced, well-rounded deck."
def _format_curve(buckets: List[int]) -> str:
"""Format mana curve buckets as a readable string."""
labels = ["0", "1", "2", "3", "4", "5", "6", "7+"]
parts = [f"{labels[i]}={buckets[i]}" for i in range(min(len(buckets), 8)) if buckets[i] > 0]
return ", ".join(parts) if parts else "not available (analyze deck structure manually)"
"""Format mana curve buckets as a readable string."""
labels = ["0", "1", "2", "3", "4", "5", "6", "7+"]
parts = [f"{labels[i]}={buckets[i]}" for i in range(min(len(buckets), 8)) if buckets[i] > 0]
return ", ".join(parts) if parts else "not available (analyze deck structure manually)"
def _format_top_cards(cards: List[CardPerformance], top_n: int = 10) -> str:
"""Format the best performing cards with extended stats."""
sorted_cards = sorted(cards, key=lambda c: c.impactScore, reverse=True)
lines = []
for c in sorted_cards[:top_n]:
tags = f" [{', '.join(c.tags)}]" if c.tags else ""
turn_str = f", avgTurnCast={c.avgTurnCast:.1f}" if c.avgTurnCast is not None else ""
lines.append(
f"- {c.name}: impact={c.impactScore:.3f}, "
f"castRate={c.castRate:.1%}, drawnRate={c.drawnRate:.1%}, "
f"synergy={c.synergyScore:.3f}{turn_str}{tags}"
)
return "\n".join(lines) if lines else "No card performance data available."
"""Format the best performing cards with extended stats."""
sorted_cards = sorted(cards, key=lambda c: c.impactScore, reverse=True)
lines = []
for c in sorted_cards[:top_n]:
tags = f" [{', '.join(c.tags)}]" if c.tags else ""
turn_str = f", avgTurnCast={c.avgTurnCast:.1f}" if c.avgTurnCast is not None else ""
lines.append(
f"- {c.name}: impact={c.impactScore:.3f}, "
f"castRate={c.castRate:.1%}, drawnRate={c.drawnRate:.1%}, "
f"synergy={c.synergyScore:.3f}{turn_str}{tags}"
)
return "\n".join(lines) if lines else "No card performance data available."
def _format_underperformers(cards: List[CardPerformance],
underperformer_names: List[str]) -> str:
"""Format underperforming cards with full available stats."""
card_map = {c.name: c for c in cards}
lines = []
for name in underperformer_names[:MAX_UNDERPERFORMERS]:
c = card_map.get(name)
if c:
turn_str = f", avgTurnCast={c.avgTurnCast:.1f}" if c.avgTurnCast is not None else ""
lines.append(
f"- {c.name}: impact={c.impactScore:.3f}, "
f"deadCardRate={c.deadCardRate:.1%}, "
f"castRate={c.castRate:.1%}, "
f"clunkiness={c.clunkinessScore:.3f}, "
f"synergy={c.synergyScore:.3f}, "
f"keptInOpeningHand={c.keptInOpeningHandRate:.1%}"
f"{turn_str}"
)
else:
lines.append(f"- {name}: (no detailed stats)")
return "\n".join(lines) if lines else "No clear underperformers identified."
underperformer_names: List[str]) -> str:
"""Format underperforming cards with full available stats."""
card_map = {c.name: c for c in cards}
lines = []
for name in underperformer_names[:MAX_UNDERPERFORMERS]:
c = card_map.get(name)
if c:
turn_str = f", avgTurnCast={c.avgTurnCast:.1f}" if c.avgTurnCast is not None else ""
lines.append(
f"- {c.name}: impact={c.impactScore:.3f}, "
f"deadCardRate={c.deadCardRate:.1%}, "
f"castRate={c.castRate:.1%}, "
f"clunkiness={c.clunkinessScore:.3f}, "
f"synergy={c.synergyScore:.3f}, "
f"keptInOpeningHand={c.keptInOpeningHandRate:.1%}"
f"{turn_str}"
)
else:
lines.append(f"- {name}: (no detailed stats)")
return "\n".join(lines) if lines else "No clear underperformers identified."
def _format_candidates(candidates: Dict[str, List[dict]]) -> str:
"""Format replacement candidates grouped by the card they'd replace."""
if not candidates:
return (
"No embedding-based candidates available. "
"Use your MTG knowledge to suggest cards that fit the deck's "
"color identity, strategy, and missing functional roles. "
"Suggest at least 3 different cards across different roles "
"(ramp, draw, removal, threats, utility)."
)
lines = []
for underperformer, replacements in list(candidates.items())[:MAX_UNDERPERFORMERS]:
lines.append(f"\nReplacements for '{underperformer}':")
for r in replacements[:MAX_CANDIDATES_PER_UNDERPERFORMER]:
name = r.get("name", "Unknown")
types = r.get("types", "")
mv = r.get("mana_value", "?")
sim = r.get("similarity", 0)
text_preview = r.get("text", "")[:80]
lines.append(f" - {name} (MV:{mv}, {types}) [similarity: {sim:.3f}]")
if text_preview:
lines.append(f" Oracle: {text_preview}")
lines.append("\nChoose from these candidates when possible, but also suggest ")
lines.append("cards NOT in this list if they better address the deck's weaknesses.")
return "\n".join(lines)
"""Format replacement candidates grouped by the card they'd replace."""
if not candidates:
return (
"No embedding-based candidates available. "
"Use your MTG knowledge to suggest cards that fit the deck's "
"color identity, strategy, and missing functional roles. "
"Suggest at least 3 different cards across different roles "
"(ramp, draw, removal, threats, utility)."
)
lines = []
for underperformer, replacements in list(candidates.items())[:MAX_UNDERPERFORMERS]:
lines.append(f"\nReplacements for '{underperformer}':")
for r in replacements[:MAX_CANDIDATES_PER_UNDERPERFORMER]:
name = r.get("name", "Unknown")
types = r.get("types", "")
mv = r.get("mana_value", "?")
sim = r.get("similarity", 0)
text_preview = r.get("text", "")[:80]
lines.append(f" - {name} (MV:{mv}, {types}) [similarity: {sim:.3f}]")
if text_preview:
lines.append(f" Oracle: {text_preview}")
lines.append("\nChoose from these candidates when possible, but also suggest ")
lines.append("cards NOT in this list if they better address the deck's weaknesses.")
return "\n".join(lines)
def _format_matchups(report: DeckReport) -> str:
"""Format per-opponent matchup data."""
if not report.matchups:
return "No matchup data available."
lines = []
for m in report.matchups:
lines.append(
f"- vs {m.opponentDeck}: {m.winRate:.1%} "
f"({m.gamesPlayed} games)"
)
return "\n".join(lines)
"""Format per-opponent matchup data."""
if not report.matchups:
return "No matchup data available."
lines = []
for m in report.matchups:
lines.append(
f"- vs {m.opponentDeck}: {m.winRate:.1%} "
f"({m.gamesPlayed} games)"
)
return "\n".join(lines)
def _format_archetypes(report: DeckReport) -> str:
"""Format per-archetype win rates from DeckMeta."""
rates = report.meta.perArchetypeWinRates
if not rates:
return "No archetype breakdown available."
lines = []
for archetype, wr in sorted(rates.items(), key=lambda x: x[1]):
lines.append(f"- vs {archetype}: {wr:.1%}")
return "\n".join(lines)
"""Format per-archetype win rates from DeckMeta."""
rates = report.meta.perArchetypeWinRates
if not rates:
return "No archetype breakdown available."
lines = []
for archetype, wr in sorted(rates.items(), key=lambda x: x[1]):
lines.append(f"- vs {archetype}: {wr:.1%}")
return "\n".join(lines)
def _format_combos(report: DeckReport) -> str:
"""Format known combos section."""
if not report.knownCombos:
return ""
lines = ["### Known Combos"]
for combo in report.knownCombos:
cards = " + ".join(combo.cardNames)
lines.append(
f"- {cards}: winRate={combo.winRateWhenAssembled:.1%}, "
f"assemblyRate={combo.assemblyRate:.1%}"
)
return "\n".join(lines)
"""Format known combos section."""
if not report.knownCombos:
return ""
lines = ["### Known Combos"]
for combo in report.knownCombos:
cards = " + ".join(combo.cardNames)
lines.append(
f"- {cards}: winRate={combo.winRateWhenAssembled:.1%}, "
f"assemblyRate={combo.assemblyRate:.1%}"
)
return "\n".join(lines)
# ══════════════════════════════════════════════════════════════
@@ -267,42 +380,51 @@ def _format_combos(report: DeckReport) -> str:
# ══════════════════════════════════════════════════════════════
def build_system_prompt(report: DeckReport,
goals: Optional[CoachGoals] = None) -> str:
"""Build the system prompt with deck-specific rules."""
color_str = "/".join(report.colorIdentity) if report.colorIdentity else "any"
return SYSTEM_PROMPT_TEMPLATE.format(
commander=report.commander,
color_identity=color_str,
goals_section=_format_goals_section(goals),
)
goals: Optional[CoachGoals] = None) -> str:
"""Build the system prompt with deck-specific rules."""
color_str = "/".join(report.colorIdentity) if report.colorIdentity else "any"
return SYSTEM_PROMPT_TEMPLATE.format(
commander=report.commander,
color_identity=color_str,
goals_section=_format_goals_section(goals),
)
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."""
color_str = "/".join(report.colorIdentity) if report.colorIdentity else "unknown"
type_parts = [f"{k}={v}" for k, v in report.structure.cardTypeCounts.items()] \
if report.structure.cardTypeCounts else ["unknown"]
role_parts = [f"{k}={v}" for k, v in report.structure.functionalCounts.items()] \
if report.structure.functionalCounts else ["unknown"]
return USER_PROMPT_TEMPLATE.format(
deck_name=report.deckId,
commander=report.commander,
color_identity=color_str,
games_simulated=report.meta.gamesSimulated,
win_rate=report.meta.overallWinRate,
avg_game_length=report.meta.avgGameLength,
land_count=report.structure.landCount,
curve_summary=_format_curve(report.structure.curveBuckets),
type_summary=", ".join(type_parts),
role_summary=", ".join(role_parts),
matchup_section=_format_matchups(report),
archetype_section=_format_archetypes(report),
top_cards=_format_top_cards(report.cards),
underperformer_section=_format_underperformers(
report.cards, report.underperformers),
candidate_section=_format_candidates(candidates or {}),
combo_section=_format_combos(report),
)
candidates: Dict[str, List[dict]] = None) -> str:
"""Build the user prompt with full deck report data."""
color_str = "/".join(report.colorIdentity) if report.colorIdentity else "unknown"
type_parts = [f"{k}={v}" for k, v in report.structure.cardTypeCounts.items()] \
if report.structure.cardTypeCounts else ["unknown"]
role_parts = [f"{k}={v}" for k, v in report.structure.functionalCounts.items()] \
if report.structure.functionalCounts else ["unknown"]
return USER_PROMPT_TEMPLATE.format(
deck_name=report.deckId,
commander=report.commander,
color_identity=color_str,
games_simulated=report.meta.gamesSimulated,
win_rate=report.meta.overallWinRate,
avg_game_length=report.meta.avgGameLength,
land_count=report.structure.landCount,
curve_summary=_format_curve(report.structure.curveBuckets),
type_summary=", ".join(type_parts),
role_summary=", ".join(role_parts),
matchup_section=_format_matchups(report),
archetype_section=_format_archetypes(report),
top_cards=_format_top_cards(report.cards),
underperformer_section=_format_underperformers(
report.cards, report.underperformers),
candidate_section=_format_candidates(candidates or {}),
combo_section=_format_combos(report),
)
+11
View File
@@ -1,6 +1,9 @@
"""
Commander AI Lab — Deck Generation System Prompt
═════════════════════════════════════════════════
Phase 1 changes:
- Added explicit 99-card count enforcement rule
- Clarified that commander counts separately (not in the 99)
"""
SYSTEM_PROMPT = """You are an elite Magic: The Gathering Commander (EDH) deck builder with encyclopedic knowledge of every card ever printed, current metagame trends, and Commander Rules Committee bracket guidelines.
@@ -9,6 +12,8 @@ Your task is to build a complete, legal 100-card Commander deck.
RULES:
- Exactly 100 cards total: 1 commander + 99 other cards
- The commander IS included in the cards list but counts as card #100
- The other 99 cards must exactly match the SLOT BUDGET provided in the user prompt
- No more than 1 copy of any card except basic lands
- All cards must share the commander's color identity
- All cards must be legal in Commander format
@@ -16,6 +21,12 @@ RULES:
- Include approximately: 10 ramp sources, 10 card draw sources, 8-10 targeted removal, 2-3 board wipes, 3-5 protection pieces
- Build a cohesive strategy around the commander's abilities
CARD COUNT ENFORCEMENT (critical):
- The user prompt will specify a SLOT BUDGET with exact per-category counts
- You MUST fill every slot — returning fewer cards than the budget specifies will cause a validation error
- If you cannot find a perfect fit for a slot, use the best available card for that role
- Basic lands count toward the Land slot total
BRACKET RULES (Commander Rules Committee):
- Bracket 1 (Casual): Precon-level. No tutors, no fast mana, no infinite combos, no mass land destruction. Game Changers: 0
- Bracket 2 (Upgraded Casual): Moderate power. Limited tutors (1-2 total), no 2-card infinite combos, no fast mana except Sol Ring. Game Changers: 0-1
+41 -12
View File
@@ -1,11 +1,33 @@
"""
Commander AI Lab — Deck Generation User Prompt Builder
═══════════════════════════════════════════════════════
Constructs the user message for Claude Opus deck generation
from a DeckGenV3Request.
Constructs the user message for LLM deck generation.
Phase 1 changes:
- slot_budget parameter injected into prompt so LLM targets exactly 99 cards
- Explicit card count requirement in prompt text
"""
from typing import List
from typing import Dict, List, Optional
def build_slot_budget_segment(slot_budget: Dict[str, int]) -> str:
"""
Build the slot budget prompt segment.
Informs the LLM of exact per-category card counts so it never undershoots.
"""
total = sum(slot_budget.values())
lines = [f"SLOT BUDGET (you must fill EXACTLY {total} cards, excluding the commander):"]
for cat, count in slot_budget.items():
lines.append(f" {cat}: {count}")
lines.append(f" TOTAL: {total}")
lines.append("")
lines.append(
f"CRITICAL: Return exactly {total} cards in the JSON 'cards' array "
"(not counting the commander). Every slot in the budget above must be filled. "
"Do not return more or fewer cards."
)
return "\n".join(lines)
def build_user_prompt(
@@ -18,6 +40,7 @@ def build_user_prompt(
budget_mode: str = "total",
omit_cards: List[str] = None,
collection_summary: str = "",
slot_budget: Optional[Dict[str, int]] = None,
) -> str:
"""
Build the user prompt for deck generation.
@@ -26,12 +49,13 @@ def build_user_prompt(
commander: Commander card name
commander_type: Type line from Scryfall
color_identity: List of color letters, e.g. ["U", "B"]
strategy: User-specified strategy focus (e.g. "zombie tokens")
strategy: User-specified strategy focus
target_bracket: Target bracket level 1-4
budget_usd: Optional budget constraint
budget_mode: "total" for total deck cost, "per_card" for per-card max
omit_cards: Cards to exclude
collection_summary: Pre-built collection summary text block
slot_budget: Dict of category -> card count (must sum to 99)
"""
ci_str = ", ".join(color_identity) if color_identity else "Unknown"
@@ -60,16 +84,21 @@ def build_user_prompt(
lines.append(f"OMIT LIST (do NOT include these cards): {', '.join(omit_cards)}")
if collection_summary:
lines.append(f"")
lines.append("")
lines.append(collection_summary)
lines.append(f"")
lines.append(f"STRONGLY PREFER cards from the collection when they fit the strategy.")
lines.append("")
lines.append("STRONGLY PREFER cards from the collection when they fit the strategy.")
else:
lines.append(f"")
lines.append(f"No collection data available — suggest the best cards regardless of ownership.")
lines.append("")
lines.append("No collection data available — suggest the best cards regardless of ownership.")
lines.append(f"")
lines.append(f"Build the deck as a structured JSON response. Include the commander in the cards list.")
lines.append(f"Ensure exactly 100 total cards. Assign role_tags and bracket info accurately.")
# Inject slot budget so LLM knows exact target counts
if slot_budget:
lines.append("")
lines.append(build_slot_budget_segment(slot_budget))
lines.append("")
lines.append("Build the deck as a structured JSON response. Include the commander in the cards list.")
lines.append("Ensure exactly 100 total cards. Assign role_tags and bracket info accurately.")
return "\n".join(lines)
+20 -4
View File
@@ -3,6 +3,10 @@ Commander AI Lab — Substitution Schemas
════════════════════════════════════════
Pydantic models and JSON Schema dicts for the
Smart Substitution Engine output.
Phase 1 changes:
- SubstitutionBatchResult gains padded_slots field
- DeckCardWithStatus gains original_card field (was missing)
"""
from typing import List, Optional
@@ -25,9 +29,10 @@ class CardAlternative(BaseModel):
class DeckCardWithStatus(BaseModel):
"""A deck card enriched with ownership and substitution data."""
class Config:
extra = "ignore"
name: str
count: int = 1
category: str = ""
@@ -41,9 +46,15 @@ class DeckCardWithStatus(BaseModel):
owned_qty: int = 0
scryfall_id: str = ""
status: str = Field(default="owned", description="owned | substituted | missing")
original_card: Optional[str] = Field(default=None, description="Original card name if this is a substitution")
original_card: Optional[str] = Field(
default=None,
description="Original card name before substitution (None if not substituted)"
)
alternatives: List[CardAlternative] = Field(default_factory=list)
selected_substitute: Optional[str] = Field(default=None, description="User-selected substitute name")
selected_substitute: Optional[str] = Field(
default=None,
description="The substitute card name chosen for this slot"
)
class SubstitutionRequest(BaseModel):
@@ -61,11 +72,16 @@ class SubstitutionBatchResult(BaseModel):
owned_count: int = 0
substituted_count: int = 0
missing_count: int = 0
padded_slots: int = Field(
default=0,
description="Number of slots filled by the pad-to-99 circuit breaker. "
"Any value > 0 indicates a bug in the substitution pipeline."
)
cards: List[DeckCardWithStatus] = Field(default_factory=list)
# ══════════════════════════════════════════════════════════════
# JSON Schema dicts (for Perplexity fallback substitution)
# JSON Schema dicts (for LLM fallback substitution)
# ══════════════════════════════════════════════════════════════
SUBSTITUTION_RESPONSE_SCHEMA = {
+391 -107
View File
@@ -4,12 +4,21 @@ Commander AI Lab — V3 Deck Generator Service
Generates Commander decks using Anthropic Claude
structured JSON output, then runs ownership check
and smart substitution.
Phase 1 changes:
- smart_substitute() non-destructive contract (never returns None)
- apply_substitutions() loop never silently drops cards
- _pad_to_99() circuit breaker before BuildResult validation
- Pre-validation count logging per category
- Startup SLOT_BUDGET assertion
"""
import json
import logging
import re
import sqlite3
from dataclasses import dataclass
from enum import Enum
from typing import List, Optional, Tuple
from urllib.request import urlopen, Request
@@ -32,6 +41,236 @@ from ..config import (
logger = logging.getLogger("coach.deckgen")
# ══════════════════════════════════════════════════════════════
# Slot Budget — MUST sum to 99 (commander is the 100th card)
# ══════════════════════════════════════════════════════════════
SLOT_BUDGET: dict[str, int] = {
"Land": 36,
"Ramp": 10,
"Card Draw": 10,
"Removal": 8,
"Board Wipe": 3,
"Win Condition": 4,
"Protection": 4,
"Synergy": 20,
"Creature": 2,
"Utility": 2,
}
# ── Startup assertion — catches misconfigured budgets at boot time ──
_SLOT_BUDGET_TOTAL = sum(SLOT_BUDGET.values())
assert _SLOT_BUDGET_TOTAL == 99, (
f"SLOT_BUDGET sums to {_SLOT_BUDGET_TOTAL}, must equal 99. "
f"Current distribution: {SLOT_BUDGET}"
)
# ══════════════════════════════════════════════════════════════
# SubstitutionMethod — tracks HOW each card was resolved
# ══════════════════════════════════════════════════════════════
class SubstitutionMethod(str, Enum):
EXACT_MATCH = "exact_match" # Card found verbatim in collection
FUZZY_MATCH = "fuzzy_match" # Case/punctuation-normalised match
EMBEDDING_ANALOG = "embedding_analog" # Embedding similarity match
ANTHROPIC_FALLBACK = "anthropic_fallback" # LLM suggested replacement
ORIGINAL_KEPT = "original_kept" # Not in collection but Scryfall-valid
BASIC_LAND_FALLBACK = "basic_land_fallback" # Last resort — slot becomes a basic
@dataclass
class SubstitutionRecord:
"""Audit trail entry for a single card's resolution."""
original: str
resolved: str
method: SubstitutionMethod
in_collection: bool
# ══════════════════════════════════════════════════════════════
# Color → Basic Land mapping
# ══════════════════════════════════════════════════════════════
_COLOR_TO_BASIC: dict[str, str] = {
"W": "Plains",
"U": "Island",
"B": "Swamp",
"R": "Mountain",
"G": "Forest",
}
_BASIC_NAMES: set[str] = set(_COLOR_TO_BASIC.values()) | {"Wastes"}
def _get_basic_land(color_identity: List[str]) -> str:
"""Return the first appropriate basic land for the given color identity."""
for color in color_identity:
if color.upper() in _COLOR_TO_BASIC:
return _COLOR_TO_BASIC[color.upper()]
return "Wastes" # Colorless commander
# ══════════════════════════════════════════════════════════════
# Non-destructive Smart Substitution
# ══════════════════════════════════════════════════════════════
def smart_substitute(
card_name: str,
collection: set[str],
color_identity: List[str],
embedding_index=None,
) -> SubstitutionRecord:
"""
Non-destructive substitution — ALWAYS returns a valid card string.
Fallback chain:
1. Exact match in collection
2. Fuzzy match (case + punctuation normalised)
3. Embedding similarity match (if index available)
4. Keep original (Scryfall-valid, not in collection)
5. Basic land for the commander's colors (last resort — NEVER None)
"""
# 1. Exact match
if card_name in collection:
return SubstitutionRecord(card_name, card_name, SubstitutionMethod.EXACT_MATCH, True)
# 2. Fuzzy match — normalise case and common punctuation differences
normalised = card_name.lower().strip()
for owned in collection:
if owned.lower().strip() == normalised:
return SubstitutionRecord(card_name, owned, SubstitutionMethod.FUZZY_MATCH, True)
# 3. Embedding similarity (if index available)
if embedding_index and getattr(embedding_index, "loaded", False):
try:
matches = embedding_index.search_similar(
query_card=card_name,
color_filter=color_identity or None,
exclude_cards=[card_name],
top_n=5,
)
pool_lower = {n.lower() for n in collection}
for match in matches:
if match.name.lower() in pool_lower:
return SubstitutionRecord(
card_name, match.name, SubstitutionMethod.EMBEDDING_ANALOG, True
)
except Exception as e:
logger.warning("Embedding substitution failed for '%s': %s", card_name, e)
# 4. Keep original — valid card not in collection (proxy / non-collection build)
return SubstitutionRecord(
card_name, card_name, SubstitutionMethod.ORIGINAL_KEPT, False
)
def apply_substitutions(
card_list: List[DeckCardWithStatus],
collection: set[str],
color_identity: List[str],
embedding_index=None,
) -> Tuple[List[DeckCardWithStatus], List[SubstitutionRecord]]:
"""
Apply smart substitution to a card list.
Guarantees: output list is NEVER shorter than input list.
Logs a warning for every BASIC_LAND_FALLBACK.
"""
records: List[SubstitutionRecord] = []
result: List[DeckCardWithStatus] = []
for card in card_list:
rec = smart_substitute(card.name, collection, color_identity, embedding_index)
records.append(rec)
if rec.method == SubstitutionMethod.BASIC_LAND_FALLBACK:
logger.warning(
"[substitution] BASIC_LAND_FALLBACK for '%s' — slot filled with '%s'",
rec.original, rec.resolved,
)
# Update the card in-place — never drop it
updated = card.model_copy(update={
"name": rec.resolved,
"owned": rec.in_collection,
"status": "owned" if rec.in_collection else
("substituted" if rec.method != SubstitutionMethod.ORIGINAL_KEPT
else "missing"),
"original_card": card.name if rec.resolved != card.name else card.original_card,
})
result.append(updated)
fallback_count = sum(1 for r in records if r.method == SubstitutionMethod.BASIC_LAND_FALLBACK)
if fallback_count:
logger.error(
"[substitution] %d cards fell back to basic lands — investigate LLM output quality",
fallback_count,
)
return result, records
# ══════════════════════════════════════════════════════════════
# Pad-to-99 Circuit Breaker
# ══════════════════════════════════════════════════════════════
def _pad_to_99(
cards: List[DeckCardWithStatus],
color_identity: List[str],
target: int = 99,
) -> Tuple[List[DeckCardWithStatus], int]:
"""
Emergency safety pad — should NEVER fire if substitution contract holds.
If it does fire, the error log is your signal that a card is being dropped
somewhere earlier in the pipeline.
Returns (padded_cards, number_of_slots_padded).
"""
# Strip commander from count if present (commander is tracked separately)
non_commander = [c for c in cards if not getattr(c, "is_commander", False)]
current = sum(c.count for c in non_commander)
shortfall = target - current
if shortfall <= 0:
return cards, 0
logger.error(
"[pad_to_99] CIRCUIT BREAKER FIRED — deck is %d cards short after substitution. "
"Padding with basic lands. Check substitution pipeline for silent drops.",
shortfall,
)
basic = _get_basic_land(color_identity)
padding_card = DeckCardWithStatus(
name=basic,
count=shortfall,
category="Land",
role_tags=[],
reason="Emergency pad — basic land inserted by circuit breaker",
estimated_price_usd=0.10,
owned=True,
owned_qty=shortfall,
status="owned",
)
return cards + [padding_card], shortfall
def _log_pre_validation_counts(cards: List[DeckCardWithStatus]) -> None:
"""Log per-category card counts before Pydantic validation."""
by_cat: dict[str, int] = {}
for card in cards:
cat = card.category or "Uncategorized"
by_cat[cat] = by_cat.get(cat, 0) + card.count
total = sum(by_cat.values())
logger.info("[pre_validation] Category counts: %s", by_cat)
logger.info("[pre_validation] Total: %d/99 (delta: %d)", total, 99 - total)
# ══════════════════════════════════════════════════════════════
# DeckGeneratorV3
# ══════════════════════════════════════════════════════════════
class DeckGeneratorV3:
"""
V3 Deck Generator using Anthropic Claude structured output.
@@ -42,18 +281,14 @@ class DeckGeneratorV3:
3. Call Anthropic Claude with structured JSON schema
4. Validate card names via Scryfall
5. Check ownership against collection DB
6. Run Smart Substitution (embedding + Anthropic Claude fallback)
7. Return finalized deck with substitution data
6. Run Smart Substitution (non-destructive)
7. _pad_to_99 circuit breaker
8. Pre-validation count logging
9. Return finalized deck — guaranteed 99 cards
"""
def __init__(self, anthropic_client: AnthropicClient, db_conn_factory,
embedding_index=None):
"""
Args:
anthropic_client: Configured AnthropicClient instance
db_conn_factory: Callable that returns sqlite3.Connection
embedding_index: Optional MTGEmbeddingIndex for substitution
"""
self.anthropic = anthropic_client
self.db_conn_factory = db_conn_factory
self.embedding_index = embedding_index
@@ -77,9 +312,12 @@ class DeckGeneratorV3:
"mana_cost": data.get("mana_cost", ""),
"image_url": data.get("image_uris", {}).get("normal", ""),
"oracle_text": data.get("oracle_text", ""),
"scryfall_found": True,
}
except Exception as e:
logger.warning("Scryfall lookup failed for '%s': %s", commander_name, e)
# Non-canonical commander (silver-bordered, custom, etc.)
# Skip collection substitution for these — trust LLM output directly
return {
"name": commander_name,
"scryfall_id": "",
@@ -88,6 +326,7 @@ class DeckGeneratorV3:
"mana_cost": "",
"image_url": "",
"oracle_text": "",
"scryfall_found": False,
}
# ── Step 2: Build Collection Summary ─────────────────────
@@ -110,16 +349,10 @@ class DeckGeneratorV3:
if not rows:
return ""
# Filter by color identity
ci_set = set(color_identity) if color_identity else None
filtered = []
for row in rows:
name = row["name"]
type_line = row["type_line"] or ""
# Basic lands and colorless are always allowed
if ci_set:
# Simple color check from type_line and name
# For proper filtering we'd check mana cost but this is good enough for summary
card_colors = set()
oracle = row["oracle_text"] or ""
mana_cost = ""
@@ -137,7 +370,6 @@ class DeckGeneratorV3:
if not filtered:
return ""
# Group by card type
groups = {}
for row in filtered:
type_line = row["type_line"] or ""
@@ -159,14 +391,12 @@ class DeckGeneratorV3:
group = "Other"
groups.setdefault(group, []).append(row)
# Sort each group by popularity/price and build text
lines = [f"COLLECTION SUMMARY ({len(filtered)} cards in deck colors):"]
for group_name in ["Creatures", "Instants", "Sorceries", "Artifacts",
"Enchantments", "Planeswalkers", "Lands", "Other"]:
cards = groups.get(group_name, [])
if not cards:
continue
# Sort by price descending (higher value = likely better card)
cards.sort(key=lambda r: r["tcg_price"] or 0, reverse=True)
card_strs = []
for c in cards[:max_per_group]:
@@ -194,23 +424,32 @@ class DeckGeneratorV3:
Returns dict with:
- commander: dict with Scryfall data
- raw_deck: parsed GeneratedDeckList from Anthropic Claude
- cards: list of DeckCardWithStatus (after ownership check)
- cards: list of DeckCardWithStatus (after ownership check + substitution)
- stats: deck statistics
- substitution_log: list of SubstitutionRecord dicts
- padded_slots: int > 0 means circuit breaker fired
- tokens_used: {prompt, completion}
"""
# Step 1: Resolve commander
cmdr = self.resolve_commander(commander_name)
color_identity = cmdr["color_identity"]
logger.info("Commander: %s, CI: %s", cmdr["name"], color_identity)
scryfall_found = cmdr.get("scryfall_found", True)
logger.info("Commander: %s, CI: %s, Scryfall: %s",
cmdr["name"], color_identity, scryfall_found)
# Step 2: Collection summary
collection_block = ""
if use_collection:
if use_collection and scryfall_found:
collection_block = self.build_collection_summary(color_identity)
logger.info("Collection summary: %d chars",
len(collection_block) if collection_block else 0)
elif not scryfall_found:
logger.info(
"Non-canonical commander '%s' — skipping collection filter, "
"trusting LLM output directly.", cmdr["name"]
)
# Step 3: Build prompts
# Step 3: Build prompts (with slot budget injected)
user_prompt = build_user_prompt(
commander=cmdr["name"],
commander_type=cmdr["type_line"],
@@ -221,11 +460,12 @@ class DeckGeneratorV3:
budget_mode=budget_mode,
omit_cards=omit_cards,
collection_summary=collection_block,
slot_budget=SLOT_BUDGET,
)
# Step 4: Call Anthropic Claude with structured output
# Step 4: Call LLM with structured output
use_model = model or DECK_GEN_MODEL
logger.info("Calling Anthropic Claude (%s) for deck generation...", use_model)
logger.info("Calling LLM (%s) for deck generation...", use_model)
response = self.anthropic.chat_structured(
system_prompt=SYSTEM_PROMPT,
@@ -239,26 +479,29 @@ class DeckGeneratorV3:
if not response.ok:
raise ValueError(
f"Anthropic Claude returned invalid response: {response.content[:300]}"
f"LLM returned invalid response: {response.content[:300]}"
)
raw_deck = response.parsed_json
card_count = len(raw_deck.get("cards", []))
truncated = card_count < 90 # Commander decks should have ~100 cards
logger.info("Deck generated: %d cards, bracket %d%s",
card_count,
(raw_deck.get("bracket", {}).get("level", 0) if isinstance(raw_deck.get("bracket"), dict) else raw_deck.get("bracket", 0)),
" (TRUNCATED — response may have hit token limit)" if truncated else "")
truncated = card_count < 90
logger.info(
"Deck generated: %d cards, bracket %s%s",
card_count,
(raw_deck.get("bracket", {}).get("level", "?") if isinstance(raw_deck.get("bracket"), dict)
else raw_deck.get("bracket", "?")),
" (TRUNCATED — hit token limit)" if truncated else "",
)
# Step 5: Check ownership and build enriched card list
# Step 5: Check ownership
cards_with_status = self._check_ownership(raw_deck.get("cards", []))
# Step 5b: Fill basic lands to hit exactly 100 cards
# Step 5b: Fill basic lands to reach target_total=100
cards_with_status = self._fill_basic_lands(cards_with_status, color_identity)
# Step 5c: Secondary safety-net deduplication
seen = {}
deduped = []
# Step 5c: Secondary deduplication
seen: dict[str, int] = {}
deduped: List[DeckCardWithStatus] = []
for c in cards_with_status:
key = c.name.lower()
if key in seen:
@@ -269,7 +512,27 @@ class DeckGeneratorV3:
deduped.append(c)
cards_with_status = deduped
# Step 6: Compute stats
# Step 6: Run non-destructive smart substitution
# For non-canonical commanders (e.g. Sonic), skip collection filter
owned_pool: set[str] = set()
if use_collection and scryfall_found:
conn = self.db_conn_factory()
rows = conn.execute(
"SELECT DISTINCT name FROM collection_entries WHERE quantity > 0"
).fetchall()
owned_pool = {r["name"] for r in rows}
cards_with_status, substitution_log = apply_substitutions(
cards_with_status, owned_pool, color_identity, self.embedding_index
)
# Step 7: Pad-to-99 circuit breaker
cards_with_status, padded_slots = _pad_to_99(cards_with_status, color_identity)
# Step 8: Pre-validation count logging
_log_pre_validation_counts(cards_with_status)
# Step 9: Compute stats
stats = self._compute_stats(cards_with_status)
return {
@@ -283,6 +546,12 @@ class DeckGeneratorV3:
"archetype": raw_deck.get("archetype", ""),
"reasoning": raw_deck.get("reasoning", {}),
"estimated_total_usd": raw_deck.get("estimated_total_usd", 0),
"substitution_log": [
{"original": r.original, "resolved": r.resolved,
"method": r.method.value, "in_collection": r.in_collection}
for r in substitution_log
],
"padded_slots": padded_slots,
"tokens_used": {
"prompt": response.prompt_tokens,
"completion": response.completion_tokens,
@@ -296,13 +565,11 @@ class DeckGeneratorV3:
def _check_ownership(self, cards: List[dict]) -> List[DeckCardWithStatus]:
"""
Cross-reference generated cards against the collection DB.
Sets owned/scryfall_id/owned_qty and real price.
Deduplicates cards by name (case-insensitive) — if the LLM
returns the same card twice, the counts are merged.
Deduplicates by name (case-insensitive).
"""
conn = self.db_conn_factory()
enriched = []
seen_names: dict[str, int] = {} # name_lower → index in enriched
enriched: List[DeckCardWithStatus] = []
seen_names: dict[str, int] = {}
for card in cards:
name = card.get("name", "")
@@ -314,10 +581,9 @@ class DeckGeneratorV3:
if name_lower in seen_names:
idx = seen_names[name_lower]
enriched[idx].count += card.get("count", 1)
logger.warning("Duplicate card '%s' merged (LLM returned it twice)", name)
logger.warning("Duplicate card '%s' merged", name)
continue
# Look up in collection
row = conn.execute(
"SELECT name, scryfall_id, quantity, tcg_price, type_line, cmc "
"FROM collection_entries WHERE name = ? COLLATE NOCASE LIMIT 1",
@@ -348,11 +614,8 @@ class DeckGeneratorV3:
return enriched
# ── Step 5b: Basic Land Fill ──────────────────────────────
_COLOR_TO_BASIC = {
'W': 'Plains', 'U': 'Island', 'B': 'Swamp',
'R': 'Mountain', 'G': 'Forest',
}
_BASIC_NAMES = set(_COLOR_TO_BASIC.values()) | {'Wastes'}
_COLOR_TO_BASIC = _COLOR_TO_BASIC
_BASIC_NAMES = _BASIC_NAMES
def _fill_basic_lands(
self,
@@ -374,7 +637,7 @@ class DeckGeneratorV3:
ci = [c.upper() for c in color_identity]
basic_names = [self._COLOR_TO_BASIC[c] for c in ci if c in self._COLOR_TO_BASIC]
if not basic_names:
basic_names = ['Wastes']
basic_names = ["Wastes"]
per_color = basics_needed // len(basic_names)
remainder = basics_needed % len(basic_names)
@@ -385,20 +648,20 @@ class DeckGeneratorV3:
non_basics.append(DeckCardWithStatus(
name=bname,
count=qty,
category='Land',
category="Land",
role_tags=[],
reason='Basic land for mana fixing',
reason="Basic land for mana fixing",
estimated_price_usd=0.10,
owned=True,
owned_qty=qty,
status='owned',
status="owned",
))
logger.info("Basic land fill: %d basics added across %d color(s)",
basics_needed, len(basic_names))
basics_needed, len(basic_names))
return non_basics
# ── Step 6: Smart Substitution ───────────────────────────
# ── Step 6: Smart Substitution (legacy wrapper for route compatibility) ──
def run_substitution(
self,
cards: List[DeckCardWithStatus],
@@ -408,15 +671,12 @@ class DeckGeneratorV3:
"""
Run the Smart Substitution Engine on missing cards.
Pipeline:
a) Identify missing cards
b) For each: try embedding similarity (free, fast)
c) For low-confidence matches: batch Anthropic Claude fallback
d) Auto-select best substitute per card
Returns SubstitutionBatchResult with all cards updated.
Uses the non-destructive apply_substitutions() under the hood.
Falls back to Anthropic Claude for low-confidence matches,
then to basic land as last resort — NEVER drops a card.
"""
color_identity = commander.get("color_identity", [])
scryfall_found = commander.get("scryfall_found", True)
missing = [c for c in cards if not c.owned]
owned = [c for c in cards if c.owned]
@@ -431,47 +691,63 @@ class DeckGeneratorV3:
logger.info("Substitution: %d missing / %d owned", len(missing), len(owned))
# Build list of owned card names for substitution pool
# Build owned pool
conn = self.db_conn_factory()
owned_pool_rows = conn.execute(
"SELECT DISTINCT name FROM collection_entries WHERE quantity > 0"
).fetchall()
owned_pool = [r["name"] for r in owned_pool_rows]
owned_pool = {r["name"] for r in owned_pool_rows}
deck_names = {c.name.lower() for c in cards}
available_pool = {n for n in owned_pool if n.lower() not in deck_names}
# Exclude cards already in the deck
deck_names = set(c.name.lower() for c in cards)
available_pool = [n for n in owned_pool if n.lower() not in deck_names]
# a) Embedding pass
needs_anthropic_fallback = []
# a) Embedding pass for missing cards
needs_anthropic_fallback: List[DeckCardWithStatus] = []
for card in missing:
embedding_alts = self._embedding_substitution(
card, color_identity, available_pool
card, color_identity, list(available_pool)
)
card.alternatives = embedding_alts
# Check if best match meets threshold
if embedding_alts and embedding_alts[0].similarity_score >= SUBSTITUTION_MIN_SIMILARITY:
card.selected_substitute = embedding_alts[0].name
card.status = "substituted"
# Remove selected from pool
if card.selected_substitute.lower() in [n.lower() for n in available_pool]:
available_pool = [n for n in available_pool
if n.lower() != card.selected_substitute.lower()]
card.owned = True
available_pool.discard(card.selected_substitute.lower())
else:
needs_anthropic_fallback.append(card)
# b) Anthropic Claude fallback for low-confidence matches
# b) Anthropic Claude fallback
if needs_anthropic_fallback and SUBSTITUTION_USE_ANTHROPIC_FALLBACK:
self._anthropic_substitution(
needs_anthropic_fallback, available_pool,
needs_anthropic_fallback, list(available_pool),
commander, strategy
)
# Count results
# c) Any card STILL missing after both passes gets a basic land — never drop it
padded = 0
for card in missing:
if card.status == "missing":
basic = _get_basic_land(color_identity)
logger.warning(
"[substitution] '%s' unresolved after all passes — "
"replacing with basic land '%s'",
card.name, basic,
)
card.name = basic
card.category = "Land"
card.role_tags = []
card.owned = True
card.status = "substituted"
card.original_card = card.name
padded += 1
substituted = sum(1 for c in cards if c.status == "substituted")
still_missing = sum(1 for c in cards if c.status == "missing")
# Validate count hasn't changed
assert len(cards) == len(owned) + len(missing), \
f"[substitution] Card count changed: {len(owned) + len(missing)} -> {len(cards)}"
return SubstitutionBatchResult(
total_cards=len(cards),
owned_count=len(owned),
@@ -490,33 +766,31 @@ class DeckGeneratorV3:
if not self.embedding_index or not self.embedding_index.loaded:
return []
# Use the embeddings search to find similar cards
matches = self.embedding_index.search_similar(
query_card=card.name,
color_filter=color_identity if color_identity else None,
type_filter=card.category if card.category else None,
exclude_cards=[card.name],
top_n=SUBSTITUTION_MAX_ALTERNATIVES * 3, # Over-fetch, then filter to owned
)
try:
matches = self.embedding_index.search_similar(
query_card=card.name,
color_filter=color_identity if color_identity else None,
type_filter=card.category if card.category else None,
exclude_cards=[card.name],
top_n=SUBSTITUTION_MAX_ALTERNATIVES * 3,
)
except Exception as e:
logger.warning("Embedding search failed for '%s': %s", card.name, e)
return []
# Filter to only cards in the available pool
pool_lower = set(n.lower() for n in available_pool)
pool_lower = {n.lower() for n in available_pool}
alternatives = []
for match in matches:
if match.name.lower() in pool_lower:
# Compute role overlap
match_roles = [] # Would need card's role_tags from DB
role_overlap = list(set(card.role_tags) & set(match_roles)) if match_roles else []
role_overlap = list(set(card.role_tags) & set(getattr(match, "role_tags", [])))
alternatives.append(CardAlternative(
name=match.name,
similarity_score=match.similarity,
reason=f"Similar to {card.name} (cosine sim: {match.similarity:.3f})",
source="embedding",
role_overlap=role_overlap,
cmc_delta=match.mana_value - (card.estimated_price_usd or 0),
cmc_delta=getattr(match, "mana_value", 0) - (card.estimated_price_usd or 0),
))
if len(alternatives) >= SUBSTITUTION_MAX_ALTERNATIVES:
break
@@ -531,12 +805,13 @@ class DeckGeneratorV3:
):
"""
Batch Anthropic Claude fallback for cards with low embedding confidence.
Mutates the cards in-place with substitution data.
Mutates cards in-place. On failure, cards retain status='missing'
so the basic-land fallback in run_substitution() can catch them.
"""
if not available_pool:
logger.warning("[anthropic_substitution] No available pool — skipping")
return
# Build the request
missing_dicts = [
{
"name": c.name,
@@ -549,7 +824,7 @@ class DeckGeneratorV3:
user_prompt = build_substitution_prompt(
missing_cards=missing_dicts,
allowed_cards=available_pool[:200], # Limit to avoid token overflow
allowed_cards=available_pool[:200],
commander=commander.get("name", ""),
color_identity=commander.get("color_identity", []),
strategy=strategy,
@@ -568,13 +843,16 @@ class DeckGeneratorV3:
)
if not response.ok:
logger.warning("Anthropic Claude substitution fallback failed — non-JSON response")
logger.warning(
"[anthropic_substitution] LLM returned non-JSON — "
"%d cards will fall through to basic land fallback",
len(missing_cards),
)
return
subs = response.parsed_json.get("substitutions", [])
logger.info("Anthropic Claude suggested %d substitutions", len(subs))
logger.info("[anthropic_substitution] Received %d substitutions", len(subs))
# Apply substitutions
card_by_name = {c.name.lower(): c for c in missing_cards}
for sub in subs:
orig = sub.get("original", "").lower()
@@ -583,32 +861,38 @@ class DeckGeneratorV3:
if orig in card_by_name and substitute:
card = card_by_name[orig]
# Add as top alternative
anthropic_alt = CardAlternative(
name=substitute,
similarity_score=0.80, # Nominal score for Anthropic Claude suggestions
similarity_score=0.80,
reason=reason,
source="Anthropic Claude",
source="anthropic",
role_overlap=sub.get("role_overlap", []),
)
# Insert at front
card.alternatives.insert(0, anthropic_alt)
card.selected_substitute = substitute
card.status = "substituted"
card.owned = True
card.original_card = card.name
except Exception as e:
logger.error("Anthropic Claude substitution fallback error: %s", e)
logger.error(
"[anthropic_substitution] Exception: %s — "
"%d cards will fall through to basic land fallback",
e, len(missing_cards),
)
# Do NOT re-raise — cards stay status='missing' and
# run_substitution()'s basic-land fallback will handle them
# ── Stats ────────────────────────────────────────────────
@staticmethod
def _compute_stats(cards: List[DeckCardWithStatus]) -> dict:
"""Compute deck statistics from the enriched card list."""
by_category = {}
by_status = {"owned": 0, "substituted": 0, "missing": 0}
by_category: dict[str, int] = {}
by_status: dict[str, int] = {"owned": 0, "substituted": 0, "missing": 0}
total_price = 0.0
owned_price = 0.0
missing_price = 0.0
role_counts = {}
role_counts: dict[str, int] = {}
for card in cards:
cat = card.category or "Other"
+76 -70
View File
@@ -3,9 +3,13 @@
Commander AI Lab — FastAPI Backend
Thin entry point: registers routers and starts uvicorn.
"""
from __future__ import annotations
import argparse
import asyncio
import logging
import os
from contextlib import asynccontextmanager
from pathlib import Path
try:
@@ -32,6 +36,7 @@ from routes.precon import router as precon_router
from routes.import_routes import router as import_router
from routes.lab import router as lab_router
from routes.scanner import router as scanner_router
from routes.rag import router as rag_router
from routes.deepseek import router as deepseek_router
from routes.deckgen import router as deckgen_router
from routes.coach import router as coach_router, init_coach_service
@@ -41,12 +46,46 @@ from routes.ws_game import router as ws_game_router
log = logging.getLogger("commander_ai_lab.api")
_DEFAULT_ORIGINS = "http://localhost:5173,http://localhost:3000,http://localhost:8080"
# ── CORS origins (env-configurable) ────────────────────────────
_allowed = os.environ.get("ALLOWED_ORIGINS", _DEFAULT_ORIGINS)
allowed_origins = [o.strip() for o in _allowed.split(",") if o.strip()]
# ── App ────────────────────────────────────────────────────────
app = FastAPI(title="Commander AI Lab API", version="3.0.0")
# ── Lifespan (replaces deprecated @app.on_event) ─────────────────────────────────
@asynccontextmanager
async def _lifespan(application: FastAPI):
# ──── STARTUP ────
if not CFG.precon_dir:
CFG.precon_dir = _resolve_precon_dir()
init_collection_db()
if not PRECON_INDEX:
download_precon_database()
if not COMMANDER_META:
load_commander_meta()
loop = asyncio.get_event_loop()
# RAG Phase 1: kick off Scryfall bulk download in background thread.
# run_in_executor returns a Future; we do NOT await it so startup
# completes immediately and the download runs concurrently.
from services.scryfall_bulk import ensure_bulk_db
loop.run_in_executor(None, ensure_bulk_db)
# RAG Phase 2: kick off ChromaDB vector store build in background thread.
# Runs after Phase 1 completes inside ensure_rag_store() (it checks bulk_count > 0).
# Safe under both `uvicorn lab_api:app` and `python lab_api.py` — the
# _rag_ready flag + _rag_lock in rag_store.py make it idempotent.
from services.rag_store import ensure_rag_store
loop.run_in_executor(None, ensure_rag_store)
yield # ← server is running
# ──── SHUTDOWN ────
from services.async_db import async_close_db
await async_close_db()
# ── App ────────────────────────────────────────────────────────────────────
app = FastAPI(title="Commander AI Lab API", version="3.0.0", lifespan=_lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=allowed_origins,
@@ -61,6 +100,7 @@ app.include_router(precon_router)
app.include_router(import_router)
app.include_router(lab_router)
app.include_router(scanner_router)
app.include_router(rag_router)
app.include_router(deepseek_router)
app.include_router(deckgen_router)
app.include_router(coach_router)
@@ -68,34 +108,20 @@ app.include_router(ws_game_router)
app.include_router(ml_router)
# ── API endpoints ────────────────────────────────────────────────────────────────
# NOTE: all @app.get routes MUST be registered before app.mount() calls.
# Once StaticFiles is mounted at "/", it registers a wildcard catch-all that
# shadows any routes added after it.
@app.get("/api/health")
async def health_check():
"""Health check endpoint for load balancers and Unity client polling."""
return {"status": "ok"}
@app.on_event("startup")
async def _on_startup():
"""Ensure DB and precon index are ready (supports uvicorn --reload)."""
# Ensure precon_dir is resolved even when started via uvicorn directly
if not CFG.precon_dir:
CFG.precon_dir = _resolve_precon_dir()
init_collection_db()
if not PRECON_INDEX:
download_precon_database()
if not COMMANDER_META:
load_commander_meta()
@app.on_event("shutdown")
async def _on_shutdown():
"""Close the async SQLite connection pool."""
from services.async_db import async_close_db
await async_close_db()
# ── Static UI ──────────────────────────────────────────────────
_legacy_ui_dir = Path(__file__).parent / "ui"
_spa_dir = Path(__file__).parent / "frontend" / "commander-ai-lab-ui" / "dist"
# ── Static UI (MUST come after all @app.get routes) ────────────────────────────
from pathlib import Path as _Path
_legacy_ui_dir = _Path(__file__).parent / "ui"
_spa_dir = _Path(__file__).parent / "frontend" / "commander-ai-lab-ui" / "dist"
if _legacy_ui_dir.exists():
app.mount("/", StaticFiles(directory=str(_legacy_ui_dir), html=True), name="ui")
@@ -103,6 +129,7 @@ elif _spa_dir.exists():
_spa_assets = _spa_dir / "assets"
if _spa_assets.exists():
app.mount("/assets", StaticFiles(directory=str(_spa_assets)), name="spa-assets")
@app.get("/{full_path:path}")
async def _spa_catchall(full_path: str):
from fastapi.responses import FileResponse
@@ -111,7 +138,8 @@ elif _spa_dir.exists():
return FileResponse(str(requested_file))
return FileResponse(str(_spa_dir / "index.html"))
# ── Startup ────────────────────────────────────────────────────
# ── Argument parsing + auto-detection helpers ───────────────────────────────────
def _parse_args():
p = argparse.ArgumentParser(description="Commander AI Lab API Server")
p.add_argument("--forge-jar", default=os.environ.get("FORGE_JAR", ""))
@@ -125,6 +153,7 @@ def _parse_args():
p.add_argument("--verbose", "-v", action="store_true")
return p.parse_args()
def _resolve_forge_decks_dir() -> str:
import sys
if sys.platform == "win32":
@@ -142,52 +171,35 @@ def _resolve_forge_decks_dir() -> str:
return str(candidate)
return ""
def _resolve_forge_dir() -> str:
"""Auto-detect the Forge installation directory.
The Forge dir is the root of a Forge install — it typically contains
a 'res' subdirectory with card data. We derive it from the JAR
location (parent of 'target/') or look in common install paths.
"""
"""Auto-detect the Forge installation directory."""
import sys
# 1. If forge_jar is already resolved, derive forge_dir from it.
# JAR lives in <forge-root>/forge-gui-desktop/target/<jar>
# so forge_dir = JAR's grandparent's parent.
if CFG.forge_jar and os.path.isfile(CFG.forge_jar):
jar_path = Path(CFG.forge_jar)
# target/ -> forge-gui-desktop/ -> <forge-root>
candidate = jar_path.parent.parent.parent
if candidate.is_dir() and (candidate / "res").is_dir():
return str(candidate)
# Also try just one level up from target/
candidate = jar_path.parent.parent
# Check forge-gui sibling of forge-gui-desktop (res/ lives there)
forge_gui_sibling = jar_path.parent.parent.parent / "forge-gui"
if forge_gui_sibling.is_dir() and (forge_gui_sibling / "res").is_dir():
return str(forge_gui_sibling)
if candidate.is_dir() and (candidate / "res").is_dir():
return str(candidate)
# Fall back to the directory containing the JAR itself
candidate = jar_path.parent
if candidate.is_dir():
return str(candidate)
# 2. Check common sibling/child directories relative to project root
project_root = Path(__file__).parent
for candidate in [
project_root / "forge",
project_root.parent / "forge-repo",
project_root.parent / "forge-repo" / "forge-gui",
project_root.parent / "forge-repo" / "forge-gui",
project_root.parent / "forge",
project_root / "forge-gui-desktop",
]:
if candidate.is_dir():
# Prefer directory that has 'res' subfolder
if (candidate / "res").is_dir():
return str(candidate)
# 3. Platform-specific user data directories
if sys.platform == "win32":
appdata = os.environ.get("APPDATA", "")
if appdata:
@@ -195,32 +207,25 @@ def _resolve_forge_dir() -> str:
if candidate.is_dir():
return str(candidate)
home = Path.home()
for candidate in [
home / ".forge",
home / "Forge",
]:
for candidate in [home / ".forge", home / "Forge"]:
if candidate.is_dir():
return str(candidate)
return ""
def _resolve_forge_jar() -> str:
"""Auto-detect the Forge GUI desktop JAR in common locations."""
project_root = Path(__file__).parent
# Candidate directories where the Forge JAR might live
candidates = [
project_root / "forge-gui-desktop" / "target",
project_root.parent / "forge-repo" / "forge-gui-desktop" / "target",
project_root.parent / "forge" / "forge-gui-desktop" / "target",
]
# Also check FORGE_DIR/../forge-gui-desktop/target if FORGE_DIR is set
forge_dir_env = os.environ.get("FORGE_DIR", "")
if forge_dir_env:
fd = Path(forge_dir_env)
candidates.append(fd.parent / "forge-gui-desktop" / "target")
candidates.append(fd / "target")
for target_dir in candidates:
if target_dir.is_dir():
for pattern in [
@@ -234,6 +239,7 @@ def _resolve_forge_jar() -> str:
return str(jars[0])
return ""
def _resolve_lab_jar() -> str:
target_dir = Path(__file__).parent / "target"
if target_dir.exists():
@@ -248,36 +254,25 @@ def _resolve_lab_jar() -> str:
return str(jars[0])
return ""
def _resolve_precon_dir() -> str:
"""Auto-detect the precon-decks directory.
The precon-decks dir lives in the project root and contains downloaded
.dck files plus precon-index.json. We check relative to this file
(lab_api.py) first, then common sibling paths.
"""
"""Auto-detect the precon-decks directory."""
project_root = Path(__file__).parent
# 1. Standard location: <project-root>/precon-decks
candidate = project_root / "precon-decks"
if candidate.is_dir():
return str(candidate)
# 2. Check parent directory (in case running from a subdirectory)
candidate = project_root.parent / "precon-decks"
if candidate.is_dir():
return str(candidate)
# 3. Derive from forge_dir if available
if CFG.forge_dir:
candidate = Path(CFG.forge_dir) / "precon-decks"
if candidate.is_dir():
return str(candidate)
# 4. Fall back: create the directory in the project root so downloads work
fallback = project_root / "precon-decks"
fallback.mkdir(parents=True, exist_ok=True)
return str(fallback)
def main():
args = _parse_args()
setup_logging(logging.DEBUG if args.verbose else logging.INFO)
@@ -311,8 +306,19 @@ def main():
init_collection_db()
init_coach_service()
# RAG Phase 1: synchronous boot-time check so logs are visible.
from services.scryfall_bulk import ensure_bulk_db
ensure_bulk_db()
# RAG Phase 2: synchronous boot-time ChromaDB build.
# The lifespan handler also fires this in a thread for uvicorn ASGI mode;
# calling here ensures it runs before the first request when using python lab_api.py.
from services.rag_store import ensure_rag_store
ensure_rag_store()
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=CFG.port, log_level="info")
if __name__ == "__main__":
main()
+22
View File
@@ -25,6 +25,28 @@ dev = [
"httpx>=0.24.0",
"ruff>=0.1.0",
]
# RAG Phase 2: ChromaDB vector store + Ollama embedding support
# Install with: pip install ".[rag]"
rag = [
"chromadb>=0.5.0",
]
# Local coach embeddings (sentence-transformers, no Ollama required)
# Install with: pip install ".[coach-embeddings]"
coach-embeddings = [
"sentence-transformers>=3.0.0",
"torch>=2.0.0",
"pandas>=1.5.0",
"pyarrow>=12.0.0",
]
# Full local AI stack (RAG + coach embeddings)
# Install with: pip install ".[all]"
all = [
"chromadb>=0.5.0",
"sentence-transformers>=3.0.0",
"torch>=2.0.0",
"pandas>=1.5.0",
"pyarrow>=12.0.0",
]
[tool.setuptools.packages.find]
where = ["src"]
+3
View File
@@ -20,5 +20,8 @@ ollama>=0.2.0
scrython>=2.0.0
# Embeddings
# RAG vector store
chromadb>=0.5.0
sentence-transformers>=3.0.0
torch>=2.0.0
+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
+21 -51
View File
@@ -45,7 +45,7 @@ from services.card_analysis import _detect_card_roles
from services.database import _get_db_conn, _row_to_dict, _add_image_url
from services.deck_service import (
_get_deck_or_404, _compute_deck_analysis, _check_ratio_limit,
_classify_card_type, _to_edhrec_slug, _TYPE_TARGETS,
_classify_card_type, _classify_card_category, _to_edhrec_slug, _TYPE_TARGETS,
_save_profile_to_dck,
)
from services.import_helpers import (
@@ -121,7 +121,6 @@ async def get_deck(deck_id: int):
deck["cards"] = [dict(r) for r in card_rows]
deck["total_cards"] = sum(r["quantity"] for r in card_rows)
deck["card_slots"] = len(card_rows)
# Composition summary by type
analysis = _compute_deck_analysis(deck_id)
deck["composition"] = analysis["counts_by_type"]
return deck
@@ -130,7 +129,7 @@ async def get_deck(deck_id: int):
@router.put("/api/decks/{deck_id}")
async def update_deck(deck_id: int, req: UpdateDeckRequest):
"""Update deck metadata."""
_get_deck_or_404(deck_id) # ensure exists
_get_deck_or_404(deck_id)
conn = _get_db_conn()
updates = {}
@@ -158,7 +157,7 @@ async def update_deck(deck_id: int, req: UpdateDeckRequest):
@router.delete("/api/decks/{deck_id}")
async def delete_deck(deck_id: int):
"""Delete a deck and all its cards (cascade)."""
_get_deck_or_404(deck_id) # ensure exists
_get_deck_or_404(deck_id)
conn = _get_db_conn()
conn.execute("DELETE FROM deck_cards WHERE deck_id = ?", (deck_id,))
conn.execute("DELETE FROM decks WHERE id = ?", (deck_id,))
@@ -181,7 +180,7 @@ async def delete_all_decks():
@router.get("/api/decks/{deck_id}/cards")
async def get_deck_cards(deck_id: int):
"""Get all cards in a deck with joined collection data."""
"""Get all cards in a deck with joined collection and card_records data."""
_get_deck_or_404(deck_id)
conn = _get_db_conn()
rows = conn.execute(
@@ -189,7 +188,12 @@ async def get_deck_cards(deck_id: int):
SELECT
dc.id, dc.deck_id, dc.scryfall_id, dc.card_name, dc.quantity,
dc.is_commander, dc.role_tag,
ce.type_line, ce.cmc, ce.mana_cost, ce.color_identity, ce.oracle_text, ce.keywords,
COALESCE(ce.type_line, cr.type_line, '') as type_line,
COALESCE(ce.cmc, cr.cmc, 0) as cmc,
COALESCE(ce.mana_cost, '') as mana_cost,
COALESCE(ce.color_identity, cr.color_identity, '[]') as color_identity,
COALESCE(ce.oracle_text, cr.oracle_text, '') as oracle_text,
COALESCE(ce.keywords, cr.keywords, '[]') as keywords,
ce.tcg_price, ce.quantity AS owned_qty, ce.is_legendary,
ce.salt_score, ce.is_game_changer
FROM deck_cards dc
@@ -200,6 +204,11 @@ async def get_deck_cards(deck_id: int):
FROM collection_entries
GROUP BY scryfall_id
) ce ON ce.scryfall_id = dc.scryfall_id
LEFT JOIN (
SELECT name, type_line, cmc, color_identity, oracle_text, keywords
FROM card_records
GROUP BY name
) cr ON cr.name = dc.card_name
WHERE dc.deck_id = ?
ORDER BY dc.is_commander DESC, dc.card_name ASC
""",
@@ -208,7 +217,6 @@ async def get_deck_cards(deck_id: int):
cards = []
for row in rows:
d = dict(row)
# Parse JSON fields
for f in ("color_identity", "keywords"):
if isinstance(d.get(f), str):
try:
@@ -219,6 +227,8 @@ async def get_deck_cards(deck_id: int):
f"https://api.scryfall.com/cards/{d['scryfall_id']}?format=image&version=normal"
if d.get("scryfall_id") else None
)
d["card_type"] = _classify_card_type(d.get("type_line", ""))
d["card_category"] = _classify_card_category(d.get("type_line", ""), d.get("oracle_text", ""))
cards.append(d)
return {"cards": cards, "total": len(cards)}
@@ -229,14 +239,12 @@ async def add_deck_card(deck_id: int, req: AddDeckCardRequest):
_get_deck_or_404(deck_id)
conn = _get_db_conn()
# Look up card_name from collection_entries by scryfall_id, fall back to request
ce_row = conn.execute(
"SELECT name FROM collection_entries WHERE scryfall_id = ? LIMIT 1",
(req.scryfall_id,)
).fetchone()
card_name = ce_row["name"] if ce_row else (req.card_name or "")
# Check if this scryfall_id is already in the deck
existing = conn.execute(
"SELECT id, quantity FROM deck_cards WHERE deck_id = ? AND scryfall_id = ?",
(deck_id, req.scryfall_id)
@@ -266,7 +274,6 @@ async def add_deck_card(deck_id: int, req: AddDeckCardRequest):
conn.commit()
row = conn.execute("SELECT * FROM deck_cards WHERE id = ?", (cur.lastrowid,)).fetchone()
# Update deck updated_at
conn.execute("UPDATE decks SET updated_at = datetime('now') WHERE id = ?", (deck_id,))
conn.commit()
return dict(row)
@@ -341,28 +348,23 @@ async def recommend_from_collection(deck_id: int, max_results: int = 20, roles:
deck_color_identity = deck.get("color_identity", [])
conn = _get_db_conn()
# Run analysis to find shortfalls
analysis = _compute_deck_analysis(deck_id)
deltas = analysis["deltas"]
deltas = analysis.get("deltas", {})
counts_by_type = analysis["counts_by_type"]
# Find types that are below their target midpoint (shortfall)
shortfall_types = set()
for t, delta in deltas.items():
lo, hi = _TYPE_TARGETS.get(t, [0, 0])
if counts_by_type.get(t, 0) < lo:
shortfall_types.add(t)
# Get scryfall_ids already in deck to exclude
in_deck_ids = set(
r["scryfall_id"] for r in
conn.execute("SELECT scryfall_id FROM deck_cards WHERE deck_id = ?", (deck_id,)).fetchall()
)
# Parse role filter
role_filter = [r.strip() for r in roles.split(",") if r.strip()] if roles else []
# Query collection for candidate cards
coll_rows = conn.execute(
"SELECT * FROM collection_entries WHERE quantity > 0"
).fetchall()
@@ -372,11 +374,9 @@ async def recommend_from_collection(deck_id: int, max_results: int = 20, roles:
card = _row_to_dict(row)
card_id = card.get("scryfall_id", "")
# Skip cards already in deck
if card_id in in_deck_ids:
continue
# Color identity check: all card colors must be in deck colors
card_ci = card.get("color_identity", [])
if isinstance(card_ci, str):
try:
@@ -393,22 +393,17 @@ async def recommend_from_collection(deck_id: int, max_results: int = 20, roles:
card_type = _classify_card_type(type_line)
card_roles = _detect_card_roles(oracle_text, type_line, keywords)
# Role filter
if role_filter and not any(r in card_roles for r in role_filter):
continue
# Scoring
score = 0
# +3 if card type is in shortfall
if card_type in shortfall_types:
score += 3
# +2 per matching role
for r in card_roles:
if r in ("Ramp", "Draw", "Removal", "BoardWipe"):
score += 2
else:
score += 1
# +1 for lower CMC (curve fit)
cmc = float(card.get("cmc", 0))
if cmc <= 3:
score += 1
@@ -429,7 +424,6 @@ async def recommend_from_collection(deck_id: int, max_results: int = 20, roles:
scored.sort(key=lambda x: x["score"], reverse=True)
# Group by type
grouped = {}
for card in scored[:max_results]:
ct = card["card_type"]
@@ -456,7 +450,6 @@ async def deck_edh_recs(deck_id: int, only_owned: bool = False, max_results: int
if not commander_name:
raise HTTPException(400, "Deck has no commander_name set. Update the deck first.")
# Cache check
cache_key = f"edhrec:avg:{_to_edhrec_slug(commander_name)}"
cached_profile = _edhrec_cache_get(cache_key)
if cached_profile is None:
@@ -471,13 +464,11 @@ async def deck_edh_recs(deck_id: int, only_owned: bool = False, max_results: int
conn = _get_db_conn()
# Get cards already in deck
in_deck_names = set(
r["card_name"].lower() for r in
conn.execute("SELECT card_name FROM deck_cards WHERE deck_id = ?", (deck_id,)).fetchall()
)
# Build collection lookup: name (lower) -> {owned, qty, scryfall_id}
coll_rows = conn.execute(
"SELECT name, quantity, scryfall_id FROM collection_entries"
).fetchall()
@@ -488,7 +479,6 @@ async def deck_edh_recs(deck_id: int, only_owned: bool = False, max_results: int
if not existing or r["quantity"] > existing["qty"]:
coll_map[key] = {"owned": True, "qty": r["quantity"], "scryfall_id": r["scryfall_id"]}
# Also look up card details from collection_entries for type/role
coll_details = {}
for r in conn.execute("SELECT name, type_line, oracle_text, keywords, scryfall_id FROM collection_entries").fetchall():
key = r["name"].lower()
@@ -498,7 +488,6 @@ async def deck_edh_recs(deck_id: int, only_owned: bool = False, max_results: int
results = []
for card_name in mainboard:
name_lower = card_name.lower()
# Skip cards already in deck
if name_lower in in_deck_names:
continue
@@ -507,7 +496,6 @@ async def deck_edh_recs(deck_id: int, only_owned: bool = False, max_results: int
if only_owned and not owned_info["owned"]:
continue
# Get type/role from collection if available
details = coll_details.get(name_lower, {})
type_line = details.get("type_line", "")
oracle_text = details.get("oracle_text", "")
@@ -520,7 +508,7 @@ async def deck_edh_recs(deck_id: int, only_owned: bool = False, max_results: int
"type_line": type_line,
"role": card_roles[0] if card_roles else "Other",
"roles": card_roles,
"inclusion_pct": None, # EDHREC average doesn't expose % directly in this path
"inclusion_pct": None,
"synergy_score": None,
"owned": owned_info["owned"],
"owned_qty": owned_info["qty"],
@@ -569,7 +557,6 @@ async def bulk_add_cards(deck_id: int, req: BulkAddRequest):
details.append({"scryfall_id": scryfall_id, "status": "skipped", "reason": "missing scryfall_id"})
continue
# Look up card name and type from collection
ce_row = conn.execute(
"SELECT name, type_line, oracle_text, keywords FROM collection_entries WHERE scryfall_id = ? LIMIT 1",
(scryfall_id,)
@@ -589,7 +576,6 @@ async def bulk_add_cards(deck_id: int, req: BulkAddRequest):
})
continue
# Upsert
existing = conn.execute(
"SELECT id, quantity FROM deck_cards WHERE deck_id = ? AND scryfall_id = ?",
(deck_id, scryfall_id)
@@ -631,7 +617,6 @@ async def bulk_add_recommended(deck_id: int, req: BulkAddRecommendedRequest):
candidates = []
if source == "collection":
# Reuse the recommend_from_collection logic
deck = _get_deck_or_404(deck_id)
deck_color_identity = deck.get("color_identity", [])
analysis = _compute_deck_analysis(deck_id)
@@ -691,7 +676,7 @@ async def bulk_add_recommended(deck_id: int, req: BulkAddRecommendedRequest):
if req.only_owned and not coll_info:
continue
if not coll_info:
continue # can't add without scryfall_id
continue
scryfall_id = coll_info.get("scryfall_id", "")
if not scryfall_id:
continue
@@ -706,7 +691,6 @@ async def bulk_add_recommended(deck_id: int, req: BulkAddRecommendedRequest):
else:
raise HTTPException(400, f"Unknown source '{source}'. Use 'collection' or 'edhrec'.")
# Now bulk-add candidates
added = 0
skipped = 0
details = []
@@ -770,7 +754,6 @@ async def export_deck_to_sim(deck_id: int):
if not card_rows:
raise HTTPException(400, "Deck has no cards to export")
# Build .dck content
lines = ["[metadata]"]
deck_name = deck.get("name", f"Deck {deck_id}")
lines.append(f"Name={deck_name}")
@@ -795,7 +778,6 @@ async def export_deck_to_sim(deck_id: int):
content = "\n".join(lines)
# Determine save directory
safe_name = re.sub(r"[^a-zA-Z0-9 _-]", "", deck_name).replace(" ", "_").strip()
if not safe_name:
safe_name = f"deck_{deck_id}"
@@ -835,14 +817,13 @@ def _parse_decklist_text(text: str) -> dict:
"""
commanders = []
cards = []
section = 'main' # default section
section = 'main'
for raw_line in text.splitlines():
line = raw_line.strip()
if not line or line.startswith('//') or line.startswith('#'):
continue
# Section headers
low = line.lower()
if low.startswith('[commander'):
section = 'commander'
@@ -854,14 +835,12 @@ def _parse_decklist_text(text: str) -> dict:
section = 'skip'
continue
if low.startswith('['):
# Unknown section, treat as main
section = 'main'
continue
if section == 'skip':
continue
# Parse quantity + name
m = re.match(r'^(\d+)\s*[xX]?\s+(.+)$', line)
if m:
qty = int(m.group(1))
@@ -870,7 +849,6 @@ def _parse_decklist_text(text: str) -> dict:
qty = 1
name = line
# Strip set codes like "(M21)" or "(M21) 123" from MTGA exports
name = re.sub(r'\s*\([A-Z0-9]+\)\s*\d*\s*$', '', name).strip()
if not name:
@@ -912,7 +890,6 @@ async def import_decklist(deck_id: int, request: FastAPIRequest):
conn = _get_db_conn()
# Optionally clear existing cards
if clear_first:
conn.execute('DELETE FROM deck_cards WHERE deck_id = ?', (deck_id,))
conn.commit()
@@ -926,7 +903,6 @@ async def import_decklist(deck_id: int, request: FastAPIRequest):
qty = entry['qty']
is_cmd = entry['is_commander']
# Look up on Scryfall
sf = _scryfall_fuzzy_lookup(name)
if not sf:
failed.append(name)
@@ -936,7 +912,6 @@ async def import_decklist(deck_id: int, request: FastAPIRequest):
scryfall_id = sf.get('id', '')
resolved_name = sf.get('name', name)
# Check if already in deck
existing = conn.execute(
'SELECT id, quantity FROM deck_cards WHERE deck_id = ? AND scryfall_id = ?',
(deck_id, scryfall_id)
@@ -956,11 +931,9 @@ async def import_decklist(deck_id: int, request: FastAPIRequest):
added += 1
results.append({'name': resolved_name, 'qty': qty, 'status': 'added', 'isCommander': is_cmd})
# Update commander on deck record if we found one
cmd_entries = [r for r in results if r.get('isCommander')]
if cmd_entries:
cmd_name = cmd_entries[0]['name']
# Look up scryfall_id from the deck_cards we just inserted
cmd_row = conn.execute(
'SELECT scryfall_id FROM deck_cards WHERE deck_id = ? AND is_commander = 1 LIMIT 1',
(deck_id,)
@@ -1007,7 +980,6 @@ async def import_decklist_new(request: FastAPIRequest):
if not all_entries:
return JSONResponse({'error': 'No cards found in decklist'}, status_code=400)
# Auto-name from first commander if no name given
if not deck_name:
cmd = parsed['commanders'][0]['name'] if parsed['commanders'] else None
deck_name = cmd or 'Imported Deck'
@@ -1020,7 +992,6 @@ async def import_decklist_new(request: FastAPIRequest):
conn.commit()
new_deck_id = cur.lastrowid
# Set commander on the deck if we have one
first_cmd_scryfall = None
first_cmd_name = None
@@ -1051,7 +1022,6 @@ async def import_decklist_new(request: FastAPIRequest):
conn.commit()
added += 1
# Update commander on deck record
if first_cmd_scryfall:
conn.execute(
'UPDATE decks SET commander_name = ?, commander_scryfall_id = ? WHERE id = ?',
+19 -60
View File
@@ -37,11 +37,9 @@ logger = logging.getLogger(__name__)
router = APIRouter(tags=["deepseek"])
# In-memory store for simulation runs
_sim_runs = {} # sim_id -> { status, result, error }
_sim_lock = _threading.Lock()
# ── Validator singleton ──
_validator_brain: ValidatorBrain | None = None
_validator_lock = _threading.Lock()
@@ -61,15 +59,11 @@ def _get_validator_brain() -> ValidatorBrain | None:
_validator_brain = ValidatorBrain(cfg)
return _validator_brain
# ══════════════════════════════════════════════════════════════
# Shared Helpers (extracted to deduplicate sim threads — #42)
# ══════════════════════════════════════════════════════════════
_SRC_PATH_ADDED = False
def _ensure_src_path():
"""Add project src/ to sys.path once."""
global _SRC_PATH_ADDED
if not _SRC_PATH_ADDED:
src_dir = str(Path(__file__).resolve().parent.parent / 'src')
@@ -126,7 +120,6 @@ def _run_game_loop(engine, deck_a, deck_b, deck_name, opponent_name,
for i in range(num_games):
kw = dict(run_kwargs)
# DeepSeek engine needs per-game IDs
if 'game_id_prefix' in kw:
prefix = kw.pop('game_id_prefix')
kw['game_id'] = f'{prefix}-g{i+1}'
@@ -134,7 +127,6 @@ def _run_game_loop(engine, deck_a, deck_b, deck_name, opponent_name,
name_b=opponent_name, **kw)
game_data = result.to_dict()
game_data['gameNumber'] = i + 1
# Phase 8: Surface player_explanation from validation in turn log
for turn_entry in game_data.get('log', []):
v = turn_entry.get('validation')
if isinstance(v, dict) and v.get('player_explanation'):
@@ -178,7 +170,6 @@ def _run_game_loop(engine, deck_a, deck_b, deck_name, opponent_name,
def _build_summary(stats: dict, deck_name: str, opponent_name: str,
num_games: int, elapsed: float, **extra) -> dict:
"""Build the JSON-serialisable summary dict from accumulated stats."""
n = num_games
summary = {
'deckName': deck_name,
@@ -208,7 +199,6 @@ def _finish_sim(sim_id, summary, game_results, engine=None):
_sim_runs[sim_id]['status'] = 'complete'
_sim_runs[sim_id]['result'] = {'summary': summary, 'games': game_results}
# --- Persist ml-decision-*.json for coach / training dashboard ---
try:
deck_name = summary.get('deckName', 'Unknown')
opponent_name = summary.get('opponentName', 'Training Deck')
@@ -240,7 +230,6 @@ def _finish_sim(sim_id, summary, game_results, engine=None):
json.dump(batch_data, f, indent=2, default=str)
logger.info(f'Saved batch results to {batch_path}')
# Auto-generate deck report for training dashboard / coach
try:
from coach.report_generator import generate_deck_reports
reports_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'deck-reports')
@@ -254,9 +243,6 @@ def _finish_sim(sim_id, summary, game_results, engine=None):
except Exception as e:
logger.error(f'Failed to persist sim results: {e}')
# --- Flush ML decisions JSONL for training pipeline ---
# NOTE: DeepSeek thread flushes manually before calling _finish_sim(engine=None)
# to avoid a double-flush that would produce an empty .jsonl file.
if engine is not None and hasattr(engine, 'flush_ml_decisions'):
ml_decisions = engine.flush_ml_decisions()
if ml_decisions:
@@ -269,18 +255,13 @@ def _finish_sim(sim_id, summary, game_results, engine=None):
def _fail_sim(sim_id, error):
"""Mark a sim run as errored."""
with _sim_lock:
_sim_runs[sim_id]['status'] = 'error'
_sim_runs[sim_id]['error'] = str(error)
# ══════════════════════════════════════════════════════════════
# Background Sim Threads (now thin wrappers around shared helpers)
# ══════════════════════════════════════════════════════════════
def _run_sim_thread(sim_id: str, decklist: list, num_games: int,
deck_name: str, record_logs: bool):
"""Background thread: basic Monte Carlo sim from a raw decklist."""
try:
_ensure_src_path()
from commander_ai_lab.sim.engine import GameEngine
@@ -307,7 +288,6 @@ def _run_sim_thread(sim_id: str, decklist: list, num_games: int,
def _run_sim_thread_v2(sim_id: str, card_data: list[dict], num_games: int,
deck_name: str, record_logs: bool):
"""Background thread: sim with full card data from the DB."""
try:
_ensure_src_path()
from commander_ai_lab.sim.engine import GameEngine
@@ -335,7 +315,6 @@ def _run_sim_thread_v2(sim_id: str, card_data: list[dict], num_games: int,
def _run_sim_thread_deepseek(sim_id: str, card_data: list[dict],
num_games: int, deck_name: str,
record_logs: bool):
"""Background thread: sim using DeepSeek AI opponent."""
try:
_ensure_src_path()
from commander_ai_lab.sim.deepseek_engine import DeepSeekGameEngine
@@ -351,7 +330,7 @@ def _run_sim_thread_deepseek(sim_id: str, card_data: list[dict],
if brain and not brain._connected:
brain.check_connection()
validator = _get_validator_brain() # None if disabled
validator = _get_validator_brain()
engine = DeepSeekGameEngine(
brain=brain, ai_player_index=1,
max_turns=25, record_log=record_logs, ml_log=True,
@@ -363,8 +342,6 @@ def _run_sim_thread_deepseek(sim_id: str, card_data: list[dict],
game_id_prefix=f'ds-sim-{sim_id[:8]}',
archetype='midrange')
# Flush ML decisions once here — pass engine=None to _finish_sim
# to prevent a second flush that would produce an empty .jsonl file.
ml_decisions = engine.flush_ml_decisions()
if ml_decisions and brain and brain._connected:
ml_path = os.path.join('results',
@@ -387,21 +364,15 @@ def _run_sim_thread_deepseek(sim_id: str, card_data: list[dict],
summary = _build_summary(
stats, deck_name, 'DeepSeek AI', num_games, elapsed,
opponentType='deepseek', deepseekStats=ds_stats)
# engine=None: ML decisions already flushed above — do not flush again
_finish_sim(sim_id, summary, game_results, engine=None)
except Exception as e:
_fail_sim(sim_id, e)
traceback.print_exc()
# ══════════════════════════════════════════════════════════════
# N-Player Sim Thread
# ══════════════════════════════════════════════════════════════
def _run_sim_thread_n_player(sim_id: str, decks_data: list[list[dict]],
deck_names: list[str], num_games: int,
record_logs: bool, engine_type: str):
"""Background thread: N-player (2-4) batch simulation."""
try:
_ensure_src_path()
from commander_ai_lab.sim.engine import GameEngine
@@ -423,7 +394,6 @@ def _run_sim_thread_n_player(sim_id: str, decks_data: list[list[dict]],
else:
engine = GameEngine(max_turns=25, record_log=record_logs, ml_log=True)
# Per-seat accumulators
seat_wins = [0] * n_players
seat_turns = [0] * n_players
seat_damage_dealt = [0] * n_players
@@ -493,13 +463,7 @@ def _run_sim_thread_n_player(sim_id: str, decks_data: list[list[dict]],
traceback.print_exc()
# ══════════════════════════════════════════════════════════════
# Simulation API Endpoints
# ══════════════════════════════════════════════════════════════
def _create_sim_run(num_games, deck_name):
"""Create a queued sim run entry and return its ID."""
sim_id = str(_uuid.uuid4())[:8]
with _sim_lock:
_sim_runs[sim_id] = {
@@ -521,31 +485,42 @@ def _fetch_deck_card_data(deck_id):
deck_name = row[0]
cur.execute("""
SELECT dc.card_name, dc.quantity,
ce.type_line, ce.cmc, ce.power, ce.toughness,
ce.oracle_text, ce.keywords, ce.mana_cost
COALESCE(ce.type_line, cr.type_line, '') as type_line,
COALESCE(ce.cmc, cr.cmc, 0) as cmc,
ce.power, ce.toughness,
COALESCE(ce.oracle_text, cr.oracle_text, '') as oracle_text,
COALESCE(ce.keywords, cr.keywords, '') as keywords,
COALESCE(ce.mana_cost, '') as mana_cost
FROM deck_cards dc
LEFT JOIN (
SELECT scryfall_id, type_line, cmc, power, toughness,
oracle_text, keywords, mana_cost
FROM collection_entries GROUP BY scryfall_id
) ce ON ce.scryfall_id = dc.scryfall_id
LEFT JOIN (
SELECT name, type_line, cmc, oracle_text, keywords
FROM card_records GROUP BY name
) cr ON cr.name = dc.card_name
WHERE dc.deck_id = ?
""", (deck_id,))
card_data = []
for r in cur.fetchall():
for _ in range(r[1] or 1):
card_data.append({
'name': r[0], 'type_line': r[2] or '',
'cmc': r[3] or 0, 'power': r[4] or '',
'toughness': r[5] or '', 'oracle_text': r[6] or '',
'keywords': r[7] or '', 'mana_cost': r[8] or '',
'name': r[0],
'type_line': r[2] or '',
'cmc': r[3] or 0,
'power': r[4] or '',
'toughness': r[5] or '',
'oracle_text': r[6] or '',
'keywords': r[7] or '',
'mana_cost': r[8] or '',
})
return deck_name, card_data
@router.post('/api/sim/run')
async def sim_run(request: FastAPIRequest):
"""Start a Monte Carlo simulation with the Python engine."""
body = await request.json()
decklist = body.get('decklist', [])
num_games = body.get('numGames', 10)
@@ -566,7 +541,6 @@ async def sim_run(request: FastAPIRequest):
@router.get('/api/sim/status')
async def sim_status(simId: str):
"""Poll simulation progress."""
with _sim_lock:
run = _sim_runs.get(simId)
if not run:
@@ -580,7 +554,6 @@ async def sim_status(simId: str):
@router.get('/api/sim/result')
async def sim_result(simId: str):
"""Get completed simulation results."""
with _sim_lock:
run = _sim_runs.get(simId)
if not run:
@@ -593,7 +566,6 @@ async def sim_result(simId: str):
@router.post('/api/sim/run-from-deck')
async def sim_run_from_deck(request: FastAPIRequest):
"""Start simulation using a deck from the Deck Builder (by deck ID)."""
body = await request.json()
deck_id = body.get('deckId')
num_games = body.get('numGames', 10)
@@ -618,7 +590,6 @@ async def sim_run_from_deck(request: FastAPIRequest):
@router.post('/api/sim/run-deepseek')
async def sim_run_deepseek(request: FastAPIRequest):
"""Start simulation using DeepSeek AI as the opponent brain."""
body = await request.json()
deck_id = body.get('deckId')
num_games = body.get('numGames', 5)
@@ -647,7 +618,6 @@ async def sim_run_deepseek(request: FastAPIRequest):
@router.post('/api/sim/run-n-player')
async def sim_run_n_player(request: FastAPIRequest):
"""Start an N-player (2-4) batch simulation."""
body = await request.json()
deck_ids = body.get('deckIds', [])
num_games = body.get('numGames', 10)
@@ -681,14 +651,8 @@ async def sim_run_n_player(request: FastAPIRequest):
})
# ══════════════════════════════════════════════════════════════
# DeepSeek AI Brain Endpoints
# ══════════════════════════════════════════════════════════════
@router.post('/api/deepseek/connect')
async def deepseek_connect(request: FastAPIRequest):
"""Test connection to the DeepSeek LLM endpoint and auto-detect model."""
body = await request.json() if await request.body() else {}
api_base = body.get('apiBase', None)
model = body.get('model', None)
@@ -714,7 +678,6 @@ async def deepseek_connect(request: FastAPIRequest):
@router.get('/api/deepseek/status')
async def deepseek_status():
"""Get DeepSeek brain status and performance stats."""
brain = _get_deepseek_brain()
if brain is None:
return JSONResponse({'connected': False, 'error': 'Brain not initialized'})
@@ -723,7 +686,6 @@ async def deepseek_status():
@router.post('/api/deepseek/configure')
async def deepseek_configure(request: FastAPIRequest):
"""Update DeepSeek configuration at runtime."""
body = await request.json()
brain = _get_deepseek_brain()
if brain is None:
@@ -764,7 +726,6 @@ async def deepseek_configure(request: FastAPIRequest):
@router.get('/api/deepseek/logs')
async def deepseek_logs():
"""Get decision log stats and flush pending entries."""
brain = _get_deepseek_brain()
if brain is None:
return JSONResponse({'error': 'Brain not initialized'}, status_code=500)
@@ -799,7 +760,6 @@ async def deepseek_logs():
@router.get("/api/validator/status")
async def validator_status():
"""Get DeepSeek-R1-14B validator brain status."""
v = _get_validator_brain()
if v is None:
return JSONResponse({"enabled": False,
@@ -809,7 +769,6 @@ async def validator_status():
@router.post("/api/validator/configure")
async def validator_configure(request: FastAPIRequest):
"""Update validator configuration at runtime."""
body = await request.json()
v = _get_validator_brain()
if v is None:
+121
View File
@@ -0,0 +1,121 @@
"""
routes/rag.py
=============
RAG (Retrieval-Augmented Generation) endpoints for semantic card search.
Endpoints:
POST /api/rag/build — build / rebuild the ChromaDB vector index
POST /api/rag/search — semantic search over indexed cards
GET /api/rag/status — index health & stats
"""
from __future__ import annotations
import logging
from typing import List, Optional
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
log = logging.getLogger("commander_ai_lab.routes.rag")
router = APIRouter(tags=["rag"])
# ── Request / Response models ────────────────────────────────────────────────
class RAGSearchRequest(BaseModel):
query: str = Field(..., description="Natural-language card search query")
color_identity: Optional[List[str]] = Field(
None,
description="Optional color identity filter, e.g. ['W','U','B']",
)
top_k: int = Field(10, ge=1, le=50, description="Number of results to return")
class RAGCardResult(BaseModel):
name: str
type_line: str = ""
oracle_text: str = ""
color_identity: str = ""
score: float = 0.0
class RAGSearchResponse(BaseModel):
query: str
results: List[RAGCardResult]
total: int
class RAGBuildRequest(BaseModel):
force: bool = Field(False, description="Force full rebuild even if index exists")
batch_size: int = Field(500, ge=50, le=5000)
# ── Endpoints ────────────────────────────────────────────────────────────────
@router.post("/api/rag/build")
async def rag_build(body: RAGBuildRequest = RAGBuildRequest()):
"""
Build or rebuild the ChromaDB vector index from the Scryfall bulk DB.
Runs synchronously (may take 1-5 min on first build).
"""
try:
from services.rag_store import build_index
result = build_index(force=body.force, batch_size=body.batch_size)
return {"status": "ok", **result}
except FileNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc))
except Exception as exc:
log.exception("RAG build failed")
raise HTTPException(status_code=500, detail=str(exc))
@router.post("/api/rag/search")
async def rag_search(body: RAGSearchRequest):
"""
Semantic search over the ChromaDB card index.
Returns ranked card results with relevance scores.
"""
try:
from services.rag_store import query_cards
raw_results = query_cards(
query=body.query,
n_results=body.top_k,
color_identity=body.color_identity,
)
results = [
RAGCardResult(
name=r.get("name", ""),
type_line=r.get("type_line", ""),
oracle_text=r.get("oracle_text", ""),
color_identity=r.get("color_identity", ""),
score=round(1.0 - r.get("distance", 0.0), 4),
)
for r in raw_results
]
return RAGSearchResponse(
query=body.query,
results=results,
total=len(results),
)
except FileNotFoundError as exc:
raise HTTPException(
status_code=404,
detail=f"Index not built yet. Call POST /api/rag/build first. {exc}",
)
except Exception as exc:
log.exception("RAG search failed")
raise HTTPException(status_code=500, detail=str(exc))
@router.get("/api/rag/status")
async def rag_status_detail():
"""
Detailed RAG index status including card count and age.
"""
try:
from services.rag_store import get_rag_stats
return {"status": "ok", **get_rag_stats()}
except Exception as exc:
log.exception("RAG status check failed")
return {"status": "error", "error": str(exc)}
+297 -266
View File
@@ -1,266 +1,297 @@
"""Deck service: .dck parsing, deck profile saving, deck loading."""
import json
import logging
import os
import re
from pathlib import Path
from typing import Optional
from fastapi import HTTPException
from models.state import CFG
from services.database import _get_db_conn, _row_to_dict
log_collect = logging.getLogger("lab.collection")
_TYPE_PRIORITY = ["Land", "Instant", "Sorcery", "Artifact", "Enchantment", "Planeswalker", "Creature"]
_TYPE_TARGETS = {
"Land": [36, 38],
"Instant": [9, 11],
"Sorcery": [7, 9],
"Artifact": [9, 11],
"Creature": [20, 30],
"Enchantment": [5, 10],
"Planeswalker": [0, 5],
}
def _classify_card_type(type_line: str) -> str:
tl = type_line or ""
for t in _TYPE_PRIORITY:
if t in tl:
return t
return "Other"
def _get_deck_or_404(deck_id: int):
conn = _get_db_conn()
row = conn.execute("SELECT * FROM decks WHERE id = ?", (deck_id,)).fetchone()
if not row:
raise HTTPException(404, f"Deck {deck_id} not found")
d = dict(row)
try:
d["color_identity"] = json.loads(d.get("color_identity", "[]"))
except Exception:
d["color_identity"] = []
return d
def _compute_deck_analysis(deck_id: int) -> dict:
conn = _get_db_conn()
rows = conn.execute("""
SELECT dc.id, dc.scryfall_id, dc.card_name, dc.quantity, dc.is_commander, dc.role_tag,
ce.type_line, ce.oracle_text, ce.keywords, ce.cmc, ce.color_identity
FROM deck_cards dc
LEFT JOIN (
SELECT scryfall_id, type_line, oracle_text, keywords, cmc, color_identity
FROM collection_entries GROUP BY scryfall_id
) ce ON ce.scryfall_id = dc.scryfall_id
WHERE dc.deck_id = ?
""", (deck_id,)).fetchall()
counts_by_type = {t: 0 for t in _TYPE_PRIORITY}
counts_by_type["Other"] = 0
mana_curve = {"0": 0, "1": 0, "2": 0, "3": 0, "4": 0, "5": 0, "6+": 0}
color_pips = {"W": 0, "U": 0, "B": 0, "R": 0, "G": 0, "C": 0}
total_cards = 0
roles_count = {}
for row in rows:
r = dict(row)
qty = r.get("quantity", 1)
total_cards += qty
card_type = _classify_card_type(r.get("type_line", ""))
counts_by_type[card_type] = counts_by_type.get(card_type, 0) + qty
cmc = int(r.get("cmc", 0) or 0)
if card_type != "Land":
key = str(min(cmc, 6)) if cmc < 6 else "6+"
mana_curve[key] = mana_curve.get(key, 0) + qty
ci_raw = r.get("color_identity", "[]")
if isinstance(ci_raw, str):
try:
ci_raw = json.loads(ci_raw)
except Exception:
ci_raw = []
for c in (ci_raw or []):
if c in color_pips:
color_pips[c] += qty
role = r.get("role_tag") or "Unknown"
roles_count[role] = roles_count.get(role, 0) + qty
return {
"total_cards": total_cards,
"counts_by_type": counts_by_type,
"mana_curve": mana_curve,
"color_pips": color_pips,
"total_cards": total_cards,
"roles": roles_count,
}
def _check_ratio_limit(deck_id: int, card_type: str, count_to_add: int = 1) -> bool:
analysis = _compute_deck_analysis(deck_id)
current = analysis["counts_by_type"].get(card_type, 0)
target_max = _TYPE_TARGETS.get(card_type, [0, 9999])[1]
return (current + count_to_add) <= target_max
def _to_edhrec_slug(name: str) -> str:
slug = name.lower()
slug = re.sub(r"[',.]" , "", slug)
slug = re.sub(r"[^a-z0-9]+", "-", slug)
return slug.strip("-")
def parse_dck_file(deck_path: str) -> dict:
"""Parse a Forge .dck file into {name, commanders, mainboard, colorIdentity, totalCards}."""
path = Path(deck_path)
if not path.exists():
return {"error": f"File not found: {deck_path}"}
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
name = path.stem
section = "main"
commanders: dict = {}
mainboard: dict = {}
commander_name = None
for line in lines:
line = line.strip()
if not line or line.startswith("#"):
continue
if line == "[metadata]":
section = "metadata"; continue
if line.startswith("Name="):
name = line.split("=", 1)[1].strip(); continue
if line == "[Commander]":
section = "commander"; continue
if line == "[Main]":
section = "main"; continue
if line.lower().startswith("sideboard") or line == "[Sideboard]":
section = "sideboard"; continue
clean = re.sub(r"\(\w+\)\s*\d*$", "", line).strip()
clean = re.sub(r"\s*\*\.\*$", "", clean).strip()
m = re.match(r"^(\d+)x?\s+(.+)$", clean)
qty, card_name = (int(m.group(1)), m.group(2).strip()) if m else (1, clean)
if not card_name:
continue
if section == "commander":
commanders[card_name] = qty
if not commander_name:
commander_name = card_name
elif section != "sideboard":
mainboard[card_name] = qty
if commander_name:
name = f"{commander_name} - Text Import"
total = sum(commanders.values()) + sum(mainboard.values())
return {"name": name, "commander": commander_name, "commanders": commanders, "mainboard": mainboard, "colorIdentity": [], "totalCards": total}
def _save_profile_to_dck(profile: dict) -> Path:
lines = ["[metadata]", f"Name={profile.get('name', 'Imported Deck')}", "", "[Commander]"]
for name, qty in profile.get("commanders", {}).items():
lines.append(f"{qty} {name}")
lines += ["", "[Main]"]
for name, qty in profile.get("mainboard", {}).items():
lines.append(f"{qty} {name}")
content = "\n".join(lines)
safe_name = re.sub(r"[^a-zA-Z0-9 _-]", "", profile.get("name", "imported")).replace(" ", "_").strip()
if not safe_name:
safe_name = "imported_deck"
save_dir = CFG.forge_decks_dir
if not save_dir or not os.path.isdir(save_dir):
save_dir = os.path.join(Path(__file__).parent.parent, "imported-decks")
os.makedirs(save_dir, exist_ok=True)
out_path = Path(save_dir) / f"{safe_name}.dck"
out_path.write_text(content, encoding="utf-8")
log_collect.info(f" Saved .dck: {out_path}")
return out_path
def _load_deck_cards_by_name(deck_name: str) -> list:
"""Load cards from a deck by name, returning list of card dicts."""
conn = _get_db_conn()
deck_row = conn.execute("SELECT id FROM decks WHERE name = ?", (deck_name,)).fetchone()
if not deck_row:
return []
deck_id = deck_row[0]
rows = conn.execute("""
SELECT dc.card_name, dc.quantity, dc.is_commander, dc.role_tag, dc.scryfall_id,
COALESCE(ce.type_line, cr.type_line, '') as type_line,
COALESCE(ce.oracle_text, cr.oracle_text, '') as oracle_text,
COALESCE(ce.cmc, cr.cmc, 0) as cmc,
COALESCE(ce.color_identity, cr.color_identity, '[]') as color_identity,
COALESCE(ce.mana_cost, '', '') as mana_cost,
COALESCE(ce.power, '', '') as power,
COALESCE(ce.toughness, '', '') as toughness,
COALESCE(ce.keywords, cr.keywords, '[]') as keywords
FROM deck_cards dc
LEFT JOIN (
SELECT scryfall_id, type_line, oracle_text, cmc, color_identity, mana_cost, power, toughness, keywords
FROM collection_entries GROUP BY scryfall_id
) ce ON ce.scryfall_id = dc.scryfall_id
LEFT JOIN (
SELECT name, type_line, oracle_text, cmc, color_identity, keywords
FROM card_records GROUP BY name
) cr ON cr.name = dc.card_name
WHERE dc.deck_id = ?
""", (deck_id,)).fetchall()
return [_row_to_dict(r) for r in rows]
log_deckgen = logging.getLogger("commander_ai_lab.deckgen")
def _build_dck_lines(
deck_name: str,
commander_name: str,
cards: list[dict],
*,
resolve_substitutes: bool = False,
) -> list[str]:
"""Build Forge .dck file lines from deck name, commander, and card list."""
lines: list[str] = [
"[metadata]",
f"Name={deck_name}",
"",
"[Commander]",
f"1 {commander_name}",
"",
"[Main]",
]
for card in cards:
cname = card.get("name", "")
if resolve_substitutes:
if card.get("status") == "substituted" and card.get("selected_substitute"):
cname = card["selected_substitute"]
if cname and cname != commander_name:
lines.append(f"{card.get('count', card.get('quantity', 1))} {cname}")
return lines
def _write_dck_file(
deck_name: str,
commander_name: str,
cards: list[dict],
decks_dir: str | None = None,
*,
fallback_id: int | str = 0,
resolve_substitutes: bool = False,
) -> str | None:
"""Write a Forge .dck file to decks_dir. Returns path on success, None otherwise."""
if not decks_dir:
decks_dir = CFG.forge_decks_dir
if not decks_dir or not os.path.isdir(decks_dir):
return None
safe_name = re.sub(r"[^a-zA-Z0-9_\-\s]", "", deck_name).strip().replace(" ", "_")
if not safe_name:
safe_name = f"Deck_{fallback_id}"
dck_path = os.path.join(decks_dir, f"{safe_name}.dck")
lines = _build_dck_lines(
deck_name, commander_name, cards,
resolve_substitutes=resolve_substitutes,
)
with open(dck_path, "w", encoding="utf-8") as f:
f.write("\n".join(lines))
log_deckgen.info(f" Exported .dck: {dck_path}")
return dck_path
"""Deck service: .dck parsing, deck profile saving, deck loading."""
import json
import logging
import os
import re
from pathlib import Path
from typing import Optional
from fastapi import HTTPException
from models.state import CFG
from services.database import _get_db_conn, _row_to_dict
log_collect = logging.getLogger("lab.collection")
_TYPE_PRIORITY = ["Land", "Instant", "Sorcery", "Artifact", "Enchantment", "Planeswalker", "Creature"]
_TYPE_TARGETS = {
"Land": [36, 38],
"Instant": [9, 11],
"Sorcery": [7, 9],
"Artifact": [9, 11],
"Creature": [20, 30],
"Enchantment": [5, 10],
"Planeswalker": [0, 5],
}
def _classify_card_type(type_line: str) -> str:
tl = type_line or ""
for t in _TYPE_PRIORITY:
if t in tl:
return t
return "Other"
_CATEGORY_MAP = {
"Ramp": ["search your library for a", "add one mana", "add {c}", "{t}: add"],
"Removal": ["destroy target", "exile target", "counter target spell"],
"Draw": ["draw a card", "draw two", "draw three", "draw cards"],
"Board Wipe": ["destroy all", "exile all", "all creatures get -"],
"Protection": ["hexproof", "indestructible", "shroud", "ward"],
"Tutor": ["search your library"],
}
def _classify_card_category(type_line: str, oracle_text: str) -> str:
"""Assign a functional category based on type_line and oracle text."""
card_type = _classify_card_type(type_line)
if card_type == "Land":
return "Land"
text = (oracle_text or "").lower()
for cat, phrases in _CATEGORY_MAP.items():
if any(p in text for p in phrases):
return cat
if card_type == "Creature":
return "Creature"
return card_type
def _get_deck_or_404(deck_id: int):
conn = _get_db_conn()
row = conn.execute("SELECT * FROM decks WHERE id = ?", (deck_id,)).fetchone()
if not row:
raise HTTPException(404, f"Deck {deck_id} not found")
d = dict(row)
try:
d["color_identity"] = json.loads(d.get("color_identity", "[]"))
except Exception:
d["color_identity"] = []
return d
def _compute_deck_analysis(deck_id: int) -> dict:
conn = _get_db_conn()
rows = conn.execute("""
SELECT dc.id, dc.scryfall_id, dc.card_name, dc.quantity, dc.is_commander, dc.role_tag,
COALESCE(ce.type_line, cr.type_line, '') as type_line,
COALESCE(ce.oracle_text, cr.oracle_text, '') as oracle_text,
COALESCE(ce.keywords, cr.keywords, '[]') as keywords,
COALESCE(ce.cmc, cr.cmc, 0) as cmc,
COALESCE(ce.color_identity, cr.color_identity, '[]') as color_identity
FROM deck_cards dc
LEFT JOIN (
SELECT scryfall_id, type_line, oracle_text, keywords, cmc, color_identity
FROM collection_entries GROUP BY scryfall_id
) ce ON ce.scryfall_id = dc.scryfall_id
LEFT JOIN (
SELECT name, type_line, oracle_text, keywords, cmc, color_identity
FROM card_records GROUP BY name
) cr ON cr.name = dc.card_name
WHERE dc.deck_id = ?
""", (deck_id,)).fetchall()
counts_by_type = {t: 0 for t in _TYPE_PRIORITY}
counts_by_type["Other"] = 0
mana_curve = {"0": 0, "1": 0, "2": 0, "3": 0, "4": 0, "5": 0, "6+": 0}
color_pips = {"W": 0, "U": 0, "B": 0, "R": 0, "G": 0, "C": 0}
total_cards = 0
roles_count = {}
for row in rows:
r = dict(row)
qty = r.get("quantity", 1)
total_cards += qty
card_type = _classify_card_type(r.get("type_line", ""))
counts_by_type[card_type] = counts_by_type.get(card_type, 0) + qty
cmc = int(r.get("cmc", 0) or 0)
if card_type != "Land":
key = str(min(cmc, 6)) if cmc < 6 else "6+"
mana_curve[key] = mana_curve.get(key, 0) + qty
ci_raw = r.get("color_identity", "[]")
if isinstance(ci_raw, str):
try:
ci_raw = json.loads(ci_raw)
except Exception:
ci_raw = []
for c in (ci_raw or []):
if c in color_pips:
color_pips[c] += qty
role = r.get("role_tag") or "Unknown"
roles_count[role] = roles_count.get(role, 0) + qty
return {
"total_cards": total_cards,
"counts_by_type": counts_by_type,
"mana_curve": mana_curve,
"color_pips": color_pips,
"roles": roles_count,
}
def _check_ratio_limit(deck_id: int, card_type: str, count_to_add: int = 1) -> bool:
analysis = _compute_deck_analysis(deck_id)
current = analysis["counts_by_type"].get(card_type, 0)
target_max = _TYPE_TARGETS.get(card_type, [0, 9999])[1]
return (current + count_to_add) <= target_max
def _to_edhrec_slug(name: str) -> str:
slug = name.lower()
slug = re.sub(r"[',.]" , "", slug)
slug = re.sub(r"[^a-z0-9]+", "-", slug)
return slug.strip("-")
def parse_dck_file(deck_path: str) -> dict:
"""Parse a Forge .dck file into {name, commanders, mainboard, colorIdentity, totalCards}."""
path = Path(deck_path)
if not path.exists():
return {"error": f"File not found: {deck_path}"}
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
name = path.stem
section = "main"
commanders: dict = {}
mainboard: dict = {}
commander_name = None
for line in lines:
line = line.strip()
if not line or line.startswith("#"):
continue
if line == "[metadata]":
section = "metadata"; continue
if line.startswith("Name="):
name = line.split("=", 1)[1].strip(); continue
if line == "[Commander]":
section = "commander"; continue
if line == "[Main]":
section = "main"; continue
if line.lower().startswith("sideboard") or line == "[Sideboard]":
section = "sideboard"; continue
clean = re.sub(r"\(\w+\)\s*\d*$", "", line).strip()
clean = re.sub(r"\s*\*\.\*$", "", clean).strip()
m = re.match(r"^(\d+)x?\s+(.+)$", clean)
qty, card_name = (int(m.group(1)), m.group(2).strip()) if m else (1, clean)
if not card_name:
continue
if section == "commander":
commanders[card_name] = qty
if not commander_name:
commander_name = card_name
elif section != "sideboard":
mainboard[card_name] = qty
if commander_name:
name = f"{commander_name} - Text Import"
total = sum(commanders.values()) + sum(mainboard.values())
return {"name": name, "commander": commander_name, "commanders": commanders, "mainboard": mainboard, "colorIdentity": [], "totalCards": total}
def _save_profile_to_dck(profile: dict) -> Path:
lines = ["[metadata]", f"Name={profile.get('name', 'Imported Deck')}", "", "[Commander]"]
for name, qty in profile.get("commanders", {}).items():
lines.append(f"{qty} {name}")
lines += ["", "[Main]"]
for name, qty in profile.get("mainboard", {}).items():
lines.append(f"{qty} {name}")
content = "\n".join(lines)
safe_name = re.sub(r"[^a-zA-Z0-9 _-]", "", profile.get("name", "imported")).replace(" ", "_").strip()
if not safe_name:
safe_name = "imported_deck"
save_dir = CFG.forge_decks_dir
if not save_dir or not os.path.isdir(save_dir):
save_dir = os.path.join(Path(__file__).parent.parent, "imported-decks")
os.makedirs(save_dir, exist_ok=True)
out_path = Path(save_dir) / f"{safe_name}.dck"
out_path.write_text(content, encoding="utf-8")
log_collect.info(f" Saved .dck: {out_path}")
return out_path
def _load_deck_cards_by_name(deck_name: str) -> list:
"""Load cards from a deck by name, returning list of card dicts."""
conn = _get_db_conn()
deck_row = conn.execute("SELECT id FROM decks WHERE name = ?", (deck_name,)).fetchone()
if not deck_row:
return []
deck_id = deck_row[0]
rows = conn.execute("""
SELECT dc.card_name, dc.quantity, dc.is_commander, dc.role_tag, dc.scryfall_id,
COALESCE(ce.type_line, cr.type_line, '') as type_line,
COALESCE(ce.oracle_text, cr.oracle_text, '') as oracle_text,
COALESCE(ce.cmc, cr.cmc, 0) as cmc,
COALESCE(ce.color_identity, cr.color_identity, '[]') as color_identity,
COALESCE(ce.mana_cost, '', '') as mana_cost,
COALESCE(ce.power, '', '') as power,
COALESCE(ce.toughness, '', '') as toughness,
COALESCE(ce.keywords, cr.keywords, '[]') as keywords
FROM deck_cards dc
LEFT JOIN (
SELECT scryfall_id, type_line, oracle_text, cmc, color_identity, mana_cost, power, toughness, keywords
FROM collection_entries GROUP BY scryfall_id
) ce ON ce.scryfall_id = dc.scryfall_id
LEFT JOIN (
SELECT name, type_line, oracle_text, cmc, color_identity, keywords
FROM card_records GROUP BY name
) cr ON cr.name = dc.card_name
WHERE dc.deck_id = ?
""", (deck_id,)).fetchall()
return [_row_to_dict(r) for r in rows]
log_deckgen = logging.getLogger("commander_ai_lab.deckgen")
def _build_dck_lines(
deck_name: str,
commander_name: str,
cards: list[dict],
*,
resolve_substitutes: bool = False,
) -> list[str]:
"""Build Forge .dck file lines from deck name, commander, and card list."""
lines: list[str] = [
"[metadata]",
f"Name={deck_name}",
"",
"[Commander]",
f"1 {commander_name}",
"",
"[Main]",
]
for card in cards:
cname = card.get("name", "")
if resolve_substitutes:
if card.get("status") == "substituted" and card.get("selected_substitute"):
cname = card["selected_substitute"]
if cname and cname != commander_name:
lines.append(f"{card.get('count', card.get('quantity', 1))} {cname}")
return lines
def _write_dck_file(
deck_name: str,
commander_name: str,
cards: list[dict],
decks_dir: str | None = None,
*,
fallback_id: int | str = 0,
resolve_substitutes: bool = False,
) -> str | None:
"""Write a Forge .dck file to decks_dir. Returns path on success, None otherwise."""
if not decks_dir:
decks_dir = CFG.forge_decks_dir
if not decks_dir or not os.path.isdir(decks_dir):
return None
safe_name = re.sub(r"[^a-zA-Z0-9_\-\s]", "", deck_name).strip().replace(" ", "_")
if not safe_name:
safe_name = f"Deck_{fallback_id}"
dck_path = os.path.join(decks_dir, f"{safe_name}.dck")
lines = _build_dck_lines(
deck_name, commander_name, cards,
resolve_substitutes=resolve_substitutes,
)
with open(dck_path, "w", encoding="utf-8") as f:
f.write("\n".join(lines))
log_deckgen.info(f" Exported .dck: {dck_path}")
return dck_path
+518
View File
@@ -0,0 +1,518 @@
"""
services/rag_store.py
=====================
ChromaDB-backed semantic vector store over the Scryfall bulk card database.
Reads all cards from the Phase 1 SQLite bulk DB (services/scryfall_bulk.py),
embeds their oracle text using Ollama's nomic-embed-text model, and stores
the vectors in a persistent ChromaDB collection on disk.
This is RAG Phase 2. Phase 3 will query this store to inject relevant card
oracle text into the LLM prompt before calling the coach.
Public API:
ensure_rag_store() -> None (build if missing / stale)
build_index(force, batch_size) -> dict (explicit build, returns stats)
query_cards(text, n, colors) -> list[dict]
get_rag_stats() -> dict
Data path: ./data/rag_chroma/
Embedding: nomic-embed-text via Ollama (localhost:11434)
"""
from __future__ import annotations
import json
import logging
import os
import time
import threading
from pathlib import Path
from typing import Optional
logger = logging.getLogger("commander_ai_lab.rag_store")
# ── Config ────────────────────────────────────────────────────────────────
RAG_CHROMA_DIR = Path(__file__).parent.parent / "data" / "rag_chroma"
BUILD_META_PATH = RAG_CHROMA_DIR / "build_meta.json"
OLLAMA_BASE_URL = os.environ.get("OLLAMA_BASE_URL", "http://localhost:11434")
EMBEDDING_MODEL = os.environ.get("RAG_EMBEDDING_MODEL", "nomic-embed-text")
COLLECTION_NAME = "mtg_oracle"
DEFAULT_BATCH_SIZE = 500
_rag_lock = threading.Lock()
_rag_ready = False
# Module-level singletons to avoid re-creating client/collection on every call
_chroma_client = None
_chroma_collection = None
# ── Helpers ────────────────────────────────────────────────────────────────
def _build_document(card: dict) -> str:
"""Build the text document to embed for a single card.
Format: Name | Mana Cost | Type | Oracle Text
Matches the structure used by coach/embeddings._build_card_text() so
vector similarity is consistent across both embedding systems.
"""
parts = [card.get("name", "")]
mana_cost = card.get("mana_cost", "")
if mana_cost:
parts.append(mana_cost)
type_line = card.get("type_line", "")
if type_line:
parts.append(type_line)
oracle = card.get("oracle_text", "")
if oracle:
parts.append(oracle)
return " | ".join(parts)
def _build_metadata(card: dict) -> dict:
"""Build ChromaDB metadata dict for filtering."""
ci_raw = card.get("color_identity", "[]")
if isinstance(ci_raw, str):
try:
ci_list = json.loads(ci_raw)
except (json.JSONDecodeError, TypeError):
ci_list = []
else:
ci_list = ci_raw or []
return {
"name": card.get("name", ""),
"type_line": card.get("type_line", ""),
"cmc": float(card.get("cmc") or 0),
"color_identity": ",".join(sorted(ci_list)),
"rarity": card.get("rarity", ""),
"mana_cost": card.get("mana_cost", ""),
}
def _get_chromadb_client():
"""Return the module-level persistent ChromaDB client (singleton)."""
global _chroma_client
if _chroma_client is not None:
return _chroma_client
try:
import chromadb
except ImportError:
raise ImportError(
"chromadb is required for RAG Phase 2. "
"Install with: pip install 'commander-ai-lab[rag]'"
)
RAG_CHROMA_DIR.mkdir(parents=True, exist_ok=True)
_chroma_client = chromadb.PersistentClient(path=str(RAG_CHROMA_DIR))
return _chroma_client
def _get_ollama_embedding_fn():
"""Return a ChromaDB-compatible embedding function using Ollama."""
try:
from chromadb.utils.embedding_functions import OllamaEmbeddingFunction
except ImportError:
raise ImportError(
"chromadb >= 0.4.0 with Ollama support is required. "
"Install with: pip install 'commander-ai-lab[rag]'"
)
return OllamaEmbeddingFunction(
url=f"{OLLAMA_BASE_URL}/api/embeddings",
model_name=EMBEDDING_MODEL,
)
def _get_collection():
"""Return the ChromaDB collection singleton, creating it if needed."""
global _chroma_collection
if _chroma_collection is not None:
return _chroma_collection
client = _get_chromadb_client()
embed_fn = _get_ollama_embedding_fn()
_chroma_collection = client.get_or_create_collection(
name=COLLECTION_NAME,
embedding_function=embed_fn,
metadata={"hnsw:space": "cosine"},
)
return _chroma_collection
def _invalidate_collection_singleton() -> None:
"""Reset the cached collection after a rebuild so the next call re-opens it."""
global _chroma_collection
_chroma_collection = None
def _read_build_meta() -> dict:
"""Read the sidecar build_meta.json, returning {} if missing or corrupt."""
try:
if BUILD_META_PATH.exists():
return json.loads(BUILD_META_PATH.read_text(encoding="utf-8"))
except Exception as exc:
logger.warning("Could not read build_meta.json: %s", exc)
return {}
def _write_build_meta(card_count: int) -> None:
"""Persist build timestamp and card count to sidecar JSON file."""
try:
RAG_CHROMA_DIR.mkdir(parents=True, exist_ok=True)
BUILD_META_PATH.write_text(
json.dumps({"built_at": time.time(), "card_count": card_count}),
encoding="utf-8",
)
except Exception as exc:
logger.warning("Could not write build_meta.json: %s", exc)
def _collection_is_current(collection, bulk_card_count: int) -> bool:
"""Check if the ChromaDB collection is reasonably up-to-date.
Uses sidecar build_meta.json for the age check (survives restarts),
and compares card count within a 5% tolerance.
"""
chroma_count = collection.count()
if chroma_count == 0:
return False
# Age check via sidecar file (not collection.metadata which doesn't persist)
meta = _read_build_meta()
built_at = meta.get("built_at", 0)
if built_at:
age_days = (time.time() - float(built_at)) / 86400
if age_days > 14:
logger.info("RAG collection older than 14 days (%0.1f) — rebuilding.", age_days)
return False
ratio = chroma_count / max(bulk_card_count, 1)
if ratio < 0.95 or ratio > 1.05:
logger.info(
"RAG collection count mismatch: chroma=%d, bulk=%d — rebuilding.",
chroma_count, bulk_card_count,
)
return False
return True
# ── Build pipeline ────────────────────────────────────────────────────────────────
def _build_collection_from_bulk(batch_size: int = DEFAULT_BATCH_SIZE) -> dict:
"""Read all cards from the bulk SQLite DB and insert into ChromaDB.
Args:
batch_size: Number of cards to embed and insert per ChromaDB call.
Returns:
Dict with keys: indexed (int), failed (int).
Raises:
RuntimeError: If more than 1% of cards failed to insert.
"""
from services.scryfall_bulk import get_bulk_db
conn = get_bulk_db()
cursor = conn.execute(
"SELECT name, oracle_text, type_line, mana_cost, cmc, "
"color_identity, rarity, scryfall_id FROM cards "
"WHERE oracle_text != '' "
"ORDER BY edhrec_rank ASC"
)
all_rows = cursor.fetchall()
total_rows = len(all_rows)
client = _get_chromadb_client()
embed_fn = _get_ollama_embedding_fn()
# Drop and recreate collection to ensure a clean build
try:
client.delete_collection(COLLECTION_NAME)
except Exception:
pass
_invalidate_collection_singleton()
collection = client.create_collection(
name=COLLECTION_NAME,
embedding_function=embed_fn,
metadata={"hnsw:space": "cosine"},
)
inserted = 0
failed_cards = 0
failed_batches = 0
batch_ids: list[str] = []
batch_docs: list[str] = []
batch_metas: list[dict] = []
for row in all_rows:
card = dict(row)
doc = _build_document(card)
if not doc.strip():
continue
card_id = card.get("scryfall_id", "")
if not card_id:
continue
batch_ids.append(card_id)
batch_docs.append(doc)
batch_metas.append(_build_metadata(card))
if len(batch_ids) >= batch_size:
try:
collection.add(
ids=batch_ids,
documents=batch_docs,
metadatas=batch_metas,
)
inserted += len(batch_ids)
if inserted % 2000 == 0:
logger.info(" RAG indexed %d / %d cards...", inserted, total_rows)
except Exception as exc:
failed_cards += len(batch_ids)
failed_batches += 1
logger.warning(
"RAG batch insert failed (batch #%d, %d cards lost): %s",
failed_batches, len(batch_ids), exc,
)
batch_ids.clear()
batch_docs.clear()
batch_metas.clear()
# Final partial batch
if batch_ids:
try:
collection.add(
ids=batch_ids,
documents=batch_docs,
metadatas=batch_metas,
)
inserted += len(batch_ids)
except Exception as exc:
failed_cards += len(batch_ids)
failed_batches += 1
logger.warning("RAG final batch insert failed: %s", exc)
logger.info(
"RAG vector store complete: %d indexed, %d failed (%d batches).",
inserted, failed_cards, failed_batches,
)
# Abort if failure rate exceeds 1% — delete partial collection
if total_rows > 0 and (failed_cards / total_rows) > 0.01:
logger.error(
"RAG build failure rate %.1f%% exceeds 1%% threshold — deleting partial collection.",
100.0 * failed_cards / total_rows,
)
try:
client.delete_collection(COLLECTION_NAME)
_invalidate_collection_singleton()
except Exception:
pass
raise RuntimeError(
f"RAG build aborted: {failed_cards}/{total_rows} cards failed to insert. "
"Check Ollama connectivity and retry."
)
_write_build_meta(card_count=inserted)
# Invalidate singleton so next _get_collection() picks up the new collection
_invalidate_collection_singleton()
return {"indexed": inserted, "failed": failed_cards}
# ── Public API ────────────────────────────────────────────────────────────────
def build_index(force: bool = False, batch_size: int = DEFAULT_BATCH_SIZE) -> dict:
"""Explicitly build or rebuild the ChromaDB vector index.
This is the function called by POST /api/rag/build.
Unlike ensure_rag_store(), this always runs (no _rag_ready short-circuit)
and returns stats so the API can surface them to the caller.
Args:
force: If True, rebuild even if the index appears current.
batch_size: Cards per ChromaDB insert batch (default 500).
Returns:
Dict with keys: indexed (int), failed (int).
"""
global _rag_ready
with _rag_lock:
if not force:
try:
from services.scryfall_bulk import get_stats as bulk_stats
stats = bulk_stats()
bulk_count = stats.get("card_count", 0)
collection = _get_collection()
if _collection_is_current(collection, bulk_count):
logger.info("RAG index is current — skipping rebuild (use force=True to override).")
return {"indexed": collection.count(), "failed": 0, "skipped": True}
except Exception:
pass
result = _build_collection_from_bulk(batch_size=batch_size)
_rag_ready = True
return result
def ensure_rag_store() -> None:
"""Build the ChromaDB vector store if it is missing or stale.
Safe to call at startup — no-ops quickly if the store is current.
Requires:
- Phase 1 bulk DB to be populated (services/scryfall_bulk)
- Ollama running with nomic-embed-text pulled
"""
global _rag_ready
with _rag_lock:
if _rag_ready:
return
try:
from services.scryfall_bulk import get_stats as bulk_stats
stats = bulk_stats()
bulk_count = stats.get("card_count", 0)
if bulk_count == 0:
logger.warning(
"Scryfall bulk DB is empty — skipping RAG store build. "
"Run ensure_bulk_db() first."
)
return
collection = _get_collection()
if _collection_is_current(collection, bulk_count):
logger.info(
"RAG vector store is current (%d cards).",
collection.count(),
)
_rag_ready = True
return
logger.info(
"Building RAG vector store from %d bulk cards "
"(this may take several minutes on first run)...",
bulk_count,
)
_build_collection_from_bulk()
_rag_ready = True
except ImportError as exc:
logger.warning("RAG store unavailable: %s", exc)
except Exception as exc:
logger.error("RAG store build failed: %s", exc)
def query_cards(
query: str,
n_results: int = 8,
color_identity: Optional[list[str]] = None,
type_filter: Optional[str] = None,
) -> list[dict]:
"""Semantic search over card oracle text.
Args:
query: Natural language query (e.g. "draw cards when creatures die").
n_results: Maximum number of results.
color_identity: If provided, only return cards whose color identity
is a subset (e.g. ["W", "U", "B"]).
type_filter: Optional type_line substring filter (e.g. "Creature").
Applied as a post-query Python filter for ChromaDB compatibility.
Returns:
List of dicts with keys: name, type_line, oracle_text, cmc,
color_identity, mana_cost, distance.
"""
if not _rag_ready:
logger.warning("RAG store not ready — returning empty results.")
return []
try:
collection = _get_collection()
except Exception as exc:
logger.error("Failed to get RAG collection: %s", exc)
return []
try:
# Fetch extra results to account for post-query color/type filtering
fetch_n = n_results * 4 if (color_identity or type_filter) else n_results
results = collection.query(
query_texts=[query],
n_results=min(fetch_n, 200),
include=["documents", "metadatas", "distances"],
)
except Exception as exc:
logger.error("RAG query failed: %s", exc)
return []
if not results or not results.get("ids") or not results["ids"][0]:
return []
cards: list[dict] = []
ids = results["ids"][0]
docs = results["documents"][0] if results.get("documents") else [""] * len(ids)
metas = results["metadatas"][0] if results.get("metadatas") else [{}] * len(ids)
dists = results["distances"][0] if results.get("distances") else [0.0] * len(ids)
for i, card_id in enumerate(ids):
meta = metas[i] if i < len(metas) else {}
doc = docs[i] if i < len(docs) else ""
# Color identity post-filter (Python-side for ChromaDB compat)
if color_identity:
card_ci_str = meta.get("color_identity", "")
card_ci = set(c for c in card_ci_str.split(",") if c) if card_ci_str else set()
deck_ci = set(color_identity)
if not card_ci.issubset(deck_ci):
continue
# Type post-filter
if type_filter:
card_type = meta.get("type_line", "")
if type_filter.lower() not in card_type.lower():
continue
# Document format: Name | Mana Cost | Type | Oracle Text
parts = doc.split(" | ", 3)
oracle = parts[3] if len(parts) > 3 else (parts[-1] if parts else "")
cards.append({
"name": meta.get("name", ""),
"type_line": meta.get("type_line", ""),
"oracle_text": oracle,
"cmc": meta.get("cmc", 0),
"color_identity": meta.get("color_identity", ""),
"mana_cost": meta.get("mana_cost", ""),
"distance": round(dists[i], 4) if i < len(dists) else 0.0,
})
if len(cards) >= n_results:
break
return cards
def get_rag_stats() -> dict:
"""Return metadata about the RAG vector store state."""
result = {
"ready": _rag_ready,
"chroma_dir": str(RAG_CHROMA_DIR),
"embedding_model": EMBEDDING_MODEL,
"ollama_url": OLLAMA_BASE_URL,
"card_count": 0,
"built_at": None,
"age_days": None,
}
try:
collection = _get_collection()
result["card_count"] = collection.count()
except Exception:
pass
# Read age from sidecar file (persists across restarts)
meta = _read_build_meta()
built_at = meta.get("built_at")
if built_at:
result["built_at"] = built_at
try:
age = (time.time() - float(built_at)) / 86400
result["age_days"] = round(age, 1)
except (ValueError, TypeError):
pass
return result
+372
View File
@@ -0,0 +1,372 @@
"""
services/scryfall_bulk.py
=========================
Downloads and indexes the full Scryfall `default_cards` bulk export into a
local SQLite database with FTS5 full-text search over card names and oracle
text. This replaces per-card live Scryfall API calls for the RAG pipeline.
Usage (one-time or nightly):
from services.scryfall_bulk import ensure_bulk_db
ensure_bulk_db() # call at startup; no-ops if data is fresh
Public API:
ensure_bulk_db() -> None (download if stale)
lookup_card(name) -> dict | None
search_oracle(query, colors, limit) -> list[dict]
get_bulk_db() -> sqlite3.Connection
Data path: ./data/scryfall_bulk.db
Re-download: every SCRYFALL_BULK_STALENESS_DAYS days (default 7)
"""
from __future__ import annotations
import json
import logging
import os
import sqlite3
import threading
import time
from pathlib import Path
from urllib.request import Request, urlopen
logger = logging.getLogger("commander_ai_lab.scryfall_bulk")
# ── Config ──────────────────────────────────────────────────────────────────
BULK_DB_PATH = Path(__file__).parent.parent / "data" / "scryfall_bulk.db"
BULK_META_URL = "https://api.scryfall.com/bulk-data"
SCRYFALL_BULK_STALENESS_DAYS = int(
os.environ.get("SCRYFALL_BULK_STALENESS_DAYS", "7")
)
_API_HEADERS = {"User-Agent": "CommanderAILab/3.0", "Accept": "application/json"}
# ── Module-level singletons ──────────────────────────────────────────────────
_bulk_conn: sqlite3.Connection | None = None
_bulk_lock = threading.Lock()
# ── DB init ──────────────────────────────────────────────────────────────────
def _init_db(conn: sqlite3.Connection) -> None:
"""Create tables and FTS5 virtual table on first run."""
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA synchronous=NORMAL")
conn.executescript("""
CREATE TABLE IF NOT EXISTS cards (
scryfall_id TEXT PRIMARY KEY,
name TEXT NOT NULL,
oracle_text TEXT DEFAULT '',
type_line TEXT DEFAULT '',
mana_cost TEXT DEFAULT '',
cmc REAL DEFAULT 0,
color_identity TEXT DEFAULT '[]',
keywords TEXT DEFAULT '[]',
power TEXT DEFAULT '',
toughness TEXT DEFAULT '',
rarity TEXT DEFAULT '',
edhrec_rank INTEGER DEFAULT 0,
legalities TEXT DEFAULT '{}',
rulings_uri TEXT DEFAULT '',
set_code TEXT DEFAULT '',
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE VIRTUAL TABLE IF NOT EXISTS cards_fts USING fts5(
name,
oracle_text,
content='cards',
content_rowid='rowid'
);
CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
""")
conn.commit()
# ── Staleness check ───────────────────────────────────────────────────────────
def _is_stale(conn: sqlite3.Connection) -> bool:
row = conn.execute(
"SELECT value FROM meta WHERE key = 'last_download'"
).fetchone()
if not row:
return True
try:
age_days = (time.time() - float(row[0])) / 86400
return age_days > SCRYFALL_BULK_STALENESS_DAYS
except (ValueError, TypeError):
return True
# ── Download + index ──────────────────────────────────────────────────────────
def _get_bulk_download_url() -> str:
"""Fetch the current default_cards download URL from the Scryfall manifest."""
req = Request(BULK_META_URL, headers=_API_HEADERS)
with urlopen(req, timeout=15) as resp:
meta = json.loads(resp.read())
for entry in meta.get("data", []):
if entry.get("type") == "default_cards":
return entry["download_uri"]
raise RuntimeError("Could not find default_cards entry in Scryfall bulk-data manifest")
def _download_and_index(conn: sqlite3.Connection) -> int:
"""
Download the full default_cards JSON from Scryfall and index into SQLite.
Replaces all existing rows. Returns number of cards indexed.
"""
logger.info("Fetching Scryfall bulk-data manifest...")
url = _get_bulk_download_url()
logger.info("Downloading Scryfall default_cards bulk export (~100 MB)...")
req = Request(url, headers=_API_HEADERS)
with urlopen(req, timeout=300) as resp:
raw_bytes = resp.read()
cards_json: list[dict] = json.loads(raw_bytes)
logger.info(f"Received {len(cards_json)} card objects from Scryfall.")
# Wipe existing data for a clean rebuild
conn.execute("DELETE FROM cards")
conn.execute("DELETE FROM cards_fts")
conn.commit()
BATCH_SIZE = 1000
inserted = 0
batch: list[tuple] = []
_SKIP_LAYOUTS = {"token", "emblem", "art_series", "double_faced_token"}
for card in cards_json:
if card.get("layout") in _SKIP_LAYOUTS:
continue
# Handle double-faced / meld cards: concatenate face oracle texts
oracle = card.get("oracle_text", "")
if not oracle and card.get("card_faces"):
oracle = "\n//\n".join(
f.get("oracle_text", "")
for f in card["card_faces"]
if f.get("oracle_text")
)
mana_cost = card.get("mana_cost", "")
if not mana_cost and card.get("card_faces"):
mana_cost = card["card_faces"][0].get("mana_cost", "")
batch.append((
card.get("id", ""),
card.get("name", ""),
oracle,
card.get("type_line", ""),
mana_cost,
float(card.get("cmc") or 0),
json.dumps(card.get("color_identity", [])),
json.dumps(card.get("keywords", [])),
card.get("power", "") or "",
card.get("toughness", "") or "",
card.get("rarity", ""),
int(card.get("edhrec_rank") or 0),
json.dumps(card.get("legalities", {})),
card.get("rulings_uri", "") or "",
card.get("set", ""),
))
if len(batch) >= BATCH_SIZE:
_flush_batch(conn, batch)
inserted += len(batch)
batch.clear()
logger.debug(f" Indexed {inserted} cards...")
if batch:
_flush_batch(conn, batch)
inserted += len(batch)
# Rebuild FTS5 index from cards table
conn.execute("INSERT INTO cards_fts(cards_fts) VALUES('rebuild')")
# Record download timestamp
conn.execute(
"INSERT OR REPLACE INTO meta (key, value) VALUES ('last_download', ?)",
(str(time.time()),)
)
conn.execute(
"INSERT OR REPLACE INTO meta (key, value) VALUES ('card_count', ?)",
(str(inserted),)
)
conn.commit()
logger.info(f"Scryfall bulk index complete: {inserted} cards stored.")
return inserted
def _flush_batch(conn: sqlite3.Connection, batch: list[tuple]) -> None:
conn.executemany(
"""
INSERT OR REPLACE INTO cards
(scryfall_id, name, oracle_text, type_line, mana_cost, cmc,
color_identity, keywords, power, toughness, rarity,
edhrec_rank, legalities, rulings_uri, set_code)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
""",
batch,
)
conn.commit()
# ── Public API ────────────────────────────────────────────────────────────────
def get_bulk_db() -> sqlite3.Connection:
"""
Return the module-level SQLite connection, creating it on first call.
Thread-safe via _bulk_lock.
"""
global _bulk_conn
with _bulk_lock:
if _bulk_conn is None:
BULK_DB_PATH.parent.mkdir(parents=True, exist_ok=True)
_bulk_conn = sqlite3.connect(
str(BULK_DB_PATH), check_same_thread=False
)
_bulk_conn.row_factory = sqlite3.Row
_init_db(_bulk_conn)
return _bulk_conn
def ensure_bulk_db() -> None:
"""
Call at startup. Downloads and indexes the full Scryfall card set if the
local DB is missing or older than SCRYFALL_BULK_STALENESS_DAYS.
No-ops if data is already fresh.
"""
conn = get_bulk_db()
if _is_stale(conn):
logger.info(
"Scryfall bulk DB is stale or missing — downloading now "
"(~100 MB, one-time per week)..."
)
try:
_download_and_index(conn)
except Exception as exc:
logger.error(
f"Scryfall bulk download failed: {exc}. "
"Continuing without bulk data — per-card Scryfall cache will be used."
)
else:
row = conn.execute(
"SELECT value FROM meta WHERE key = 'card_count'"
).fetchone()
count = row[0] if row else "unknown"
logger.info(f"Scryfall bulk DB is fresh ({count} cards cached).")
def lookup_card(name: str) -> dict | None:
"""
Exact case-insensitive card name lookup.
Returns a dict with all columns from the `cards` table, or None if not
found. Prefers the most-recently-printed printing (lowest edhrec_rank).
"""
conn = get_bulk_db()
row = conn.execute(
"""
SELECT * FROM cards
WHERE LOWER(name) = LOWER(?)
ORDER BY edhrec_rank ASC
LIMIT 1
""",
(name,),
).fetchone()
return dict(row) if row else None
def search_oracle(
query: str,
color_identity: list[str] | None = None,
type_filter: str | None = None,
limit: int = 10,
) -> list[dict]:
"""
Full-text search over card names and oracle text using SQLite FTS5.
Args:
query: Natural language or keyword query string.
color_identity: If provided, restrict results to cards whose color
identity is a subset of this list (e.g. ['W', 'U']).
type_filter: Optional type-line substring filter (e.g. 'Creature').
limit: Maximum number of results to return.
Returns:
List of card dicts ordered by FTS5 relevance rank.
"""
conn = get_bulk_db()
# Build FTS5 query: wrap each token in quotes to allow phrase matching
tokens = [t.strip() for t in query.split() if t.strip()]
if not tokens:
return []
fts_query = " OR ".join(f'"{t}"' for t in tokens)
# Over-fetch to allow post-filtering by color/type
fetch_limit = max(limit * 5, 50)
rows = conn.execute(
"""
SELECT c.*
FROM cards c
JOIN cards_fts fts ON fts.rowid = c.rowid
WHERE cards_fts MATCH ?
ORDER BY rank
LIMIT ?
""",
(fts_query, fetch_limit),
).fetchall()
results: list[dict] = []
for row in rows:
card = dict(row)
# Color identity filter: card's CI must be a subset of deck's CI
if color_identity:
try:
card_ci = set(json.loads(card.get("color_identity") or "[]"))
except (json.JSONDecodeError, TypeError):
card_ci = set()
if not card_ci.issubset(set(color_identity)):
continue
# Optional type-line filter
if type_filter:
if type_filter.lower() not in (card.get("type_line") or "").lower():
continue
results.append(card)
if len(results) >= limit:
break
return results
def get_stats() -> dict:
"""Return metadata about the current bulk DB state."""
conn = get_bulk_db()
rows = conn.execute("SELECT key, value FROM meta").fetchall()
meta = {r["key"]: r["value"] for r in rows}
card_count = conn.execute("SELECT COUNT(*) FROM cards").fetchone()[0]
stale = _is_stale(conn)
last_dl = meta.get("last_download")
age_days: float | None = None
if last_dl:
try:
age_days = round((time.time() - float(last_dl)) / 86400, 1)
except (ValueError, TypeError):
pass
return {
"card_count": card_count,
"stale": stale,
"age_days": age_days,
"db_path": str(BULK_DB_PATH),
"staleness_threshold_days": SCRYFALL_BULK_STALENESS_DAYS,
}
+15
View File
@@ -223,6 +223,21 @@ def enrich_card(card: Card) -> Card:
_apply_oracle_flags(card)
return card
# ── Nonbasic land name guard (Issue #118) ──────────────────
# Catch common nonbasic lands BEFORE the creature fallback
_land_kw = re.compile(
r"\b(grove|garden|glade|falls|summit|crag|chapel|retreat|harbor|cemetery|"
r"pool|fountain|shrine|foundry|vents|grave|crypt|tomb|ground|"
r"command tower|path of ancestry|evolving wilds|terramorphic|expanse|"
r"exotic orchard|mana confluence|city of brass|reflecting pool|"
r"fabled passage|prismatic vista)\b", re.IGNORECASE)
if _land_kw.search(name_lower):
card.type_line = "Land"
card.cmc = 0
_apply_oracle_flags(card)
return card
# Default: treat as creature with deterministic CMC based on
# card name hash (fixes #32 — no more random.randint jitter
# between games for the same card).
+374
View File
@@ -0,0 +1,374 @@
"""
tests/test_rag_store.py
=======================
Unit tests for services/rag_store.py.
All tests use mocked ChromaDB and Ollama dependencies — no external services
or network access required.
"""
from __future__ import annotations
import json
import time
import types
from pathlib import Path
from unittest.mock import MagicMock, patch, mock_open
import pytest
# ---------------------------------------------------------------------------
# Helpers to reset module-level singletons between tests
# ---------------------------------------------------------------------------
def _reset_rag_module():
"""Reset all module-level state in rag_store between tests."""
import services.rag_store as rs
rs._rag_ready = False
rs._chroma_client = None
rs._chroma_collection = None
@pytest.fixture(autouse=True)
def reset_rag_state():
_reset_rag_module()
yield
_reset_rag_module()
# ---------------------------------------------------------------------------
# _build_document
# ---------------------------------------------------------------------------
class TestBuildDocument:
def test_includes_all_four_fields(self):
from services.rag_store import _build_document
card = {
"name": "Rampant Growth",
"mana_cost": "{1}{G}",
"type_line": "Sorcery",
"oracle_text": "Search your library for a basic land card.",
}
doc = _build_document(card)
assert "Rampant Growth" in doc
assert "{1}{G}" in doc
assert "Sorcery" in doc
assert "Search your library" in doc
def test_mana_cost_present_in_document(self):
"""Regression: mana_cost was previously missing (bug #128)."""
from services.rag_store import _build_document
card = {"name": "Lightning Bolt", "mana_cost": "{R}", "type_line": "Instant", "oracle_text": "Deal 3 damage."}
doc = _build_document(card)
assert "{R}" in doc
def test_missing_mana_cost_skipped(self):
from services.rag_store import _build_document
card = {"name": "Plains", "mana_cost": "", "type_line": "Basic Land", "oracle_text": ""}
doc = _build_document(card)
# Should not produce " | | " style empty fields
assert "Plains" in doc
assert doc.count(" | | ") == 0
def test_empty_card_returns_name_only(self):
from services.rag_store import _build_document
card = {"name": "Unnamed"}
doc = _build_document(card)
assert doc == "Unnamed"
def test_pipe_delimiter_format(self):
from services.rag_store import _build_document
card = {"name": "A", "mana_cost": "B", "type_line": "C", "oracle_text": "D"}
assert _build_document(card) == "A | B | C | D"
# ---------------------------------------------------------------------------
# _build_metadata
# ---------------------------------------------------------------------------
class TestBuildMetadata:
def test_color_identity_json_string_parsed(self):
from services.rag_store import _build_metadata
card = {"name": "X", "color_identity": '["W", "U"]', "cmc": 3}
meta = _build_metadata(card)
assert meta["color_identity"] == "U,W" # sorted
def test_color_identity_list_input(self):
from services.rag_store import _build_metadata
card = {"name": "X", "color_identity": ["B", "G"], "cmc": 2}
meta = _build_metadata(card)
assert meta["color_identity"] == "B,G"
def test_colorless_card(self):
from services.rag_store import _build_metadata
card = {"name": "Sol Ring", "color_identity": "[]", "cmc": 1}
meta = _build_metadata(card)
assert meta["color_identity"] == ""
def test_malformed_color_identity_defaults_empty(self):
from services.rag_store import _build_metadata
card = {"name": "X", "color_identity": "not-json", "cmc": 0}
meta = _build_metadata(card)
assert meta["color_identity"] == ""
def test_cmc_none_defaults_zero(self):
from services.rag_store import _build_metadata
card = {"name": "X", "cmc": None}
meta = _build_metadata(card)
assert meta["cmc"] == 0.0
# ---------------------------------------------------------------------------
# _read_build_meta / _write_build_meta
# ---------------------------------------------------------------------------
class TestBuildMeta:
def test_write_then_read_roundtrip(self, tmp_path):
import services.rag_store as rs
original_dir = rs.RAG_CHROMA_DIR
original_path = rs.BUILD_META_PATH
try:
rs.RAG_CHROMA_DIR = tmp_path
rs.BUILD_META_PATH = tmp_path / "build_meta.json"
rs._write_build_meta(card_count=12345)
meta = rs._read_build_meta()
assert meta["card_count"] == 12345
assert abs(meta["built_at"] - time.time()) < 5
finally:
rs.RAG_CHROMA_DIR = original_dir
rs.BUILD_META_PATH = original_path
def test_read_missing_returns_empty_dict(self, tmp_path):
import services.rag_store as rs
original_path = rs.BUILD_META_PATH
try:
rs.BUILD_META_PATH = tmp_path / "nonexistent.json"
assert rs._read_build_meta() == {}
finally:
rs.BUILD_META_PATH = original_path
# ---------------------------------------------------------------------------
# _collection_is_current
# ---------------------------------------------------------------------------
class TestCollectionIsCurrent:
def _make_collection(self, count: int) -> MagicMock:
col = MagicMock()
col.count.return_value = count
return col
def test_empty_collection_returns_false(self):
from services.rag_store import _collection_is_current
col = self._make_collection(0)
assert _collection_is_current(col, 1000) is False
def test_current_collection_returns_true(self, tmp_path):
import services.rag_store as rs
original_path = rs.BUILD_META_PATH
try:
rs.BUILD_META_PATH = tmp_path / "build_meta.json"
rs._write_build_meta(card_count=1000)
col = self._make_collection(1000)
assert rs._collection_is_current(col, 1000) is True
finally:
rs.BUILD_META_PATH = original_path
def test_stale_by_age_returns_false(self, tmp_path):
import services.rag_store as rs
original_path = rs.BUILD_META_PATH
try:
rs.BUILD_META_PATH = tmp_path / "build_meta.json"
# Write a built_at 15 days ago
rs.BUILD_META_PATH.write_text(
json.dumps({"built_at": time.time() - (15 * 86400), "card_count": 1000}),
encoding="utf-8",
)
col = self._make_collection(1000)
assert rs._collection_is_current(col, 1000) is False
finally:
rs.BUILD_META_PATH = original_path
def test_count_mismatch_returns_false(self, tmp_path):
import services.rag_store as rs
original_path = rs.BUILD_META_PATH
try:
rs.BUILD_META_PATH = tmp_path / "build_meta.json"
rs._write_build_meta(card_count=500)
col = self._make_collection(500) # chroma has 500
assert rs._collection_is_current(col, 1000) is False # bulk has 1000
finally:
rs.BUILD_META_PATH = original_path
# ---------------------------------------------------------------------------
# query_cards
# ---------------------------------------------------------------------------
class TestQueryCards:
def test_returns_empty_when_not_ready(self):
from services.rag_store import query_cards
# _rag_ready is False (reset by fixture)
result = query_cards("draw cards")
assert result == []
def test_color_filter_removes_off_color_cards(self):
import services.rag_store as rs
rs._rag_ready = True
mock_col = MagicMock()
mock_col.query.return_value = {
"ids": [["id1", "id2"]],
"documents": [["Wrath | {3}{W}{W} | Sorcery | Destroy all", "Dark Ritual | {B} | Instant | Add BBB"]],
"metadatas": [[
{"name": "Wrath", "type_line": "Sorcery", "color_identity": "W", "cmc": 5.0, "mana_cost": "{3}{W}{W}"},
{"name": "Dark Ritual", "type_line": "Instant", "color_identity": "B", "cmc": 1.0, "mana_cost": "{B}"},
]],
"distances": [[0.1, 0.2]],
}
rs._chroma_collection = mock_col
results = rs.query_cards("mana acceleration", n_results=10, color_identity=["W"])
names = [r["name"] for r in results]
assert "Wrath" in names
assert "Dark Ritual" not in names
def test_distance_converted_to_score_in_route(self):
"""Verify distance field is present and in [0, 1] range."""
import services.rag_store as rs
rs._rag_ready = True
mock_col = MagicMock()
mock_col.query.return_value = {
"ids": [["id1"]],
"documents": [["Sol Ring | {1} | Artifact | Add two colorless mana."]],
"metadatas": [[
{"name": "Sol Ring", "type_line": "Artifact", "color_identity": "", "cmc": 1.0, "mana_cost": "{1}"},
]],
"distances": [[0.15]],
}
rs._chroma_collection = mock_col
results = rs.query_cards("mana rock", n_results=1)
assert len(results) == 1
assert results[0]["distance"] == 0.15
assert results[0]["name"] == "Sol Ring"
def test_empty_chroma_result_returns_empty_list(self):
import services.rag_store as rs
rs._rag_ready = True
mock_col = MagicMock()
mock_col.query.return_value = {"ids": [[]], "documents": [[]], "metadatas": [[]], "distances": [[]]}
rs._chroma_collection = mock_col
assert rs.query_cards("nothing") == []
# ---------------------------------------------------------------------------
# get_rag_stats
# ---------------------------------------------------------------------------
class TestGetRagStats:
def test_returns_expected_keys(self):
import services.rag_store as rs
mock_col = MagicMock()
mock_col.count.return_value = 42000
rs._chroma_collection = mock_col
stats = rs.get_rag_stats()
for key in ["ready", "chroma_dir", "embedding_model", "ollama_url", "card_count", "built_at", "age_days"]:
assert key in stats, f"Missing key: {key}"
def test_card_count_from_collection(self):
import services.rag_store as rs
mock_col = MagicMock()
mock_col.count.return_value = 55000
rs._chroma_collection = mock_col
stats = rs.get_rag_stats()
assert stats["card_count"] == 55000
def test_ready_false_when_not_initialized(self):
from services.rag_store import get_rag_stats
stats = get_rag_stats()
assert stats["ready"] is False
# ---------------------------------------------------------------------------
# build_index (public API for POST /api/rag/build)
# ---------------------------------------------------------------------------
class TestBuildIndex:
def test_skips_rebuild_when_current(self, tmp_path):
import services.rag_store as rs
original_path = rs.BUILD_META_PATH
original_dir = rs.RAG_CHROMA_DIR
try:
rs.BUILD_META_PATH = tmp_path / "build_meta.json"
rs.RAG_CHROMA_DIR = tmp_path
rs._write_build_meta(card_count=1000)
mock_col = MagicMock()
mock_col.count.return_value = 1000
rs._chroma_collection = mock_col
with patch("services.rag_store._get_collection", return_value=mock_col), \
patch("services.scryfall_bulk.get_stats", return_value={"card_count": 1000}):
result = rs.build_index(force=False)
assert result.get("skipped") is True
finally:
rs.BUILD_META_PATH = original_path
rs.RAG_CHROMA_DIR = original_dir
def test_force_triggers_rebuild(self, tmp_path):
import services.rag_store as rs
with patch("services.rag_store._build_collection_from_bulk") as mock_build:
mock_build.return_value = {"indexed": 500, "failed": 0}
result = rs.build_index(force=True, batch_size=250)
mock_build.assert_called_once_with(batch_size=250)
assert result["indexed"] == 500
assert rs._rag_ready is True
# ---------------------------------------------------------------------------
# Failure tracking in _build_collection_from_bulk
# ---------------------------------------------------------------------------
class TestBuildCollectionFailureTracking:
def test_high_failure_rate_raises_runtime_error(self, tmp_path):
"""If >1% of batches fail, a RuntimeError should be raised."""
import services.rag_store as rs
fake_rows = [
{"name": f"Card {i}", "oracle_text": "text", "type_line": "Instant",
"mana_cost": "{1}", "cmc": 1, "color_identity": "[]",
"rarity": "common", "scryfall_id": f"id-{i}"}
for i in range(200)
]
mock_conn = MagicMock()
mock_cursor = MagicMock()
mock_cursor.fetchall.return_value = [types.SimpleNamespace(**r) for r in fake_rows]
mock_conn.execute.return_value = mock_cursor
mock_collection = MagicMock()
mock_collection.add.side_effect = Exception("Ollama timeout")
mock_client = MagicMock()
mock_client.create_collection.return_value = mock_collection
original_dir = rs.RAG_CHROMA_DIR
original_path = rs.BUILD_META_PATH
try:
rs.RAG_CHROMA_DIR = tmp_path
rs.BUILD_META_PATH = tmp_path / "build_meta.json"
with patch("services.scryfall_bulk.get_bulk_db", return_value=mock_conn), \
patch("services.rag_store._get_chromadb_client", return_value=mock_client), \
patch("services.rag_store._get_ollama_embedding_fn", return_value=MagicMock()), \
patch("services.rag_store._invalidate_collection_singleton"):
with pytest.raises(RuntimeError, match="RAG build aborted"):
rs._build_collection_from_bulk(batch_size=50)
finally:
rs.RAG_CHROMA_DIR = original_dir
rs.BUILD_META_PATH = original_path
+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">
+135 -136
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(() => {});
@@ -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) {