Restructure into full project layout

This commit is contained in:
2026-06-16 23:06:16 -06:00
parent de4862b2d1
commit 57765496a6
74 changed files with 4441 additions and 3 deletions
+142
View File
@@ -0,0 +1,142 @@
"""
Claude API client for deck generation.
Responsibilities:
1. Call the Anthropic API with the appropriate prompt
2. Parse and validate the JSON response
3. Strip [UNOWNED] markers and track ownership
4. Validate card names against Scryfall (batch)
5. Return a structured DeckPayload ready to persist
"""
import json
import re
from typing import Optional
import anthropic
from app.core.config import settings
from app.models.deck import CardSlot
from app.services import scryfall
_client: anthropic.AsyncAnthropic | None = None
def _get_client() -> anthropic.AsyncAnthropic:
global _client
if _client is None:
_client = anthropic.AsyncAnthropic(api_key=settings.ANTHROPIC_API_KEY)
return _client
async def call_claude(
system_prompt: str,
user_message: str,
max_tokens: int = 8000,
) -> "DeckPayload":
client = _get_client()
message = await client.messages.create(
model=settings.ANTHROPIC_MODEL,
max_tokens=max_tokens,
system=system_prompt,
messages=[{"role": "user", "content": user_message}],
)
raw_text = _extract_text(message)
deck_json = _parse_json(raw_text)
payload = _build_payload(deck_json)
await _enrich_with_scryfall(payload)
return payload
UNOWNED_RE = re.compile(r"\s*\[UNOWNED\]\s*$", re.IGNORECASE)
def _extract_text(message) -> str:
for block in message.content:
if block.type == "text":
return block.text.strip()
raise ValueError("Claude returned no text content")
def _parse_json(text: str) -> dict:
text = re.sub(r"^```(?:json)?\s*", "", text, flags=re.MULTILINE)
text = re.sub(r"\s*```$", "", text, flags=re.MULTILINE)
try:
return json.loads(text.strip())
except json.JSONDecodeError as e:
raise ValueError(f"Claude response was not valid JSON: {e}\n\nRaw: {text[:500]}")
def _parse_slot(raw: str) -> CardSlot:
normalised = raw.strip().lower()
try:
return CardSlot(normalised)
except ValueError:
mapping = {
"lands": CardSlot.LAND,
"creatures": CardSlot.CREATURE,
"instants": CardSlot.INSTANT,
"sorceries": CardSlot.SORCERY,
"artifacts": CardSlot.ARTIFACT,
"enchantments": CardSlot.ENCHANTMENT,
"planeswalkers": CardSlot.PLANESWALKER,
}
return mapping.get(normalised, CardSlot.CREATURE)
class CardEntry:
def __init__(self, raw: dict):
raw_name: str = raw.get("name", "").strip()
self.is_owned: bool = not bool(UNOWNED_RE.search(raw_name))
self.card_name: str = UNOWNED_RE.sub("", raw_name).strip()
self.slot: CardSlot = _parse_slot(raw.get("slot", "creature"))
self.quantity: int = max(1, int(raw.get("quantity", 1)))
self.reasoning: Optional[str] = raw.get("reasoning")
self.scryfall_id: str = ""
self.scryfall_data: dict = {}
class CutEntry:
def __init__(self, raw: dict):
self.card_name: str = raw.get("name", "").strip()
self.reasoning: Optional[str] = raw.get("reasoning")
class DeckPayload:
def __init__(self):
self.deck_name: str = ""
self.strategy_summary: str = ""
self.cards: list[CardEntry] = []
self.cuts: list[CutEntry] = []
self.unresolved: list[str] = []
def _build_payload(data: dict) -> DeckPayload:
payload = DeckPayload()
payload.deck_name = data.get("deck_name", "Untitled Deck")
payload.strategy_summary = data.get("strategy_summary", "")
for raw_card in data.get("cards", []):
if raw_card.get("name"):
payload.cards.append(CardEntry(raw_card))
for raw_cut in data.get("cuts", []):
if raw_cut.get("name"):
payload.cuts.append(CutEntry(raw_cut))
return payload
async def _enrich_with_scryfall(payload: DeckPayload) -> None:
names = [c.card_name for c in payload.cards]
name_map = await scryfall.batch_enrich_by_name(names)
for entry in payload.cards:
sf_data = name_map.get(entry.card_name.lower())
if sf_data:
entry.scryfall_id = sf_data["id"]
entry.scryfall_data = sf_data
else:
sf_data = await scryfall.get_card_by_name(entry.card_name, exact=False)
if sf_data:
entry.scryfall_id = sf_data["id"]
entry.scryfall_data = sf_data
entry.card_name = sf_data["name"]
else:
payload.unresolved.append(entry.card_name)