fix: re-run scribe on cards a failed chunk left blank; never paint an empty (subjectless) prompt
Browse files
app.py
CHANGED
|
@@ -448,6 +448,29 @@ def do_conjure(theme, style, custom):
|
|
| 448 |
yield from _run_parallel(chunks, fn=lambda i, ch: _gpu_scribe(deck, ch),
|
| 449 |
on_done=lambda res, ch: merge_fields(res, ch, _SCRIBE_OUT),
|
| 450 |
tick=scribe_tick)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 451 |
t_scribe = time.time() - t_scribe0
|
| 452 |
|
| 453 |
# Lore is done → OPEN THE REAL DECK VIEW now (names + full meanings); the art then
|
|
@@ -1060,6 +1083,153 @@ footer { display:none !important; }
|
|
| 1060 |
"""
|
| 1061 |
|
| 1062 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1063 |
# ----------------------------------------------------------------- ui
|
| 1064 |
def build_demo() -> gr.Blocks:
|
| 1065 |
with gr.Blocks(title="Arcana") as demo:
|
|
@@ -1211,6 +1381,21 @@ def build_demo() -> gr.Blocks:
|
|
| 1211 |
# on load, collapse to just the landing page.
|
| 1212 |
demo.load(lambda: show("landing"), None, pages)
|
| 1213 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1214 |
return demo
|
| 1215 |
|
| 1216 |
|
|
|
|
| 448 |
yield from _run_parallel(chunks, fn=lambda i, ch: _gpu_scribe(deck, ch),
|
| 449 |
on_done=lambda res, ch: merge_fields(res, ch, _SCRIBE_OUT),
|
| 450 |
tick=scribe_tick)
|
| 451 |
+
|
| 452 |
+
# RECONCILE: if a scribe chunk failed, its cards still have blank meaning/art.
|
| 453 |
+
# Re-run the scribe on JUST those (one more GPU pass) so we never paint an empty,
|
| 454 |
+
# subjectless prompt — which FLUX fills with a generic medieval/religious figure —
|
| 455 |
+
# and so those cards aren't left meaningless in the reading.
|
| 456 |
+
def _blank(c):
|
| 457 |
+
return not (c.get("art_prompt") or "").strip()
|
| 458 |
+
missing = [c["arcana_number"] for c in deck["cards"] if _blank(c)]
|
| 459 |
+
for _try in range(2):
|
| 460 |
+
if not missing:
|
| 461 |
+
break
|
| 462 |
+
yield stay("<div class='gen-load'><div class='gen-spin'></div>"
|
| 463 |
+
"<h2>✦ Writing the lore</h2><p>finishing the last cards…</p></div>")
|
| 464 |
+
try:
|
| 465 |
+
merge_fields(_gpu_scribe(deck, missing), missing, _SCRIBE_OUT)
|
| 466 |
+
except Exception as e:
|
| 467 |
+
print(f"[SCRIBE_RECONCILE_ERR] {missing}: {type(e).__name__}: {e}", flush=True)
|
| 468 |
+
missing = [c["arcana_number"] for c in deck["cards"] if _blank(c)]
|
| 469 |
+
if missing: # still blank after retries → at least give FLUX the concept as subject
|
| 470 |
+
print(f"[SCRIBE_FALLBACK] concept-only art for {missing}", flush=True)
|
| 471 |
+
for c in deck["cards"]:
|
| 472 |
+
if _blank(c):
|
| 473 |
+
c["art_prompt"] = c["concept"]
|
| 474 |
t_scribe = time.time() - t_scribe0
|
| 475 |
|
| 476 |
# Lore is done → OPEN THE REAL DECK VIEW now (names + full meanings); the art then
|
|
|
|
| 1083 |
"""
|
| 1084 |
|
| 1085 |
|
| 1086 |
+
TRACE_ON = os.environ.get("ARCANA_TRACE", "") == "1" # the JG1310 trace-logging copy sets this
|
| 1087 |
+
|
| 1088 |
+
|
| 1089 |
+
def _assemble_trace(steps, task, theme, visual_style, deck_id, models, pipeline, extra=None):
|
| 1090 |
+
import hashlib
|
| 1091 |
+
from datetime import datetime, timezone
|
| 1092 |
+
trace_id = hashlib.sha1(f"{deck_id}:{task}".encode("utf-8")).hexdigest()[:8]
|
| 1093 |
+
obj = {
|
| 1094 |
+
"trace_id": trace_id, "app": "Arcana", "task": task,
|
| 1095 |
+
"theme": theme, "visual_style": visual_style, "deck_id": deck_id,
|
| 1096 |
+
"ts": datetime.now(timezone.utc).isoformat(),
|
| 1097 |
+
"models": models, "pipeline": pipeline, "n_steps": len(steps),
|
| 1098 |
+
"provenance": ("Live capture — every step's prompts, raw model output, and "
|
| 1099 |
+
"latency were recorded as the pipeline ran (sequential trace "
|
| 1100 |
+
"harness on the JG1310/arcana-gpu-dev trace-logging copy)."),
|
| 1101 |
+
"steps": steps,
|
| 1102 |
+
}
|
| 1103 |
+
if extra:
|
| 1104 |
+
obj.update(extra)
|
| 1105 |
+
return obj
|
| 1106 |
+
|
| 1107 |
+
|
| 1108 |
+
_TR_TEXT = {"name": "Qwen3-14B", "backend": "llama.cpp"}
|
| 1109 |
+
_TR_IMG = {"name": "FLUX.2-klein-9B", "backend": "diffusers"}
|
| 1110 |
+
|
| 1111 |
+
|
| 1112 |
+
def _heartbeat(target, args):
|
| 1113 |
+
"""Run `target(*args, state)` in a worker thread, yielding a heartbeat every ~3s so
|
| 1114 |
+
the SSE stream stays alive through long GPU phases; final yield is the result/ERROR.
|
| 1115 |
+
Splitting the work into SHORT GPU calls (this is used per-phase) also avoids the
|
| 1116 |
+
'GPU task aborted' that a single multi-minute ZeroGPU hold triggers."""
|
| 1117 |
+
import threading, time as _t
|
| 1118 |
+
state = {"done": False, "result": None, "err": None, "msg": "starting…"}
|
| 1119 |
+
th = threading.Thread(target=target, args=(*args, state), daemon=True)
|
| 1120 |
+
th.start()
|
| 1121 |
+
while not state["done"]:
|
| 1122 |
+
_t.sleep(3)
|
| 1123 |
+
yield f"⏳ {state['msg']}"
|
| 1124 |
+
yield ("ERROR\n" + state["err"]) if state["err"] else state["result"]
|
| 1125 |
+
|
| 1126 |
+
|
| 1127 |
+
def _run_llm_phase(theme, style, custom, question, state):
|
| 1128 |
+
"""designer → scribe (4 chunks) → reader, all through tracing wrappers. Returns the
|
| 1129 |
+
deck + the LLM trace steps (no images here, so it stays a short ~150s GPU call)."""
|
| 1130 |
+
import json as _json, traceback
|
| 1131 |
+
from arcana import tracing, gpu_models as gm
|
| 1132 |
+
from arcana.styles import resolve_style
|
| 1133 |
+
from arcana.designer import design_deck
|
| 1134 |
+
from arcana.loremaster import scribe_deck
|
| 1135 |
+
from arcana.build import deck_dir, slugify, _seed_base
|
| 1136 |
+
from arcana.reader import draw_spread, card_partial, final_synthesis
|
| 1137 |
+
from arcana.llm import get_llm
|
| 1138 |
+
try:
|
| 1139 |
+
theme = (theme or "Greek mythology").strip()
|
| 1140 |
+
question = (question or "").strip()
|
| 1141 |
+
TEXT = {**_TR_TEXT, "repo": gm.MAP_REPO, "file": gm.MAP_FILE}
|
| 1142 |
+
gtr = tracing.Tracer()
|
| 1143 |
+
gllm = tracing.TracingLLM(get_llm(), gtr)
|
| 1144 |
+
state["msg"] = "designer: mapping the 22 arcana…"
|
| 1145 |
+
gtr.ctx("designer", "mapping")
|
| 1146 |
+
deck = design_deck(theme, llm=gllm)
|
| 1147 |
+
style_id, style_suffix = resolve_style(style, custom)
|
| 1148 |
+
deck_id = f"{slugify(theme)}-{style_id}-{int(time.time())}"
|
| 1149 |
+
os.makedirs(deck_dir(deck_id), exist_ok=True)
|
| 1150 |
+
deck.update({"deck_id": deck_id, "theme": theme, "visual_style": style_id,
|
| 1151 |
+
"style_suffix": style_suffix, "seed_base": _seed_base(theme, style_id)})
|
| 1152 |
+
nums = [c["arcana_number"] for c in deck["cards"]]
|
| 1153 |
+
chunks = [sorted(c) for c in (nums[i::4] for i in range(4)) if c]
|
| 1154 |
+
for wi, ch in enumerate(chunks):
|
| 1155 |
+
state["msg"] = f"scribe: writing lore + art ({wi + 1}/{len(chunks)} batches)…"
|
| 1156 |
+
gtr.ctx("scribe", "meaning+art", worker=wi, cards=ch)
|
| 1157 |
+
scribe_deck(deck, subset=ch, llm=gllm)
|
| 1158 |
+
gen_llm_steps = gtr.steps
|
| 1159 |
+
|
| 1160 |
+
rtr = tracing.Tracer()
|
| 1161 |
+
rllm = tracing.TracingLLM(get_llm(), rtr)
|
| 1162 |
+
drawn = draw_spread(deck, spread="three", reversals=True, seed=7)
|
| 1163 |
+
partials = []
|
| 1164 |
+
for i in range(len(drawn)):
|
| 1165 |
+
state["msg"] = f"reader: interpreting card {i + 1}/{len(drawn)}…"
|
| 1166 |
+
rtr.ctx("reader", "card_reading", position=drawn[i]["position"],
|
| 1167 |
+
concept=drawn[i]["concept"], orientation=drawn[i]["orientation"])
|
| 1168 |
+
partials.append(card_partial(deck, question, drawn, i, llm=rllm))
|
| 1169 |
+
state["msg"] = "reader: final synthesis…"
|
| 1170 |
+
rtr.ctx("reader", "synthesis")
|
| 1171 |
+
final_synthesis(deck, question, drawn, partials, llm=rllm)
|
| 1172 |
+
|
| 1173 |
+
meta = {"theme": theme, "visual_style": style_id, "deck_id": deck_id,
|
| 1174 |
+
"question": question or "(open reading)", "spread": "three",
|
| 1175 |
+
"text_model": TEXT, "image_model": _TR_IMG,
|
| 1176 |
+
"drawn": [{"position": d["position"], "concept": d["concept"],
|
| 1177 |
+
"orientation": d["orientation"]} for d in drawn]}
|
| 1178 |
+
state["result"] = _json.dumps({"deck": deck, "gen_llm_steps": gen_llm_steps,
|
| 1179 |
+
"read_steps": rtr.steps, "meta": meta},
|
| 1180 |
+
ensure_ascii=False)
|
| 1181 |
+
except Exception:
|
| 1182 |
+
state["err"] = traceback.format_exc()
|
| 1183 |
+
finally:
|
| 1184 |
+
state["done"] = True
|
| 1185 |
+
|
| 1186 |
+
|
| 1187 |
+
def _run_paint_phase(deck_json, subset_csv, state):
|
| 1188 |
+
"""Trace the FLUX image calls for a subset of cards (or 'back'). Image is discarded —
|
| 1189 |
+
the trace records the real call (styled prompt the model receives, seed, latency).
|
| 1190 |
+
Kept to a short subset per call so the GPU hold stays well under the abort threshold."""
|
| 1191 |
+
import json as _json, traceback
|
| 1192 |
+
from arcana import tracing, gpu_models as gm
|
| 1193 |
+
from arcana.imagegen import get_imagegen
|
| 1194 |
+
from arcana.build import _back_prompt
|
| 1195 |
+
try:
|
| 1196 |
+
deck = _json.loads(deck_json)
|
| 1197 |
+
by_n = {c["arcana_number"]: c for c in deck["cards"]}
|
| 1198 |
+
style_suffix = deck.get("style_suffix")
|
| 1199 |
+
seed_base = deck["seed_base"]
|
| 1200 |
+
IMG = {**_TR_IMG, "repo": gm.IMAGE_MODEL}
|
| 1201 |
+
tr = tracing.Tracer()
|
| 1202 |
+
tig = tracing.TracingImageGen(get_imagegen(deck_style=style_suffix), tr, style_suffix, IMG["name"])
|
| 1203 |
+
if subset_csv.strip().lower() == "back":
|
| 1204 |
+
state["msg"] = "painter: deck-back…"
|
| 1205 |
+
tig.generate(_back_prompt(style_suffix), seed=seed_base - 1)
|
| 1206 |
+
tr.steps[-1]["card"] = {"deck_back": True}
|
| 1207 |
+
else:
|
| 1208 |
+
nums = [int(x) for x in subset_csv.split(",") if x.strip() != ""]
|
| 1209 |
+
for i, m in enumerate(nums):
|
| 1210 |
+
state["msg"] = f"painter: FLUX rendering {i + 1}/{len(nums)} (card {m})…"
|
| 1211 |
+
tig.generate(by_n[m].get("art_prompt", ""), seed=seed_base + m)
|
| 1212 |
+
tr.steps[-1]["card"] = {"arcana_number": m, "arcana_name": by_n[m].get("arcana_name"),
|
| 1213 |
+
"concept": by_n[m].get("concept")}
|
| 1214 |
+
state["result"] = _json.dumps({"paint_steps": tr.steps}, ensure_ascii=False)
|
| 1215 |
+
except Exception:
|
| 1216 |
+
state["err"] = traceback.format_exc()
|
| 1217 |
+
finally:
|
| 1218 |
+
state["done"] = True
|
| 1219 |
+
|
| 1220 |
+
|
| 1221 |
+
@GPU(duration=300)
|
| 1222 |
+
def trace_llm(theme, style, custom, question):
|
| 1223 |
+
"""LIVE trace (trace-logging copy only): designer + scribe + reader in one short call."""
|
| 1224 |
+
yield from _heartbeat(_run_llm_phase, (theme, style, custom, question))
|
| 1225 |
+
|
| 1226 |
+
|
| 1227 |
+
@GPU(duration=300)
|
| 1228 |
+
def trace_paint(deck_json, subset_csv):
|
| 1229 |
+
"""LIVE trace (trace-logging copy only): FLUX image calls for a subset of cards."""
|
| 1230 |
+
yield from _heartbeat(_run_paint_phase, (deck_json, subset_csv))
|
| 1231 |
+
|
| 1232 |
+
|
| 1233 |
# ----------------------------------------------------------------- ui
|
| 1234 |
def build_demo() -> gr.Blocks:
|
| 1235 |
with gr.Blocks(title="Arcana") as demo:
|
|
|
|
| 1381 |
# on load, collapse to just the landing page.
|
| 1382 |
demo.load(lambda: show("landing"), None, pages)
|
| 1383 |
|
| 1384 |
+
# TRACE-LOGGING COPY ONLY (ARCANA_TRACE=1): a hidden endpoint that runs the full
|
| 1385 |
+
# pipeline through tracing wrappers and returns the live traces. Never registered
|
| 1386 |
+
# on the production Space, so its behaviour is byte-identical there.
|
| 1387 |
+
if TRACE_ON:
|
| 1388 |
+
_llm_in = [gr.Textbox("Greek mythology", visible=False),
|
| 1389 |
+
gr.Textbox("rider-waite-smith", visible=False),
|
| 1390 |
+
gr.Textbox("", visible=False), gr.Textbox("", visible=False)]
|
| 1391 |
+
_llm_out = gr.Textbox(visible=False)
|
| 1392 |
+
gr.Button(visible=False).click(trace_llm, _llm_in, _llm_out, api_name="trace_llm")
|
| 1393 |
+
_pt_deck = gr.Textbox("", visible=False)
|
| 1394 |
+
_pt_sub = gr.Textbox("", visible=False)
|
| 1395 |
+
_pt_out = gr.Textbox(visible=False)
|
| 1396 |
+
gr.Button(visible=False).click(trace_paint, [_pt_deck, _pt_sub], _pt_out,
|
| 1397 |
+
api_name="trace_paint")
|
| 1398 |
+
|
| 1399 |
return demo
|
| 1400 |
|
| 1401 |
|