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)
|
||||
@@ -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"
|
||||
@@ -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()]
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user