Upload files
Browse filesInitial upload of five project files.
- Dockerfile +28 -0
- app.py +568 -0
- cardnames.txt +0 -0
- mtg_schemas.py +82 -0
- requirements.txt +5 -0
Dockerfile
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.10-slim
|
| 2 |
+
|
| 3 |
+
# Prevents Python from writing .pyc files
|
| 4 |
+
ENV PYTHONDONTWRITEBYTECODE=1
|
| 5 |
+
ENV PYTHONUNBUFFERED=1
|
| 6 |
+
|
| 7 |
+
# Working directory
|
| 8 |
+
WORKDIR /app
|
| 9 |
+
|
| 10 |
+
# System deps (Pillow needs these)
|
| 11 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 12 |
+
build-essential \
|
| 13 |
+
libjpeg-dev \
|
| 14 |
+
zlib1g-dev \
|
| 15 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 16 |
+
|
| 17 |
+
# Install Python dependencies
|
| 18 |
+
COPY requirements.txt .
|
| 19 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 20 |
+
|
| 21 |
+
# Copy app code
|
| 22 |
+
COPY . .
|
| 23 |
+
|
| 24 |
+
# Expose the port Streamlit will run on
|
| 25 |
+
EXPOSE 7860
|
| 26 |
+
|
| 27 |
+
# Streamlit entrypoint
|
| 28 |
+
CMD ["streamlit", "run", "app.py", "--server.port=7860", "--server.address=0.0.0.0"]
|
app.py
ADDED
|
@@ -0,0 +1,568 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
# coding: utf-8
|
| 3 |
+
|
| 4 |
+
import os
|
| 5 |
+
import json
|
| 6 |
+
import re
|
| 7 |
+
from typing import List
|
| 8 |
+
|
| 9 |
+
import streamlit as st
|
| 10 |
+
from dotenv import load_dotenv
|
| 11 |
+
from openai import OpenAI
|
| 12 |
+
from pydantic import ValidationError
|
| 13 |
+
|
| 14 |
+
# Pydantic schemas (your "original" structured outputs)
|
| 15 |
+
from mtg_schemas import MTGCard, MTGCardList, YesNoName, YesNoNameList, MTGNameOnly
|
| 16 |
+
|
| 17 |
+
# ============================================================================
|
| 18 |
+
# ENV + CLIENT SETUP
|
| 19 |
+
# ============================================================================
|
| 20 |
+
|
| 21 |
+
load_dotenv(override=True)
|
| 22 |
+
|
| 23 |
+
openai_api_key = os.getenv("OPENAI_API_KEY")
|
| 24 |
+
if not openai_api_key:
|
| 25 |
+
raise RuntimeWarning("No usable OpenAI API key found in environment.")
|
| 26 |
+
openai_client = OpenAI(api_key=openai_api_key)
|
| 27 |
+
|
| 28 |
+
openrouter_api_key = os.getenv("OPENROUTER_API_KEY")
|
| 29 |
+
if not openrouter_api_key:
|
| 30 |
+
print("⚠ No usable OpenRouter API key found in environment. OpenRouter models will be disabled.")
|
| 31 |
+
|
| 32 |
+
openrouter_url = "https://openrouter.ai/api/v1"
|
| 33 |
+
|
| 34 |
+
clients = {
|
| 35 |
+
"openai": openai_client,
|
| 36 |
+
"openrouter": OpenAI(api_key=openrouter_api_key, base_url=openrouter_url) if openrouter_api_key else None,
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
# Model mappings
|
| 40 |
+
card_models = {
|
| 41 |
+
"gpt-4.1-nano-2025-04-14": "openai",
|
| 42 |
+
"gpt-4o-mini": "openai",
|
| 43 |
+
"x-ai/grok-4-fast": "openrouter",
|
| 44 |
+
"deepseek/deepseek-chat-v3.1": "openrouter",
|
| 45 |
+
"meta-llama/llama-3.2-3b-instruct": "openrouter",
|
| 46 |
+
"qwen/qwen3-vl-30b-a3b-instruct": "openrouter"
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
extract_models = card_models.copy()
|
| 50 |
+
|
| 51 |
+
# Filter out OpenRouter models if API key is not available
|
| 52 |
+
if not openrouter_api_key:
|
| 53 |
+
card_models_available = {k: v for k, v in card_models.items() if v != "openrouter"}
|
| 54 |
+
extract_models_available = {k: v for k, v in extract_models.items() if v != "openrouter"}
|
| 55 |
+
else:
|
| 56 |
+
card_models_available = card_models
|
| 57 |
+
extract_models_available = extract_models
|
| 58 |
+
|
| 59 |
+
# Card generation limits
|
| 60 |
+
MAX_NUM_CARDS = 5
|
| 61 |
+
MIN_NUM_CARDS = 2
|
| 62 |
+
|
| 63 |
+
system_prompt = (
|
| 64 |
+
f"""You are a creative and imaginative designer of cards for the collectible/trading card game
|
| 65 |
+
Magic: The Gathering. Respond only with a single JSON object that matches the schema.
|
| 66 |
+
If the card has a non-null mana cost, try to match the mana cost with the potency of the card.
|
| 67 |
+
I.e., creatures with high Power and/or Toughness should tend to cost more; and instants that
|
| 68 |
+
cause more damage should tend to cost more. Keep in mind that Lands typically do not cost mana.
|
| 69 |
+
Most (82%) MTG cards have a NaN (missing) Supertype value; the most common non-missing Supertype value is 'Legendary',
|
| 70 |
+
accounting for 14% of all cards. It is OK to generate a card with a missing/None Supertype value!
|
| 71 |
+
In fact, if the card is a common and/or low-powered creature or artifact, or if it isn't a creature or artifact to begin with,
|
| 72 |
+
it might be best to just have Supertype with a value of None (missing).
|
| 73 |
+
The top six most common Type values are (in decreasing order): Creature, Land, Instant, Sorcery, Enchantment, and Artifact.
|
| 74 |
+
Creatures are the most common Type value, accounting for about 44% of all cards.
|
| 75 |
+
Land cards are the next most common Type.
|
| 76 |
+
A large proportion of (38%) cards have a missing Subtype.
|
| 77 |
+
|
| 78 |
+
IMPORTANT: When generating cards, you must generate between {MIN_NUM_CARDS} and {MAX_NUM_CARDS} cards (inclusive) that have interesting, synergistic, and mutually reinforcing interactions.
|
| 79 |
+
The minimum is {MIN_NUM_CARDS} cards because we are interested in interactions between cards.
|
| 80 |
+
The maximum is {MAX_NUM_CARDS} cards. If the user requests more than {MAX_NUM_CARDS} cards or fewer than {MIN_NUM_CARDS} cards,
|
| 81 |
+
or uses vague terms like "many" or "several", generate exactly {MAX_NUM_CARDS} cards if they ask for more, or {MIN_NUM_CARDS} cards if they ask for fewer or don't specify a number.
|
| 82 |
+
|
| 83 |
+
CRITICAL: The cards you generate MUST have interesting, synergistic, and mutually reinforcing interactions. They should work together in meaningful ways, not just be individually interesting cards. The explanation field must describe these interactions clearly. Do not generate a set of cards that are merely individually interesting without interactions between them.
|
| 84 |
+
|
| 85 |
+
EXPLANATION REQUIREMENTS: The explanation field must:
|
| 86 |
+
1. Mention each card in the MTGCardList at least once by its exact name.
|
| 87 |
+
2. Only mention card names that actually exist in the cards array of the MTGCardList. Do not reference card names that are not in the generated set.
|
| 88 |
+
3. Clearly describe how the cards interact with each other, using their exact names when referring to them."""
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
# ============================================================================
|
| 92 |
+
# CARD NAME DATABASE
|
| 93 |
+
# ============================================================================
|
| 94 |
+
|
| 95 |
+
try:
|
| 96 |
+
CARD_NAMES_FILE = "cardnames.txt"
|
| 97 |
+
with open(CARD_NAMES_FILE, "r", encoding="utf-8", errors="replace") as f:
|
| 98 |
+
card_names = set(f.read().splitlines())
|
| 99 |
+
print(f"✓ Loaded {len(card_names)} existing card names")
|
| 100 |
+
except FileNotFoundError:
|
| 101 |
+
print("⚠ Card names file not found, starting with empty set")
|
| 102 |
+
card_names = set()
|
| 103 |
+
|
| 104 |
+
# ============================================================================
|
| 105 |
+
# HELPER FUNCTIONS (ported from mtg_gradio_v9, adapted to Streamlit)
|
| 106 |
+
# ============================================================================
|
| 107 |
+
|
| 108 |
+
def get_client(model_name: str, model_dict: dict) -> OpenAI:
|
| 109 |
+
"""Get the appropriate client for a given model."""
|
| 110 |
+
provider = model_dict.get(model_name)
|
| 111 |
+
if provider is None:
|
| 112 |
+
raise ValueError(f"Unknown model: {model_name}")
|
| 113 |
+
client = clients.get(provider)
|
| 114 |
+
if client is None:
|
| 115 |
+
raise ValueError(
|
| 116 |
+
f"Client not configured for provider: {provider}. "
|
| 117 |
+
"Check that the corresponding API key is set."
|
| 118 |
+
)
|
| 119 |
+
return client
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def ExtractCardCount(txt: str, extract_model: str) -> int:
|
| 123 |
+
"""Extract the number of cards requested from user text. Returns 0 if not specified."""
|
| 124 |
+
# First, check for implicit count via named cards
|
| 125 |
+
named_cards = ExtractNameIfAny(txt, extract_model)
|
| 126 |
+
if named_cards:
|
| 127 |
+
return len(named_cards)
|
| 128 |
+
|
| 129 |
+
# Check for singular forms (implicitly requesting 1 card)
|
| 130 |
+
txt_lower = txt.lower()
|
| 131 |
+
# Patterns that indicate singular (1 card): "a card", "an MTG card", "one card", "a new card", etc.
|
| 132 |
+
# Using word boundaries and allowing for punctuation at the end
|
| 133 |
+
singular_patterns = [
|
| 134 |
+
r'\ba\s+new\s+mtg\s+card\b', # "a new MTG card"
|
| 135 |
+
r'\ba\s+new\s+card\b', # "a new card"
|
| 136 |
+
r'\ba\s+mtg\s+card\b', # "a MTG card"
|
| 137 |
+
r'\ba\s+card\b', # "a card"
|
| 138 |
+
r'\ban\s+new\s+mtg\s+card\b', # "an new MTG card"
|
| 139 |
+
r'\ban\s+new\s+card\b', # "an new card"
|
| 140 |
+
r'\ban\s+mtg\s+card\b', # "an MTG card"
|
| 141 |
+
r'\ban\s+card\b', # "an card"
|
| 142 |
+
r'\bone\s+new\s+mtg\s+card\b', # "one new MTG card"
|
| 143 |
+
r'\bone\s+new\s+card\b', # "one new card"
|
| 144 |
+
r'\bone\s+mtg\s+card\b', # "one MTG card"
|
| 145 |
+
r'\bone\s+card\b', # "one card"
|
| 146 |
+
r'\b(?:generate|create|make|please\s+generate|please\s+create|please\s+make)\s+(?:a|an|one)\s+(?:new\s+)?(?:mtg\s+)?card\b', # "generate a new MTG card", etc.
|
| 147 |
+
]
|
| 148 |
+
for pattern in singular_patterns:
|
| 149 |
+
if re.search(pattern, txt_lower):
|
| 150 |
+
return 1
|
| 151 |
+
|
| 152 |
+
# Then check for explicit number in the text
|
| 153 |
+
client = get_client(extract_model, extract_models)
|
| 154 |
+
|
| 155 |
+
msg = f"""Here is some text.
|
| 156 |
+
<TEXT>
|
| 157 |
+
{txt}
|
| 158 |
+
</TEXT>
|
| 159 |
+
Extract the number of cards requested in this text. For example:
|
| 160 |
+
- "generate five cards" → 5
|
| 161 |
+
- "create 3 MTG cards" → 3
|
| 162 |
+
- "generate two cards" → 2
|
| 163 |
+
- "create a card" → 1
|
| 164 |
+
- "generate cards" (no number specified) → 0
|
| 165 |
+
|
| 166 |
+
Respond with ONLY the number (0 if not specified), nothing else.
|
| 167 |
+
"""
|
| 168 |
+
|
| 169 |
+
messages = [
|
| 170 |
+
{"role": "system", "content": system_prompt},
|
| 171 |
+
{"role": "user", "content": msg},
|
| 172 |
+
]
|
| 173 |
+
|
| 174 |
+
try:
|
| 175 |
+
# Use a simple completion to extract the number
|
| 176 |
+
completion = client.chat.completions.create(
|
| 177 |
+
model=extract_model,
|
| 178 |
+
messages=messages,
|
| 179 |
+
temperature=0.2,
|
| 180 |
+
max_tokens=10,
|
| 181 |
+
)
|
| 182 |
+
|
| 183 |
+
response = completion.choices[0].message.content.strip()
|
| 184 |
+
# Try to extract number from response
|
| 185 |
+
numbers = re.findall(r'\d+', response)
|
| 186 |
+
if numbers:
|
| 187 |
+
return int(numbers[0])
|
| 188 |
+
# Check for word numbers
|
| 189 |
+
word_to_num = {
|
| 190 |
+
'one': 1, 'two': 2, 'three': 3, 'four': 4, 'five': 5,
|
| 191 |
+
'six': 6, 'seven': 7, 'eight': 8, 'nine': 9, 'ten': 10
|
| 192 |
+
}
|
| 193 |
+
response_lower = response.lower()
|
| 194 |
+
for word, num in word_to_num.items():
|
| 195 |
+
if word in response_lower:
|
| 196 |
+
return num
|
| 197 |
+
return 0
|
| 198 |
+
except Exception as e:
|
| 199 |
+
print(f"Warning: Card count extraction failed: {e}")
|
| 200 |
+
return 0
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
def ExtractNameIfAny(txt: str, extract_model: str) -> List[str]:
|
| 204 |
+
"""Extract all card names from user text if specified. Returns a list of card names, or empty list if none found."""
|
| 205 |
+
client = get_client(extract_model, extract_models)
|
| 206 |
+
|
| 207 |
+
msg = f"""Here is some text.
|
| 208 |
+
<TEXT>
|
| 209 |
+
{txt}
|
| 210 |
+
</TEXT>
|
| 211 |
+
If the text includes a request to specify the name(s) of one or more items (e.g., cards), extract ALL the specified names.
|
| 212 |
+
For example, if the text says "create cards named 'Test' and 'Example'", extract both 'Test' and 'Example'.
|
| 213 |
+
If the text says "create a card named 'Test'", extract 'Test'.
|
| 214 |
+
If no specific names are requested, return an empty list.
|
| 215 |
+
"""
|
| 216 |
+
|
| 217 |
+
messages = [
|
| 218 |
+
{"role": "system", "content": system_prompt},
|
| 219 |
+
{"role": "user", "content": msg},
|
| 220 |
+
]
|
| 221 |
+
|
| 222 |
+
try:
|
| 223 |
+
completion = client.beta.chat.completions.parse(
|
| 224 |
+
model=extract_model,
|
| 225 |
+
messages=messages,
|
| 226 |
+
response_format=YesNoNameList,
|
| 227 |
+
temperature=0.2,
|
| 228 |
+
)
|
| 229 |
+
|
| 230 |
+
parsed = completion.choices[0].message.parsed
|
| 231 |
+
# Extract all names from the list where YesNo == "Yes"
|
| 232 |
+
card_names_list = [
|
| 233 |
+
item.Name for item in parsed.items
|
| 234 |
+
if item.YesNo == "Yes" and item.Name and item.Name.strip()
|
| 235 |
+
]
|
| 236 |
+
return card_names_list
|
| 237 |
+
except Exception as e:
|
| 238 |
+
print(f"Warning: Name extraction failed: {e}")
|
| 239 |
+
return []
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
def generate_unique_name_for_card(parsed_card, used_names, extract_model):
|
| 243 |
+
"""
|
| 244 |
+
Ask the LLM to generate a new, unique card name,
|
| 245 |
+
consistent with the card's other attributes.
|
| 246 |
+
"""
|
| 247 |
+
|
| 248 |
+
client = get_client(extract_model, extract_models)
|
| 249 |
+
|
| 250 |
+
card_info = json.dumps(parsed_card.model_dump(), indent=2)
|
| 251 |
+
|
| 252 |
+
prompt = f"""
|
| 253 |
+
You must generate a NEW, UNIQUE name for this Magic: The Gathering card.
|
| 254 |
+
|
| 255 |
+
Here are all of the card's attributes except the name:
|
| 256 |
+
<card>
|
| 257 |
+
{card_info}
|
| 258 |
+
</card>
|
| 259 |
+
|
| 260 |
+
Requirements:
|
| 261 |
+
- Do NOT reuse any name in the following list:
|
| 262 |
+
{list(used_names)}
|
| 263 |
+
- The new name MUST NOT match any existing card name.
|
| 264 |
+
- The new name MUST match the style, color identity, type, subtype, flavor,
|
| 265 |
+
and general theme of the provided card.
|
| 266 |
+
- Respond ONLY with a single JSON object containing the field "Name".
|
| 267 |
+
"""
|
| 268 |
+
|
| 269 |
+
completion = client.beta.chat.completions.parse(
|
| 270 |
+
model=extract_model,
|
| 271 |
+
messages=[{"role": "user", "content": prompt}],
|
| 272 |
+
response_format=MTGNameOnly,
|
| 273 |
+
temperature=0.4, # low temperature is best for names
|
| 274 |
+
)
|
| 275 |
+
|
| 276 |
+
return completion.choices[0].message.parsed.Name
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
def CreateCard(msg: str, card_model: str, extract_model: str, temp: float):
|
| 280 |
+
"""Main function to create MTG cards (can be multiple)."""
|
| 281 |
+
messages = [
|
| 282 |
+
{"role": "system", "content": system_prompt},
|
| 283 |
+
{"role": "user", "content": msg},
|
| 284 |
+
]
|
| 285 |
+
|
| 286 |
+
# Check if any requested names already exist
|
| 287 |
+
requested_names = ExtractNameIfAny(msg, extract_model)
|
| 288 |
+
if requested_names:
|
| 289 |
+
duplicate_names = [name for name in requested_names if name in card_names]
|
| 290 |
+
if duplicate_names:
|
| 291 |
+
names_str = ", ".join([f"'{name}'" for name in duplicate_names])
|
| 292 |
+
return (
|
| 293 |
+
f"❌ Sorry, the following name(s) have already been used: {names_str}. "
|
| 294 |
+
"Please select other names or leave names unspecified.",
|
| 295 |
+
""
|
| 296 |
+
)
|
| 297 |
+
|
| 298 |
+
# Try to create cards (with retries for duplicate names)
|
| 299 |
+
max_card_attempts = 5 # regenerate cards up to 5 times
|
| 300 |
+
for attempt in range(max_card_attempts):
|
| 301 |
+
try:
|
| 302 |
+
client = get_client(card_model, card_models)
|
| 303 |
+
|
| 304 |
+
completion = client.beta.chat.completions.parse(
|
| 305 |
+
model=card_model,
|
| 306 |
+
messages=messages,
|
| 307 |
+
response_format=MTGCardList,
|
| 308 |
+
temperature=temp,
|
| 309 |
+
)
|
| 310 |
+
|
| 311 |
+
parsed_list: MTGCardList = completion.choices[0].message.parsed
|
| 312 |
+
cards = parsed_list.cards
|
| 313 |
+
explanation = parsed_list.explanation
|
| 314 |
+
|
| 315 |
+
# Track names in the current batch to ensure uniqueness within the batch
|
| 316 |
+
batch_names = set()
|
| 317 |
+
|
| 318 |
+
# Check if any generated card names are duplicates and regenerate them
|
| 319 |
+
for i, card in enumerate(cards):
|
| 320 |
+
# Check if name is duplicate in existing card database
|
| 321 |
+
if card.Name in card_names:
|
| 322 |
+
try:
|
| 323 |
+
# Combine existing names and current batch names to avoid duplicates
|
| 324 |
+
all_used_names = card_names | batch_names
|
| 325 |
+
new_name = generate_unique_name_for_card(
|
| 326 |
+
parsed_card=card,
|
| 327 |
+
used_names=all_used_names,
|
| 328 |
+
extract_model=extract_model,
|
| 329 |
+
)
|
| 330 |
+
# Verify the regenerated name is actually unique
|
| 331 |
+
if new_name in card_names:
|
| 332 |
+
print(f"⚠ Regenerated name '{new_name}' is still a duplicate in card_names")
|
| 333 |
+
raise ValueError("Regenerated name is still a duplicate")
|
| 334 |
+
if new_name in batch_names:
|
| 335 |
+
print(f"⚠ Regenerated name '{new_name}' conflicts with another card in this batch")
|
| 336 |
+
raise ValueError("Regenerated name conflicts with batch")
|
| 337 |
+
card.Name = new_name
|
| 338 |
+
except Exception as e:
|
| 339 |
+
print(f"⚠ Failed to generate replacement name: {e}")
|
| 340 |
+
# Continue to next attempt
|
| 341 |
+
raise ValueError("Failed to generate unique name")
|
| 342 |
+
|
| 343 |
+
# Check if name is duplicate within the current batch
|
| 344 |
+
if card.Name in batch_names:
|
| 345 |
+
try:
|
| 346 |
+
# Combine existing names and current batch names to avoid duplicates
|
| 347 |
+
all_used_names = card_names | batch_names
|
| 348 |
+
new_name = generate_unique_name_for_card(
|
| 349 |
+
parsed_card=card,
|
| 350 |
+
used_names=all_used_names,
|
| 351 |
+
extract_model=extract_model,
|
| 352 |
+
)
|
| 353 |
+
# Verify the regenerated name is actually unique
|
| 354 |
+
if new_name in card_names:
|
| 355 |
+
print(f"⚠ Regenerated name '{new_name}' is still a duplicate in card_names")
|
| 356 |
+
raise ValueError("Regenerated name is still a duplicate")
|
| 357 |
+
if new_name in batch_names:
|
| 358 |
+
print(f"⚠ Regenerated name '{new_name}' conflicts with another card in this batch")
|
| 359 |
+
raise ValueError("Regenerated name conflicts with batch")
|
| 360 |
+
card.Name = new_name
|
| 361 |
+
except Exception as e:
|
| 362 |
+
print(f"⚠ Failed to generate replacement name for batch duplicate: {e}")
|
| 363 |
+
# Continue to next attempt
|
| 364 |
+
raise ValueError("Failed to generate unique name")
|
| 365 |
+
|
| 366 |
+
# Add the (now unique) name to batch tracking
|
| 367 |
+
batch_names.add(card.Name)
|
| 368 |
+
|
| 369 |
+
# Success - format all cards (without explanation)
|
| 370 |
+
formatted_cards = []
|
| 371 |
+
for i, card in enumerate(cards):
|
| 372 |
+
card_json = json.dumps(card.model_dump(), indent=4, ensure_ascii=False)
|
| 373 |
+
pretty_text = format_card_info(card_json)
|
| 374 |
+
# Add horizontal rule before each card except the first
|
| 375 |
+
separator = "\n---\n" if i > 0 else ""
|
| 376 |
+
formatted_cards.append(f"{separator}GENERATED CARD #{i+1}:\n\n{pretty_text}\n")
|
| 377 |
+
|
| 378 |
+
cards_text = "\n".join(formatted_cards)
|
| 379 |
+
|
| 380 |
+
# Return cards text and explanation separately
|
| 381 |
+
return cards_text, explanation
|
| 382 |
+
|
| 383 |
+
except ValidationError as ve:
|
| 384 |
+
return f"❌ Validation Error: {ve}", ""
|
| 385 |
+
|
| 386 |
+
except Exception as e:
|
| 387 |
+
print(f"❌ Unexpected error while generating cards: {e}")
|
| 388 |
+
continue
|
| 389 |
+
|
| 390 |
+
return "❌ Failed to generate safe cards after several attempts.", ""
|
| 391 |
+
|
| 392 |
+
# pretty-printing function
|
| 393 |
+
def format_card_info(raw_json: str) -> str:
|
| 394 |
+
"""
|
| 395 |
+
Transform the raw JSON dump into a nicer human-readable block:
|
| 396 |
+
- Remove quotes and commas
|
| 397 |
+
- Rename keys (OriginalText → Original Text, etc.)
|
| 398 |
+
- Flatten Colors list into comma-separated string
|
| 399 |
+
- Convert None → None
|
| 400 |
+
"""
|
| 401 |
+
try:
|
| 402 |
+
data = json.loads(raw_json)
|
| 403 |
+
except Exception:
|
| 404 |
+
return raw_json # fallback
|
| 405 |
+
|
| 406 |
+
# Key renaming map
|
| 407 |
+
rename = {
|
| 408 |
+
"OriginalText": "Original Text",
|
| 409 |
+
"FlavorText": "Flavor Text",
|
| 410 |
+
"ManaCost": "Mana Cost",
|
| 411 |
+
}
|
| 412 |
+
|
| 413 |
+
# Build formatted lines
|
| 414 |
+
lines = []
|
| 415 |
+
|
| 416 |
+
for key, value in data.items():
|
| 417 |
+
# Rename key if applicable
|
| 418 |
+
pretty_key = rename.get(key, key)
|
| 419 |
+
|
| 420 |
+
# Process Colors list
|
| 421 |
+
if key == "Colors":
|
| 422 |
+
if isinstance(value, list):
|
| 423 |
+
value_str = ", ".join(value)
|
| 424 |
+
else:
|
| 425 |
+
value_str = "None"
|
| 426 |
+
# Process None
|
| 427 |
+
elif value is None:
|
| 428 |
+
value_str = "None"
|
| 429 |
+
else:
|
| 430 |
+
value_str = str(value)
|
| 431 |
+
|
| 432 |
+
# Remove quotes from value_str (clean but safe)
|
| 433 |
+
value_str = value_str.replace('"', "")
|
| 434 |
+
|
| 435 |
+
# Format each field on its own line with HTML line break for single spacing
|
| 436 |
+
lines.append(f"{pretty_key}: {value_str}")
|
| 437 |
+
|
| 438 |
+
# Join with HTML line breaks to ensure single line spacing in markdown
|
| 439 |
+
return "<br>".join(lines)
|
| 440 |
+
|
| 441 |
+
|
| 442 |
+
# ============================================================================
|
| 443 |
+
# STREAMLIT UI
|
| 444 |
+
# ============================================================================
|
| 445 |
+
|
| 446 |
+
st.set_page_config(page_title="Generation of MTG Cards With Interesting Interactions", layout="wide")
|
| 447 |
+
|
| 448 |
+
st.title("🎴 Generation of MTG Cards With Interesting Interactions")
|
| 449 |
+
st.markdown(
|
| 450 |
+
"Generate a set of custom MTG cards with interesting, synergistic, and mutually reinforcing interactions."
|
| 451 |
+
)
|
| 452 |
+
|
| 453 |
+
# Initialize session state for card info and explanation
|
| 454 |
+
if "card_info" not in st.session_state:
|
| 455 |
+
st.session_state["card_info"] = ""
|
| 456 |
+
if "card_explanation" not in st.session_state:
|
| 457 |
+
st.session_state["card_explanation"] = ""
|
| 458 |
+
|
| 459 |
+
# Main layout with two columns
|
| 460 |
+
col_left, col_right = st.columns([1, 1])
|
| 461 |
+
|
| 462 |
+
with col_left:
|
| 463 |
+
st.markdown("#### User Prompt")
|
| 464 |
+
user_prompt = st.text_area(
|
| 465 |
+
"Card Description",
|
| 466 |
+
value="Please generate two new MTG cards.",
|
| 467 |
+
height=120,
|
| 468 |
+
)
|
| 469 |
+
|
| 470 |
+
generate_btn = st.button("Generate Cards", type="primary", use_container_width=True)
|
| 471 |
+
|
| 472 |
+
# Error message placeholder
|
| 473 |
+
error_placeholder = st.empty()
|
| 474 |
+
|
| 475 |
+
# Settings section (vertically)
|
| 476 |
+
st.markdown("---")
|
| 477 |
+
st.markdown("#### ⚙️ Settings")
|
| 478 |
+
card_model_choice = st.selectbox(
|
| 479 |
+
"Card Generation Model",
|
| 480 |
+
options=list(card_models_available.keys()),
|
| 481 |
+
index=list(card_models_available.keys()).index("gpt-4o-mini") if "gpt-4o-mini" in card_models_available else 0,
|
| 482 |
+
)
|
| 483 |
+
extract_model_choice = st.selectbox(
|
| 484 |
+
"Name Extraction Model",
|
| 485 |
+
options=list(extract_models_available.keys()),
|
| 486 |
+
index=list(extract_models_available.keys()).index("gpt-4.1-nano-2025-04-14") if "gpt-4.1-nano-2025-04-14" in extract_models_available else 0,
|
| 487 |
+
)
|
| 488 |
+
temperature = st.slider("Temperature", min_value=0.0, max_value=2.0, value=0.8, step=0.1)
|
| 489 |
+
|
| 490 |
+
with col_right:
|
| 491 |
+
# Explanation widget at the top
|
| 492 |
+
st.markdown("#### Explanation")
|
| 493 |
+
if st.session_state["card_explanation"]:
|
| 494 |
+
st.markdown(st.session_state["card_explanation"])
|
| 495 |
+
else:
|
| 496 |
+
st.info("Explanation of card interactions will appear here after generation.")
|
| 497 |
+
|
| 498 |
+
st.markdown("---")
|
| 499 |
+
|
| 500 |
+
# Card Information below
|
| 501 |
+
st.markdown("#### Card Information")
|
| 502 |
+
if st.session_state["card_info"]:
|
| 503 |
+
raw = st.session_state["card_info"]
|
| 504 |
+
|
| 505 |
+
# Check if text is already formatted (starts with card separator or horizontal rule)
|
| 506 |
+
if raw.startswith("########################") or raw.startswith("---"):
|
| 507 |
+
# Replace hash marks with horizontal rules if present
|
| 508 |
+
pretty = raw.replace("########################", "---")
|
| 509 |
+
else:
|
| 510 |
+
# Remove optional prefix like "✓ Generated Card:"
|
| 511 |
+
if raw.startswith("✓ Generated Card:"):
|
| 512 |
+
# Split on the first '{'
|
| 513 |
+
_, json_part = raw.split("{", 1)
|
| 514 |
+
raw_json = "{" + json_part.strip()
|
| 515 |
+
else:
|
| 516 |
+
raw_json = raw
|
| 517 |
+
|
| 518 |
+
pretty = format_card_info(raw_json)
|
| 519 |
+
|
| 520 |
+
st.markdown(pretty, unsafe_allow_html=True)
|
| 521 |
+
else:
|
| 522 |
+
st.info("Card details will appear here after generation.")
|
| 523 |
+
|
| 524 |
+
# On submit
|
| 525 |
+
if generate_btn:
|
| 526 |
+
if not user_prompt.strip():
|
| 527 |
+
error_placeholder.warning("Please enter a description for the cards.")
|
| 528 |
+
else:
|
| 529 |
+
# Check the number of cards requested
|
| 530 |
+
try:
|
| 531 |
+
requested_count = ExtractCardCount(user_prompt.strip(), extract_model_choice)
|
| 532 |
+
if requested_count > MAX_NUM_CARDS:
|
| 533 |
+
error_placeholder.error(
|
| 534 |
+
f"❌ At most {MAX_NUM_CARDS} cards can be generated at any one time. "
|
| 535 |
+
f"Your request asks for {requested_count} cards. Please reduce the number and try again."
|
| 536 |
+
)
|
| 537 |
+
elif requested_count == 1:
|
| 538 |
+
error_placeholder.error(
|
| 539 |
+
f"❌ A minimum of {MIN_NUM_CARDS} cards must be requested, since cards with an interesting interaction between them will be generated. "
|
| 540 |
+
f"Your request asks for 1 card. Please request at least {MIN_NUM_CARDS} cards."
|
| 541 |
+
)
|
| 542 |
+
elif requested_count > 0 and requested_count < MIN_NUM_CARDS:
|
| 543 |
+
error_placeholder.error(
|
| 544 |
+
f"❌ A minimum of {MIN_NUM_CARDS} cards must be requested, since cards with an interesting interaction between them will be generated. "
|
| 545 |
+
f"Your request asks for {requested_count} cards. Please request at least {MIN_NUM_CARDS} cards."
|
| 546 |
+
)
|
| 547 |
+
else:
|
| 548 |
+
try:
|
| 549 |
+
with st.spinner("Generating cards..."):
|
| 550 |
+
card_info, explanation = CreateCard(
|
| 551 |
+
msg=user_prompt.strip(),
|
| 552 |
+
card_model=card_model_choice,
|
| 553 |
+
extract_model=extract_model_choice,
|
| 554 |
+
temp=temperature,
|
| 555 |
+
)
|
| 556 |
+
|
| 557 |
+
if card_info.startswith("❌"):
|
| 558 |
+
error_placeholder.error(card_info)
|
| 559 |
+
else:
|
| 560 |
+
st.session_state["card_info"] = card_info
|
| 561 |
+
st.session_state["card_explanation"] = explanation
|
| 562 |
+
st.success("Cards generated successfully!")
|
| 563 |
+
st.rerun() # ← Force UI to update immediately
|
| 564 |
+
except Exception as e:
|
| 565 |
+
error_placeholder.error(f"Unexpected error: {e}")
|
| 566 |
+
except Exception as e:
|
| 567 |
+
error_placeholder.error(f"Error checking card count: {e}")
|
| 568 |
+
|
cardnames.txt
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
mtg_schemas.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import List, Literal, Annotated, Optional
|
| 2 |
+
from pydantic import BaseModel, Field, ValidationError, field_validator, conint, StringConstraints
|
| 3 |
+
|
| 4 |
+
# StrEnum does not come with Python 3.10, and we need to use Python 3.10 because that's what Hugging Face Spaces uses.
|
| 5 |
+
# Create a compatible fallback
|
| 6 |
+
#from enum import c
|
| 7 |
+
|
| 8 |
+
import sys
|
| 9 |
+
|
| 10 |
+
if sys.version_info >= (3, 11):
|
| 11 |
+
# Python 3.11+ has StrEnum built-in
|
| 12 |
+
from enum import StrEnum
|
| 13 |
+
else:
|
| 14 |
+
# For Python 3.10 and below, create a compatible fallback
|
| 15 |
+
from enum import Enum
|
| 16 |
+
|
| 17 |
+
class StrEnum(str, Enum):
|
| 18 |
+
"""Compatibility fallback for Python < 3.11."""
|
| 19 |
+
pass
|
| 20 |
+
|
| 21 |
+
import base64
|
| 22 |
+
from io import BytesIO
|
| 23 |
+
from PIL import Image
|
| 24 |
+
|
| 25 |
+
class YesNoAnswer(StrEnum):
|
| 26 |
+
Yes = "Yes"
|
| 27 |
+
No = "No"
|
| 28 |
+
|
| 29 |
+
class YesNoName(BaseModel):
|
| 30 |
+
YesNo: YesNoAnswer = Field(description="A Yes or No Answer.")
|
| 31 |
+
Name: str = Field(default="", description="The name (may be empty).")
|
| 32 |
+
|
| 33 |
+
# Define a structured output for a list of YesNoName objects.
|
| 34 |
+
class YesNoNameList(BaseModel):
|
| 35 |
+
items: List[YesNoName]
|
| 36 |
+
|
| 37 |
+
# Used to enforce uniqueness of new card name.
|
| 38 |
+
# We don't want to re-use a card name that has already been used by a pre-existing card.
|
| 39 |
+
class MTGNameOnly(BaseModel):
|
| 40 |
+
Name: str
|
| 41 |
+
|
| 42 |
+
# Regex: one or more tokens; each token is { <digits> | X | R | U | W | G | B }
|
| 43 |
+
# ManaCost definition (from earlier)
|
| 44 |
+
ManaCost = Optional[
|
| 45 |
+
Annotated[str, StringConstraints(pattern=r'^(?:\{(?:[0-9]+|[XRUWGB])\})+$')]
|
| 46 |
+
]
|
| 47 |
+
|
| 48 |
+
# These are the Subtypes that I found MTG card information downloaded from MTGJSON (https://mtgjson.com/)
|
| 49 |
+
# Obviously, some of them are rather niche.
|
| 50 |
+
class SubtypeEnum(StrEnum):
|
| 51 |
+
Legendary = "Legendary"
|
| 52 |
+
Basic = "Basic"
|
| 53 |
+
Snow = "Snow"
|
| 54 |
+
BasicSnow = "Basic, Snow"
|
| 55 |
+
World = "World"
|
| 56 |
+
LegendarySnow = "Legendary, Snow"
|
| 57 |
+
Host = "Host"
|
| 58 |
+
Ongoing = "Ongoing"
|
| 59 |
+
NoneType = "None" # sentinel if no subtype
|
| 60 |
+
|
| 61 |
+
class MTGCard(BaseModel):
|
| 62 |
+
# simple strings
|
| 63 |
+
Name: str
|
| 64 |
+
Supertype: Optional[SubtypeEnum] = None
|
| 65 |
+
Type: str
|
| 66 |
+
Subtype: str
|
| 67 |
+
Keywords: str
|
| 68 |
+
Text: str
|
| 69 |
+
FlavorText: Optional[str] = ""
|
| 70 |
+
|
| 71 |
+
# constrained fields
|
| 72 |
+
Colors: Optional[List[Literal['R', 'U', 'W', 'G', 'B']]] = None
|
| 73 |
+
ManaCost: ManaCost
|
| 74 |
+
|
| 75 |
+
# Power/toughness may be absent for non-creatures
|
| 76 |
+
Power: Optional[conint(gt=0)] = None
|
| 77 |
+
Toughness: Optional[conint(gt=0)] = None
|
| 78 |
+
|
| 79 |
+
# Define a structured output for a list of MTG cards.
|
| 80 |
+
class MTGCardList(BaseModel):
|
| 81 |
+
cards: List[MTGCard]
|
| 82 |
+
explanation: str
|
requirements.txt
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
streamlit==1.39.0
|
| 2 |
+
openai>=1.52.0,<2.0.0
|
| 3 |
+
pydantic>=2.7.0,<3.0.0
|
| 4 |
+
python-dotenv>=1.0.0
|
| 5 |
+
Pillow>=10.0.0
|