kestrel256 commited on
Commit
2c86792
·
verified ·
1 Parent(s): 5865a91

Upload files

Browse files

Initial upload of five project files.

Files changed (5) hide show
  1. Dockerfile +28 -0
  2. app.py +568 -0
  3. cardnames.txt +0 -0
  4. mtg_schemas.py +82 -0
  5. 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