resakemal commited on
Commit
42d00cb
Β·
1 Parent(s): a700bb2

Revise ollama & hf deployment to llama.cpp

Browse files
README.md CHANGED
@@ -10,6 +10,14 @@ app_file: app.py
10
  pinned: false
11
  short_description: Tell a story. Watch its feelings take shape.
12
  license: mit
 
 
 
 
 
 
 
 
13
  ---
14
 
15
  # The Shape of Words
@@ -33,25 +41,23 @@ custom HTML/JS frontend gets queuing, streaming, and Hugging Face Spaces hosting
33
 
34
  ```
35
  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
36
- β”‚ Browser β€” static/index.html (custom HTML/JS, no build step) β”‚
37
- β”‚ β€’ renderer Β· mappings Β· layout Β· sound Β· share card Β· UI β”‚
38
- β”‚ β€’ all rendering is client-side (JS port of engine/) β”‚
39
- β”‚ β€’ calls the backend for MODEL JUDGMENT only β”‚
40
  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
41
  β”‚ /gradio_api/call/<name> (queued, SSE)
42
  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
43
- β”‚ app.py β€” gradio.Server β”‚
44
- β”‚ @app.api judge_beat Β· judge_beat_segmented Β· β”‚
45
- β”‚ continue_story Β· reveal Β· title_story Β· paint β”‚
46
- β”‚ @app.get "/" serves the frontend β”‚
47
  β””β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
48
- β”‚ model/backend.py β”‚ model/painter.py
49
- β”‚ STORY_SHAPES_BACKEND β”‚ STORY_SHAPES_PAINT_BACKEND
50
- β–Ό β–Ό
51
  llamacpp GGUF in-process (llama.cpp) modal Modal endpoint (FLUX.2 Klein)
52
  modal_llm Modal endpoint (transformers) flux_local in-process diffusers
53
- ollama local Ollama daemon
54
- hf transformers in-process (Qwen3)
55
  ```
56
 
57
  The **model only judges affect** (valence / arousal / dominance + a few flags +
@@ -61,42 +67,32 @@ in perception research (see the spec's references).
61
 
62
  ---
63
 
64
- ## Run locally (Ollama + Qwen3-8B)
65
 
66
- **1. Install Ollama** β€” https://ollama.com/download
 
67
 
68
- **2. Pull the model** (once):
69
- ```bash
70
- ollama pull qwen3:8b
71
- ```
72
-
73
- **3. Make sure Ollama is running** (it usually runs as a background service; if
74
- not: `ollama serve`).
75
-
76
- **4. Install Python deps and launch:**
77
  ```bash
78
  pip install -r requirements.txt
79
- python app.py
80
  ```
81
 
82
- **5. Open** http://localhost:7860
 
 
83
 
84
  Config via env vars (all optional):
85
  | var | default | meaning |
86
  |-----|---------|---------|
87
- | `STORY_SHAPES_BACKEND` | `ollama` | `llamacpp` Β· `modal_llm` Β· `ollama` Β· `hf` |
88
- | `STORY_SHAPES_MODEL` | `qwen3:8b` | Ollama model tag (`ollama`) or HF repo id (`hf`) |
89
- | `OLLAMA_URL` | `http://localhost:11434` | Ollama host |
90
- | `PORT` | `7860` | server port |
 
91
 
92
  > The frontend falls back to a built-in keyword **stub** if the backend is
93
  > unreachable, so `static/index.html` also opens standalone for quick UI testing.
94
 
95
- Prefer the **llama.cpp** runtime locally too? Set `STORY_SHAPES_BACKEND=llamacpp`
96
- β€” it pulls a quantized GGUF (default `openbmb/MiniCPM4.1-8B-GGUF`, Q4_K_M) and
97
- runs in-process via `llama-cpp-python`, no Ollama daemon needed. Set
98
- `STORY_SHAPES_LLAMACPP_GPU_LAYERS=0` for CPU-only.
99
-
100
  ---
101
 
102
  ## Deploy to a Hugging Face Space
@@ -126,12 +122,11 @@ ZeroGPU's per-request quota attribution.
126
  | `STORY_SHAPES_PAINT_BACKEND` | `modal` | painter on a Modal GPU (`modal deploy modal_painter.py`) |
127
  | `STORY_SHAPES_PAINT_MODAL_URL` | *(from `modal deploy`)* | the printed `modal_painter` URL |
128
 
129
- > **Why not run MiniCPM4.1 in-process under the `hf` backend?** gradio 6 forces
130
- > `transformers β‰₯ 5.0`, and MiniCPM4.1's `trust_remote_code` modeling code is
131
- > incompatible with transformers 5.x (it loads with a shim but crashes during
132
- > generation). So MiniCPM4.1 runs via **llama.cpp** (no transformers) or on
133
- > **Modal** with transformers pinned to 4.x. The `hf` backend still works for
134
- > Qwen3, which is transformers-5-native.
135
 
136
  See `requirements.txt` for the llama.cpp install (a prebuilt CUDA wheel, with a
137
  one-comment CPU-fallback toggle) and the diffusers-from-source line FLUX needs.
@@ -148,7 +143,7 @@ engine/ deterministic core (also mirrored in the frontend JS)
148
  renderer.py geometry -> shape points
149
  scorer.py zone-based puzzle scoring (+ band + hint)
150
  model/
151
- backend.py LLM abstraction: llamacpp / modal_llm / ollama / hf
152
  painter.py painting abstraction: modal / flux_local
153
  modal_llm.py Modal GPU endpoint for the LLM (deploy once)
154
  modal_painter.py Modal GPU endpoint for FLUX.2 Klein (deploy once)
@@ -173,8 +168,8 @@ docs/
173
  - βœ… **Origin tooltip**: hover/tap a shape or layer row to see its source beat.
174
  - βœ… **Painting step (FLUX.2 Klein img2img)**: "Paint this"; free style field +
175
  preset styles + 🎲 Surprise me; randomized seed; `modal` or `flux_local`.
176
- - βœ… **LLM backends**: `llamacpp` (GGUF, Llama Champion), `modal_llm`, `ollama`,
177
- `hf` (Qwen3). All produce schema-constrained JSON.
178
  - βœ… **Felt-quality eval** (`eval_felt_quality.py`): A/B models on a fixed set.
179
  - βœ… **HF Space deployment** (gradio 6.18, `gr.Server`).
180
  - ⬜ Puzzle mode UI (engine + scorer exist).
 
10
  pinned: false
11
  short_description: Tell a story. Watch its feelings take shape.
12
  license: mit
13
+ tags:
14
+ - track:wood
15
+ - sponsor:openbmb
16
+ - sponsor:modal
17
+ - achievement:offgrid
18
+ - achievement:offbrand
19
+ - achievement:llama
20
+ - achievement:fieldnotes
21
  ---
22
 
23
  # The Shape of Words
 
41
 
42
  ```
43
  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
44
+ β”‚ Browser β€” static/index.html (custom HTML/JS, no build step) β”‚
45
+ β”‚ β€’ renderer Β· mappings Β· layout Β· sound Β· share card Β· UI β”‚
46
+ β”‚ β€’ all rendering is client-side (JS port of engine/) β”‚
47
+ β”‚ β€’ calls the backend for MODEL JUDGMENT only β”‚
48
  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
49
  β”‚ /gradio_api/call/<name> (queued, SSE)
50
  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
51
+ β”‚ app.py β€” gradio.Server β”‚
52
+ β”‚ @app.api judge_beat Β· judge_beat_segmented Β· β”‚
53
+ β”‚ continue_story Β· reveal Β· title_story Β· paint β”‚
54
+ β”‚ @app.get "/" serves the frontend β”‚
55
  β””β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
56
+ β”‚ model/backend.py β”‚ model/painter.py
57
+ β”‚ STORY_SHAPES_BACKEND β”‚ STORY_SHAPES_PAINT_BACKEND
58
+ β–Ό β–Ό
59
  llamacpp GGUF in-process (llama.cpp) modal Modal endpoint (FLUX.2 Klein)
60
  modal_llm Modal endpoint (transformers) flux_local in-process diffusers
 
 
61
  ```
62
 
63
  The **model only judges affect** (valence / arousal / dominance + a few flags +
 
67
 
68
  ---
69
 
70
+ ## Run locally (llama.cpp + MiniCPM4.1-8B)
71
 
72
+ The default backend runs the LLM in-process via `llama-cpp-python` β€” no daemon,
73
+ no separate model server.
74
 
 
 
 
 
 
 
 
 
 
75
  ```bash
76
  pip install -r requirements.txt
77
+ python app.py # serves http://localhost:7860
78
  ```
79
 
80
+ On first run it pulls a quantized GGUF (default `openbmb/MiniCPM4.1-8B-GGUF`,
81
+ Q4_K_M, ~5 GB). With a GPU it offloads all layers automatically; **CPU-only?**
82
+ set `STORY_SHAPES_LLAMACPP_GPU_LAYERS=0`. Then open http://localhost:7860.
83
 
84
  Config via env vars (all optional):
85
  | var | default | meaning |
86
  |-----|---------|---------|
87
+ | `STORY_SHAPES_BACKEND` | `llamacpp` | `llamacpp` (in-process) or `modal_llm` (Modal endpoint) |
88
+ | `STORY_SHAPES_LLAMACPP_REPO` | `openbmb/MiniCPM4.1-8B-GGUF` | GGUF Hub repo |
89
+ | `STORY_SHAPES_LLAMACPP_FILE` | `*Q4_K_M.gguf` | GGUF filename glob |
90
+ | `STORY_SHAPES_LLAMACPP_GPU_LAYERS` | `-1` | layers on GPU (`-1` all, `0` CPU-only) |
91
+ | `PORT` | `7860` | server port |
92
 
93
  > The frontend falls back to a built-in keyword **stub** if the backend is
94
  > unreachable, so `static/index.html` also opens standalone for quick UI testing.
95
 
 
 
 
 
 
96
  ---
97
 
98
  ## Deploy to a Hugging Face Space
 
122
  | `STORY_SHAPES_PAINT_BACKEND` | `modal` | painter on a Modal GPU (`modal deploy modal_painter.py`) |
123
  | `STORY_SHAPES_PAINT_MODAL_URL` | *(from `modal deploy`)* | the printed `modal_painter` URL |
124
 
125
+ > **Why not run MiniCPM4.1 in-process under plain `transformers`?** gradio 6
126
+ > forces `transformers β‰₯ 5.0`, and MiniCPM4.1's `trust_remote_code` modeling
127
+ > code is incompatible with transformers 5.x (it loads with a shim but crashes
128
+ > during generation). That's exactly why the LLM runs via **llama.cpp** (no
129
+ > transformers at all) or on **Modal** with transformers pinned to 4.x.
 
130
 
131
  See `requirements.txt` for the llama.cpp install (a prebuilt CUDA wheel, with a
132
  one-comment CPU-fallback toggle) and the diffusers-from-source line FLUX needs.
 
143
  renderer.py geometry -> shape points
144
  scorer.py zone-based puzzle scoring (+ band + hint)
145
  model/
146
+ backend.py LLM abstraction: llamacpp / modal_llm
147
  painter.py painting abstraction: modal / flux_local
148
  modal_llm.py Modal GPU endpoint for the LLM (deploy once)
149
  modal_painter.py Modal GPU endpoint for FLUX.2 Klein (deploy once)
 
168
  - βœ… **Origin tooltip**: hover/tap a shape or layer row to see its source beat.
169
  - βœ… **Painting step (FLUX.2 Klein img2img)**: "Paint this"; free style field +
170
  preset styles + 🎲 Surprise me; randomized seed; `modal` or `flux_local`.
171
+ - βœ… **LLM backends**: `llamacpp` (GGUF, Llama Champion) and `modal_llm`. Both
172
+ produce schema-constrained JSON.
173
  - βœ… **Felt-quality eval** (`eval_felt_quality.py`): A/B models on a fixed set.
174
  - βœ… **HF Space deployment** (gradio 6.18, `gr.Server`).
175
  - ⬜ Puzzle mode UI (engine + scorer exist).
__pycache__/app.cpython-310.pyc ADDED
Binary file (4.57 kB). View file
 
app.py CHANGED
@@ -3,15 +3,17 @@ app.py β€” Story β†’ Shapes backend, built on gradio.Server (FastAPI + Gradio en
3
 
4
  Serves the custom HTML/JS frontend at "/" and exposes JSON API endpoints the
5
  frontend calls. The model judgment (affect) comes from the model backend
6
- (Ollama locally / HF on a Space); the deterministic scoring lives here; geometry
7
- + color rendering happen client-side (the frontend has the ported renderer).
 
8
 
9
- Run locally:
10
- ollama serve # in one terminal (if not already running)
11
- ollama pull qwen3:8b # once
12
- python app.py # serves http://localhost:7860
13
 
14
- On a HF Space: set STORY_SHAPES_BACKEND=hf and wire model/backend.py:_hf_chat.
 
15
  """
16
  import os
17
  from gradio import Server
 
3
 
4
  Serves the custom HTML/JS frontend at "/" and exposes JSON API endpoints the
5
  frontend calls. The model judgment (affect) comes from the model backend
6
+ (llama.cpp in-process, or a Modal GPU endpoint); the deterministic scoring lives
7
+ here; geometry + color rendering happen client-side (the frontend has the
8
+ ported renderer).
9
 
10
+ Run locally (llama.cpp, the default backend):
11
+ pip install -r requirements.txt
12
+ python app.py # serves http://localhost:7860 (pulls the GGUF on first run)
13
+ # CPU-only? set STORY_SHAPES_LLAMACPP_GPU_LAYERS=0
14
 
15
+ On a HF Space: keep STORY_SHAPES_BACKEND=llamacpp (in-process GGUF) or set
16
+ modal_llm + STORY_SHAPES_LLM_MODAL_URL to offload the LLM to Modal.
17
  """
18
  import os
19
  from gradio import Server
app_spec.md CHANGED
@@ -332,14 +332,11 @@ app.py β€” gradio.Server (satisfies Off-Brand badge; gradio 6.x)
332
  Β· title_story Β· paint (each concurrency_limit=1)
333
  β”‚
334
  model/backend.py ← STORY_SHAPES_BACKEND
335
- "llamacpp" β†’ GGUF in-process via llama-cpp-python ← recommended on-Space
336
  GBNF grammar from JSON schema β†’ guaranteed-valid JSON.
337
  No transformers; ~5GB at Q4_K_M. Earns Llama Champion.
338
  "modal_llm" β†’ HTTP POST to a Modal GPU endpoint (modal_llm.py)
339
  transformers pinned to 4.x there, where MiniCPM4.1 works.
340
- "ollama" β†’ Ollama HTTP (local dev); JSON-schema `format`.
341
- "hf" β†’ transformers in-process. WORKS for Qwen3; does NOT work for
342
- MiniCPM4.1 under transformers 5.x (see Β§20). Kept for Qwen3.
343
  β”‚
344
  model/painter.py ← STORY_SHAPES_PAINT_BACKEND
345
  "modal" β†’ Modal web endpoint (A10G GPU, FLUX.2 Klein 4B)
@@ -358,7 +355,7 @@ All engine math is mirrored in `static/index.html` (JS port, verified identical
358
  **Model judgment:**
359
  - Full story context with per-beat status flags on every call β€” globally aware pacing.
360
  - Pacing note branches: "no shape yet β†’ be generous" vs "settled beats β†’ skip."
361
- - Judgment model: MiniCPM4.1-8B (OpenBMB prize) or Qwen3-8B β€” swappable via `STORY_SHAPES_MODEL`. Structured output is enforced by the backend's native grammar facility (Ollama `format`, llama.cpp GBNF-from-schema) or, for the prompt-based backends, `_extract_json` + a 3-attempt parse/retry. `<think>` blocks are stripped (grammar-constrained paths suppress them outright).
362
 
363
  ---
364
 
@@ -366,8 +363,7 @@ All engine math is mirrored in `static/index.html` (JS port, verified identical
366
 
367
  | Variable | Default | Purpose |
368
  |---|---|---|
369
- | `STORY_SHAPES_BACKEND` | `ollama` | LLM backend: `llamacpp` Β· `modal_llm` Β· `ollama` Β· `hf` |
370
- | `STORY_SHAPES_MODEL` | `qwen3:8b` / `Qwen/Qwen3-8B` | model tag or HF repo id (`ollama`/`hf` backends) |
371
  | `STORY_SHAPES_LLM_MODAL_URL` | *(required for `modal_llm`)* | URL from `modal deploy modal_llm.py` |
372
  | `STORY_SHAPES_LLAMACPP_REPO` | `openbmb/MiniCPM4.1-8B-GGUF` | GGUF Hub repo (`llamacpp` backend) |
373
  | `STORY_SHAPES_LLAMACPP_FILE` | `*Q4_K_M.gguf` | GGUF filename glob |
@@ -376,7 +372,6 @@ All engine math is mirrored in `static/index.html` (JS port, verified identical
376
  | `STORY_SHAPES_PAINT_BACKEND` | `modal` | `modal` or `flux_local` |
377
  | `STORY_SHAPES_PAINT_MODAL_URL` | *(required for modal paint)* | URL from `modal deploy modal_painter.py` |
378
  | `STORY_SHAPES_FLUX_MODEL` | `black-forest-labs/FLUX.2-klein-4B` | FLUX repo (`flux_local`) |
379
- | `OLLAMA_URL` | `http://localhost:11434` | Ollama host (local only) |
380
  | `PORT` | `7860` | server port |
381
 
382
  ---
@@ -402,10 +397,8 @@ All engine math is mirrored in `static/index.html` (JS port, verified identical
402
  | Share card (canvas + painting, popup) | βœ… | AI title (`title_story`, cached); transparent shapes card |
403
  | Reveal sound (chimes / tick / pad) + replay | βœ… | Web Audio; pentatonic; Settings toggle |
404
  | Origin tooltip (shape + layer, hover/touch) | βœ… | shows verbatim source beat |
405
- | llama.cpp backend (`llamacpp`) | βœ… | GGUF in-process; GBNF from schema; **Llama Champion** |
406
  | Modal LLM backend (`modal_llm`) | βœ… | transformers 4.x on Modal; MiniCPM4.1 verified |
407
- | Ollama backend (JSON-schema constrained) | βœ… | local dev |
408
- | HF transformers backend (`hf`) | βœ… | Qwen3 only; MiniCPM4.1 broken on transformers 5.x (Β§20) |
409
  | Felt-quality eval harness | βœ… | `eval_felt_quality.py` |
410
  | `devReveal()` console shortcut | βœ… | dev/testing only |
411
  | **HF Space deployment** | βœ… | gradio 6.18 (`gr.Server`); LLM via llama.cpp/Modal, painter via Modal |
 
332
  Β· title_story Β· paint (each concurrency_limit=1)
333
  β”‚
334
  model/backend.py ← STORY_SHAPES_BACKEND
335
+ "llamacpp" β†’ GGUF in-process via llama-cpp-python ← default; local & on-Space
336
  GBNF grammar from JSON schema β†’ guaranteed-valid JSON.
337
  No transformers; ~5GB at Q4_K_M. Earns Llama Champion.
338
  "modal_llm" β†’ HTTP POST to a Modal GPU endpoint (modal_llm.py)
339
  transformers pinned to 4.x there, where MiniCPM4.1 works.
 
 
 
340
  β”‚
341
  model/painter.py ← STORY_SHAPES_PAINT_BACKEND
342
  "modal" β†’ Modal web endpoint (A10G GPU, FLUX.2 Klein 4B)
 
355
  **Model judgment:**
356
  - Full story context with per-beat status flags on every call β€” globally aware pacing.
357
  - Pacing note branches: "no shape yet β†’ be generous" vs "settled beats β†’ skip."
358
+ - Judgment model: MiniCPM4.1-8B (OpenBMB prize), run via `llamacpp` (GGUF) or `modal_llm`. Structured output is enforced by llama.cpp's GBNF-from-schema grammar (guaranteed-valid JSON) or, for the Modal path, `_extract_json` + a 3-attempt parse/retry. `<think>` blocks are stripped (the grammar path suppresses them outright).
359
 
360
  ---
361
 
 
363
 
364
  | Variable | Default | Purpose |
365
  |---|---|---|
366
+ | `STORY_SHAPES_BACKEND` | `llamacpp` | LLM backend: `llamacpp` or `modal_llm` |
 
367
  | `STORY_SHAPES_LLM_MODAL_URL` | *(required for `modal_llm`)* | URL from `modal deploy modal_llm.py` |
368
  | `STORY_SHAPES_LLAMACPP_REPO` | `openbmb/MiniCPM4.1-8B-GGUF` | GGUF Hub repo (`llamacpp` backend) |
369
  | `STORY_SHAPES_LLAMACPP_FILE` | `*Q4_K_M.gguf` | GGUF filename glob |
 
372
  | `STORY_SHAPES_PAINT_BACKEND` | `modal` | `modal` or `flux_local` |
373
  | `STORY_SHAPES_PAINT_MODAL_URL` | *(required for modal paint)* | URL from `modal deploy modal_painter.py` |
374
  | `STORY_SHAPES_FLUX_MODEL` | `black-forest-labs/FLUX.2-klein-4B` | FLUX repo (`flux_local`) |
 
375
  | `PORT` | `7860` | server port |
376
 
377
  ---
 
397
  | Share card (canvas + painting, popup) | βœ… | AI title (`title_story`, cached); transparent shapes card |
398
  | Reveal sound (chimes / tick / pad) + replay | βœ… | Web Audio; pentatonic; Settings toggle |
399
  | Origin tooltip (shape + layer, hover/touch) | βœ… | shows verbatim source beat |
400
+ | llama.cpp backend (`llamacpp`) | βœ… | default; GGUF in-process; GBNF from schema; **Llama Champion** |
401
  | Modal LLM backend (`modal_llm`) | βœ… | transformers 4.x on Modal; MiniCPM4.1 verified |
 
 
402
  | Felt-quality eval harness | βœ… | `eval_felt_quality.py` |
403
  | `devReveal()` console shortcut | βœ… | dev/testing only |
404
  | **HF Space deployment** | βœ… | gradio 6.18 (`gr.Server`); LLM via llama.cpp/Modal, painter via Modal |
field_notes.md CHANGED
@@ -232,14 +232,12 @@ app.py β€” gradio.Server (satisfies the Off-Brand badge; gradio 6.x)
232
  @app.api judge_beat Β· judge_beat_segmented Β· continue_story Β· reveal
233
  Β· title_story Β· paint (each concurrency_limit=1)
234
  β”‚
235
- model/backend.py
236
- "llamacpp" β†’ GGUF in-process via llama-cpp-python ← recommended on-Space
237
  GBNF grammar from JSON schema β†’ guaranteed-valid JSON.
238
  No transformers; ~5GB at Q4_K_M. Earns Llama Champion.
239
  "modal_llm" β†’ HTTP POST to a Modal GPU endpoint (modal_llm.py),
240
  with transformers pinned to 4.x, where MiniCPM4.1 works.
241
- "ollama" β†’ Ollama HTTP (local dev); JSON-schema `format`.
242
- "hf" β†’ transformers in-process. Works for Qwen3; kept for it.
243
  β”‚
244
  model/painter.py
245
  "modal" β†’ Modal web endpoint (A10G GPU, FLUX.2 Klein 4B)
@@ -248,9 +246,9 @@ app.py β€” gradio.Server (satisfies the Off-Brand badge; gradio 6.x)
248
 
249
  Both Modal services (`modal_llm.py`, `modal_painter.py`) are deployed once with `modal deploy`; they keep one container warm (`scaledown_window`) and lazy-load weights on the first call.
250
 
251
- **Why four LLM backends?** This was the hardest part of shipping. `gr.Server` (for the custom frontend) needs gradio 6.x, which forces `huggingface-hub β‰₯ 1.2`, which forces `transformers β‰₯ 5.0`. But MiniCPM4.1-8B loads via `trust_remote_code`, and its remote code is incompatible with transformers 5.x: loading needs a shim, and generation then still crashes deep in attention. The two clean escapes are to run it on **Modal** with transformers pinned to 4.x, or β€” better β€” to run it through **llama.cpp** as a quantized GGUF, which uses no `transformers` at all and is small enough (~5 GB) to sit beside FLUX on a single 24 GB GPU. The llama.cpp route also earns the Llama Champion badge.
252
 
253
- **The deployment journey.** The four backends weren't designed up front β€” they're the fossil record of getting this to run on the hardware I actually had (a laptop with **16 GB RAM / 8 GB VRAM**, nowhere near enough for an 8B LLM *and* a 4B diffusion model at once):
254
 
255
  1. **Qwen3-8B on Ollama (local) + FLUX.2 Klein on Modal.** The first working setup. The LLM ran locally through Ollama (which is itself llama.cpp under the hood); the painter β€” far too heavy for 8 GB β€” was pushed to a Modal GPU endpoint. This split kept the loop fast to iterate on while the expensive image step lived in the cloud.
256
  2. **MiniCPM4.1-8B via llama.cpp + FLUX.2 Klein on Modal.** To target the OpenBMB prize I swapped the judge to MiniCPM4.1-8B, and moved to running it directly on **llama.cpp** (a quantized GGUF via `llama-cpp-python`) rather than Ollama β€” which also claims the Llama Champion badge. The painter is still on Modal due to previous RAM constraints.
 
232
  @app.api judge_beat Β· judge_beat_segmented Β· continue_story Β· reveal
233
  Β· title_story Β· paint (each concurrency_limit=1)
234
  β”‚
235
+ model/backend.py ← STORY_SHAPES_BACKEND
236
+ "llamacpp" β†’ GGUF in-process via llama-cpp-python ← default; local & on-Space
237
  GBNF grammar from JSON schema β†’ guaranteed-valid JSON.
238
  No transformers; ~5GB at Q4_K_M. Earns Llama Champion.
239
  "modal_llm" β†’ HTTP POST to a Modal GPU endpoint (modal_llm.py),
240
  with transformers pinned to 4.x, where MiniCPM4.1 works.
 
 
241
  β”‚
242
  model/painter.py
243
  "modal" β†’ Modal web endpoint (A10G GPU, FLUX.2 Klein 4B)
 
246
 
247
  Both Modal services (`modal_llm.py`, `modal_painter.py`) are deployed once with `modal deploy`; they keep one container warm (`scaledown_window`) and lazy-load weights on the first call.
248
 
249
+ **Why llama.cpp, not in-process `transformers`?** This was the hardest part of shipping. `gr.Server` (for the custom frontend) needs gradio 6.x, which forces `huggingface-hub β‰₯ 1.2`, which forces `transformers β‰₯ 5.0`. But MiniCPM4.1-8B loads via `trust_remote_code`, and its remote code is incompatible with transformers 5.x: loading needs a shim, and generation then still crashes deep in attention. The two clean escapes are to run it on **Modal** with transformers pinned to 4.x, or β€” better β€” to run it through **llama.cpp** as a quantized GGUF, which uses no `transformers` at all and is small enough (~5 GB) to sit beside FLUX on a single 24 GB GPU. The llama.cpp route also earns the Llama Champion badge.
250
 
251
+ **The deployment journey.** Those two backends weren't the first attempt β€” they're what survived after a path of dead ends, driven by the hardware I actually had (a laptop with **16 GB RAM / 8 GB VRAM**, nowhere near enough for an 8B LLM *and* a 4B diffusion model at once):
252
 
253
  1. **Qwen3-8B on Ollama (local) + FLUX.2 Klein on Modal.** The first working setup. The LLM ran locally through Ollama (which is itself llama.cpp under the hood); the painter β€” far too heavy for 8 GB β€” was pushed to a Modal GPU endpoint. This split kept the loop fast to iterate on while the expensive image step lived in the cloud.
254
  2. **MiniCPM4.1-8B via llama.cpp + FLUX.2 Klein on Modal.** To target the OpenBMB prize I swapped the judge to MiniCPM4.1-8B, and moved to running it directly on **llama.cpp** (a quantized GGUF via `llama-cpp-python`) rather than Ollama β€” which also claims the Llama Champion badge. The painter is still on Modal due to previous RAM constraints.
modal_llm.py CHANGED
@@ -57,10 +57,7 @@ _NO_THINK_SUFFIX = [{"role": "assistant", "content": "<think>\n\n</think>\n\n"}]
57
  class LLM:
58
  @modal.enter()
59
  def load(self):
60
- # import transformers.utils.import_utils
61
- # setattr(transformers.utils.import_utils, 'is_torch_fx_available', lambda: True)
62
-
63
- from transformers import pipeline
64
  import torch
65
  print(f"Loading {MODEL_ID}…")
66
  self.pipe = pipeline(
 
57
  class LLM:
58
  @modal.enter()
59
  def load(self):
60
+ from transformers import pipeline
 
 
 
61
  import torch
62
  print(f"Loading {MODEL_ID}…")
63
  self.pipe = pipeline(
model/__pycache__/backend.cpython-310.pyc CHANGED
Binary files a/model/__pycache__/backend.cpython-310.pyc and b/model/__pycache__/backend.cpython-310.pyc differ
 
model/backend.py CHANGED
@@ -1,31 +1,28 @@
1
  """
2
- ...
3
- Backends (STORY_SHAPES_BACKEND env var, default "ollama"):
4
- - "ollama" : local dev. HTTP to a running Ollama daemon. Uses Ollama's
5
- structured-output `format` for guaranteed-valid JSON.
6
- - "hf" : HF Space / any machine with a GPU + transformers. Loads the
7
- model in-process. Uses prompt-based JSON + parse+retry for
8
- structured output. Thinking disabled via the Qwen3 assistant
9
- prefix trick (<think>\\n\\n</think>).
10
-
11
- Qwen3 note: thinking is disabled in both backends (Ollama: /no_think header;
12
- HF: empty-think assistant prefix). This keeps latency low and JSON clean.
 
 
 
 
 
 
 
13
  """
14
  import os, json, re, urllib.request
15
 
16
- # ZeroGPU: decorate GPU-using functions with @_GPU so HF Spaces can allocate
17
- # the GPU on demand. Falls back to a no-op when running locally (no `spaces` pkg).
18
  import spaces
19
- # try:
20
- # import spaces
21
- # # _GPU = spaces.GPU
22
- # except ImportError:
23
- # def _GPU(fn=None, *, duration=60):
24
- # return fn if fn is not None else (lambda f: f)
25
-
26
- # is_torch_fx_available fix
27
- import transformers.utils.import_utils
28
- setattr(transformers.utils.import_utils, 'is_torch_fx_available', lambda: True)
29
 
30
  import logging
31
  logging.basicConfig(
@@ -35,34 +32,23 @@ logging.basicConfig(
35
  )
36
  log = logging.getLogger("story_shapes")
37
 
38
- # BACKEND = os.environ.get("STORY_SHAPES_BACKEND", "ollama")
39
- BACKEND = os.environ.get("STORY_SHAPES_BACKEND", "modal_llm")
40
- OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://localhost:11434")
41
  LLM_URL = os.environ.get("STORY_SHAPES_LLM_MODAL_URL", "") # Modal LLM endpoint URL
42
- # Default: Qwen3-8B. Swap to MiniCPM4.1-8B for the OpenBMB prize by setting
43
- # STORY_SHAPES_MODEL=openbmb/MiniCPM4.1-8B (HF Space, BACKEND=hf)
44
- # STORY_SHAPES_MODEL=openbmb/minicpm4.1 (Ollama local)
45
- # MiniCPM4.1-8B uses the same <think> tag convention as Qwen3, so the existing
46
- # empty-think assistant prefix (see _NO_THINK_SUFFIX) suppresses thinking for both.
47
- # Requires transformers>=4.56 (already pinned in requirements.txt).
48
- MODEL = os.environ.get(
49
- "STORY_SHAPES_MODEL",
50
- "qwen3:8b" if BACKEND == "ollama" else "Qwen/Qwen3-8B"
51
- )
52
 
53
- # llama.cpp backend (STORY_SHAPES_BACKEND=llamacpp): runs a quantized GGUF
54
- # in-process via llama-cpp-python. Q4_K_M of MiniCPM4.1-8B is ~5 GB VRAM, so it
55
- # coexists with the FLUX painter on a single 24 GB L4. Does NOT use transformers
56
- # (so it sidesteps the transformers-5 / MiniCPM remote-code incompatibility).
57
  LLAMACPP_REPO = os.environ.get("STORY_SHAPES_LLAMACPP_REPO", "openbmb/MiniCPM4.1-8B-GGUF")
58
  LLAMACPP_FILE = os.environ.get("STORY_SHAPES_LLAMACPP_FILE", "*Q4_K_M.gguf")
59
  LLAMACPP_CTX = int(os.environ.get("STORY_SHAPES_LLAMACPP_CTX", "4096"))
60
  # -1 offloads all layers to GPU; set 0 for CPU-only.
61
  LLAMACPP_GPU_LAYERS = int(os.environ.get("STORY_SHAPES_LLAMACPP_GPU_LAYERS", "-1"))
62
 
 
 
 
 
63
  PROMPT_MARKDOWN_DIVIDER = "================================================================================"
64
 
65
- # ---- JSON schemas (mirror the GBNF grammars; used as Ollama `format`) ----
66
  UNIT = {"type": "number", "minimum": 0, "maximum": 1}
67
  MATERIAL_ENUM = {"type": "string", "enum": ["paper", "ink", "glass", "enamel", "chalk", "metal"]}
68
  CORE_SCHEMA = {
@@ -139,102 +125,19 @@ def _load_prompt(name):
139
  with open(os.path.join(here, "prompts", name), encoding="utf-8") as f:
140
  return f.read()
141
 
142
- # ---------------------------------------------------------------------------
143
- # Ollama backend
144
- # ---------------------------------------------------------------------------
145
- def _ollama_chat(system, user, schema):
146
- body = {
147
- "model": MODEL,
148
- "messages": [
149
- {"role": "system", "content": system + "\n/no_think"}, # Qwen3: thinking off
150
- {"role": "user", "content": user},
151
- ],
152
- "stream": False,
153
- "format": schema, # structured output -> guaranteed-valid JSON
154
- "options": {"temperature": 0.6, "top_p": 0.9},
155
- }
156
- req = urllib.request.Request(
157
- OLLAMA_URL + "/api/chat",
158
- data=json.dumps(body).encode(),
159
- headers={"Content-Type": "application/json"},
160
- )
161
- with urllib.request.urlopen(req, timeout=120) as r:
162
- resp = json.loads(r.read())
163
- content = resp["message"]["content"].strip()
164
- log.info("OLLAMA raw <- %s", content[:500])
165
- # strip any stray <think></think> if the model emitted one despite /no_think
166
- if "</think>" in content:
167
- content = content.split("</think>", 1)[1].strip()
168
- parsed = json.loads(content)
169
- log.info("OLLAMA json -> %s", parsed)
170
- return parsed
171
-
172
  # extract just the system prompt block from a prompt .md (between the SYSTEM markers)
173
  def _system_block(md, marker="SYSTEM PROMPT"):
174
  if marker in md:
175
  after = md.split(marker, 1)[1]
176
- # cut at the next "====" divider
177
- # return after.split("====", 1)[0].strip().lstrip("=").strip()
178
  return after.split(PROMPT_MARKDOWN_DIVIDER, 2)[1].strip()
179
  return md
180
 
181
  # ---------------------------------------------------------------------------
182
- # HF backend β€” transformers in-process (HF Space / any GPU machine)
183
  # ---------------------------------------------------------------------------
184
- _hf_pipe = None # lazy-loaded pipeline
185
-
186
- def _get_hf_pipe():
187
- global _hf_pipe
188
- if _hf_pipe is not None:
189
- return _hf_pipe
190
- from transformers import pipeline, GenerationConfig
191
- import torch
192
- log.info("loading HF model %s …", MODEL)
193
- _hf_pipe = pipeline(
194
- "text-generation",
195
- model=MODEL,
196
- dtype=torch.bfloat16, # transformers 5.x: dtype, not torch_dtype
197
- device_map="auto",
198
- trust_remote_code=True,
199
- )
200
- # Clear the model's generation_config so our per-call GenerationConfig is
201
- # the sole source of truth. MiniCPM4.1 ships max_length=20 in its
202
- # generation_config.json; if left in place it conflicts with max_new_tokens
203
- # and causes a Key/Value sequence-length mismatch in SDPA.
204
- _hf_pipe.model.generation_config = GenerationConfig()
205
- log.info("HF model loaded.")
206
- return _hf_pipe
207
-
208
- # Qwen3 thinking-disable: append an assistant message with an empty <think> block.
209
- # Stateless (one-turn only), strictly prevents thinking tokens per Qwen3 docs.
210
- _NO_THINK_SUFFIX = [{"role": "assistant", "content": "<think>\n\n</think>\n\n"}]
211
-
212
- # @_GPU(duration=120)
213
- @spaces.GPU(duration=60)
214
- def _hf_generate(messages, max_new_tokens=512, temperature=0.6) -> str:
215
- from transformers import GenerationConfig
216
- pipe = _get_hf_pipe()
217
- full_messages = messages + _NO_THINK_SUFFIX
218
- # Pass a single GenerationConfig rather than mixing kwargs + model's config.
219
- # Transformers 5.x deprecates passing both simultaneously.
220
- gen_cfg = GenerationConfig(
221
- max_new_tokens=max_new_tokens,
222
- temperature=temperature,
223
- do_sample=temperature > 0,
224
- )
225
- out = pipe(
226
- full_messages,
227
- generation_config=gen_cfg,
228
- return_full_text=False,
229
- )
230
- text = out[0]["generated_text"]
231
- if "</think>" in text:
232
- text = text.split("</think>", 1)[1]
233
- return text.strip()
234
-
235
  def _extract_json(text: str) -> dict:
236
  """Extract the first {...} JSON object, handling markdown fences and
237
- trailing commas β€” the three most common model formatting mistakes."""
238
  text = re.sub(r"```(?:json)?", "", text).strip()
239
  m = re.search(r"\{.*\}", text, re.DOTALL)
240
  if not m:
@@ -268,45 +171,8 @@ def _apply_defaults(parsed: dict, schema: dict) -> dict:
268
  parsed[k] = prop["enum"][0] # coerce invalid enum value to first valid
269
  return parsed
270
 
271
- def _hf_chat(system: str, user: str, schema: dict) -> dict:
272
- """Call the HF pipeline, parse JSON, validate, retry up to 3 times."""
273
- hint = _schema_hint(schema)
274
- messages = [
275
- {"role": "system", "content": system},
276
- {"role": "user", "content": f"{user}\n\nReturn ONLY a JSON object with keys: {hint}"},
277
- ]
278
- for attempt in range(3):
279
- raw = _hf_generate(messages)
280
- log.info("HF raw (attempt %d) <- %s", attempt + 1, raw[:400])
281
- try:
282
- parsed = _apply_defaults(_extract_json(raw), schema)
283
- log.info("HF json -> %s", parsed)
284
- return parsed
285
- except (ValueError, json.JSONDecodeError, KeyError) as e:
286
- log.warning("HF parse attempt %d failed: %s", attempt + 1, e)
287
- if attempt == 2:
288
- raise RuntimeError(
289
- f"HF backend failed to produce valid JSON after 3 attempts. "
290
- f"Last output: {raw!r}") from e
291
-
292
- def _hf_continue_story(story: str) -> str:
293
- if story == "":
294
- user_prompt = f"Story so far:\n{story}\nStart with 1-3 sentences."
295
- else:
296
- user_prompt = f"Story so far:\n{story}\nContinue with 1-3 sentences."
297
- messages = [
298
- {"role": "system", "content": (
299
- "You are co-writing a story one story beat at a time. "
300
- "Continue with exactly 1-3 sentences that follow naturally. "
301
- "If the story is empty, start it by any means; up to you."
302
- "Output only the sentence(s), no quotes, no preamble."
303
- )},
304
- {"role": "user", "content": user_prompt},
305
- ]
306
- return _hf_generate(messages, max_new_tokens=200, temperature=0.85)
307
-
308
  # ---------------------------------------------------------------------------
309
- # Modal LLM backend β€” HTTP POST to deployed modal_llm.py endpoint
310
  # ---------------------------------------------------------------------------
311
  def _modal_generate(messages, max_new_tokens=1024, temperature=0.6) -> str:
312
  if not LLM_URL:
@@ -329,7 +195,7 @@ def _modal_generate(messages, max_new_tokens=1024, temperature=0.6) -> str:
329
  return resp["text"]
330
 
331
  def _modal_llm_chat(system: str, user: str, schema: dict) -> dict:
332
- """Like _hf_chat but generates via the Modal LLM endpoint instead of in-process."""
333
  hint = _schema_hint(schema)
334
  messages = [
335
  {"role": "system", "content": system},
@@ -358,6 +224,13 @@ def _get_llama():
358
  global _llama
359
  if _llama is not None:
360
  return _llama
 
 
 
 
 
 
 
361
  from llama_cpp import Llama
362
  log.info("loading llama.cpp model %s / %s …", LLAMACPP_REPO, LLAMACPP_FILE)
363
  _llama = Llama.from_pretrained(
@@ -415,13 +288,17 @@ def _chat(system, user, schema):
415
  log.info("system prompt: %s", system)
416
  log.info("user prompt: %s", user)
417
  log.info("output schema: %s", schema)
418
- if BACKEND == "ollama":
419
- return _ollama_chat(system, user, schema)
420
  if BACKEND == "modal_llm":
421
  return _modal_llm_chat(system, user, schema)
422
- if BACKEND == "llamacpp":
423
- return _llamacpp_chat(system, user, schema)
424
- return _hf_chat(system, user, schema)
 
 
 
 
 
 
425
 
426
  # ---------------------------------------------------------------------------
427
  # Public interface
@@ -487,91 +364,27 @@ def judge_attempt(target_affect, shape_sentence):
487
 
488
  def continue_story(story):
489
  log.info("continue_story (story %d chars)", len(story))
490
-
491
- if BACKEND == "hf":
492
- out = _hf_continue_story(story)
493
- log.info("continue_story -> %r", out)
494
- return out
495
-
496
  if BACKEND == "modal_llm":
497
- prompt = f"Story so far:\n{story}\n{'Continue' if story else 'Start'} with 1-3 sentences."
498
- messages = [
499
- {"role": "system", "content": (
500
- "You are co-writing a story one story beat at a time. "
501
- "Continue with exactly 1-3 sentences that follow naturally. "
502
- "If the story is empty, start it; up to you. "
503
- "Output only the sentence(s), no quotes, no preamble."
504
- )},
505
- {"role": "user", "content": prompt},
506
- ]
507
  out = _modal_generate(messages, max_new_tokens=200, temperature=0.85)
508
- if "</think>" in out:
509
- out = out.split("</think>", 1)[1].strip()
510
- log.info("continue_story (modal_llm) -> %r", out)
511
- return out.strip().strip('"')
512
-
513
- if BACKEND == "llamacpp":
514
- prompt = f"Story so far:\n{story}\n{'Continue' if story else 'Start'} with 1-3 sentences."
515
- messages = [
516
- {"role": "system", "content": (
517
- "You are co-writing a story one story beat at a time. "
518
- "Continue with exactly 1-3 sentences that follow naturally. "
519
- "If the story is empty, start it; up to you. "
520
- "Output only the sentence(s), no quotes, no preamble."
521
- )},
522
- {"role": "user", "content": prompt},
523
- ]
524
  out = _llamacpp_generate(messages, max_new_tokens=200, temperature=0.85)
525
- log.info("continue_story (llamacpp) -> %r", out)
526
- return out.strip().strip('"')
527
-
528
- md = _load_prompt("prompt_passpen.md")
529
- system = _system_block(md)
530
- if story == "":
531
- user = f"Story so far:\n{story}\nStart with 1-3 sentences."
532
- else:
533
- user = f"Story so far:\n{story}\nContinue with 1-3 sentences."
534
- log.info("system prompt: %s", system)
535
- log.info("user prompt: %s", user)
536
- # prose, not JSON β€” call without a schema
537
- body = {"model": MODEL,
538
- "messages": [{"role": "system", "content": system + "\n/no_think"},
539
- {"role": "user", "content": user}],
540
- "stream": False, "options": {"temperature": 0.85}}
541
- req = urllib.request.Request(OLLAMA_URL + "/api/chat",
542
- data=json.dumps(body).encode(), headers={"Content-Type": "application/json"})
543
- with urllib.request.urlopen(req, timeout=120) as r:
544
- resp = json.loads(r.read())
545
- out = resp["message"]["content"].strip()
546
  if "</think>" in out:
547
  out = out.split("</think>", 1)[1].strip()
548
- log.info("continue_story -> %r", out.strip().strip('"'))
549
  return out.strip().strip('"')
550
 
551
  def _generate_prose(system: str, user: str, temperature=0.8, max_new_tokens=200) -> str:
552
  """Backend-agnostic prose (non-JSON) generation, used by title_story."""
553
- if BACKEND == "hf":
554
- out = _hf_generate(
555
- [{"role": "system", "content": system}, {"role": "user", "content": user}],
556
- max_new_tokens=max_new_tokens, temperature=temperature)
557
- elif BACKEND == "modal_llm":
558
- out = _modal_generate(
559
- [{"role": "system", "content": system}, {"role": "user", "content": user}],
560
- max_new_tokens=max_new_tokens, temperature=temperature)
561
- elif BACKEND == "llamacpp":
562
- out = _llamacpp_generate(
563
- [{"role": "system", "content": system}, {"role": "user", "content": user}],
564
- max_new_tokens=max_new_tokens, temperature=temperature)
565
- else: # ollama
566
- body = {"model": MODEL,
567
- "messages": [{"role": "system", "content": system + "\n/no_think"},
568
- {"role": "user", "content": user}],
569
- "stream": False, "options": {"temperature": temperature}}
570
- req = urllib.request.Request(OLLAMA_URL + "/api/chat",
571
- data=json.dumps(body).encode(), headers={"Content-Type": "application/json"})
572
- with urllib.request.urlopen(req, timeout=120) as r:
573
- resp = json.loads(r.read())
574
- out = resp["message"]["content"].strip()
575
  if "</think>" in out:
576
  out = out.split("</think>", 1)[1].strip()
577
  return out.strip()
 
1
  """
2
+ model/backend.py β€” language-model abstraction for the affect judgments.
3
+
4
+ Backends (STORY_SHAPES_BACKEND env var, default "llamacpp"):
5
+ - "llamacpp" : recommended; runs locally and on a GPU Space. A quantized GGUF
6
+ run in-process via llama-cpp-python (the llama.cpp runtime) β€”
7
+ no `transformers`. Structured output is enforced by a GBNF
8
+ grammar built from the JSON schema, so JSON is guaranteed
9
+ valid. ~5 GB at Q4_K_M, so it fits beside the FLUX painter on
10
+ one 24 GB GPU.
11
+ - "modal_llm" : HTTP POST to a deployed Modal GPU endpoint (modal_llm.py),
12
+ where `transformers` is pinned to 4.x (the version MiniCPM4.1
13
+ needs). Use when the Space itself has no GPU.
14
+
15
+ (The painter is independent β€” see model/painter.py / STORY_SHAPES_PAINT_BACKEND.)
16
+
17
+ MiniCPM4.1-8B uses the <think> tag convention; thinking is suppressed by the
18
+ grammar (JSON paths) or a /no_think system line (prose paths), and stripped
19
+ defensively either way.
20
  """
21
  import os, json, re, urllib.request
22
 
23
+ # ZeroGPU: @spaces.GPU lets a GPU Space allocate the GPU on demand for the
24
+ # in-process llama.cpp calls. On dedicated/local hardware it's a passthrough.
25
  import spaces
 
 
 
 
 
 
 
 
 
 
26
 
27
  import logging
28
  logging.basicConfig(
 
32
  )
33
  log = logging.getLogger("story_shapes")
34
 
35
+ BACKEND = os.environ.get("STORY_SHAPES_BACKEND", "llamacpp")
 
 
36
  LLM_URL = os.environ.get("STORY_SHAPES_LLM_MODAL_URL", "") # Modal LLM endpoint URL
 
 
 
 
 
 
 
 
 
 
37
 
38
+ # llama.cpp backend: a quantized GGUF run in-process via llama-cpp-python.
 
 
 
39
  LLAMACPP_REPO = os.environ.get("STORY_SHAPES_LLAMACPP_REPO", "openbmb/MiniCPM4.1-8B-GGUF")
40
  LLAMACPP_FILE = os.environ.get("STORY_SHAPES_LLAMACPP_FILE", "*Q4_K_M.gguf")
41
  LLAMACPP_CTX = int(os.environ.get("STORY_SHAPES_LLAMACPP_CTX", "4096"))
42
  # -1 offloads all layers to GPU; set 0 for CPU-only.
43
  LLAMACPP_GPU_LAYERS = int(os.environ.get("STORY_SHAPES_LLAMACPP_GPU_LAYERS", "-1"))
44
 
45
+ # Active model identifier, for /health and the eval harness (informational β€”
46
+ # the Modal endpoint pins its own model id internally).
47
+ MODEL = os.environ.get("STORY_SHAPES_MODEL", LLAMACPP_REPO)
48
+
49
  PROMPT_MARKDOWN_DIVIDER = "================================================================================"
50
 
51
+ # ---- JSON schemas (source for the llama.cpp GBNF / schema-constrained output) ----
52
  UNIT = {"type": "number", "minimum": 0, "maximum": 1}
53
  MATERIAL_ENUM = {"type": "string", "enum": ["paper", "ink", "glass", "enamel", "chalk", "metal"]}
54
  CORE_SCHEMA = {
 
125
  with open(os.path.join(here, "prompts", name), encoding="utf-8") as f:
126
  return f.read()
127
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
  # extract just the system prompt block from a prompt .md (between the SYSTEM markers)
129
  def _system_block(md, marker="SYSTEM PROMPT"):
130
  if marker in md:
131
  after = md.split(marker, 1)[1]
 
 
132
  return after.split(PROMPT_MARKDOWN_DIVIDER, 2)[1].strip()
133
  return md
134
 
135
  # ---------------------------------------------------------------------------
136
+ # Shared JSON helpers (used by both backends)
137
  # ---------------------------------------------------------------------------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
  def _extract_json(text: str) -> dict:
139
  """Extract the first {...} JSON object, handling markdown fences and
140
+ trailing commas β€” the most common model formatting mistakes."""
141
  text = re.sub(r"```(?:json)?", "", text).strip()
142
  m = re.search(r"\{.*\}", text, re.DOTALL)
143
  if not m:
 
171
  parsed[k] = prop["enum"][0] # coerce invalid enum value to first valid
172
  return parsed
173
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
174
  # ---------------------------------------------------------------------------
175
+ # Modal LLM backend β€” HTTP POST to the deployed modal_llm.py endpoint
176
  # ---------------------------------------------------------------------------
177
  def _modal_generate(messages, max_new_tokens=1024, temperature=0.6) -> str:
178
  if not LLM_URL:
 
195
  return resp["text"]
196
 
197
  def _modal_llm_chat(system: str, user: str, schema: dict) -> dict:
198
+ """Generate JSON via the Modal LLM endpoint; parse, validate, retry up to 3Γ—."""
199
  hint = _schema_hint(schema)
200
  messages = [
201
  {"role": "system", "content": system},
 
224
  global _llama
225
  if _llama is not None:
226
  return _llama
227
+ # Import torch first: it loads the CUDA runtime libraries (libcudart, etc.)
228
+ # into the process, which the prebuilt llama-cpp-python CUDA wheel's
229
+ # libllama.so needs to resolve when it loads. Harmless if torch is absent.
230
+ try:
231
+ import torch # noqa: F401
232
+ except Exception:
233
+ pass
234
  from llama_cpp import Llama
235
  log.info("loading llama.cpp model %s / %s …", LLAMACPP_REPO, LLAMACPP_FILE)
236
  _llama = Llama.from_pretrained(
 
288
  log.info("system prompt: %s", system)
289
  log.info("user prompt: %s", user)
290
  log.info("output schema: %s", schema)
 
 
291
  if BACKEND == "modal_llm":
292
  return _modal_llm_chat(system, user, schema)
293
+ return _llamacpp_chat(system, user, schema)
294
+
295
+ # Co-writing system prompt, shared by the pass-the-pen continuation.
296
+ _CONTINUE_SYSTEM = (
297
+ "You are co-writing a story one story beat at a time. "
298
+ "Continue with exactly 1-3 sentences that follow naturally. "
299
+ "If the story is empty, start it; up to you. "
300
+ "Output only the sentence(s), no quotes, no preamble."
301
+ )
302
 
303
  # ---------------------------------------------------------------------------
304
  # Public interface
 
364
 
365
  def continue_story(story):
366
  log.info("continue_story (story %d chars)", len(story))
367
+ prompt = f"Story so far:\n{story}\n{'Continue' if story else 'Start'} with 1-3 sentences."
368
+ messages = [
369
+ {"role": "system", "content": _CONTINUE_SYSTEM},
370
+ {"role": "user", "content": prompt},
371
+ ]
 
372
  if BACKEND == "modal_llm":
 
 
 
 
 
 
 
 
 
 
373
  out = _modal_generate(messages, max_new_tokens=200, temperature=0.85)
374
+ else: # llamacpp
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
375
  out = _llamacpp_generate(messages, max_new_tokens=200, temperature=0.85)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
376
  if "</think>" in out:
377
  out = out.split("</think>", 1)[1].strip()
378
+ log.info("continue_story -> %r", out)
379
  return out.strip().strip('"')
380
 
381
  def _generate_prose(system: str, user: str, temperature=0.8, max_new_tokens=200) -> str:
382
  """Backend-agnostic prose (non-JSON) generation, used by title_story."""
383
+ messages = [{"role": "system", "content": system}, {"role": "user", "content": user}]
384
+ if BACKEND == "modal_llm":
385
+ out = _modal_generate(messages, max_new_tokens=max_new_tokens, temperature=temperature)
386
+ else: # llamacpp
387
+ out = _llamacpp_generate(messages, max_new_tokens=max_new_tokens, temperature=temperature)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
388
  if "</think>" in out:
389
  out = out.split("</think>", 1)[1].strip()
390
  return out.strip()
requirements.txt CHANGED
@@ -32,25 +32,8 @@ diffusers @ git+https://github.com/huggingface/diffusers.git
32
  # Runs a quantized GGUF (default openbmb/MiniCPM4.1-8B-GGUF Q4_K_M, ~5GB) in
33
  # process β€” no transformers, so it avoids the transformers-5/MiniCPM crash and
34
  # leaves VRAM for the FLUX painter on one 24GB L4.
35
- #
36
- # β”Œβ”€ PICK EXACTLY ONE BLOCK ────────────────────────────────────────────────┐
37
- #
38
- # [A] GPU (default) β€” prebuilt CUDA-12 wheel + runtime libs. The nvidia-*-cu12
39
- # packages provide libcudart.so.12 / libcublas*.so.12, which
40
- # model/backend.py preloads before importing llama_cpp (the Space image
41
- # doesn't expose CUDA-12 runtime libs on the loader path otherwise).
42
- # Env: STORY_SHAPES_LLAMACPP_GPU_LAYERS=-1 (or just leave it unset).
43
  --extra-index-url https://download.pytorch.org/whl/cu128
44
  torch==2.8.0
45
  hf_transfer
46
  --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cu124
47
  llama-cpp-python>=0.3.0
48
-
49
- #
50
- # [B] CPU FALLBACK β€” if the CUDA build keeps failing. Comment out the four [A]
51
- # lines above, uncomment the one line below, and set the Space variable
52
- # STORY_SHAPES_LLAMACPP_GPU_LAYERS=0. Plain CPU wheel, installs everywhere;
53
- # the LLM runs on CPU (~10-15s per short JSON judgment), painter still GPU.
54
- # llama-cpp-python>=0.3.0
55
- #
56
- # β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
 
32
  # Runs a quantized GGUF (default openbmb/MiniCPM4.1-8B-GGUF Q4_K_M, ~5GB) in
33
  # process β€” no transformers, so it avoids the transformers-5/MiniCPM crash and
34
  # leaves VRAM for the FLUX painter on one 24GB L4.
 
 
 
 
 
 
 
 
35
  --extra-index-url https://download.pytorch.org/whl/cu128
36
  torch==2.8.0
37
  hf_transfer
38
  --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cu124
39
  llama-cpp-python>=0.3.0