Restructure into full project layout
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user