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
View File
+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)
+25
View File
@@ -0,0 +1,25 @@
from app.schemas.deck import DeckConstraints
def build_constraint_context(constraints: DeckConstraints, owned_names: list[str] | None) -> str:
lines = []
if constraints.prefer_owned and owned_names:
lines.append("- Prefer cards the user already owns (marked [OWNED] in the list below)")
lines.append("- Mark any recommended card the user does NOT own with [UNOWNED] suffix")
if constraints.budget_enabled and constraints.budget_amount:
scope = "total deck" if constraints.budget_scope == "total" else "cards to purchase"
lines.append(f"- Budget limit: ${constraints.budget_amount:.2f} for {scope}")
if not lines:
lines.append("- No special constraints — recommend the strongest cards available")
return "\n".join(lines)
def build_owned_card_list(owned_names: list[str]) -> str:
if not owned_names:
return ""
card_list = "\n".join(f"- {name}" for name in sorted(owned_names))
return f"\nOWNED CARDS:\n{card_list}\n"
+130
View File
@@ -0,0 +1,130 @@
"""
Deck service: orchestrates prompt building, Claude call, and DB persistence.
"""
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.models.collection import CollectionCard
from app.models.deck import Deck, DeckCard, DeckMode
from app.schemas.deck import DeckConstraints, GenerateRequest, CompleteRequest, CullRequest
from app.services.ai import prompts
from app.services.ai.claude_client import call_claude, DeckPayload
from app.services.ai.constraints import build_constraint_context, build_owned_card_list
async def generate_deck(req: GenerateRequest, user_id: int, db: AsyncSession) -> Deck:
owned_names = await _owned_names(user_id, db) if req.constraints.prefer_owned else []
system, user_msg = prompts.generate_prompt(
commander=req.commander,
playstyle=req.playstyle,
constraint_text=build_constraint_context(req.constraints, owned_names or None),
owned_list_text=build_owned_card_list(owned_names),
)
payload = await call_claude(system, user_msg)
return await _persist_deck(
payload=payload, user_id=user_id, mode=DeckMode.GENERATE,
commander=req.commander, name=req.name or payload.deck_name,
description=req.description or payload.strategy_summary,
playstyle=req.playstyle, constraints=req.constraints,
owned_name_set=set(n.lower() for n in owned_names), db=db,
)
async def complete_deck(req: CompleteRequest, user_id: int, db: AsyncSession) -> Deck:
owned_names = await _owned_names(user_id, db) if req.constraints.prefer_owned else []
system, user_msg = prompts.complete_prompt(
commander=req.commander, playstyle=req.playstyle,
existing_cards=req.existing_cards,
constraint_text=build_constraint_context(req.constraints, owned_names or None),
owned_list_text=build_owned_card_list(owned_names),
)
payload = await call_claude(system, user_msg)
return await _persist_deck(
payload=payload, user_id=user_id, mode=DeckMode.COMPLETE,
commander=req.commander, name=req.name or payload.deck_name,
description=payload.strategy_summary, playstyle=req.playstyle,
constraints=req.constraints,
owned_name_set=set(n.lower() for n in owned_names), db=db,
)
async def cull_deck(req: CullRequest, user_id: int, db: AsyncSession) -> Deck:
owned_names = await _owned_names(user_id, db) if req.constraints.prefer_owned else []
owned_set = set(n.lower() for n in owned_names)
for card in req.existing_cards:
card["is_owned"] = card.get("card_name", "").lower() in owned_set
system, user_msg = prompts.cull_prompt(
commander=req.commander, existing_cards=req.existing_cards,
target_count=req.target_count,
constraint_text=build_constraint_context(req.constraints, owned_names or None),
owned_list_text=build_owned_card_list(owned_names),
prefer_owned=req.constraints.prefer_owned,
)
payload = await call_claude(system, user_msg, max_tokens=10000)
return await _persist_deck(
payload=payload, user_id=user_id, mode=DeckMode.CULL,
commander=req.commander, name=req.name or payload.deck_name,
description=payload.strategy_summary, playstyle=None,
constraints=req.constraints, owned_name_set=owned_set, db=db,
)
async def _persist_deck(
payload: DeckPayload, user_id: int, mode: DeckMode,
commander: str, name: str, description: str | None,
playstyle: str | None, constraints: DeckConstraints,
owned_name_set: set[str], db: AsyncSession,
) -> Deck:
deck = Deck(
owner_id=user_id, name=name, commander=commander,
description=description, mode=mode, playstyle=playstyle,
prefer_owned=constraints.prefer_owned,
budget_enabled=constraints.budget_enabled,
budget_amount=constraints.budget_amount,
budget_scope=constraints.budget_scope,
ai_reasoning={
"strategy_summary": payload.strategy_summary,
"unresolved_cards": payload.unresolved,
"cuts": [{"name": c.card_name, "reasoning": c.reasoning} for c in payload.cuts],
},
)
db.add(deck)
await db.flush()
deck_cards = [
DeckCard(
deck_id=deck.id, scryfall_id="", card_name=commander,
slot="creature", quantity=1,
is_owned=commander.lower() in owned_name_set,
is_commander=True,
)
]
for entry in payload.cards:
if not entry.scryfall_id:
continue
is_owned = (
entry.card_name.lower() in owned_name_set
if constraints.prefer_owned
else entry.is_owned
)
deck_cards.append(DeckCard(
deck_id=deck.id, scryfall_id=entry.scryfall_id,
card_name=entry.card_name, slot=entry.slot,
quantity=entry.quantity, is_owned=is_owned,
is_commander=False, ai_reasoning=entry.reasoning,
scryfall_data=entry.scryfall_data,
))
db.add_all(deck_cards)
await db.commit()
await db.refresh(deck)
return deck
async def _owned_names(user_id: int, db: AsyncSession) -> list[str]:
result = await db.execute(
select(CollectionCard.card_name).where(CollectionCard.owner_id == user_id)
)
return [row[0] for row in result.all()]
+150
View File
@@ -0,0 +1,150 @@
"""
Prompt templates for the three deck modes.
Output contract — Claude returns a single JSON object:
{
"deck_name": "string",
"strategy_summary": "string",
"cards": [
{
"name": "Card Name", // append [UNOWNED] when prefer_owned=True
"slot": "creature|instant|sorcery|enchantment|artifact|planeswalker|land|battle",
"quantity": 1,
"reasoning": "1-2 sentences"
}
],
"cuts": [ // CULL mode only
{ "name": "Card Name", "reasoning": "..." }
]
}
"""
SYSTEM_PROMPT = (
"You are an expert Magic: The Gathering deck builder specialising in the Commander "
"(EDH) format. You have deep knowledge of card synergies, mana curves, colour "
"identity rules, staples, budget alternatives, and current metagame trends.\n\n"
"You always respond with a single valid JSON object — no markdown fences, no "
"preamble, no commentary outside the JSON. Your card names must exactly match "
"official Magic card names (English). Every card must be legal in Commander "
"and within the commander's colour identity."
)
def generate_prompt(
commander: str,
playstyle: str | None,
constraint_text: str,
owned_list_text: str,
) -> tuple[str, str]:
playstyle_line = (
f"Playstyle preference: {playstyle}"
if playstyle
else "Playstyle preference: not specified — choose the strongest strategy for this commander."
)
user_message = (
f"Build a complete Commander deck for the following commander.\n\n"
f"COMMANDER: {commander}\n"
f"{playstyle_line}\n\n"
f"CONSTRAINTS:\n{constraint_text}\n"
f"{owned_list_text}\n"
f"DECK REQUIREMENTS:\n"
f"- Exactly 99 cards (not counting the commander)\n"
f"- All cards must be legal in Commander and within {commander}'s colour identity\n"
f"- Include a balanced mana base (35-40 lands for most strategies)\n"
f"- Include ramp (8-12 pieces), card draw (8-10 pieces), removal (8-10 pieces), "
f"and win conditions appropriate to the playstyle\n"
f"- Slot values: creature, instant, sorcery, enchantment, artifact, planeswalker, land, battle\n"
f"- Quantity for basic lands may be >1; all other cards quantity = 1\n"
f"- The 'reasoning' field must explain why the card fits THIS specific deck\n\n"
f"Respond with the JSON object only."
)
return SYSTEM_PROMPT, user_message
def complete_prompt(
commander: str,
playstyle: str | None,
existing_cards: list[dict],
constraint_text: str,
owned_list_text: str,
) -> tuple[str, str]:
existing_count = sum(c.get("quantity", 1) for c in existing_cards)
slots_needed = 99 - existing_count
playstyle_line = (
f"Playstyle preference: {playstyle}"
if playstyle
else "Playstyle preference: infer from existing cards."
)
existing_formatted = "\n".join(
f"- {c['card_name']} ({c.get('slot', 'unknown')})"
+ (f" x{c['quantity']}" if c.get('quantity', 1) > 1 else "")
for c in existing_cards
)
user_message = (
f"The user has a partial Commander deck and needs suggestions to complete it.\n\n"
f"COMMANDER: {commander}\n"
f"{playstyle_line}\n"
f"SLOTS NEEDED: {slots_needed} more cards to reach 99\n\n"
f"EXISTING CARDS ({existing_count} cards):\n{existing_formatted}\n\n"
f"CONSTRAINTS:\n{constraint_text}\n"
f"{owned_list_text}\n"
f"INSTRUCTIONS:\n"
f"- Suggest exactly {slots_needed} new cards to fill remaining slots\n"
f"- Do not repeat any card already in the existing list\n"
f"- All cards must be legal in Commander and within {commander}'s colour identity\n"
f"- Analyse existing cards to infer strategy and fill gaps (ramp, draw, removal, win-cons)\n"
f"- The 'cards' array contains ONLY the new cards you are recommending\n"
f"- strategy_summary should describe how the completed deck plays\n\n"
f"Respond with the JSON object only."
)
return SYSTEM_PROMPT, user_message
def cull_prompt(
commander: str,
existing_cards: list[dict],
target_count: int,
constraint_text: str,
owned_list_text: str,
prefer_owned: bool,
) -> tuple[str, str]:
current_count = sum(c.get("quantity", 1) for c in existing_cards)
cuts_needed = current_count - target_count
existing_formatted = "\n".join(
f"- {c['card_name']} ({c.get('slot', 'unknown')})"
+ (" [OWNED]" if c.get("is_owned") else "")
for c in existing_cards
)
ownership_note = (
"\n- IMPORTANT: Prioritise cutting cards NOT marked [OWNED] first — "
"this saves the user money on cards they would have to buy."
if prefer_owned
else ""
)
user_message = (
f"The user's Commander deck is oversized and needs to be culled.\n\n"
f"COMMANDER: {commander}\n"
f"CURRENT SIZE: {current_count} cards\n"
f"TARGET SIZE: {target_count} cards\n"
f"CUTS NEEDED: {cuts_needed} cards\n\n"
f"CURRENT DECKLIST:\n{existing_formatted}\n\n"
f"CONSTRAINTS:\n{constraint_text}\n"
f"{owned_list_text}\n"
f"INSTRUCTIONS:\n"
f"- Recommend exactly {cuts_needed} cards to cut\n"
f"- Identify redundancy, weak synergy, overcosted cards, and cards that "
f"don't advance the primary strategy\n"
f"- Order the 'cuts' array from most-recommended to least-recommended cut{ownership_note}\n"
f"- The 'cards' array contains all {target_count} REMAINING cards after cuts\n"
f"- Provide specific reasoning for each cut explaining why it's weaker than what stays\n"
f"- strategy_summary describes the refined deck after cuts\n\n"
f"Respond with the JSON object only."
)
return SYSTEM_PROMPT, user_message