JG1310 commited on
Commit
97da009
·
verified ·
1 Parent(s): f18860f

Sync from dev: Qwen3-14B (single model) + FLUX.2-klein, generative scribe, 4-way parallel scribe/paint, lazy deck view, prompt logging, GUI + card-frame fixes

Browse files
app.py CHANGED
@@ -24,6 +24,7 @@ import base64
24
  import html
25
  import json
26
  import os
 
27
  import tempfile
28
  import threading
29
  import time
@@ -75,7 +76,7 @@ if os.environ.get("IMAGE_BACKEND", "").lower() == "local":
75
 
76
  WELCOME = ("Welcome, traveler. Every world has its hidden archetypes. "
77
  "Name one, and I shall draw out its fortune.")
78
- EXAMPLE_THEMES = ["Physics", "Birds", "Lord of the Rings Characters", "Breakfast Foods"]
79
  SPREAD_CHOICES = [("Single card", "single"), ("Past · Present · Future", "three")]
80
 
81
  PAGE_ORDER = ["landing", "taro", "generate", "pick", "reading", "view"]
@@ -106,17 +107,62 @@ with open(os.path.join(ROOT, "frontend", "deckview.html"), encoding="utf-8") as
106
  _DECKVIEW_HTML = _f.read()
107
 
108
 
109
- def deck_iframe(deck: dict | None, nonce: int = 0) -> str:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
  """The deck browser — a self-contained coverflow/overhead widget embedded via an
111
  iframe ``srcdoc`` (the whole widget HTML is inlined, with the deck's image URLs +
112
  names injected as base64 JSON in a global). Using srcdoc instead of a /viewer
113
  static mount keeps everything inside gradio's native server (ZeroGPU-compatible).
114
- All swipe/flip/fade/toggle/lightbox is handled client-side in the widget."""
 
 
 
 
115
  if not deck or not deck.get("cards"):
116
  return "<div class='spread-empty'>No deck to show.</div>"
117
  data = {
118
  "back": url_for(deck.get("back_disp") or deck.get("back_path")),
119
- "cards": [{"name": c["concept"], "arc": c.get("arcana_name", ""),
 
 
120
  "front": disp_url(c), "full": full_url(c)}
121
  for c in deck["cards"]],
122
  }
@@ -125,8 +171,8 @@ def deck_iframe(deck: dict | None, nonce: int = 0) -> str:
125
  doc = _DECKVIEW_HTML.replace("</head>", inject + "</head>", 1)
126
  srcdoc = html.escape(doc, quote=True)
127
  return (f"<iframe srcdoc='{srcdoc}' title='Deck' "
128
- "style='width:100%;height:74vh;min-height:520px;border:0;display:block;"
129
- "background:transparent;'></iframe>")
130
 
131
 
132
  def zip_deck(deck: dict | None):
@@ -170,11 +216,29 @@ WORDS_FADE_CAP = 14.0 # never wait longer than this for a fade to finish
170
 
171
  def _fade(text: str, step: float = FADE_STEP, start: float = 0.0) -> str:
172
  """Wrap each word in a span that fades in on a staggered delay — the browser
173
- animates the whole block in one render (no per-word server round-trips)."""
174
- out = []
175
- for i, w in enumerate(text.split()):
176
- out.append(f"<span class='fw' style='animation-delay:{start + i * step:.2f}s'>"
177
- f"{html.escape(w)} </span>")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
178
  return "".join(out)
179
 
180
 
@@ -203,10 +267,10 @@ def _text_col(d: dict, body_html: str) -> str:
203
  return f"<div class='text-col'>{head}{ess}{body}</div>"
204
 
205
 
206
- def _stage(back_url, completed, active=None, synth_html=""):
207
- """active: (d, body_html, anim) or None. completed: list of (d, body_html).
208
- The synthesis, when present, renders at the TOP (in the reading area) above the
209
- list of already-read cards."""
210
  parts = []
211
  if active is not None:
212
  d, body, anim = active
@@ -214,13 +278,18 @@ def _stage(back_url, completed, active=None, synth_html=""):
214
  f"{_flip_card(d, back_url, anim)}</div>{_text_col(d, body)}</div>")
215
  if synth_html:
216
  parts.append(f"<div id='fortune-box'>{synth_html}</div>")
217
- if completed:
218
- rows = "".join(f"<div class='done-row'><div class='card-col'>{_still_card(d)}</div>"
219
- f"{_text_col(d, body)}</div>" for d, body in completed)
220
- parts.append(f"<div class='completed'>{rows}</div>")
221
  return f"<div class='reading-stage'>{''.join(parts)}</div>"
222
 
223
 
 
 
 
 
 
 
 
 
 
224
  # Manual, reader-paced theatre (§14): "Draw" reveals the first card; a "Next" button
225
  # advances one beat at a time so people can read in their own time. Reading state
226
  # lives in a session dict carried in gr.State between clicks.
@@ -233,43 +302,51 @@ def _gpu_reading(deck, question, drawn):
233
  """Compute ALL card readings + the synthesis in ONE GPU session (the Nemotron
234
  GGUF can't be cached across separate ZeroGPU calls). Revealed progressively
235
  client-side afterwards. Returns (partials, synthesis)."""
 
 
236
  llm = get_llm()
237
  partials = []
 
238
  for i in range(len(drawn)):
239
  try:
240
  partials.append(card_partial(deck, question, drawn, i, llm=llm))
241
  except Exception:
242
  partials.append("")
 
 
243
  try:
244
  synth = final_synthesis(deck, question, drawn, partials, llm=llm)
245
  except Exception as e:
246
  synth = f"(the synthesis slips away — {type(e).__name__})"
 
247
  return partials, synth
248
 
249
 
250
  def _reveal_card(sess):
251
  """Flip the card at sess['i'] into the active area and reveal its (already
252
- computed) reading. Yields (stage_html, sess, next_button_update)."""
253
  drawn, back, i = sess["drawn"], sess["back"], sess["i"]
254
  d, completed = drawn[i], sess["completed"]
 
255
  label = "Next card ▸" if i + 1 < len(drawn) else "Reveal the verdict ▸"
256
  # flip + essence show first, then the deeper reading fades in a beat later
257
- yield _stage(back, completed, active=(d, "", True)), sess, _next_btn(label)
258
  time.sleep(FLIP_BEAT)
259
  p = sess["partials_pre"][i] if i < len(sess.get("partials_pre", [])) else ""
260
  sess["last_p"] = p
261
  body = _fade(p) if p else ""
262
- yield _stage(back, completed, active=(d, body, False)), sess, _next_btn(label)
263
 
264
 
265
  def do_draw(deck, question, spread, reversals):
266
  """Begin a reading: draw the spread, compute every card's reading on the GPU in
267
  one pass, then reveal card by card as the reader clicks Next."""
268
  if not deck:
269
- yield "<div class='spread-empty'>Conjure or open a deck first.</div>", None, _next_btn(None)
 
270
  return
271
  drawn = draw_spread(deck, spread=spread, reversals=reversals)
272
- yield ("<div class='spread-empty'>…the oracle contemplates the spread…</div>",
273
  None, _next_btn(None))
274
  partials, synth = _gpu_reading(deck, question, drawn)
275
  sess = {"deck": deck, "drawn": drawn, "question": question,
@@ -281,10 +358,9 @@ def do_draw(deck, question, spread, reversals):
281
  def do_next(sess):
282
  """Advance the reading one beat (reader-paced)."""
283
  if not sess or sess.get("phase") != "card":
284
- yield gr.update(), sess, _next_btn(None)
285
  return
286
- # drop the current card to the list below, with its full reading (essence
287
- # stands alone if the deeper text was unavailable)
288
  d = sess["drawn"][sess["i"]]
289
  lp = sess.get("last_p") or ""
290
  sess["completed"].append((d, f"<span>{html.escape(lp)}</span>" if lp else ""))
@@ -292,18 +368,18 @@ def do_next(sess):
292
  sess["i"] += 1
293
  yield from _reveal_card(sess)
294
  return
295
- # all cards read — gather and reveal the synthesis at the top (reading area)
296
  sess["phase"] = "synth"
297
  back = sess["back"]
298
- yield (_stage(back, sess["completed"],
299
- synth_html="<div class='syn-load'>…the oracle gathers the threads…</div>"),
300
- sess, _next_btn(None))
301
  time.sleep(FLIP_BEAT)
302
  synth = sess.get("synth_pre") or "(the synthesis slips away)"
303
  sess["phase"] = "done"
304
- yield (_stage(back, sess["completed"],
305
- synth_html=f"<h3>✦ The cards together</h3><div class='syn-body'>{_fade(synth)}</div>"),
306
- sess, _next_btn(None))
307
 
308
  # ----------------------------------------------------------------- generate
309
  def do_conjure(theme, style, custom):
@@ -311,42 +387,206 @@ def do_conjure(theme, style, custom):
311
  deck in the SAME Deck-View/Overhead browser used by View Deck (so you can flip
312
  through the new cards), with Read / Save actions there.
313
 
314
- Outputs: gen_status, deck_state, vd_deck, vd_html, download_btn, *pages."""
 
315
  HOLD = (gr.update(),) * 4 # deck_state, vd_deck, vd_html, download_btn
 
 
 
 
 
 
316
 
317
  def stay(status): # progress: update only the status line, stay on the generate page
318
- return (status, *HOLD, *show("generate"))
319
 
 
320
  theme = (theme or "").strip()
321
  if not theme:
322
  yield stay("Name a theme to begin."); return
323
- # PHASE 1 — the 30B mapping (its own GPU call); PHASE 2 FLUX images (separate
324
- # GPU call). Split so the 30B and the 9B image model never share the 48GB GPU.
 
325
  deck = None
326
- # STAGE 1 — generating the cards (the LLM mapping); shown as its own loader.
327
  for kind, payload, frac in _gpu_map(theme, style, custom):
328
- if kind == "progress":
329
- yield stay("<div class='gen-load'><div class='gen-spin'></div>"
330
- f"<h2>✦ Generating the cards</h2><p>{html.escape(payload)}</p>"
331
- f"<p class='gen-sub'>mapping <em>{html.escape(theme)}</em> onto the 22 arcana…</p></div>")
332
- elif kind == "error":
333
  yield stay(f"⚠️ The conjuring faltered ({payload}). Try again."); return
 
 
 
334
  elif kind == "done":
335
  deck = payload
336
  if not deck:
337
  yield stay("⚠️ The conjuring faltered (no deck). Try again."); return
338
- # STAGE 2 — painting the cards (FLUX); card-by-card progress bar.
339
- for kind, payload, frac in _gpu_images(deck):
340
- if kind == "progress":
341
- yield stay("<div class='gen-load'><h2>🎴 Painting the deck</h2>"
342
- f"<p>{html.escape(payload)}</p>"
343
- f"<div class='gen-bar'><div style='width:{int(frac * 100)}%'></div></div></div>")
344
- elif kind == "error":
345
- yield stay(f"⚠️ The painting faltered ({payload}). Try again."); return
346
- elif kind == "done":
347
- deck = payload
348
- # open the freshly-made deck in the coverflow deck browser
349
- yield ("", deck, deck, deck_iframe(deck, _next_nonce()), zip_deck(deck), *show("view"))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
350
 
351
 
352
  def _threaded_progress(work):
@@ -373,41 +613,285 @@ def _threaded_progress(work):
373
  yield ("done", st["result"], 1.0)
374
 
375
 
376
- @GPU(duration=600)
377
  def _gpu_map(theme, style, custom):
378
- """Phase 1 (own GPU call): the 30B mapping (design + loremaster), no images.
379
- 600s: the 30B reasons for the designer AND the loremaster + first-call warm-up."""
380
- from arcana.build import design_phase
 
 
381
  yield from _threaded_progress(
382
- lambda emit: design_phase(theme, visual_style=style, custom_style=custom,
383
- progress=emit))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
384
 
385
 
386
  @GPU(duration=600)
387
  def _gpu_maptest(theme):
388
- """Quota-cheap mapping-only probe (no images): returns timing + a quality check
389
- so I can tune the 30B mapping without paying for the 24-image paint each run."""
 
390
  import time as _t
391
- from arcana.build import design_phase
392
- t0 = _t.time()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
393
  try:
394
- deck = design_phase(theme or "Greek mythology")
395
  except Exception as e:
396
- return f"FAILED in {_t.time()-t0:.0f}s: {type(e).__name__}: {e}"
397
- dt = _t.time() - t0
398
- cards = deck.get("cards", [])
399
- cs = [c.get("concept", "") for c in cards]
400
- empties = sum(1 for x in cs if not x.strip())
401
- dups = len(cs) - len(set(cs))
402
- sample = "; ".join(f"{c['arcana_number']}:{c['concept']}" for c in cards[:8])
403
- return f"{dt:.0f}s | {len(cards)} cards | empties={empties} dups={dups} | {sample}"
 
 
 
 
 
 
 
 
 
 
 
404
 
405
 
406
  @GPU(duration=300)
407
  def _gpu_images(deck):
408
- """Phase 2 (own GPU call): paint back + 22 cards with FLUX (no LLM resident)."""
 
 
 
 
409
  from arcana.build import paint_phase
410
- yield from _threaded_progress(lambda emit: paint_phase(deck, progress=emit))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
411
 
412
 
413
  def toggle_custom(style):
@@ -422,15 +906,21 @@ def _next_nonce():
422
 
423
 
424
  def open_picked(deck_id, mode):
425
- """Outputs: deck_state, vd_deck, vd_html, download_btn, *pages."""
 
 
 
426
  deck = load_deck(deck_id) if deck_id else None
427
  if not deck:
428
- return (gr.update(), gr.update(), gr.update(), gr.update(), *show("pick"))
 
429
  if mode == "view":
430
  return (gr.update(), deck, deck_iframe(deck, _next_nonce()),
431
- zip_deck(deck), *show("view"))
 
432
  # read mode → straight to the reading room
433
- return (deck, gr.update(), gr.update(), gr.update(), *show("reading"))
 
434
 
435
 
436
  def read_this_deck(deck):
@@ -542,6 +1032,44 @@ button.primary, .big-btn { background:linear-gradient(135deg,#9a7b2e,#d8b15a) !i
542
  border-radius:6px; overflow:hidden; }
543
  .gen-bar > div { height:100%; background:linear-gradient(90deg,#9a7b2e,#d8b15a);
544
  transition:width .35s ease; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
545
  #chips { flex-wrap:wrap !important; gap:6px !important; justify-content:center; }
546
  #chips .secondary { border-radius:999px !important; font-size:1.7rem !important; padding:8px 20px !important; }
547
  #deck img, #preview img { border-radius:8px; box-shadow:0 6px 20px rgba(0,0,0,.55); }
@@ -574,6 +1102,8 @@ button.primary, .big-btn { background:linear-gradient(135deg,#9a7b2e,#d8b15a) !i
574
  .syn-load { font-style:italic; color:#cdbf93; font-size:2rem; text-align:center; padding:10px; }
575
  /* word fade-in (§2) — each word eases in on a staggered delay */
576
  .fw { opacity:0; display:inline; animation:fadeWord .7s ease forwards; }
 
 
577
  @keyframes fadeWord { from { opacity:0; } to { opacity:1; } }
578
  /* input contrast — light text on dark fields, never yellow-on-white (§4) */
579
  .gradio-container textarea, .gradio-container input, .gradio-container select,
@@ -588,6 +1118,9 @@ button.primary, .big-btn { background:linear-gradient(135deg,#9a7b2e,#d8b15a) !i
588
  background: rgba(243,230,196,.85) !important; border:1px solid var(--gold) !important;
589
  border-radius:4px !important; appearance:auto !important; -webkit-appearance:checkbox !important;
590
  opacity:1 !important; cursor:pointer; }
 
 
 
591
  /* dropdown popup + options — never yellow-on-white (§5) */
592
  .gradio-container ul[role="listbox"], .gradio-container .options, .gradio-container .option,
593
  .gradio-container li[role="option"], .gradio-container li.item, .gradio-container [class*="options"] {
@@ -712,18 +1245,17 @@ def build_demo() -> gr.Blocks:
712
  placeholder="Name anything to build a tarot deck from — a field, a world, a "
713
  "feeling… e.g. Greek mythology, the deep sea, jazz, office life",
714
  show_label=False, value="", max_lines=1)
715
- gr.Markdown("*…or tap an example to conjure one live (every card generated "
716
- "fresh):*", elem_classes=["status"])
717
  with gr.Row(elem_id="chips"):
718
  chips = [gr.Button(t, size="sm", elem_classes=["secondary"]) for t in EXAMPLE_THEMES]
719
- with gr.Row():
720
- style = gr.Dropdown(STYLE_CHOICES, value="rider-waite-smith",
721
- label="Visual style", scale=3)
722
- # toggling a CONTAINER's visibility is reliable here (unlike a bare
723
- # Textbox) — created visible so it mounts; hidden on entry; shown for Custom.
724
- with gr.Column(visible=True, scale=4) as custom_box:
725
- custom = gr.Textbox(label="Describe your style",
726
- placeholder=CUSTOM_PLACEHOLDER, max_lines=1)
727
  conjure = gr.Button("✦ Conjure the deck", elem_classes=["big-btn"])
728
  gen_status = gr.Markdown("", elem_classes=["status"])
729
  gen_back = gr.Button("↩ Back", scale=1, elem_classes=["secondary"])
@@ -747,12 +1279,13 @@ def build_demo() -> gr.Blocks:
747
  spread = gr.Dropdown(SPREAD_CHOICES, value="three", label="Spread",
748
  scale=3, min_width=260)
749
  reversals = gr.Checkbox(value=True, label="Allow reversals",
750
- scale=2, min_width=220)
751
  draw = gr.Button("✦ Draw the cards", elem_classes=["big-btn"])
752
  stage = gr.HTML("<div class='spread-empty'>Pose your question, then draw.</div>",
753
  elem_id="stage")
754
- with gr.Row(elem_id="next-row"):
755
- next_btn = gr.Button("Next card ▸", visible=False, elem_classes=["big-btn"])
 
756
  with gr.Row():
757
  save_in_read = gr.Button("✦ Save this deck", scale=1, elem_classes=["secondary"])
758
  read_back = gr.Button("↩ Back to start", scale=1, elem_classes=["secondary"])
@@ -791,25 +1324,28 @@ def build_demo() -> gr.Blocks:
791
  style.change(toggle_custom, style, custom_box)
792
  # example theme chips — one click sets the theme AND conjures it from
793
  # scratch (the examples ARE live demos: every card is freshly generated).
 
 
794
  for chip, t in zip(chips, EXAMPLE_THEMES):
795
- chip.click(lambda t=t: t, None, theme).then(
796
- do_conjure, [theme, style, custom],
797
- [gen_status, deck_state, vd_deck, vd_html, download_btn, *pages])
798
 
799
  # generate → opens the new deck in the Deck-View/Overhead browser (pg_view)
800
  conjure.click(do_conjure, [theme, style, custom],
801
- [gen_status, deck_state, vd_deck, vd_html, download_btn, *pages])
 
802
 
803
  # pick → open
804
  pick_refresh.click(lambda: gr.update(choices=deck_choices()), None, pick_dd)
805
  pick_open.click(open_picked, [pick_dd, pick_mode],
806
- [deck_state, vd_deck, vd_html, download_btn, *pages])
 
807
  read_btn.click(read_this_deck, vd_deck, [deck_state, *pages])
808
 
809
  # reading
810
  draw.click(do_draw, [deck_state, question, spread, reversals],
811
- [stage, reading_sess, next_btn])
812
- next_btn.click(do_next, reading_sess, [stage, reading_sess, next_btn])
813
  save_in_read.click(save_current, deck_state, save_note)
814
 
815
  # pages are created visible=True so gallery components mount cleanly
@@ -849,6 +1385,49 @@ def build_demo() -> gr.Blocks:
849
  _mt_theme = gr.Textbox("Greek mythology", visible=False)
850
  _mt_out = gr.Textbox(visible=False)
851
  gr.Button(visible=False).click(_gpu_maptest, _mt_theme, _mt_out, api_name="maptest")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
852
 
853
  return demo
854
 
 
24
  import html
25
  import json
26
  import os
27
+ import re
28
  import tempfile
29
  import threading
30
  import time
 
76
 
77
  WELCOME = ("Welcome, traveler. Every world has its hidden archetypes. "
78
  "Name one, and I shall draw out its fortune.")
79
+ EXAMPLE_THEMES = ["physics", "birds", "lord of the rings characters", "breakfast foods"]
80
  SPREAD_CHOICES = [("Single card", "single"), ("Past · Present · Future", "three")]
81
 
82
  PAGE_ORDER = ["landing", "taro", "generate", "pick", "reading", "view"]
 
107
  _DECKVIEW_HTML = _f.read()
108
 
109
 
110
+ def _card_desc(c: dict) -> str:
111
+ """Short caption text for a card — its essence if the loremaster has written it,
112
+ else the (draft) upright meaning, trimmed."""
113
+ d = (c.get("essence") or c.get("upright_meaning") or "").strip()
114
+ d = d.replace("**", "").replace("*", "") # drop raw markdown markers in the caption
115
+ return d[:200]
116
+
117
+
118
+ def _status_paths(deck: dict):
119
+ dd = deck_dir(deck["deck_id"])
120
+ return os.path.join(dd, "art.json"), os.path.join(dd, "lore.json")
121
+
122
+
123
+ def _write_json_atomic(path: str, data: dict) -> None:
124
+ tmp = path + ".tmp"
125
+ with open(tmp, "w", encoding="utf-8") as f:
126
+ json.dump(data, f)
127
+ os.replace(tmp, path) # atomic → the poller never reads a half-written file
128
+
129
+
130
+ def _write_art_status(deck: dict, done: bool) -> None:
131
+ """Live status the deck-view widget polls: which cards are painted (num → url)."""
132
+ art, _ = _status_paths(deck)
133
+ cards = {c["arcana_number"]: disp_url(c) for c in deck["cards"] if c.get("disp_path")}
134
+ _write_json_atomic(art, {"cards": cards, "done": done,
135
+ "back": url_for(deck.get("back_disp") or deck.get("back_path"))})
136
+
137
+
138
+ def _write_lore_status(deck: dict, done: bool) -> None:
139
+ """Live status the widget polls: each card's (refined) meaning."""
140
+ _, lore = _status_paths(deck)
141
+ cards = {c["arcana_number"]: {"essence": c.get("essence", ""),
142
+ "upright": c.get("upright_meaning", ""),
143
+ "reversed": c.get("reversed_meaning", "")}
144
+ for c in deck["cards"]}
145
+ _write_json_atomic(lore, {"cards": cards, "done": done})
146
+
147
+
148
+ def deck_iframe(deck: dict | None, nonce: int = 0, live: bool = False,
149
+ art_url: str = "", lore_url: str = "") -> str:
150
  """The deck browser — a self-contained coverflow/overhead widget embedded via an
151
  iframe ``srcdoc`` (the whole widget HTML is inlined, with the deck's image URLs +
152
  names injected as base64 JSON in a global). Using srcdoc instead of a /viewer
153
  static mount keeps everything inside gradio's native server (ZeroGPU-compatible).
154
+ All swipe/flip/fade/toggle/lightbox is handled client-side in the widget.
155
+
156
+ live=True seeds the widget with names + (draft) descriptions and EMPTY art (a
157
+ shimmer placeholder per card); the widget then polls `art_url`/`lore_url` and
158
+ fills each card's art + refined meaning in as the two GPU phases finish."""
159
  if not deck or not deck.get("cards"):
160
  return "<div class='spread-empty'>No deck to show.</div>"
161
  data = {
162
  "back": url_for(deck.get("back_disp") or deck.get("back_path")),
163
+ "live": bool(live), "artUrl": art_url, "loreUrl": lore_url,
164
+ "cards": [{"num": c["arcana_number"], "name": c["concept"],
165
+ "arc": c.get("arcana_name", ""), "desc": _card_desc(c),
166
  "front": disp_url(c), "full": full_url(c)}
167
  for c in deck["cards"]],
168
  }
 
171
  doc = _DECKVIEW_HTML.replace("</head>", inject + "</head>", 1)
172
  srcdoc = html.escape(doc, quote=True)
173
  return (f"<iframe srcdoc='{srcdoc}' title='Deck' "
174
+ "style='width:100%;height:74vh;max-height:660px;min-height:520px;border:0;"
175
+ "display:block;background:transparent;'></iframe>")
176
 
177
 
178
  def zip_deck(deck: dict | None):
 
216
 
217
  def _fade(text: str, step: float = FADE_STEP, start: float = 0.0) -> str:
218
  """Wrap each word in a span that fades in on a staggered delay — the browser
219
+ animates the whole block in one render (no per-word server round-trips).
220
+
221
+ Nemotron likes to emphasise with Markdown (**bold**, *italic*); rather than fight
222
+ it we honour it as a formatting cue. We split the text into styled runs and fade
223
+ word-by-word within them, so the effect and the HTML both stay valid."""
224
+ runs, pos = [], 0
225
+ for m in re.finditer(r"\*\*(.+?)\*\*|\*(.+?)\*", text, flags=re.DOTALL):
226
+ if m.start() > pos:
227
+ runs.append((text[pos:m.start()], False, False))
228
+ if m.group(1) is not None:
229
+ runs.append((m.group(1), True, False))
230
+ else:
231
+ runs.append((m.group(2), False, True))
232
+ pos = m.end()
233
+ if pos < len(text):
234
+ runs.append((text[pos:], False, False))
235
+ out, idx = [], 0
236
+ for seg, bold, ital in runs:
237
+ cls = "fw" + (" fwb" if bold else "") + (" fwi" if ital else "")
238
+ for w in seg.split():
239
+ out.append(f"<span class='{cls}' style='animation-delay:{start + idx * step:.2f}s'>"
240
+ f"{html.escape(w)} </span>")
241
+ idx += 1
242
  return "".join(out)
243
 
244
 
 
267
  return f"<div class='text-col'>{head}{ess}{body}</div>"
268
 
269
 
270
+ def _stage_active(back_url, active=None, synth_html=""):
271
+ """The TOP area: the currently-active card (+ its reading), or the synthesis.
272
+ Rendered ABOVE the Next button so Next always sits right under the live card —
273
+ the already-read history goes in _stage_done, below Next."""
274
  parts = []
275
  if active is not None:
276
  d, body, anim = active
 
278
  f"{_flip_card(d, back_url, anim)}</div>{_text_col(d, body)}</div>")
279
  if synth_html:
280
  parts.append(f"<div id='fortune-box'>{synth_html}</div>")
 
 
 
 
281
  return f"<div class='reading-stage'>{''.join(parts)}</div>"
282
 
283
 
284
+ def _stage_done(completed):
285
+ """The BOTTOM area: the list of already-read cards (below the Next button)."""
286
+ if not completed:
287
+ return ""
288
+ rows = "".join(f"<div class='done-row'><div class='card-col'>{_still_card(d)}</div>"
289
+ f"{_text_col(d, body)}</div>" for d, body in completed)
290
+ return f"<div class='reading-stage'><div class='completed'>{rows}</div></div>"
291
+
292
+
293
  # Manual, reader-paced theatre (§14): "Draw" reveals the first card; a "Next" button
294
  # advances one beat at a time so people can read in their own time. Reading state
295
  # lives in a session dict carried in gr.State between clicks.
 
302
  """Compute ALL card readings + the synthesis in ONE GPU session (the Nemotron
303
  GGUF can't be cached across separate ZeroGPU calls). Revealed progressively
304
  client-side afterwards. Returns (partials, synthesis)."""
305
+ from arcana.timing import record
306
+ import time as _t
307
  llm = get_llm()
308
  partials = []
309
+ tc = _t.time()
310
  for i in range(len(drawn)):
311
  try:
312
  partials.append(card_partial(deck, question, drawn, i, llm=llm))
313
  except Exception:
314
  partials.append("")
315
+ record("reading_cards", _t.time() - tc, {"n": len(drawn)})
316
+ ts = _t.time()
317
  try:
318
  synth = final_synthesis(deck, question, drawn, partials, llm=llm)
319
  except Exception as e:
320
  synth = f"(the synthesis slips away — {type(e).__name__})"
321
+ record("reading_synthesis", _t.time() - ts)
322
  return partials, synth
323
 
324
 
325
  def _reveal_card(sess):
326
  """Flip the card at sess['i'] into the active area and reveal its (already
327
+ computed) reading. Yields (active_html, done_html, sess, next_button_update)."""
328
  drawn, back, i = sess["drawn"], sess["back"], sess["i"]
329
  d, completed = drawn[i], sess["completed"]
330
+ done_html = _stage_done(completed)
331
  label = "Next card ▸" if i + 1 < len(drawn) else "Reveal the verdict ▸"
332
  # flip + essence show first, then the deeper reading fades in a beat later
333
+ yield _stage_active(back, active=(d, "", True)), done_html, sess, _next_btn(label)
334
  time.sleep(FLIP_BEAT)
335
  p = sess["partials_pre"][i] if i < len(sess.get("partials_pre", [])) else ""
336
  sess["last_p"] = p
337
  body = _fade(p) if p else ""
338
+ yield _stage_active(back, active=(d, body, False)), done_html, sess, _next_btn(label)
339
 
340
 
341
  def do_draw(deck, question, spread, reversals):
342
  """Begin a reading: draw the spread, compute every card's reading on the GPU in
343
  one pass, then reveal card by card as the reader clicks Next."""
344
  if not deck:
345
+ yield ("<div class='spread-empty'>Conjure or open a deck first.</div>", "",
346
+ None, _next_btn(None))
347
  return
348
  drawn = draw_spread(deck, spread=spread, reversals=reversals)
349
+ yield ("<div class='spread-empty'>…the oracle contemplates the spread…</div>", "",
350
  None, _next_btn(None))
351
  partials, synth = _gpu_reading(deck, question, drawn)
352
  sess = {"deck": deck, "drawn": drawn, "question": question,
 
358
  def do_next(sess):
359
  """Advance the reading one beat (reader-paced)."""
360
  if not sess or sess.get("phase") != "card":
361
+ yield gr.update(), gr.update(), sess, _next_btn(None)
362
  return
363
+ # drop the current card to the history list below, with its full reading
 
364
  d = sess["drawn"][sess["i"]]
365
  lp = sess.get("last_p") or ""
366
  sess["completed"].append((d, f"<span>{html.escape(lp)}</span>" if lp else ""))
 
368
  sess["i"] += 1
369
  yield from _reveal_card(sess)
370
  return
371
+ # all cards read — reveal the synthesis in the (top) active area
372
  sess["phase"] = "synth"
373
  back = sess["back"]
374
+ done_html = _stage_done(sess["completed"])
375
+ yield (_stage_active(back, synth_html="<div class='syn-load'>…the oracle gathers the threads…</div>"),
376
+ done_html, sess, _next_btn(None))
377
  time.sleep(FLIP_BEAT)
378
  synth = sess.get("synth_pre") or "(the synthesis slips away)"
379
  sess["phase"] = "done"
380
+ yield (_stage_active(back, synth_html=f"<h3>✦ The cards together</h3>"
381
+ f"<div class='syn-body'>{_fade(synth)}</div>"),
382
+ done_html, sess, _next_btn(None))
383
 
384
  # ----------------------------------------------------------------- generate
385
  def do_conjure(theme, style, custom):
 
387
  deck in the SAME Deck-View/Overhead browser used by View Deck (so you can flip
388
  through the new cards), with Read / Save actions there.
389
 
390
+ Outputs: gen_status, deck_state, vd_deck, vd_html, download_btn, *pages,
391
+ read_btn, download_btn2(view_note)."""
392
  HOLD = (gr.update(),) * 4 # deck_state, vd_deck, vd_html, download_btn
393
+ # read_btn + view_note tail (grey out Read/Download while the deck is still painting)
394
+ TAIL_IDLE = (gr.update(), gr.update())
395
+ READ_BUSY = gr.update(interactive=False, value="🎴 Painting the deck…")
396
+ READ_DONE = gr.update(interactive=True, value="✦ Read this deck")
397
+ NOTE_BUSY = ("🎴 Painting the cards & writing the lore — the reading and download "
398
+ "unlock once the whole deck is complete.")
399
 
400
  def stay(status): # progress: update only the status line, stay on the generate page
401
+ return (status, *HOLD, *show("generate"), *TAIL_IDLE)
402
 
403
+ NOOP = (gr.update(),) * (5 + len(PAGE_ORDER) + 2) # change nothing, keep the stream alive
404
  theme = (theme or "").strip()
405
  if not theme:
406
  yield stay("Name a theme to begin."); return
407
+ # PHASE 1 — the mapping (its own GPU call): theme 22 named cards with draft
408
+ # meanings + art briefs. As soon as it lands we jump STRAIGHT TO THE DECK VIEW.
409
+ t_map0 = time.time()
410
  deck = None
 
411
  for kind, payload, frac in _gpu_map(theme, style, custom):
412
+ if kind == "error":
 
 
 
 
413
  yield stay(f"⚠️ The conjuring faltered ({payload}). Try again."); return
414
+ elif kind == "progress":
415
+ yield stay(_loader("✦ Finding the cards", payload,
416
+ f"mapping {html.escape(theme)} onto the 22 cards of the major arcana…"))
417
  elif kind == "done":
418
  deck = payload
419
  if not deck:
420
  yield stay("⚠️ The conjuring faltered (no deck). Try again."); return
421
+ t_map = time.time() - t_map0
422
+
423
+ # Fan the 22 cards across the GPU slots, round-robin balanced.
424
+ by = {c["arcana_number"]: c for c in deck["cards"]}
425
+ nums = [c["arcana_number"] for c in deck["cards"]]
426
+ chunks = [c for c in (nums[i::PARALLEL_GPUS] for i in range(PARALLEL_GPUS)) if c]
427
+ NW = len(chunks)
428
+
429
+ def merge_fields(res, chunk, fields):
430
+ rb = {c["arcana_number"]: c for c in (res or {}).get("cards", [])}
431
+ for n in chunk:
432
+ src = rb.get(n, {})
433
+ for k in fields:
434
+ if src.get(k):
435
+ by[n][k] = src[k]
436
+
437
+ # PHASE 2 — SCRIBE (parallel): generate all meanings + art prompts behind a LOADER
438
+ # (the deck view is withheld until the lore is fully written, per the new design).
439
+ t_scribe0 = time.time()
440
+
441
+ def scribe_tick(n):
442
+ pct = int(100 * n / NW)
443
+ bar = f"<div class='gen-bar'><div style='width:{pct}%'></div></div>"
444
+ return stay("<div class='gen-load'><div class='gen-spin'></div>"
445
+ "<h2>✦ Writing the lore</h2><p>the scribe inks the 22 cards…</p>"
446
+ f"<p class='gen-sub'>{n}/{NW} batches</p>{bar}</div>")
447
+ yield from _run_parallel(chunks, fn=lambda i, ch: _gpu_scribe(deck, ch),
448
+ on_done=lambda res, ch: merge_fields(res, ch, _SCRIBE_OUT),
449
+ tick=scribe_tick)
450
+ t_scribe = time.time() - t_scribe0
451
+
452
+ # Lore is done → OPEN THE REAL DECK VIEW now (names + full meanings); the art then
453
+ # lazy-loads card-by-card (the iframe polls art.json, which we rebuild from disk).
454
+ _write_art_from_disk(deck, done=False)
455
+ art_url = url_for(os.path.relpath(_status_paths(deck)[0], ROOT))
456
+ yield ("", deck, deck,
457
+ deck_iframe(deck, _next_nonce(), live=True, art_url=art_url, lore_url=""),
458
+ gr.update(interactive=False), *show("view"), READ_BUSY, NOTE_BUSY)
459
+
460
+ # PHASE 3 — PAINT (parallel): each tick we rebuild art.json from whatever card
461
+ # images exist on disk, so cards fill in INDIVIDUALLY as their files land (lazy),
462
+ # not in chunk-batches.
463
+ def merge_paint(res, ch):
464
+ merge_fields(res, ch, _PAINT_OUT)
465
+ for bk in ("back_path", "back_disp"):
466
+ if (res or {}).get(bk):
467
+ deck[bk] = res[bk]
468
+
469
+ def paint_tick(n):
470
+ _write_art_from_disk(deck, done=False)
471
+ return NOOP
472
+ t_paint0 = time.time()
473
+ yield from _run_parallel(chunks, fn=lambda i, ch: _gpu_paint(deck, ch, i == 0),
474
+ on_done=merge_paint, tick=paint_tick)
475
+ _reconcile_from_disk(deck) # rescue any cards a failed chunk left unmerged
476
+ _write_art_from_disk(deck, done=True)
477
+ t_paint = time.time() - t_paint0
478
+
479
+ from arcana.timing import record as _rec
480
+ _rec("conjure_total", t_map + t_scribe + t_paint,
481
+ {"map": round(t_map), "scribe": round(t_scribe), "paint": round(t_paint)})
482
+
483
+ timing_note = (f"⏱ mapping {t_map:.0f}s · scribe {t_scribe:.0f}s (×{NW}) · "
484
+ f"painting {t_paint:.0f}s (×{NW}) · total {t_map + t_scribe + t_paint:.0f}s")
485
+ yield ("", deck, deck, deck_iframe(deck, _next_nonce()),
486
+ gr.update(value=zip_deck(deck), interactive=True), *show("view"),
487
+ READ_DONE, timing_note)
488
+
489
+
490
+ _SCRIBE_OUT = ("essence", "upright_meaning", "reversed_meaning", "art_prompt")
491
+ _PAINT_OUT = ("art_path", "disp_path", "full_path", "seed")
492
+ # Fan-out width. ZeroGPU grants ~4-5 concurrent slots (fluctuating) for this account,
493
+ # so 4 fits in one wave with minimal model-load overhead; higher just queues into extra
494
+ # waves. Env-overridable for experiments.
495
+ PARALLEL_GPUS = int(os.environ.get("PARALLEL_GPUS", "4"))
496
+
497
+
498
+ def _run_parallel(chunks, fn, on_done, tick):
499
+ """Run fn(i, chunk) for each chunk on its own thread → concurrent @spaces.GPU
500
+ calls. As each finishes, on_done(result, chunk) merges it; each loop yields
501
+ tick(n_done) to refresh the UI / status while the GPUs work (keeps the Gradio
502
+ stream alive). Chunk errors are LOGGED (not swallowed) so a failed chunk is
503
+ visible — the caller reconciles from disk afterwards so its cards aren't lost."""
504
+ import concurrent.futures as _cf
505
+ with _cf.ThreadPoolExecutor(max_workers=len(chunks)) as ex:
506
+ futs = {ex.submit(fn, i, ch): ch for i, ch in enumerate(chunks)}
507
+ pending, n = set(futs), 0
508
+ while pending:
509
+ for f in [x for x in pending if x.done()]:
510
+ pending.discard(f)
511
+ try:
512
+ on_done(f.result(), futs[f])
513
+ except Exception as e:
514
+ print(f"[CHUNK_ERR] cards {futs[f]}: {type(e).__name__}: {e}", flush=True)
515
+ n += 1
516
+ yield tick(n)
517
+ time.sleep(0.4)
518
+
519
+
520
+ def _reconcile_from_disk(deck: dict) -> dict:
521
+ """Fill any card's image paths from the files that ACTUALLY exist on disk. Guards
522
+ against a paint chunk that errored after saving files but before its result merged
523
+ — without this, such cards stay blank (shimmering) in the final deck view."""
524
+ dd = deck_dir(deck["deck_id"])
525
+ thumbs = os.path.join(dd, "thumbs")
526
+ for c in deck["cards"]:
527
+ base = f"{c['arcana_number']:02d}_{slugify(c['concept'])}"
528
+ for key, path in (("art_path", os.path.join(dd, base + ".png")),
529
+ ("disp_path", os.path.join(thumbs, base + ".webp")),
530
+ ("full_path", os.path.join(thumbs, base + ".full.webp"))):
531
+ if not c.get(key) and os.path.exists(path):
532
+ c[key] = os.path.relpath(path, ROOT)
533
+ for key, path in (("back_path", os.path.join(dd, "back.png")),
534
+ ("back_disp", os.path.join(thumbs, "back.webp"))):
535
+ if not deck.get(key) and os.path.exists(path):
536
+ deck[key] = os.path.relpath(path, ROOT)
537
+ return deck
538
+
539
+
540
+ def _write_art_from_disk(deck: dict, done: bool) -> None:
541
+ """Rebuild art.json from the card images that ACTUALLY exist on disk — so the
542
+ deck view fills in card-by-card (lazy) as each file lands, regardless of which
543
+ parallel chunk produced it. Disp path is deterministic from arcana#+concept."""
544
+ thumbs = os.path.join(deck_dir(deck["deck_id"]), "thumbs")
545
+ cards = {}
546
+ for c in deck["cards"]:
547
+ p = os.path.join(thumbs, f"{c['arcana_number']:02d}_{slugify(c['concept'])}.webp")
548
+ if os.path.exists(p):
549
+ cards[c["arcana_number"]] = url_for(os.path.relpath(p, ROOT))
550
+ bp = os.path.join(thumbs, "back.webp")
551
+ back = url_for(os.path.relpath(bp, ROOT)) if os.path.exists(bp) else ""
552
+ _write_json_atomic(_status_paths(deck)[0], {"cards": cards, "back": back, "done": done})
553
+
554
+
555
+ def _loader(title: str, msg: str, sub: str) -> str:
556
+ return ("<div class='gen-load'><div class='gen-spin'></div>"
557
+ f"<h2>{title}</h2><p>{html.escape(msg)}</p>"
558
+ + (f"<p class='gen-sub'>{sub}</p>" if sub else "") + "</div>")
559
+
560
+
561
+ def _conjure_grid(deck: dict, painted: dict, title: str, sub: str,
562
+ show_desc: bool, frac: float | None = None) -> str:
563
+ """Progressive deck-view grid: every card shows its name immediately, its
564
+ description once the loremaster has written it, and its art the moment FLUX
565
+ finishes painting it (a shimmer placeholder until then)."""
566
+ cells = []
567
+ for c in deck["cards"]:
568
+ num = c["arcana_number"]
569
+ name = html.escape(c.get("concept", ""))
570
+ arc = html.escape(c.get("arcana_name", ""))
571
+ url = painted.get(num)
572
+ art = (f"<img src='{url}' alt='{name}' loading='lazy'/>" if url
573
+ else "<div class='cj-shim'></div>")
574
+ desc = ""
575
+ if show_desc:
576
+ d = c.get("essence") or c.get("upright_meaning", "")
577
+ if d:
578
+ desc = f"<p class='cj-desc'>{html.escape(d[:160])}</p>"
579
+ cells.append(
580
+ f"<div class='cj-card{' cj-lit' if url else ''}'>"
581
+ f"<div class='cj-art'>{art}</div>"
582
+ f"<div class='cj-meta'><span class='cj-arc'>{arc}</span>"
583
+ f"<span class='cj-name'>{name}</span>{desc}</div></div>")
584
+ bar = (f"<div class='gen-bar'><div style='width:{int((frac or 0) * 100)}%'></div></div>"
585
+ if frac is not None else "")
586
+ return ("<div class='cj-wrap'><div class='cj-head'>"
587
+ f"<div class='gen-spin'></div><h2>{title}</h2>"
588
+ f"<p class='gen-sub'>{sub}</p>{bar}</div>"
589
+ f"<div class='cj-grid'>{''.join(cells)}</div></div>")
590
 
591
 
592
  def _threaded_progress(work):
 
613
  yield ("done", st["result"], 1.0)
614
 
615
 
616
+ @GPU(duration=400)
617
  def _gpu_map(theme, style, custom):
618
+ """Phase 1 (own GPU call): the 30B designer mapping ONLY theme 22 named cards
619
+ with justifications, draft meanings, and art briefs. Returns fast enough to jump
620
+ straight to the deck view; the loremaster + FLUX then run in parallel. 400s covers
621
+ first-call warm-up + the single designer pass."""
622
+ from arcana.build import map_phase
623
  yield from _threaded_progress(
624
+ lambda emit: map_phase(theme, visual_style=style, custom_style=custom, progress=emit))
625
+
626
+
627
+ @GPU(duration=240)
628
+ def _gpu_scribe(deck, subset=None):
629
+ """Generative scribe (one parallel chunk): invent meaning + art_prompt for the
630
+ `subset` of arcana numbers (default all 22). Returns the deck with that subset
631
+ filled; the MAIN process merges chunks + writes lore.json (avoids 4 workers
632
+ clobbering one status file)."""
633
+ from arcana.build import scribe_phase
634
+ scribe_phase(deck, subset=subset)
635
+ return deck
636
+
637
+
638
+ @GPU(duration=200)
639
+ def _gpu_paint(deck, subset, paint_back=False):
640
+ """Paint one parallel chunk of cards (+ the deck-back if paint_back). Saves images
641
+ to the shared disk and returns the deck with that subset's paths filled; the MAIN
642
+ process merges + writes art.json."""
643
+ from arcana.build import paint_subset
644
+ paint_subset(deck, subset, paint_back=paint_back)
645
+ return deck
646
+
647
+
648
+ @GPU(duration=400)
649
+ def _gpu_mapdiag(theme):
650
+ """FIX TEST v2: force the exact schema with a JSON-schema GRAMMAR (prevents tool
651
+ calls, malformed JSON, and field drift all at once) on the STRIPPED mapping
652
+ (arcana_number+concept+justification only). 3 runs to gauge reliability."""
653
+ import json, time as _t
654
+ from arcana.gpu_models import _get_llama, _DEF_30B
655
+ theme = theme or "Greek mythology"
656
+ llm = _get_llama(_DEF_30B[0], _DEF_30B[1], 16384)
657
+ SCHEMA = {
658
+ "type": "object", "required": ["cards"],
659
+ "properties": {"cards": {"type": "array", "items": {
660
+ "type": "object", "required": ["arcana_number", "concept", "justification"],
661
+ "properties": {
662
+ "arcana_number": {"type": "integer"},
663
+ "concept": {"type": "string"},
664
+ "justification": {"type": "string"}}}}}}
665
+ sysmsg = (
666
+ "Map the 22 Major Arcana onto concepts from the given theme. For EVERY arcana 0-21, "
667
+ "pick ONE DISTINCT in-theme concept (a short proper noun/name, NOT a sentence) whose "
668
+ "meaning rhymes with that archetype, plus a one-line justification. Output JSON.")
669
+ usr = f"Theme: {theme}\nMap all 22 arcana (0 through 21). Each concept must be unique."
670
+ blocks = []
671
+ for run in range(3):
672
+ t = _t.time()
673
+ try:
674
+ out = llm.create_chat_completion(
675
+ messages=[{"role": "system", "content": sysmsg},
676
+ {"role": "user", "content": usr}],
677
+ response_format={"type": "json_object", "schema": SCHEMA},
678
+ max_tokens=5000, temperature=0.4)
679
+ raw = (out["choices"][0]["message"]["content"] or "")
680
+ except Exception as e:
681
+ blocks.append(f"run{run+1}: GEN ERROR {type(e).__name__}: {str(e)[:90]}"); continue
682
+ dt = _t.time() - t
683
+ info = [f"run{run+1}: {dt:.0f}s len={len(raw)}"]
684
+ try:
685
+ cards = json.loads(raw).get("cards", [])
686
+ cons = [(c.get("concept") or "").strip() for c in cards]
687
+ nums = sorted(c.get("arcana_number") for c in cards if isinstance(c.get("arcana_number"), int))
688
+ info.append(f"PARSED ✓ cards={len(cards)} nums_ok={nums==list(range(22))} "
689
+ f"empty={sum(1 for x in cons if not x)} dup={len(cons)-len(set(x.lower() for x in cons))}")
690
+ info.append("concepts: " + " | ".join(cons))
691
+ except Exception as e:
692
+ info.append(f"PARSE FAIL {type(e).__name__}: {str(e)[:90]} | TAIL " + raw[-180:].replace("\n", "\\n"))
693
+ blocks.append("\n".join(info))
694
+ return "\n\n====\n\n".join(blocks)
695
+
696
+
697
+ @GPU(duration=40)
698
+ def _gpu_ping(tag, hold):
699
+ """A trivial GPU task that holds its allocation for `hold` seconds — used to test
700
+ whether ZeroGPU grants ONE user TWO concurrent GPU slots (the #3 parallel
701
+ text+image design hinges on this)."""
702
+ import time as _t
703
+ t0 = _t.time()
704
+ try:
705
+ import torch
706
+ x = torch.ones(1, device="cuda"); _ = float((x + 1).sum().item())
707
+ except Exception as e:
708
+ return f"{tag}|ERR|{type(e).__name__}:{str(e)[:60]}"
709
+ _t.sleep(hold)
710
+ return f"{tag}|{t0:.3f}|{_t.time():.3f}"
711
+
712
+
713
+ def _concurrency_probe():
714
+ """Fire K @GPU pings at once and report the MAX number that ran simultaneously —
715
+ i.e. how many concurrent ZeroGPU slots this account actually gets. Decides how far
716
+ we can parallelize the meaning/art agent and the painting."""
717
+ import time as _t, concurrent.futures as _cf
718
+ HOLD, K = 7.0, 4
719
+ t_start = _t.time()
720
+ with _cf.ThreadPoolExecutor(max_workers=K) as ex:
721
+ futs = [ex.submit(_gpu_ping, str(i), HOLD) for i in range(K)]
722
+ res = []
723
+ for f in futs:
724
+ try:
725
+ res.append(f.result())
726
+ except Exception as e:
727
+ res.append(f"X|ERR|{type(e).__name__}:{str(e)[:50]}")
728
+ total = _t.time() - t_start
729
+ ivs, fails = [], 0
730
+ for r in res:
731
+ p = r.split("|")
732
+ if len(p) >= 3 and p[1] == "ERR":
733
+ fails += 1
734
+ else:
735
+ try:
736
+ ivs.append((float(p[1]), float(p[2])))
737
+ except Exception:
738
+ fails += 1
739
+ # sweep line: max overlapping intervals
740
+ ev = []
741
+ for s, e in ivs:
742
+ ev += [(s, 1), (e, -1)]
743
+ ev.sort()
744
+ cur = mx = 0
745
+ for _, d in ev:
746
+ cur += d; mx = max(mx, cur)
747
+ return (f"max_concurrent_gpus={mx} of {K} requested (failed/rejected={fails}) "
748
+ f"total={total:.1f}s\nintervals={[(round(s, 1), round(e, 1)) for s, e in ivs]}")
749
+
750
+
751
+ @GPU(duration=400)
752
+ def _gpu_scribetest(theme):
753
+ """New pipeline check: designer (mapping+justification only) → generative scribe
754
+ (meaning + art). Reports timings, the mapping, and the scribe's meanings + ART
755
+ prompts for the religion-prone archetypes (Hierophant/Death/Temperance/Judgement)
756
+ — the regression risk from re-splitting meaning/art into a separate agent."""
757
+ import time as _t
758
+ from arcana import designer
759
+ from arcana.build import map_phase, scribe_phase
760
+ theme = theme or "Greek mythology"
761
+ t = _t.time(); deck = map_phase(theme); tmap = _t.time() - t
762
+ t = _t.time(); scribe_phase(deck); tsc = _t.time() - t
763
+ by = {c["arcana_number"]: c for c in deck["cards"]}
764
+ out = [f"THEME: {theme} | map={tmap:.0f}s (attempts={getattr(designer,'LAST_ATTEMPTS','?')})"
765
+ f" | scribe={tsc:.0f}s",
766
+ "\nCONCEPTS: " + " | ".join(c["concept"] for c in deck["cards"])]
767
+ for n in (5, 13, 14, 20):
768
+ c = by.get(n, {})
769
+ out.append(f"\n[{n}] {c.get('concept','?')}")
770
+ out.append(f" essence: {c.get('essence','')}")
771
+ out.append(f" upright: {c.get('upright_meaning','')[:220]}")
772
+ out.append(f" ART : {c.get('art_prompt','')[:260]}")
773
+ return "\n".join(out)
774
+
775
+
776
+ @GPU(duration=400)
777
+ def _gpu_loretest(theme):
778
+ """Side-by-side: the DESIGNER's draft meanings vs the LOREMASTER's refined ones,
779
+ for a spread of representative cards — to judge whether the loremaster pass earns
780
+ its keep or whether the designer drafts are already better/more detailed."""
781
+ from arcana.designer import design_deck
782
+ from arcana.loremaster import refine_deck
783
+ theme = theme or "Greek mythology"
784
+ deck = design_deck(theme)
785
+ draft = {c["arcana_number"]: dict(concept=c["concept"],
786
+ up=c["upright_meaning"], rev=c["reversed_meaning"])
787
+ for c in deck["cards"]}
788
+ refine_deck(deck) # mutates meanings in place
789
+ fin = {c["arcana_number"]: dict(up=c["upright_meaning"], rev=c["reversed_meaning"],
790
+ ess=c.get("essence", "")) for c in deck["cards"]}
791
+ out = [f"THEME: {theme}\n(draft = designer • refined = loremaster)\n"]
792
+ for n in (0, 6, 10, 13, 16, 21):
793
+ d, f = draft.get(n, {}), fin.get(n, {})
794
+ out.append(f"\n========== [{n}] {d.get('concept','?')} ==========")
795
+ out.append(f"UPRIGHT draft : {d.get('up','')}")
796
+ out.append(f"UPRIGHT refined: {f.get('up','')}")
797
+ out.append(f"REVERSED draft : {d.get('rev','')}")
798
+ out.append(f"REVERSED refined: {f.get('rev','')}")
799
+ out.append(f"essence (loremaster only): {f.get('ess','')}")
800
+ # rough length comparison across ALL 22 cards
801
+ da = sum(len(v["up"]) for v in draft.values()) / 22
802
+ fa = sum(len(v["up"]) for v in fin.values()) / 22
803
+ out.append(f"\n--- avg upright length: draft={da:.0f} chars · refined={fa:.0f} chars ---")
804
+ return "\n".join(out)
805
 
806
 
807
  @GPU(duration=600)
808
  def _gpu_maptest(theme):
809
+ """Quota-cheap mapping-only probe (no images): breaks the LLM phase into
810
+ warm-up vs designer (with retry count) vs loremaster, so we can see EXACTLY
811
+ where the minutes go."""
812
  import time as _t
813
+ from arcana import designer
814
+ from arcana.designer import design_deck
815
+ from arcana.loremaster import refine_deck
816
+ from arcana.gpu_models import llama_map
817
+ theme = theme or "Greek mythology"
818
+ L = []
819
+ # 1) warm up the 8B mapper in isolation (load + first-inference JIT)
820
+ t = _t.time()
821
+ try:
822
+ llama_map("Reply with one tiny JSON object.", "Output {\"ok\": 1}", max_tokens=40)
823
+ except Exception as e:
824
+ L.append(f"warmup ERR {type(e).__name__}")
825
+ L.append(f"map-model warmup(load+1st gen)={_t.time()-t:.0f}s")
826
+ # 2) designer mapping (now warm) — note attempt count
827
+ t = _t.time()
828
+ try:
829
+ deck = design_deck(theme)
830
+ except Exception as e:
831
+ return " | ".join(L) + f" | designer FAILED {type(e).__name__}: {str(e)[:120]}"
832
+ L.append(f"designer={_t.time()-t:.0f}s attempts={getattr(designer,'LAST_ATTEMPTS','?')}")
833
+ # 3) loremaster pass
834
+ t = _t.time()
835
  try:
836
+ deck = refine_deck(deck)
837
  except Exception as e:
838
+ L.append(f"loremaster FAILED {type(e).__name__}")
839
+ else:
840
+ L.append(f"loremaster={_t.time()-t:.0f}s")
841
+ cs = [c.get("concept", "") for c in deck.get("cards", [])]
842
+ L.append(f"cards={len(cs)} empties={sum(1 for x in cs if not x.strip())} dups={len(cs)-len(set(cs))}")
843
+ out = " | ".join(L)
844
+ out += "\n\nCONCEPTS: " + " | ".join(cs)
845
+ by_n = {c.get("arcana_number"): c for c in deck.get("cards", [])}
846
+ sample = by_n.get(13)
847
+ if sample:
848
+ out += (f"\n\nSAMPLE (Death→{sample.get('concept')}): "
849
+ f"{sample.get('upright_meaning','')[:240]}")
850
+ # art prompts for the religion-prone archetypes (Hierophant/Temperance/Judgement)
851
+ for n in (5, 14, 20):
852
+ c = by_n.get(n)
853
+ if c:
854
+ out += (f"\n\nART[{n} {c.get('arcana_name')}→{c.get('concept')}]: "
855
+ f"{c.get('art_prompt','')[:300]}")
856
+ return out
857
 
858
 
859
  @GPU(duration=300)
860
  def _gpu_images(deck):
861
+ """Phase 2 (own GPU call): paint back + 22 cards with FLUX (no LLM resident).
862
+ Streams each finished card's display URL as it's painted so the deck-view grid
863
+ can fill in card-by-card. paint_phase mutates `deck` in this worker; we scan it
864
+ each tick and ship the ready cards' URLs (the saved files are served by the main
865
+ process). Yields ('paint', {msg, cards:[{num,url}], back}, frac) then ('done', deck)."""
866
  from arcana.build import paint_phase
867
+ st = {"msg": "…", "frac": 0.0, "done": False, "result": None, "err": None}
868
+
869
+ def run():
870
+ try:
871
+ st["result"] = paint_phase(deck, lambda m, f: st.update(msg=m, frac=f))
872
+ except Exception as e:
873
+ st["err"] = e
874
+ finally:
875
+ st["done"] = True
876
+
877
+ def snapshot():
878
+ cards = [{"num": c["arcana_number"], "url": disp_url(c)}
879
+ for c in deck["cards"] if c.get("disp_path")]
880
+ return {"msg": st["msg"], "cards": cards,
881
+ "back": url_for(deck.get("back_disp") or deck.get("back_path"))}
882
+
883
+ threading.Thread(target=run, daemon=True).start()
884
+ while not st["done"]:
885
+ _write_art_status(deck, done=False) # live status the deck view polls
886
+ yield ("paint", snapshot(), min(st["frac"], 0.99))
887
+ time.sleep(0.4)
888
+ if st["err"] or st["result"] is None:
889
+ _write_art_status(deck, done=True) # stop the poller even on failure
890
+ why = f"{type(st['err']).__name__}: {st['err']}" if st["err"] else "no result"
891
+ yield ("error", why[:300], 0.0); return
892
+ _write_art_status(deck, done=True)
893
+ yield ("paint", snapshot(), 1.0)
894
+ yield ("done", st["result"], 1.0)
895
 
896
 
897
  def toggle_custom(style):
 
906
 
907
 
908
  def open_picked(deck_id, mode):
909
+ """Outputs: deck_state, vd_deck, vd_html, download_btn, *pages, read_btn, view_note.
910
+ Opening a (complete) deck always re-enables Read/Download — in case a prior
911
+ conjure was interrupted mid-paint and left them greyed out."""
912
+ read_on = gr.update(interactive=True, value="✦ Read this deck")
913
  deck = load_deck(deck_id) if deck_id else None
914
  if not deck:
915
+ return (gr.update(), gr.update(), gr.update(), gr.update(), *show("pick"),
916
+ gr.update(), gr.update())
917
  if mode == "view":
918
  return (gr.update(), deck, deck_iframe(deck, _next_nonce()),
919
+ gr.update(value=zip_deck(deck), interactive=True), *show("view"),
920
+ read_on, "")
921
  # read mode → straight to the reading room
922
+ return (deck, gr.update(), gr.update(), gr.update(), *show("reading"),
923
+ read_on, "")
924
 
925
 
926
  def read_this_deck(deck):
 
1032
  border-radius:6px; overflow:hidden; }
1033
  .gen-bar > div { height:100%; background:linear-gradient(90deg,#9a7b2e,#d8b15a);
1034
  transition:width .35s ease; }
1035
+ /* progressive conjure grid (deck view fills in as names/desc/art arrive) */
1036
+ .cj-wrap { padding:14px 8px 26px; }
1037
+ .cj-head { text-align:center; margin-bottom:18px; }
1038
+ .cj-head h2 { font-family:'Cinzel Decorative',serif; color:#d8b15a !important;
1039
+ font-size:2.3rem !important; margin:12px 0 6px; }
1040
+ .cj-head .gen-sub { color:#9a8f70 !important; font-size:1.45rem !important;
1041
+ font-style:italic; }
1042
+ .cj-head .gen-bar { max-width:440px; }
1043
+ .cj-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(150px,1fr));
1044
+ gap:14px; max-width:1180px; margin:0 auto; }
1045
+ .cj-card { background:rgba(20,16,30,.55); border:1px solid rgba(216,177,90,.20);
1046
+ border-radius:10px; overflow:hidden; display:flex; flex-direction:column;
1047
+ opacity:.55; transition:opacity .5s ease, border-color .5s ease; }
1048
+ .cj-card.cj-lit { opacity:1; border-color:rgba(216,177,90,.5); }
1049
+ .cj-art { aspect-ratio:3/5; position:relative; background:rgba(216,177,90,.04); }
1050
+ .cj-art img { width:100%; height:100%; object-fit:cover; display:block;
1051
+ animation:cjfade .6s ease; }
1052
+ @keyframes cjfade { from { opacity:0; } to { opacity:1; } }
1053
+ .cj-shim { position:absolute; inset:0; background:linear-gradient(100deg,
1054
+ rgba(216,177,90,.05) 30%, rgba(216,177,90,.16) 50%, rgba(216,177,90,.05) 70%);
1055
+ background-size:220% 100%; animation:cjshim 1.4s linear infinite; }
1056
+ @keyframes cjshim { to { background-position:-220% 0; } }
1057
+ .cj-meta { padding:8px 10px 11px; }
1058
+ .cj-arc { display:block; color:#9a8f70 !important; font-size:1.0rem !important;
1059
+ letter-spacing:.04em; text-transform:uppercase; }
1060
+ .cj-name { display:block; font-family:'Cinzel Decorative',serif;
1061
+ color:#e9d9a8 !important; font-size:1.45rem !important; line-height:1.2; margin-top:2px; }
1062
+ .cj-desc { color:#bcae86 !important; font-size:1.12rem !important; font-style:italic;
1063
+ line-height:1.35 !important; margin:5px 0 0 !important; }
1064
+ /* suppress Gradio's pulsing blue/orange "generating" border-box on components
1065
+ while an event streams (we have our own loaders) */
1066
+ .gradio-container .generating,
1067
+ .gradio-container .block.generating,
1068
+ .gradio-container *.generating {
1069
+ border-color: transparent !important; box-shadow: none !important;
1070
+ animation: none !important; }
1071
+ .gradio-container .generating::before, .gradio-container .generating::after {
1072
+ display: none !important; animation: none !important; }
1073
  #chips { flex-wrap:wrap !important; gap:6px !important; justify-content:center; }
1074
  #chips .secondary { border-radius:999px !important; font-size:1.7rem !important; padding:8px 20px !important; }
1075
  #deck img, #preview img { border-radius:8px; box-shadow:0 6px 20px rgba(0,0,0,.55); }
 
1102
  .syn-load { font-style:italic; color:#cdbf93; font-size:2rem; text-align:center; padding:10px; }
1103
  /* word fade-in (§2) — each word eases in on a staggered delay */
1104
  .fw { opacity:0; display:inline; animation:fadeWord .7s ease forwards; }
1105
+ .fwb { font-weight:700; color:#e9d2a0; } /* Nemotron's **bold** as emphasis */
1106
+ .fwi { font-style:italic; }
1107
  @keyframes fadeWord { from { opacity:0; } to { opacity:1; } }
1108
  /* input contrast — light text on dark fields, never yellow-on-white (§4) */
1109
  .gradio-container textarea, .gradio-container input, .gradio-container select,
 
1118
  background: rgba(243,230,196,.85) !important; border:1px solid var(--gold) !important;
1119
  border-radius:4px !important; appearance:auto !important; -webkit-appearance:checkbox !important;
1120
  opacity:1 !important; cursor:pointer; }
1121
+ /* "Allow reversals": nudge right + tighter wrap between the two words */
1122
+ #rev-box { margin-left:10px !important; }
1123
+ #rev-box label, #rev-box label span { line-height:1.12 !important; }
1124
  /* dropdown popup + options — never yellow-on-white (§5) */
1125
  .gradio-container ul[role="listbox"], .gradio-container .options, .gradio-container .option,
1126
  .gradio-container li[role="option"], .gradio-container li.item, .gradio-container [class*="options"] {
 
1245
  placeholder="Name anything to build a tarot deck from — a field, a world, a "
1246
  "feeling… e.g. Greek mythology, the deep sea, jazz, office life",
1247
  show_label=False, value="", max_lines=1)
1248
+ gr.Markdown("*…or tap an example:*", elem_classes=["status"])
 
1249
  with gr.Row(elem_id="chips"):
1250
  chips = [gr.Button(t, size="sm", elem_classes=["secondary"]) for t in EXAMPLE_THEMES]
1251
+ # full-width dropdown; the custom-style box unfurls BELOW it (stacked),
1252
+ # and only when "Custom…" is chosen — hidden by default so it never shows
1253
+ # spuriously (e.g. during a conjure).
1254
+ style = gr.Dropdown(STYLE_CHOICES, value="rider-waite-smith",
1255
+ label="Visual style")
1256
+ with gr.Column(visible=False) as custom_box:
1257
+ custom = gr.Textbox(label="Describe your style",
1258
+ placeholder=CUSTOM_PLACEHOLDER, max_lines=1)
1259
  conjure = gr.Button("✦ Conjure the deck", elem_classes=["big-btn"])
1260
  gen_status = gr.Markdown("", elem_classes=["status"])
1261
  gen_back = gr.Button("↩ Back", scale=1, elem_classes=["secondary"])
 
1279
  spread = gr.Dropdown(SPREAD_CHOICES, value="three", label="Spread",
1280
  scale=3, min_width=260)
1281
  reversals = gr.Checkbox(value=True, label="Allow reversals",
1282
+ scale=2, min_width=220, elem_id="rev-box")
1283
  draw = gr.Button("✦ Draw the cards", elem_classes=["big-btn"])
1284
  stage = gr.HTML("<div class='spread-empty'>Pose your question, then draw.</div>",
1285
  elem_id="stage")
1286
+ next_btn = gr.Button("Next card ▸", visible=False, elem_classes=["big-btn"],
1287
+ elem_id="next-btn")
1288
+ stage_done = gr.HTML("", elem_id="stage-done") # read-history, below Next
1289
  with gr.Row():
1290
  save_in_read = gr.Button("✦ Save this deck", scale=1, elem_classes=["secondary"])
1291
  read_back = gr.Button("↩ Back to start", scale=1, elem_classes=["secondary"])
 
1324
  style.change(toggle_custom, style, custom_box)
1325
  # example theme chips — one click sets the theme AND conjures it from
1326
  # scratch (the examples ARE live demos: every card is freshly generated).
1327
+ # example chips just FILL the theme box (press Conjure to start) — they no
1328
+ # longer auto-conjure
1329
  for chip, t in zip(chips, EXAMPLE_THEMES):
1330
+ chip.click(lambda t=t: (t, f"“{t}” — press ✦ Conjure to begin."),
1331
+ None, [theme, gen_status])
 
1332
 
1333
  # generate → opens the new deck in the Deck-View/Overhead browser (pg_view)
1334
  conjure.click(do_conjure, [theme, style, custom],
1335
+ [gen_status, deck_state, vd_deck, vd_html, download_btn, *pages,
1336
+ read_btn, view_note])
1337
 
1338
  # pick → open
1339
  pick_refresh.click(lambda: gr.update(choices=deck_choices()), None, pick_dd)
1340
  pick_open.click(open_picked, [pick_dd, pick_mode],
1341
+ [deck_state, vd_deck, vd_html, download_btn, *pages,
1342
+ read_btn, view_note])
1343
  read_btn.click(read_this_deck, vd_deck, [deck_state, *pages])
1344
 
1345
  # reading
1346
  draw.click(do_draw, [deck_state, question, spread, reversals],
1347
+ [stage, stage_done, reading_sess, next_btn])
1348
+ next_btn.click(do_next, reading_sess, [stage, stage_done, reading_sess, next_btn])
1349
  save_in_read.click(save_current, deck_state, save_note)
1350
 
1351
  # pages are created visible=True so gallery components mount cleanly
 
1385
  _mt_theme = gr.Textbox("Greek mythology", visible=False)
1386
  _mt_out = gr.Textbox(visible=False)
1387
  gr.Button(visible=False).click(_gpu_maptest, _mt_theme, _mt_out, api_name="maptest")
1388
+ _md_theme = gr.Textbox("Greek mythology", visible=False)
1389
+ _md_out = gr.Textbox(visible=False)
1390
+ gr.Button(visible=False).click(_gpu_mapdiag, _md_theme, _md_out, api_name="mapdiag")
1391
+ _cc_out = gr.Textbox(visible=False)
1392
+ gr.Button(visible=False).click(_concurrency_probe, None, _cc_out, api_name="concurrency")
1393
+
1394
+ def _imgdiag():
1395
+ """Run the FLUX CPU preload and surface any error (the startup thread
1396
+ swallows it). CPU-only construct + download; no GPU needed."""
1397
+ import time as _t, traceback
1398
+ from arcana import gpu_models as _gm
1399
+ t = _t.time()
1400
+ try:
1401
+ p = _gm.preload_pipe()
1402
+ return f"preload OK in {_t.time()-t:.0f}s: {type(p).__name__} (model={_gm.IMAGE_MODEL})"
1403
+ except Exception as e:
1404
+ return (f"preload FAILED after {_t.time()-t:.0f}s (model={_gm.IMAGE_MODEL}): "
1405
+ f"{type(e).__name__}: {e}\n\n" + traceback.format_exc()[-1400:])
1406
+ _id_out = gr.Textbox(visible=False)
1407
+ gr.Button(visible=False).click(_imgdiag, None, _id_out, api_name="imgdiag")
1408
+
1409
+ def _timings():
1410
+ """Recent live-pipeline stage timings (most recent last). Reads the
1411
+ shared JSONL the @spaces.GPU workers append to, so it reflects NATURAL
1412
+ runs, not just diagnostics. Also grep the Space logs for '[TIMING]'."""
1413
+ from arcana.timing import recent
1414
+ rows = recent(50)
1415
+ if not rows:
1416
+ return "no timings recorded yet — conjure or read a deck first."
1417
+ out = []
1418
+ for r in rows:
1419
+ extra = " ".join(f"{k}={v}" for k, v in r.items()
1420
+ if k not in ("ts", "stage", "secs"))
1421
+ out.append(f"{r.get('stage',''):<22} {r.get('secs',0):>7.1f}s {extra}")
1422
+ return "\n".join(out)
1423
+ _tm_out = gr.Textbox(visible=False)
1424
+ gr.Button(visible=False).click(_timings, None, _tm_out, api_name="timings")
1425
+ _lt_theme = gr.Textbox("Greek mythology", visible=False)
1426
+ _lt_out = gr.Textbox(visible=False)
1427
+ gr.Button(visible=False).click(_gpu_loretest, _lt_theme, _lt_out, api_name="loretest")
1428
+ _st_theme = gr.Textbox("Greek mythology", visible=False)
1429
+ _st_out = gr.Textbox(visible=False)
1430
+ gr.Button(visible=False).click(_gpu_scribetest, _st_theme, _st_out, api_name="scribetest")
1431
 
1432
  return demo
1433
 
arcana/build.py CHANGED
@@ -16,13 +16,15 @@ import os
16
  import time
17
  from typing import Callable
18
 
 
19
  from .archetypes import ROMAN_BY_NUMBER
20
  from .compositor import compose_card
21
  from .designer import design_deck
22
  from .imagegen import get_imagegen
23
  from .llm import get_llm
24
- from .loremaster import refine_deck
25
  from .styles import resolve_style
 
26
 
27
  ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
28
  DECKS_DIR = os.path.join(ROOT, "decks")
@@ -101,13 +103,13 @@ def _back_prompt(style_suffix: str) -> str:
101
 
102
 
103
  # ------------------------------------------------------------------ build
104
- def design_phase(theme: str, visual_style: str = "rider-waite-smith",
105
- custom_style: str | None = None,
106
- progress: Progress | None = None) -> dict:
107
- """LLM phase ONLY (the 30B mapping + loremaster). Returns a deck dict with
108
- concepts + meanings but NO images yet. Split out so it can run in its own
109
- @spaces.GPU call the 30B and the FLUX image model are too big to share the
110
- 48GB GPU, so we never load them together."""
111
  def emit(msg, frac):
112
  if progress:
113
  progress(msg, frac)
@@ -118,16 +120,10 @@ def design_phase(theme: str, visual_style: str = "rider-waite-smith",
118
  os.makedirs(deck_dir(deck_id), exist_ok=True)
119
  seed_base = _seed_base(theme, style_id)
120
 
121
- llm = get_llm()
122
  emit("Consulting the deck designer…", 0.05)
123
- deck = design_deck(theme, llm=llm)
124
- if os.environ.get("SKIP_LOREMASTER", "").lower() not in ("1", "true", "yes"):
125
- emit("The loremaster reinterprets the cards…", 0.10)
126
- try:
127
- deck = refine_deck(deck, llm=llm)
128
- except Exception:
129
- pass # keep the designer's draft meanings if refinement hiccups
130
-
131
  deck.update({
132
  "deck_id": deck_id, "theme": theme, "visual_style": style_id,
133
  "style_suffix": style_suffix, "seed_base": seed_base,
@@ -135,33 +131,63 @@ def design_phase(theme: str, visual_style: str = "rider-waite-smith",
135
  return deck
136
 
137
 
138
- def paint_phase(deck: dict, progress: Progress | None = None) -> dict:
139
- """Image phase ONLY (FLUX). Paints the back + 22 cards into the deck dir and
140
- fills in the image paths. Runs in its own @spaces.GPU call (see design_phase)."""
 
 
 
141
  def emit(msg, frac):
142
  if progress:
143
  progress(msg, frac)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
144
 
 
 
 
 
 
 
 
145
  deck_id = deck["deck_id"]
146
  out_dir = deck_dir(deck_id)
147
  os.makedirs(out_dir, exist_ok=True)
148
  seed_base = deck["seed_base"]
149
  ig = get_imagegen(deck_style=deck.get("style_suffix"))
150
 
151
- emit("Pressing the deck-back…", 0.15)
152
- back_path = os.path.join(out_dir, "back.png")
153
- try:
154
- back = ig.generate(_back_prompt(deck.get("style_suffix") or ""), seed=seed_base - 1)
155
- back.save(back_path)
156
- deck["back_path"] = os.path.relpath(back_path, ROOT)
157
- deck["back_disp"] = save_display(back, deck_id, "back")
158
- except Exception:
159
- deck["back_path"] = deck["back_disp"] = None
160
-
161
- n = len(deck["cards"])
162
- for i, c in enumerate(deck["cards"]):
 
 
163
  num = c["arcana_number"]
164
- emit(f"Painting {c['concept']} card {i + 1} of {n}", 0.18 + 0.80 * i / n)
 
165
  seed = seed_base + num
166
  art = ig.generate(c["art_prompt"], seed=seed)
167
  card = compose_card(art, c["concept"], ROMAN_BY_NUMBER[num])
@@ -172,11 +198,19 @@ def paint_phase(deck: dict, progress: Progress | None = None) -> dict:
172
  c["art_path"] = os.path.relpath(path, ROOT)
173
  c["disp_path"] = save_display(card, deck_id, base)
174
  c["full_path"] = save_full(card, deck_id, base)
175
-
176
- emit("The deck is ready.", 1.0)
177
  return deck
178
 
179
 
 
 
 
 
 
 
 
 
180
  def build_deck(theme: str, visual_style: str = "rider-waite-smith",
181
  custom_style: str | None = None,
182
  progress: Progress | None = None) -> dict:
 
16
  import time
17
  from typing import Callable
18
 
19
+ from . import designer as _designer
20
  from .archetypes import ROMAN_BY_NUMBER
21
  from .compositor import compose_card
22
  from .designer import design_deck
23
  from .imagegen import get_imagegen
24
  from .llm import get_llm
25
+ from .loremaster import refine_deck, scribe_deck
26
  from .styles import resolve_style
27
+ from .timing import clock, record
28
 
29
  ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
30
  DECKS_DIR = os.path.join(ROOT, "decks")
 
103
 
104
 
105
  # ------------------------------------------------------------------ build
106
+ def map_phase(theme: str, visual_style: str = "rider-waite-smith",
107
+ custom_style: str | None = None,
108
+ progress: Progress | None = None) -> dict:
109
+ """Mapping ONLY (the 30B designer): theme -> 22 archetype→concept mappings with
110
+ justifications, no meanings/art/images yet. Split from the loremaster so the UI
111
+ can reveal the card NAMES the instant the mapping lands (the loremaster's
112
+ meanings + art briefs follow in the same GPU call, no model reload)."""
113
  def emit(msg, frac):
114
  if progress:
115
  progress(msg, frac)
 
120
  os.makedirs(deck_dir(deck_id), exist_ok=True)
121
  seed_base = _seed_base(theme, style_id)
122
 
 
123
  emit("Consulting the deck designer…", 0.05)
124
+ with clock("mapping") as c:
125
+ deck = design_deck(theme, llm=get_llm())
126
+ c.extra = {"attempts": getattr(_designer, "LAST_ATTEMPTS", "?")}
 
 
 
 
 
127
  deck.update({
128
  "deck_id": deck_id, "theme": theme, "visual_style": style_id,
129
  "style_suffix": style_suffix, "seed_base": seed_base,
 
131
  return deck
132
 
133
 
134
+ def scribe_phase(deck: dict, subset: list[int] | None = None,
135
+ progress: Progress | None = None) -> dict:
136
+ """Scribe: GENERATE meaning + art_prompt for the cards (default all 22; `subset`
137
+ for parallel chunking). MANDATORY — the designer outputs only the mapping, so the
138
+ meanings AND art come solely from here; a failure surfaces (scribe self-repairs
139
+ once). Mutates and returns the deck dict."""
140
  def emit(msg, frac):
141
  if progress:
142
  progress(msg, frac)
143
+ emit("The scribe writes the lore and the art…", 0.10)
144
+ with clock("scribe", {"n": len(subset) if subset is not None else 22}):
145
+ return scribe_deck(deck, subset=subset, llm=get_llm())
146
+
147
+
148
+ # back-compat alias
149
+ def lore_phase(deck: dict, progress: Progress | None = None) -> dict:
150
+ return scribe_phase(deck, progress=progress)
151
+
152
+
153
+ def design_phase(theme: str, visual_style: str = "rider-waite-smith",
154
+ custom_style: str | None = None,
155
+ progress: Progress | None = None) -> dict:
156
+ """LLM phase (mapping + scribe) in one go — for non-streaming callers.
157
+ Returns a deck dict with concepts + meanings + art briefs but NO images yet."""
158
+ deck = map_phase(theme, visual_style, custom_style, progress=progress)
159
+ return scribe_phase(deck, progress=progress)
160
 
161
+
162
+ def paint_subset(deck: dict, nums: list[int], paint_back: bool = False,
163
+ progress: Progress | None = None) -> dict:
164
+ """Paint a SUBSET of the deck's cards (by arcana number), saving each card's PNG +
165
+ display WEBPs and filling its image paths. `paint_back` also presses the deck-back.
166
+ Designed to run in its own @spaces.GPU worker so the 22 cards fan out across GPUs;
167
+ the saved files land on the shared disk for the main process to serve."""
168
  deck_id = deck["deck_id"]
169
  out_dir = deck_dir(deck_id)
170
  os.makedirs(out_dir, exist_ok=True)
171
  seed_base = deck["seed_base"]
172
  ig = get_imagegen(deck_style=deck.get("style_suffix"))
173
 
174
+ if paint_back:
175
+ back_path = os.path.join(out_dir, "back.png")
176
+ try:
177
+ with clock("paint_back"):
178
+ back = ig.generate(_back_prompt(deck.get("style_suffix") or ""), seed=seed_base - 1)
179
+ back.save(back_path)
180
+ deck["back_path"] = os.path.relpath(back_path, ROOT)
181
+ deck["back_disp"] = save_display(back, deck_id, "back")
182
+ except Exception:
183
+ deck["back_path"] = deck["back_disp"] = None
184
+
185
+ nset = set(nums)
186
+ t0 = time.time()
187
+ for c in deck["cards"]:
188
  num = c["arcana_number"]
189
+ if num not in nset:
190
+ continue
191
  seed = seed_base + num
192
  art = ig.generate(c["art_prompt"], seed=seed)
193
  card = compose_card(art, c["concept"], ROMAN_BY_NUMBER[num])
 
198
  c["art_path"] = os.path.relpath(path, ROOT)
199
  c["disp_path"] = save_display(card, deck_id, base)
200
  c["full_path"] = save_full(card, deck_id, base)
201
+ dt = time.time() - t0
202
+ record("paint_chunk", dt, {"n": len(nums), "per_card": round(dt / max(len(nums), 1), 1)})
203
  return deck
204
 
205
 
206
+ def paint_phase(deck: dict, progress: Progress | None = None) -> dict:
207
+ """Image phase (FLUX), whole deck in one call — for non-streaming callers."""
208
+ if progress:
209
+ progress("Painting the deck…", 0.15)
210
+ nums = [c["arcana_number"] for c in deck["cards"]]
211
+ return paint_subset(deck, nums, paint_back=True, progress=progress)
212
+
213
+
214
  def build_deck(theme: str, visual_style: str = "rider-waite-smith",
215
  custom_style: str | None = None,
216
  progress: Progress | None = None) -> dict:
arcana/compositor.py CHANGED
@@ -69,11 +69,13 @@ def compose_card(art: Image.Image, concept: str, roman: str,
69
  card.paste(frame_overlay(w, h), (0, 0), frame_overlay(w, h))
70
  draw = ImageDraw.Draw(card)
71
 
72
- # roman numeral in the top plate
73
- nf = font(40, weight=700)
74
  nw = draw.textlength(roman, font=nf)
75
- ny = (NUM_PLATE[1] + NUM_PLATE[3]) / 2 - (nf.getbbox("X")[3] - nf.getbbox("X")[1]) / 2 - nf.getbbox("X")[1]
76
- draw.text(((NUM_PLATE[0] + NUM_PLATE[2]) / 2 - nw / 2, ny), roman, font=nf, fill=GOLD)
 
 
77
 
78
  # concept name in the bottom band (auto-fit, up to 2 lines), warm gold
79
  cf, lines = _fit_lines(draw, concept, "Cinzel", CART_PLATE, max_lines=2, start=44)
 
69
  card.paste(frame_overlay(w, h), (0, 0), frame_overlay(w, h))
70
  draw = ImageDraw.Draw(card)
71
 
72
+ # roman numeral centered in the top-left compartment (ink plate → legible)
73
+ nf = font(30, weight=700)
74
  nw = draw.textlength(roman, font=nf)
75
+ bb = nf.getbbox("X")
76
+ nx = (NUM_PLATE[0] + NUM_PLATE[2]) / 2 - nw / 2
77
+ ny = (NUM_PLATE[1] + NUM_PLATE[3]) / 2 - (bb[3] - bb[1]) / 2 - bb[1]
78
+ draw.text((nx, ny), roman, font=nf, fill=GOLD)
79
 
80
  # concept name in the bottom band (auto-fit, up to 2 lines), warm gold
81
  cf, lines = _fit_lines(draw, concept, "Cinzel", CART_PLATE, max_lines=2, start=44)
arcana/designer.py CHANGED
@@ -16,14 +16,44 @@ from .archetypes import MAJOR_ARCANA, NAME_BY_NUMBER
16
  from .llm import LLM, get_llm
17
  from .prompts import designer_system_prompt, designer_user_prompt
18
 
19
- _CARD_FIELDS = ("concept", "justification", "upright_meaning",
20
- "reversed_meaning", "art_prompt")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
 
22
 
23
  class DeckError(ValueError):
24
  """The model's output could not be coerced into a valid 22-card deck."""
25
 
26
 
 
 
 
 
 
 
 
 
 
27
  # ------------------------------------------------------------------ json parse
28
  def extract_json(text: str) -> dict:
29
  """Pull the first JSON object out of a model reply, tolerating fences and
@@ -102,17 +132,37 @@ def validate_deck(data: dict, theme: str) -> dict:
102
  if missing:
103
  raise DeckError(f"missing arcana numbers: {missing}")
104
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
  # normalize: canonical names/order, trimmed strings, keep concept dupes visible
106
  norm_cards = []
107
  seen_concepts: dict[str, int] = {}
108
  for a in MAJOR_ARCANA:
109
  c = by_number[a.number]
110
  concept = c["concept"].strip()
111
- key = concept.lower()
 
 
 
 
 
112
  if key in seen_concepts:
113
  raise DeckError(
114
- f"concept {concept!r} reused for arcana {a.number} and "
115
- f"{seen_concepts[key]}"
116
  )
117
  seen_concepts[key] = a.number
118
  norm_cards.append({
@@ -120,9 +170,11 @@ def validate_deck(data: dict, theme: str) -> dict:
120
  "arcana_name": a.name,
121
  "concept": concept,
122
  "justification": c["justification"].strip(),
123
- "upright_meaning": c["upright_meaning"].strip(),
124
- "reversed_meaning": c["reversed_meaning"].strip(),
125
- "art_prompt": c["art_prompt"].strip(),
 
 
126
  "art_path": None,
127
  })
128
 
@@ -152,24 +204,27 @@ def design_deck(theme: str, llm: LLM | None = None) -> dict:
152
  # dropped card) are largely random per draw, so re-rolling beats trying to
153
  # surgically repair a bad draw (the repair often returns a partial deck).
154
  import os as _os
 
155
  attempts = int(_os.environ.get("DESIGN_ATTEMPTS", "4"))
156
  first_err = None
157
- for _ in range(attempts):
158
  try:
159
- raw = llm.complete(system, user, json_mode=True)
160
- return validate_deck(extract_json(raw), theme)
 
 
161
  except DeckError as e:
162
  first_err = first_err or e
 
163
 
164
  # last resort: one targeted repair, told exactly what was wrong
165
  repair = (
166
  f"{user}\n\nYour previous reply was rejected: {first_err}. "
167
  "Return ONLY the corrected strict JSON object with exactly 22 cards "
168
- "covering arcana 0 through 21, each with a DISTINCT non-empty concept, "
169
- "plus justification, upright_meaning, reversed_meaning and art_prompt. "
170
- "No prose, no code fences."
171
  )
172
- raw2 = llm.complete(system, repair, json_mode=True)
173
  try:
174
  return validate_deck(extract_json(raw2), theme)
175
  except DeckError as e:
 
16
  from .llm import LLM, get_llm
17
  from .prompts import designer_system_prompt, designer_user_prompt
18
 
19
+ LAST_ATTEMPTS = 0 # how many full generations design_deck used last run (diagnostic)
20
+
21
+ # The designer does ONLY the mapping now: concept + justification per archetype. The
22
+ # meanings AND art_prompt are invented downstream by the generative scribe (loremaster.
23
+ # scribe_deck), which is fanned out across parallel GPU workers. Keeping this generation
24
+ # tiny is what makes it fast and lets the heavy meaning/art work parallelize.
25
+ _CARD_FIELDS = ("concept", "justification")
26
+
27
+ # JSON-schema grammar for the stripped mapping. additionalProperties:false keeps the
28
+ # GBNF tight. Types only; validate_deck enforces 22-coverage / no-dupe / anti-echo.
29
+ DESIGNER_SCHEMA = {
30
+ "type": "object", "additionalProperties": False,
31
+ "required": ["theme", "style_suffix", "cards"],
32
+ "properties": {
33
+ "theme": {"type": "string"},
34
+ "style_suffix": {"type": "string"},
35
+ "cards": {"type": "array", "items": {
36
+ "type": "object", "additionalProperties": False,
37
+ "required": ["arcana_number", "concept", "justification"],
38
+ "properties": {
39
+ "arcana_number": {"type": "integer"},
40
+ "concept": {"type": "string"},
41
+ "justification": {"type": "string"}}}}}}
42
 
43
 
44
  class DeckError(ValueError):
45
  """The model's output could not be coerced into a valid 22-card deck."""
46
 
47
 
48
+ def _concept_key(concept: str) -> str:
49
+ """Normalized key for distinctness — paren/qualifier-insensitive, so "Frodo
50
+ Baggins" and "Frodo Baggins (Again)" / "Frodo Baggins II" collide as duplicates."""
51
+ s = re.sub(r"\s*[\(\[\{].*?[\)\]\}]", " ", concept) # drop bracketed parts
52
+ s = re.sub(r"\s+(again|redux|ii|iii|iv|2|3|the (?:second|elder|younger|next))\.?$",
53
+ "", s.strip(), flags=re.I) # drop trailing qualifiers
54
+ return re.sub(r"\s+", " ", s).strip().lower()
55
+
56
+
57
  # ------------------------------------------------------------------ json parse
58
  def extract_json(text: str) -> dict:
59
  """Pull the first JSON object out of a model reply, tolerating fences and
 
132
  if missing:
133
  raise DeckError(f"missing arcana numbers: {missing}")
134
 
135
+ # anti-echo guard: a grammar-constrained model occasionally degenerates into
136
+ # restating the ARCHETYPE NAMES as the concepts ("Death", "The Tower", ...)
137
+ # instead of mapping into the theme. That passes the distinct-non-empty check
138
+ # but is a non-mapping. Count concepts that just echo their arcana's own name
139
+ # (or any arcana name); 3+ means the draw collapsed — reject so design_deck
140
+ # re-rolls. A single coincidental match (a theme that legitimately contains a
141
+ # 'Tower') is tolerated.
142
+ def _norm(s: str) -> str:
143
+ return re.sub(r"^the\s+", "", s.strip().lower()).strip(" .'\"")
144
+ arcana_names = {_norm(a.name) for a in MAJOR_ARCANA}
145
+ echoes = sum(1 for n, c in by_number.items()
146
+ if _norm(c["concept"]) in arcana_names)
147
+ if echoes >= 3:
148
+ raise DeckError(f"archetype-echo: {echoes} concepts merely restate the "
149
+ "arcana names instead of mapping into the theme")
150
+
151
  # normalize: canonical names/order, trimmed strings, keep concept dupes visible
152
  norm_cards = []
153
  seen_concepts: dict[str, int] = {}
154
  for a in MAJOR_ARCANA:
155
  c = by_number[a.number]
156
  concept = c["concept"].strip()
157
+ if any(ch in concept for ch in "()[]{}"):
158
+ raise DeckError(
159
+ f"concept {concept!r} (arcana {a.number}) has brackets/qualifiers; "
160
+ "use a clean name"
161
+ )
162
+ key = _concept_key(concept) # normalized: paren/qualifier-insensitive
163
  if key in seen_concepts:
164
  raise DeckError(
165
+ f"concept {concept!r} not distinct from arcana {seen_concepts[key]}"
 
166
  )
167
  seen_concepts[key] = a.number
168
  norm_cards.append({
 
170
  "arcana_name": a.name,
171
  "concept": concept,
172
  "justification": c["justification"].strip(),
173
+ # invented downstream by the generative scribe (loremaster.scribe_deck)
174
+ "essence": "",
175
+ "upright_meaning": "",
176
+ "reversed_meaning": "",
177
+ "art_prompt": "",
178
  "art_path": None,
179
  })
180
 
 
204
  # dropped card) are largely random per draw, so re-rolling beats trying to
205
  # surgically repair a bad draw (the repair often returns a partial deck).
206
  import os as _os
207
+ global LAST_ATTEMPTS
208
  attempts = int(_os.environ.get("DESIGN_ATTEMPTS", "4"))
209
  first_err = None
210
+ for _i in range(attempts):
211
  try:
212
+ raw = llm.complete(system, user, json_mode=True, schema=DESIGNER_SCHEMA)
213
+ deck = validate_deck(extract_json(raw), theme)
214
+ LAST_ATTEMPTS = _i + 1
215
+ return deck
216
  except DeckError as e:
217
  first_err = first_err or e
218
+ LAST_ATTEMPTS = attempts + 1 # all attempts failed → fell through to repair
219
 
220
  # last resort: one targeted repair, told exactly what was wrong
221
  repair = (
222
  f"{user}\n\nYour previous reply was rejected: {first_err}. "
223
  "Return ONLY the corrected strict JSON object with exactly 22 cards "
224
+ "covering arcana 0 through 21, each with a DISTINCT non-empty concept "
225
+ "and a one-line justification. No prose, no code fences."
 
226
  )
227
+ raw2 = llm.complete(system, repair, json_mode=True, schema=DESIGNER_SCHEMA)
228
  try:
229
  return validate_deck(extract_json(raw2), theme)
230
  except DeckError as e:
arcana/frame.py CHANGED
@@ -26,7 +26,7 @@ INK = (18, 11, 30) # deep purple-black
26
  PARCH = (243, 230, 196) # parchment text
27
 
28
  BORDER = 26 # outer dark band thickness
29
- NUM_PLATE = (CARD_W // 2 - 70, 30, CARD_W // 2 + 70, 104) # roman numeral
30
  NAME_TOP = CARD_H - 196 # top of the name band
31
  CART_PLATE = (BORDER + 10, NAME_TOP + 16, CARD_W - BORDER - 10, CARD_H - 24) # name text area
32
  NAME_TEXT = (232, 200, 122) # warm gold for the name, to match the frame
@@ -72,8 +72,10 @@ def frame_overlay(w: int = CARD_W, h: int = CARD_H) -> Image.Image:
72
  d.polygon([(cx, cy - 10), (cx + 10, cy), (cx, cy + 10), (cx - 10, cy)],
73
  fill=(*GOLD, 255))
74
 
75
- # top numeral plate
76
- _rounded(d, NUM_PLATE, 10, fill=(*INK, 245), outline=(*GOLD, 255), width=2)
 
 
77
 
78
  # bottom NAME BAND — a solid, opaque, full-width band that reaches the very
79
  # bottom edge, so it covers (masks) any label the image model may have
 
26
  PARCH = (243, 230, 196) # parchment text
27
 
28
  BORDER = 26 # outer dark band thickness
29
+ NUM_PLATE = (BORDER + 4, BORDER + 4, BORDER + 96, BORDER + 72) # top-left numeral compartment
30
  NAME_TOP = CARD_H - 196 # top of the name band
31
  CART_PLATE = (BORDER + 10, NAME_TOP + 16, CARD_W - BORDER - 10, CARD_H - 24) # name text area
32
  NAME_TEXT = (232, 200, 122) # warm gold for the name, to match the frame
 
72
  d.polygon([(cx, cy - 10), (cx + 10, cy), (cx, cy + 10), (cx - 10, cy)],
73
  fill=(*GOLD, 255))
74
 
75
+ # top-LEFT numeral compartment — same ink fill as the name band, gold border that
76
+ # blends with the card's frame (top/left edges sit on the inner gold rule)
77
+ d.rectangle(list(NUM_PLATE), fill=(*INK, 255))
78
+ d.rectangle(list(NUM_PLATE), outline=(*GOLD, 255), width=2)
79
 
80
  # bottom NAME BAND — a solid, opaque, full-width band that reaches the very
81
  # bottom edge, so it covers (masks) any label the image model may have
arcana/gpu_models.py CHANGED
@@ -40,26 +40,33 @@ except Exception:
40
  pass
41
 
42
  # ---- model selection (env-overridable) ------------------------------------
43
- # HYBRID, by necessity: the 30B-Q4-MoE CANNOT reliably emit the dense 22-card×5-field
44
- # mapping JSON (tested both ways: reasoning = too slow/GPU-aborts; suppressed = leaves
45
- # fields empty, fails all retries). The dense 8B-Q4 (Llama-arch) fills the JSON
46
- # reliably and warms fast. So: 8B MAPS, 30B READS (rich prose). Both Nemotron, both
47
- # llama.cpp (the right engine for the hybrid-Mamba 30B transformers can't build its
48
- # Mamba kernels on ZeroGPU). They run in separate @spaces.GPU calls so the 8B+FLUX
49
- # (build) and the 30B (reading) never share the 48GB GPU.
 
 
50
  _DEF_8B = ("bartowski/nvidia_Llama-3.1-Nemotron-Nano-8B-v1-GGUF",
51
  "nvidia_Llama-3.1-Nemotron-Nano-8B-v1-Q4_K_M.gguf")
52
  _DEF_30B = ("unsloth/Nemotron-3-Nano-30B-A3B-GGUF",
53
  "Nemotron-3-Nano-30B-A3B-Q4_K_M.gguf")
54
- MAP_REPO = os.environ.get("LOCAL_MAP_REPO", _DEF_8B[0])
55
- MAP_FILE = os.environ.get("LOCAL_MAP_FILE", _DEF_8B[1])
 
56
  MAP_N_CTX = int(os.environ.get("LOCAL_MAP_N_CTX", "16384"))
57
- READ_REPO = os.environ.get("LOCAL_READ_REPO", _DEF_30B[0])
58
- READ_FILE = os.environ.get("LOCAL_READ_FILE", _DEF_30B[1])
59
- READ_N_CTX = int(os.environ.get("LOCAL_READ_N_CTX", "8192"))
60
 
61
- # Distilled klein (built for 4 steps); set LOCAL_IMAGE_MODEL=...-klein-9B for best.
62
- IMAGE_MODEL = os.environ.get("LOCAL_IMAGE_MODEL", "black-forest-labs/FLUX.2-klein-4B")
 
 
 
 
63
 
64
  _models: dict = {}
65
  _pipe = None
@@ -96,19 +103,23 @@ def _get_llama(repo: str, fname: str, n_ctx: int):
96
  hybrid-Mamba doesn't support FA) + a big n_batch to speed long-prompt eval (the
97
  designer's few-shot mapping prompt)."""
98
  if fname not in _models:
 
 
 
99
  _preload_cuda()
100
  from huggingface_hub import hf_hub_download
101
  from llama_cpp import Llama
102
  path = hf_hub_download(repo, fname, token=os.environ.get("HF_TOKEN"))
103
  kwargs = dict(model_path=path, n_gpu_layers=-1, n_ctx=n_ctx,
104
  n_batch=2048, verbose=False)
105
- if not _is_nemotron3(fname): # FA works for Llama-arch (8B), not the Mamba hybrid
106
  kwargs["flash_attn"] = True
107
  try:
108
  _models[fname] = Llama(**kwargs)
109
  except Exception:
110
  kwargs.pop("flash_attn", None) # fall back if the wheel rejects it
111
  _models[fname] = Llama(**kwargs)
 
112
  return _models[fname]
113
 
114
 
@@ -117,6 +128,27 @@ def _is_nemotron3(fname: str) -> bool:
117
  return "30b" in f or "nemotron-3-nano-3" in f
118
 
119
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
120
  def _last_balanced_json(text: str) -> str:
121
  """Return the LAST top-level {...} block in text (the final answer after any
122
  reasoning), else the whole text. Lets a reasoning model think first, then we
@@ -152,37 +184,25 @@ def _suppress_gen(llm, system: str, user: str, max_tokens: int, temperature: flo
152
 
153
 
154
  def llama_map(system: str, user: str, max_tokens: int = 12000,
155
- temperature: float = 0.5) -> str:
156
- """Structured JSON mapping think-SUPPRESSED (fast). Reasoning-then-extract was
157
- far too slow (16k tokens × 2 passesGPU-time aborts). Suppressed generation
158
- with a generous token budget should be clean: the earlier 'empty concept'
159
- failures were almost certainly JSON truncation from too-low max_tokens, not the
160
- model. designer.extract_json + a couple retries cover any residual slip."""
161
  llm = _get_llama(MAP_REPO, MAP_FILE, MAP_N_CTX)
162
- if _is_nemotron3(MAP_FILE):
163
- return _suppress_gen(llm, system, user, max_tokens, temperature)
164
- out = llm.create_chat_completion(
165
- messages=[{"role": "system", "content": "detailed thinking off\n\n" + system},
166
- {"role": "user", "content": user}],
167
- max_tokens=max_tokens, temperature=temperature)
168
- return (out["choices"][0]["message"]["content"] or "").strip()
169
 
170
 
171
  def llama_read(system: str, user: str, max_tokens: int = 9000,
172
  temperature: float = 0.7) -> str:
173
- """Prose readings (think-suppressed for clean, fast output)."""
174
  llm = _get_llama(READ_REPO, READ_FILE, READ_N_CTX)
175
- if _is_nemotron3(READ_FILE):
176
- return _suppress_gen(llm, system, user, max_tokens, temperature)
177
- out = llm.create_chat_completion(
178
- messages=[{"role": "system", "content": "detailed thinking off\n\n" + system},
179
- {"role": "user", "content": user}],
180
- max_tokens=max_tokens, temperature=temperature)
181
- return (out["choices"][0]["message"]["content"] or "").strip()
182
 
183
 
184
  def preload_pipe():
185
- """Construct the FLUX.2 pipeline ON CPU (the expensive from_pretrained: disk
186
  read + build). Call at STARTUP (off the GPU clock) so each conjure only pays
187
  the fast .to('cuda') move — not the full load. Safe to call outside @spaces.GPU
188
  (no CUDA touched here; do NOT .to('cuda') here — that trips ZeroGPU emulation)."""
@@ -200,7 +220,14 @@ def preload_pipe():
200
 
201
  def get_pipe():
202
  """Move the (CPU-preloaded) FLUX.2 pipeline to the GPU — call inside @spaces.GPU.
203
- The slow construct already happened at startup, so this is just the transfer."""
 
 
 
 
204
  pipe = preload_pipe()
205
  pipe.to("cuda")
 
 
 
206
  return pipe
 
40
  pass
41
 
42
  # ---- model selection (env-overridable) ------------------------------------
43
+ # SINGLE MODEL: Qwen3-14B for BOTH mapping and reading. We dropped Nemotron-3-30B
44
+ # (and the Nemotron prize): as a 3.5B-ACTIVE MoE at Q4 it was too literal-minded —
45
+ # it emitted tool calls instead of JSON, refused "divination", and went meta in the
46
+ # reader. Qwen3-14B is the same family as the original endpoint version (Qwen3-32B)
47
+ # at a safely ≤32B size; a plain capable instruct/reasoning model. It's a "thinking"
48
+ # model, so we append "/no_think" (exactly as the old endpoint build did) for direct
49
+ # output; JSON mapping is still grammar-constrained (response_format). One model,
50
+ # warmed once. Mapping (design), loremaster and reading run in separate @spaces.GPU
51
+ # calls; lore + FLUX run on two CONCURRENT GPUs so they never share 48GB.
52
  _DEF_8B = ("bartowski/nvidia_Llama-3.1-Nemotron-Nano-8B-v1-GGUF",
53
  "nvidia_Llama-3.1-Nemotron-Nano-8B-v1-Q4_K_M.gguf")
54
  _DEF_30B = ("unsloth/Nemotron-3-Nano-30B-A3B-GGUF",
55
  "Nemotron-3-Nano-30B-A3B-Q4_K_M.gguf")
56
+ _DEF_QWEN14 = ("unsloth/Qwen3-14B-GGUF", "Qwen3-14B-Q4_K_M.gguf")
57
+ MAP_REPO = os.environ.get("LOCAL_MAP_REPO", _DEF_QWEN14[0])
58
+ MAP_FILE = os.environ.get("LOCAL_MAP_FILE", _DEF_QWEN14[1])
59
  MAP_N_CTX = int(os.environ.get("LOCAL_MAP_N_CTX", "16384"))
60
+ READ_REPO = os.environ.get("LOCAL_READ_REPO", _DEF_QWEN14[0])
61
+ READ_FILE = os.environ.get("LOCAL_READ_FILE", _DEF_QWEN14[1])
62
+ READ_N_CTX = int(os.environ.get("LOCAL_READ_N_CTX", "16384"))
63
 
64
+ # FLUX.2-klein-9B (distilled 4 steps, guidance_scale=1.0) run LOCALLY via diffusers.
65
+ # Back to FLUX.2: the religious-imagery / quality drop we'd blamed on klein was really
66
+ # Nemotron's weak art prompts — FLUX.1 had the same bias on RWS-styled prompts. With
67
+ # Qwen3-14B art prompts + the no-religious guard in imagegen._style, klein is the better
68
+ # (newer, sharper) model. Gated but already accepted on this account.
69
+ IMAGE_MODEL = os.environ.get("LOCAL_IMAGE_MODEL", "black-forest-labs/FLUX.2-klein-9B")
70
 
71
  _models: dict = {}
72
  _pipe = None
 
103
  hybrid-Mamba doesn't support FA) + a big n_batch to speed long-prompt eval (the
104
  designer's few-shot mapping prompt)."""
105
  if fname not in _models:
106
+ import time as _t
107
+ from .timing import record
108
+ t0 = _t.time()
109
  _preload_cuda()
110
  from huggingface_hub import hf_hub_download
111
  from llama_cpp import Llama
112
  path = hf_hub_download(repo, fname, token=os.environ.get("HF_TOKEN"))
113
  kwargs = dict(model_path=path, n_gpu_layers=-1, n_ctx=n_ctx,
114
  n_batch=2048, verbose=False)
115
+ if not _is_nemotron3(fname): # FA works for Llama/Qwen arch, not the Mamba hybrid
116
  kwargs["flash_attn"] = True
117
  try:
118
  _models[fname] = Llama(**kwargs)
119
  except Exception:
120
  kwargs.pop("flash_attn", None) # fall back if the wheel rejects it
121
  _models[fname] = Llama(**kwargs)
122
+ record("llm_load", _t.time() - t0, {"file": fname})
123
  return _models[fname]
124
 
125
 
 
128
  return "30b" in f or "nemotron-3-nano-3" in f
129
 
130
 
131
+ def _is_qwen3(fname: str) -> bool:
132
+ return "qwen3" in fname.lower()
133
+
134
+
135
+ def _chat(llm, fname: str, system: str, user: str, max_tokens: int,
136
+ temperature: float, schema: dict | None) -> str:
137
+ """One chat-completion call. Qwen3 is a thinking model — we append '/no_think'
138
+ (as the original endpoint build did) for a direct answer. A JSON schema, when
139
+ given, constrains generation to that exact shape via llama.cpp's grammar."""
140
+ if _is_qwen3(fname):
141
+ user = user.rstrip() + "\n/no_think"
142
+ kwargs = dict(
143
+ messages=[{"role": "system", "content": system},
144
+ {"role": "user", "content": user}],
145
+ max_tokens=max_tokens, temperature=temperature)
146
+ if schema is not None:
147
+ kwargs["response_format"] = {"type": "json_object", "schema": schema}
148
+ out = llm.create_chat_completion(**kwargs)
149
+ return (out["choices"][0]["message"]["content"] or "").strip()
150
+
151
+
152
  def _last_balanced_json(text: str) -> str:
153
  """Return the LAST top-level {...} block in text (the final answer after any
154
  reasoning), else the whole text. Lets a reasoning model think first, then we
 
184
 
185
 
186
  def llama_map(system: str, user: str, max_tokens: int = 12000,
187
+ temperature: float = 0.6, schema: dict | None = None) -> str:
188
+ """Structured JSON generation (mapping AND loremaster). When a JSON-SCHEMA is
189
+ given we constrain generation to that exact shape (response_format GBNF
190
+ grammar): valid JSON from token 1, no preamble, no extra keys (callers set
191
+ additionalProperties:false). Each caller passes its OWN schema (designer
192
+ loremaster). The reader's extract_json/validate cover any residual slip."""
193
  llm = _get_llama(MAP_REPO, MAP_FILE, MAP_N_CTX)
194
+ return _chat(llm, MAP_FILE, system, user, max_tokens, temperature, schema)
 
 
 
 
 
 
195
 
196
 
197
  def llama_read(system: str, user: str, max_tokens: int = 9000,
198
  temperature: float = 0.7) -> str:
199
+ """Prose readings (free text, /no_think for direct Qwen3 output)."""
200
  llm = _get_llama(READ_REPO, READ_FILE, READ_N_CTX)
201
+ return _chat(llm, READ_FILE, system, user, max_tokens, temperature, None)
 
 
 
 
 
 
202
 
203
 
204
  def preload_pipe():
205
+ """Construct the FLUX.2-klein pipeline ON CPU (the expensive from_pretrained: disk
206
  read + build). Call at STARTUP (off the GPU clock) so each conjure only pays
207
  the fast .to('cuda') move — not the full load. Safe to call outside @spaces.GPU
208
  (no CUDA touched here; do NOT .to('cuda') here — that trips ZeroGPU emulation)."""
 
220
 
221
  def get_pipe():
222
  """Move the (CPU-preloaded) FLUX.2 pipeline to the GPU — call inside @spaces.GPU.
223
+ The slow construct already happened at startup, so this is just the transfer.
224
+ Called once per card; only the first (cold) move is worth logging."""
225
+ import time as _t
226
+ from .timing import record
227
+ t0 = _t.time()
228
  pipe = preload_pipe()
229
  pipe.to("cuda")
230
+ dt = _t.time() - t0
231
+ if dt > 0.5: # skip the warm per-card no-op calls (keeps /timings + logs clean)
232
+ record("pipe_load+to_cuda", dt)
233
  return pipe
arcana/imagegen.py CHANGED
@@ -34,6 +34,14 @@ GLOBAL_STYLE = ("ornate symbolic tarot card illustration, centered subject, "
34
  "cohesive color palette, painterly, no text, no words, no border, "
35
  "no frame, full-bleed artwork")
36
 
 
 
 
 
 
 
 
 
37
 
38
  @runtime_checkable
39
  class ImageGen(Protocol):
@@ -47,6 +55,7 @@ def _style(prompt: str, deck_style: str | None) -> str:
47
  if deck_style:
48
  parts.append(deck_style.strip().rstrip("."))
49
  parts.append(GLOBAL_STYLE)
 
50
  return ", ".join(p for p in parts if p)
51
 
52
 
@@ -91,11 +100,12 @@ class EndpointImageGen:
91
 
92
 
93
  class LocalImageGen:
94
- """FLUX.2 [klein] on the Space's GPU via diffusers (local-first, §7/§14).
95
 
96
  Generation MUST run inside an ``@spaces.GPU`` context — the pipeline loads on
97
- first use (see gpu_models.get_pipe). The DISTILLED klein-4B is sharp at 4 steps
98
- with guidance_scale=1.0 (per its model card).
 
99
  """
100
 
101
  def __init__(self, deck_style: str | None = None, size: int = 1024,
@@ -103,7 +113,7 @@ class LocalImageGen:
103
  self.deck_style = deck_style
104
  self.size = int(os.environ.get("IMAGE_SIZE_PX", size))
105
  self.steps = int(os.environ.get("IMAGE_STEPS", steps or 4))
106
- self.guidance = float(os.environ.get("IMAGE_GUIDANCE", 1.0))
107
 
108
  def generate(self, prompt: str, seed: int | None = None) -> Image.Image:
109
  import torch
@@ -112,8 +122,10 @@ class LocalImageGen:
112
  gen = None
113
  if seed is not None:
114
  gen = torch.Generator(device="cuda").manual_seed(int(seed))
115
- img = pipe(prompt=_style(prompt, self.deck_style),
116
- height=self.size, width=self.size,
 
 
117
  num_inference_steps=self.steps, guidance_scale=self.guidance,
118
  generator=gen).images[0]
119
  return img.convert("RGB")
 
34
  "cohesive color palette, painterly, no text, no words, no border, "
35
  "no frame, full-bleed artwork")
36
 
37
+ # Appended to every card's prompt: keep the image to what the scribe described,
38
+ # suppressing FLUX's habit of adding stray people/beings while leaving any figure the
39
+ # description itself calls for intact. Deliberately does NOT name specific creatures
40
+ # (naming them tends to summon them in diffusion models).
41
+ _SUBJECT_ONLY = ("depict only the subject described above — do not add any extra "
42
+ "human figures, faces, or mystical beings beyond what the "
43
+ "description itself calls for")
44
+
45
 
46
  @runtime_checkable
47
  class ImageGen(Protocol):
 
55
  if deck_style:
56
  parts.append(deck_style.strip().rstrip("."))
57
  parts.append(GLOBAL_STYLE)
58
+ parts.append(_SUBJECT_ONLY) # always — it self-scopes ("beyond what's described")
59
  return ", ".join(p for p in parts if p)
60
 
61
 
 
100
 
101
 
102
  class LocalImageGen:
103
+ """FLUX.2-klein on the Space's GPU via diffusers (local-first, §7/§14).
104
 
105
  Generation MUST run inside an ``@spaces.GPU`` context — the pipeline loads on
106
+ first use (see gpu_models.get_pipe). klein is guidance-DISTILLED: sharp at 4
107
+ steps with guidance_scale=1.0. It ignores CFG, so a negative prompt has no
108
+ effect — religious-imagery steering is appended to the positive prompt instead.
109
  """
110
 
111
  def __init__(self, deck_style: str | None = None, size: int = 1024,
 
113
  self.deck_style = deck_style
114
  self.size = int(os.environ.get("IMAGE_SIZE_PX", size))
115
  self.steps = int(os.environ.get("IMAGE_STEPS", steps or 4))
116
+ self.guidance = float(os.environ.get("IMAGE_GUIDANCE", 1.0)) # klein: 4 steps, g=1.0
117
 
118
  def generate(self, prompt: str, seed: int | None = None) -> Image.Image:
119
  import torch
 
122
  gen = None
123
  if seed is not None:
124
  gen = torch.Generator(device="cuda").manual_seed(int(seed))
125
+ styled = _style(prompt, self.deck_style)
126
+ # log exactly what FLUX receives (the image-agent side of the handoff)
127
+ print(f"[FLUX_PROMPT] {styled[:260]}", flush=True)
128
+ img = pipe(prompt=styled, height=self.size, width=self.size,
129
  num_inference_steps=self.steps, guidance_scale=self.guidance,
130
  generator=gen).images[0]
131
  return img.convert("RGB")
arcana/llm.py CHANGED
@@ -25,8 +25,11 @@ from typing import Protocol, runtime_checkable
25
 
26
  @runtime_checkable
27
  class LLM(Protocol):
28
- def complete(self, system: str, user: str, json_mode: bool = False) -> str:
29
- """Return the model's text completion for a system+user prompt."""
 
 
 
30
  ...
31
 
32
 
@@ -74,7 +77,7 @@ class EndpointLLM:
74
  return "qwen3" in m and "instruct" not in m
75
 
76
  def complete(self, system: str, user: str, json_mode: bool = False,
77
- timeout: float | None = None) -> str:
78
  if self._is_thinking_qwen():
79
  user = user.rstrip() + "\n/no_think"
80
  elif "nemotron" in self.model.lower():
@@ -121,11 +124,11 @@ class FallbackLLM:
121
  return self.backends[0].base_url
122
 
123
  def complete(self, system: str, user: str, json_mode: bool = False,
124
- timeout: float | None = None) -> str:
125
  last = None
126
  for be in self.backends:
127
  try:
128
- return be.complete(system, user, json_mode, timeout=timeout)
129
  except Exception as e:
130
  last = e
131
  raise last
@@ -147,12 +150,13 @@ class LocalLLM:
147
  self.temperature = float(os.environ.get("QWEN_TEMPERATURE", temperature))
148
 
149
  def complete(self, system: str, user: str, json_mode: bool = False,
150
- timeout: float | None = None) -> str:
151
  from .gpu_models import llama_map, llama_read
152
  if json_mode:
153
- # mapping → 30B, think-suppressed (fast); generous tokens so the full
154
- # 22-card JSON never truncates
155
- return llama_map(system, user, max_tokens=12000, temperature=0.5)
 
156
  # prose readings → 30B (think-suppressed for clean, fast output)
157
  return llama_read(system, user, max_tokens=self.max_tokens,
158
  temperature=self.temperature)
 
25
 
26
  @runtime_checkable
27
  class LLM(Protocol):
28
+ def complete(self, system: str, user: str, json_mode: bool = False,
29
+ schema: dict | None = None) -> str:
30
+ """Return the model's text completion for a system+user prompt. When
31
+ json_mode is set, `schema` (if given) constrains the JSON to an exact shape
32
+ — each structured caller (designer, loremaster) passes its own."""
33
  ...
34
 
35
 
 
77
  return "qwen3" in m and "instruct" not in m
78
 
79
  def complete(self, system: str, user: str, json_mode: bool = False,
80
+ schema: dict | None = None, timeout: float | None = None) -> str:
81
  if self._is_thinking_qwen():
82
  user = user.rstrip() + "\n/no_think"
83
  elif "nemotron" in self.model.lower():
 
124
  return self.backends[0].base_url
125
 
126
  def complete(self, system: str, user: str, json_mode: bool = False,
127
+ schema: dict | None = None, timeout: float | None = None) -> str:
128
  last = None
129
  for be in self.backends:
130
  try:
131
+ return be.complete(system, user, json_mode, schema=schema, timeout=timeout)
132
  except Exception as e:
133
  last = e
134
  raise last
 
150
  self.temperature = float(os.environ.get("QWEN_TEMPERATURE", temperature))
151
 
152
  def complete(self, system: str, user: str, json_mode: bool = False,
153
+ schema: dict | None = None, timeout: float | None = None) -> str:
154
  from .gpu_models import llama_map, llama_read
155
  if json_mode:
156
+ # structured JSON → 30B under the caller's grammar (designer/loremaster
157
+ # each pass their own schema). Generous tokens so 22 cards never truncate.
158
+ return llama_map(system, user, max_tokens=12000, temperature=0.5,
159
+ schema=schema)
160
  # prose readings → 30B (think-suppressed for clean, fast output)
161
  return llama_read(system, user, max_tokens=self.max_tokens,
162
  temperature=self.temperature)
arcana/loremaster.py CHANGED
@@ -1,16 +1,14 @@
1
- """Agent 1.5 — the Loremaster (deck reinterpretation pass).
2
-
3
- After the Designer maps the 22 archetypes onto in-theme concepts, this agent
4
- takes the WHOLE deckeach concept, the classical archetype it came from, that
5
- archetype's canonical meaning, and the Designer's first-draft meanings and
6
- rewrites every card's meaning to be *native to the concept itself*, rather than a
7
- stiff reproduction of the original tarot card. It sees all 22 at once, so it can
8
- make the deck internally coherent and keep cards distinct.
9
-
10
- The point: a card like "The One Ring" should read about corruption, burden and
11
- the fate it drags behind it not a generic "cycles and turning points" gloss
12
- inherited from Wheel of Fortune. The archetype is the seed; the concept is the
13
- plant. This pass also adds a one-line `essence` the Reader can draw on.
14
  """
15
  from __future__ import annotations
16
 
@@ -18,94 +16,151 @@ from .archetypes import MAJOR_ARCANA, NAME_BY_NUMBER
18
  from .designer import DeckError, extract_json
19
  from .llm import LLM, get_llm
20
 
21
- _FIELDS = ("essence", "upright_meaning", "reversed_meaning")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
 
23
 
24
  def _system() -> str:
25
  return """\
26
- You are the loremaster of a custom tarot deck. Each card began life as a classical
27
- Major Arcana archetype, but has been re-cast as a concept from a single theme. Your
28
- job is to make every card's meaning feel TRUE TO ITS OWN CONCEPT — drawn from what
29
- that concept actually is, does, and evokes instead of a stiff hand-me-down of the
30
- original tarot card's text.
31
-
32
- You are given, for each card: the concept, the archetype it was seeded from, that
33
- archetype's canonical meaning, and a first-draft upright/reversed meaning. Keep the
34
- archetype's underlying SHAPE (a Death-card still concerns endings; a Tower-card still
35
- concerns sudden collapse) but re-express it through the concept's own specifics,
36
- imagery and stakes. Lean into what makes THIS concept particular. Make the 22 cards
37
- distinct from one another — no two should read interchangeably.
38
 
39
  For each card produce:
40
- - essence: one vivid line naming what this card uniquely means in this deck.
41
- - upright_meaning: 2-3 sentences, concept-native, concrete and evocativenever
42
- boilerplate that could be pasted onto any card.
43
- - reversed_meaning: 1-2 sentences the shadow / blockage / inversion, also
44
- concept-native.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
 
46
  Speak in confident, grounded tarot voice. No hedging, no meta-commentary.
47
 
48
  OUTPUT: strict JSON only, no prose, no fences:
49
- { "cards": [ { "arcana_number": <0-21>, "essence": "...", "upright_meaning": "...", "reversed_meaning": "..." }, ... 22 ] }"""
50
 
51
 
52
- def _user(deck: dict) -> str:
53
  canon = {a.number: a.meaning for a in MAJOR_ARCANA}
 
54
  blocks = []
55
- for c in deck["cards"]:
56
- n = c["arcana_number"]
57
  blocks.append(
58
  f"--- card {n} ---\n"
59
  f"concept: {c['concept']}\n"
60
- f"seeded from archetype: {NAME_BY_NUMBER[n]} (classical meaning: {canon[n]})\n"
61
- f"draft upright: {c['upright_meaning']}\n"
62
- f"draft reversed: {c['reversed_meaning']}"
63
  )
64
  return (
65
  f"Theme: {deck.get('theme')}\n\n"
66
- "Reinterpret all 22 cards so each meaning is native to its own concept. "
67
- "Return the strict JSON object only.\n\n" + "\n\n".join(blocks)
68
  )
69
 
70
 
71
- def _apply(deck: dict, data: dict) -> dict:
72
  if not isinstance(data, dict) or not isinstance(data.get("cards"), list):
73
- raise DeckError("loremaster output missing 'cards' list")
74
- refined = {}
75
  for c in data["cards"]:
76
  try:
77
  n = int(c.get("arcana_number"))
78
  except (TypeError, ValueError):
79
- raise DeckError("loremaster card has non-integer arcana_number")
80
  for f in _FIELDS:
81
  v = c.get(f)
82
  if not isinstance(v, str) or not v.strip():
83
- raise DeckError(f"loremaster card {n} has empty '{f}'")
84
- refined[n] = c
85
- missing = [a.number for a in MAJOR_ARCANA if a.number not in refined]
86
  if missing:
87
- raise DeckError(f"loremaster missing arcana: {missing}")
88
-
89
- for card in deck["cards"]:
90
- r = refined[card["arcana_number"]]
91
  card["essence"] = r["essence"].strip()
92
  card["upright_meaning"] = r["upright_meaning"].strip()
93
  card["reversed_meaning"] = r["reversed_meaning"].strip()
 
 
 
 
94
  return deck
95
 
96
 
97
- def refine_deck(deck: dict, llm: LLM | None = None) -> dict:
98
- """Rewrite every card's meaning to be concept-native (one batched call, with
99
- one repair retry). Mutates and returns the deck dict."""
 
 
 
100
  llm = llm or get_llm()
101
- system, user = _system(), _user(deck)
102
 
103
- raw = llm.complete(system, user, json_mode=True)
104
  try:
105
- return _apply(deck, extract_json(raw))
106
  except DeckError as first:
107
  repair = (f"{user}\n\nYour previous reply was rejected: {first}. Return ONLY "
108
- "the corrected strict JSON with all 22 cards, each having non-empty "
109
- "essence, upright_meaning and reversed_meaning.")
110
- raw2 = llm.complete(system, repair, json_mode=True)
111
- return _apply(deck, extract_json(raw2))
 
 
 
 
 
 
1
+ """Agent 2 — the Scribe (generative meaning + art).
2
+
3
+ The Designer now does ONLY the mapping (concept + justification). This agent INVENTS
4
+ the rest of each card its meaning AND its artwork brief generatively, built from
5
+ what the concept actually is, does and evokes. It is NOT a reword of a draft and NOT
6
+ a mechanical derivation from "concept X = archetype Y": the archetype sets only the
7
+ emotional SHAPE of the meaning, and is kept entirely OUT of the picture (the art is
8
+ the concept itself this is what avoids the tarot/religious-cliché bias).
9
+
10
+ Works on the whole deck or on a SUBSET of arcana numbers, so the 22 cards can be
11
+ fanned out across several @spaces.GPU workers in parallel.
 
 
12
  """
13
  from __future__ import annotations
14
 
 
16
  from .designer import DeckError, extract_json
17
  from .llm import LLM, get_llm
18
 
19
+ _FIELDS = ("essence", "upright_meaning", "reversed_meaning", "art_prompt")
20
+
21
+ SCRIBE_SCHEMA = {
22
+ "type": "object", "additionalProperties": False,
23
+ "required": ["cards"],
24
+ "properties": {"cards": {"type": "array", "items": {
25
+ "type": "object", "additionalProperties": False,
26
+ "required": ["arcana_number", "essence", "upright_meaning",
27
+ "reversed_meaning", "art_prompt"],
28
+ "properties": {
29
+ "arcana_number": {"type": "integer"},
30
+ "essence": {"type": "string"},
31
+ "upright_meaning": {"type": "string"},
32
+ "reversed_meaning": {"type": "string"},
33
+ "art_prompt": {"type": "string"}}}}}}
34
 
35
 
36
  def _system() -> str:
37
  return """\
38
+ You are the artist-scribe of a custom tarot deck. Each card is a CONCEPT drawn from a
39
+ single theme, mapped onto a classical Major Arcana archetype. Your job is to INVENT —
40
+ generatively, from scratch two things per card: its MEANING and its ARTWORK, both
41
+ created from what the concept actually IS, does, and evokes in the real world. This is
42
+ an act of creation, not a reword and not a mechanical "archetype → text" derivation.
43
+
44
+ You are given, per card: the concept, why it was mapped (justification), the archetype
45
+ it was seeded from, and that archetype's canonical meaning. Use the archetype ONLY to
46
+ set the emotional SHAPE of the meaning (a Death-card concerns endings; a Tower-card
47
+ concerns sudden collapse) then build everything from the concept's own specifics.
48
+ Make the cards distinct; no two should read interchangeably.
 
49
 
50
  For each card produce:
51
+ - essence: one vivid line what this card uniquely means in THIS deck.
52
+ - upright_meaning: the DIVINATORY MESSAGE when this card appears what it counsels,
53
+ portends, or asks of the QUERENT in their own life. Speak to the person in tarot
54
+ voice (2-3 sentences). This is INTERPRETATION NOT a description of the picture.
55
+ - reversed_meaning: the shadow / blockage / inversion, also as guidance for the
56
+ querent (1-2 sentences) — again, not a scene.
57
+ - art_prompt: a vivid CENTRAL ILLUSTRATION — the ONLY visual field. It depicts the CONCEPT and its
58
+ THEME literally — the concept's real appearance, objects, setting, action. The
59
+ archetype must NOT appear in the image and you must NOT borrow tarot symbolism. Do
60
+ NOT depict religious or occult iconography (no Jesus, angels, halos, crosses, robed
61
+ priests, popes, pentagrams) unless the theme itself is explicitly religious — even a
62
+ card seeded from "Judgement" or "The Hierophant" is painted purely as its concept.
63
+ NEVER use a vague "a figure", "a being", "a person", "a lone figure", "a silhouette",
64
+ or "a robed/cloaked figure" — those make the artist default to the same generic robed
65
+ man. Name EXACTLY what is there: its species, age, sex, role, dress (e.g. "a young
66
+ female diver in a wetsuit", "an old bearded blacksmith", "a brass automaton"), or use
67
+ NO people at all if none are needed.
68
+ CRUCIAL: when the concept names a creature or character, the picture's SUBJECT is
69
+ THAT exact being, shown embodying the card's idea — NEVER a stand-in robed/cloaked
70
+ human borrowed from the original tarot archetype. E.g. "The Minotaur's Resolve" → a
71
+ powerful bull-headed minotaur standing firm in a labyrinth (NOT a robed figure);
72
+ "The Siren's Solitude" → a lone winged siren on a sea-cliff (NOT a cloaked figure).
73
+ No border, no frame, no card layout, no text or numerals. A shared visual style is
74
+ applied separately, so do not restate it.
75
+
76
+ Keep MEANING (what the card counsels) and ART (what it looks like) clearly separate.
77
+ e.g. "The Lighthouse" — upright_meaning: "You are being guided home; trust the steady
78
+ signal cutting through confusion, and it will carry you past the rocks." art_prompt:
79
+ "a lone stone lighthouse sweeping its beam across heaving night water." Never let the
80
+ meaning fields turn into a description of the scene.
81
+
82
+ Two art_prompt examples that anchor on the concept, not the card:
83
+ - [thermodynamics] "Entropy" (from Death): "a crumbling sandcastle dissolving grain by
84
+ grain into a still dark sea, warm embers cooling to grey ash drifting outward"
85
+ - [the ocean] "The Lighthouse" (from The Hierophant): "a lone stone lighthouse on a
86
+ black headland sweeping one steady beam across heaving night water, gulls scattering
87
+ through the shaft of light" — NOT a priest, despite the archetype.
88
 
89
  Speak in confident, grounded tarot voice. No hedging, no meta-commentary.
90
 
91
  OUTPUT: strict JSON only, no prose, no fences:
92
+ { "cards": [ { "arcana_number": <0-21>, "essence": "...", "upright_meaning": "...", "reversed_meaning": "...", "art_prompt": "..." }, ... ] }"""
93
 
94
 
95
+ def _user(deck: dict, nums: list[int]) -> str:
96
  canon = {a.number: a.meaning for a in MAJOR_ARCANA}
97
+ by_n = {c["arcana_number"]: c for c in deck["cards"]}
98
  blocks = []
99
+ for n in nums:
100
+ c = by_n[n]
101
  blocks.append(
102
  f"--- card {n} ---\n"
103
  f"concept: {c['concept']}\n"
104
+ f"why it maps: {c.get('justification', '')}\n"
105
+ f"seeded from archetype: {NAME_BY_NUMBER[n]} (classical meaning: {canon[n]})"
 
106
  )
107
  return (
108
  f"Theme: {deck.get('theme')}\n\n"
109
+ f"Invent the meaning and the artwork for these {len(nums)} cards, each created "
110
+ "from its own concept. Return the strict JSON object only.\n\n" + "\n\n".join(blocks)
111
  )
112
 
113
 
114
+ def _apply(deck: dict, data: dict, nums: list[int]) -> dict:
115
  if not isinstance(data, dict) or not isinstance(data.get("cards"), list):
116
+ raise DeckError("scribe output missing 'cards' list")
117
+ got = {}
118
  for c in data["cards"]:
119
  try:
120
  n = int(c.get("arcana_number"))
121
  except (TypeError, ValueError):
122
+ raise DeckError("scribe card has non-integer arcana_number")
123
  for f in _FIELDS:
124
  v = c.get(f)
125
  if not isinstance(v, str) or not v.strip():
126
+ raise DeckError(f"scribe card {n} has empty '{f}'")
127
+ got[n] = c
128
+ missing = [n for n in nums if n not in got]
129
  if missing:
130
+ raise DeckError(f"scribe missing arcana: {missing}")
131
+ by_n = {c["arcana_number"]: c for c in deck["cards"]}
132
+ for n in nums:
133
+ r, card = got[n], by_n[n]
134
  card["essence"] = r["essence"].strip()
135
  card["upright_meaning"] = r["upright_meaning"].strip()
136
  card["reversed_meaning"] = r["reversed_meaning"].strip()
137
+ card["art_prompt"] = r["art_prompt"].strip()
138
+ # log the scribe's art output (the lore-agent side of the handoff) so we can
139
+ # tell whether off-theme imagery originates here vs in the image model
140
+ print(f"[SCRIBE_ART] {n} {card['concept']}: {card['art_prompt'][:200]}", flush=True)
141
  return deck
142
 
143
 
144
+ def scribe_deck(deck: dict, subset: list[int] | None = None,
145
+ llm: LLM | None = None) -> dict:
146
+ """Generate meaning + art_prompt for the given arcana numbers (default: all 22),
147
+ one batched call + one repair retry. Mutates and returns the deck. `subset` lets
148
+ callers fan the 22 cards out across parallel workers."""
149
+ nums = sorted(subset) if subset is not None else [c["arcana_number"] for c in deck["cards"]]
150
  llm = llm or get_llm()
151
+ system, user = _system(), _user(deck, nums)
152
 
153
+ raw = llm.complete(system, user, json_mode=True, schema=SCRIBE_SCHEMA)
154
  try:
155
+ return _apply(deck, extract_json(raw), nums)
156
  except DeckError as first:
157
  repair = (f"{user}\n\nYour previous reply was rejected: {first}. Return ONLY "
158
+ "the corrected strict JSON with every listed card, each having a "
159
+ "non-empty essence, upright_meaning, reversed_meaning and art_prompt.")
160
+ raw2 = llm.complete(system, repair, json_mode=True, schema=SCRIBE_SCHEMA)
161
+ return _apply(deck, extract_json(raw2), nums)
162
+
163
+
164
+ # back-compat alias (build.py / older callers)
165
+ def refine_deck(deck: dict, llm: LLM | None = None) -> dict:
166
+ return scribe_deck(deck, subset=None, llm=llm)
arcana/prompts.py CHANGED
@@ -95,36 +95,44 @@ mapping ruins the whole deck.
95
  THE 22 MAJOR ARCANA (archetype — canonical meaning):
96
  {reference_block()}
97
 
 
 
 
 
 
98
  RULES FOR A GREAT MAPPING:
99
  - One in-theme concept per archetype. Exactly 22, covering arcana 0-21, no \
100
  concept repeated.
 
 
 
 
 
 
 
 
 
101
  - Choose the concept whose real meaning rhymes with the archetype's meaning — \
102
  not merely a famous item from the theme stuffed into a slot. Earn it.
103
  - `justification`: one crisp line naming the shared meaning that makes the fit click.
104
- - `upright_meaning` / `reversed_meaning`: the archetype's meaning RE-EXPRESSED \
105
- through the concept, in evocative tarot voice. Concrete and specific to this \
106
- concept — never generic boilerplate that would fit any card.
107
- - `art_prompt`: a vivid CENTRAL ILLUSTRATION ONLY — no border, no frame, no card \
108
- layout, and NO text or numerals in the image (those are added later). Describe \
109
- symbolic imagery, composition, and mood.
110
  - `style_suffix`: choose ONE short visual style line that suits the whole theme \
111
- (e.g. art-deco blueprint, warm storybook gouache, baroque oil painting). It will \
112
- be appended to every card's art_prompt so the 22 cards share one cohesive look.
 
113
 
114
- Study how these two reference decks earn every mapping:
115
 
116
  {_FEWSHOT_THERMO}
117
 
118
  {_FEWSHOT_BREAKFAST}
119
 
120
- {_FEWSHOT_CARD}
121
-
122
  OUTPUT CONTRACT — reply with STRICT JSON ONLY. No prose, no markdown fences. \
123
- Shape:
124
  {{
125
  "theme": "<the theme>",
126
  "style_suffix": "<one shared visual style line>",
127
- "cards": [ {{ 22 card objects as shown, in arcana order 0..21 }} ]
 
128
  }}"""
129
 
130
 
 
95
  THE 22 MAJOR ARCANA (archetype — canonical meaning):
96
  {reference_block()}
97
 
98
+ Your ONLY job here is the MAPPING — the concept for each archetype and a one-line
99
+ reason it's earned. The card meanings and the artwork are invented later by another
100
+ hand; do not produce them. Pour all your skill into the 22 fits and into choosing one
101
+ cohesive visual style for the deck.
102
+
103
  RULES FOR A GREAT MAPPING:
104
  - One in-theme concept per archetype. Exactly 22, covering arcana 0-21, no \
105
  concept repeated.
106
+ - All 22 concepts must be GENUINELY DISTINCT. Never reuse a concept or pad the \
107
+ list with a qualifier to fake distinctness — NOT "Frodo Baggins" then "Frodo \
108
+ Baggins (Again)", no "X II", no "another X". If the theme feels narrow, dig harder \
109
+ for 22 truly different facets.
110
+ - Concept names must be CLEAN: a plain name or short term with NO parentheses, \
111
+ brackets, or trailing qualifiers (never "(again)", "(part 2)", "[the elder]").
112
+ - NEVER use the archetype's own name (or a generic tarot word like "Death", \
113
+ "The Tower", "Strength", "Justice") as the concept. Every concept MUST be a \
114
+ specific thing, person, or term drawn from the named THEME.
115
  - Choose the concept whose real meaning rhymes with the archetype's meaning — \
116
  not merely a famous item from the theme stuffed into a slot. Earn it.
117
  - `justification`: one crisp line naming the shared meaning that makes the fit click.
 
 
 
 
 
 
118
  - `style_suffix`: choose ONE short visual style line that suits the whole theme \
119
+ (e.g. art-deco blueprint, warm storybook gouache, baroque oil painting, dark \
120
+ esoteric Thoth-tarot occult). It sets the cohesive look the artist will use for all \
121
+ 22 cards.
122
 
123
+ Study how these two reference decks earn every mapping (archetype → concept — why):
124
 
125
  {_FEWSHOT_THERMO}
126
 
127
  {_FEWSHOT_BREAKFAST}
128
 
 
 
129
  OUTPUT CONTRACT — reply with STRICT JSON ONLY. No prose, no markdown fences. \
130
+ Each card object is exactly {{ "arcana_number", "concept", "justification" }}. Shape:
131
  {{
132
  "theme": "<the theme>",
133
  "style_suffix": "<one shared visual style line>",
134
+ "cards": [ {{ "arcana_number": 0, "concept": "...", "justification": "..." }}, \
135
+ ... 22 in arcana order 0..21 ]
136
  }}"""
137
 
138
 
arcana/reader.py CHANGED
@@ -31,6 +31,43 @@ def _strip_think(text: str) -> str:
31
  return re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL).strip()
32
 
33
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  def draw_spread(deck: dict, spread: str = "three", reversals: bool = True,
35
  seed: int | None = None) -> list[dict]:
36
  """Draw cards (no repeats); orientations random only if reversals enabled."""
@@ -102,7 +139,9 @@ Interpret ONLY the card just turned — its meaning in its position, coloured by
102
  cards already shown. Call it by its concept name and treat it as that very thing,
103
  with its own texture and stakes. Do NOT name classical tarot cards or archetypes.
104
  Build the thread and let suspense gather — do NOT resolve the reading yet; the final
105
- synthesis comes after the last card. 2 to 4 sentences, plain prose, no preamble."""
 
 
106
  user = (
107
  f"Question: {q}\n\n"
108
  f"Cards already turned:\n{_prior_block(drawn, idx)}\n\n"
@@ -112,7 +151,10 @@ synthesis comes after the last card. 2 to 4 sentences, plain prose, no preamble.
112
  f" journey: this concept sits at {journey_note(d['arcana_number'])}.\n\n"
113
  "Give the interpretation of this one card now."
114
  )
115
- return _strip_think(llm.complete(system, user, json_mode=False, timeout=40).strip())
 
 
 
116
 
117
 
118
  def final_synthesis(deck: dict, question: str, drawn: list[dict],
@@ -138,8 +180,15 @@ answers the question. The craft is in collision — show how the cards modify on
138
  another across their positions (not separate paragraphs), how the story they tell
139
  together turns. Call each card by its concept name; never name classical tarot cards
140
  or archetypes. Honour reversals. End with a clear, earned takeaway the querent can
141
- hold. A few short paragraphs of flowing prose — no headings, no bullets."""
 
 
142
  user = (f"Question: {q}\n\nThe full spread, in order:\n" +
143
  "\n".join(spread_lines) + prior +
144
  "\n\nNow give the final synthesis and verdict.")
145
- return _strip_think(llm.complete(system, user, json_mode=False, timeout=40).strip())
 
 
 
 
 
 
31
  return re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL).strip()
32
 
33
 
34
+ # The 30B occasionally reads "tarot reading" as real divination and REFUSES
35
+ # ("I'm sorry, but I can't continue that story"). We (a) frame the task as fiction
36
+ # up front, (b) detect a refusal and retry with a firmer nudge, (c) fall back to the
37
+ # card's own meaning so a raw refusal NEVER reaches the screen.
38
+ _NO_REFUSE = (
39
+ "This is an imaginative, symbolic performance — a creative interpretation of "
40
+ "invented concept-cards, NOT real divination, prediction, or occult practice. "
41
+ "Stay fully in character as the reader. Never refuse, never add disclaimers or "
42
+ "safety caveats, never break character — always deliver the interpretation.")
43
+ _REFUSAL = re.compile(
44
+ r"(i'?m sorry|i can'?t|i cannot|i am unable|i'?m not able|i won'?t|as an ai|"
45
+ r"i'?m just an ai|continue that story|can'?t (?:continue|help with|assist))", re.I)
46
+ # weaker models also dodge by going META instead of refusing outright — e.g.
47
+ # "(Only this card is present; no further synthesis is possible at this stage.)"
48
+ _META = re.compile(
49
+ r"(no (?:further |additional )?(?:synthesis|interpretation|reading) (?:is |would be )?"
50
+ r"possible|only (?:this|one) card (?:is |was )?(?:present|given|provided)|"
51
+ r"cannot (?:yet )?(?:synthesi|provide a synthesis)|nothing (?:further |more )?to "
52
+ r"(?:synthesi|weave))", re.I)
53
+
54
+
55
+ def _looks_refusal(t: str) -> bool:
56
+ t = (t or "").strip()
57
+ return (not t) or bool(_REFUSAL.search(t[:160])) or bool(_META.search(t))
58
+
59
+
60
+ def _read(llm: LLM, system: str, user: str) -> str:
61
+ """Complete a reading turn; if it reads as a refusal, retry once with a firmer
62
+ in-character nudge. Returns '' if still refusing (caller supplies a fallback)."""
63
+ out = _strip_think(llm.complete(system, user, json_mode=False, timeout=40).strip())
64
+ if _looks_refusal(out):
65
+ nudge = (system + "\n\n" + _NO_REFUSE + " Begin directly with the imagery; "
66
+ "do not start with an apology.")
67
+ out = _strip_think(llm.complete(nudge, user, json_mode=False, timeout=40).strip())
68
+ return "" if _looks_refusal(out) else out
69
+
70
+
71
  def draw_spread(deck: dict, spread: str = "three", reversals: bool = True,
72
  seed: int | None = None) -> list[dict]:
73
  """Draw cards (no repeats); orientations random only if reversals enabled."""
 
139
  cards already shown. Call it by its concept name and treat it as that very thing,
140
  with its own texture and stakes. Do NOT name classical tarot cards or archetypes.
141
  Build the thread and let suspense gather — do NOT resolve the reading yet; the final
142
+ synthesis comes after the last card. 2 to 4 sentences, plain prose, no preamble.
143
+
144
+ {_NO_REFUSE}"""
145
  user = (
146
  f"Question: {q}\n\n"
147
  f"Cards already turned:\n{_prior_block(drawn, idx)}\n\n"
 
151
  f" journey: this concept sits at {journey_note(d['arcana_number'])}.\n\n"
152
  "Give the interpretation of this one card now."
153
  )
154
+ out = _read(llm, system, user)
155
+ if not out: # last-resort fallback so a refusal never shows
156
+ out = f"{d['concept']} turns {d['orientation']} here — {d.get('meaning','')}".strip()
157
+ return out
158
 
159
 
160
  def final_synthesis(deck: dict, question: str, drawn: list[dict],
 
180
  another across their positions (not separate paragraphs), how the story they tell
181
  together turns. Call each card by its concept name; never name classical tarot cards
182
  or archetypes. Honour reversals. End with a clear, earned takeaway the querent can
183
+ hold. A few short paragraphs of flowing prose — no headings, no bullets.
184
+
185
+ {_NO_REFUSE}"""
186
  user = (f"Question: {q}\n\nThe full spread, in order:\n" +
187
  "\n".join(spread_lines) + prior +
188
  "\n\nNow give the final synthesis and verdict.")
189
+ out = _read(llm, system, user)
190
+ if not out: # fallback weave from the cards' own meanings
191
+ out = "Taken together: " + " ".join(
192
+ f"{d['concept']} ({d['orientation']}) speaks of {d['meaning']}"
193
+ for d in drawn).strip()
194
+ return out
arcana/timing.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Lightweight, always-on timing for the live pipeline (mapping / loremaster /
2
+ painting / reading / model loads).
3
+
4
+ Two sinks so it works during a NATURAL run, not just diagnostics:
5
+ * stdout with a ``[TIMING]`` prefix → shows in the Space logs (grep-friendly).
6
+ * an append-only JSONL on the shared deck disk → readable by the main process
7
+ even though the GPU work happens in a forked @spaces.GPU worker (same trick as
8
+ the live status files). The ``/timings`` endpoint and the UI summary read this.
9
+
10
+ Single-line JSON appends are atomic on POSIX (O_APPEND), so the two concurrent GPU
11
+ workers (loremaster ‖ painter) can both record without corrupting the file.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+ import time
18
+
19
+ ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
20
+ _PATH = os.path.join(ROOT, "decks", "_timings.jsonl")
21
+
22
+
23
+ def record(stage: str, seconds: float, extra: dict | None = None) -> None:
24
+ entry = {"ts": time.time(), "stage": stage, "secs": round(seconds, 2)}
25
+ if extra:
26
+ entry.update(extra)
27
+ tail = (" " + " ".join(f"{k}={v}" for k, v in extra.items())) if extra else ""
28
+ print(f"[TIMING] {stage} = {seconds:.1f}s{tail}", flush=True)
29
+ try:
30
+ os.makedirs(os.path.dirname(_PATH), exist_ok=True)
31
+ with open(_PATH, "a", encoding="utf-8") as f:
32
+ f.write(json.dumps(entry) + "\n")
33
+ except Exception:
34
+ pass # monitoring must never break a real run
35
+
36
+
37
+ class clock:
38
+ """``with clock('mapping'): ...`` — records the elapsed wall time on exit.
39
+ Pass ``extra`` (or set ``.extra`` inside the block) for fields like attempts."""
40
+
41
+ def __init__(self, stage: str, extra: dict | None = None):
42
+ self.stage = stage
43
+ self.extra = extra or {}
44
+
45
+ def __enter__(self):
46
+ self._t = time.time()
47
+ return self
48
+
49
+ def __exit__(self, *exc):
50
+ record(self.stage, time.time() - self._t, self.extra)
51
+ return False
52
+
53
+
54
+ def recent(limit: int = 40) -> list[dict]:
55
+ """Last ``limit`` recorded entries (newest last)."""
56
+ try:
57
+ with open(_PATH, encoding="utf-8") as f:
58
+ lines = f.readlines()[-limit:]
59
+ return [json.loads(ln) for ln in lines if ln.strip()]
60
+ except Exception:
61
+ return []
frontend/deckview.html CHANGED
@@ -21,7 +21,7 @@
21
 
22
  /* coverflow */
23
  #flow { position:absolute; inset:0; perspective:1600px; }
24
- #flow .card { position:absolute; top:43%; left:50%; width:300px; height:459px;
25
  margin:-229px 0 0 -150px; transform-style:preserve-3d; cursor:pointer;
26
  transition:transform .5s cubic-bezier(.25,.6,.2,1), opacity .5s ease; will-change:transform,opacity; }
27
  #flow .inner { position:relative; width:100%; height:100%; transform-style:preserve-3d;
@@ -33,20 +33,35 @@
33
  #flow .card.sel .inner { transform:rotateY(0deg); } /* selected shows front */
34
  #flow .card:not(.sel) .inner { transform:rotateY(180deg); } /* others show back */
35
 
36
- .arrow { position:absolute; top:43%; transform:translateY(-50%); z-index:30;
 
 
 
 
 
 
 
 
 
 
 
37
  width:58px; height:58px; border-radius:50%; border:0; cursor:pointer; font-size:2rem; line-height:1;
38
  color:#1a1208; background:linear-gradient(135deg,#9a7b2e,#d8b15a);
39
  box-shadow:0 8px 22px rgba(0,0,0,.5); }
40
  #prev { left:18px; } #next { right:18px; }
41
  .arrow:active { transform:translateY(-50%) scale(.94); }
42
 
43
- #caption { position:absolute; left:0; right:0; bottom:18px; text-align:center; z-index:20;
44
- pointer-events:none; }
 
 
45
  #caption .name { font-family:'Cinzel Decorative',serif; color:var(--gold); font-size:1.7rem;
46
  text-shadow:0 2px 14px rgba(0,0,0,.8); }
47
  #caption .arc { font-family:'Cormorant Garamond',serif; font-style:italic; color:#cdbf93;
48
  font-size:1.28rem; margin-top:1px; } /* ~3/4 the title; the Major-Arcana identity */
49
- #caption .count { color:#9a8f70; font-size:1.05rem; margin-top:3px; }
 
 
50
  #caption .hint { color:#8c8266; font-size:.92rem; font-style:italic; margin-top:2px; }
51
 
52
  /* overhead grid */
@@ -75,7 +90,7 @@
75
  <div id="flow"></div>
76
  <button class="arrow" id="prev" aria-label="previous">‹</button>
77
  <button class="arrow" id="next" aria-label="next">›</button>
78
- <div id="caption"><div class="name"></div><div class="arc"></div><div class="count"></div><div class="hint">click the card to see it full size · drag or use ← →</div></div>
79
  <div id="grid"></div>
80
  </div>
81
  <div id="lb"><button class="x">×</button><img alt=""></div>
@@ -90,31 +105,42 @@
90
  }
91
  const deck = parseDeck();
92
  const cards = deck.cards || [];
93
- const back = deck.back || '';
94
  let sel = 0;
95
 
 
 
 
 
96
  const flow = document.getElementById('flow');
97
  const grid = document.getElementById('grid');
98
  const nameEl = document.querySelector('#caption .name');
99
  const arcEl = document.querySelector('#caption .arc');
 
100
  const countEl = document.querySelector('#caption .count');
101
 
102
- // preload all fronts for smooth flipping
103
- cards.forEach(c => { const i = new Image(); i.src = c.front; });
104
  if (back) { const b = new Image(); b.src = back; }
105
 
 
 
 
 
106
  // build coverflow cards
107
  const els = cards.map((c, idx) => {
108
  const card = document.createElement('div');
109
  card.className = 'card';
110
  card.innerHTML =
111
  '<div class="inner">' +
112
- '<div class="face back"><img src="' + (back||'') + '" alt=""></div>' +
113
- '<div class="face front"><img alt=""></div>' +
114
  '</div>';
115
- // lazy-set front src (kept ready via preload)
116
- card.querySelector('.front img').src = c.front;
117
- card.addEventListener('click', () => { if (idx === sel) openLb(c.full || c.front); else go(idx); });
 
 
118
  flow.appendChild(card);
119
  return card;
120
  });
@@ -139,6 +165,7 @@
139
  const c = cards[sel] || {};
140
  nameEl.textContent = c.name || '';
141
  arcEl.textContent = c.arc || '';
 
142
  countEl.textContent = (sel + 1) + ' / ' + cards.length;
143
  }
144
  function go(i) { sel = (i + cards.length) % cards.length; layout(); }
@@ -159,11 +186,12 @@
159
  });
160
 
161
  // overhead grid
162
- cards.forEach((c, idx) => {
163
  const f = document.createElement('figure');
164
- f.innerHTML = '<img src="' + c.front + '" alt="">';
165
- f.addEventListener('click', () => openLb(c.full || c.front));
166
  grid.appendChild(f);
 
167
  });
168
 
169
  // modes
@@ -186,7 +214,49 @@
186
  lb.addEventListener('click', closeLb);
187
  lb.querySelector('img').addEventListener('click', e => e.stopPropagation());
188
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
189
  layout();
 
190
  })();
191
  </script>
192
  </body>
 
21
 
22
  /* coverflow */
23
  #flow { position:absolute; inset:0; perspective:1600px; }
24
+ #flow .card { position:absolute; top:44%; left:50%; width:300px; height:459px;
25
  margin:-229px 0 0 -150px; transform-style:preserve-3d; cursor:pointer;
26
  transition:transform .5s cubic-bezier(.25,.6,.2,1), opacity .5s ease; will-change:transform,opacity; }
27
  #flow .inner { position:relative; width:100%; height:100%; transform-style:preserve-3d;
 
33
  #flow .card.sel .inner { transform:rotateY(0deg); } /* selected shows front */
34
  #flow .card:not(.sel) .inner { transform:rotateY(180deg); } /* others show back */
35
 
36
+ /* shimmer placeholder for a not-yet-painted card */
37
+ .shim { position:absolute; inset:0; border-radius:14px;
38
+ background:linear-gradient(115deg, rgba(28,22,44,.92) 0%, rgba(40,32,60,.92) 50%, rgba(28,22,44,.92) 100%),
39
+ radial-gradient(circle at 50% 38%, rgba(216,177,90,.10), transparent 60%);
40
+ background-size:220% 100%, 100% 100%; animation:shim 1.5s linear infinite;
41
+ display:flex; align-items:center; justify-content:center; }
42
+ .shim::after { content:'✦'; color:rgba(216,177,90,.5); font-size:2.2rem; animation:pulse 1.6s ease-in-out infinite; }
43
+ @keyframes shim { to { background-position:-220% 0, 0 0; } }
44
+ @keyframes pulse { 0%,100%{opacity:.35} 50%{opacity:.85} }
45
+ #grid .shim { position:relative; border-radius:10px; aspect-ratio:300/459; }
46
+
47
+ .arrow { position:absolute; top:44%; transform:translateY(-50%); z-index:30;
48
  width:58px; height:58px; border-radius:50%; border:0; cursor:pointer; font-size:2rem; line-height:1;
49
  color:#1a1208; background:linear-gradient(135deg,#9a7b2e,#d8b15a);
50
  box-shadow:0 8px 22px rgba(0,0,0,.5); }
51
  #prev { left:18px; } #next { right:18px; }
52
  .arrow:active { transform:translateY(-50%) scale(.94); }
53
 
54
+ /* anchored just BELOW the card (same 40% reference) so the gap stays constant
55
+ regardless of iframe height — was bottom-pinned, which drifted far below */
56
+ #caption { position:absolute; left:0; right:0; top:calc(44% + 232px); text-align:center;
57
+ z-index:20; pointer-events:none; padding:0 14px; }
58
  #caption .name { font-family:'Cinzel Decorative',serif; color:var(--gold); font-size:1.7rem;
59
  text-shadow:0 2px 14px rgba(0,0,0,.8); }
60
  #caption .arc { font-family:'Cormorant Garamond',serif; font-style:italic; color:#cdbf93;
61
  font-size:1.28rem; margin-top:1px; } /* ~3/4 the title; the Major-Arcana identity */
62
+ #caption .desc { color:#d7c8a0; font-size:1.12rem; line-height:1.34; max-width:620px;
63
+ margin:5px auto 0; text-shadow:0 2px 12px rgba(0,0,0,.85); min-height:1.1em; }
64
+ #caption .count { color:#9a8f70; font-size:1.05rem; margin-top:4px; }
65
  #caption .hint { color:#8c8266; font-size:.92rem; font-style:italic; margin-top:2px; }
66
 
67
  /* overhead grid */
 
90
  <div id="flow"></div>
91
  <button class="arrow" id="prev" aria-label="previous">‹</button>
92
  <button class="arrow" id="next" aria-label="next">›</button>
93
+ <div id="caption"><div class="name"></div><div class="arc"></div><div class="desc"></div><div class="count"></div><div class="hint">click the card to see it full size · drag or use ← →</div></div>
94
  <div id="grid"></div>
95
  </div>
96
  <div id="lb"><button class="x">×</button><img alt=""></div>
 
105
  }
106
  const deck = parseDeck();
107
  const cards = deck.cards || [];
108
+ let back = deck.back || '';
109
  let sel = 0;
110
 
111
+ // num (arcana 0-21) -> card index, for live updates
112
+ const numToIdx = {};
113
+ cards.forEach((c, i) => { if (c.num !== undefined && c.num !== null) numToIdx[c.num] = i; });
114
+
115
  const flow = document.getElementById('flow');
116
  const grid = document.getElementById('grid');
117
  const nameEl = document.querySelector('#caption .name');
118
  const arcEl = document.querySelector('#caption .arc');
119
+ const descEl = document.querySelector('#caption .desc');
120
  const countEl = document.querySelector('#caption .count');
121
 
122
+ // preload available fronts for smooth flipping
123
+ cards.forEach(c => { if (c.front) { const i = new Image(); i.src = c.front; } });
124
  if (back) { const b = new Image(); b.src = back; }
125
 
126
+ function frontFace(c) {
127
+ return c.front ? '<img alt="">' : '<div class="shim"></div>';
128
+ }
129
+
130
  // build coverflow cards
131
  const els = cards.map((c, idx) => {
132
  const card = document.createElement('div');
133
  card.className = 'card';
134
  card.innerHTML =
135
  '<div class="inner">' +
136
+ '<div class="face back">' + (back ? '<img src="' + back + '" alt="">' : '<div class="shim"></div>') + '</div>' +
137
+ '<div class="face front">' + frontFace(c) + '</div>' +
138
  '</div>';
139
+ if (c.front) card.querySelector('.front img').src = c.front;
140
+ card.addEventListener('click', () => {
141
+ if (idx === sel) { if (c.front) openLb(c.full || c.front); }
142
+ else go(idx);
143
+ });
144
  flow.appendChild(card);
145
  return card;
146
  });
 
165
  const c = cards[sel] || {};
166
  nameEl.textContent = c.name || '';
167
  arcEl.textContent = c.arc || '';
168
+ descEl.textContent = c.desc || '';
169
  countEl.textContent = (sel + 1) + ' / ' + cards.length;
170
  }
171
  function go(i) { sel = (i + cards.length) % cards.length; layout(); }
 
186
  });
187
 
188
  // overhead grid
189
+ const figs = cards.map((c, idx) => {
190
  const f = document.createElement('figure');
191
+ f.innerHTML = c.front ? '<img src="' + c.front + '" alt="">' : '<div class="shim"></div>';
192
+ f.addEventListener('click', () => { if (c.front) openLb(c.full || c.front); });
193
  grid.appendChild(f);
194
+ return f;
195
  });
196
 
197
  // modes
 
214
  lb.addEventListener('click', closeLb);
215
  lb.querySelector('img').addEventListener('click', e => e.stopPropagation());
216
 
217
+ // ---- live mode: poll status files and fill cards in as they finish --------
218
+ function setBack(url) {
219
+ if (!url || back) return;
220
+ back = url; const b = new Image(); b.src = url;
221
+ els.forEach(card => {
222
+ const bf = card.querySelector('.back');
223
+ bf.innerHTML = '<img src="' + url + '" alt="">';
224
+ });
225
+ }
226
+ function setCardArt(num, url) {
227
+ const idx = numToIdx[num];
228
+ if (idx === undefined || !url || cards[idx].front) return;
229
+ cards[idx].front = url; cards[idx].full = cards[idx].full || url;
230
+ const im = new Image(); im.src = url;
231
+ const ff = els[idx].querySelector('.front');
232
+ ff.innerHTML = '<img alt="">'; ff.querySelector('img').src = url;
233
+ if (figs[idx]) figs[idx].innerHTML = '<img src="' + url + '" alt="">';
234
+ }
235
+ function setCardDesc(num, desc) {
236
+ const idx = numToIdx[num];
237
+ if (idx === undefined || !desc) return;
238
+ cards[idx].desc = desc;
239
+ if (idx === sel) descEl.textContent = desc;
240
+ }
241
+ async function fetchJson(u) {
242
+ try { const r = await fetch(u + (u.indexOf('?')<0?'?':'&') + 't=' + Date.now());
243
+ return r.ok ? await r.json() : null; } catch (e) { return null; }
244
+ }
245
+ async function poll() {
246
+ let artDone = false, loreDone = false;
247
+ if (deck.artUrl) {
248
+ const a = await fetchJson(deck.artUrl);
249
+ if (a) { setBack(a.back); Object.keys(a.cards || {}).forEach(n => setCardArt(+n, a.cards[n])); artDone = !!a.done; }
250
+ } else artDone = true;
251
+ if (deck.loreUrl) {
252
+ const l = await fetchJson(deck.loreUrl);
253
+ if (l) { Object.keys(l.cards || {}).forEach(n => { const m = l.cards[n]; setCardDesc(+n, m.essence || m.upright || ''); }); loreDone = !!l.done; }
254
+ } else loreDone = true;
255
+ if (!(artDone && loreDone)) setTimeout(poll, 1500);
256
+ }
257
+
258
  layout();
259
+ if (deck.live) poll();
260
  })();
261
  </script>
262
  </body>