VirusDumb commited on
Commit
ddc2d41
·
1 Parent(s): 9439894

Phase 0 + A: capability probe, SQLite storage + HF login

Browse files
Files changed (5) hide show
  1. .gitignore +1 -0
  2. README.md +6 -2
  3. app.py +42 -21
  4. probe_nemotron.py +252 -0
  5. 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
- Your photo and quest state live **only in your browser** (localStorage); the photo is sent to the
30
- GPU transiently for generation and is never stored server-side.
 
 
 
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). The photo goes to the GPU only transiently during
13
- generation and is never persisted server-side.
 
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 boot(browser):
407
- """Restore everything from BrowserState on page load. Shows cached scenes only (never
408
- generates) so reloads are instant; uncached scenes regenerate when the quest is clicked."""
409
- b = browser or _default_state()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.HTML('<div class="fq-topbar"><h1 class="fq-logo">FROG<b>QUEST</b></h1></div>')
 
 
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. It stays in your '
454
- 'browser sent to the GPU only to draw your scenes, never stored.</p>')
 
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, selected_id_state, images_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())