cvpfus Codex commited on
Commit
41a3f6b
·
1 Parent(s): c9015ec

Add CPU Spaces deployment with Modal runtimes

Browse files

Co-authored-by: Codex <codex@openai.com>

Files changed (9) hide show
  1. .env.example +6 -1
  2. DEPLOY_SPACES.md +300 -0
  3. Dockerfile +44 -0
  4. README.md +28 -4
  5. app.py +372 -101
  6. modal_workers/klein_image.py +122 -68
  7. modal_workers/reader_brain.py +101 -0
  8. scripts/verify.py +155 -3
  9. start.sh +15 -0
.env.example CHANGED
@@ -5,11 +5,15 @@
5
  # App server
6
  GRADIO_SERVER_NAME=0.0.0.0
7
  GRADIO_SERVER_PORT=7860
 
8
  PUBLIC_BASE_URL=http://localhost:7860
9
 
10
- # Reader brain: llama.cpp OpenAI-compatible server
 
11
  LLAMA_CPP_BASE_URL=http://localhost:8080/v1
12
  LLAMA_CPP_MODEL=narrator-brain
 
 
13
 
14
  # Speech: Kokoro is loaded locally when the kokoro and soundfile packages are installed.
15
  # No API key is required.
@@ -23,4 +27,5 @@ MINICPM_VISION_TIMEOUT_SECONDS=45
23
  # Image generation: Modal-hosted FLUX.2 klein worker
24
  KLEIN_MODAL_ENDPOINT=
25
  KLEIN_MODAL_TOKEN=
 
26
  KLEIN_MODAL_TIMEOUT_SECONDS=120
 
5
  # App server
6
  GRADIO_SERVER_NAME=0.0.0.0
7
  GRADIO_SERVER_PORT=7860
8
+ GRADIO_SHARE=false
9
  PUBLIC_BASE_URL=http://localhost:7860
10
 
11
+ # Reader brain: llama.cpp OpenAI-compatible server.
12
+ # Use http://localhost:8080/v1 for local llama.cpp, or a Modal URL ending in /v1.
13
  LLAMA_CPP_BASE_URL=http://localhost:8080/v1
14
  LLAMA_CPP_MODEL=narrator-brain
15
+ LLAMA_CPP_TOKEN=
16
+ LLAMA_CPP_TIMEOUT_SECONDS=90
17
 
18
  # Speech: Kokoro is loaded locally when the kokoro and soundfile packages are installed.
19
  # No API key is required.
 
27
  # Image generation: Modal-hosted FLUX.2 klein worker
28
  KLEIN_MODAL_ENDPOINT=
29
  KLEIN_MODAL_TOKEN=
30
+ KLEIN_MODAL_HEALTH_TIMEOUT_SECONDS=30
31
  KLEIN_MODAL_TIMEOUT_SECONDS=120
DEPLOY_SPACES.md ADDED
@@ -0,0 +1,300 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Deploy Tiny Narrator to Hugging Face Spaces
2
+
3
+ This guide deploys Tiny Narrator on Hugging Face Spaces using the **Docker SDK on free CPU**. The Space serves the custom article UI, FastAPI routes, Kokoro/browser speech path, and fallback assets. GPU inference runs outside the Space through Modal or other OpenAI-compatible endpoints.
4
+
5
+ > **Why Docker SDK?** Tiny Narrator uses `gradio.Server` with custom FastAPI routes and static files. The Docker SDK preserves that app shape without rewriting the UI into `gr.Blocks`.
6
+ >
7
+ > **Why free CPU?** Paid HF GPU is not required when `llama.cpp` and image generation are hosted externally. The Space can stay on CPU Basic while still calling live model endpoints.
8
+
9
+ ---
10
+
11
+ ## Prerequisites
12
+
13
+ - A Hugging Face account
14
+ - Git and Git LFS installed locally
15
+ - Python 3.11+ installed locally for testing
16
+ - Modal CLI installed if you want live reader-brain or Klein image generation
17
+
18
+ ---
19
+
20
+ ## Step 1 - Create a New Space
21
+
22
+ 1. Go to [https://huggingface.co/new-space](https://huggingface.co/new-space).
23
+ 2. Fill in the form:
24
+ - **Space name**: `tiny-narrator`
25
+ - **SDK**: **Docker**
26
+ - **Hardware**: **CPU Basic**
27
+ - **Visibility**: Public or Private
28
+ 3. Click **Create Space**.
29
+
30
+ Your Space README metadata should include:
31
+
32
+ ```yaml
33
+ ---
34
+ title: Tiny Narrator
35
+ emoji: book
36
+ colorFrom: blue
37
+ colorTo: teal
38
+ sdk: docker
39
+ app_port: 7860
40
+ ---
41
+ ```
42
+
43
+ The `app_port: 7860` line is important because Tiny Narrator binds to port 7860 by default.
44
+
45
+ ---
46
+
47
+ ## Step 2 - Copy Project Files
48
+
49
+ Clone the Space repository and copy the Tiny Narrator project files into it:
50
+
51
+ ```bash
52
+ git clone https://huggingface.co/spaces/<YOUR_USERNAME>/tiny-narrator
53
+ cd tiny-narrator
54
+ ```
55
+
56
+ Include these files and directories:
57
+
58
+ ```text
59
+ app.py
60
+ requirements.txt
61
+ Dockerfile
62
+ start.sh
63
+ README.md
64
+ SUBMISSION.md
65
+ FIELD_NOTES.md
66
+ LICENSE
67
+ static/
68
+ modal_workers/
69
+ scripts/
70
+ ```
71
+
72
+ Do **not** copy `.env`; configure secrets in the Space Settings UI.
73
+
74
+ ---
75
+
76
+ ## Step 3 - Deploy Modal Reader Brain
77
+
78
+ The reader-brain role uses `nvidia/NVIDIA-Nemotron-3-Nano-4B-GGUF:Q4_K_M` through `llama.cpp`. Deploy it to Modal:
79
+
80
+ ```bash
81
+ pip install modal
82
+ modal setup
83
+ modal secret create tiny-narrator-reader-brain-token LLAMA_CPP_TOKEN=your-random-token
84
+ modal deploy modal_workers/reader_brain.py
85
+ ```
86
+
87
+ After deployment, Modal prints a URL similar to:
88
+
89
+ ```text
90
+ https://your-workspace--tiny-narrator-reader-brain.modal.run
91
+ ```
92
+
93
+ Set the Space variable:
94
+
95
+ ```text
96
+ LLAMA_CPP_BASE_URL=https://your-workspace--tiny-narrator-reader-brain.modal.run/v1
97
+ LLAMA_CPP_MODEL=narrator-brain
98
+ LLAMA_CPP_TOKEN=your-random-token
99
+ LLAMA_CPP_TIMEOUT_SECONDS=90
100
+ ```
101
+
102
+ The worker starts `llama-server` with:
103
+
104
+ ```bash
105
+ llama-server \
106
+ -hf nvidia/NVIDIA-Nemotron-3-Nano-4B-GGUF:Q4_K_M \
107
+ --alias narrator-brain \
108
+ --host 0.0.0.0 \
109
+ --port 8080 \
110
+ --ctx-size 0 \
111
+ --reasoning off \
112
+ --n-gpu-layers 999 \
113
+ --api-key your-random-token
114
+ ```
115
+
116
+ Modal scales the worker down when idle. The first request after scale-to-zero can be slow while the container and model start.
117
+
118
+ ---
119
+
120
+ ## Step 4 - Optional Live Image Generation
121
+
122
+ Tiny Narrator can use the Modal Klein worker for `black-forest-labs/FLUX.2-klein-4B` thumbnails:
123
+
124
+ ```bash
125
+ modal secret create tiny-narrator-klein-token KLEIN_MODAL_TOKEN=your-random-token
126
+ modal deploy modal_workers/klein_image.py
127
+ ```
128
+
129
+ Set these Space values:
130
+
131
+ ```text
132
+ KLEIN_MODAL_ENDPOINT=https://your-workspace--tiny-narrator-klein.modal.run
133
+ KLEIN_MODAL_TOKEN=your-random-token
134
+ KLEIN_MODAL_HEALTH_TIMEOUT_SECONDS=30
135
+ KLEIN_MODAL_TIMEOUT_SECONDS=120
136
+ ```
137
+
138
+ If the Klein worker is not configured, the app falls back to bundled SVG assets.
139
+
140
+ ---
141
+
142
+ ## Step 5 - Optional MiniCPM-V Image Descriptions
143
+
144
+ If you have an OpenAI-compatible MiniCPM-V-4.6 endpoint, set:
145
+
146
+ ```text
147
+ MINICPM_VISION_BASE_URL=https://your-vision-endpoint.example.com/v1
148
+ MINICPM_VISION_API_KEY=<secret>
149
+ MINICPM_VISION_MODEL=openbmb/MiniCPM-V-4.6
150
+ MINICPM_VISION_TIMEOUT_SECONDS=45
151
+ ```
152
+
153
+ If this endpoint is not configured, Tiny Narrator uses deterministic cached alt text.
154
+
155
+ ---
156
+
157
+ ## Step 6 - Configure Space Variables
158
+
159
+ In your Space, open **Settings -> Variables and secrets**.
160
+
161
+ Add these variables:
162
+
163
+ | Variable | Example | Notes |
164
+ | --- | --- | --- |
165
+ | `PUBLIC_BASE_URL` | `https://your-username-tiny-narrator.hf.space` | Used in generated judge/demo links |
166
+ | `GRADIO_SHARE` | `false` | Keep false on Spaces |
167
+ | `LLAMA_CPP_BASE_URL` | `https://...modal.run/v1` | Modal reader-brain endpoint |
168
+ | `LLAMA_CPP_MODEL` | `narrator-brain` | Alias served by llama.cpp |
169
+ | `LLAMA_CPP_TIMEOUT_SECONDS` | `90` | Reader/article generation timeout |
170
+ | `KLEIN_MODAL_ENDPOINT` | `https://...modal.run` | Optional image generation endpoint |
171
+ | `KLEIN_MODAL_HEALTH_TIMEOUT_SECONDS` | `30` | Optional Klein health timeout |
172
+ | `KLEIN_MODAL_TIMEOUT_SECONDS` | `120` | Optional Klein generation timeout |
173
+ | `MINICPM_VISION_BASE_URL` | `https://.../v1` | Optional image descriptor endpoint |
174
+ | `MINICPM_VISION_MODEL` | `openbmb/MiniCPM-V-4.6` | Optional descriptor model id |
175
+ | `MINICPM_VISION_TIMEOUT_SECONDS` | `45` | Optional descriptor timeout |
176
+
177
+ Add these as **Secrets**:
178
+
179
+ | Secret | Notes |
180
+ | --- | --- |
181
+ | `LLAMA_CPP_TOKEN` | Recommended for Modal reader-brain auth |
182
+ | `KLEIN_MODAL_TOKEN` | Only needed if Modal Klein is enabled |
183
+ | `MINICPM_VISION_API_KEY` | Only needed if MiniCPM-V is enabled |
184
+
185
+ ---
186
+
187
+ ## Step 7 - Commit and Push
188
+
189
+ ```bash
190
+ git add .
191
+ git commit -m "Deploy Tiny Narrator to HF Spaces CPU"
192
+ git push origin main
193
+ ```
194
+
195
+ HF Spaces will:
196
+
197
+ 1. Detect the Dockerfile.
198
+ 2. Build a small CPU image.
199
+ 3. Run `start.sh`.
200
+ 4. Launch `python app.py` on port 7860.
201
+ 5. Expose the app at `https://<YOUR_USERNAME>-tiny-narrator.hf.space`.
202
+
203
+ ---
204
+
205
+ ## Step 8 - Verify the Deployment
206
+
207
+ Open the Space URL in a browser, then check:
208
+
209
+ ```bash
210
+ curl https://<YOUR_USERNAME>-tiny-narrator.hf.space/api/health
211
+ curl https://<YOUR_USERNAME>-tiny-narrator.hf.space/api/model-budget
212
+ curl https://<YOUR_USERNAME>-tiny-narrator.hf.space/api/runtime-status
213
+ curl https://<YOUR_USERNAME>-tiny-narrator.hf.space/api/submission-readiness
214
+ ```
215
+
216
+ `/api/runtime-status` should show:
217
+
218
+ - `reader_brain`: `online` when Modal llama.cpp is reachable, `fallback-ready` otherwise
219
+ - `speech`: Kokoro or fallback speech path
220
+ - `vision`: MiniCPM online or fallback-ready
221
+ - `image_generation`: Modal Klein online or fallback-ready
222
+
223
+ ---
224
+
225
+ ## Troubleshooting
226
+
227
+ ### Space starts but reader brain is fallback-ready
228
+
229
+ - Confirm `LLAMA_CPP_BASE_URL` ends in `/v1`.
230
+ - Confirm the Space `LLAMA_CPP_TOKEN` secret matches the Modal `tiny-narrator-reader-brain-token` secret.
231
+ - Open the Modal logs for `tiny-narrator-reader-brain`.
232
+ - Check that the Modal URL is reachable at `/v1/models`.
233
+ - The first request after scale-to-zero may need extra time while Modal starts the container and loads the GGUF.
234
+
235
+ ### Space stuck on "Starting"
236
+
237
+ - Check the Space **Container** logs.
238
+ - Make sure `requirements.txt` installed successfully.
239
+ - Make sure the app binds to `0.0.0.0:7860`; the defaults already do this.
240
+
241
+ ### Kokoro TTS not producing audio
242
+
243
+ - `libsndfile1` is installed in the Docker image.
244
+ - If Kokoro fails to load, the app falls back to browser speech synthesis and transcript output.
245
+
246
+ ### Modal reader-brain cold starts are slow
247
+
248
+ - Keep `scaledown_window` higher in `modal_workers/reader_brain.py` if you want the container to stay warm longer.
249
+ - Use a faster GPU by setting `READER_BRAIN_MODAL_GPU` before deploying.
250
+ - Keep `min_containers=0` for cheapest operation; use a warm container only if you accept continuous cost.
251
+
252
+ ### External services unreachable
253
+
254
+ - HF Spaces has outbound internet access, but private endpoints are not reachable.
255
+ - Use public HTTPS endpoints for Modal, MiniCPM, and any tunnels.
256
+
257
+ ---
258
+
259
+ ## Architecture Overview
260
+
261
+ ```text
262
+ Hugging Face Space (Docker CPU)
263
+ app.py
264
+ static HTML/CSS/JS
265
+ outputs WAV files
266
+ deterministic fallbacks
267
+ |
268
+ +--> Modal reader-brain worker
269
+ | llama.cpp /v1/chat/completions
270
+ |
271
+ +--> Modal Klein image worker (optional)
272
+ |
273
+ +--> MiniCPM-V-4.6 OpenAI-compatible endpoint (optional)
274
+ ```
275
+
276
+ ---
277
+
278
+ ## Quick Reference
279
+
280
+ | Item | Value |
281
+ | --- | --- |
282
+ | Space SDK | Docker |
283
+ | Space hardware | CPU Basic |
284
+ | App port | 7860 |
285
+ | Space base image | `python:3.12-slim` |
286
+ | Reader brain runtime | Modal-hosted llama.cpp |
287
+ | Reader brain model | `nvidia/NVIDIA-Nemotron-3-Nano-4B-GGUF:Q4_K_M` |
288
+ | Reader brain base URL | `https://<modal-url>/v1` |
289
+ | Image generation | Modal Klein worker or SVG fallback |
290
+ | Image descriptions | MiniCPM-V endpoint or cached fallback |
291
+
292
+ ---
293
+
294
+ ## Further Reading
295
+
296
+ - [HF Spaces Docker SDK docs](https://huggingface.co/docs/hub/spaces-sdks-docker)
297
+ - [HF Spaces Secrets & Variables](https://huggingface.co/docs/hub/spaces-overview#managing-secrets)
298
+ - [Modal Web Functions](https://modal.com/docs/guide/webhooks)
299
+ - [Modal GPU acceleration](https://modal.com/docs/guide/gpu)
300
+ - [llama.cpp repository](https://github.com/ggml-org/llama.cpp)
Dockerfile ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Tiny Narrator - Hugging Face Spaces Docker deployment (free CPU)
2
+ # Serves the custom Gradio/FastAPI app. GPU model runtimes are external.
3
+
4
+ FROM python:3.12-slim
5
+
6
+ RUN apt-get update && \
7
+ apt-get install -y --no-install-recommends \
8
+ libsndfile1 \
9
+ && rm -rf /var/lib/apt/lists/*
10
+
11
+ RUN useradd -m -u 1000 user
12
+
13
+ WORKDIR /home/user/app
14
+
15
+ COPY --chown=user requirements.txt requirements.txt
16
+ RUN pip install --no-cache-dir -r requirements.txt
17
+
18
+ COPY --chown=user app.py app.py
19
+ COPY --chown=user start.sh start.sh
20
+ COPY --chown=user static/ static/
21
+ COPY --chown=user modal_workers/ modal_workers/
22
+ COPY --chown=user scripts/ scripts/
23
+ COPY --chown=user README.md README.md
24
+ COPY --chown=user SUBMISSION.md SUBMISSION.md
25
+ COPY --chown=user FIELD_NOTES.md FIELD_NOTES.md
26
+ COPY --chown=user LICENSE LICENSE
27
+
28
+ RUN mkdir -p /home/user/app/outputs && \
29
+ chown user:user /home/user/app/outputs && \
30
+ chmod +x /home/user/app/start.sh
31
+
32
+ USER user
33
+
34
+ ENV HOME=/home/user \
35
+ PATH=/home/user/.local/bin:$PATH \
36
+ GRADIO_SERVER_NAME=0.0.0.0 \
37
+ GRADIO_SERVER_PORT=7860 \
38
+ GRADIO_SHARE=false \
39
+ LLAMA_CPP_BASE_URL= \
40
+ LLAMA_CPP_MODEL=narrator-brain
41
+
42
+ EXPOSE 7860
43
+
44
+ CMD ["/home/user/app/start.sh"]
README.md CHANGED
@@ -46,10 +46,10 @@ Copy the example environment file to `.env` and fill in the values. The app load
46
  Copy-Item .env.example .env
47
  ```
48
 
49
- Start the llama.cpp reader-brain server:
50
 
51
  ```powershell
52
- llama-server -hf nvidia/NVIDIA-Nemotron-3-Nano-4B-GGUF:Q4_K_M --alias narrator-brain --port 8080 --host 0.0.0.0
53
  ```
54
 
55
  Start the app:
@@ -60,15 +60,35 @@ python app.py
60
 
61
  Open the local URL printed by Gradio. The Reader route calls `/api/reader-brain`, `/api/image-descriptions`, `/api/describe-image`, `/api/speak`, and `/api/model-budget`. The Generate route calls `/api/generate-article`, which drafts an article with the reader-brain model path and attaches a Klein thumbnail receipt.
62
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
  ## Modal Klein Image Generation
64
 
65
  The image generation path uses a Modal-hosted `black-forest-labs/FLUX.2-klein-4B` worker. Deploy the worker:
66
 
67
  ```powershell
 
68
  modal deploy modal_workers/klein_image.py
69
  ```
70
 
71
- Set `KLEIN_MODAL_ENDPOINT` to the deployed worker URL. Set `KLEIN_MODAL_TOKEN` to a shared secret and configure the same token in the Modal worker environment (e.g. via `modal secret create`). When the token is configured, the worker rejects unauthenticated `POST /generate` requests with HTTP 401. When no token is set on either side, requests are allowed for local compatibility.
72
 
73
  `/api/runtime-status` reports whether the Modal Klein worker is online or fallback-ready. `/api/runtime-setup` includes the deploy command and required environment variables.
74
 
@@ -82,13 +102,17 @@ Useful environment variables:
82
 
83
  | Variable | Default | Purpose |
84
  | --- | --- | --- |
85
- | `LLAMA_CPP_BASE_URL` | `http://localhost:8080/v1` | OpenAI-compatible llama.cpp server URL |
86
  | `LLAMA_CPP_MODEL` | `narrator-brain` | Alias passed to llama.cpp |
 
87
  | `GRADIO_SERVER_NAME` | `0.0.0.0` | Bind address for local or Space runtime |
88
  | `GRADIO_SERVER_PORT` / `PORT` | `7860` | App port |
 
89
  | `PUBLIC_BASE_URL` | `http://localhost:7860` | Base URL used in generated judge API commands |
 
90
  | `KLEIN_MODAL_ENDPOINT` | *(empty)* | Base URL for the Modal Klein worker, without trailing slash. When set, `/api/generate-image` calls the live worker. |
91
  | `KLEIN_MODAL_TOKEN` | *(empty)* | Shared bearer token for Modal worker auth. When set, the app sends `Authorization: Bearer <token>` and the worker rejects unauthenticated requests. |
 
92
  | `KLEIN_MODAL_TIMEOUT_SECONDS` | `120` | Request timeout for Modal Klein image generation. |
93
  | `MINICPM_VISION_BASE_URL` | *(empty)* | OpenAI-compatible MiniCPM-V-4.6 base URL; root or `/v1` both work. |
94
  | `MINICPM_VISION_API_KEY` | *(empty)* | Bearer token for the MiniCPM vision endpoint. |
 
46
  Copy-Item .env.example .env
47
  ```
48
 
49
+ Start the llama.cpp reader-brain server locally:
50
 
51
  ```powershell
52
+ llama-server -hf nvidia/NVIDIA-Nemotron-3-Nano-4B-GGUF:Q4_K_M --alias narrator-brain --port 8080 --host 0.0.0.0 --ctx-size 0 --reasoning off --n-gpu-layers 999
53
  ```
54
 
55
  Start the app:
 
60
 
61
  Open the local URL printed by Gradio. The Reader route calls `/api/reader-brain`, `/api/image-descriptions`, `/api/describe-image`, `/api/speak`, and `/api/model-budget`. The Generate route calls `/api/generate-article`, which drafts an article with the reader-brain model path and attaches a Klein thumbnail receipt.
62
 
63
+ ## Modal Reader Brain
64
+
65
+ For a free CPU Hugging Face Space, host the llama.cpp reader-brain server on Modal and point the app at its OpenAI-compatible `/v1` endpoint:
66
+
67
+ ```powershell
68
+ modal secret create tiny-narrator-reader-brain-token LLAMA_CPP_TOKEN=your-random-token
69
+ modal deploy modal_workers/reader_brain.py
70
+ ```
71
+
72
+ Set `LLAMA_CPP_BASE_URL` to the deployed Modal URL with `/v1` appended, for example:
73
+
74
+ ```env
75
+ LLAMA_CPP_BASE_URL=https://your-workspace--tiny-narrator-reader-brain.modal.run/v1
76
+ LLAMA_CPP_MODEL=narrator-brain
77
+ LLAMA_CPP_TOKEN=your-random-token
78
+ ```
79
+
80
+ The Modal worker starts Nemotron with `--ctx-size 0`, `--reasoning off`, full GPU offload, and `--api-key` when `LLAMA_CPP_TOKEN` is configured. It scales down when idle, so the first request after a cold start can be slower.
81
+
82
  ## Modal Klein Image Generation
83
 
84
  The image generation path uses a Modal-hosted `black-forest-labs/FLUX.2-klein-4B` worker. Deploy the worker:
85
 
86
  ```powershell
87
+ modal secret create tiny-narrator-klein-token KLEIN_MODAL_TOKEN=your-random-token
88
  modal deploy modal_workers/klein_image.py
89
  ```
90
 
91
+ Set `KLEIN_MODAL_ENDPOINT` to the deployed worker URL. Set the same `KLEIN_MODAL_TOKEN` value in your local `.env`. The Modal worker uses the fixed `tiny-narrator-klein-token` secret name so Modal's local and remote dependency graphs stay identical.
92
 
93
  `/api/runtime-status` reports whether the Modal Klein worker is online or fallback-ready. `/api/runtime-setup` includes the deploy command and required environment variables.
94
 
 
102
 
103
  | Variable | Default | Purpose |
104
  | --- | --- | --- |
105
+ | `LLAMA_CPP_BASE_URL` | `http://localhost:8080/v1` | OpenAI-compatible llama.cpp server URL, local or Modal |
106
  | `LLAMA_CPP_MODEL` | `narrator-brain` | Alias passed to llama.cpp |
107
+ | `LLAMA_CPP_TOKEN` | *(empty)* | Optional bearer token for protected llama.cpp endpoints. When set, the app sends `Authorization: Bearer <token>`. |
108
  | `GRADIO_SERVER_NAME` | `0.0.0.0` | Bind address for local or Space runtime |
109
  | `GRADIO_SERVER_PORT` / `PORT` | `7860` | App port |
110
+ | `GRADIO_SHARE` | `false` | Set to `true` if Gradio cannot verify localhost and needs a share URL |
111
  | `PUBLIC_BASE_URL` | `http://localhost:7860` | Base URL used in generated judge API commands |
112
+ | `LLAMA_CPP_TIMEOUT_SECONDS` | `90` | Timeout for local llama.cpp reader-brain and article generation calls |
113
  | `KLEIN_MODAL_ENDPOINT` | *(empty)* | Base URL for the Modal Klein worker, without trailing slash. When set, `/api/generate-image` calls the live worker. |
114
  | `KLEIN_MODAL_TOKEN` | *(empty)* | Shared bearer token for Modal worker auth. When set, the app sends `Authorization: Bearer <token>` and the worker rejects unauthenticated requests. |
115
+ | `KLEIN_MODAL_HEALTH_TIMEOUT_SECONDS` | `30` | Timeout for Modal Klein `/health`; increase for cold-starting Modal endpoints. |
116
  | `KLEIN_MODAL_TIMEOUT_SECONDS` | `120` | Request timeout for Modal Klein image generation. |
117
  | `MINICPM_VISION_BASE_URL` | *(empty)* | OpenAI-compatible MiniCPM-V-4.6 base URL; root or `/v1` both work. |
118
  | `MINICPM_VISION_API_KEY` | *(empty)* | Bearer token for the MiniCPM vision endpoint. |
app.py CHANGED
@@ -3,6 +3,7 @@ from __future__ import annotations
3
  import importlib.util
4
  import json
5
  import os
 
6
  import time
7
  import urllib.error
8
  import urllib.request
@@ -34,13 +35,41 @@ def _int_env(name: str, default: int) -> int:
34
  return default
35
 
36
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  LLAMA_CPP_BASE_URL = os.getenv("LLAMA_CPP_BASE_URL", "http://localhost:8080/v1")
38
  LLAMA_CPP_MODEL = os.getenv("LLAMA_CPP_MODEL", "narrator-brain")
 
 
39
  GRADIO_SERVER_NAME = os.getenv("GRADIO_SERVER_NAME", "0.0.0.0")
40
  GRADIO_SERVER_PORT = int(os.getenv("PORT", os.getenv("GRADIO_SERVER_PORT", "7860")))
 
41
  PUBLIC_BASE_URL = os.getenv("PUBLIC_BASE_URL", f"http://localhost:{GRADIO_SERVER_PORT}").rstrip("/")
42
  KLEIN_MODAL_ENDPOINT = os.getenv("KLEIN_MODAL_ENDPOINT", "").rstrip("/")
43
  KLEIN_MODAL_TOKEN = os.getenv("KLEIN_MODAL_TOKEN", "")
 
44
  KLEIN_MODAL_TIMEOUT_SECONDS = _int_env("KLEIN_MODAL_TIMEOUT_SECONDS", 120)
45
  MINICPM_VISION_BASE_URL = os.getenv("MINICPM_VISION_BASE_URL", "").rstrip("/")
46
  MINICPM_VISION_API_KEY = os.getenv("MINICPM_VISION_API_KEY", "")
@@ -176,6 +205,7 @@ def runtime_setup_core() -> dict[str, Any]:
176
  "env": {
177
  "GRADIO_SERVER_NAME": GRADIO_SERVER_NAME,
178
  "GRADIO_SERVER_PORT": str(GRADIO_SERVER_PORT),
 
179
  "PUBLIC_BASE_URL": PUBLIC_BASE_URL,
180
  },
181
  },
@@ -187,9 +217,16 @@ def runtime_setup_core() -> dict[str, Any]:
187
  "runtime": "llama.cpp",
188
  "command": (
189
  "llama-server -hf nvidia/NVIDIA-Nemotron-3-Nano-4B-GGUF:Q4_K_M "
190
- "--alias narrator-brain --port 8080 --host 0.0.0.0"
 
191
  ),
192
- "env": {"LLAMA_CPP_BASE_URL": LLAMA_CPP_BASE_URL, "LLAMA_CPP_MODEL": LLAMA_CPP_MODEL},
 
 
 
 
 
 
193
  "fallback": "rule-based local narration",
194
  },
195
  {
@@ -224,6 +261,7 @@ def runtime_setup_core() -> dict[str, Any]:
224
  "env": {
225
  "KLEIN_MODAL_ENDPOINT": KLEIN_MODAL_ENDPOINT or "(set to Modal worker URL)",
226
  "KLEIN_MODAL_TOKEN": "(configured)" if KLEIN_MODAL_TOKEN else "(not set)",
 
227
  "KLEIN_MODAL_TIMEOUT_SECONDS": str(KLEIN_MODAL_TIMEOUT_SECONDS),
228
  },
229
  "fallback": "bundled generated article assets",
@@ -589,68 +627,113 @@ def _validate_modal_klein_health(payload: dict[str, Any]) -> None:
589
  raise ValueError("health check returned unexpected runtime")
590
 
591
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
592
  def _runtime_status_core() -> dict[str, Any]:
593
  start = time.perf_counter()
594
  llama_start = time.perf_counter()
595
  llama_status: dict[str, Any]
596
- try:
597
- request = urllib.request.Request(f"{LLAMA_CPP_BASE_URL}/models", method="GET")
598
- with urllib.request.urlopen(request, timeout=1.5) as response:
599
- payload = json.loads(response.read().decode("utf-8"))
600
- model_ids = [item.get("id", "") for item in payload.get("data", []) if isinstance(item, dict)]
601
- llama_status = {
602
- "available": True,
603
- "status": "online",
604
- "base_url": LLAMA_CPP_BASE_URL,
605
- "model": LLAMA_CPP_MODEL,
606
- "models": model_ids,
607
- "elapsed_ms": _elapsed_ms(llama_start),
608
- }
609
- except (OSError, urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc:
610
  llama_status = {
611
  "available": False,
612
  "status": "fallback-ready",
613
  "base_url": LLAMA_CPP_BASE_URL,
614
  "model": LLAMA_CPP_MODEL,
615
  "fallback": "rule-based local narration",
616
- "warning": exc.__class__.__name__,
617
  "elapsed_ms": _elapsed_ms(llama_start),
618
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
619
 
620
  kokoro_available = importlib.util.find_spec("kokoro") is not None
621
  soundfile_available = importlib.util.find_spec("soundfile") is not None
622
 
623
  klein_status: dict[str, Any]
624
  if KLEIN_MODAL_ENDPOINT:
 
 
625
  try:
626
  health_headers: dict[str, str] = {}
627
  if KLEIN_MODAL_TOKEN:
628
  health_headers["Authorization"] = f"Bearer {KLEIN_MODAL_TOKEN}"
 
 
 
 
 
629
  request = urllib.request.Request(
630
- f"{KLEIN_MODAL_ENDPOINT}/health", method="GET",
631
  headers=health_headers,
632
  )
633
- with urllib.request.urlopen(request, timeout=5) as response:
634
  payload = json.loads(response.read().decode("utf-8"))
 
 
 
 
635
  _validate_modal_klein_health(payload)
636
  klein_status = {
637
  "available": True,
638
  "status": "online",
639
  "model": MODEL_MANIFEST["image_generation"]["id"],
640
- "endpoint": KLEIN_MODAL_ENDPOINT,
641
  "elapsed_ms": _elapsed_ms(start),
642
  }
643
  except (OSError, urllib.error.URLError, TimeoutError, ValueError, json.JSONDecodeError) as exc:
 
 
644
  klein_status = {
645
  "available": False,
646
  "status": "fallback-ready",
647
  "model": MODEL_MANIFEST["image_generation"]["id"],
648
- "endpoint": KLEIN_MODAL_ENDPOINT,
 
649
  "fallback": "bundled generated article assets",
650
- "warning": exc.__class__.__name__,
651
  "elapsed_ms": _elapsed_ms(start),
652
  }
653
  else:
 
654
  klein_status = {
655
  "available": False,
656
  "status": "fallback-ready",
@@ -702,21 +785,38 @@ def _fallback_article(topic: str) -> dict[str, Any]:
702
  "heading": f"Why {clean_topic} matters",
703
  "body": (
704
  f"{clean_topic.capitalize()} is easier to understand when the page explains the main idea first, "
705
- "then moves through examples in a predictable order."
 
706
  ),
707
  },
708
  {
709
  "heading": "How a tiny narrator helps",
710
  "body": (
711
  "A small reader model can turn each heading, paragraph, and image caption into concise narration "
712
- "without turning the article into a chatbot."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
713
  ),
714
  },
715
  {
716
  "heading": "What to try next",
717
  "body": (
718
  "Use the reader controls to move by heading or image, then compare the transcript with the article "
719
- "to check whether the generated structure is easy to follow."
 
720
  ),
721
  },
722
  ],
@@ -725,16 +825,84 @@ def _fallback_article(topic: str) -> dict[str, Any]:
725
 
726
  def _article_generation_prompt(topic: str) -> str:
727
  return (
728
- "Write a short accessible article for Tiny Narrator.\n"
729
  f"Topic: {topic}\n\n"
730
  "Return strict JSON with keys title, dek, and sections. "
731
- "sections must contain exactly three objects with heading and body. "
732
- "Keep every body under 45 words. Do not include markdown."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
733
  )
734
 
735
 
736
  def reader_brain_core(node_type: str, text: str, position: str | None, mode: str) -> dict[str, Any]:
737
  start = time.perf_counter()
 
 
 
 
 
 
 
 
 
 
738
  if mode == "summarize":
739
  prompt = (
740
  "Summarize this article section for screen-reader navigation.\n"
@@ -742,7 +910,8 @@ def reader_brain_core(node_type: str, text: str, position: str | None, mode: str
742
  f"Position: {position or 'unknown'}\n"
743
  f"Content: {text}\n\n"
744
  "Rules: start with 'Summary.', use one or two short sentences, explain what the "
745
- "reader can learn in this section, and never mention implementation details."
 
746
  )
747
  else:
748
  prompt = (
@@ -752,7 +921,8 @@ def reader_brain_core(node_type: str, text: str, position: str | None, mode: str
752
  f"Position: {position or 'unknown'}\n"
753
  f"Content: {text}\n\n"
754
  "Rules: announce the node type only when it helps orientation, keep prose short, "
755
- "and never mention implementation details."
 
756
  )
757
 
758
  try:
@@ -764,24 +934,42 @@ def reader_brain_core(node_type: str, text: str, position: str | None, mode: str
764
  "role": "system",
765
  "content": (
766
  "You are Tiny Narrator's accessibility layer. "
767
- "Produce clear screen-reader narration for article content."
 
 
 
 
768
  ),
769
  },
770
  {"role": "user", "content": prompt},
771
  ],
772
  "temperature": 0.2,
773
- "max_tokens": 180,
 
 
774
  }
775
  ).encode("utf-8")
776
  request = urllib.request.Request(
777
  f"{LLAMA_CPP_BASE_URL}/chat/completions",
778
  data=body,
779
- headers={"Content-Type": "application/json"},
780
  method="POST",
781
  )
782
- with urllib.request.urlopen(request, timeout=45) as response:
783
  payload = json.loads(response.read().decode("utf-8"))
784
- narration = payload["choices"][0]["message"]["content"].strip()
 
 
 
 
 
 
 
 
 
 
 
 
785
  return {
786
  "ok": True,
787
  "runtime": "llama.cpp",
@@ -789,21 +977,13 @@ def reader_brain_core(node_type: str, text: str, position: str | None, mode: str
789
  "narration": narration,
790
  "elapsed_ms": _elapsed_ms(start),
791
  }
792
- except (OSError, urllib.error.URLError, KeyError, json.JSONDecodeError) as exc:
793
- prefix = {
794
- "heading": "Heading. ",
795
- "image": "Image. ",
796
- "button": "Control. ",
797
- "quote": "Quote. ",
798
- }.get(node_type, "")
799
- if mode == "summarize":
800
- prefix = "Summary. "
801
  return {
802
  "ok": True,
803
  "runtime": "fallback",
804
  "model": "rule-based local fallback",
805
  "warning": f"llama.cpp unavailable: {exc.__class__.__name__}",
806
- "narration": f"{prefix}{_compact_text(text)}".strip(),
807
  "elapsed_ms": _elapsed_ms(start),
808
  }
809
 
@@ -910,6 +1090,32 @@ def _call_minicpm_vision(image_url: str, caption: str | None, prompt: str | None
910
  }
911
 
912
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
913
  def _vision_runtime_status() -> dict[str, Any]:
914
  if not MINICPM_VISION_BASE_URL or not MINICPM_VISION_API_KEY:
915
  return {
@@ -920,6 +1126,8 @@ def _vision_runtime_status() -> dict[str, Any]:
920
  "fallback": "cached deterministic alt text",
921
  }
922
  start = time.perf_counter()
 
 
923
  try:
924
  request = urllib.request.Request(
925
  _openai_compatible_url(MINICPM_VISION_BASE_URL, "models"),
@@ -934,7 +1142,8 @@ def _vision_runtime_status() -> dict[str, Any]:
934
  if isinstance(item, dict)
935
  ]
936
  if model_ids and MINICPM_VISION_MODEL not in model_ids:
937
- raise ValueError("MiniCPM vision model was not listed by /models")
 
938
  return {
939
  "available": True,
940
  "status": "online",
@@ -942,9 +1151,25 @@ def _vision_runtime_status() -> dict[str, Any]:
942
  "configured": True,
943
  "base_url": MINICPM_VISION_BASE_URL,
944
  "models": model_ids,
 
945
  "elapsed_ms": _elapsed_ms(start),
946
  }
947
  except (OSError, urllib.error.URLError, TimeoutError, ValueError, json.JSONDecodeError) as exc:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
948
  return {
949
  "available": False,
950
  "status": "fallback-ready",
@@ -1030,12 +1255,26 @@ def _prune_speech_outputs(keep_path: Path, max_files: int = 24) -> None:
1030
 
1031
  def speak_core(text: str, voice: str, speed: float) -> dict[str, Any]:
1032
  start = time.perf_counter()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1033
  try:
1034
  from kokoro import KPipeline
1035
  import soundfile as sf
1036
 
1037
  pipeline = KPipeline(lang_code="a")
1038
- generator = pipeline(text, voice=voice, speed=speed)
1039
  _, _, audio = next(generator)
1040
  output_path = OUTPUT_DIR / f"speech-{uuid4().hex}.wav"
1041
  sf.write(output_path, audio, 24000)
@@ -1055,7 +1294,7 @@ def speak_core(text: str, voice: str, speed: float) -> dict[str, Any]:
1055
  "model": "hexgrad/Kokoro-82M",
1056
  "warning": warning,
1057
  "audio_url": f"/outputs/{output_path.name}",
1058
- "transcript": text,
1059
  "elapsed_ms": _elapsed_ms(start),
1060
  }
1061
 
@@ -1067,25 +1306,41 @@ def _call_modal_klein(prompt: str, seed: int | None) -> dict[str, Any]:
1067
  headers: dict[str, str] = {"Content-Type": "application/json"}
1068
  if KLEIN_MODAL_TOKEN:
1069
  headers["Authorization"] = f"Bearer {KLEIN_MODAL_TOKEN}"
 
 
 
 
 
 
1070
  request = urllib.request.Request(
1071
- f"{KLEIN_MODAL_ENDPOINT}/generate",
1072
  data=body,
1073
  headers=headers,
1074
  method="POST",
1075
  )
1076
- with urllib.request.urlopen(request, timeout=KLEIN_MODAL_TIMEOUT_SECONDS) as response:
1077
- payload = json.loads(response.read().decode("utf-8"))
 
 
 
 
 
1078
  if payload.get("ok") is not True:
 
1079
  raise ValueError(str(payload.get("error") or payload.get("detail") or "Modal Klein returned ok=false"))
1080
  if payload.get("model") != MODEL_MANIFEST["image_generation"]["id"]:
 
1081
  raise ValueError("Modal Klein returned unexpected model")
1082
  if payload.get("runtime") != "modal-klein":
 
1083
  raise ValueError("Modal Klein returned unexpected runtime")
1084
  image_url = payload.get("image_url", "")
1085
  if not isinstance(image_url, str) or not image_url:
 
1086
  raise ValueError("Modal Klein response did not include image_url")
1087
  if image_url and image_url.startswith("/media/"):
1088
- image_url = f"{KLEIN_MODAL_ENDPOINT}{image_url}"
 
1089
  return {
1090
  "ok": True,
1091
  "runtime": payload.get("runtime", "modal-klein"),
@@ -1117,10 +1372,23 @@ def generate_image_core(prompt: str, seed: int | None) -> dict[str, Any]:
1117
  try:
1118
  return _call_modal_klein(prompt, seed)
1119
  except (OSError, urllib.error.URLError, TimeoutError, ValueError, KeyError, json.JSONDecodeError) as exc:
1120
- return _fallback_image(prompt, seed, warning=f"Modal Klein unavailable: {exc.__class__.__name__}")
 
1121
  return _fallback_image(prompt, seed)
1122
 
1123
 
 
 
 
 
 
 
 
 
 
 
 
 
1124
  def generate_article_core(topic: str) -> dict[str, Any]:
1125
  start = time.perf_counter()
1126
  clean_topic = _compact_text(topic, 100).strip()
@@ -1130,54 +1398,57 @@ def generate_article_core(topic: str) -> dict[str, Any]:
1130
  article = _fallback_article(clean_topic)
1131
  runtime = "fallback"
1132
  warning = None
1133
- try:
1134
- body = json.dumps(
1135
- {
1136
- "model": LLAMA_CPP_MODEL,
1137
- "messages": [
1138
- {
1139
- "role": "system",
1140
- "content": (
1141
- "You are Tiny Narrator's article generator. "
1142
- "Create concise, semantic, accessible article drafts."
1143
- ),
1144
- },
1145
- {"role": "user", "content": _article_generation_prompt(clean_topic)},
1146
- ],
1147
- "temperature": 0.35,
1148
- "max_tokens": 420,
1149
- }
1150
- ).encode("utf-8")
1151
- request = urllib.request.Request(
1152
- f"{LLAMA_CPP_BASE_URL}/chat/completions",
1153
- data=body,
1154
- headers={"Content-Type": "application/json"},
1155
- method="POST",
1156
- )
1157
- with urllib.request.urlopen(request, timeout=45) as response:
1158
- payload = json.loads(response.read().decode("utf-8"))
1159
- generated = json.loads(payload["choices"][0]["message"]["content"].strip())
1160
- sections = generated.get("sections", [])
1161
- if (
1162
- isinstance(generated.get("title"), str)
1163
- and isinstance(generated.get("dek"), str)
1164
- and isinstance(sections, list)
1165
- and len(sections) == 3
1166
- and all(isinstance(item.get("heading"), str) and isinstance(item.get("body"), str) for item in sections)
1167
- ):
1168
- article = {
1169
- "title": _compact_text(generated["title"], 90),
1170
- "dek": _compact_text(generated["dek"], 180),
1171
- "sections": [
1172
- {"heading": _compact_text(item["heading"], 80), "body": _compact_text(item["body"], 260)}
1173
- for item in sections
1174
- ],
1175
- }
1176
- runtime = "llama.cpp"
1177
- except (OSError, urllib.error.URLError, TimeoutError, KeyError, json.JSONDecodeError) as exc:
1178
- warning = f"llama.cpp unavailable: {exc.__class__.__name__}"
 
 
 
1179
 
1180
- image_prompt = f"Editorial thumbnail for an accessible article about {clean_topic}."
1181
  thumbnail = generate_image_core(image_prompt, seed=_topic_seed(clean_topic))
1182
  return {
1183
  "ok": True,
@@ -1360,4 +1631,4 @@ async def handle_exception(_: Request, exc: Exception) -> JSONResponse:
1360
 
1361
 
1362
  if __name__ == "__main__":
1363
- app.launch(server_name=GRADIO_SERVER_NAME, server_port=GRADIO_SERVER_PORT)
 
3
  import importlib.util
4
  import json
5
  import os
6
+ import sys
7
  import time
8
  import urllib.error
9
  import urllib.request
 
35
  return default
36
 
37
 
38
+ def _bool_env(name: str, default: bool = False) -> bool:
39
+ value = os.getenv(name)
40
+ if value is None:
41
+ return default
42
+ return value.strip().lower() in {"1", "true", "yes", "on"}
43
+
44
+
45
+ def _runtime_log(message: str) -> None:
46
+ print(f"[tiny-narrator] {message}", file=sys.stderr, flush=True)
47
+
48
+
49
+ def _http_exception_detail(exc: BaseException) -> str:
50
+ if isinstance(exc, urllib.error.HTTPError):
51
+ try:
52
+ body = exc.read().decode("utf-8", errors="replace")
53
+ except Exception:
54
+ body = ""
55
+ body = _compact_text(body, 400) if body else ""
56
+ return f"HTTPError status={exc.code} reason={exc.reason} body={body}"
57
+ if isinstance(exc, urllib.error.URLError):
58
+ return f"URLError reason={exc.reason}"
59
+ return f"{exc.__class__.__name__}: {exc}"
60
+
61
+
62
  LLAMA_CPP_BASE_URL = os.getenv("LLAMA_CPP_BASE_URL", "http://localhost:8080/v1")
63
  LLAMA_CPP_MODEL = os.getenv("LLAMA_CPP_MODEL", "narrator-brain")
64
+ LLAMA_CPP_TOKEN = os.getenv("LLAMA_CPP_TOKEN", "")
65
+ LLAMA_CPP_TIMEOUT_SECONDS = _int_env("LLAMA_CPP_TIMEOUT_SECONDS", 90)
66
  GRADIO_SERVER_NAME = os.getenv("GRADIO_SERVER_NAME", "0.0.0.0")
67
  GRADIO_SERVER_PORT = int(os.getenv("PORT", os.getenv("GRADIO_SERVER_PORT", "7860")))
68
+ GRADIO_SHARE = _bool_env("GRADIO_SHARE")
69
  PUBLIC_BASE_URL = os.getenv("PUBLIC_BASE_URL", f"http://localhost:{GRADIO_SERVER_PORT}").rstrip("/")
70
  KLEIN_MODAL_ENDPOINT = os.getenv("KLEIN_MODAL_ENDPOINT", "").rstrip("/")
71
  KLEIN_MODAL_TOKEN = os.getenv("KLEIN_MODAL_TOKEN", "")
72
+ KLEIN_MODAL_HEALTH_TIMEOUT_SECONDS = _int_env("KLEIN_MODAL_HEALTH_TIMEOUT_SECONDS", 30)
73
  KLEIN_MODAL_TIMEOUT_SECONDS = _int_env("KLEIN_MODAL_TIMEOUT_SECONDS", 120)
74
  MINICPM_VISION_BASE_URL = os.getenv("MINICPM_VISION_BASE_URL", "").rstrip("/")
75
  MINICPM_VISION_API_KEY = os.getenv("MINICPM_VISION_API_KEY", "")
 
205
  "env": {
206
  "GRADIO_SERVER_NAME": GRADIO_SERVER_NAME,
207
  "GRADIO_SERVER_PORT": str(GRADIO_SERVER_PORT),
208
+ "GRADIO_SHARE": str(GRADIO_SHARE).lower(),
209
  "PUBLIC_BASE_URL": PUBLIC_BASE_URL,
210
  },
211
  },
 
217
  "runtime": "llama.cpp",
218
  "command": (
219
  "llama-server -hf nvidia/NVIDIA-Nemotron-3-Nano-4B-GGUF:Q4_K_M "
220
+ "--alias narrator-brain --port 8080 --host 0.0.0.0 "
221
+ "--ctx-size 0 --reasoning off --n-gpu-layers 999"
222
  ),
223
+ "modal_command": "modal deploy modal_workers/reader_brain.py",
224
+ "env": {
225
+ "LLAMA_CPP_BASE_URL": LLAMA_CPP_BASE_URL,
226
+ "LLAMA_CPP_MODEL": LLAMA_CPP_MODEL,
227
+ "LLAMA_CPP_TOKEN": "(configured)" if LLAMA_CPP_TOKEN else "(not set)",
228
+ "LLAMA_CPP_TIMEOUT_SECONDS": str(LLAMA_CPP_TIMEOUT_SECONDS),
229
+ },
230
  "fallback": "rule-based local narration",
231
  },
232
  {
 
261
  "env": {
262
  "KLEIN_MODAL_ENDPOINT": KLEIN_MODAL_ENDPOINT or "(set to Modal worker URL)",
263
  "KLEIN_MODAL_TOKEN": "(configured)" if KLEIN_MODAL_TOKEN else "(not set)",
264
+ "KLEIN_MODAL_HEALTH_TIMEOUT_SECONDS": str(KLEIN_MODAL_HEALTH_TIMEOUT_SECONDS),
265
  "KLEIN_MODAL_TIMEOUT_SECONDS": str(KLEIN_MODAL_TIMEOUT_SECONDS),
266
  },
267
  "fallback": "bundled generated article assets",
 
627
  raise ValueError("health check returned unexpected runtime")
628
 
629
 
630
+ def _modal_klein_base_url() -> str:
631
+ normalized = KLEIN_MODAL_ENDPOINT.rstrip("/")
632
+ for suffix in ("/generate", "/health"):
633
+ if normalized.endswith(suffix):
634
+ return normalized[: -len(suffix)]
635
+ return normalized
636
+
637
+
638
+ def _llama_cpp_headers(extra: dict[str, str] | None = None) -> dict[str, str]:
639
+ headers = dict(extra or {})
640
+ if LLAMA_CPP_TOKEN:
641
+ headers["Authorization"] = f"Bearer {LLAMA_CPP_TOKEN}"
642
+ return headers
643
+
644
+
645
  def _runtime_status_core() -> dict[str, Any]:
646
  start = time.perf_counter()
647
  llama_start = time.perf_counter()
648
  llama_status: dict[str, Any]
649
+ if not LLAMA_CPP_BASE_URL:
 
 
 
 
 
 
 
 
 
 
 
 
 
650
  llama_status = {
651
  "available": False,
652
  "status": "fallback-ready",
653
  "base_url": LLAMA_CPP_BASE_URL,
654
  "model": LLAMA_CPP_MODEL,
655
  "fallback": "rule-based local narration",
656
+ "warning": "LLAMA_CPP_BASE_URL is not configured",
657
  "elapsed_ms": _elapsed_ms(llama_start),
658
  }
659
+ else:
660
+ try:
661
+ request = urllib.request.Request(
662
+ f"{LLAMA_CPP_BASE_URL}/models",
663
+ headers=_llama_cpp_headers(),
664
+ method="GET",
665
+ )
666
+ with urllib.request.urlopen(request, timeout=1.5) as response:
667
+ payload = json.loads(response.read().decode("utf-8"))
668
+ model_ids = [item.get("id", "") for item in payload.get("data", []) if isinstance(item, dict)]
669
+ llama_status = {
670
+ "available": True,
671
+ "status": "online",
672
+ "base_url": LLAMA_CPP_BASE_URL,
673
+ "model": LLAMA_CPP_MODEL,
674
+ "models": model_ids,
675
+ "elapsed_ms": _elapsed_ms(llama_start),
676
+ }
677
+ except (OSError, urllib.error.URLError, TimeoutError, ValueError, json.JSONDecodeError) as exc:
678
+ llama_status = {
679
+ "available": False,
680
+ "status": "fallback-ready",
681
+ "base_url": LLAMA_CPP_BASE_URL,
682
+ "model": LLAMA_CPP_MODEL,
683
+ "fallback": "rule-based local narration",
684
+ "warning": exc.__class__.__name__,
685
+ "elapsed_ms": _elapsed_ms(llama_start),
686
+ }
687
 
688
  kokoro_available = importlib.util.find_spec("kokoro") is not None
689
  soundfile_available = importlib.util.find_spec("soundfile") is not None
690
 
691
  klein_status: dict[str, Any]
692
  if KLEIN_MODAL_ENDPOINT:
693
+ endpoint = _modal_klein_base_url()
694
+ health_url = f"{endpoint}/health"
695
  try:
696
  health_headers: dict[str, str] = {}
697
  if KLEIN_MODAL_TOKEN:
698
  health_headers["Authorization"] = f"Bearer {KLEIN_MODAL_TOKEN}"
699
+ _runtime_log(
700
+ "Modal Klein health check "
701
+ f"url={health_url} token={'configured' if KLEIN_MODAL_TOKEN else 'not-set'} "
702
+ f"timeout={KLEIN_MODAL_HEALTH_TIMEOUT_SECONDS}s"
703
+ )
704
  request = urllib.request.Request(
705
+ health_url, method="GET",
706
  headers=health_headers,
707
  )
708
+ with urllib.request.urlopen(request, timeout=KLEIN_MODAL_HEALTH_TIMEOUT_SECONDS) as response:
709
  payload = json.loads(response.read().decode("utf-8"))
710
+ _runtime_log(
711
+ "Modal Klein health response "
712
+ f"model={payload.get('model')} runtime={payload.get('runtime')} ok={payload.get('ok')}"
713
+ )
714
  _validate_modal_klein_health(payload)
715
  klein_status = {
716
  "available": True,
717
  "status": "online",
718
  "model": MODEL_MANIFEST["image_generation"]["id"],
719
+ "endpoint": endpoint,
720
  "elapsed_ms": _elapsed_ms(start),
721
  }
722
  except (OSError, urllib.error.URLError, TimeoutError, ValueError, json.JSONDecodeError) as exc:
723
+ detail = _http_exception_detail(exc)
724
+ _runtime_log(f"Modal Klein health failed url={health_url} error={detail}")
725
  klein_status = {
726
  "available": False,
727
  "status": "fallback-ready",
728
  "model": MODEL_MANIFEST["image_generation"]["id"],
729
+ "endpoint": endpoint,
730
+ "health_url": health_url,
731
  "fallback": "bundled generated article assets",
732
+ "warning": detail,
733
  "elapsed_ms": _elapsed_ms(start),
734
  }
735
  else:
736
+ _runtime_log("Modal Klein endpoint is not configured; using image fallback")
737
  klein_status = {
738
  "available": False,
739
  "status": "fallback-ready",
 
785
  "heading": f"Why {clean_topic} matters",
786
  "body": (
787
  f"{clean_topic.capitalize()} is easier to understand when the page explains the main idea first, "
788
+ "then moves through examples in a predictable order. A longer article gives the reader enough "
789
+ "context to pause, repeat, and compare sections without feeling rushed."
790
  ),
791
  },
792
  {
793
  "heading": "How a tiny narrator helps",
794
  "body": (
795
  "A small reader model can turn each heading, paragraph, and image caption into concise narration "
796
+ "without turning the article into a chatbot. It keeps the experience close to the article, so "
797
+ "the user stays oriented while moving through the page."
798
+ ),
799
+ },
800
+ {
801
+ "heading": "What image descriptions add",
802
+ "body": (
803
+ "Images often carry examples, diagrams, or emotional context. A visual descriptor gives those "
804
+ "details a spoken form, helping readers understand why an illustration belongs with the surrounding text."
805
+ ),
806
+ },
807
+ {
808
+ "heading": "Designing for repeated listening",
809
+ "body": (
810
+ "Reader controls should make it easy to move backward, skip to the next heading, replay a sentence, "
811
+ "or stop speech completely. Those small controls matter when someone is studying, teaching, or checking details."
812
  ),
813
  },
814
  {
815
  "heading": "What to try next",
816
  "body": (
817
  "Use the reader controls to move by heading or image, then compare the transcript with the article "
818
+ "to check whether the generated structure is easy to follow. If a section sounds confusing, rewrite it "
819
+ "with simpler language and a clearer heading."
820
  ),
821
  },
822
  ],
 
825
 
826
  def _article_generation_prompt(topic: str) -> str:
827
  return (
828
+ "Write a complete accessible article for Tiny Narrator.\n"
829
  f"Topic: {topic}\n\n"
830
  "Return strict JSON with keys title, dek, and sections. "
831
+ "sections must contain exactly five objects with heading and body. "
832
+ "Each body should be 45 to 70 words, with concrete examples and practical detail. "
833
+ "Use clear plain language suitable for screen-reader narration. Do not include markdown."
834
+ )
835
+
836
+
837
+ def _fallback_narration(node_type: str, text: str, mode: str) -> str:
838
+ prefix = {
839
+ "heading": "Heading. ",
840
+ "image": "Image. ",
841
+ "button": "Control. ",
842
+ "quote": "Quote. ",
843
+ }.get(node_type, "")
844
+ if mode == "summarize":
845
+ prefix = "Summary. "
846
+ fallback_text = _compact_text(text) or "No readable text is available for this item."
847
+ return f"{prefix}{fallback_text}".strip()
848
+
849
+
850
+ def _chat_message_text(payload: dict[str, Any]) -> str:
851
+ choices = payload.get("choices", [])
852
+ if not choices or not isinstance(choices[0], dict):
853
+ return ""
854
+ choice = choices[0]
855
+ message = choice.get("message")
856
+ if isinstance(message, dict):
857
+ for key in ("content", "reasoning_content", "reasoning", "text"):
858
+ value = message.get(key)
859
+ if isinstance(value, str) and value.strip():
860
+ return value.strip()
861
+ if isinstance(value, list):
862
+ parts = []
863
+ for item in value:
864
+ if isinstance(item, dict):
865
+ part = item.get("text") or item.get("content")
866
+ if isinstance(part, str):
867
+ parts.append(part)
868
+ elif isinstance(item, str):
869
+ parts.append(item)
870
+ combined = " ".join(parts).strip()
871
+ if combined:
872
+ return combined
873
+ text = choice.get("text")
874
+ if isinstance(text, str) and text.strip():
875
+ return text.strip()
876
+ return ""
877
+
878
+
879
+ def _chat_response_debug(payload: dict[str, Any]) -> str:
880
+ choices = payload.get("choices", [])
881
+ if not choices or not isinstance(choices[0], dict):
882
+ return f"top_keys={list(payload)[:8]} choices=missing"
883
+ choice = choices[0]
884
+ message = choice.get("message")
885
+ message_keys = list(message)[:8] if isinstance(message, dict) else []
886
+ return (
887
+ f"top_keys={list(payload)[:8]} "
888
+ f"choice_keys={list(choice)[:8]} "
889
+ f"message_keys={message_keys} "
890
+ f"finish_reason={choice.get('finish_reason')}"
891
  )
892
 
893
 
894
  def reader_brain_core(node_type: str, text: str, position: str | None, mode: str) -> dict[str, Any]:
895
  start = time.perf_counter()
896
+ if not LLAMA_CPP_BASE_URL:
897
+ return {
898
+ "ok": True,
899
+ "runtime": "fallback",
900
+ "model": "rule-based local fallback",
901
+ "warning": "llama.cpp unavailable: LLAMA_CPP_BASE_URL is not configured",
902
+ "narration": _fallback_narration(node_type, text, mode),
903
+ "elapsed_ms": _elapsed_ms(start),
904
+ }
905
+
906
  if mode == "summarize":
907
  prompt = (
908
  "Summarize this article section for screen-reader navigation.\n"
 
910
  f"Position: {position or 'unknown'}\n"
911
  f"Content: {text}\n\n"
912
  "Rules: start with 'Summary.', use one or two short sentences, explain what the "
913
+ "reader can learn in this section, and never mention implementation details. "
914
+ "Return only the final spoken narration. Do not explain your reasoning."
915
  )
916
  else:
917
  prompt = (
 
921
  f"Position: {position or 'unknown'}\n"
922
  f"Content: {text}\n\n"
923
  "Rules: announce the node type only when it helps orientation, keep prose short, "
924
+ "and never mention implementation details. Return only the final spoken narration. "
925
+ "Do not explain your reasoning."
926
  )
927
 
928
  try:
 
934
  "role": "system",
935
  "content": (
936
  "You are Tiny Narrator's accessibility layer. "
937
+ "Reasoning mode: off. Do not generate a reasoning trace. "
938
+ "Do not think step by step. "
939
+ "Produce clear screen-reader narration for article content. "
940
+ "Return only the exact text to speak. Do not include analysis, alternatives, "
941
+ "markdown, labels, or explanations."
942
  ),
943
  },
944
  {"role": "user", "content": prompt},
945
  ],
946
  "temperature": 0.2,
947
+ "top_p": 0.9,
948
+ "stream": False,
949
+ "max_tokens": 80,
950
  }
951
  ).encode("utf-8")
952
  request = urllib.request.Request(
953
  f"{LLAMA_CPP_BASE_URL}/chat/completions",
954
  data=body,
955
+ headers=_llama_cpp_headers({"Content-Type": "application/json"}),
956
  method="POST",
957
  )
958
+ with urllib.request.urlopen(request, timeout=LLAMA_CPP_TIMEOUT_SECONDS) as response:
959
  payload = json.loads(response.read().decode("utf-8"))
960
+ narration = _compact_text(_chat_message_text(payload), 220)
961
+ if not narration:
962
+ fallback = _fallback_narration(node_type, text, mode)
963
+ detail = _chat_response_debug(payload)
964
+ _runtime_log(f"llama.cpp returned empty narration; response_shape={detail}")
965
+ return {
966
+ "ok": True,
967
+ "runtime": "fallback",
968
+ "model": "rule-based local fallback",
969
+ "warning": f"llama.cpp returned empty narration ({detail})",
970
+ "narration": fallback,
971
+ "elapsed_ms": _elapsed_ms(start),
972
+ }
973
  return {
974
  "ok": True,
975
  "runtime": "llama.cpp",
 
977
  "narration": narration,
978
  "elapsed_ms": _elapsed_ms(start),
979
  }
980
+ except (OSError, urllib.error.URLError, TimeoutError, ValueError, KeyError, json.JSONDecodeError) as exc:
 
 
 
 
 
 
 
 
981
  return {
982
  "ok": True,
983
  "runtime": "fallback",
984
  "model": "rule-based local fallback",
985
  "warning": f"llama.cpp unavailable: {exc.__class__.__name__}",
986
+ "narration": _fallback_narration(node_type, text, mode),
987
  "elapsed_ms": _elapsed_ms(start),
988
  }
989
 
 
1090
  }
1091
 
1092
 
1093
+ def _check_minicpm_chat_ready() -> dict[str, Any]:
1094
+ body = json.dumps(
1095
+ {
1096
+ "model": MINICPM_VISION_MODEL,
1097
+ "messages": [{"role": "user", "content": "Reply with ready."}],
1098
+ "temperature": 0,
1099
+ "max_tokens": 8,
1100
+ }
1101
+ ).encode("utf-8")
1102
+ request = urllib.request.Request(
1103
+ _openai_compatible_url(MINICPM_VISION_BASE_URL, "chat/completions"),
1104
+ data=body,
1105
+ headers={
1106
+ "Authorization": f"Bearer {MINICPM_VISION_API_KEY}",
1107
+ "Content-Type": "application/json",
1108
+ },
1109
+ method="POST",
1110
+ )
1111
+ with urllib.request.urlopen(request, timeout=min(MINICPM_VISION_TIMEOUT_SECONDS, 10)) as response:
1112
+ payload = json.loads(response.read().decode("utf-8"))
1113
+ content = payload["choices"][0]["message"]["content"]
1114
+ if not isinstance(content, str) or not content.strip():
1115
+ raise ValueError("MiniCPM readiness response did not include text content")
1116
+ return payload
1117
+
1118
+
1119
  def _vision_runtime_status() -> dict[str, Any]:
1120
  if not MINICPM_VISION_BASE_URL or not MINICPM_VISION_API_KEY:
1121
  return {
 
1126
  "fallback": "cached deterministic alt text",
1127
  }
1128
  start = time.perf_counter()
1129
+ model_ids: list[str] = []
1130
+ models_warning: str | None = None
1131
  try:
1132
  request = urllib.request.Request(
1133
  _openai_compatible_url(MINICPM_VISION_BASE_URL, "models"),
 
1142
  if isinstance(item, dict)
1143
  ]
1144
  if model_ids and MINICPM_VISION_MODEL not in model_ids:
1145
+ models_warning = "MiniCPM vision model was not listed by /models"
1146
+ _check_minicpm_chat_ready()
1147
  return {
1148
  "available": True,
1149
  "status": "online",
 
1151
  "configured": True,
1152
  "base_url": MINICPM_VISION_BASE_URL,
1153
  "models": model_ids,
1154
+ "warning": models_warning,
1155
  "elapsed_ms": _elapsed_ms(start),
1156
  }
1157
  except (OSError, urllib.error.URLError, TimeoutError, ValueError, json.JSONDecodeError) as exc:
1158
+ models_warning = exc.__class__.__name__
1159
+
1160
+ try:
1161
+ _check_minicpm_chat_ready()
1162
+ return {
1163
+ "available": True,
1164
+ "status": "online",
1165
+ "model": MINICPM_VISION_MODEL,
1166
+ "configured": True,
1167
+ "base_url": MINICPM_VISION_BASE_URL,
1168
+ "models": model_ids,
1169
+ "warning": f"/models unavailable, chat completions ready: {models_warning}",
1170
+ "elapsed_ms": _elapsed_ms(start),
1171
+ }
1172
+ except (OSError, urllib.error.URLError, TimeoutError, KeyError, IndexError, TypeError, ValueError, json.JSONDecodeError) as exc:
1173
  return {
1174
  "available": False,
1175
  "status": "fallback-ready",
 
1255
 
1256
  def speak_core(text: str, voice: str, speed: float) -> dict[str, Any]:
1257
  start = time.perf_counter()
1258
+ clean_text = _compact_text(text, 1200)
1259
+ if not clean_text:
1260
+ output_path = OUTPUT_DIR / f"speech-fallback-{uuid4().hex}.wav"
1261
+ _silent_wav(output_path)
1262
+ _prune_speech_outputs(output_path)
1263
+ return {
1264
+ "ok": True,
1265
+ "runtime": "fallback",
1266
+ "model": "hexgrad/Kokoro-82M",
1267
+ "warning": "Kokoro skipped because transcript was empty",
1268
+ "audio_url": f"/outputs/{output_path.name}",
1269
+ "transcript": "",
1270
+ "elapsed_ms": _elapsed_ms(start),
1271
+ }
1272
  try:
1273
  from kokoro import KPipeline
1274
  import soundfile as sf
1275
 
1276
  pipeline = KPipeline(lang_code="a")
1277
+ generator = pipeline(clean_text, voice=voice, speed=speed)
1278
  _, _, audio = next(generator)
1279
  output_path = OUTPUT_DIR / f"speech-{uuid4().hex}.wav"
1280
  sf.write(output_path, audio, 24000)
 
1294
  "model": "hexgrad/Kokoro-82M",
1295
  "warning": warning,
1296
  "audio_url": f"/outputs/{output_path.name}",
1297
+ "transcript": clean_text,
1298
  "elapsed_ms": _elapsed_ms(start),
1299
  }
1300
 
 
1306
  headers: dict[str, str] = {"Content-Type": "application/json"}
1307
  if KLEIN_MODAL_TOKEN:
1308
  headers["Authorization"] = f"Bearer {KLEIN_MODAL_TOKEN}"
1309
+ endpoint = _modal_klein_base_url()
1310
+ generate_url = f"{endpoint}/generate"
1311
+ _runtime_log(
1312
+ "Modal Klein generate request "
1313
+ f"url={generate_url} token={'configured' if KLEIN_MODAL_TOKEN else 'not-set'} seed={seed}"
1314
+ )
1315
  request = urllib.request.Request(
1316
+ generate_url,
1317
  data=body,
1318
  headers=headers,
1319
  method="POST",
1320
  )
1321
+ try:
1322
+ with urllib.request.urlopen(request, timeout=KLEIN_MODAL_TIMEOUT_SECONDS) as response:
1323
+ payload = json.loads(response.read().decode("utf-8"))
1324
+ except (OSError, urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc:
1325
+ detail = _http_exception_detail(exc)
1326
+ _runtime_log(f"Modal Klein generate failed url={generate_url} error={detail}")
1327
+ raise
1328
  if payload.get("ok") is not True:
1329
+ _runtime_log(f"Modal Klein generate returned ok=false payload={_compact_text(json.dumps(payload), 400)}")
1330
  raise ValueError(str(payload.get("error") or payload.get("detail") or "Modal Klein returned ok=false"))
1331
  if payload.get("model") != MODEL_MANIFEST["image_generation"]["id"]:
1332
+ _runtime_log(f"Modal Klein generate returned unexpected model={payload.get('model')}")
1333
  raise ValueError("Modal Klein returned unexpected model")
1334
  if payload.get("runtime") != "modal-klein":
1335
+ _runtime_log(f"Modal Klein generate returned unexpected runtime={payload.get('runtime')}")
1336
  raise ValueError("Modal Klein returned unexpected runtime")
1337
  image_url = payload.get("image_url", "")
1338
  if not isinstance(image_url, str) or not image_url:
1339
+ _runtime_log("Modal Klein generate response did not include image_url")
1340
  raise ValueError("Modal Klein response did not include image_url")
1341
  if image_url and image_url.startswith("/media/"):
1342
+ image_url = f"{endpoint}{image_url}"
1343
+ _runtime_log(f"Modal Klein generate succeeded runtime={payload.get('runtime')} image_url={image_url}")
1344
  return {
1345
  "ok": True,
1346
  "runtime": payload.get("runtime", "modal-klein"),
 
1372
  try:
1373
  return _call_modal_klein(prompt, seed)
1374
  except (OSError, urllib.error.URLError, TimeoutError, ValueError, KeyError, json.JSONDecodeError) as exc:
1375
+ return _fallback_image(prompt, seed, warning=f"Modal Klein unavailable: {_http_exception_detail(exc)}")
1376
+ _runtime_log("Modal Klein generate skipped because KLEIN_MODAL_ENDPOINT is not configured")
1377
  return _fallback_image(prompt, seed)
1378
 
1379
 
1380
+ def _thumbnail_image_prompt(topic: str) -> str:
1381
+ return (
1382
+ f"Square image-only editorial thumbnail about {topic}. "
1383
+ "Create one centered cohesive scene: a friendly assistive robot or small AI helper guiding a reader "
1384
+ "through visual information with light, sound waves, image cards, and accessibility cues. "
1385
+ "Fill most of the frame with the subject while leaving a small clean margin. "
1386
+ "Modern polished 3D editorial illustration, soft gradients, warm blue and teal accents, clear focal point. "
1387
+ "No words, no letters, no numbers, no captions, no title, no logo, no watermark, "
1388
+ "no user interface, no screenshot, no document page, no poster layout, no split panels."
1389
+ )
1390
+
1391
+
1392
  def generate_article_core(topic: str) -> dict[str, Any]:
1393
  start = time.perf_counter()
1394
  clean_topic = _compact_text(topic, 100).strip()
 
1398
  article = _fallback_article(clean_topic)
1399
  runtime = "fallback"
1400
  warning = None
1401
+ if not LLAMA_CPP_BASE_URL:
1402
+ warning = "llama.cpp unavailable: LLAMA_CPP_BASE_URL is not configured"
1403
+ else:
1404
+ try:
1405
+ body = json.dumps(
1406
+ {
1407
+ "model": LLAMA_CPP_MODEL,
1408
+ "messages": [
1409
+ {
1410
+ "role": "system",
1411
+ "content": (
1412
+ "You are Tiny Narrator's article generator. "
1413
+ "Create concise, semantic, accessible article drafts."
1414
+ ),
1415
+ },
1416
+ {"role": "user", "content": _article_generation_prompt(clean_topic)},
1417
+ ],
1418
+ "temperature": 0.35,
1419
+ "max_tokens": 650,
1420
+ }
1421
+ ).encode("utf-8")
1422
+ request = urllib.request.Request(
1423
+ f"{LLAMA_CPP_BASE_URL}/chat/completions",
1424
+ data=body,
1425
+ headers=_llama_cpp_headers({"Content-Type": "application/json"}),
1426
+ method="POST",
1427
+ )
1428
+ with urllib.request.urlopen(request, timeout=LLAMA_CPP_TIMEOUT_SECONDS) as response:
1429
+ payload = json.loads(response.read().decode("utf-8"))
1430
+ generated = json.loads(payload["choices"][0]["message"]["content"].strip())
1431
+ sections = generated.get("sections", [])
1432
+ if (
1433
+ isinstance(generated.get("title"), str)
1434
+ and isinstance(generated.get("dek"), str)
1435
+ and isinstance(sections, list)
1436
+ and len(sections) == 5
1437
+ and all(isinstance(item.get("heading"), str) and isinstance(item.get("body"), str) for item in sections)
1438
+ ):
1439
+ article = {
1440
+ "title": _compact_text(generated["title"], 90),
1441
+ "dek": _compact_text(generated["dek"], 180),
1442
+ "sections": [
1443
+ {"heading": _compact_text(item["heading"], 80), "body": _compact_text(item["body"], 720)}
1444
+ for item in sections
1445
+ ],
1446
+ }
1447
+ runtime = "llama.cpp"
1448
+ except (OSError, urllib.error.URLError, TimeoutError, ValueError, KeyError, json.JSONDecodeError) as exc:
1449
+ warning = f"llama.cpp unavailable: {exc.__class__.__name__}"
1450
 
1451
+ image_prompt = _thumbnail_image_prompt(clean_topic)
1452
  thumbnail = generate_image_core(image_prompt, seed=_topic_seed(clean_topic))
1453
  return {
1454
  "ok": True,
 
1631
 
1632
 
1633
  if __name__ == "__main__":
1634
+ app.launch(server_name=GRADIO_SERVER_NAME, server_port=GRADIO_SERVER_PORT, share=GRADIO_SHARE)
modal_workers/klein_image.py CHANGED
@@ -11,71 +11,148 @@ The deployed ASGI app exposes one base URL with:
11
  Tiny Narrator's app.py calls these routes through KLEIN_MODAL_ENDPOINT.
12
  """
13
 
14
- from __future__ import annotations
15
-
16
  import io
17
  import os
18
- import time
19
  from pathlib import Path
20
  from uuid import uuid4
21
 
22
  import modal
23
 
24
- app = modal.App("tiny-narrator-klein")
25
 
26
- output_volume = modal.Volume.from_name("tiny-narrator-klein-outputs", create_if_missing=True)
 
27
  MEDIA_DIR = Path("/outputs")
28
-
29
  IMAGE_MODEL_ID = "black-forest-labs/FLUX.2-klein-4B"
30
- _PIPELINE = None
 
 
 
31
 
32
  klein_image = (
33
- modal.Image.debian_slim(python_version="3.11")
 
 
 
 
 
34
  .pip_install(
35
- "fastapi[standard]",
36
- "huggingface-hub[hf-transfer]>=0.36.0",
37
- "pillow",
38
- "torch",
39
- "transformers",
40
- "git+https://github.com/huggingface/diffusers.git",
41
  "safetensors>=0.8.0rc0",
42
- "accelerate",
43
- "sentencepiece",
44
  )
45
  .env({"HF_HUB_ENABLE_HF_TRANSFER": "1"})
46
  )
47
 
48
 
49
- def _get_pipeline():
50
- """Load the Klein pipeline once per warm ASGI container."""
51
- global _PIPELINE
52
- if _PIPELINE is None:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  import torch
54
- from diffusers import FluxPipeline
55
 
56
- pipe = FluxPipeline.from_pretrained(
57
- IMAGE_MODEL_ID,
 
 
 
58
  torch_dtype=torch.bfloat16,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
  )
60
- pipe.to("cuda")
61
- _PIPELINE = pipe
62
- return _PIPELINE
 
 
 
 
 
 
 
 
 
 
63
 
64
 
65
  @app.function(
66
  image=klein_image,
67
- gpu="A10G",
68
- timeout=900,
69
- container_idle_timeout=300,
70
  volumes={str(MEDIA_DIR): output_volume},
 
 
71
  )
72
- @modal.asgi_app()
 
73
  def klein_api():
74
  from fastapi import FastAPI, HTTPException, Request
75
- from fastapi.responses import Response
76
- import torch
77
 
78
  api = FastAPI(title="Tiny Narrator Klein Worker")
 
79
 
80
  def _check_token(request: Request) -> None:
81
  """Reject the request if a token is configured but not provided or mismatched."""
@@ -101,64 +178,41 @@ def klein_api():
101
  "runtime": "modal-klein",
102
  }
103
 
 
 
 
 
104
  @api.post("/generate")
105
- async def generate(request: Request, body: dict) -> dict:
106
  _check_token(request)
107
  start = time.perf_counter()
 
108
  prompt = str(body.get("prompt") or "").strip()
109
  if not prompt:
110
  raise HTTPException(status_code=400, detail="prompt is required")
111
 
112
  seed = body.get("seed")
113
- generator = None
114
  if seed is not None:
115
  try:
116
  seed = int(seed)
117
  except (TypeError, ValueError) as exc:
118
  raise HTTPException(status_code=400, detail="seed must be an integer") from exc
119
- generator = torch.Generator(device="cuda").manual_seed(seed)
120
-
121
- result = _get_pipeline()(
122
- prompt=prompt,
123
- num_inference_steps=28,
124
- guidance_scale=3.5,
125
- generator=generator,
126
- )
127
- image = result.images[0]
128
-
129
- filename = f"klein-{uuid4().hex}.png"
130
- output_path = MEDIA_DIR / filename
131
- output_path.parent.mkdir(parents=True, exist_ok=True)
132
 
133
- buffer = io.BytesIO()
134
- image.save(buffer, format="PNG")
135
- output_path.write_bytes(buffer.getvalue())
136
- output_volume.commit()
137
 
 
 
138
  return {
139
  "ok": True,
140
  "runtime": "modal-klein",
141
  "model": IMAGE_MODEL_ID,
142
- "image_url": f"/media/{filename}",
143
  "prompt": prompt,
144
  "seed": seed,
145
  "elapsed_ms": round((time.perf_counter() - start) * 1000),
146
  }
147
 
148
- @api.get("/media/{filename}")
149
- async def media(filename: str):
150
- output_volume.reload()
151
- media_path = MEDIA_DIR / filename
152
- if not media_path.exists() or not media_path.is_file():
153
- raise HTTPException(status_code=404, detail=f"Media file not found: {filename}")
154
-
155
- data = media_path.read_bytes()
156
- content_type = "image/png"
157
- if filename.endswith(".webp"):
158
- content_type = "image/webp"
159
- elif filename.endswith(".jpg") or filename.endswith(".jpeg"):
160
- content_type = "image/jpeg"
161
-
162
- return Response(content=data, media_type=content_type)
163
-
164
  return api
 
11
  Tiny Narrator's app.py calls these routes through KLEIN_MODAL_ENDPOINT.
12
  """
13
 
 
 
14
  import io
15
  import os
 
16
  from pathlib import Path
17
  from uuid import uuid4
18
 
19
  import modal
20
 
 
21
 
22
+ APP_NAME = "tiny-narrator-klein"
23
+ CACHE_DIR = Path("/cache")
24
  MEDIA_DIR = Path("/outputs")
 
25
  IMAGE_MODEL_ID = "black-forest-labs/FLUX.2-klein-4B"
26
+
27
+ app = modal.App(APP_NAME)
28
+ model_cache = modal.Volume.from_name("tiny-narrator-klein-cache", create_if_missing=True)
29
+ output_volume = modal.Volume.from_name("tiny-narrator-klein-outputs", create_if_missing=True)
30
 
31
  klein_image = (
32
+ modal.Image.debian_slim(python_version="3.12")
33
+ .apt_install("git")
34
+ .pip_install(
35
+ "torch==2.9.1",
36
+ index_url="https://download.pytorch.org/whl/cu128",
37
+ )
38
  .pip_install(
39
+ "accelerate==1.12.0",
40
+ "diffusers @ git+https://github.com/huggingface/diffusers.git",
41
+ "fastapi[standard]==0.136.3",
42
+ "huggingface-hub[hf-transfer]==0.36.0",
43
+ "pillow==12.2.0",
 
44
  "safetensors>=0.8.0rc0",
45
+ "sentencepiece==0.2.1",
46
+ "transformers==4.57.3",
47
  )
48
  .env({"HF_HUB_ENABLE_HF_TRANSFER": "1"})
49
  )
50
 
51
 
52
+ def _secret_names() -> list[modal.Secret]:
53
+ """Return the fixed secrets used by Modal deployments.
54
+
55
+ Modal requires dependencies to be identical between local deploy-time import
56
+ and remote container import. Keep this list static; do not derive it from
57
+ environment variables.
58
+ """
59
+
60
+ return [modal.Secret.from_name("tiny-narrator-klein-token")]
61
+
62
+
63
+ async def _media_file_response(filename: str):
64
+ from fastapi import HTTPException
65
+ from fastapi.responses import Response
66
+
67
+ path = MEDIA_DIR / filename
68
+ if not path.exists() or not path.is_file():
69
+ try:
70
+ await output_volume.reload.aio()
71
+ except RuntimeError as exc:
72
+ if not path.exists() or not path.is_file():
73
+ raise HTTPException(status_code=503, detail=f"Media volume reload failed: {exc}") from exc
74
+
75
+ if not path.exists() or not path.is_file():
76
+ raise HTTPException(status_code=404, detail=f"Media file not found: {filename}")
77
+
78
+ content_type = "image/png"
79
+ if filename.endswith(".webp"):
80
+ content_type = "image/webp"
81
+ elif filename.endswith((".jpg", ".jpeg")):
82
+ content_type = "image/jpeg"
83
+ return Response(content=path.read_bytes(), media_type=content_type)
84
+
85
+
86
+ @app.cls(
87
+ image=klein_image,
88
+ gpu=os.getenv("KLEIN_MODAL_GPU", "A10G"),
89
+ volumes={str(CACHE_DIR): model_cache, str(MEDIA_DIR): output_volume},
90
+ secrets=_secret_names(),
91
+ timeout=900,
92
+ scaledown_window=300,
93
+ max_containers=1,
94
+ )
95
+ class KleinImageModel:
96
+ @modal.enter()
97
+ def load(self) -> None:
98
  import torch
99
+ from diffusers import Flux2KleinPipeline
100
 
101
+ self.model_id = os.environ.get("KLEIN_IMAGE_MODEL_ID", IMAGE_MODEL_ID)
102
+ token = os.environ.get("HF_TOKEN")
103
+ self.pipe = Flux2KleinPipeline.from_pretrained(
104
+ self.model_id,
105
+ cache_dir=str(CACHE_DIR / "huggingface"),
106
  torch_dtype=torch.bfloat16,
107
+ token=token,
108
+ )
109
+ self.pipe.to("cuda")
110
+
111
+ @modal.method()
112
+ def generate(self, prompt: str, seed: int | None = None) -> dict:
113
+ import torch
114
+
115
+ MEDIA_DIR.mkdir(parents=True, exist_ok=True)
116
+ generator = None
117
+ if seed is not None:
118
+ generator = torch.Generator(device="cuda").manual_seed(seed)
119
+
120
+ result = self.pipe(
121
+ prompt=prompt,
122
+ num_inference_steps=int(os.environ.get("KLEIN_MODAL_STEPS", "4")),
123
+ guidance_scale=float(os.environ.get("KLEIN_MODAL_GUIDANCE", "1.0")),
124
+ generator=generator,
125
  )
126
+ image = result.images[0]
127
+
128
+ filename = f"klein-{uuid4().hex}.png"
129
+ output_path = MEDIA_DIR / filename
130
+ buffer = io.BytesIO()
131
+ image.save(buffer, format="PNG")
132
+ output_path.write_bytes(buffer.getvalue())
133
+ output_volume.commit()
134
+
135
+ return {
136
+ "filename": filename,
137
+ "model": self.model_id,
138
+ }
139
 
140
 
141
  @app.function(
142
  image=klein_image,
 
 
 
143
  volumes={str(MEDIA_DIR): output_volume},
144
+ secrets=_secret_names(),
145
+ timeout=300,
146
  )
147
+ @modal.concurrent(max_inputs=20)
148
+ @modal.asgi_app(label="tiny-narrator-klein")
149
  def klein_api():
150
  from fastapi import FastAPI, HTTPException, Request
151
+
152
+ import time
153
 
154
  api = FastAPI(title="Tiny Narrator Klein Worker")
155
+ model = KleinImageModel()
156
 
157
  def _check_token(request: Request) -> None:
158
  """Reject the request if a token is configured but not provided or mismatched."""
 
178
  "runtime": "modal-klein",
179
  }
180
 
181
+ @api.get("/media/{filename}")
182
+ async def media(filename: str):
183
+ return await _media_file_response(filename)
184
+
185
  @api.post("/generate")
186
+ async def generate(request: Request) -> dict:
187
  _check_token(request)
188
  start = time.perf_counter()
189
+ body = await request.json()
190
  prompt = str(body.get("prompt") or "").strip()
191
  if not prompt:
192
  raise HTTPException(status_code=400, detail="prompt is required")
193
 
194
  seed = body.get("seed")
 
195
  if seed is not None:
196
  try:
197
  seed = int(seed)
198
  except (TypeError, ValueError) as exc:
199
  raise HTTPException(status_code=400, detail="seed must be an integer") from exc
 
 
 
 
 
 
 
 
 
 
 
 
 
200
 
201
+ result = await model.generate.remote.aio(prompt, seed)
202
+ model_id = result.get("model") or IMAGE_MODEL_ID
203
+ if model_id != IMAGE_MODEL_ID:
204
+ raise HTTPException(status_code=500, detail=f"unexpected model: {model_id}")
205
 
206
+ filename = result["filename"]
207
+ image_url = str(request.url_for("media", filename=filename))
208
  return {
209
  "ok": True,
210
  "runtime": "modal-klein",
211
  "model": IMAGE_MODEL_ID,
212
+ "image_url": image_url,
213
  "prompt": prompt,
214
  "seed": seed,
215
  "elapsed_ms": round((time.perf_counter() - start) * 1000),
216
  }
217
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
218
  return api
modal_workers/reader_brain.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Modal worker for Tiny Narrator's llama.cpp reader brain.
2
+
3
+ Deploy:
4
+ modal deploy modal_workers/reader_brain.py
5
+
6
+ The deployed web server exposes llama.cpp's OpenAI-compatible API. Set:
7
+ LLAMA_CPP_BASE_URL=https://<modal-app-url>/v1
8
+ LLAMA_CPP_MODEL=narrator-brain
9
+ LLAMA_CPP_TOKEN=<same value as Modal secret>
10
+
11
+ The server intentionally binds to 0.0.0.0 because Modal's web_server proxy
12
+ routes traffic to the container port.
13
+ """
14
+
15
+ import os
16
+ import subprocess
17
+
18
+ import modal
19
+
20
+
21
+ APP_NAME = "tiny-narrator-reader-brain"
22
+ MODEL_REF = "nvidia/NVIDIA-Nemotron-3-Nano-4B-GGUF:Q4_K_M"
23
+ MODEL_ALIAS = "narrator-brain"
24
+ SERVER_PORT = 8080
25
+ CACHE_DIR = "/cache"
26
+
27
+ app = modal.App(APP_NAME)
28
+ model_cache = modal.Volume.from_name("tiny-narrator-reader-brain-cache", create_if_missing=True)
29
+
30
+
31
+ def _secret_names() -> list[modal.Secret]:
32
+ return [modal.Secret.from_name("tiny-narrator-reader-brain-token")]
33
+
34
+ reader_brain_image = (
35
+ modal.Image.from_registry(
36
+ "nvidia/cuda:12.4.1-devel-ubuntu22.04",
37
+ add_python="3.12",
38
+ )
39
+ .apt_install("git", "cmake", "build-essential", "curl", "ca-certificates")
40
+ .run_commands(
41
+ "git clone --depth 1 https://github.com/ggml-org/llama.cpp.git /tmp/llama.cpp",
42
+ "cmake -S /tmp/llama.cpp -B /tmp/llama.cpp/build "
43
+ "-DGGML_CUDA=ON -DCMAKE_BUILD_TYPE=Release",
44
+ "cmake --build /tmp/llama.cpp/build --target llama-server -j$(nproc)",
45
+ "cp /tmp/llama.cpp/build/bin/llama-server /usr/local/bin/llama-server",
46
+ "chmod +x /usr/local/bin/llama-server",
47
+ "rm -rf /tmp/llama.cpp",
48
+ )
49
+ .env(
50
+ {
51
+ "HF_HOME": f"{CACHE_DIR}/huggingface",
52
+ }
53
+ )
54
+ )
55
+
56
+
57
+ @app.function(
58
+ image=reader_brain_image,
59
+ gpu=os.getenv("READER_BRAIN_MODAL_GPU", "T4"),
60
+ volumes={CACHE_DIR: model_cache},
61
+ secrets=_secret_names(),
62
+ timeout=900,
63
+ scaledown_window=300,
64
+ max_containers=1,
65
+ )
66
+ @modal.concurrent(max_inputs=20)
67
+ @modal.web_server(SERVER_PORT)
68
+ def reader_brain_server():
69
+ model_ref = os.getenv("READER_BRAIN_MODEL_REF", MODEL_REF)
70
+ model_alias = os.getenv("READER_BRAIN_MODEL_ALIAS", MODEL_ALIAS)
71
+ context_size = os.getenv("READER_BRAIN_CTX_SIZE", "0")
72
+ gpu_layers = os.getenv("READER_BRAIN_GPU_LAYERS", "999")
73
+ api_key = os.getenv("LLAMA_CPP_TOKEN", "")
74
+
75
+ command = [
76
+ "llama-server",
77
+ "--host",
78
+ "0.0.0.0",
79
+ "--port",
80
+ str(SERVER_PORT),
81
+ "-hf",
82
+ model_ref,
83
+ "--alias",
84
+ model_alias,
85
+ "--ctx-size",
86
+ context_size,
87
+ "--reasoning",
88
+ "off",
89
+ "--n-gpu-layers",
90
+ gpu_layers,
91
+ ]
92
+ if api_key:
93
+ command.extend(["--api-key", api_key])
94
+
95
+ display_command = command.copy()
96
+ if "--api-key" in display_command:
97
+ key_index = display_command.index("--api-key") + 1
98
+ if key_index < len(display_command):
99
+ display_command[key_index] = "***"
100
+ print(f"[tiny-narrator-reader-brain] starting: {' '.join(display_command)}", flush=True)
101
+ subprocess.Popen(command)
scripts/verify.py CHANGED
@@ -3,6 +3,7 @@ from __future__ import annotations
3
  import json
4
  import py_compile
5
  import sys
 
6
  import wave
7
  from pathlib import Path
8
  from unittest.mock import patch, MagicMock
@@ -23,6 +24,9 @@ def assert_true(condition: bool, message: str) -> None:
23
  def verify_static_assets() -> None:
24
  required = [
25
  ROOT / "LICENSE",
 
 
 
26
  ROOT / "SUBMISSION.md",
27
  ROOT / "static" / "index.html",
28
  ROOT / "static" / "generate.html",
@@ -40,6 +44,17 @@ def verify_static_assets() -> None:
40
  generate_js = (ROOT / "static" / "generate.js").read_text(encoding="utf-8")
41
  index_html = (ROOT / "static" / "index.html").read_text(encoding="utf-8")
42
  generate_html = (ROOT / "static" / "generate.html").read_text(encoding="utf-8")
 
 
 
 
 
 
 
 
 
 
 
43
  assert_true('href="/" aria-current="page">Reader' in index_html, "Reader page should mark Reader route current")
44
  assert_true('href="/generate">Generate' in index_html, "Reader page should link to Generate route")
45
  assert_true('href="/">Reader' in generate_html, "Generate page should link back to Reader route")
@@ -52,6 +67,8 @@ def verify_static_assets() -> None:
52
  assert_true("/api/generate-article" in generate_js, "Generate frontend should call article generation API")
53
  assert_true("/api/reader-brain" in generate_js, "Generate frontend should call reader-brain narration API")
54
  assert_true("/api/speak" in generate_js, "Generate frontend should call speech API")
 
 
55
  assert_true("refreshReaderNodes" in generate_js, "Generate frontend should build a generated article reader path")
56
  assert_true('data-reader-type="heading"' in generate_js, "Generated article sections should mark heading reader nodes")
57
  assert_true('data-reader-type="paragraph"' in generate_js, "Generated article sections should mark paragraph reader nodes")
@@ -102,6 +119,8 @@ def verify_static_assets() -> None:
102
  )
103
  assert_true("transcript-position" in app_js, "Transcript should render the narrated reader position")
104
  assert_true("readerItemStatus(node)" in app_js, "Narration transcript entries should include reader item position")
 
 
105
  assert_true(
106
  "[${entry.type} / ${entry.position} / ${entry.runtime}]" in app_js,
107
  "Copied transcript should include type, position, and runtime",
@@ -258,6 +277,56 @@ def verify_core_fallbacks() -> None:
258
  assert_true(summary["ok"], "Reader-brain summary fallback did not return ok")
259
  assert_true(summary["narration"].startswith("Summary."), "Summary fallback should announce summary mode")
260
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
261
  description = app.describe_image_core("model-map", caption=None, prompt=None)
262
  assert_true(description["ok"], "Image description did not return ok")
263
  assert_true("small AI models" in description["alt_text"], "Image description fallback changed unexpectedly")
@@ -289,6 +358,10 @@ def verify_core_fallbacks() -> None:
289
  assert_true(wav.getframerate() == 24000, "Speech output should be 24 kHz")
290
  assert_true(wav.getnchannels() == 1, "Speech output should be mono")
291
 
 
 
 
 
292
  generated = app.generate_image_core("Tiny accessibility article image.", seed=3)
293
  assert_true(generated["ok"], "Image generation placeholder should return ok")
294
  assert_true(isinstance(generated["elapsed_ms"], int), "Image generation should include elapsed_ms")
@@ -306,11 +379,14 @@ def verify_core_fallbacks() -> None:
306
  article = app.generate_article_core("tiny classroom robotics")
307
  assert_true(article["ok"], "Article generation should return ok")
308
  assert_true(article["model"] == app.MODEL_MANIFEST["reader_brain"]["id"], "Article generation should use reader-brain model")
309
- assert_true(len(article["article"]["sections"]) == 3, "Article generation should return three sections")
310
  assert_true(
311
  article["thumbnail"]["generation_model"] == app.MODEL_MANIFEST["image_generation"]["id"],
312
  "Article generation should use the Klein image model for thumbnail receipt",
313
  )
 
 
 
314
 
315
 
316
  def verify_modal_klein_integration() -> None:
@@ -387,12 +463,56 @@ def verify_modal_klein_integration() -> None:
387
  assert_true("modal.App" in worker_source, "Modal worker should create a Modal App")
388
  assert_true("modal.Volume" in worker_source, "Modal worker should use a Modal Volume")
389
  assert_true("FLUX.2-klein-4B" in worker_source, "Modal worker should reference the Klein model")
 
390
  assert_true("modal.asgi_app" in worker_source, "Modal worker should expose one ASGI app")
391
  assert_true('@api.post("/generate")' in worker_source, "Modal worker should expose POST /generate")
392
  assert_true('@api.get("/health")' in worker_source, "Modal worker should expose GET /health")
393
  assert_true('@api.get("/media/{filename}")' in worker_source, "Modal worker should expose GET /media/{filename}")
394
  assert_true("404" in worker_source or "not found" in worker_source.lower(), "Modal worker should handle missing media with 404")
395
- assert_true("reload" in worker_source, "Modal worker should reload volume before serving")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
396
 
397
  # Test: auth wiring — app sends bearer token when configured
398
  with patch.object(app, "KLEIN_MODAL_ENDPOINT", "https://example-modal.modal.run"), patch.object(app, "KLEIN_MODAL_TOKEN", "test-secret-token"):
@@ -421,6 +541,7 @@ def verify_modal_klein_integration() -> None:
421
  # Test: .env.example includes KLEIN_MODAL_TOKEN
422
  env_example = (ROOT / ".env.example").read_text(encoding="utf-8")
423
  assert_true("KLEIN_MODAL_TOKEN" in env_example, ".env.example should document KLEIN_MODAL_TOKEN")
 
424
 
425
  # Test: worker validates token
426
  assert_true("KLEIN_MODAL_TOKEN" in worker_source, "Modal worker should read KLEIN_MODAL_TOKEN")
@@ -446,6 +567,17 @@ def verify_modal_klein_integration() -> None:
446
  "Runtime setup should show token as configured without exposing the value",
447
  )
448
 
 
 
 
 
 
 
 
 
 
 
 
449
  # Test: runtime status includes Modal Klein path
450
  with patch.object(app, "KLEIN_MODAL_ENDPOINT", ""):
451
  status = app._runtime_status_core()
@@ -554,6 +686,17 @@ def verify_minicpm_vision_integration() -> None:
554
  assert_true(status["status"] == "online", "MiniCPM /models response should mark vision online")
555
  assert_true("secret-key" not in json.dumps(status), "Vision runtime status should not expose API key")
556
 
 
 
 
 
 
 
 
 
 
 
 
557
  setup = app.runtime_setup_core()
558
  vision_step = next(step for step in setup["steps"] if step["role"] == "vision")
559
  assert_true("MiniCPM-V-4.6" in vision_step["model"], "Runtime setup should document MiniCPM-V-4.6")
@@ -710,6 +853,14 @@ def verify_routes() -> None:
710
  "llama-server" in setup_payload["steps"][0]["command"],
711
  "Runtime setup should include the llama.cpp launch command",
712
  )
 
 
 
 
 
 
 
 
713
  assert_true(
714
  setup_payload["app"]["env"]["PUBLIC_BASE_URL"] == app.PUBLIC_BASE_URL,
715
  "Runtime setup should expose the public command base URL",
@@ -793,7 +944,7 @@ def verify_routes() -> None:
793
  assert_true(article_sample.status_code == 200, "Article generation route should return 200")
794
  article_sample_payload = article_sample.json()
795
  assert_true(article_sample_payload["ok"], "Article generation payload should return ok")
796
- assert_true(len(article_sample_payload["article"]["sections"]) == 3, "Article generation payload should include three sections")
797
  assert_true(
798
  article_sample_payload["thumbnail"]["generation_model"] == app.MODEL_MANIFEST["image_generation"]["id"],
799
  "Article generation payload should include Klein thumbnail provenance",
@@ -928,6 +1079,7 @@ def verify_routes() -> None:
928
  def main() -> None:
929
  py_compile.compile(str(ROOT / "app.py"), doraise=True)
930
  py_compile.compile(str(ROOT / "modal_workers" / "klein_image.py"), doraise=True)
 
931
  verify_static_assets()
932
  verify_space_metadata()
933
  verify_dotenv_wiring()
 
3
  import json
4
  import py_compile
5
  import sys
6
+ import urllib.error
7
  import wave
8
  from pathlib import Path
9
  from unittest.mock import patch, MagicMock
 
24
  def verify_static_assets() -> None:
25
  required = [
26
  ROOT / "LICENSE",
27
+ ROOT / "DEPLOY_SPACES.md",
28
+ ROOT / "Dockerfile",
29
+ ROOT / "start.sh",
30
  ROOT / "SUBMISSION.md",
31
  ROOT / "static" / "index.html",
32
  ROOT / "static" / "generate.html",
 
44
  generate_js = (ROOT / "static" / "generate.js").read_text(encoding="utf-8")
45
  index_html = (ROOT / "static" / "index.html").read_text(encoding="utf-8")
46
  generate_html = (ROOT / "static" / "generate.html").read_text(encoding="utf-8")
47
+ deploy_spaces = (ROOT / "DEPLOY_SPACES.md").read_text(encoding="utf-8")
48
+ dockerfile = (ROOT / "Dockerfile").read_text(encoding="utf-8")
49
+ start_script = (ROOT / "start.sh").read_text(encoding="utf-8")
50
+ assert_true("FROM python:3.12-slim" in dockerfile, "Spaces Dockerfile should use a free CPU Python base image")
51
+ assert_true("nvidia/cuda" not in dockerfile, "Spaces Dockerfile should not require paid HF GPU CUDA image")
52
+ assert_true("llama-server" not in start_script, "Spaces start script should not launch local llama.cpp")
53
+ assert_true("free CPU" in deploy_spaces, "Spaces deploy guide should describe free CPU deployment")
54
+ assert_true("modal_workers/reader_brain.py" in deploy_spaces, "Spaces deploy guide should document Modal reader-brain deployment")
55
+ assert_true("CPU Basic" in deploy_spaces, "Spaces deploy guide should recommend CPU Basic hardware")
56
+ assert_true("LLAMA_CPP_TOKEN" in deploy_spaces, "Spaces deploy guide should document reader-brain auth token")
57
+ assert_true("tiny-narrator-reader-brain-token" in deploy_spaces, "Spaces deploy guide should document the Modal reader token secret")
58
  assert_true('href="/" aria-current="page">Reader' in index_html, "Reader page should mark Reader route current")
59
  assert_true('href="/generate">Generate' in index_html, "Reader page should link to Generate route")
60
  assert_true('href="/">Reader' in generate_html, "Generate page should link back to Reader route")
 
67
  assert_true("/api/generate-article" in generate_js, "Generate frontend should call article generation API")
68
  assert_true("/api/reader-brain" in generate_js, "Generate frontend should call reader-brain narration API")
69
  assert_true("/api/speak" in generate_js, "Generate frontend should call speech API")
70
+ assert_true('node.type === "image"' in generate_js, "Generate reader should reserve reader-brain item narration for images")
71
+ assert_true('runtime: "raw text"' in generate_js, "Generate reader should speak text-only nodes without reader-brain")
72
  assert_true("refreshReaderNodes" in generate_js, "Generate frontend should build a generated article reader path")
73
  assert_true('data-reader-type="heading"' in generate_js, "Generated article sections should mark heading reader nodes")
74
  assert_true('data-reader-type="paragraph"' in generate_js, "Generated article sections should mark paragraph reader nodes")
 
119
  )
120
  assert_true("transcript-position" in app_js, "Transcript should render the narrated reader position")
121
  assert_true("readerItemStatus(node)" in app_js, "Narration transcript entries should include reader item position")
122
+ assert_true('node.type === "image"' in app_js, "Article reader should reserve reader-brain item narration for images")
123
+ assert_true('runtime: "raw text"' in app_js, "Article reader should speak text-only nodes without reader-brain")
124
  assert_true(
125
  "[${entry.type} / ${entry.position} / ${entry.runtime}]" in app_js,
126
  "Copied transcript should include type, position, and runtime",
 
277
  assert_true(summary["ok"], "Reader-brain summary fallback did not return ok")
278
  assert_true(summary["narration"].startswith("Summary."), "Summary fallback should announce summary mode")
279
 
280
+ with patch.object(app, "LLAMA_CPP_BASE_URL", ""):
281
+ unconfigured_reader = app.reader_brain_core(
282
+ node_type="paragraph",
283
+ text="This paragraph should be narrated without a configured endpoint.",
284
+ position="item 1 of 1",
285
+ mode="narrate",
286
+ )
287
+ assert_true(unconfigured_reader["runtime"] == "fallback", "Unconfigured reader brain should use fallback")
288
+ assert_true(
289
+ "not configured" in unconfigured_reader["warning"],
290
+ "Unconfigured reader brain should explain the missing endpoint",
291
+ )
292
+
293
+ unconfigured_article = app.generate_article_core("accessible classroom tools")
294
+ assert_true(unconfigured_article["runtime"] == "fallback", "Unconfigured article generation should use fallback")
295
+ assert_true(
296
+ "not configured" in unconfigured_article["warning"],
297
+ "Unconfigured article generation should explain the missing endpoint",
298
+ )
299
+
300
+ empty_llama_response = MagicMock()
301
+ empty_llama_response.read.return_value = json.dumps({"choices": [{"message": {"content": ""}}]}).encode("utf-8")
302
+ empty_llama_response.__enter__ = lambda s: s
303
+ empty_llama_response.__exit__ = MagicMock(return_value=False)
304
+ with patch("urllib.request.urlopen", return_value=empty_llama_response):
305
+ empty_narration = app.reader_brain_core(
306
+ node_type="paragraph",
307
+ text="This paragraph still needs narration.",
308
+ position="item 2 of 3",
309
+ mode="narrate",
310
+ )
311
+ assert_true(empty_narration["runtime"] == "fallback", "Empty llama.cpp response should use fallback narration")
312
+ assert_true(empty_narration["narration"], "Empty llama.cpp response should not return empty narration")
313
+
314
+ reasoning_response = MagicMock()
315
+ reasoning_response.read.return_value = json.dumps(
316
+ {"choices": [{"message": {"content": "", "reasoning_content": "Heading. Live narration from llama.cpp."}}]}
317
+ ).encode("utf-8")
318
+ reasoning_response.__enter__ = lambda s: s
319
+ reasoning_response.__exit__ = MagicMock(return_value=False)
320
+ with patch("urllib.request.urlopen", return_value=reasoning_response):
321
+ reasoning_narration = app.reader_brain_core(
322
+ node_type="heading",
323
+ text="Live heading",
324
+ position="item 1 of 1",
325
+ mode="narrate",
326
+ )
327
+ assert_true(reasoning_narration["runtime"] == "llama.cpp", "Reader brain should use alternate llama.cpp message text fields")
328
+ assert_true("Live narration" in reasoning_narration["narration"], "Reader brain should extract reasoning_content when content is empty")
329
+
330
  description = app.describe_image_core("model-map", caption=None, prompt=None)
331
  assert_true(description["ok"], "Image description did not return ok")
332
  assert_true("small AI models" in description["alt_text"], "Image description fallback changed unexpectedly")
 
358
  assert_true(wav.getframerate() == 24000, "Speech output should be 24 kHz")
359
  assert_true(wav.getnchannels() == 1, "Speech output should be mono")
360
 
361
+ empty_speech = app.speak_core("", voice="af_heart", speed=1.0)
362
+ assert_true(empty_speech["runtime"] == "fallback", "Empty speech input should use fast fallback")
363
+ assert_true("empty" in empty_speech["warning"].lower(), "Empty speech input should explain why Kokoro was skipped")
364
+
365
  generated = app.generate_image_core("Tiny accessibility article image.", seed=3)
366
  assert_true(generated["ok"], "Image generation placeholder should return ok")
367
  assert_true(isinstance(generated["elapsed_ms"], int), "Image generation should include elapsed_ms")
 
379
  article = app.generate_article_core("tiny classroom robotics")
380
  assert_true(article["ok"], "Article generation should return ok")
381
  assert_true(article["model"] == app.MODEL_MANIFEST["reader_brain"]["id"], "Article generation should use reader-brain model")
382
+ assert_true(len(article["article"]["sections"]) == 5, "Article generation should return five sections")
383
  assert_true(
384
  article["thumbnail"]["generation_model"] == app.MODEL_MANIFEST["image_generation"]["id"],
385
  "Article generation should use the Klein image model for thumbnail receipt",
386
  )
387
+ assert_true("No words" in article["thumbnail"]["prompt"], "Article thumbnail prompt should forbid unreadable generated text")
388
+ assert_true("no user interface" in article["thumbnail"]["prompt"], "Article thumbnail prompt should avoid UI screenshots")
389
+ assert_true("one centered cohesive scene" in article["thumbnail"]["prompt"], "Article thumbnail prompt should avoid sparse disconnected layouts")
390
 
391
 
392
  def verify_modal_klein_integration() -> None:
 
463
  assert_true("modal.App" in worker_source, "Modal worker should create a Modal App")
464
  assert_true("modal.Volume" in worker_source, "Modal worker should use a Modal Volume")
465
  assert_true("FLUX.2-klein-4B" in worker_source, "Modal worker should reference the Klein model")
466
+ assert_true("Flux2KleinPipeline" in worker_source, "Modal worker should use the FLUX.2 Klein Diffusers pipeline")
467
  assert_true("modal.asgi_app" in worker_source, "Modal worker should expose one ASGI app")
468
  assert_true('@api.post("/generate")' in worker_source, "Modal worker should expose POST /generate")
469
  assert_true('@api.get("/health")' in worker_source, "Modal worker should expose GET /health")
470
  assert_true('@api.get("/media/{filename}")' in worker_source, "Modal worker should expose GET /media/{filename}")
471
  assert_true("404" in worker_source or "not found" in worker_source.lower(), "Modal worker should handle missing media with 404")
472
+ assert_true("reload.aio()" in worker_source, "Modal worker should reload volume with async API before serving missing media")
473
+ assert_true("path.read_bytes()" in worker_source, "Modal worker should avoid streaming open files from the volume")
474
+
475
+ reader_worker_path = ROOT / "modal_workers" / "reader_brain.py"
476
+ assert_true(reader_worker_path.exists(), "Modal reader-brain worker file should exist")
477
+ reader_worker_source = reader_worker_path.read_text(encoding="utf-8")
478
+ assert_true("modal.web_server" in reader_worker_source, "Reader-brain worker should expose llama-server through Modal web_server")
479
+ assert_true("nvidia/cuda:12.4.1-devel-ubuntu22.04" in reader_worker_source, "Reader-brain worker should use a CUDA build image")
480
+ assert_true("GGML_CUDA=ON" in reader_worker_source, "Reader-brain worker should compile llama.cpp with CUDA")
481
+ assert_true("NVIDIA-Nemotron-3-Nano-4B-GGUF:Q4_K_M" in reader_worker_source, "Reader-brain worker should serve the Nemotron GGUF")
482
+ assert_true('"--reasoning"' in reader_worker_source and '"off"' in reader_worker_source, "Reader-brain worker should disable reasoning mode")
483
+ assert_true('"--ctx-size"' in reader_worker_source and '"0"' in reader_worker_source, "Reader-brain worker should use model context size")
484
+ assert_true('"--n-gpu-layers"' in reader_worker_source and '"999"' in reader_worker_source, "Reader-brain worker should request full GPU offload")
485
+ assert_true("modal.Volume" in reader_worker_source, "Reader-brain worker should cache model downloads in a Modal volume")
486
+ assert_true("tiny-narrator-reader-brain-token" in reader_worker_source, "Reader-brain worker should use a fixed Modal token secret")
487
+ assert_true('"--api-key"' in reader_worker_source, "Reader-brain worker should pass an API key to llama-server when configured")
488
+ assert_true('display_command[key_index] = "***"' in reader_worker_source, "Reader-brain worker should redact API keys in logs")
489
+
490
+ # Test: reader-brain auth wiring sends bearer tokens when configured
491
+ model_response = MagicMock()
492
+ model_response.read.return_value = json.dumps({"data": [{"id": "narrator-brain"}]}).encode("utf-8")
493
+ model_response.__enter__ = lambda s: s
494
+ model_response.__exit__ = MagicMock(return_value=False)
495
+ with patch.object(app, "LLAMA_CPP_BASE_URL", "https://reader.example/v1"), patch.object(app, "LLAMA_CPP_TOKEN", "reader-secret"):
496
+ with patch("urllib.request.urlopen", return_value=model_response) as urlopen_mock:
497
+ app._runtime_status_core()
498
+ status_request = urlopen_mock.call_args.args[0]
499
+ assert_true(
500
+ status_request.headers.get("Authorization") == "Bearer reader-secret",
501
+ "Runtime status should send reader-brain Bearer token when LLAMA_CPP_TOKEN is configured",
502
+ )
503
+
504
+ chat_response = MagicMock()
505
+ chat_response.read.return_value = json.dumps({"choices": [{"message": {"content": "Heading. Token protected narration."}}]}).encode("utf-8")
506
+ chat_response.__enter__ = lambda s: s
507
+ chat_response.__exit__ = MagicMock(return_value=False)
508
+ with patch.object(app, "LLAMA_CPP_BASE_URL", "https://reader.example/v1"), patch.object(app, "LLAMA_CPP_TOKEN", "reader-secret"):
509
+ with patch("urllib.request.urlopen", return_value=chat_response) as urlopen_mock:
510
+ app.reader_brain_core("heading", "Token protected narration", "item 1 of 1", "narrate")
511
+ chat_request = urlopen_mock.call_args.args[0]
512
+ assert_true(
513
+ chat_request.headers.get("Authorization") == "Bearer reader-secret",
514
+ "Reader-brain requests should send Bearer token when LLAMA_CPP_TOKEN is configured",
515
+ )
516
 
517
  # Test: auth wiring — app sends bearer token when configured
518
  with patch.object(app, "KLEIN_MODAL_ENDPOINT", "https://example-modal.modal.run"), patch.object(app, "KLEIN_MODAL_TOKEN", "test-secret-token"):
 
541
  # Test: .env.example includes KLEIN_MODAL_TOKEN
542
  env_example = (ROOT / ".env.example").read_text(encoding="utf-8")
543
  assert_true("KLEIN_MODAL_TOKEN" in env_example, ".env.example should document KLEIN_MODAL_TOKEN")
544
+ assert_true("LLAMA_CPP_TOKEN" in env_example, ".env.example should document LLAMA_CPP_TOKEN")
545
 
546
  # Test: worker validates token
547
  assert_true("KLEIN_MODAL_TOKEN" in worker_source, "Modal worker should read KLEIN_MODAL_TOKEN")
 
567
  "Runtime setup should show token as configured without exposing the value",
568
  )
569
 
570
+ with patch.object(app, "LLAMA_CPP_TOKEN", "reader-super-secret"):
571
+ setup = app.runtime_setup_core()
572
+ setup_json = json.dumps(setup)
573
+ assert_true("reader-super-secret" not in setup_json, "Runtime setup must never expose the reader token value")
574
+ reader_step = next(step for step in setup["steps"] if step["role"] == "reader_brain")
575
+ assert_true("LLAMA_CPP_TOKEN" in reader_step["env"], "Runtime setup should document LLAMA_CPP_TOKEN")
576
+ assert_true(
577
+ reader_step["env"]["LLAMA_CPP_TOKEN"] == "(configured)",
578
+ "Runtime setup should show reader token as configured without exposing the value",
579
+ )
580
+
581
  # Test: runtime status includes Modal Klein path
582
  with patch.object(app, "KLEIN_MODAL_ENDPOINT", ""):
583
  status = app._runtime_status_core()
 
686
  assert_true(status["status"] == "online", "MiniCPM /models response should mark vision online")
687
  assert_true("secret-key" not in json.dumps(status), "Vision runtime status should not expose API key")
688
 
689
+ ready_response = MagicMock()
690
+ ready_response.read.return_value = json.dumps({"choices": [{"message": {"content": "ready"}}]}).encode("utf-8")
691
+ ready_response.__enter__ = lambda s: s
692
+ ready_response.__exit__ = MagicMock(return_value=False)
693
+ with patch.object(app, "MINICPM_VISION_BASE_URL", "https://vision.example/v1"):
694
+ with patch.object(app, "MINICPM_VISION_API_KEY", "secret-key"):
695
+ with patch("urllib.request.urlopen", side_effect=[urllib.error.HTTPError("https://vision.example/v1/models", 404, "Not Found", {}, None), ready_response]):
696
+ status = app._vision_runtime_status()
697
+ assert_true(status["status"] == "online", "MiniCPM chat completions readiness should mark vision online when /models is unavailable")
698
+ assert_true("chat completions ready" in status.get("warning", ""), "Vision status should explain chat-completions readiness fallback")
699
+
700
  setup = app.runtime_setup_core()
701
  vision_step = next(step for step in setup["steps"] if step["role"] == "vision")
702
  assert_true("MiniCPM-V-4.6" in vision_step["model"], "Runtime setup should document MiniCPM-V-4.6")
 
853
  "llama-server" in setup_payload["steps"][0]["command"],
854
  "Runtime setup should include the llama.cpp launch command",
855
  )
856
+ assert_true(
857
+ setup_payload["steps"][0]["modal_command"] == "modal deploy modal_workers/reader_brain.py",
858
+ "Runtime setup should include the Modal reader-brain deploy command",
859
+ )
860
+ assert_true(
861
+ "--reasoning off" in setup_payload["steps"][0]["command"],
862
+ "Runtime setup should document non-reasoning llama.cpp mode",
863
+ )
864
  assert_true(
865
  setup_payload["app"]["env"]["PUBLIC_BASE_URL"] == app.PUBLIC_BASE_URL,
866
  "Runtime setup should expose the public command base URL",
 
944
  assert_true(article_sample.status_code == 200, "Article generation route should return 200")
945
  article_sample_payload = article_sample.json()
946
  assert_true(article_sample_payload["ok"], "Article generation payload should return ok")
947
+ assert_true(len(article_sample_payload["article"]["sections"]) == 5, "Article generation payload should include five sections")
948
  assert_true(
949
  article_sample_payload["thumbnail"]["generation_model"] == app.MODEL_MANIFEST["image_generation"]["id"],
950
  "Article generation payload should include Klein thumbnail provenance",
 
1079
  def main() -> None:
1080
  py_compile.compile(str(ROOT / "app.py"), doraise=True)
1081
  py_compile.compile(str(ROOT / "modal_workers" / "klein_image.py"), doraise=True)
1082
+ py_compile.compile(str(ROOT / "modal_workers" / "reader_brain.py"), doraise=True)
1083
  verify_static_assets()
1084
  verify_space_metadata()
1085
  verify_dotenv_wiring()
start.sh ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/sh
2
+ # Startup script for Tiny Narrator on Hugging Face Spaces free CPU.
3
+ # Model inference endpoints are configured through environment variables.
4
+
5
+ set -eu
6
+
7
+ echo "[tiny-narrator] Starting Tiny Narrator app on ${GRADIO_SERVER_NAME:-0.0.0.0}:${PORT:-${GRADIO_SERVER_PORT:-7860}}"
8
+
9
+ if [ -n "${LLAMA_CPP_BASE_URL:-}" ]; then
10
+ echo "[tiny-narrator] Reader brain endpoint configured at ${LLAMA_CPP_BASE_URL}"
11
+ else
12
+ echo "[tiny-narrator] LLAMA_CPP_BASE_URL is not configured; reader brain will use deterministic fallback."
13
+ fi
14
+
15
+ exec python app.py