promote ZeroGPU local-first build: Nemotron via llama.cpp + FLUX.2 via diffusers
Browse files- app.py +114 -47
- arcana/designer.py +16 -9
- arcana/gpu_models.py +102 -0
- arcana/imagegen.py +23 -5
- arcana/llm.py +19 -8
- frontend/deckview.html +1 -2
- requirements.txt +17 -1
app.py
CHANGED
|
@@ -11,6 +11,15 @@ CPU box (no torch/diffusers), consistent with Globe.
|
|
| 11 |
"""
|
| 12 |
from __future__ import annotations
|
| 13 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
import base64
|
| 15 |
import html
|
| 16 |
import json
|
|
@@ -26,12 +35,32 @@ from fastapi.staticfiles import StaticFiles
|
|
| 26 |
|
| 27 |
from arcana.build import (DECKS_DIR, build_deck, deck_dir, list_decks, load_deck,
|
| 28 |
save_deck, slugify)
|
|
|
|
| 29 |
from arcana.reader import (SPREADS, card_partial, draw_spread, final_synthesis)
|
| 30 |
from arcana.styles import CUSTOM_PLACEHOLDER, STYLE_CHOICES
|
| 31 |
|
| 32 |
ROOT = os.path.dirname(os.path.abspath(__file__))
|
| 33 |
DEFAULT_THEME = "thermodynamics"
|
| 34 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
WELCOME = ("Welcome, traveler. Every world has its hidden archetypes. "
|
| 36 |
"Name one, and I shall draw out its fortune.")
|
| 37 |
EXAMPLE_THEMES = ["Physics", "Birds", "Lord of the Rings Characters", "Breakfast Foods"]
|
|
@@ -45,7 +74,10 @@ def _abs(art_path: str | None):
|
|
| 45 |
|
| 46 |
|
| 47 |
def url_for(art_path: str | None) -> str:
|
| 48 |
-
|
|
|
|
|
|
|
|
|
|
| 49 |
|
| 50 |
|
| 51 |
def disp_url(c: dict) -> str:
|
|
@@ -58,11 +90,16 @@ def full_url(c: dict) -> str:
|
|
| 58 |
return url_for(c.get("full_path") or c.get("art_path"))
|
| 59 |
|
| 60 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
def deck_iframe(deck: dict | None, nonce: int = 0) -> str:
|
| 62 |
-
"""The deck browser — a self-contained coverflow/overhead widget
|
| 63 |
-
|
| 64 |
-
names injected as base64 JSON.
|
| 65 |
-
|
|
|
|
| 66 |
if not deck or not deck.get("cards"):
|
| 67 |
return "<div class='spread-empty'>No deck to show.</div>"
|
| 68 |
data = {
|
|
@@ -72,8 +109,10 @@ def deck_iframe(deck: dict | None, nonce: int = 0) -> str:
|
|
| 72 |
for c in deck["cards"]],
|
| 73 |
}
|
| 74 |
b64 = base64.b64encode(json.dumps(data).encode("utf-8")).decode()
|
| 75 |
-
|
| 76 |
-
|
|
|
|
|
|
|
| 77 |
"style='width:100%;height:74vh;min-height:520px;border:0;display:block;"
|
| 78 |
"background:transparent;'></iframe>")
|
| 79 |
|
|
@@ -177,36 +216,53 @@ def _next_btn(label: str | None):
|
|
| 177 |
return gr.update(value=label, visible=True) if label else gr.update(visible=False)
|
| 178 |
|
| 179 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
def _reveal_card(sess):
|
| 181 |
-
"""Flip the card at sess['i'] into the active area and
|
| 182 |
-
Yields (stage_html, sess, next_button_update)."""
|
| 183 |
-
|
| 184 |
d, completed = drawn[i], sess["completed"]
|
| 185 |
label = "Next card ▸" if i + 1 < len(drawn) else "Reveal the verdict ▸"
|
| 186 |
-
# flip + essence
|
| 187 |
-
# on the model (which can be slow/overloaded); the deeper reading fades in after.
|
| 188 |
yield _stage(back, completed, active=(d, "", True)), sess, _next_btn(label)
|
| 189 |
time.sleep(FLIP_BEAT)
|
| 190 |
-
|
| 191 |
-
p = card_partial(deck, sess["question"], drawn, i)
|
| 192 |
-
except Exception:
|
| 193 |
-
p = "" # model unavailable — the essence above stands as the reading
|
| 194 |
sess["last_p"] = p
|
| 195 |
-
sess["partials"].append(p)
|
| 196 |
body = _fade(p) if p else ""
|
| 197 |
yield _stage(back, completed, active=(d, body, False)), sess, _next_btn(label)
|
| 198 |
|
| 199 |
|
| 200 |
def do_draw(deck, question, spread, reversals):
|
| 201 |
-
"""Begin a reading: draw the spread
|
| 202 |
-
|
| 203 |
if not deck:
|
| 204 |
yield "<div class='spread-empty'>Conjure or open a deck first.</div>", None, _next_btn(None)
|
| 205 |
return
|
| 206 |
drawn = draw_spread(deck, spread=spread, reversals=reversals)
|
|
|
|
|
|
|
|
|
|
| 207 |
sess = {"deck": deck, "drawn": drawn, "question": question,
|
| 208 |
"back": deck.get("back_disp") or deck.get("back_path"), "i": 0, "completed": [],
|
| 209 |
-
"partials":
|
| 210 |
yield from _reveal_card(sess)
|
| 211 |
|
| 212 |
|
|
@@ -230,10 +286,8 @@ def do_next(sess):
|
|
| 230 |
yield (_stage(back, sess["completed"],
|
| 231 |
synth_html="<div class='syn-load'>…the oracle gathers the threads…</div>"),
|
| 232 |
sess, _next_btn(None))
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
except Exception as e:
|
| 236 |
-
synth = f"(the synthesis slips away — {type(e).__name__})"
|
| 237 |
sess["phase"] = "done"
|
| 238 |
yield (_stage(back, sess["completed"],
|
| 239 |
synth_html=f"<h3>✦ The cards together</h3><div class='syn-body'>{_fade(synth)}</div>"),
|
|
@@ -254,6 +308,26 @@ def do_conjure(theme, style, custom):
|
|
| 254 |
theme = (theme or "").strip()
|
| 255 |
if not theme:
|
| 256 |
yield stay("Name a theme to begin."); return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 257 |
st = {"msg": "Consulting the deck designer…", "frac": 0.0,
|
| 258 |
"done": False, "deck": None, "err": None}
|
| 259 |
|
|
@@ -268,17 +342,13 @@ def do_conjure(theme, style, custom):
|
|
| 268 |
|
| 269 |
threading.Thread(target=run, daemon=True).start()
|
| 270 |
while not st["done"]:
|
| 271 |
-
|
| 272 |
-
yield stay(f"### ✦ {st['msg']}\n\n**{pct}%** — conjuring *{theme}*")
|
| 273 |
time.sleep(0.4)
|
| 274 |
-
|
| 275 |
if st["err"] or not st["deck"]:
|
| 276 |
-
why = type(st[
|
| 277 |
-
yield
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
# open the freshly-made deck in the coverflow deck browser
|
| 281 |
-
yield ("", deck, deck, deck_iframe(deck, _next_nonce()), zip_deck(deck), *show("view"))
|
| 282 |
|
| 283 |
|
| 284 |
def toggle_custom(style):
|
|
@@ -671,19 +741,16 @@ def build_demo() -> gr.Blocks:
|
|
| 671 |
return demo
|
| 672 |
|
| 673 |
|
| 674 |
-
|
| 675 |
-
|
| 676 |
-
|
| 677 |
-
app.mount("/decks", StaticFiles(directory=DECKS_DIR), name="decks")
|
| 678 |
-
app.mount("/viewer", StaticFiles(directory=os.path.join(ROOT, "frontend")), name="viewer")
|
| 679 |
-
demo = build_demo()
|
| 680 |
-
demo.queue(default_concurrency_limit=4)
|
| 681 |
-
return gr.mount_gradio_app(app, demo, path="/", css=CSS,
|
| 682 |
-
theme=gr.themes.Soft(), ssr_mode=False)
|
| 683 |
-
|
| 684 |
-
|
| 685 |
-
app = build_app()
|
| 686 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 687 |
if __name__ == "__main__":
|
| 688 |
-
|
| 689 |
-
|
|
|
|
|
|
| 11 |
"""
|
| 12 |
from __future__ import annotations
|
| 13 |
|
| 14 |
+
# IMPORT ORDER MATTERS: `spaces` must be imported BEFORE gradio (and before torch)
|
| 15 |
+
# so it can patch Gradio for ZeroGPU. If gradio loads first, ZeroGPU never
|
| 16 |
+
# initialises and the Space boots then immediately gets SIGTERM ("Shutting down").
|
| 17 |
+
try:
|
| 18 |
+
import spaces
|
| 19 |
+
_HAS_SPACES = True
|
| 20 |
+
except Exception:
|
| 21 |
+
_HAS_SPACES = False
|
| 22 |
+
|
| 23 |
import base64
|
| 24 |
import html
|
| 25 |
import json
|
|
|
|
| 35 |
|
| 36 |
from arcana.build import (DECKS_DIR, build_deck, deck_dir, list_decks, load_deck,
|
| 37 |
save_deck, slugify)
|
| 38 |
+
from arcana.llm import get_llm
|
| 39 |
from arcana.reader import (SPREADS, card_partial, draw_spread, final_synthesis)
|
| 40 |
from arcana.styles import CUSTOM_PLACEHOLDER, STYLE_CHOICES
|
| 41 |
|
| 42 |
ROOT = os.path.dirname(os.path.abspath(__file__))
|
| 43 |
DEFAULT_THEME = "thermodynamics"
|
| 44 |
|
| 45 |
+
# ---- ZeroGPU (local-first build) ------------------------------------------
|
| 46 |
+
# When LLM/IMAGE backends are local, the heavy work runs on the Space's GPU and
|
| 47 |
+
# must be wrapped in @spaces.GPU. Off-Space (or endpoint mode) @GPU is a no-op.
|
| 48 |
+
LOCAL_GPU = (os.environ.get("LLM_BACKEND", "").lower() == "local"
|
| 49 |
+
or os.environ.get("IMAGE_BACKEND", "").lower() == "local")
|
| 50 |
+
if _HAS_SPACES:
|
| 51 |
+
def GPU(duration=120):
|
| 52 |
+
return spaces.GPU(duration=duration)
|
| 53 |
+
else:
|
| 54 |
+
def GPU(duration=120):
|
| 55 |
+
def _deco(fn):
|
| 56 |
+
return fn
|
| 57 |
+
return _deco
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
# NOTE: no startup prefetch — downloading the 24GB GGUF in a startup thread made
|
| 61 |
+
# the Space crash on boot (and is unnecessary: the in-@spaces.GPU download path is
|
| 62 |
+
# fast, ~20s). Models load lazily on the first GPU call and cache on disk after.
|
| 63 |
+
|
| 64 |
WELCOME = ("Welcome, traveler. Every world has its hidden archetypes. "
|
| 65 |
"Name one, and I shall draw out its fortune.")
|
| 66 |
EXAMPLE_THEMES = ["Physics", "Birds", "Lord of the Rings Characters", "Breakfast Foods"]
|
|
|
|
| 74 |
|
| 75 |
|
| 76 |
def url_for(art_path: str | None) -> str:
|
| 77 |
+
# Served by Gradio's own static-file route (demo.launch + allowed_paths). This
|
| 78 |
+
# replaces the FastAPI /decks mount, which is incompatible with ZeroGPU (ZeroGPU
|
| 79 |
+
# needs gradio's native launch, not a custom uvicorn-mounted app).
|
| 80 |
+
return f"/gradio_api/file={_abs(art_path)}" if art_path else ""
|
| 81 |
|
| 82 |
|
| 83 |
def disp_url(c: dict) -> str:
|
|
|
|
| 90 |
return url_for(c.get("full_path") or c.get("art_path"))
|
| 91 |
|
| 92 |
|
| 93 |
+
with open(os.path.join(ROOT, "frontend", "deckview.html"), encoding="utf-8") as _f:
|
| 94 |
+
_DECKVIEW_HTML = _f.read()
|
| 95 |
+
|
| 96 |
+
|
| 97 |
def deck_iframe(deck: dict | None, nonce: int = 0) -> str:
|
| 98 |
+
"""The deck browser — a self-contained coverflow/overhead widget embedded via an
|
| 99 |
+
iframe ``srcdoc`` (the whole widget HTML is inlined, with the deck's image URLs +
|
| 100 |
+
names injected as base64 JSON in a global). Using srcdoc instead of a /viewer
|
| 101 |
+
static mount keeps everything inside gradio's native server (ZeroGPU-compatible).
|
| 102 |
+
All swipe/flip/fade/toggle/lightbox is handled client-side in the widget."""
|
| 103 |
if not deck or not deck.get("cards"):
|
| 104 |
return "<div class='spread-empty'>No deck to show.</div>"
|
| 105 |
data = {
|
|
|
|
| 109 |
for c in deck["cards"]],
|
| 110 |
}
|
| 111 |
b64 = base64.b64encode(json.dumps(data).encode("utf-8")).decode()
|
| 112 |
+
inject = f"<script>window.DECK_B64={json.dumps(b64)};</script>"
|
| 113 |
+
doc = _DECKVIEW_HTML.replace("</head>", inject + "</head>", 1)
|
| 114 |
+
srcdoc = html.escape(doc, quote=True)
|
| 115 |
+
return (f"<iframe srcdoc='{srcdoc}' title='Deck' "
|
| 116 |
"style='width:100%;height:74vh;min-height:520px;border:0;display:block;"
|
| 117 |
"background:transparent;'></iframe>")
|
| 118 |
|
|
|
|
| 216 |
return gr.update(value=label, visible=True) if label else gr.update(visible=False)
|
| 217 |
|
| 218 |
|
| 219 |
+
@GPU(duration=300)
|
| 220 |
+
def _gpu_reading(deck, question, drawn):
|
| 221 |
+
"""Compute ALL card readings + the synthesis in ONE GPU session (the Nemotron
|
| 222 |
+
GGUF can't be cached across separate ZeroGPU calls). Revealed progressively
|
| 223 |
+
client-side afterwards. Returns (partials, synthesis)."""
|
| 224 |
+
llm = get_llm()
|
| 225 |
+
partials = []
|
| 226 |
+
for i in range(len(drawn)):
|
| 227 |
+
try:
|
| 228 |
+
partials.append(card_partial(deck, question, drawn, i, llm=llm))
|
| 229 |
+
except Exception:
|
| 230 |
+
partials.append("")
|
| 231 |
+
try:
|
| 232 |
+
synth = final_synthesis(deck, question, drawn, partials, llm=llm)
|
| 233 |
+
except Exception as e:
|
| 234 |
+
synth = f"(the synthesis slips away — {type(e).__name__})"
|
| 235 |
+
return partials, synth
|
| 236 |
+
|
| 237 |
+
|
| 238 |
def _reveal_card(sess):
|
| 239 |
+
"""Flip the card at sess['i'] into the active area and reveal its (already
|
| 240 |
+
computed) reading. Yields (stage_html, sess, next_button_update)."""
|
| 241 |
+
drawn, back, i = sess["drawn"], sess["back"], sess["i"]
|
| 242 |
d, completed = drawn[i], sess["completed"]
|
| 243 |
label = "Next card ▸" if i + 1 < len(drawn) else "Reveal the verdict ▸"
|
| 244 |
+
# flip + essence show first, then the deeper reading fades in a beat later
|
|
|
|
| 245 |
yield _stage(back, completed, active=(d, "", True)), sess, _next_btn(label)
|
| 246 |
time.sleep(FLIP_BEAT)
|
| 247 |
+
p = sess["partials_pre"][i] if i < len(sess.get("partials_pre", [])) else ""
|
|
|
|
|
|
|
|
|
|
| 248 |
sess["last_p"] = p
|
|
|
|
| 249 |
body = _fade(p) if p else ""
|
| 250 |
yield _stage(back, completed, active=(d, body, False)), sess, _next_btn(label)
|
| 251 |
|
| 252 |
|
| 253 |
def do_draw(deck, question, spread, reversals):
|
| 254 |
+
"""Begin a reading: draw the spread, compute every card's reading on the GPU in
|
| 255 |
+
one pass, then reveal card by card as the reader clicks Next."""
|
| 256 |
if not deck:
|
| 257 |
yield "<div class='spread-empty'>Conjure or open a deck first.</div>", None, _next_btn(None)
|
| 258 |
return
|
| 259 |
drawn = draw_spread(deck, spread=spread, reversals=reversals)
|
| 260 |
+
yield ("<div class='spread-empty'>…the oracle contemplates the spread…</div>",
|
| 261 |
+
None, _next_btn(None))
|
| 262 |
+
partials, synth = _gpu_reading(deck, question, drawn)
|
| 263 |
sess = {"deck": deck, "drawn": drawn, "question": question,
|
| 264 |
"back": deck.get("back_disp") or deck.get("back_path"), "i": 0, "completed": [],
|
| 265 |
+
"partials_pre": partials, "synth_pre": synth, "last_p": "", "phase": "card"}
|
| 266 |
yield from _reveal_card(sess)
|
| 267 |
|
| 268 |
|
|
|
|
| 286 |
yield (_stage(back, sess["completed"],
|
| 287 |
synth_html="<div class='syn-load'>…the oracle gathers the threads…</div>"),
|
| 288 |
sess, _next_btn(None))
|
| 289 |
+
time.sleep(FLIP_BEAT)
|
| 290 |
+
synth = sess.get("synth_pre") or "(the synthesis slips away)"
|
|
|
|
|
|
|
| 291 |
sess["phase"] = "done"
|
| 292 |
yield (_stage(back, sess["completed"],
|
| 293 |
synth_html=f"<h3>✦ The cards together</h3><div class='syn-body'>{_fade(synth)}</div>"),
|
|
|
|
| 308 |
theme = (theme or "").strip()
|
| 309 |
if not theme:
|
| 310 |
yield stay("Name a theme to begin."); return
|
| 311 |
+
deck = None
|
| 312 |
+
for kind, payload, frac in _gpu_build(theme, style, custom):
|
| 313 |
+
if kind == "progress":
|
| 314 |
+
yield stay(f"### ✦ {payload}\n\n**{int(frac * 100)}%** — conjuring *{theme}*")
|
| 315 |
+
elif kind == "error":
|
| 316 |
+
yield stay(f"⚠️ The conjuring faltered ({payload}). Try again."); return
|
| 317 |
+
elif kind == "done":
|
| 318 |
+
deck = payload
|
| 319 |
+
if not deck:
|
| 320 |
+
yield stay("⚠️ The conjuring faltered (no deck). Try again."); return
|
| 321 |
+
# open the freshly-made deck in the coverflow deck browser
|
| 322 |
+
yield ("", deck, deck, deck_iframe(deck, _next_nonce()), zip_deck(deck), *show("view"))
|
| 323 |
+
|
| 324 |
+
|
| 325 |
+
@GPU(duration=600)
|
| 326 |
+
def _gpu_build(theme, style, custom):
|
| 327 |
+
"""Build the whole deck (local Nemotron mapping + local FLUX.2 art) in ONE GPU
|
| 328 |
+
session, streaming (kind, payload, frac) progress events. The build runs in a
|
| 329 |
+
thread so this generator can keep yielding progress; the thread shares the
|
| 330 |
+
process's attached GPU."""
|
| 331 |
st = {"msg": "Consulting the deck designer…", "frac": 0.0,
|
| 332 |
"done": False, "deck": None, "err": None}
|
| 333 |
|
|
|
|
| 342 |
|
| 343 |
threading.Thread(target=run, daemon=True).start()
|
| 344 |
while not st["done"]:
|
| 345 |
+
yield ("progress", st["msg"], min(st["frac"], 0.99))
|
|
|
|
| 346 |
time.sleep(0.4)
|
|
|
|
| 347 |
if st["err"] or not st["deck"]:
|
| 348 |
+
why = f"{type(st['err']).__name__}: {st['err']}" if st["err"] else "no deck"
|
| 349 |
+
yield ("error", why[:300], 0.0)
|
| 350 |
+
else:
|
| 351 |
+
yield ("done", st["deck"], 1.0)
|
|
|
|
|
|
|
| 352 |
|
| 353 |
|
| 354 |
def toggle_custom(style):
|
|
|
|
| 741 |
return demo
|
| 742 |
|
| 743 |
|
| 744 |
+
os.makedirs(DECKS_DIR, exist_ok=True)
|
| 745 |
+
demo = build_demo()
|
| 746 |
+
demo.queue(default_concurrency_limit=4)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 747 |
|
| 748 |
+
# ZeroGPU requires gradio's NATIVE launch — a custom uvicorn-mounted FastAPI app
|
| 749 |
+
# boots then immediately gets SIGTERM (ZeroGPU never registers). Card images are
|
| 750 |
+
# served via gradio's own static-file route (allowed_paths) instead of a /decks
|
| 751 |
+
# mount; the deck-view widget is inlined via iframe srcdoc instead of a /viewer
|
| 752 |
+
# mount. ssr_mode=False (ssr tries to bind a 2nd port and crashes here).
|
| 753 |
if __name__ == "__main__":
|
| 754 |
+
demo.launch(server_name="0.0.0.0", server_port=int(os.environ.get("PORT", 7860)),
|
| 755 |
+
allowed_paths=[DECKS_DIR, os.path.join(ROOT, "frontend")],
|
| 756 |
+
css=CSS, theme=gr.themes.Soft(), ssr_mode=False)
|
arcana/designer.py
CHANGED
|
@@ -148,23 +148,30 @@ def design_deck(theme: str, llm: LLM | None = None) -> dict:
|
|
| 148 |
system = designer_system_prompt()
|
| 149 |
user = designer_user_prompt(theme)
|
| 150 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
first_err = None
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
|
|
|
| 157 |
|
| 158 |
-
# one
|
| 159 |
repair = (
|
| 160 |
f"{user}\n\nYour previous reply was rejected: {first_err}. "
|
| 161 |
"Return ONLY the corrected strict JSON object with exactly 22 cards "
|
| 162 |
-
"covering arcana 0 through 21, each with non-empty concept, "
|
| 163 |
-
"justification, upright_meaning, reversed_meaning and art_prompt. "
|
| 164 |
"No prose, no code fences."
|
| 165 |
)
|
| 166 |
raw2 = llm.complete(system, repair, json_mode=True)
|
| 167 |
try:
|
| 168 |
return validate_deck(extract_json(raw2), theme)
|
| 169 |
except DeckError as e:
|
| 170 |
-
raise DeckError(f"deck invalid after
|
|
|
|
|
|
| 148 |
system = designer_system_prompt()
|
| 149 |
user = designer_user_prompt(theme)
|
| 150 |
|
| 151 |
+
# Fresh full generations first — structural slips (a duplicated concept, a
|
| 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:
|
| 176 |
+
raise DeckError(f"deck invalid after {attempts} tries + repair "
|
| 177 |
+
f"(first: {first_err}) -> {e}") from e
|
arcana/gpu_models.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""On-Space GPU model singletons for the ZeroGPU (local-first) build.
|
| 2 |
+
|
| 3 |
+
Both heavy models — the Nemotron GGUF (via llama.cpp) and FLUX.2 (via diffusers)
|
| 4 |
+
— are loaded LAZILY and cached process-wide here. They MUST be constructed inside
|
| 5 |
+
an ``@spaces.GPU`` context (that's the only time the GPU is attached on ZeroGPU),
|
| 6 |
+
so nothing here runs at import; the first call from inside a decorated function
|
| 7 |
+
does the load. CUDA must not initialise at import either (the process forks).
|
| 8 |
+
|
| 9 |
+
Hard-won ZeroGPU facts (June 2026, RTX PRO 6000 Blackwell / CUDA 13 / torch cu130):
|
| 10 |
+
* llama-cpp-python must be the **cu130** prebuilt wheel; its libllama.so needs
|
| 11 |
+
the CUDA-13 runtime libs which live under site-packages/nvidia/cu13/lib and
|
| 12 |
+
are NOT on LD_LIBRARY_PATH — preload them RTLD_GLOBAL before importing llama_cpp.
|
| 13 |
+
* The Nemotron GGUF reasons unless the assistant turn is seeded with an empty
|
| 14 |
+
``<think></think>`` block — so we hand-build a ChatML prompt + create_completion.
|
| 15 |
+
"""
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import ctypes
|
| 19 |
+
import glob
|
| 20 |
+
import os
|
| 21 |
+
|
| 22 |
+
# fast, robust multi-connection downloads from the Hub (the 24GB GGUF + FLUX
|
| 23 |
+
# weights crawl at ~8MB/s otherwise). Set before any huggingface_hub import.
|
| 24 |
+
os.environ.setdefault("HF_HUB_ENABLE_HF_TRANSFER", "1")
|
| 25 |
+
|
| 26 |
+
# ---- model selection (overridable via env) --------------------------------
|
| 27 |
+
# Nemotron via llama.cpp. We use Llama-3.1-Nemotron-Nano-8B (NVIDIA, dense
|
| 28 |
+
# Llama-arch) rather than the 30B-A3B MoE: at Q4 the 30B-MoE reliably left some
|
| 29 |
+
# 'concept' fields EMPTY in the 22-card mapping JSON and took ~5min/deck, whereas
|
| 30 |
+
# the dense 8B follows the structured prompt cleanly and fast. Still a Nemotron
|
| 31 |
+
# model (prize-qualifying) + llama.cpp + local-first.
|
| 32 |
+
LLM_REPO = os.environ.get("LOCAL_LLM_REPO", "bartowski/nvidia_Llama-3.1-Nemotron-Nano-8B-v1-GGUF")
|
| 33 |
+
LLM_FILE = os.environ.get("LOCAL_LLM_FILE", "nvidia_Llama-3.1-Nemotron-Nano-8B-v1-Q4_K_M.gguf")
|
| 34 |
+
IMAGE_MODEL = os.environ.get("LOCAL_IMAGE_MODEL", "black-forest-labs/FLUX.2-klein-base-4B")
|
| 35 |
+
LLM_N_CTX = int(os.environ.get("LOCAL_LLM_N_CTX", "16384"))
|
| 36 |
+
|
| 37 |
+
_llm = None
|
| 38 |
+
_pipe = None
|
| 39 |
+
_cuda_preloaded = False
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _preload_cuda() -> None:
|
| 43 |
+
"""Preload CUDA runtime libs so llama.cpp's CUDA .so can dlopen its deps."""
|
| 44 |
+
global _cuda_preloaded
|
| 45 |
+
if _cuda_preloaded:
|
| 46 |
+
return
|
| 47 |
+
names = ["libcudart.so.13", "libcublas.so.13", "libcublasLt.so.13",
|
| 48 |
+
"libcudart.so.12", "libcublas.so.12", "libcublasLt.so.12"]
|
| 49 |
+
roots = ["/usr/local/lib/python3.10/site-packages",
|
| 50 |
+
"/usr/local/lib/python3.11/site-packages"]
|
| 51 |
+
for name in names:
|
| 52 |
+
for root in roots:
|
| 53 |
+
hits = glob.glob(f"{root}/nvidia/*/lib/{name}")
|
| 54 |
+
if hits:
|
| 55 |
+
try:
|
| 56 |
+
ctypes.CDLL(hits[0], mode=ctypes.RTLD_GLOBAL)
|
| 57 |
+
except OSError:
|
| 58 |
+
pass
|
| 59 |
+
break
|
| 60 |
+
_cuda_preloaded = True
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def get_llama():
|
| 64 |
+
"""Lazily load + cache the Nemotron GGUF on the GPU (call inside @spaces.GPU)."""
|
| 65 |
+
global _llm
|
| 66 |
+
if _llm is None:
|
| 67 |
+
_preload_cuda()
|
| 68 |
+
from huggingface_hub import hf_hub_download
|
| 69 |
+
from llama_cpp import Llama
|
| 70 |
+
path = hf_hub_download(LLM_REPO, LLM_FILE, token=os.environ.get("HF_TOKEN"))
|
| 71 |
+
_llm = Llama(model_path=path, n_gpu_layers=-1, n_ctx=LLM_N_CTX, verbose=False)
|
| 72 |
+
return _llm
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def llama_chat(system: str, user: str, json_mode: bool = False,
|
| 76 |
+
max_tokens: int = 4096, temperature: float = 0.7) -> str:
|
| 77 |
+
"""Generate via create_chat_completion (applies the GGUF's own chat template —
|
| 78 |
+
correct for the 8B's Llama-3 format). 'detailed thinking off' is Nemotron's
|
| 79 |
+
control to skip the reasoning trace for clean prose; json_mode adds a JSON
|
| 80 |
+
grammar (response_format) so the mapping output is always parseable."""
|
| 81 |
+
llm = get_llama()
|
| 82 |
+
sysmsg = ("detailed thinking off\n\n" + system) if not json_mode else system
|
| 83 |
+
kwargs = dict(
|
| 84 |
+
messages=[{"role": "system", "content": sysmsg},
|
| 85 |
+
{"role": "user", "content": user}],
|
| 86 |
+
max_tokens=max_tokens, temperature=temperature)
|
| 87 |
+
if json_mode:
|
| 88 |
+
kwargs["response_format"] = {"type": "json_object"}
|
| 89 |
+
out = llm.create_chat_completion(**kwargs)
|
| 90 |
+
return (out["choices"][0]["message"]["content"] or "").strip()
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def get_pipe():
|
| 94 |
+
"""Lazily load + cache the FLUX.2 pipeline on the GPU (call inside @spaces.GPU)."""
|
| 95 |
+
global _pipe
|
| 96 |
+
if _pipe is None:
|
| 97 |
+
import torch
|
| 98 |
+
from diffusers import Flux2KleinPipeline
|
| 99 |
+
_pipe = Flux2KleinPipeline.from_pretrained(
|
| 100 |
+
IMAGE_MODEL, torch_dtype=torch.bfloat16, token=os.environ.get("HF_TOKEN"))
|
| 101 |
+
_pipe.to("cuda")
|
| 102 |
+
return _pipe
|
arcana/imagegen.py
CHANGED
|
@@ -91,16 +91,34 @@ class EndpointImageGen:
|
|
| 91 |
|
| 92 |
|
| 93 |
class LocalImageGen:
|
| 94 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
|
| 96 |
def generate(self, prompt: str, seed: int | None = None) -> Image.Image:
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
|
| 101 |
|
| 102 |
def get_imagegen(deck_style: str | None = None) -> ImageGen:
|
| 103 |
backend = os.environ.get("IMAGE_BACKEND", "endpoint").lower()
|
| 104 |
if backend == "local":
|
| 105 |
-
return LocalImageGen()
|
| 106 |
return EndpointImageGen(deck_style=deck_style)
|
|
|
|
| 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). klein-base-4B is sharp at just 4 steps.
|
| 98 |
+
"""
|
| 99 |
+
|
| 100 |
+
def __init__(self, deck_style: str | None = None, size: int = 1024,
|
| 101 |
+
steps: int | None = None):
|
| 102 |
+
self.deck_style = deck_style
|
| 103 |
+
self.size = int(os.environ.get("IMAGE_SIZE_PX", size))
|
| 104 |
+
self.steps = int(os.environ.get("IMAGE_STEPS", steps or 4))
|
| 105 |
|
| 106 |
def generate(self, prompt: str, seed: int | None = None) -> Image.Image:
|
| 107 |
+
import torch
|
| 108 |
+
from .gpu_models import get_pipe
|
| 109 |
+
pipe = get_pipe()
|
| 110 |
+
gen = None
|
| 111 |
+
if seed is not None:
|
| 112 |
+
gen = torch.Generator(device="cuda").manual_seed(int(seed))
|
| 113 |
+
img = pipe(prompt=_style(prompt, self.deck_style),
|
| 114 |
+
height=self.size, width=self.size,
|
| 115 |
+
num_inference_steps=self.steps, guidance_scale=4.0,
|
| 116 |
+
generator=gen).images[0]
|
| 117 |
+
return img.convert("RGB")
|
| 118 |
|
| 119 |
|
| 120 |
def get_imagegen(deck_style: str | None = None) -> ImageGen:
|
| 121 |
backend = os.environ.get("IMAGE_BACKEND", "endpoint").lower()
|
| 122 |
if backend == "local":
|
| 123 |
+
return LocalImageGen(deck_style=deck_style)
|
| 124 |
return EndpointImageGen(deck_style=deck_style)
|
arcana/llm.py
CHANGED
|
@@ -132,19 +132,30 @@ class FallbackLLM:
|
|
| 132 |
|
| 133 |
|
| 134 |
class LocalLLM:
|
| 135 |
-
"""
|
|
|
|
|
|
|
| 136 |
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
block on GPU access (SPEC §6, §12, §14).
|
| 141 |
"""
|
| 142 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
def complete(self, system: str, user: str, json_mode: bool = False,
|
| 144 |
timeout: float | None = None) -> str:
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
|
| 149 |
|
| 150 |
def get_llm() -> LLM:
|
|
|
|
| 132 |
|
| 133 |
|
| 134 |
class LocalLLM:
|
| 135 |
+
"""In-process Nemotron GGUF via llama.cpp on the Space's GPU — local-first,
|
| 136 |
+
llama.cpp badge, and the Nemotron prize all at once. Generation MUST run inside
|
| 137 |
+
an ``@spaces.GPU`` context (the model loads on first use); see gpu_models.
|
| 138 |
|
| 139 |
+
json_mode is honoured by nudging the prompt to emit only JSON — designer/
|
| 140 |
+
loremaster already extract+repair JSON, and the GGUF's reasoning trace is
|
| 141 |
+
suppressed (gpu_models.llama_complete) so the output starts at the JSON.
|
|
|
|
| 142 |
"""
|
| 143 |
|
| 144 |
+
def __init__(self, max_tokens: int = 14000, temperature: float = 0.7):
|
| 145 |
+
self.model = os.environ.get("LOCAL_LLM_FILE", "Nemotron-3-Nano-30B-A3B (llama.cpp)")
|
| 146 |
+
self.max_tokens = int(os.environ.get("QWEN_MAX_TOKENS", max_tokens))
|
| 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_chat
|
| 152 |
+
if json_mode:
|
| 153 |
+
# mapping: lower temp → fewer duplicate concepts (fewer retries);
|
| 154 |
+
# cap tokens → no runaway generation (22-card JSON fits in ~7k).
|
| 155 |
+
return llama_chat(system, user, json_mode=True, max_tokens=10000,
|
| 156 |
+
temperature=0.4)
|
| 157 |
+
return llama_chat(system, user, json_mode=False,
|
| 158 |
+
max_tokens=self.max_tokens, temperature=self.temperature)
|
| 159 |
|
| 160 |
|
| 161 |
def get_llm() -> LLM:
|
frontend/deckview.html
CHANGED
|
@@ -83,8 +83,7 @@
|
|
| 83 |
(function () {
|
| 84 |
function parseDeck() {
|
| 85 |
try {
|
| 86 |
-
const
|
| 87 |
-
const d = u.searchParams.get('d');
|
| 88 |
if (!d) return {back:'', cards:[]};
|
| 89 |
return JSON.parse(decodeURIComponent(escape(atob(d))));
|
| 90 |
} catch (e) { return {back:'', cards:[]}; }
|
|
|
|
| 83 |
(function () {
|
| 84 |
function parseDeck() {
|
| 85 |
try {
|
| 86 |
+
const d = window.DECK_B64 || new URL(window.location.href).searchParams.get('d');
|
|
|
|
| 87 |
if (!d) return {back:'', cards:[]};
|
| 88 |
return JSON.parse(decodeURIComponent(escape(atob(d))));
|
| 89 |
} catch (e) { return {back:'', cards:[]}; }
|
requirements.txt
CHANGED
|
@@ -1,6 +1,22 @@
|
|
| 1 |
-
|
|
|
|
|
|
|
| 2 |
openai>=1.40,<3
|
| 3 |
fastapi>=0.110
|
| 4 |
uvicorn>=0.30
|
| 5 |
pillow>=10
|
| 6 |
requests>=2.31
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Gradio is provided by the Space SDK (do NOT pin it — causes a resolver conflict).
|
| 2 |
+
# torch is preinstalled on ZeroGPU (cu130) — do NOT add it.
|
| 3 |
+
spaces
|
| 4 |
openai>=1.40,<3
|
| 5 |
fastapi>=0.110
|
| 6 |
uvicorn>=0.30
|
| 7 |
pillow>=10
|
| 8 |
requests>=2.31
|
| 9 |
+
huggingface_hub
|
| 10 |
+
hf_transfer
|
| 11 |
+
|
| 12 |
+
# LLM — Nemotron GGUF via llama.cpp on the GPU. Install the cu130 prebuilt wheel
|
| 13 |
+
# by DIRECT URL (an --extra-index-url here makes pip backtrack on every other
|
| 14 |
+
# package and the build hangs); the direct ref keeps resolution clean.
|
| 15 |
+
llama_cpp_python @ https://github.com/abetlen/llama-cpp-python/releases/download/v0.3.28-cu130/llama_cpp_python-0.3.28-py3-none-linux_x86_64.whl
|
| 16 |
+
|
| 17 |
+
# Image — FLUX.2 [klein] via diffusers (Flux2KleinPipeline needs diffusers-from-git)
|
| 18 |
+
git+https://github.com/huggingface/diffusers.git
|
| 19 |
+
transformers
|
| 20 |
+
accelerate
|
| 21 |
+
sentencepiece
|
| 22 |
+
protobuf
|