Phase 0 + A: capability probe, SQLite storage + HF login
Browse files- .gitignore +1 -0
- README.md +6 -2
- app.py +42 -21
- probe_nemotron.py +252 -0
- store.py +112 -0
.gitignore
CHANGED
|
@@ -59,6 +59,7 @@ gradio_cached_examples/
|
|
| 59 |
# Generated adventures / sites, model caches, local DBs
|
| 60 |
generated_sites/
|
| 61 |
adventures/
|
|
|
|
| 62 |
*.gguf
|
| 63 |
*.sqlite
|
| 64 |
*.db
|
|
|
|
| 59 |
# Generated adventures / sites, model caches, local DBs
|
| 60 |
generated_sites/
|
| 61 |
adventures/
|
| 62 |
+
.frogquest/ # local dev SQLite (store.py fallback when /data isn't mounted) + WAL sidecars
|
| 63 |
*.gguf
|
| 64 |
*.sqlite
|
| 65 |
*.db
|
README.md
CHANGED
|
@@ -7,6 +7,7 @@ sdk: gradio
|
|
| 7 |
sdk_version: 6.17.3
|
| 8 |
python_version: '3.12'
|
| 9 |
app_file: app.py
|
|
|
|
| 10 |
pinned: true
|
| 11 |
license: apache-2.0
|
| 12 |
short_description: A text-image based RPG built to help fight procrastination
|
|
@@ -26,8 +27,11 @@ boss quest you face first.
|
|
| 26 |
- **FLUX.2 [klein] 4B** (diffusers) generates each quest's image using your uploaded photo as a
|
| 27 |
reference, then edits that same image into success / "retreat to fight another day" states.
|
| 28 |
|
| 29 |
-
|
| 30 |
-
|
|
|
|
|
|
|
|
|
|
| 31 |
|
| 32 |
### GPU backend (ZeroGPU or Modal)
|
| 33 |
|
|
|
|
| 7 |
sdk_version: 6.17.3
|
| 8 |
python_version: '3.12'
|
| 9 |
app_file: app.py
|
| 10 |
+
hf_oauth: true
|
| 11 |
pinned: true
|
| 12 |
license: apache-2.0
|
| 13 |
short_description: A text-image based RPG built to help fight procrastination
|
|
|
|
| 27 |
- **FLUX.2 [klein] 4B** (diffusers) generates each quest's image using your uploaded photo as a
|
| 28 |
reference, then edits that same image into success / "retreat to fight another day" states.
|
| 29 |
|
| 30 |
+
Logged out, your photo and quest state live **only in your browser** (localStorage). When you
|
| 31 |
+
**log in with your Hugging Face account**, the same state — photo, quests, campaigns, and generated
|
| 32 |
+
images — is also saved to your **private per-account record** (SQLite on the Space's persistent
|
| 33 |
+
storage) so it survives a cleared browser. The photo is sent to the GPU only transiently during
|
| 34 |
+
generation.
|
| 35 |
|
| 36 |
### GPU backend (ZeroGPU or Modal)
|
| 37 |
|
app.py
CHANGED
|
@@ -9,8 +9,9 @@ Layout (one page, no onboarding gate):
|
|
| 9 |
|
| 10 |
State lives in gr.BrowserState (per-browser localStorage): the resized photo (base64), the
|
| 11 |
chosen world/theme, the validated adventure/quests JSON, the selected quest id, and a cache of
|
| 12 |
-
generated scene images (JPEG data-urls).
|
| 13 |
-
|
|
|
|
| 14 |
|
| 15 |
Images generate LAZILY: selecting a quest with no cached scene generates it on the spot; Done /
|
| 16 |
Couldn't EDIT the cached initial scene into a success / failure state (never regenerate).
|
|
@@ -32,6 +33,7 @@ import spaces
|
|
| 32 |
from PIL import Image
|
| 33 |
|
| 34 |
from schema import THEMES, merge_quests, validate_and_clamp
|
|
|
|
| 35 |
|
| 36 |
|
| 37 |
# ZeroGPU scans for a @spaces.GPU function at startup; this guarantees detection regardless
|
|
@@ -403,10 +405,24 @@ def reset_all():
|
|
| 403 |
_desc_html(None, None), _stats_html([]), "fantasy")
|
| 404 |
|
| 405 |
|
| 406 |
-
def
|
| 407 |
-
"""
|
| 408 |
-
|
| 409 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 410 |
photo = b.get("photo")
|
| 411 |
quests = b.get("quests") or []
|
| 412 |
adventure = b.get("adventure")
|
|
@@ -422,7 +438,7 @@ def boot(browser):
|
|
| 422 |
if data:
|
| 423 |
scene = _dataurl_to_pil(data)
|
| 424 |
photo_pil = _dataurl_to_pil(photo) if photo else None
|
| 425 |
-
return (photo_pil, photo, quests, adventure, selected, images,
|
| 426 |
scene, _desc_html(quest, adventure), _stats_html(quests), theme)
|
| 427 |
|
| 428 |
|
|
@@ -439,8 +455,11 @@ with gr.Blocks(title="FrogQuest") as demo:
|
|
| 439 |
adventure_state = gr.State(None) # title / art_style / seed / theme
|
| 440 |
selected_id_state = gr.State(None) # currently selected quest id
|
| 441 |
images_state = gr.State({}) # {quest_id: {initial/success/failure: jpeg data-url}}
|
|
|
|
| 442 |
|
| 443 |
-
gr.
|
|
|
|
|
|
|
| 444 |
|
| 445 |
with gr.Row(elem_classes=["fq-app-grid"]):
|
| 446 |
# ---------- LEFT: hero ----------
|
|
@@ -450,8 +469,9 @@ with gr.Blocks(title="FrogQuest") as demo:
|
|
| 450 |
type="pil", sources=["upload"], show_label=False, height=190,
|
| 451 |
elem_classes=["fq-photo"],
|
| 452 |
)
|
| 453 |
-
gr.HTML('<p class="hint">Click to upload / change your photo.
|
| 454 |
-
'
|
|
|
|
| 455 |
stats_html = gr.HTML(_stats_html([]))
|
| 456 |
gr.HTML('<h3 class="panel-subtitle">WORLD</h3>')
|
| 457 |
theme_radio = gr.Radio(
|
|
@@ -512,7 +532,7 @@ with gr.Blocks(title="FrogQuest") as demo:
|
|
| 512 |
select_quest(qid, photo, adv, imgs, qs, br)))(q["id"]),
|
| 513 |
inputs=[photo_state, adventure_state, images_state, quests_state, browser],
|
| 514 |
outputs=[selected_id_state, scene_image, desc_html, images_state, browser],
|
| 515 |
-
)
|
| 516 |
# Clear button appears only once a quest is done/failed, to remove it.
|
| 517 |
if finished:
|
| 518 |
clr = gr.Button("✕", elem_classes=["pix-btn", "small", "fq-task-clear"])
|
|
@@ -522,7 +542,7 @@ with gr.Blocks(title="FrogQuest") as demo:
|
|
| 522 |
inputs=[quests_state, adventure_state, images_state, selected_id_state, browser],
|
| 523 |
outputs=[quests_state, images_state, selected_id_state, scene_image,
|
| 524 |
desc_html, stats_html, browser],
|
| 525 |
-
)
|
| 526 |
|
| 527 |
# ---------- BOTTOM: Frog Master chat ----------
|
| 528 |
with gr.Row(elem_classes=["fq-chatbar"]):
|
|
@@ -535,36 +555,37 @@ with gr.Blocks(title="FrogQuest") as demo:
|
|
| 535 |
'"I finished the report" · "Couldn\'t do the gym — too tired"</p><p>Where to Start, Where to Break and let Go,<br>Where to change <br>Where to look and to grow </p>')
|
| 536 |
|
| 537 |
# ---------- wiring ----------
|
| 538 |
-
photo_image.upload(upload_photo, [photo_image, browser], [photo_state, browser]
|
|
|
|
| 539 |
theme_radio.change(
|
| 540 |
change_theme, [theme_radio, adventure_state, images_state, browser],
|
| 541 |
[adventure_state, images_state, scene_image, desc_html, browser],
|
| 542 |
-
)
|
| 543 |
reset_btn.click(
|
| 544 |
reset_all, None,
|
| 545 |
[browser, photo_image, photo_state, quests_state, adventure_state, selected_id_state,
|
| 546 |
images_state, scene_image, desc_html, stats_html, theme_radio],
|
| 547 |
-
)
|
| 548 |
done_btn.click(
|
| 549 |
apply_done, [selected_id_state, quests_state, adventure_state, photo_state, images_state, browser],
|
| 550 |
[quests_state, images_state, scene_image, desc_html, stats_html, browser],
|
| 551 |
-
)
|
| 552 |
couldnt_btn.click(
|
| 553 |
apply_couldnt, [selected_id_state, quests_state, adventure_state, photo_state, images_state, browser],
|
| 554 |
[quests_state, images_state, scene_image, desc_html, stats_html, browser],
|
| 555 |
-
)
|
| 556 |
|
| 557 |
_chat_inputs = [chat_input, quests_state, adventure_state, theme_radio, photo_state,
|
| 558 |
selected_id_state, images_state, browser]
|
| 559 |
_chat_outputs = [chat_input, quests_state, adventure_state, selected_id_state, images_state,
|
| 560 |
scene_image, desc_html, stats_html, browser]
|
| 561 |
-
send_btn.click(chat_send, _chat_inputs, _chat_outputs)
|
| 562 |
-
chat_input.submit(chat_send, _chat_inputs, _chat_outputs)
|
| 563 |
|
| 564 |
demo.load(
|
| 565 |
boot, [browser],
|
| 566 |
-
[photo_image, photo_state, quests_state, adventure_state,
|
| 567 |
-
scene_image, desc_html, stats_html, theme_radio],
|
| 568 |
)
|
| 569 |
|
| 570 |
|
|
|
|
| 9 |
|
| 10 |
State lives in gr.BrowserState (per-browser localStorage): the resized photo (base64), the
|
| 11 |
chosen world/theme, the validated adventure/quests JSON, the selected quest id, and a cache of
|
| 12 |
+
generated scene images (JPEG data-urls). When you log in with an HF account, the SAME full state
|
| 13 |
+
(photo and generated images included) is also saved to your private per-account record in SQLite
|
| 14 |
+
(store.py) so it survives a cleared browser; logged-out use stays browser-only.
|
| 15 |
|
| 16 |
Images generate LAZILY: selecting a quest with no cached scene generates it on the spot; Done /
|
| 17 |
Couldn't EDIT the cached initial scene into a success / failure state (never regenerate).
|
|
|
|
| 33 |
from PIL import Image
|
| 34 |
|
| 35 |
from schema import THEMES, merge_quests, validate_and_clamp
|
| 36 |
+
import store
|
| 37 |
|
| 38 |
|
| 39 |
# ZeroGPU scans for a @spaces.GPU function at startup; this guarantees detection regardless
|
|
|
|
| 405 |
_desc_html(None, None), _stats_html([]), "fantasy")
|
| 406 |
|
| 407 |
|
| 408 |
+
def persist(uid, browser):
|
| 409 |
+
"""Write-through the full state to the user's durable SQLite record. No-op when logged out
|
| 410 |
+
(anonymous users live in BrowserState only). Bound via .then(...) after every write event."""
|
| 411 |
+
store.save(uid, browser)
|
| 412 |
+
|
| 413 |
+
|
| 414 |
+
def boot(browser, request: gr.Request):
|
| 415 |
+
"""Restore on page load. If logged in (HF OAuth) the durable SQLite record is the source of
|
| 416 |
+
truth and is mirrored back into BrowserState; otherwise BrowserState is used, and migrated into
|
| 417 |
+
SQLite on first login. Shows cached scenes only (never generates) so reloads are instant."""
|
| 418 |
+
uid = getattr(request, "username", None) if request is not None else None
|
| 419 |
+
saved = store.load(uid)
|
| 420 |
+
if saved:
|
| 421 |
+
b = saved
|
| 422 |
+
else:
|
| 423 |
+
b = browser or _default_state()
|
| 424 |
+
if uid:
|
| 425 |
+
store.save(uid, b) # first login on this account: adopt the browser's current state
|
| 426 |
photo = b.get("photo")
|
| 427 |
quests = b.get("quests") or []
|
| 428 |
adventure = b.get("adventure")
|
|
|
|
| 438 |
if data:
|
| 439 |
scene = _dataurl_to_pil(data)
|
| 440 |
photo_pil = _dataurl_to_pil(photo) if photo else None
|
| 441 |
+
return (uid, b, photo_pil, photo, quests, adventure, selected, images,
|
| 442 |
scene, _desc_html(quest, adventure), _stats_html(quests), theme)
|
| 443 |
|
| 444 |
|
|
|
|
| 455 |
adventure_state = gr.State(None) # title / art_style / seed / theme
|
| 456 |
selected_id_state = gr.State(None) # currently selected quest id
|
| 457 |
images_state = gr.State({}) # {quest_id: {initial/success/failure: jpeg data-url}}
|
| 458 |
+
uid_state = gr.State(None) # HF login id (None = anonymous); set by boot from gr.Request
|
| 459 |
|
| 460 |
+
with gr.Row(elem_classes=["fq-topbar"]):
|
| 461 |
+
gr.HTML('<h1 class="fq-logo">FROG<b>QUEST</b></h1>')
|
| 462 |
+
login_btn = gr.LoginButton(elem_classes=["fq-login"])
|
| 463 |
|
| 464 |
with gr.Row(elem_classes=["fq-app-grid"]):
|
| 465 |
# ---------- LEFT: hero ----------
|
|
|
|
| 469 |
type="pil", sources=["upload"], show_label=False, height=190,
|
| 470 |
elem_classes=["fq-photo"],
|
| 471 |
)
|
| 472 |
+
gr.HTML('<p class="hint">Click to upload / change your photo. Sent to the GPU only to '
|
| 473 |
+
'draw your scenes. Stored in your browser, and in your private account record '
|
| 474 |
+
'when you log in.</p>')
|
| 475 |
stats_html = gr.HTML(_stats_html([]))
|
| 476 |
gr.HTML('<h3 class="panel-subtitle">WORLD</h3>')
|
| 477 |
theme_radio = gr.Radio(
|
|
|
|
| 532 |
select_quest(qid, photo, adv, imgs, qs, br)))(q["id"]),
|
| 533 |
inputs=[photo_state, adventure_state, images_state, quests_state, browser],
|
| 534 |
outputs=[selected_id_state, scene_image, desc_html, images_state, browser],
|
| 535 |
+
).then(persist, [uid_state, browser])
|
| 536 |
# Clear button appears only once a quest is done/failed, to remove it.
|
| 537 |
if finished:
|
| 538 |
clr = gr.Button("✕", elem_classes=["pix-btn", "small", "fq-task-clear"])
|
|
|
|
| 542 |
inputs=[quests_state, adventure_state, images_state, selected_id_state, browser],
|
| 543 |
outputs=[quests_state, images_state, selected_id_state, scene_image,
|
| 544 |
desc_html, stats_html, browser],
|
| 545 |
+
).then(persist, [uid_state, browser])
|
| 546 |
|
| 547 |
# ---------- BOTTOM: Frog Master chat ----------
|
| 548 |
with gr.Row(elem_classes=["fq-chatbar"]):
|
|
|
|
| 555 |
'"I finished the report" · "Couldn\'t do the gym — too tired"</p><p>Where to Start, Where to Break and let Go,<br>Where to change <br>Where to look and to grow </p>')
|
| 556 |
|
| 557 |
# ---------- wiring ----------
|
| 558 |
+
photo_image.upload(upload_photo, [photo_image, browser], [photo_state, browser]
|
| 559 |
+
).then(persist, [uid_state, browser])
|
| 560 |
theme_radio.change(
|
| 561 |
change_theme, [theme_radio, adventure_state, images_state, browser],
|
| 562 |
[adventure_state, images_state, scene_image, desc_html, browser],
|
| 563 |
+
).then(persist, [uid_state, browser])
|
| 564 |
reset_btn.click(
|
| 565 |
reset_all, None,
|
| 566 |
[browser, photo_image, photo_state, quests_state, adventure_state, selected_id_state,
|
| 567 |
images_state, scene_image, desc_html, stats_html, theme_radio],
|
| 568 |
+
).then(persist, [uid_state, browser])
|
| 569 |
done_btn.click(
|
| 570 |
apply_done, [selected_id_state, quests_state, adventure_state, photo_state, images_state, browser],
|
| 571 |
[quests_state, images_state, scene_image, desc_html, stats_html, browser],
|
| 572 |
+
).then(persist, [uid_state, browser])
|
| 573 |
couldnt_btn.click(
|
| 574 |
apply_couldnt, [selected_id_state, quests_state, adventure_state, photo_state, images_state, browser],
|
| 575 |
[quests_state, images_state, scene_image, desc_html, stats_html, browser],
|
| 576 |
+
).then(persist, [uid_state, browser])
|
| 577 |
|
| 578 |
_chat_inputs = [chat_input, quests_state, adventure_state, theme_radio, photo_state,
|
| 579 |
selected_id_state, images_state, browser]
|
| 580 |
_chat_outputs = [chat_input, quests_state, adventure_state, selected_id_state, images_state,
|
| 581 |
scene_image, desc_html, stats_html, browser]
|
| 582 |
+
send_btn.click(chat_send, _chat_inputs, _chat_outputs).then(persist, [uid_state, browser])
|
| 583 |
+
chat_input.submit(chat_send, _chat_inputs, _chat_outputs).then(persist, [uid_state, browser])
|
| 584 |
|
| 585 |
demo.load(
|
| 586 |
boot, [browser],
|
| 587 |
+
[uid_state, browser, photo_image, photo_state, quests_state, adventure_state,
|
| 588 |
+
selected_id_state, images_state, scene_image, desc_html, stats_html, theme_radio],
|
| 589 |
)
|
| 590 |
|
| 591 |
|
probe_nemotron.py
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Phase 0 capability probe (THROWAWAY — not part of the app, safe to delete).
|
| 2 |
+
|
| 3 |
+
Question it answers: how capable is Nemotron Nano 4B at the *narrow* language jobs the
|
| 4 |
+
"thin LLM, fat code" plan leaves to it? We do NOT test scheduling/structure here — that's
|
| 5 |
+
deterministic Python. We test only:
|
| 6 |
+
|
| 7 |
+
1. intent — classify a user message into the router enum (reuses the app's real prompt/schema)
|
| 8 |
+
2. ask — turn a long-term goal into 1-3 clarifying questions
|
| 9 |
+
3. plan — turn a goal + web-research snippets into an ordered milestone list
|
| 10 |
+
4. quest — turn one milestone into themed quest title + narrative
|
| 11 |
+
|
| 12 |
+
For each task we report: did it return VALID JSON to the schema, and a light quality heuristic.
|
| 13 |
+
Eyeball the printed outputs — the pass-rate is a guardrail, the text quality is the real signal.
|
| 14 |
+
|
| 15 |
+
Run:
|
| 16 |
+
python probe_nemotron.py # auto: GPU if available, else CPU (slow, ~4.3GB download)
|
| 17 |
+
FROGQUEST_PROBE_GPU_LAYERS=-1 python probe_nemotron.py # force all layers on GPU
|
| 18 |
+
FROGQUEST_PROBE_GPU_LAYERS=0 python probe_nemotron.py # force CPU
|
| 19 |
+
|
| 20 |
+
Reuses gpu_shared (model id/config, extract_json, CUDA preloader) and schema (INTENT_SCHEMA)
|
| 21 |
+
so the probe exercises the same path the app will.
|
| 22 |
+
"""
|
| 23 |
+
from __future__ import annotations
|
| 24 |
+
|
| 25 |
+
import importlib.util
|
| 26 |
+
import os
|
| 27 |
+
import time
|
| 28 |
+
|
| 29 |
+
# Faster downloads only if hf_transfer is actually installed — setting the flag without the
|
| 30 |
+
# package makes huggingface_hub raise on download (common on a fresh local CPU box).
|
| 31 |
+
if os.environ.get("HF_HUB_ENABLE_HF_TRANSFER") is None and importlib.util.find_spec("hf_transfer"):
|
| 32 |
+
os.environ["HF_HUB_ENABLE_HF_TRANSFER"] = "1"
|
| 33 |
+
|
| 34 |
+
from gpu_shared import ( # noqa: E402
|
| 35 |
+
GGUF_FILE,
|
| 36 |
+
GGUF_REPO,
|
| 37 |
+
INTENT_SYSTEM_PROMPT,
|
| 38 |
+
extract_json,
|
| 39 |
+
preload_cuda_libs,
|
| 40 |
+
)
|
| 41 |
+
from schema import INTENT_SCHEMA # noqa: E402
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
# ----------------------------- model -----------------------------
|
| 45 |
+
|
| 46 |
+
def _gpu_layers() -> int:
|
| 47 |
+
"""All layers on GPU when one is available, else CPU. Override with FROGQUEST_PROBE_GPU_LAYERS."""
|
| 48 |
+
override = os.environ.get("FROGQUEST_PROBE_GPU_LAYERS")
|
| 49 |
+
if override is not None:
|
| 50 |
+
return int(override)
|
| 51 |
+
try:
|
| 52 |
+
import torch
|
| 53 |
+
return -1 if torch.cuda.is_available() else 0
|
| 54 |
+
except Exception:
|
| 55 |
+
return 0
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def load_llm():
|
| 59 |
+
layers = _gpu_layers()
|
| 60 |
+
print(f"[probe] loading {GGUF_REPO} ({GGUF_FILE}) with n_gpu_layers={layers} ...")
|
| 61 |
+
try:
|
| 62 |
+
preload_cuda_libs() # no-op without the CUDA libs (e.g. pure-CPU local run)
|
| 63 |
+
except Exception:
|
| 64 |
+
pass
|
| 65 |
+
from llama_cpp import Llama
|
| 66 |
+
t0 = time.time()
|
| 67 |
+
llm = Llama.from_pretrained(
|
| 68 |
+
repo_id=GGUF_REPO, filename=GGUF_FILE,
|
| 69 |
+
n_gpu_layers=layers, n_ctx=4096, verbose=False,
|
| 70 |
+
)
|
| 71 |
+
print(f"[probe] model ready in {time.time() - t0:.1f}s\n")
|
| 72 |
+
return llm
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def ask(llm, system: str, user: str, schema: dict, max_tokens: int = 512):
|
| 76 |
+
"""One constrained call. Returns (raw_text, parsed_or_None, seconds)."""
|
| 77 |
+
t0 = time.time()
|
| 78 |
+
try:
|
| 79 |
+
out = llm.create_chat_completion(
|
| 80 |
+
messages=[{"role": "system", "content": system},
|
| 81 |
+
{"role": "user", "content": user}],
|
| 82 |
+
response_format={"type": "json_object", "schema": schema},
|
| 83 |
+
temperature=0.0, max_tokens=max_tokens,
|
| 84 |
+
)
|
| 85 |
+
raw = out["choices"][0]["message"]["content"]
|
| 86 |
+
except Exception as e: # generation itself blew up
|
| 87 |
+
return f"<<ERROR: {e}>>", None, time.time() - t0
|
| 88 |
+
parsed = extract_json(raw)
|
| 89 |
+
return raw, (parsed if isinstance(parsed, dict) and parsed else None), time.time() - t0
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
# ----------------------------- throwaway prompts/schemas for the NEW tasks -----------------------------
|
| 93 |
+
|
| 94 |
+
ASK_SYS = (
|
| 95 |
+
"You help plan a user's long-term goal. Given the goal, output the 1-3 most important "
|
| 96 |
+
"clarifying questions you must ask before you could plan it (deadline, current level, time "
|
| 97 |
+
"available, constraints). OUTPUT JSON ONLY: {\"questions\": [\"...\"]}. No prose.\n/no_think"
|
| 98 |
+
)
|
| 99 |
+
ASK_SCHEMA = {
|
| 100 |
+
"type": "object",
|
| 101 |
+
"properties": {"questions": {"type": "array", "items": {"type": "string"}}},
|
| 102 |
+
"required": ["questions"],
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
PLAN_SYS = (
|
| 106 |
+
"You turn a long-term goal plus web-research snippets into an ordered plan. OUTPUT JSON ONLY: "
|
| 107 |
+
"{\"milestones\": [{\"title\": \"\", \"summary\": \"\"}]} with 3-6 milestones in the order "
|
| 108 |
+
"they should be done. Ground them in the snippets. No prose.\n/no_think"
|
| 109 |
+
)
|
| 110 |
+
PLAN_SCHEMA = {
|
| 111 |
+
"type": "object",
|
| 112 |
+
"properties": {
|
| 113 |
+
"milestones": {
|
| 114 |
+
"type": "array",
|
| 115 |
+
"items": {
|
| 116 |
+
"type": "object",
|
| 117 |
+
"properties": {"title": {"type": "string"}, "summary": {"type": "string"}},
|
| 118 |
+
"required": ["title", "summary"],
|
| 119 |
+
},
|
| 120 |
+
}
|
| 121 |
+
},
|
| 122 |
+
"required": ["milestones"],
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
QUEST_SYS = (
|
| 126 |
+
"You are a quest writer. Given one real task/milestone and a theme, write a short themed quest "
|
| 127 |
+
"title and a 1-2 sentence narrative where the user is the hero. OUTPUT JSON ONLY: "
|
| 128 |
+
"{\"quest_title\": \"\", \"narrative\": \"\"}. No prose.\n/no_think"
|
| 129 |
+
)
|
| 130 |
+
QUEST_SCHEMA = {
|
| 131 |
+
"type": "object",
|
| 132 |
+
"properties": {"quest_title": {"type": "string"}, "narrative": {"type": "string"}},
|
| 133 |
+
"required": ["quest_title", "narrative"],
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
# ----------------------------- test batteries -----------------------------
|
| 138 |
+
# Each case: (user_input, checker(parsed)->bool, label). The checker is a LIGHT quality heuristic
|
| 139 |
+
# on top of "valid JSON"; the printed text is the real signal.
|
| 140 |
+
|
| 141 |
+
INTENT_CASES = [
|
| 142 |
+
("Finish the tax report, reply to emails, book the dentist", "forge"),
|
| 143 |
+
("I finished the tax report", "mark_done"),
|
| 144 |
+
("Couldn't make it to the gym, too tired", "mark_couldnt"),
|
| 145 |
+
("Also add: call mom and water the plants", "add_tasks"),
|
| 146 |
+
("what's the weather like today?", "unknown"),
|
| 147 |
+
("start over, here are my plans for the week", "forge"),
|
| 148 |
+
]
|
| 149 |
+
|
| 150 |
+
ASK_CASES = [
|
| 151 |
+
"I want to score in the 95th percentile on the GRE",
|
| 152 |
+
"Help me win this hackathon",
|
| 153 |
+
"I want to learn to play guitar",
|
| 154 |
+
"Get fit",
|
| 155 |
+
"Launch my SaaS side project",
|
| 156 |
+
]
|
| 157 |
+
|
| 158 |
+
PLAN_CASES = [
|
| 159 |
+
("Run a 10k race", [
|
| 160 |
+
"Couch-to-10k programs build from run/walk intervals over 8-10 weeks.",
|
| 161 |
+
"Most plans run 3-4 days/week with one long run that grows weekly.",
|
| 162 |
+
"Rest days and easy pace prevent injury; add a tempo run after week 4.",
|
| 163 |
+
]),
|
| 164 |
+
("Learn basic Spanish for travel", [
|
| 165 |
+
"Start with the 500 most common words and survival phrases.",
|
| 166 |
+
"Apps like Anki use spaced repetition for vocab retention.",
|
| 167 |
+
"Practicing speaking early, even badly, accelerates fluency.",
|
| 168 |
+
]),
|
| 169 |
+
]
|
| 170 |
+
|
| 171 |
+
QUEST_CASES = [
|
| 172 |
+
("Write the introduction section of the thesis", "fantasy"),
|
| 173 |
+
("Do 20 minutes of cardio", "cyberpunk"),
|
| 174 |
+
("Refactor the auth module", "space"),
|
| 175 |
+
]
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def run_intent(llm):
|
| 179 |
+
print("=== TASK 1: intent classification (real app prompt + schema) ===")
|
| 180 |
+
ok_json = ok_label = 0
|
| 181 |
+
for msg, expected in INTENT_CASES:
|
| 182 |
+
raw, parsed, secs = ask(llm, INTENT_SYSTEM_PROMPT, f"Context:\nNo quest log exists yet.\n\nUser message:\n{msg}",
|
| 183 |
+
INTENT_SCHEMA, max_tokens=128)
|
| 184 |
+
got = (parsed or {}).get("intent")
|
| 185 |
+
ok_json += parsed is not None
|
| 186 |
+
match = got == expected
|
| 187 |
+
ok_label += match
|
| 188 |
+
print(f" [{secs:4.1f}s] json={'Y' if parsed else 'N'} got={got!r:16} want={expected!r:14} {'OK' if match else 'MISS'} | {msg[:48]}")
|
| 189 |
+
print(f" -> valid-JSON {ok_json}/{len(INTENT_CASES)} | correct-intent {ok_label}/{len(INTENT_CASES)}\n")
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def run_ask(llm):
|
| 193 |
+
print("=== TASK 2: goal -> 1-3 clarifying questions ===")
|
| 194 |
+
ok_json = ok_quality = 0
|
| 195 |
+
for goal in ASK_CASES:
|
| 196 |
+
raw, parsed, secs = ask(llm, ASK_SYS, f"Goal: {goal}", ASK_SCHEMA, max_tokens=256)
|
| 197 |
+
qs = (parsed or {}).get("questions") if parsed else None
|
| 198 |
+
good = isinstance(qs, list) and 1 <= len(qs) <= 3 and all(isinstance(q, str) and q.strip() for q in qs)
|
| 199 |
+
ok_json += parsed is not None
|
| 200 |
+
ok_quality += good
|
| 201 |
+
preview = " / ".join(qs)[:90] if isinstance(qs, list) else "(none)"
|
| 202 |
+
print(f" [{secs:4.1f}s] json={'Y' if parsed else 'N'} 1-3q={'Y' if good else 'N'} | {goal[:30]:30} -> {preview}")
|
| 203 |
+
print(f" -> valid-JSON {ok_json}/{len(ASK_CASES)} | 1-3 good questions {ok_quality}/{len(ASK_CASES)}\n")
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
def run_plan(llm):
|
| 207 |
+
print("=== TASK 3: goal + snippets -> ordered milestones ===")
|
| 208 |
+
ok_json = ok_quality = 0
|
| 209 |
+
for goal, snippets in PLAN_CASES:
|
| 210 |
+
user = f"Goal: {goal}\n\nResearch snippets:\n" + "\n".join(f"- {s}" for s in snippets)
|
| 211 |
+
raw, parsed, secs = ask(llm, PLAN_SYS, user, PLAN_SCHEMA, max_tokens=640)
|
| 212 |
+
ms = (parsed or {}).get("milestones") if parsed else None
|
| 213 |
+
good = isinstance(ms, list) and 3 <= len(ms) <= 6 and all(
|
| 214 |
+
isinstance(m, dict) and m.get("title") for m in ms)
|
| 215 |
+
ok_json += parsed is not None
|
| 216 |
+
ok_quality += good
|
| 217 |
+
print(f" [{secs:4.1f}s] json={'Y' if parsed else 'N'} 3-6ms={'Y' if good else 'N'} | {goal}")
|
| 218 |
+
if isinstance(ms, list):
|
| 219 |
+
for m in ms[:6]:
|
| 220 |
+
if isinstance(m, dict):
|
| 221 |
+
print(f" - {str(m.get('title'))[:60]}")
|
| 222 |
+
print(f" -> valid-JSON {ok_json}/{len(PLAN_CASES)} | 3-6 milestones {ok_quality}/{len(PLAN_CASES)}\n")
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
def run_quest(llm):
|
| 226 |
+
print("=== TASK 4: milestone -> themed quest title + narrative ===")
|
| 227 |
+
ok_json = ok_quality = 0
|
| 228 |
+
for task, theme in QUEST_CASES:
|
| 229 |
+
raw, parsed, secs = ask(llm, QUEST_SYS, f"Theme: {theme}\nTask: {task}", QUEST_SCHEMA, max_tokens=256)
|
| 230 |
+
good = bool(parsed) and bool((parsed or {}).get("quest_title")) and bool((parsed or {}).get("narrative"))
|
| 231 |
+
ok_json += parsed is not None
|
| 232 |
+
ok_quality += good
|
| 233 |
+
title = (parsed or {}).get("quest_title", "")
|
| 234 |
+
narr = (parsed or {}).get("narrative", "")
|
| 235 |
+
print(f" [{secs:4.1f}s] json={'Y' if parsed else 'N'} | ({theme}) {task[:34]:34} -> {title[:40]}")
|
| 236 |
+
if narr:
|
| 237 |
+
print(f" {narr[:96]}")
|
| 238 |
+
print(f" -> valid-JSON {ok_json}/{len(QUEST_CASES)} | usable {ok_quality}/{len(QUEST_CASES)}\n")
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
def main():
|
| 242 |
+
llm = load_llm()
|
| 243 |
+
run_intent(llm)
|
| 244 |
+
run_ask(llm)
|
| 245 |
+
run_plan(llm)
|
| 246 |
+
run_quest(llm)
|
| 247 |
+
print("[probe] done. Read the outputs: valid-JSON rate is the floor; the text quality decides\n"
|
| 248 |
+
" which tasks need a heavier code fallback in the real build.")
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
if __name__ == "__main__":
|
| 252 |
+
main()
|
store.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Durable per-user storage for FrogQuest — a single SQLite DB holding the WHOLE app state.
|
| 2 |
+
|
| 3 |
+
Why this exists: `gr.BrowserState` (localStorage) is wiped by OS/browser cache clears and capped
|
| 4 |
+
at ~5MB, so quests/campaigns/images don't survive on real devices. With HF login giving a stable
|
| 5 |
+
per-user id, we persist the entire state dict (profile, campaigns, quests, generated images, chat,
|
| 6 |
+
pending) server-side, keyed by that id. BrowserState stays as a fast cache / anonymous fallback;
|
| 7 |
+
SQLite is the durable source of truth.
|
| 8 |
+
|
| 9 |
+
Storage location:
|
| 10 |
+
- On a HF Space with Persistent Storage, `/data` is mounted and survives restarts -> `/data/frogquest.db`.
|
| 11 |
+
- Locally (dev) `/data` doesn't exist -> fall back to `./.frogquest/frogquest.db`.
|
| 12 |
+
- `FROGQUEST_DB_PATH` overrides both.
|
| 13 |
+
|
| 14 |
+
Schema is deliberately trivial: one row per user, the state stored as a JSON blob (mirrors the
|
| 15 |
+
app's in-memory state dict exactly, so there's no object-relational mapping to drift). Images are
|
| 16 |
+
data-URL strings inside that JSON — SQLite has no 5MB cap, so `_trim_images` is unnecessary here.
|
| 17 |
+
"""
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
import json
|
| 21 |
+
import os
|
| 22 |
+
import sqlite3
|
| 23 |
+
import threading
|
| 24 |
+
from datetime import datetime, timezone
|
| 25 |
+
|
| 26 |
+
_lock = threading.Lock()
|
| 27 |
+
_conn: sqlite3.Connection | None = None
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _db_path() -> str:
|
| 31 |
+
override = os.environ.get("FROGQUEST_DB_PATH")
|
| 32 |
+
if override:
|
| 33 |
+
return override
|
| 34 |
+
if os.path.isdir("/data") and os.access("/data", os.W_OK): # HF Persistent Storage
|
| 35 |
+
return "/data/frogquest.db"
|
| 36 |
+
base = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".frogquest") # local dev
|
| 37 |
+
os.makedirs(base, exist_ok=True)
|
| 38 |
+
return os.path.join(base, "frogquest.db")
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _get_conn() -> sqlite3.Connection:
|
| 42 |
+
"""Lazily open one shared connection. check_same_thread=False + the module _lock make it safe
|
| 43 |
+
across Gradio's worker threads; WAL keeps reads from blocking the single writer."""
|
| 44 |
+
global _conn
|
| 45 |
+
if _conn is None:
|
| 46 |
+
path = _db_path()
|
| 47 |
+
parent = os.path.dirname(path)
|
| 48 |
+
if parent:
|
| 49 |
+
os.makedirs(parent, exist_ok=True)
|
| 50 |
+
_conn = sqlite3.connect(path, check_same_thread=False)
|
| 51 |
+
_conn.execute("PRAGMA journal_mode=WAL")
|
| 52 |
+
_conn.execute("PRAGMA synchronous=NORMAL")
|
| 53 |
+
_conn.execute(
|
| 54 |
+
"CREATE TABLE IF NOT EXISTS users ("
|
| 55 |
+
" uid TEXT PRIMARY KEY,"
|
| 56 |
+
" state TEXT NOT NULL,"
|
| 57 |
+
" updated_at TEXT NOT NULL"
|
| 58 |
+
")"
|
| 59 |
+
)
|
| 60 |
+
_conn.commit()
|
| 61 |
+
return _conn
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def load(uid: str | None) -> dict | None:
|
| 65 |
+
"""Return the saved state dict for this user, or None if absent / not logged in / unreadable."""
|
| 66 |
+
if not uid:
|
| 67 |
+
return None
|
| 68 |
+
with _lock:
|
| 69 |
+
row = _get_conn().execute("SELECT state FROM users WHERE uid = ?", (uid,)).fetchone()
|
| 70 |
+
if not row:
|
| 71 |
+
return None
|
| 72 |
+
try:
|
| 73 |
+
data = json.loads(row[0])
|
| 74 |
+
return data if isinstance(data, dict) else None
|
| 75 |
+
except (json.JSONDecodeError, TypeError):
|
| 76 |
+
return None
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def save(uid: str | None, state: dict) -> None:
|
| 80 |
+
"""Upsert the full state dict for this user. No-op when not logged in (anonymous -> BrowserState
|
| 81 |
+
only). Never raises into the request path: persistence failures are swallowed and logged."""
|
| 82 |
+
if not uid or not isinstance(state, dict):
|
| 83 |
+
return
|
| 84 |
+
try:
|
| 85 |
+
blob = json.dumps(state, ensure_ascii=False)
|
| 86 |
+
except (TypeError, ValueError):
|
| 87 |
+
return
|
| 88 |
+
now = datetime.now(timezone.utc).isoformat()
|
| 89 |
+
try:
|
| 90 |
+
with _lock:
|
| 91 |
+
conn = _get_conn()
|
| 92 |
+
conn.execute(
|
| 93 |
+
"INSERT INTO users (uid, state, updated_at) VALUES (?, ?, ?) "
|
| 94 |
+
"ON CONFLICT(uid) DO UPDATE SET state=excluded.state, updated_at=excluded.updated_at",
|
| 95 |
+
(uid, blob, now),
|
| 96 |
+
)
|
| 97 |
+
conn.commit()
|
| 98 |
+
except sqlite3.Error as e:
|
| 99 |
+
print(f"[store] save failed for uid={uid!r}: {e}")
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
if __name__ == "__main__": # quick self-test (no model, no network)
|
| 103 |
+
os.environ.setdefault("FROGQUEST_DB_PATH", os.path.join(os.path.dirname(__file__), ".frogquest", "selftest.db"))
|
| 104 |
+
assert load("nobody") is None
|
| 105 |
+
sample = {"profile": {"theme": "fantasy"}, "quests": [{"id": "q1"}], "images": {"q1": {"initial": "data:..."}}}
|
| 106 |
+
save("user-123", sample)
|
| 107 |
+
got = load("user-123")
|
| 108 |
+
assert got == sample, got
|
| 109 |
+
sample["quests"].append({"id": "q2"})
|
| 110 |
+
save("user-123", sample)
|
| 111 |
+
assert len(load("user-123")["quests"]) == 2
|
| 112 |
+
print("store.py self-test OK ->", _db_path())
|