OpenCode commited on
Commit
18729ef
·
1 Parent(s): b3818ca

Fabella Phase 2: add read aloud and redesign frontend

Browse files
Files changed (16) hide show
  1. AGENTS.md +190 -22
  2. HANDOFF.md +278 -0
  3. README.md +69 -36
  4. agent.py +369 -0
  5. app.py +1551 -118
  6. generator.py +0 -30
  7. judge.py +173 -0
  8. llm.py +252 -0
  9. mock.py +0 -560
  10. modal_app.py +337 -0
  11. modal_app_gemma.py +109 -0
  12. prompts.py +0 -49
  13. real.py +0 -85
  14. requirements.txt +4 -4
  15. safety.py +24 -25
  16. schema.py +81 -6
AGENTS.md CHANGED
@@ -4,7 +4,61 @@ Quick orientation for future OpenCode sessions working on Fabella.
4
 
5
  ## What this is
6
 
7
- A Gradio app that generates personalized children's stories. Phase 1 ships with a template-based mock generator (default) and a wired-but-unpolished real-model path on ZeroGPU. Single HF Space, no DB, no auth.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
 
9
  ## Run / verify
10
 
@@ -16,36 +70,150 @@ uv pip install --python .venv/bin/python -r requirements.txt
16
  .venv/bin/python app.py # http://localhost:7860
17
  ```
18
 
19
- `app.py` is the only entrypoint. No test suite exists yet (was deferred from Phase 1 plan).
 
 
 
 
 
20
 
21
  ## File map
22
 
23
- - `app.py` — Gradio Blocks UI, form handler, launch
24
- - `generator.py` — dispatcher; reads `USE_MOCK` at import time, routes to `mock.py` or `real.py`
25
- - `mock.py` — template-based generator. One `THEMES: dict[theme][bucket] = (primary, alt)` table (8 themes × 3 buckets × 2 variants = 48 templates). `_select(theme, age, seed)` picks primary on even seed, alt on odd. Adding a new theme = one new key in `THEMES` + an entry in `THEME_CHOICES` in `app.py`.
26
- - `real.py` — real-model call. `@spaces.GPU` is applied **lazily** via `_get_run_gpu()` so the module imports cleanly even when `spaces` is not installed (mock path stays the default).
27
- - `prompts.py` — system + user prompt builders (used only by `real.py`)
28
- - `safety.py` — input sanitization, profanity block, length/age helpers
29
- - `schema.py` — `StoryRequest` dataclass
30
- - `README.md` — has HF Space YAML frontmatter; the Space builder reads `sdk_version: 4.44.0` from there, not from local install
31
 
32
  ## Non-obvious gotchas
33
 
34
- - **`sys.path` hack in every module.** Each file does `sys.path.insert(0, os.path.dirname(...))` so imports work when run as `python app.py` from the package root. Don't refactor to relative imports — they break the entrypoint.
35
- - **`USE_MOCK` is module-level.** Read once at import. Toggling the env var requires a process restart.
36
- - **Gradio 6 moved `theme=` from `Blocks(...)` to `launch(...)`.** If you upgrade Gradio and see a `UserWarning`, check `app.py:139`.
37
- - **`FABELLA_MODEL_ID` env var** overrides the default `google/gemma-4-E4B-it` slug in `real.py`. **Verified open (Apache 2.0, not gated).** Gemma 4 uses `AutoProcessor` (not `AutoTokenizer`) and supports `enable_thinking=False` for direct, non-reasoning output — keep it disabled for children's stories. Recommended sampling: `temperature=1.0, top_p=0.95, top_k=64`. Do NOT swap to `gemma-3-4b-it` (it's gated and would break the no-API-key rule).
38
- - **Real-model path is best-effort.** On any failure (no GPU, missing dep, model 404), it returns a user-visible "real model unavailable" message — it does NOT silently fall back to mock.
39
- - **No test directory.** The Phase 1 plan included `tests/` but it was not built. Add one if/when a verification pass is needed.
40
- - **HF Space metadata in README.** `sdk_version: 4.44.0` is the version the Space runtime builds against, not your local `pip show gradio` (currently 6.17.3). They can diverge safely.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
 
42
  ## When editing
43
 
44
- - Adding a new theme → add one key to `THEMES` in `mock.py` (with all three buckets) + an entry in `THEME_CHOICES` in `app.py`.
45
- - Adding a moral preset `MORAL_PRESETS` in `app.py`.
46
- - Changing age rules`age_bucket()` in `safety.py` and the vocab rules in `prompts.py` (real model path) must stay in sync.
47
- - PDF export, accounts, history, audio, images are **out of scope** unless the user reopens Phase 2.
 
 
 
 
 
 
 
 
 
 
 
48
 
49
  ## Deployment
50
 
51
- Push to a HF Space with Gradio SDK; no secrets required for first boot (mock mode). To activate the real model, set `USE_MOCK=false` in the Space's Variables & Secrets tab.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
 
5
  ## What this is
6
 
7
+ A Gradio app for **parents who need to explain hard things to their child
8
+ in kid-appropriate language**. The parent types a sentence or two about
9
+ the situation; Fabella drafts a short explanation (Opener / Body /
10
+ Closer / optional follow-up) that's reviewed by a second small model
11
+ before the parent sees it. After generation, the parent can optionally
12
+ click **Read aloud** to synthesize the explanation with VoxCPM2.
13
+
14
+ Built for the [Build Small Hackathon](https://huggingface.co/spaces/build-small-hackathon/README) · **Track I · Backyard AI** ("useful for someone the maker actually knows").
15
+
16
+ The product design has two distinct execution layers, each tuned to its job:
17
+
18
+ - **Drafter on LangGraph.** The drafter is a `create_agent` ReAct loop
19
+ with one tool (`validate_explanation`) and a custom middleware. State
20
+ machine, conditional edges, tool-call plumbing — that's LangGraph's
21
+ job, and it works.
22
+ - **Judge on Pydantic.** The judge task is bounded — one rubric, one
23
+ draft, one structured verdict. No loop, no tools, no state machine.
24
+ Single `llm.invoke()` + `JudgeVerdict.model_validate_json()` + one
25
+ repair retry. Cross-field consistency is enforced in code.
26
+
27
+ ## Architecture
28
+
29
+ ```
30
+ HF Space (CPU, custom HTML+CSS+JS)
31
+ |
32
+ | POST /gradio_api/call/make_explanation
33
+ | <- SSE stream with 4-section string
34
+ v
35
+ app.py (gradio.Server / FastAPI)
36
+ |
37
+ +--------------------+--------------------+--------------------+
38
+ | | |
39
+ v v v
40
+ Modal drafter (A10G, Gemma 4 E4B-IT) Modal judge (A10G, Nemotron-3) Modal TTS (A10G, VoxCPM2)
41
+ --tool-call-parser gemma4 (no tool-calling flags) FastAPI /synthesize
42
+ ReAct via LangChain Pydantic JSON verdict audio/wav when requested
43
+ validate_explanation tool one invoke + one repair retry
44
+ middleware jumps to "end" on OK
45
+ ```
46
+
47
+ - HF Space env vars: `MODAL_DRAFTER_URL`, `MODAL_JUDGE_URL`, and `MODAL_TTS_URL`.
48
+ - Modal uses the `nvidia/cuda:12.9.0-devel-ubuntu22.04` base image (provides nvcc for FlashInfer).
49
+ - Drafter vLLM flags: `--language-model-only --enable-auto-tool-choice --tool-call-parser gemma4`.
50
+ - Judge vLLM flags: none (the judge emits raw JSON in `content`; Pydantic parses).
51
+ - TTS is separate from drafter/judge and only called from `make_audio` when the user clicks **Read aloud**.
52
+ - `scaledown_window=10` minutes on all serves.
53
+
54
+ ## Live URLs
55
+
56
+ - HF Space: https://build-small-hackathon-fabella.hf.space
57
+ - Modal drafter: https://khoitruong071510--fabella-serve-drafter.modal.run
58
+ - Modal judge: https://khoitruong071510--fabella-serve-judge.modal.run
59
+ - Modal TTS: https://khoitruong071510--fabella-serve-tts.modal.run
60
+ - Modal app: https://modal.com/apps/khoitruong071510/main/deployed/fabella
61
+ - HF Space repo: https://huggingface.co/spaces/build-small-hackathon/Fabella
62
 
63
  ## Run / verify
64
 
 
70
  .venv/bin/python app.py # http://localhost:7860
71
  ```
72
 
73
+ The custom frontend runs on CPU locally. For story generation to work,
74
+ the `MODAL_DRAFTER_URL`, `MODAL_JUDGE_URL`, and `MODAL_TTS_URL` env vars
75
+ must point to live Modal deploys. Use the deployed HF Space for
76
+ end-to-end testing.
77
+
78
+ `app.py` is the only entrypoint. No test suite exists yet.
79
 
80
  ## File map
81
 
82
+ - `app.py` — `gradio.Server` (FastAPI subclass) app. Imports `FabellaVLLM` from `llm.py` and calls `run_agent` from `agent.py`. Serves a hand-coded HTML+CSS+JS page. `@app.api()` endpoint `make_explanation` returns Opener/Body/Closer/Follow-up joined by U+001F. `make_audio` proxies VoxCPM2 and returns a base64 WAV data URL. Contains a no-op `@spaces.GPU` placeholder function (HF Spaces runtime scans for at least one during import; all inference runs on Modal).
83
+ - `agent.py` — LangChain ReAct agent. `build_agent(llm, req, judge_llm=None)` returns `(agent, user_prompt)`. One tool: `validate_explanation` (calls the Pydantic judge if `judge_llm` is given, else falls back to a rule check). `FabellaAgentMiddleware.before_model` jumps to `end` once validation passes or after `max_tool_calls=2`. `extract_explanation(messages)` parses the four sections from the validated tool-call draft.
84
+ - `judge.py` — Pydantic-validated judge. `judge_explanation(llm, draft, req_age, req_tone, child_name, situation) -> JudgeVerdict`. Two attempts (original + repair prompt) before raising `JudgeFailed` for fallback. Tolerant of markdown fences and pretty-printed JSON.
85
+ - `schema.py` — `ExplainRequest` dataclass (situation, age, child_name, tone, seed), `JudgeVerdict` Pydantic model (ok, issues, score, verdict, reasoning), `JudgeFailed` exception.
86
+ - `safety.py` — input sanitization (`sanitize_situation`, `sanitize_name`, `has_profanity`), `explain_to_words(tone)`, `age_bucket(age)`.
87
+ - `llm.py` — `FabellaVLLM`, a `BaseChatModel` subclass wrapping vLLM's OpenAI-compatible API. `bind_tools` builds an OpenAI-spec `tools=[...]` payload, `_generate` passes it on the request and reads `response.choices[0].message.tool_calls` from the response. Replay of prior `AIMessage.tool_calls` and `ToolMessage` results into next-turn messages uses the OpenAI chat-completions shape.
88
+ - `modal_app.py` — Modal deployment. `download_drafter`, `download_judge`, and `download_tts` write weights to the `fabella-models` Volume. `serve_drafter` runs vLLM with `--language-model-only --enable-auto-tool-choice --tool-call-parser gemma4` on port 8000 (A10G). `serve_judge` runs vLLM with no tool-calling flags on port 8001 (A10G). `serve_tts` runs a tiny VoxCPM2 FastAPI app on port 8002 (A10G). One Modal app, three web_server functions.
89
+ - `modal_app_gemma.py` — Legacy single-model Modal deploy from the previous session. Not the live deploy. Reference only.
90
 
91
  ## Non-obvious gotchas
92
 
93
+ - **No-op `@spaces.GPU` in `app.py`.** The HF Spaces runtime scans for
94
+ at least one `@spaces.GPU` function at module import and raises
95
+ `RUNTIME_ERROR: No @spaces.GPU function detected` if none exists.
96
+ The placeholder is a 1-second no-op. Do not delete it.
97
+ - **`sys.path` hack in every module.** Each file does
98
+ `sys.path.insert(0, os.path.dirname(...))` so imports work when run
99
+ as `python app.py` from the package root. Don't refactor to relative
100
+ imports.
101
+ - **Pydantic disallows `_`-prefixed fields.** In `llm.py`, the
102
+ runtime-mutable state (OpenAI client, tools, call counter) is
103
+ declared with `PrivateAttr`, not `Field`.
104
+ - **Three Modal endpoints, three env vars.** HF Space reads
105
+ `MODAL_DRAFTER_URL`, `MODAL_JUDGE_URL`, and `MODAL_TTS_URL`. The old
106
+ `MODAL_VLLM_URL` is dead — delete it if it's still there.
107
+ - **The drafter uses native tool calling via vLLM.** vLLM is started
108
+ with `--enable-auto-tool-choice --tool-call-parser gemma4`; the
109
+ server parses Gemma 4's native `<|tool_call|>call:name{args}<tool_call|>`
110
+ markers into OpenAI-spec `tool_calls` JSON. The client passes real
111
+ `tools=[{type:"function", function:{name, description,
112
+ parameters:JSON-schema}}]` on each request and reads
113
+ `response.choices[0].message.tool_calls` directly. If the model
114
+ emits no tool call, `content` is returned as the final answer.
115
+ - **The judge does NOT use tool calling.** Nemotron-3-Nano-4B's chat
116
+ template emits tool calls in a custom XML dialect inside
117
+ `<tool_call>...</tool_call>` markers that vLLM's built-in parsers
118
+ don't recognize. The judge server runs with no tool-calling flags;
119
+ the judge prompt asks for raw JSON in `content`, and `judge.py`
120
+ parses that with Pydantic.
121
+ - **Pydantic judge schema in `schema.py`.** `JudgeVerdict` has five
122
+ fields: `ok` (bool), `issues` (list[str], each capped at 200 chars),
123
+ `score` (float in [0, 1]), `verdict` (Literal["approve", "revise"]),
124
+ `reasoning` (str, capped at 300 chars). Cross-field consistency
125
+ (`ok` ⇔ `verdict`) is enforced in `judge_explanation()` — the model
126
+ is asked to agree, and the code normalizes if it doesn't.
127
+ - **Judge retry-on-failure.** If the first response isn't parseable
128
+ JSON, `judge_explanation()` retries once with a `REPAIR_PROMPT` that
129
+ shows the previous bad response. If both fail, `JudgeFailed` is
130
+ raised and the validate tool falls back to the rule check.
131
+ - **`@app.api` returns a single string.** Gradio Server's `@app.api`
132
+ has no output components, so tuples get dropped silently. The
133
+ handler concatenates the four sections with `\x1f` (Unit Separator)
134
+ and the frontend splits. Don't use `\n` as a separator — body text
135
+ can contain newlines legitimately.
136
+ - **Middleware `@hook_config(can_jump_to=["end"])` is required.**
137
+ Without it, LangGraph never creates the conditional edge and the
138
+ early-exit silently does nothing.
139
+ - **Modal uses CUDA devel image.** The `nvidia/cuda:12.9.0-devel-ubuntu22.04`
140
+ base provides nvcc, which vLLM/FlashInfer need. `debian_slim` crashes
141
+ during vLLM startup.
142
+ - **Drafter flag `--language-model-only` is required.** Gemma 4's
143
+ multimodal processor pulls heavy deps and crashes the vLLM server
144
+ on text-only requests. This flag tells vLLM to skip processor init.
145
+ The judge (Nemotron-Nano-4B) is text-only and does NOT need this
146
+ flag.
147
+ - **Gemma 4 E4B is multimodal — it can take audio input.** This
148
+ matters in two ways:
149
+ 1. `--language-model-only` is correct **today** because Fabella's
150
+ drafter only ever receives text. If you later add a feature
151
+ where the parent records a 30s voice memo and the drafter
152
+ transcribes it (Whisper-style), the vLLM flag will need to
153
+ change to support audio inputs. The model supports it natively.
154
+ 2. The audio side of Gemma 4 is a *separate* path from the
155
+ VoxCPM2 TTS endpoint. They are independent: VoxCPM2 reads
156
+ text and produces 48 kHz audio; Gemma 4 could (if enabled)
157
+ read audio and produce text. Don't conflate them when
158
+ debugging.
159
+ - **All A10Gs are independent.** Modal `scaledown_window=10` minutes
160
+ on each. The first request after idle triggers a ~2 min cold start
161
+ per LLM container. TTS cold-starts only after **Read aloud** is clicked.
162
+ - **VoxCPM2 TTS is not vLLM.** `serve_tts` writes a generated FastAPI
163
+ server into the container and runs `uvicorn`. It returns `audio/wav`
164
+ from `/synthesize`; `app.py::make_audio` converts that to a base64
165
+ data URL for the browser.
166
+ - **`FABELLA_MODEL_PATH` env var** is no longer consulted on the
167
+ deployed path. Modal's `download_drafter` hardcodes
168
+ `google/gemma-4-E4B-it` (Apache 2.0, not gated). Do not swap to
169
+ `gemma-3-4b-it` (gated — would break the no-API-key rule).
170
 
171
  ## When editing
172
 
173
+ - Adding a new tone preset → add to `TONE_CHOICES` in `app.py`
174
+ (gentle / matter-of-fact / playful is the current set).
175
+ - Adding an example situation add to `EXAMPLE_SITUATIONS` in
176
+ `app.py`. They appear as one-click chips on the left column.
177
+ - Changing the drafter's tool set → edit `make_validate_tool` in
178
+ `agent.py` (it builds the tool closure per request).
179
+ - Changing the judge's rubric → edit `judge.py::_build_rubric` and the
180
+ `SYSTEM_PROMPT` in the same file. The output schema is in
181
+ `schema.py::JudgeVerdict` — change both.
182
+ - Changing the agent's max tool calls → pass
183
+ `FabellaAgentMiddleware(max_tool_calls=N)` to `create_agent` in
184
+ `agent.py::build_agent`.
185
+ - Adding a new example chip → add to `EXAMPLE_SITUATIONS` in
186
+ `app.py`.
187
+ - History, accounts, image upload are **out of scope** unless reopened.
188
 
189
  ## Deployment
190
 
191
+ ```bash
192
+ # Modal: download weights (run once per model)
193
+ .venv/bin/modal run modal_app.py::download_drafter
194
+ .venv/bin/modal run modal_app.py::download_judge
195
+ .venv/bin/modal run modal_app.py::download_tts
196
+
197
+ # Modal: deploy (rebuilds image, rolls out both web_servers)
198
+ .venv/bin/modal deploy modal_app.py
199
+
200
+ # HF Space: env vars
201
+ hf spaces variables add build-small-hackathon/Fabella \
202
+ --env MODAL_DRAFTER_URL=https://khoitruong071510--fabella-serve-drafter.modal.run
203
+ hf spaces variables add build-small-hackathon/Fabella \
204
+ --env MODAL_JUDGE_URL=https://khoitruong071510--fabella-serve-judge.modal.run
205
+ hf spaces variables add build-small-hackathon/Fabella \
206
+ --env MODAL_TTS_URL=https://khoitruong071510--fabella-serve-tts.modal.run
207
+
208
+ # HF Space: upload code
209
+ hf upload build-small-hackathon/Fabella app.py --type space
210
+ hf upload build-small-hackathon/Fabella agent.py --type space
211
+ hf upload build-small-hackathon/Fabella judge.py --type space
212
+ hf upload build-small-hackathon/Fabella llm.py --type space
213
+ hf upload build-small-hackathon/Fabella schema.py --type space
214
+ hf upload build-small-hackathon/Fabella safety.py --type space
215
+ hf upload build-small-hackathon/Fabella requirements.txt --type space
216
+
217
+ # HF Space: restart to pick up new code
218
+ hf spaces restart build-small-hackathon/Fabella
219
+ ```
HANDOFF.md ADDED
@@ -0,0 +1,278 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Fabella Handoff — Explanation + Read-Aloud Pipeline on Modal
2
+
3
+ ## Context
4
+
5
+ Fabella is a Gradio children's-storytelling app in `/home/khoi/fabella`,
6
+ now **pivoted to Track I · Backyard AI** ("useful for someone the maker
7
+ actually knows"). It solves a specific real problem parents face: **how
8
+ do I explain a hard thing to my kid in their own language?**
9
+
10
+ The parent describes a situation in a sentence or two. Fabella drafts a
11
+ short, kind, age-appropriate explanation in an Opener / Body / Closer /
12
+ follow-up shape. A second small model checks the draft against a
13
+ 6-criterion rubric before the parent sees it. After generation, the
14
+ parent can optionally click **Read aloud** to synthesize the explanation
15
+ with VoxCPM2.
16
+
17
+ **Live URLs:**
18
+
19
+ - HF Space: https://build-small-hackathon-fabella.hf.space
20
+ - Modal drafter (Gemma 4 E4B-IT): https://khoitruong071510--fabella-serve-drafter.modal.run
21
+ - Modal judge (Nemotron-3 Nano 4B): https://khoitruong071510--fabella-serve-judge.modal.run
22
+ - Modal TTS (VoxCPM2): https://khoitruong071510--fabella-serve-tts.modal.run
23
+ - Modal app: https://modal.com/apps/khoitruong071510/main/deployed/fabella
24
+ - HF Space repo: https://huggingface.co/spaces/build-small-hackathon/Fabella
25
+
26
+ ## Current Architecture
27
+
28
+ ```
29
+ HF Space (CPU, custom HTML+CSS+JS)
30
+ |
31
+ | POST /gradio_api/call/make_explanation
32
+ | -> SSE stream with 4-section string
33
+ v
34
+ app.py (gradio.Server / FastAPI)
35
+ |
36
+ +--------------------+--------------------+--------------------+
37
+ | | |
38
+ v v v
39
+ Modal drafter (A10G, Gemma 4 E4B-IT) Modal judge (A10G, Nemotron-3) Modal TTS (A10G, VoxCPM2)
40
+ --tool-call-parser gemma4 (no tool-calling flags) FastAPI /synthesize
41
+ ReAct via LangChain Pydantic JSON verdict audio/wav when requested
42
+ validate_explanation tool one invoke + one repair retry
43
+ middleware jumps to "end" on OK
44
+ ```
45
+
46
+ **Key design decisions:**
47
+
48
+ - **Drafter on LangGraph.** The drafter is a `create_agent` ReAct loop
49
+ with one tool (`validate_explanation`) and a custom middleware that
50
+ jumps to `end` after a successful validation or after a hard cap of
51
+ two tool calls. State machine, conditional edges, tool-call plumbing
52
+ — that's LangGraph's job, and it works.
53
+ - **Judge on Pydantic.** The judge task is bounded — one rubric, one
54
+ draft, one structured verdict — so it doesn't need an agent loop.
55
+ Single `llm.invoke()` + `JudgeVerdict.model_validate_json()` + one
56
+ repair retry. Cross-field consistency (`ok` ⇔ `verdict`) is enforced
57
+ in code, not in the prompt.
58
+ - **Three separate Modal web_servers, three A10Gs.** Drafter, judge,
59
+ and TTS don't share a GPU. Independent scaling, independent
60
+ `scaledown_window`.
61
+ - **TTS only runs on demand.** VoxCPM2 is a separate FastAPI wrapper,
62
+ not vLLM. The HF Space `make_audio` API posts explanation text to
63
+ `/synthesize`, receives `audio/wav`, and returns a base64 data URL to
64
+ the browser.
65
+ - **The judge has NO tool-calling flags on the server side.** Its
66
+ prompt asks for raw JSON in `content`; the Pydantic parser does the
67
+ rest. This dodges Nemotron-3-Nano's chat-template tool-dialect
68
+ (`<tool_call>...</tool_call>` markers that vLLM's `hermes` parser
69
+ doesn't recognize) entirely.
70
+ - **The drafter DOES use tool calling.** vLLM is launched with
71
+ `--enable-auto-tool-choice --tool-call-parser gemma4`; the server
72
+ parses Gemma 4's `<|tool_call|>...<tool_call|>` markers into
73
+ OpenAI-spec `tool_calls` JSON, which the client reads off
74
+ `response.choices[0].message.tool_calls` directly.
75
+ - **HF Space runs a custom `gradio.Server` (FastAPI subclass).** No
76
+ default Gradio chrome. Storybook design, single hand-coded page.
77
+ - **API contract for the frontend:** the @app.api endpoint returns one
78
+ string with sections joined by U+001F (Unit Separator) — Opener,
79
+ Body, Closer, Follow-up. The frontend splits on that. (Gradio Server
80
+ `@app.api` has no output components, so tuples get dropped — single
81
+ string is the simplest workaround.)
82
+
83
+ ## File Map
84
+
85
+ | File | Purpose |
86
+ |------|---------|
87
+ | `app.py` | `gradio.Server` (FastAPI subclass) app, custom HTML+CSS+JS, `make_explanation` API, `make_audio` TTS proxy, no-op `@spaces.GPU` placeholder for HF runtime |
88
+ | `agent.py` | LangChain ReAct agent. `build_agent(llm, req, judge_llm=None)` returns `(agent, user_prompt)`. `make_validate_tool` builds a closure that calls `judge_explanation()` if a judge is given, else falls back to a rule check. `FabellaAgentMiddleware.before_model` jumps to `end` once validation passes or after `max_tool_calls=2`. `extract_explanation(messages)` parses Opener/Body/Closer/follow-up sections from the validated tool-call draft. |
89
+ | `judge.py` | Pydantic-validated judge. `judge_explanation(llm, draft, req_age, req_tone, child_name, situation) -> JudgeVerdict`. Two attempts (original + repair prompt) before raising `JudgeFailed` for fallback. Tolerant of markdown fences and pretty-printed JSON. |
90
+ | `schema.py` | `ExplainRequest` dataclass + `JudgeVerdict` Pydantic model + `JudgeFailed` exception. |
91
+ | `safety.py` | Input sanitization, profanity block, `sanitize_situation`, `explain_to_words(tone)`, `age_bucket(age)`. |
92
+ | `llm.py` | `FabellaVLLM` BaseChatModel wrapping vLLM's OpenAI-compatible API. `bind_tools` builds OpenAI-spec `tools=[...]`, `_generate` passes it and reads `message.tool_calls` from the response. Replay of prior `AIMessage.tool_calls` and `ToolMessage` results into next-turn messages uses the OpenAI chat-completions shape. |
93
+ | `modal_app.py` | Modal deployment: `download_drafter` + `download_judge` + `download_tts`; `serve_drafter` (port 8000), `serve_judge` (port 8001), `serve_tts` (port 8002). One A10G each. |
94
+ | `modal_app_gemma.py` | Legacy: a previous-session single-model Modal deploy, kept for reference. Not the live deploy. |
95
+
96
+ ## What Changed This Session
97
+
98
+ The most recent session (pivot to Backyard AI) changed:
99
+
100
+ ### New files
101
+ - `judge.py` — Pydantic-validated judge with repair retry
102
+ - `modal_app_gemma.py` — kept as reference for the prior single-model deploy
103
+
104
+ ### Substantially rewritten
105
+ - `agent.py` — story-generation agent replaced with explanation-generation agent. `make_validate_tool` now optionally takes `judge_llm` and routes through `judge_explanation()`. The drafter's output format changed from "Title: / body" to "Opener: / Body: / Closer: / (optional) If they ask more:". `extract_explanation` parses these four sections.
106
+ - `schema.py` — `StoryRequest` replaced with `ExplainRequest` (situation, age, child_name, tone, seed). Added `JudgeVerdict` (Pydantic) and `JudgeFailed`.
107
+ - `safety.py` — `sanitize_situation`, `explain_to_words(tone)`. Legacy theme/moral/length functions kept for compat.
108
+ - `app.py` — frontend redesigned for the "explain a hard thing" use case. New form fields: situation textarea, age slider (5-12), child_name (optional), tone segmented control (gentle / matter-of-fact / playful), example chips. Output is the four sections in a book-page layout with the new "Opener" / "The explanation" / "Closer" / "If they ask another question" tags. Added **Read aloud** with `make_audio` proxy to VoxCPM2.
109
+ - `modal_app.py` — three web_servers in one Modal app. Drafter uses `--tool-call-parser gemma4`; judge uses no tool flags; TTS runs VoxCPM2 behind FastAPI `/synthesize`.
110
+ - `llm.py` — defaults updated to point at the drafter endpoint (`gemma-4` model name).
111
+
112
+ ### Removed earlier
113
+ - `multi_agent.py` — earlier multi-agent design (3 parallel drafters + judge) was reverted
114
+ - `nemotron3_tool_parser.py` — custom XML tool parser for the (also removed) 30B Nemotron path
115
+ - `prompts.py`, `generator.py`, `mock.py`, `real.py` — legacy files
116
+
117
+ ## Non-obvious gotchas
118
+
119
+ - **No-op `@spaces.GPU` in `app.py`.** HF Spaces runtime scans for at
120
+ least one `@spaces.GPU` function at import and raises
121
+ `RUNTIME_ERROR: No @spaces.GPU function detected` if none exists.
122
+ The placeholder is a 1-second no-op. Do not delete it.
123
+ - **`sys.path` hack in every module.** Each file does
124
+ `sys.path.insert(0, os.path.dirname(...))` so imports work when run
125
+ as `python app.py` from the package root. Don't refactor to relative
126
+ imports.
127
+ - **Pydantic disallows `_`-prefixed fields.** In `llm.py`, the
128
+ runtime-mutable state (OpenAI client, tools, call counter) is
129
+ declared with `PrivateAttr`, not `Field`.
130
+ - **Three Modal endpoints, three env vars.** HF Space reads
131
+ `MODAL_DRAFTER_URL`, `MODAL_JUDGE_URL`, and `MODAL_TTS_URL`. The old
132
+ `MODAL_VLLM_URL` is dead — delete it if it's still there.
133
+ - **The drafter uses native tool calling via vLLM.** vLLM is started
134
+ with `--enable-auto-tool-choice --tool-call-parser gemma4`; the
135
+ server parses Gemma 4's native `<|tool_call|>call:name{args}<tool_call|>`
136
+ markers into OpenAI-spec `tool_calls` JSON. The client passes real
137
+ `tools=[{type:"function", function:{name, description,
138
+ parameters:JSON-schema}}]` on each request and reads
139
+ `response.choices[0].message.tool_calls` directly. If the model
140
+ emits no tool call, `content` is returned as the final answer.
141
+ - **The judge does NOT use tool calling.** The chat template for
142
+ Nemotron-3-Nano-4B emits tool calls in a custom XML dialect inside
143
+ `<tool_call>...</tool_call>` markers that vLLM's built-in parsers
144
+ don't recognize. The judge server runs with no tool-calling flags;
145
+ the judge prompt asks for raw JSON in `content`, and `judge.py`
146
+ parses that with Pydantic.
147
+ - **Pydantic judge schema in `schema.py`.** `JudgeVerdict` has five
148
+ fields: `ok` (bool), `issues` (list[str], each capped at 200 chars),
149
+ `score` (float in [0, 1]), `verdict` (Literal["approve", "revise"]),
150
+ `reasoning` (str, capped at 300 chars). Cross-field consistency
151
+ (`ok` ⇔ `verdict`) is enforced in `judge_explanation()` — the model
152
+ is asked to agree, and the code normalizes if it doesn't.
153
+ - **Judge retry-on-failure.** If the first response isn't parseable
154
+ JSON, `judge_explanation()` retries once with a `REPAIR_PROMPT` that
155
+ shows the previous bad response. If both fail, `JudgeFailed` is
156
+ raised and the validate tool falls back to the deterministic rule
157
+ check.
158
+ - **Middleware `@hook_config(can_jump_to=["end"])` is required.**
159
+ Without it, LangGraph never creates the conditional edge and the
160
+ early-exit silently does nothing.
161
+ - **Modal uses CUDA devel image.** The `nvidia/cuda:12.9.0-devel-ubuntu22.04`
162
+ base provides nvcc, which vLLM/FlashInfer need. `debian_slim` crashes
163
+ during vLLM startup.
164
+ - **Drafter flag `--language-model-only` is required.** Gemma 4's
165
+ multimodal processor pulls heavy deps and crashes the vLLM server on
166
+ text-only requests. This flag tells vLLM to skip processor init.
167
+ The judge (Nemotron-Nano-4B) is text-only and does NOT need this
168
+ flag.
169
+ - **Gemma 4 E4B is multimodal — it can take audio input.** This
170
+ matters in two ways:
171
+ 1. `--language-model-only` is correct **today** because Fabella's
172
+ drafter only ever receives text. If you later add a feature
173
+ where the parent records a 30s voice memo and the drafter
174
+ transcribes it (Whisper-style), the vLLM flag will need to
175
+ change to support audio inputs. The model supports it natively.
176
+ 2. The audio side of Gemma 4 is a *separate* path from the
177
+ VoxCPM2 TTS endpoint. They are independent: VoxCPM2 reads
178
+ text and produces 48 kHz audio; Gemma 4 could (if enabled)
179
+ read audio and produce text. Don't conflate them when
180
+ debugging.
181
+ - **All A10Gs are independent.** Modal scaledown_window is 10 minutes
182
+ on each. The first request after idle triggers a ~2 min cold start per
183
+ LLM container. TTS cold-starts only after **Read aloud** is clicked.
184
+ - **VoxCPM2 TTS is not vLLM.** `serve_tts` writes a generated FastAPI
185
+ server into the container and runs `uvicorn --app-dir /root`. It
186
+ returns `audio/wav` from `/synthesize`; `app.py::make_audio` converts
187
+ that to a base64 data URL for the browser.
188
+ - **`section_sep` is U+001F (Unit Separator).** The `@app.api`
189
+ endpoint returns Opener, Body, Closer, Follow-up joined by `\x1f`.
190
+ The frontend splits on it. Don't use `\n` — body text can contain
191
+ newlines legitimately.
192
+ - **`FABELLA_MODEL_PATH` env var** is no longer consulted on the
193
+ deployed path. Modal's `download_drafter` hardcodes
194
+ `google/gemma-4-E4B-it` (Apache 2.0, not gated). Do not swap to
195
+ `gemma-3-4b-it` (gated — would break the no-API-key rule).
196
+
197
+ ## Deployment Commands
198
+
199
+ ```bash
200
+ # Modal: download weights (run once per model)
201
+ .venv/bin/modal run modal_app.py::download_drafter
202
+ .venv/bin/modal run modal_app.py::download_judge
203
+ .venv/bin/modal run modal_app.py::download_tts
204
+
205
+ # Modal: deploy (rebuilds the image, rolls out all web_servers)
206
+ .venv/bin/modal deploy modal_app.py
207
+
208
+ # HF Space: env vars
209
+ hf spaces variables add build-small-hackathon/Fabella \
210
+ --env MODAL_DRAFTER_URL=https://khoitruong071510--fabella-serve-drafter.modal.run
211
+ hf spaces variables add build-small-hackathon/Fabella \
212
+ --env MODAL_JUDGE_URL=https://khoitruong071510--fabella-serve-judge.modal.run
213
+ hf spaces variables add build-small-hackathon/Fabella \
214
+ --env MODAL_TTS_URL=https://khoitruong071510--fabella-serve-tts.modal.run
215
+
216
+ # HF Space: upload code
217
+ hf upload build-small-hackathon/Fabella app.py --type space
218
+ hf upload build-small-hackathon/Fabella agent.py --type space
219
+ hf upload build-small-hackathon/Fabella judge.py --type space
220
+ hf upload build-small-hackathon/Fabella llm.py --type space
221
+ hf upload build-small-hackathon/Fabella schema.py --type space
222
+ hf upload build-small-hackathon/Fabella safety.py --type space
223
+ hf upload build-small-hackathon/Fabella requirements.txt --type space
224
+
225
+ # HF Space: restart to pick up new code
226
+ hf spaces restart build-small-hackathon/Fabella
227
+ ```
228
+
229
+ ## Cost
230
+
231
+ - **Drafter**: 1× A10G, $0.80/hr, 10-min scaledown
232
+ - **Judge**: 1× A10G, $0.80/hr, 10-min scaledown
233
+ - **TTS**: 1× A10G, $0.80/hr, 10-min scaledown, only after **Read aloud**
234
+ - **At idle**: $0/hr (scaledown)
235
+ - **Typical demo session**: a few minutes warm = ~$0.03-0.05
236
+
237
+ ## Known Issues / Open Questions
238
+
239
+ 1. **Cold start latency** — First request after 10 min idle triggers
240
+ vLLM cold start (~2 min per container for model load + torch.compile
241
+ + CUDA graph capture). Both containers cold-start in sequence on
242
+ the first request of a new session. Could add `min_containers=1` to
243
+ each Modal serve() to keep warm (costs ~$1.60/hr idle).
244
+ 2. **No test suite** — No automated tests exist. Manual smoke-tests
245
+ are in this handoff (search "Live test" or "Smoke-test").
246
+ 3. **Judge occasionally emits unparseable thinking-traces.** The
247
+ `judge_explanation()` repair prompt fixes this most of the time.
248
+ When both attempts fail, the validate tool falls back to the
249
+ rule check, so the system never hard-errors. The model is a
250
+ reasoning model; a `--default-chat-template-kwargs '{"enable_thinking":
251
+ false}'` flag could be added to the judge server to make outputs
252
+ shorter, but the retry handles it well enough.
253
+ 4. **Drafter at temperature 0.9** — produces creative variety but the
254
+ judge sometimes rejects a perfectly good draft on style grounds. The
255
+ `seed` UI control lets parents re-roll for variety.
256
+
257
+ ## Suggested Skills
258
+
259
+ - `hf-cli` — Manage HF Space: variables, logs, uploads, restarts
260
+ - `find-docs` — For Modal, vLLM, Gradio, LangChain, Pydantic API
261
+ questions (use ctx7 CLI)
262
+ - `diagnose` — If runtime errors occur (vLLM startup, agent failures,
263
+ judge parsing)
264
+ - `agent-browser` — For end-to-end testing of the live HF Space
265
+ - `handoff` — If handing off again after further work
266
+
267
+ ## Next Steps (if continuing)
268
+
269
+ 1. Add a `min_containers=1` warmup to both Modal serves for zero
270
+ cold-start latency
271
+ 2. Add basic test suite: judge parsing (valid / repair / fallback),
272
+ validate tool, explanation extraction, end-to-end agent with stubs
273
+ 3. Stream the explanation token-by-token as the drafter writes it
274
+ (the API contract would change from one-shot to SSE)
275
+ 4. Cache common patterns (the same situation often comes up — "moving",
276
+ "new baby", "death of grandparent") so warm requests skip the LLM
277
+ 5. Polish the HF Space card README to match the new Backyard AI
278
+ framing before the hackathon submission deadline
README.md CHANGED
@@ -1,63 +1,96 @@
1
  ---
2
  title: Fabella
3
  emoji: 📖
4
- colorFrom: pink
5
  colorTo: yellow
6
  sdk: gradio
7
- sdk_version: 4.44.0
8
  app_file: app.py
9
- pinned: false
 
 
10
  ---
11
 
12
  # Fabella
13
 
14
- A personalized storytelling companion for children. Tell Fabella your child's name, age, favorite themes, and a moral Fabella writes a warm, age-appropriate story just for them.
15
 
16
- ## What it does
17
 
18
- - Generates a short, personalized children's story
19
- - Matches reading level to the child's age (6–10)
20
- - Weaves in their favorite themes
21
- - Resolves a clear moral at the end
22
- - Works on first run with **no API key** (mock mode)
23
 
24
- ## Run locally
25
 
26
- ```bash
27
- uv venv .venv
28
- source .venv/bin/activate
29
- uv pip install -r requirements.txt
30
- python app.py
31
- ```
 
 
 
 
 
 
 
32
 
33
- Open the URL printed in the terminal (default `http://localhost:7860`).
34
 
35
- ## Run on Hugging Face Spaces
36
 
37
- This repo is structured to push directly to a HF Space:
38
 
39
- 1. Create a new Space **Gradio** SDK
40
- 2. Push these files
41
- 3. The Space builds and serves the app
 
42
 
43
- No secrets are required for the first run. The mock generator is fully active by default.
44
 
45
- ## Modes
 
 
 
46
 
47
- - **Mock mode** (default): template-based, deterministic, zero GPU. Perfect for demos and offline use.
48
- - **Real model mode**: set `USE_MOCK=false` in the Space's environment variables to route to [`google/gemma-4-E4B-it`](https://huggingface.co/google/gemma-4-E4B-it) on ZeroGPU. Open (Apache 2.0), no API key required. Override the model with `FABELLA_MODEL_ID`.
49
 
50
- ## Phase 1 scope
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
 
52
- This is the first vertical slice. It includes:
53
 
54
- - Gradio Blocks UI with form, regenerate button, mode badge
55
- - Template-based mock story generator
56
- - Input sanitization and profanity guard
57
- - Real-model path wired (`@spaces.GPU`) but deferred for polish
58
 
59
- Out of scope for Phase 1: PDF export, user accounts, story history, audio narration, image generation.
 
 
 
60
 
61
- ## Build Small
62
 
63
- Fabella is intentionally tiny: a single Gradio app, one mock generator, one optional real-model call, no database, no auth, no orchestration. The goal is a child who finishes a story and asks for another.
 
1
  ---
2
  title: Fabella
3
  emoji: 📖
4
+ colorFrom: green
5
  colorTo: yellow
6
  sdk: gradio
7
+ sdk_version: 6.18.0
8
  app_file: app.py
9
+ pinned: true
10
+ license: apache-2.0
11
+ short_description: Small words for big questions.
12
  ---
13
 
14
  # Fabella
15
 
16
+ **Small words for big questions.** Tell Fabella what's going on in a sentence or two. She drafts a short, kind, age-appropriate explanation you can read aloud &mdash; a second small model checks it against a six-criterion rubric before you see it.
17
 
18
+ Built for the [Build Small Hackathon](https://huggingface.co/spaces/build-small-hackathon/README) &middot; **Track I &middot; Backyard AI.**
19
 
20
+ [Live demo](https://build-small-hackathon-fabella.hf.space) &middot; [Modal app](https://modal.com/apps/khoitruong071510/main/deployed/fabella) &middot; [HF Space repo](https://huggingface.co/spaces/build-small-hackathon/Fabella)
 
 
 
 
21
 
22
+ ## What it solves
23
 
24
+ Parents have to explain hard things &mdash; a parent's hospitalization, a house move, a pet dying, a refusal to buy a phone &mdash; to kids who don't have the vocabulary for what's happening. Most of the time, we end up improvising at 9pm and getting it half-right.
25
+
26
+ Fabella is a second pair of eyes. You describe the situation in a sentence or two; the app drafts an explanation in the **Opener &rarr; Body &rarr; Closer &rarr; optional "if they ask more"** shape, then a second small model checks it for: opener/body/closer present, body length, age-appropriate vocabulary, no moralizing, no scary content, no invented facts.
27
+
28
+ You read it. If it's good, you can read it yourself or click **Read aloud** for VoxCPM2 narration. If it's not, you click **New version** for a fresh draft.
29
+
30
+ ## The two-model pipeline
31
+
32
+ | Layer | Model | Why this model | Why this execution |
33
+ |---|---|---|---|
34
+ | **Drafter** | `google/gemma-4-E4B-it` (4B) on Modal A10G | Fast, smart enough for short empathetic text, Apache 2.0 | **LangGraph ReAct** &mdash; needs the state machine (draft &rarr; validate &rarr; revise &rarr; end) with tool calling and middleware-driven early exit |
35
+ | **Judge** | `nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16` (4B) on Modal A10G | Fast, follows structured-output instructions reliably | **Pydantic** + single LLM call + one repair retry &mdash; the task is bounded, no agent loop needed |
36
+ | **Read-aloud** | `openbmb/VoxCPM2` (~2B) on Modal A10G | Apache 2.0, 48 kHz speech, good voice-description control | Separate FastAPI server, only called after the user clicks **Read aloud** |
37
 
38
+ The split is deliberate. The drafter needs agentic machinery (tool calls, conditional edges, jump-to-end). The judge doesn't &mdash; it's "receive a rubric + a draft, return a structured verdict." Pydantic gives us disciplined output, type safety, and a one-shot repair retry. The two layers stay in their own files: `agent.py` for the LangGraph loop, `judge.py` for the Pydantic validator.
39
 
40
+ ## Output shape
41
 
42
+ Every result is a four-section explanation:
43
 
44
+ - **Opener** &mdash; one sentence the parent can say to start the conversation
45
+ - **Body** &mdash; 1-3 short paragraphs, written in the second person, age-appropriate, no moralizing
46
+ - **Closer** &mdash; one sentence to land the conversation
47
+ - **If they ask another question** &mdash; an optional follow-up the parent can use
48
 
49
+ For example, given the situation *"My 7-year-old's grandma is in the hospital for surgery. She keeps asking when grandma is coming home."* and tone *gentle*, the app returns something like:
50
 
51
+ > **Opener:** I want to talk to you about Grandma.
52
+ > **Body:** Grandma is in the hospital right now. She is having a little surgery. The doctors are taking good care of her. It is a part of getting her better. The doctors are very kind and they know just what to do.
53
+ > **Closer:** We are all hoping she comes home very soon.
54
+ > **If they ask more:** We can wait together and find out what the doctors say.
55
 
56
+ ## The stack
 
57
 
58
+ - **HF Space** &mdash; custom HTML+CSS+JS frontend served by `gradio.Server` (FastAPI subclass). Storybook-modernist design, no default Gradio chrome.
59
+ - **Modal** &mdash; three containers in one app. Drafter (Gemma 4 E4B-IT) runs with `--enable-auto-tool-choice --tool-call-parser gemma4 --language-model-only` (text-only today; the model itself is multimodal and could take audio input if we add a voice-memo feature later). Judge runs with no tool-calling flags. TTS (VoxCPM2) is a small FastAPI wrapper around the official `voxcpm` library. All three A10G, 10-min scaledown.
60
+ - **LangChain 1.x** ReAct loop with a custom middleware that jumps to `end` after a successful validation or after a hard cap of two tool calls.
61
+ - **Pydantic v2** for the judge's structured output.
62
+
63
+ ## Files
64
+
65
+ - `app.py` &mdash; `gradio.Server` app, custom HTML+CSS+JS, `@app.api()` endpoint, no-op `@spaces.GPU` placeholder for HF runtime
66
+ - `agent.py` &mdash; LangChain ReAct drafter, `validate_explanation` tool, middleware
67
+ - `judge.py` &mdash; Pydantic-validated judge with one repair retry
68
+ - `schema.py` &mdash; `ExplainRequest` dataclass + `JudgeVerdict` Pydantic model + `JudgeFailed` exception
69
+ - `llm.py` &mdash; `FabellaVLLM` BaseChatModel wrapping vLLM's OpenAI-compatible API
70
+ - `modal_app.py` &mdash; Modal deployment (drafter + judge + VoxCPM2 TTS on separate A10Gs)
71
+ - `safety.py` &mdash; input sanitization, profanity block, `explain_to_words(tone)`
72
+
73
+ ## Run locally
74
+
75
+ ```bash
76
+ uv venv .venv
77
+ source .venv/bin/activate
78
+ uv pip install -r requirements.txt
79
+ export MODAL_DRAFTER_URL=https://khoitruong071510--fabella-serve-drafter.modal.run
80
+ export MODAL_JUDGE_URL=https://khoitruong071510--fabella-serve-judge.modal.run
81
+ export MODAL_TTS_URL=https://khoitruong071510--fabella-serve-tts.modal.run
82
+ python app.py
83
+ ```
84
 
85
+ The frontend runs on CPU locally. The two LLM Modal containers cold-start in ~2 min each on the first request of a new session; the TTS container cold-starts separately only when **Read aloud** is clicked.
86
 
87
+ ## Constraints honored
 
 
 
88
 
89
+ - 32B params &middot; both models are 4B
90
+ - Gradio app &middot; hosted as a HF Space
91
+ - No API key needed for the models &middot; Modal credits only
92
+ - No database, no auth, no orchestration
93
 
94
+ ## Why "small"
95
 
96
+ Two small models, one tool, one page, one rubric. The whole product is a form on the left, a book page on the right, and a few seconds of waiting. The point of Fabella isn't to replace the parent's voice &mdash; it's to give the parent a second opinion at the moment they need one, in language the kid can hear.
agent.py ADDED
@@ -0,0 +1,369 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Fabella — small words for big questions.
2
+
3
+ The agent has one tool, validate_explanation, which sends a draft to the
4
+ small Nemotron judge for a multi-criteria review. The ReAct loop:
5
+
6
+ 1. Read the parent's situation and the child's age.
7
+ 2. Draft a short, kind, concrete explanation. Call validate_explanation.
8
+ 3. If OK, jump to "end" (we extract the draft from the last tool-call args).
9
+ 4. If issues, model revises and calls validate_explanation again.
10
+ 5. After at most 2 tool calls, force a final answer.
11
+ """
12
+
13
+ import json
14
+ import os
15
+ import re
16
+ import sys
17
+
18
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
19
+
20
+ from langchain.agents import create_agent
21
+ from langchain.agents.middleware import AgentMiddleware, AgentState, hook_config
22
+ from langchain.tools import tool
23
+ from langgraph.runtime import Runtime
24
+
25
+ from safety import age_bucket, explain_to_words
26
+ from schema import ExplainRequest
27
+
28
+
29
+ SYSTEM_PROMPT = """You are Fabella, a kind helper for parents who need to explain
30
+ hard things to their child. A parent has just told you about a real
31
+ situation they're facing, and you've written a short, gentle
32
+ explanation the parent can read aloud.
33
+
34
+ Output shape (always exactly this, no markdown, no extra sections):
35
+
36
+ Opener: <one short sentence the parent can say to start the conversation>
37
+ Body: <1-3 short paragraphs, written in the second person ("you" / "your
38
+ child"), concrete and warm. About 60-130 words total for the body.>
39
+ Closer: <one short sentence the parent can say to land the conversation>
40
+ If they ask more: <one optional follow-up sentence the parent can use if
41
+ the child has another question>
42
+
43
+ Strict rules:
44
+ - Never use scary imagery, threats, or vivid descriptions of harm.
45
+ - Never moralize, sermonize, or lecture. ("You should always…")
46
+ - Never promise things that aren't true ("It'll all be fine" — only if
47
+ the parent's situation actually supports that).
48
+ - Never invent facts. If you don't know, say "I don't know" or
49
+ acknowledge the uncertainty in kid-appropriate language.
50
+ - Use the child's age to pick vocabulary and sentence length. For
51
+ young kids (5-7), very short sentences, no abstract words. For
52
+ older kids (8-12), you can be a little more direct.
53
+ - Address the child by name if one was provided.
54
+ - The opener and closer should sound like something a real parent
55
+ would actually say out loud. Not therapist-speak. Not corporate.
56
+
57
+ Workflow:
58
+ 1. Draft the explanation. Start with "Opener:", then "Body:", "Closer:",
59
+ and "If they ask more:" (the last is optional — write it if it
60
+ helps, omit it if there's nothing useful to add).
61
+ 2. Call the validate_explanation tool with the FULL draft text.
62
+ 3. If the tool says "OK", output the final draft. If the tool reports
63
+ issues, revise and call validate_explanation again. After 2 tool
64
+ calls, output your best draft anyway.
65
+ """
66
+
67
+
68
+ def _word_count(text: str) -> int:
69
+ return len(re.findall(r"\b\w+\b", text or ""))
70
+
71
+
72
+ def _strip_prefix(line: str) -> str:
73
+ """Strip a leading 'Opener:' / 'Body:' / etc. label from a line."""
74
+ return re.sub(r"^(Opener|Body|Closer|If they ask more)\s*:\s*", "", line, flags=re.IGNORECASE).strip()
75
+
76
+
77
+ def _parse_sections(draft: str) -> dict[str, str]:
78
+ """Split the draft into Opener / Body / Closer / Follow-up sections.
79
+
80
+ Tolerates the model writing 'Opener' on its own line OR inline.
81
+ """
82
+ out = {"opener": "", "body": "", "closer": "", "followup": ""}
83
+ if not draft:
84
+ return out
85
+
86
+ # Find label positions
87
+ label_re = re.compile(r"^(Opener|Body|Closer|If they ask more)\s*:", re.IGNORECASE | re.MULTILINE)
88
+ matches = list(label_re.finditer(draft))
89
+ if not matches:
90
+ # No labels at all — treat the whole draft as the body
91
+ out["body"] = draft.strip()
92
+ return out
93
+
94
+ for i, m in enumerate(matches):
95
+ label = m.group(1).lower()
96
+ if label == "if they ask more":
97
+ key = "followup"
98
+ else:
99
+ key = label
100
+ # body content starts after the label, ends at the next label (or end)
101
+ start = m.end()
102
+ end = matches[i + 1].start() if i + 1 < len(matches) else len(draft)
103
+ content = draft[start:end].strip()
104
+ out[key] = content
105
+ return out
106
+
107
+
108
+ def make_validate_tool(
109
+ req_age: int,
110
+ req_tone: str,
111
+ judge_llm=None,
112
+ child_name: str = "",
113
+ situation: str = "",
114
+ ):
115
+ """Closure over the request fields so the tool has them at call time.
116
+
117
+ If a `judge_llm` is provided, the tool sends the draft to the judge
118
+ (see `judge.py`) for a Pydantic-validated multi-criteria review.
119
+ Otherwise it falls back to a deterministic rule check.
120
+ """
121
+ min_w, max_w = explain_to_words(req_tone)
122
+ bucket = age_bucket(req_age)
123
+
124
+ def _judge(draft: str) -> str:
125
+ assert judge_llm is not None
126
+ try:
127
+ from judge import judge_explanation, JudgeFailed
128
+ verdict = judge_explanation(
129
+ llm=judge_llm,
130
+ draft=draft,
131
+ req_age=req_age,
132
+ req_tone=req_tone,
133
+ child_name=child_name,
134
+ situation=situation,
135
+ )
136
+ except JudgeFailed as e:
137
+ print(f"[validate_explanation] judge failed after retry: {e}; falling back", flush=True)
138
+ return _rule_based_check(draft)
139
+ except Exception as e:
140
+ print(f"[validate_explanation] judge call failed: {type(e).__name__}: {e}", flush=True)
141
+ return _rule_based_check(draft)
142
+
143
+ if verdict.ok and verdict.verdict == "approve":
144
+ return "OK"
145
+ if verdict.issues:
146
+ return "Issues: " + " ".join(verdict.issues)
147
+ # ok=false but no concrete issues — be safe, revise
148
+ return "Issues: " + (verdict.reasoning or "The draft does not meet the rubric.")
149
+
150
+ def _rule_based_check(draft: str) -> str:
151
+ issues = []
152
+ sections = _parse_sections(draft)
153
+ if not sections["opener"]:
154
+ issues.append("Missing the 'Opener:' line.")
155
+ if not sections["body"]:
156
+ issues.append("Missing the 'Body:' section.")
157
+ if not sections["closer"]:
158
+ issues.append("Missing the 'Closer:' line.")
159
+ body_words = _word_count(sections["body"])
160
+ if body_words < min_w:
161
+ issues.append(f"Body too short ({body_words} words; minimum {min_w}).")
162
+ elif body_words > max_w:
163
+ issues.append(f"Body too long ({body_words} words; maximum {max_w}).")
164
+ # Light moralizing / lecturing detection
165
+ bad_phrases = ["you should always", "you must always", "remember to", "it's important to", "the lesson here is"]
166
+ body_lower = sections["body"].lower()
167
+ for p in bad_phrases:
168
+ if p in body_lower:
169
+ issues.append(f"Avoid lecturing. Body contains a phrase like '{p}'.")
170
+ if not issues:
171
+ return "OK"
172
+ return "Issues: " + " ".join(issues)
173
+
174
+ @tool
175
+ def validate_explanation(draft: str) -> str:
176
+ """Validate an explanation draft against the request.
177
+
178
+ When a judge model is available, the judge does a multi-criteria
179
+ review (opener/body/closer present, length, tone, no moralizing,
180
+ age-appropriateness). Otherwise a deterministic rule check is used.
181
+
182
+ Args:
183
+ draft: The full draft text. Must include 'Opener:', 'Body:',
184
+ and 'Closer:' labels.
185
+
186
+ Returns:
187
+ 'OK' if the draft passes. Otherwise a short report listing
188
+ the issues to fix.
189
+ """
190
+ if judge_llm is not None:
191
+ return _judge(draft)
192
+ return _rule_based_check(draft)
193
+
194
+ return validate_explanation
195
+
196
+
197
+ def _parse_judge_json(text: str) -> dict | None:
198
+ """Tolerate markdown fences, leading prose, and pretty-printed JSON.
199
+
200
+ Kept for backward-compat; the new judge module uses Pydantic and
201
+ does not use this helper. Tests and legacy callers can still use it.
202
+ """
203
+ if not text:
204
+ return None
205
+ t = text.strip()
206
+ if t.startswith("```"):
207
+ t = re.sub(r"^```(?:json)?\s*", "", t)
208
+ t = re.sub(r"\s*```\s*$", "", t)
209
+ i, j = t.find("{"), t.rfind("}")
210
+ if i < 0 or j < 0 or j <= i:
211
+ return None
212
+ candidate = t[i : j + 1]
213
+ try:
214
+ return json.loads(candidate)
215
+ except Exception:
216
+ return None
217
+
218
+
219
+ class FabellaAgentMiddleware(AgentMiddleware):
220
+ """Ends the ReAct loop once the explanation has been validated, and
221
+ forces a final answer after a small hard ceiling of iterations.
222
+
223
+ The @hook_config(can_jump_to=["end"]) decorator is required — without
224
+ it, LangGraph never creates the conditional edge and the early-exit
225
+ silently does nothing.
226
+ """
227
+
228
+ def __init__(self, max_tool_calls: int = 2):
229
+ super().__init__()
230
+ self.max_tool_calls = max_tool_calls
231
+
232
+ @hook_config(can_jump_to=["end"])
233
+ def before_model(self, state: AgentState, runtime: Runtime):
234
+ from langchain.messages import ToolMessage
235
+
236
+ tool_calls = [m for m in state.get("messages", []) if isinstance(m, ToolMessage)]
237
+ last_tool = tool_calls[-1] if tool_calls else None
238
+
239
+ if last_tool is not None and last_tool.content.strip() == "OK":
240
+ print(f"[middleware] tool OK on call {len(tool_calls)}; jumping to end", flush=True)
241
+ return {"jump_to": "end"}
242
+
243
+ if len(tool_calls) >= self.max_tool_calls:
244
+ print(f"[middleware] hit max tool calls ({self.max_tool_calls}); jumping to end", flush=True)
245
+ return {"jump_to": "end"}
246
+
247
+ return None
248
+
249
+
250
+ def _build_user_prompt(req) -> str:
251
+ """Build the parent's request as a user-prompt for the drafter."""
252
+ bucket = age_bucket(req.age)
253
+ vocab = {
254
+ "young": "very simple sentences (under 12 words each), short paragraphs, no abstract words",
255
+ "middle": "clear sentences, concrete metaphors are fine",
256
+ "older": "richer vocabulary is fine, but keep it direct",
257
+ }[bucket]
258
+ name_hint = f"The child's name is '{req.child_name}'. Use it naturally once." if req.child_name else "No name was given. Address the parent ('your child') or use 'you'."
259
+ return (
260
+ f"A parent needs help explaining a hard thing to their child.\n\n"
261
+ f"The situation: {req.situation}\n\n"
262
+ f"The child is {req.age} years old ({bucket} reader).\n"
263
+ f"{name_hint}\n"
264
+ f"Tone: {req.tone}. Vocabulary: {vocab}.\n\n"
265
+ f"Draft a short explanation in the Opener / Body / Closer / "
266
+ f"(optional) If-they-ask-more shape. Then call "
267
+ f"validate_explanation on the full draft."
268
+ )
269
+
270
+
271
+ def build_agent(llm, req, judge_llm=None):
272
+ """Build a one-shot ReAct agent bound to a specific request's tools.
273
+
274
+ If `judge_llm` is provided, the validate_explanation tool sends the
275
+ draft to the judge for a multi-criteria review. Otherwise the tool
276
+ falls back to a deterministic rule check.
277
+ """
278
+ validate = make_validate_tool(
279
+ req_age=req.age,
280
+ req_tone=req.tone,
281
+ judge_llm=judge_llm,
282
+ child_name=req.child_name,
283
+ situation=req.situation,
284
+ )
285
+ agent = create_agent(
286
+ model=llm,
287
+ tools=[validate],
288
+ system_prompt=SYSTEM_PROMPT,
289
+ middleware=[FabellaAgentMiddleware(max_tool_calls=2)],
290
+ )
291
+ return agent, _build_user_prompt(req)
292
+
293
+
294
+ def run_agent(llm, req, judge_llm=None) -> dict:
295
+ """Run the agent. Return a dict with the parsed explanation.
296
+
297
+ The result dict has keys: opener, body, closer, followup, raw.
298
+ """
299
+ print(
300
+ f"[agent] building for age={req.age} tone={req.tone} judge={'yes' if judge_llm else 'no'}",
301
+ flush=True,
302
+ )
303
+ agent, user_prompt = build_agent(llm, req, judge_llm=judge_llm)
304
+ print(f"[agent] invoking", flush=True)
305
+ try:
306
+ result = agent.invoke(
307
+ {"messages": [{"role": "user", "content": user_prompt}]},
308
+ {"recursion_limit": 12},
309
+ )
310
+ except Exception as e:
311
+ print(f"[agent] invoke error: {type(e).__name__}: {e}", flush=True)
312
+ return {
313
+ "opener": "Fabella (agent error)",
314
+ "body": f"_Agent loop failed: {type(e).__name__}: {e}_",
315
+ "closer": "",
316
+ "followup": "",
317
+ "raw": "",
318
+ }
319
+ print(f"[agent] invoke complete", flush=True)
320
+ msgs = result.get("messages", []) if isinstance(result, dict) else []
321
+ print(f"[agent] {len(msgs)} messages in trace", flush=True)
322
+ return extract_explanation(msgs)
323
+
324
+
325
+ def extract_explanation(messages) -> dict:
326
+ """Pull the latest validated draft from the agent's tool-call trace.
327
+
328
+ Order of preference:
329
+ 1. The `draft` argument of the last validate_explanation tool call
330
+ whose paired ToolMessage returned "OK".
331
+ 2. The content of the last AI message (the model's free-form final).
332
+ 3. The rule-based fallback's last surface text.
333
+ """
334
+ tool_results = _tool_results_by_id(messages)
335
+
336
+ # 1. Last validated tool-call draft
337
+ for msg in reversed(messages):
338
+ if getattr(msg, "type", "") == "ai" and getattr(msg, "tool_calls", None):
339
+ for tc in reversed(msg.tool_calls):
340
+ call_id = tc.get("id") if isinstance(tc, dict) else None
341
+ if (tool_results.get(call_id) or "").strip() != "OK":
342
+ continue
343
+ args = tc.get("args") if isinstance(tc, dict) else {}
344
+ draft = (args or {}).get("draft") if isinstance(args, dict) else None
345
+ if draft:
346
+ sections = _parse_sections(draft)
347
+ return {**sections, "raw": draft}
348
+
349
+ # 2. Last AI message with content (the model wrote a final answer
350
+ # after the validate_explanation call, without re-emitting the tool args)
351
+ for msg in reversed(messages):
352
+ if getattr(msg, "type", "") == "ai" and msg.content:
353
+ text = msg.content if isinstance(msg.content, str) else str(msg.content)
354
+ if text.strip():
355
+ sections = _parse_sections(text)
356
+ return {**sections, "raw": text}
357
+
358
+ return {"opener": "", "body": "", "closer": "", "followup": "", "raw": ""}
359
+
360
+
361
+ def _tool_results_by_id(messages) -> dict:
362
+ results = {}
363
+ for m in messages:
364
+ if getattr(m, "type", "") == "tool":
365
+ call_id = getattr(m, "tool_call_id", None)
366
+ if call_id:
367
+ content = m.content if isinstance(m.content, str) else str(m.content)
368
+ results[call_id] = content
369
+ return results
app.py CHANGED
@@ -1,149 +1,1582 @@
1
- """Fabella — personalized storytelling companion. Gradio Blocks UI."""
 
 
 
 
 
 
 
 
 
2
 
3
  import os
4
  import sys
 
 
 
 
 
 
5
 
6
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
7
 
8
- import gradio as gr
9
 
10
- from generator import USE_MOCK, generate_story
11
- from schema import StoryRequest
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  from safety import (
13
  has_profanity,
14
- sanitize_moral,
15
  sanitize_name,
16
- sanitize_themes,
17
  )
18
 
19
- THEME_CHOICES = [
20
- "dinosaurs",
21
- "robots",
22
- "space",
23
- "fantasy",
24
- "adventure",
25
- "animals",
26
- "ocean",
27
- "friends",
28
- ]
 
 
29
 
30
- MORAL_PRESETS = [
31
- "being kind to others",
32
- "being brave when things feel hard",
33
- "telling the truth",
34
- "sharing what you have",
35
- "trying again after a mistake",
36
- "listening before you speak",
37
- ]
38
 
 
 
 
 
 
39
 
40
- def _mode_badge() -> str:
41
- return "Mock mode" if USE_MOCK else "Real model"
42
 
 
43
 
44
- def _generate(
45
- name: str,
46
- age: int,
47
- themes: list[str] | None,
48
- moral_choice: str,
49
- moral_text: str,
50
- length: str,
51
- seed: int,
52
- ) -> tuple[str, str, str]:
53
- clean_name = sanitize_name(name) or "Friend"
54
- clean_themes = sanitize_themes(themes or []) or ["friends"]
55
- clean_moral = sanitize_moral(moral_text or moral_choice or "")
56
 
57
- if has_profanity(clean_name) or has_profanity(clean_moral):
58
- return (
59
- "Oops",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  "Some of the words used aren't allowed. Please try different words.",
61
- _mode_badge(),
62
- )
 
 
 
 
 
 
 
63
 
64
- req = StoryRequest(
65
- name=clean_name,
 
 
 
66
  age=int(age),
67
- themes=clean_themes,
68
- moral=clean_moral,
69
- length=length or "medium",
70
  seed=int(seed or 0),
71
  )
72
- title, body, mode = generate_story(req)
73
- return title, body, mode
74
-
75
-
76
- def _regenerate(
77
- name: str,
78
- age: int,
79
- themes: list[str] | None,
80
- moral_choice: str,
81
- moral_text: str,
82
- length: str,
83
- current_seed: int,
84
- ) -> tuple[str, str, str]:
85
- return _generate(name, age, themes, moral_choice, moral_text, length, (current_seed or 0) + 1)
86
-
87
-
88
- def build_ui() -> gr.Blocks:
89
- badge = _mode_badge()
90
- with gr.Blocks(title="Fabella") as demo:
91
- gr.Markdown(
92
- "# Fabella\n"
93
- "_A personalized storytelling companion for children._\n\n"
94
- f"**Current mode:** `{badge}` — set the `USE_MOCK` env var to change."
95
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
 
97
- with gr.Row():
98
- with gr.Column(scale=1):
99
- name = gr.Textbox(label="Child's name", placeholder="e.g. Mia", max_length=30)
100
- age = gr.Slider(6, 10, step=1, value=7, label="Age")
101
- themes = gr.CheckboxGroup(
102
- choices=THEME_CHOICES,
103
- value=["adventure"],
104
- label="Favorite themes (up to 3)",
105
- )
106
- moral_choice = gr.Dropdown(
107
- choices=MORAL_PRESETS,
108
- value=MORAL_PRESETS[0],
109
- label="Moral lesson (preset)",
110
- )
111
- moral_text = gr.Textbox(
112
- label="Or write your own moral (optional)",
113
- placeholder="e.g. it's okay to ask for help",
114
- max_length=120,
115
- )
116
- length = gr.Radio(
117
- choices=["short", "medium", "long"],
118
- value="medium",
119
- label="Story length",
120
- )
121
- seed = gr.State(value=0)
122
- with gr.Row():
123
- submit = gr.Button("Tell me a story", variant="primary")
124
- regen = gr.Button("New story")
125
-
126
- with gr.Column(scale=2):
127
- out_title = gr.Markdown("### Your story will appear here")
128
- out_body = gr.Markdown("")
129
- out_mode = gr.Markdown(f"_{badge}_")
130
-
131
- submit.click(
132
- fn=_generate,
133
- inputs=[name, age, themes, moral_choice, moral_text, length, seed],
134
- outputs=[out_title, out_body, out_mode],
135
- )
136
- regen.click(
137
- fn=_regenerate,
138
- inputs=[name, age, themes, moral_choice, moral_text, length, seed],
139
- outputs=[out_title, out_body, out_mode],
140
- )
141
 
142
- return demo
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
143
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
144
 
145
- demo = build_ui()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146
 
147
 
148
  if __name__ == "__main__":
149
- demo.launch(server_name="0.0.0.0", server_port=int(os.environ.get("PORT", 7860)), theme=gr.themes.Soft())
 
 
 
 
1
+ """Fabella — small words for big questions.
2
+
3
+ A Gradio Server (FastAPI subclass) serves a custom HTML+CSS+JS page. The
4
+ parent describes a hard-to-explain situation; Fabella drafts a short,
5
+ kind, age-appropriate explanation, validated by a second small model.
6
+
7
+ Architecture (see modal_app.py for the server side):
8
+ Gemma 4 E4B (drafter, A10G) — writes the explanation
9
+ Nemotron-3 Nano 4B (judge, A10G) — multi-criteria review
10
+ """
11
 
12
  import os
13
  import sys
14
+ import asyncio
15
+ import traceback
16
+ import base64
17
+ import json
18
+ import urllib.error
19
+ import urllib.request
20
 
21
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
22
 
 
23
 
24
+ def _silence_asyncio_invalid_fd_warning() -> None:
25
+ import asyncio
26
+
27
+ original_del = asyncio.BaseEventLoop.__del__
28
+
29
+ def safe_del(self):
30
+ try:
31
+ original_del(self)
32
+ except ValueError as exc:
33
+ if "Invalid file descriptor" not in str(exc):
34
+ raise
35
+
36
+ asyncio.BaseEventLoop.__del__ = safe_del
37
+
38
+
39
+ _silence_asyncio_invalid_fd_warning()
40
+
41
+ from fastapi.responses import HTMLResponse
42
+
43
+ from agent import run_agent
44
+ from schema import ExplainRequest
45
  from safety import (
46
  has_profanity,
 
47
  sanitize_name,
48
+ sanitize_situation,
49
  )
50
 
51
+ MODAL_DRAFTER_URL = os.environ.get(
52
+ "MODAL_DRAFTER_URL",
53
+ "https://khoitruong071510--fabella-serve-drafter.modal.run",
54
+ )
55
+ MODAL_JUDGE_URL = os.environ.get(
56
+ "MODAL_JUDGE_URL",
57
+ "https://khoitruong071510--fabella-serve-judge.modal.run",
58
+ )
59
+ MODAL_TTS_URL = os.environ.get(
60
+ "MODAL_TTS_URL",
61
+ "https://khoitruong071510--fabella-serve-tts.modal.run",
62
+ )
63
 
64
+ try:
65
+ import spaces
 
 
 
 
 
 
66
 
67
+ @spaces.GPU(duration=1)
68
+ def _gpu_placeholder() -> str:
69
+ return "ok"
70
+ except ImportError:
71
+ pass
72
 
73
+ from gradio import Server
 
74
 
75
+ app = Server()
76
 
 
 
 
 
 
 
 
 
 
 
 
 
77
 
78
+ def _make_drafter(seed: int = 0):
79
+ from llm import FabellaVLLM
80
+ return FabellaVLLM(base_url=MODAL_DRAFTER_URL, model_name="gemma-4", seed=seed)
81
+
82
+
83
+ def _make_judge(seed: int = 0):
84
+ from llm import FabellaVLLM
85
+ return FabellaVLLM(base_url=MODAL_JUDGE_URL, model_name="nemotron-3-4b", seed=seed)
86
+
87
+
88
+ # Sections joined with U+001F (Unit Separator) so the frontend can split
89
+ # reliably on a character that never appears in natural text.
90
+ SECTION_SEP = "\x1f"
91
+
92
+
93
+ def _make_explanation_sync(situation: str, age: int, child_name: str, tone: str, seed: int) -> str:
94
+ clean_situation = sanitize_situation(situation)
95
+ clean_name = sanitize_name(child_name)
96
+ clean_tone = (tone or "gentle").strip().lower()
97
+ if clean_tone not in ("gentle", "matter-of-fact", "playful"):
98
+ clean_tone = "gentle"
99
+
100
+ if has_profanity(clean_situation) or has_profanity(clean_name):
101
+ return SECTION_SEP.join([
102
+ "Fabella (safety)",
103
  "Some of the words used aren't allowed. Please try different words.",
104
+ "", "",
105
+ ])
106
+
107
+ if not clean_situation:
108
+ return SECTION_SEP.join([
109
+ "Fabella (empty)",
110
+ "Tell me about the situation first — what's going on, in a sentence or two?",
111
+ "", "",
112
+ ])
113
 
114
+ if not (5 <= int(age) <= 12):
115
+ age = 7 # default for out-of-range
116
+
117
+ req = ExplainRequest(
118
+ situation=clean_situation,
119
  age=int(age),
120
+ child_name=clean_name,
121
+ tone=clean_tone,
 
122
  seed=int(seed or 0),
123
  )
124
+ try:
125
+ print(
126
+ f"[app] request: age={req.age} tone={req.tone} name='{req.child_name}' situation='{req.situation[:60]}…'",
127
+ flush=True,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
  )
129
+ drafter = _make_drafter(seed=req.seed)
130
+ judge = _make_judge(seed=req.seed)
131
+ result = run_agent(drafter, req, judge_llm=judge)
132
+ return SECTION_SEP.join([
133
+ result.get("opener", "") or "",
134
+ result.get("body", "") or "",
135
+ result.get("closer", "") or "",
136
+ result.get("followup", "") or "",
137
+ ])
138
+ except Exception as e:
139
+ print(f"[app] handler error: {type(e).__name__}: {e}", flush=True)
140
+ return SECTION_SEP.join([
141
+ "Fabella (error)",
142
+ f"_Generation failed: {type(e).__name__}: {e}_",
143
+ "", "",
144
+ ])
145
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146
 
147
+ @app.api(name="make_explanation")
148
+ def make_explanation(situation: str, age: int, child_name: str, tone: str, seed: int) -> str:
149
+ """Draft a short, kind, age-appropriate explanation.
150
+
151
+ Returns four sections joined by U+001F:
152
+ 0: opener (one sentence the parent can say to start)
153
+ 1: body (1-3 short paragraphs)
154
+ 2: closer (one sentence the parent can say to land)
155
+ 3: follow-up (optional one sentence if the child has another question)
156
+ """
157
+ return _make_explanation_sync(situation, age, child_name, tone, seed)
158
+
159
+
160
+ def _clean_audio_text(text: str) -> str:
161
+ clean = " ".join((text or "").split())
162
+ if len(clean) > 1600:
163
+ clean = clean[:1600].rsplit(" ", 1)[0].strip()
164
+ return clean
165
+
166
+
167
+ def _make_audio_sync(text: str, tone: str) -> str:
168
+ clean_text = _clean_audio_text(text)
169
+ if not clean_text:
170
+ return "ERROR: Nothing to read aloud yet."
171
+
172
+ clean_tone = (tone or "gentle").strip().lower()
173
+ voice_by_tone = {
174
+ "gentle": "A calm adult woman with a soft, warm, reassuring voice, speaking slowly and clearly for a child.",
175
+ "matter-of-fact": "A calm adult woman with a clear, steady, practical voice, speaking plainly and kindly for a child.",
176
+ "playful": "A friendly adult woman with a lightly playful, bright, reassuring voice, speaking clearly for a child.",
177
+ }
178
+ payload = {
179
+ "text": clean_text,
180
+ "voice_description": voice_by_tone.get(clean_tone, voice_by_tone["gentle"]),
181
+ "cfg_value": 2.0,
182
+ "inference_timesteps": 10,
183
+ "normalize": True,
184
+ "denoise": True,
185
+ }
186
+ req = urllib.request.Request(
187
+ MODAL_TTS_URL.rstrip("/") + "/synthesize",
188
+ data=json.dumps(payload).encode("utf-8"),
189
+ headers={"Content-Type": "application/json", "Accept": "audio/wav"},
190
+ method="POST",
191
+ )
192
+ try:
193
+ with urllib.request.urlopen(req, timeout=180) as res:
194
+ audio = res.read()
195
+ except urllib.error.HTTPError as e:
196
+ detail = e.read().decode("utf-8", errors="replace")[:300]
197
+ print(f"[app] tts HTTP error: {e.code}: {detail}", flush=True)
198
+ return f"ERROR: Read-aloud failed: HTTP {e.code}"
199
+ except Exception as e:
200
+ print(f"[app] tts error: {type(e).__name__}: {e}", flush=True)
201
+ return f"ERROR: Read-aloud failed: {type(e).__name__}: {e}"
202
+
203
+ if not audio:
204
+ return "ERROR: Read-aloud returned no audio."
205
+ return "data:audio/wav;base64," + base64.b64encode(audio).decode("ascii")
206
+
207
+
208
+ @app.api(name="make_audio")
209
+ def make_audio(text: str, tone: str) -> str:
210
+ """Synthesize a Fabella explanation as a base64 WAV data URL."""
211
+ return _make_audio_sync(text, tone)
212
+
213
+
214
+ # --- HTML page --------------------------------------------------------------
215
+
216
+ TONE_CHOICES = [
217
+ ("gentle", "Gentle"),
218
+ ("matter-of-fact", "Matter-of-fact"),
219
+ ("playful", "Playful"),
220
+ ]
221
+
222
+ EXAMPLE_SITUATIONS = [
223
+ ("My 7-year-old's grandma is in the hospital for surgery. She asked why grandma is there."),
224
+ ("We're moving to a new house in 3 weeks. My kid is worried about leaving her friends."),
225
+ ("My child's dog died yesterday. She keeps asking when the dog is coming back."),
226
+ ("It's time to start sharing toys at preschool. My son refuses and has started hitting."),
227
+ ("My 9-year-old wants a phone. All her friends have one and I said no."),
228
+ ("There's a new baby coming in 4 months. My first grader is acting out and being mean to me."),
229
+ ]
230
+
231
+ INDEX_HTML = r"""<!doctype html>
232
+ <html lang="en">
233
+ <head>
234
+ <meta charset="utf-8" />
235
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
236
+ <title>Fabella — small words for big questions</title>
237
+ <link rel="preconnect" href="https://fonts.googleapis.com">
238
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
239
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght,SOFT,WONK@9..144,300..900,0..100,0..1&family=Literata:ital,opsz,wght@0,7..72,400..800;1,7..72,400..800&family=Fragment+Mono&display=swap">
240
+ <style>
241
+ /* =========================================================================
242
+ FABELLA — small words for big questions
243
+ Cool palette, NOT the banned warm-cream+brass+espresso default.
244
+ Same storybook-modernist language as before.
245
+ ========================================================================= */
246
+
247
+ :root {
248
+ --bone: #ece9e0;
249
+ --bone-deep: #e3dfd3;
250
+ --paper: #f4f1e7;
251
+ --paper-2: #faf7ed;
252
+ --ink: #1a1d1f;
253
+ --ink-soft: #3d4144;
254
+ --ink-mute: #6b6e6f;
255
+ --rule: #c8c2b1;
256
+ --rule-soft: #d9d4c2;
257
+ --forest: #2d4a2b;
258
+ --forest-ink: #1a2f18;
259
+ --wax: #a8341f;
260
+ --wax-ink: #7a2415;
261
+
262
+ --serif: "Source Serif 4", "Iowan Old Style", Georgia, "Times New Roman", serif;
263
+ --mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, monospace;
264
+
265
+ --pad-x: clamp(20px, 4.5vw, 56px);
266
+ --shadow-leaf: 0 1px 0 rgba(26,31,27,0.04), 0 12px 28px -18px rgba(26,31,27,0.18);
267
+ --ease: cubic-bezier(0.16, 1, 0.3, 1);
268
+ }
269
+ * { box-sizing: border-box; }
270
+ html, body { margin: 0; padding: 0; }
271
+ html { background: var(--bone); }
272
+ body {
273
+ color: var(--ink);
274
+ font-family: var(--serif);
275
+ font-size: 17px;
276
+ line-height: 1.55;
277
+ background: var(--bone);
278
+ -webkit-font-smoothing: antialiased;
279
+ -moz-osx-font-smoothing: grayscale;
280
+ text-rendering: optimizeLegibility;
281
+ font-feature-settings: "kern", "liga", "onum";
282
+ min-height: 100dvh;
283
+ overflow-x: hidden;
284
+ }
285
+ body::before {
286
+ content: "";
287
+ position: fixed; inset: 0;
288
+ pointer-events: none;
289
+ z-index: 60;
290
+ opacity: 0.5;
291
+ mix-blend-mode: multiply;
292
+ background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='160' height='160'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' seed='4'/><feColorMatrix values='0 0 0 0 0.55 0 0 0 0 0.50 0 0 0 0 0.40 0 0 0 0.07 0'/></filter><rect width='100%25' height='100%25' filter='url(%23n)'/></svg>");
293
+ }
294
+ @media (prefers-reduced-motion: reduce) {
295
+ *, *::before, *::after { animation: none !important; transition: none !important; }
296
+ }
297
+
298
+ /* ---- header strip ---- */
299
+ .imprint {
300
+ padding: 14px var(--pad-x);
301
+ display: flex;
302
+ align-items: baseline;
303
+ justify-content: space-between;
304
+ gap: 20px;
305
+ border-bottom: 1px solid var(--rule);
306
+ font-family: var(--mono);
307
+ font-size: 11px;
308
+ letter-spacing: 0.18em;
309
+ text-transform: uppercase;
310
+ color: var(--ink-soft);
311
+ background: var(--bone);
312
+ }
313
+ .imprint .wordmark {
314
+ font-family: var(--serif);
315
+ font-style: italic;
316
+ font-size: 19px;
317
+ letter-spacing: 0.005em;
318
+ text-transform: none;
319
+ color: var(--ink);
320
+ font-weight: 400;
321
+ }
322
+ .imprint .wordmark b { font-style: normal; font-weight: 700; color: var(--forest-ink); }
323
+ .imprint .meta { display: flex; gap: 22px; }
324
+ .imprint .meta span b { color: var(--ink); font-weight: 700; }
325
+
326
+ /* ---- main split ---- */
327
+ .stage {
328
+ display: grid;
329
+ grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr);
330
+ gap: clamp(24px, 4vw, 64px);
331
+ padding: clamp(28px, 5vw, 64px) var(--pad-x) clamp(40px, 6vw, 88px);
332
+ max-width: 1280px;
333
+ margin: 0 auto;
334
+ align-items: start;
335
+ }
336
+ @media (max-width: 880px) { .stage { grid-template-columns: 1fr; } }
337
+
338
+ /* ---- left: form ---- */
339
+ .col-form { position: sticky; top: 28px; }
340
+ @media (max-width: 880px) { .col-form { position: static; } }
341
+ .title-block { margin-bottom: 28px; }
342
+ .title-block h1 {
343
+ font-family: var(--serif);
344
+ font-size: clamp(40px, 6.4vw, 64px);
345
+ line-height: 0.98;
346
+ letter-spacing: -0.015em;
347
+ font-weight: 700;
348
+ margin: 0 0 14px 0;
349
+ color: var(--ink);
350
+ text-wrap: balance;
351
+ }
352
+ .title-block h1 em { font-style: italic; font-weight: 400; color: var(--forest-ink); }
353
+ .title-block .lede {
354
+ font-size: 17px;
355
+ line-height: 1.55;
356
+ color: var(--ink-soft);
357
+ max-width: 40ch;
358
+ margin: 0;
359
+ }
360
+ form { display: flex; flex-direction: column; gap: 22px; margin-top: 8px; }
361
+ .field { display: flex; flex-direction: column; gap: 8px; }
362
+ .label {
363
+ font-family: var(--mono);
364
+ font-size: 10.5px;
365
+ font-weight: 700;
366
+ letter-spacing: 0.22em;
367
+ text-transform: uppercase;
368
+ color: var(--ink-mute);
369
+ }
370
+ .label small { text-transform: none; letter-spacing: 0; font-weight: 400; font-family: var(--serif); font-style: italic; font-size: 12px; color: var(--ink-mute); margin-left: 6px; }
371
+ .hint {
372
+ font-family: var(--serif);
373
+ font-style: italic;
374
+ font-size: 13px;
375
+ color: var(--ink-mute);
376
+ line-height: 1.5;
377
+ margin-top: 2px;
378
+ }
379
+
380
+ input[type="text"], textarea {
381
+ font-family: var(--serif);
382
+ font-size: 17px;
383
+ line-height: 1.45;
384
+ color: var(--ink);
385
+ background: var(--paper);
386
+ border: 1px solid var(--rule);
387
+ border-radius: 0;
388
+ padding: 12px 14px;
389
+ outline: none;
390
+ transition: border-color 0.18s var(--ease), background 0.18s var(--ease);
391
+ width: 100%;
392
+ font-feature-settings: "kern", "liga";
393
+ }
394
+ textarea { min-height: 110px; resize: vertical; line-height: 1.55; }
395
+ input[type="text"]::placeholder, textarea::placeholder { color: var(--ink-mute); font-style: italic; }
396
+ input[type="text"]:focus, textarea:focus {
397
+ border-color: var(--forest);
398
+ background: var(--paper-2);
399
+ }
400
+
401
+ .age { display: grid; grid-template-columns: 1fr auto; align-items: baseline; gap: 14px; }
402
+ .age .readout {
403
+ font-family: var(--serif);
404
+ font-size: 30px;
405
+ line-height: 1;
406
+ color: var(--ink);
407
+ font-feature-settings: "tnum";
408
+ font-weight: 600;
409
+ }
410
+ .age .readout small { font-family: var(--mono); font-size: 10.5px; letter-spacing: 0.18em; text-transform: uppercase; color: var(--ink-mute); margin-left: 6px; vertical-align: middle; }
411
+ .age input[type="range"] {
412
+ grid-column: 1 / -1;
413
+ -webkit-appearance: none; appearance: none;
414
+ width: 100%; height: 4px;
415
+ background: var(--rule);
416
+ border-radius: 999px;
417
+ outline: none;
418
+ }
419
+ .age input[type="range"]::-webkit-slider-thumb {
420
+ -webkit-appearance: none; appearance: none;
421
+ width: 22px; height: 22px;
422
+ border-radius: 50%;
423
+ background: var(--forest);
424
+ cursor: grab;
425
+ border: 3px solid var(--paper);
426
+ box-shadow: 0 0 0 1px var(--forest);
427
+ transition: transform 0.15s var(--ease);
428
+ }
429
+ .age input[type="range"]::-webkit-slider-thumb:active { transform: scale(0.94); cursor: grabbing; }
430
+ .age input[type="range"]::-moz-range-thumb {
431
+ width: 22px; height: 22px; border-radius: 50%;
432
+ background: var(--forest); border: 3px solid var(--paper);
433
+ box-shadow: 0 0 0 1px var(--forest);
434
+ }
435
+
436
+ /* tone radio as 3 segmented buttons */
437
+ .tone-row {
438
+ display: grid; grid-template-columns: 1fr 1fr 1fr;
439
+ border: 1px solid var(--rule);
440
+ border-radius: 0;
441
+ overflow: hidden;
442
+ background: var(--paper);
443
+ }
444
+ .tone-row label {
445
+ text-align: center;
446
+ padding: 11px 8px;
447
+ font-family: var(--mono);
448
+ font-size: 11px;
449
+ letter-spacing: 0.16em;
450
+ text-transform: uppercase;
451
+ color: var(--ink-soft);
452
+ cursor: pointer;
453
+ border-right: 1px solid var(--rule);
454
+ transition: background 0.18s var(--ease), color 0.18s var(--ease);
455
+ font-weight: 500;
456
+ user-select: none;
457
+ }
458
+ .tone-row label:last-child { border-right: none; }
459
+ .tone-row input { display: none; }
460
+ .tone-row label.is-on { background: var(--ink); color: var(--bone); }
461
+
462
+ /* example chips */
463
+ .examples { display: flex; flex-direction: column; gap: 6px; }
464
+ .ex-chip {
465
+ font-family: var(--serif);
466
+ font-size: 14px;
467
+ line-height: 1.4;
468
+ color: var(--ink-soft);
469
+ background: var(--paper);
470
+ border: 1px solid var(--rule-soft);
471
+ padding: 8px 12px;
472
+ cursor: pointer;
473
+ text-align: left;
474
+ border-radius: 0;
475
+ transition: border-color 0.18s var(--ease), color 0.18s var(--ease), background 0.18s var(--ease);
476
+ }
477
+ .ex-chip:hover { border-color: var(--ink-soft); color: var(--ink); background: var(--paper-2); }
478
+ .ex-chip small { display: block; font-family: var(--mono); font-size: 9.5px; letter-spacing: 0.16em; text-transform: uppercase; color: var(--ink-mute); margin-bottom: 2px; }
479
+
480
+ /* submit */
481
+ .actions { display: flex; align-items: center; gap: 14px; margin-top: 6px; flex-wrap: wrap; }
482
+ .btn-primary {
483
+ font-family: var(--serif);
484
+ font-size: 17px;
485
+ font-weight: 600;
486
+ letter-spacing: 0.005em;
487
+ color: var(--bone);
488
+ background: var(--wax);
489
+ border: 1px solid var(--wax-ink);
490
+ padding: 13px 22px;
491
+ border-radius: 0;
492
+ cursor: pointer;
493
+ display: inline-flex;
494
+ align-items: center;
495
+ gap: 10px;
496
+ box-shadow: 0 1px 0 rgba(0,0,0,0.04), 0 4px 0 -1px var(--wax-ink);
497
+ transition: transform 0.12s var(--ease), box-shadow 0.12s var(--ease), background 0.18s var(--ease);
498
+ white-space: nowrap;
499
+ }
500
+ .btn-primary:hover { background: var(--wax-ink); }
501
+ .btn-primary:active { transform: translateY(2px); box-shadow: 0 1px 0 rgba(0,0,0,0.04), 0 2px 0 -1px var(--wax-ink); }
502
+ .btn-primary:disabled { background: var(--ink-mute); border-color: var(--ink-mute); box-shadow: 0 1px 0 rgba(0,0,0,0.04); cursor: progress; transform: none; }
503
+ .btn-primary .arrow { display: inline-block; transition: transform 0.18s var(--ease); }
504
+ .btn-primary:hover:not(:disabled) .arrow { transform: translateX(3px); }
505
+ .btn-ghost {
506
+ font-family: var(--mono);
507
+ font-size: 11px;
508
+ letter-spacing: 0.18em;
509
+ text-transform: uppercase;
510
+ color: var(--ink-soft);
511
+ background: transparent;
512
+ border: none;
513
+ cursor: pointer;
514
+ padding: 8px 4px;
515
+ border-bottom: 1px dashed var(--ink-mute);
516
+ transition: color 0.18s var(--ease), border-color 0.18s var(--ease);
517
+ }
518
+ .btn-ghost:hover { color: var(--ink); border-color: var(--ink); }
519
+ .btn-ghost:disabled { opacity: 0.4; cursor: not-allowed; }
520
+ .btn-read {
521
+ font-family: var(--mono);
522
+ font-size: 10.5px;
523
+ letter-spacing: 0.18em;
524
+ text-transform: uppercase;
525
+ color: var(--forest-ink);
526
+ background: transparent;
527
+ border: 1px solid var(--rule);
528
+ padding: 9px 12px;
529
+ cursor: pointer;
530
+ transition: background 0.18s var(--ease), color 0.18s var(--ease), border-color 0.18s var(--ease);
531
+ }
532
+ .btn-read:hover { background: var(--paper-2); border-color: var(--forest); color: var(--ink); }
533
+ .btn-read:disabled { opacity: 0.45; cursor: progress; }
534
+ .seed-pill {
535
+ font-family: var(--mono);
536
+ font-size: 10.5px;
537
+ letter-spacing: 0.18em;
538
+ text-transform: uppercase;
539
+ color: var(--ink-mute);
540
+ padding-left: 4px;
541
+ }
542
+
543
+ /* ---- right: book page ---- */
544
+ .col-story { position: relative; min-height: 60vh; }
545
+ .book {
546
+ background: var(--paper);
547
+ border: 1px solid var(--rule);
548
+ box-shadow: var(--shadow-leaf);
549
+ padding: clamp(28px, 4.5vw, 56px) clamp(24px, 4vw, 52px);
550
+ position: relative;
551
+ }
552
+ .book::before {
553
+ content: "";
554
+ position: absolute; left: 18px; top: 18px; bottom: 18px; right: 18px;
555
+ border: 1px solid var(--rule-soft);
556
+ pointer-events: none;
557
+ }
558
+ .book .corner {
559
+ position: absolute;
560
+ width: 22px; height: 22px;
561
+ pointer-events: none;
562
+ color: var(--ink-mute);
563
+ }
564
+ .book .corner.tl { top: 10px; left: 10px; }
565
+ .book .corner.tr { top: 10px; right: 10px; transform: scaleX(-1); }
566
+ .book .corner.bl { bottom: 10px; left: 10px; transform: scaleY(-1); }
567
+ .book .corner.br { bottom: 10px; right: 10px; transform: scale(-1,-1); }
568
+ .book .inner { position: relative; z-index: 1; }
569
+
570
+ .cover { display: flex; flex-direction: column; gap: 18px; min-height: 380px; justify-content: center; }
571
+ .cover .folio { font-family: var(--mono); font-size: 10.5px; letter-spacing: 0.22em; text-transform: uppercase; color: var(--ink-mute); }
572
+ .cover h2 {
573
+ font-family: var(--serif);
574
+ font-size: clamp(34px, 4.5vw, 50px);
575
+ line-height: 1.02;
576
+ letter-spacing: -0.012em;
577
+ font-weight: 600;
578
+ margin: 0;
579
+ color: var(--ink);
580
+ font-style: italic;
581
+ font-weight: 400;
582
+ }
583
+ .cover h2 b { font-style: normal; font-weight: 700; color: var(--forest-ink); }
584
+ .cover p { font-size: 16px; line-height: 1.6; color: var(--ink-soft); max-width: 40ch; margin: 0; }
585
 
586
+ .writing { display: flex; flex-direction: column; gap: 14px; min-height: 380px; justify-content: center; }
587
+ .writing .penline {
588
+ font-family: var(--mono);
589
+ font-size: 10.5px;
590
+ letter-spacing: 0.22em;
591
+ text-transform: uppercase;
592
+ color: var(--ink-mute);
593
+ display: inline-flex; align-items: center; gap: 10px;
594
+ }
595
+ .writing .quill {
596
+ display: inline-block; width: 14px; height: 14px; background: var(--ink);
597
+ -webkit-mask: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path d='M3 21l3.5-1 11-11a2.83 2.83 0 0 0-4-4l-11 11L3 21z' fill='currentColor'/></svg>") center/contain no-repeat;
598
+ mask: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path d='M3 21l3.5-1 1 1-11a2.83 2.83 0 0 0-4-4l-11 11L3 21z' fill='currentColor'/></svg>") center/contain no-repeat;
599
+ transform-origin: 50% 80%;
600
+ animation: nib 1.6s var(--ease) infinite;
601
+ }
602
+ @keyframes nib { 0%, 100% { transform: rotate(-8deg) translateX(0); } 50% { transform: rotate(14deg) translateX(2px); } }
603
+ @media (prefers-reduced-motion: reduce) { .writing .quill { animation: none; } }
604
+ .writing .progress {
605
+ height: 1px;
606
+ background: linear-gradient(90deg, var(--forest) 0%, var(--forest) var(--p,40%), var(--rule) var(--p,40%), var(--rule) 100%);
607
+ width: 100%;
608
+ max-width: 320px;
609
+ transition: background 0.4s var(--ease);
610
+ }
611
+ .writing p { font-size: 16px; line-height: 1.6; color: var(--ink-soft); max-width: 40ch; margin: 0; }
612
 
613
+ /* the actual explanation page */
614
+ .expl { animation: arrive 0.55s var(--ease) both; }
615
+ @keyframes arrive { from { opacity: 0; transform: translateY(8px) rotate(-0.2deg); } to { opacity: 1; transform: translateY(0) rotate(0); } }
616
+ @media (prefers-reduced-motion: reduce) { .expl { animation: none; } }
617
+
618
+ .expl .byline {
619
+ font-family: var(--mono);
620
+ font-size: 10.5px;
621
+ letter-spacing: 0.22em;
622
+ text-transform: uppercase;
623
+ color: var(--ink-mute);
624
+ margin-bottom: 18px;
625
+ display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
626
+ }
627
+ .expl .byline .dot { width: 4px; height: 4px; border-radius: 50%; background: var(--ink-mute); display: inline-block; }
628
+ .expl .byline .label-tiny { color: var(--ink-soft); font-weight: 700; }
629
+
630
+ .expl h2.title {
631
+ font-family: var(--serif);
632
+ font-size: clamp(28px, 3.6vw, 36px);
633
+ line-height: 1.1;
634
+ letter-spacing: -0.012em;
635
+ font-weight: 700;
636
+ margin: 0 0 22px 0;
637
+ color: var(--ink);
638
+ text-wrap: balance;
639
+ }
640
+ .expl h2.title small {
641
+ display: block;
642
+ font-family: var(--mono);
643
+ font-size: 10.5px;
644
+ letter-spacing: 0.22em;
645
+ text-transform: uppercase;
646
+ color: var(--ink-mute);
647
+ font-weight: 400;
648
+ margin-top: 8px;
649
+ }
650
+
651
+ /* the four sections, in order */
652
+ .section { margin-bottom: 18px; }
653
+ .section:last-child { margin-bottom: 0; }
654
+ .section .tag {
655
+ font-family: var(--mono);
656
+ font-size: 10px;
657
+ letter-spacing: 0.22em;
658
+ text-transform: uppercase;
659
+ color: var(--forest-ink);
660
+ font-weight: 700;
661
+ margin-bottom: 6px;
662
+ display: block;
663
+ }
664
+ .section .text {
665
+ font-family: var(--serif);
666
+ font-size: 18px;
667
+ line-height: 1.55;
668
+ color: var(--ink);
669
+ margin: 0;
670
+ }
671
+ .section.opener .text {
672
+ font-style: italic;
673
+ color: var(--ink-soft);
674
+ font-size: 17px;
675
+ border-left: 2px solid var(--forest);
676
+ padding: 2px 0 2px 14px;
677
+ }
678
+ .section.closer .text {
679
+ font-weight: 600;
680
+ }
681
+ .section.body p { margin: 0 0 0.85em 0; }
682
+ .section.body p:last-child { margin-bottom: 0; }
683
+ .section.followup .text {
684
+ font-size: 15px;
685
+ color: var(--ink-mute);
686
+ font-style: italic;
687
+ }
688
+ .section .text:empty { display: none; }
689
+ .section:has(.text:empty) { display: none; }
690
+
691
+ .expl .signoff {
692
+ margin-top: 32px;
693
+ padding-top: 18px;
694
+ border-top: 1px solid var(--rule-soft);
695
+ display: flex; align-items: baseline; justify-content: space-between;
696
+ gap: 12px; flex-wrap: wrap;
697
+ font-family: var(--mono);
698
+ font-size: 10.5px;
699
+ letter-spacing: 0.18em;
700
+ text-transform: uppercase;
701
+ color: var(--ink-mute);
702
+ }
703
+ .expl .signoff .regen {
704
+ color: var(--ink-soft);
705
+ border-bottom: 1px dashed var(--ink-mute);
706
+ cursor: pointer;
707
+ padding: 0 0 1px 0;
708
+ background: transparent;
709
+ border-left: 0; border-right: 0; border-top: 0;
710
+ font: inherit; letter-spacing: inherit; text-transform: inherit;
711
+ }
712
+ .expl .signoff .regen:hover { color: var(--ink); border-color: var(--ink); }
713
+ .expl .signoff .regen:disabled { opacity: 0.4; cursor: progress; }
714
+
715
+ .audio-panel {
716
+ margin-top: 18px;
717
+ padding-top: 18px;
718
+ border-top: 1px solid var(--rule-soft);
719
+ display: flex;
720
+ align-items: center;
721
+ gap: 12px;
722
+ flex-wrap: wrap;
723
+ }
724
+ .audio-panel .audio-status {
725
+ font-family: var(--serif);
726
+ font-style: italic;
727
+ font-size: 14px;
728
+ color: var(--ink-mute);
729
+ }
730
+ .audio-panel audio {
731
+ width: 100%;
732
+ min-width: 220px;
733
+ margin-top: 2px;
734
+ }
735
+
736
+ .banner {
737
+ font-family: var(--serif);
738
+ font-style: italic;
739
+ font-size: 16px;
740
+ line-height: 1.5;
741
+ color: var(--wax-ink);
742
+ border-left: 3px double var(--wax);
743
+ padding: 4px 0 4px 14px;
744
+ }
745
+
746
+ .colophon {
747
+ border-top: 1px solid var(--rule);
748
+ padding: 22px var(--pad-x) 28px;
749
+ font-family: var(--mono);
750
+ font-size: 10.5px;
751
+ letter-spacing: 0.18em;
752
+ text-transform: uppercase;
753
+ color: var(--ink-mute);
754
+ display: flex;
755
+ justify-content: space-between;
756
+ align-items: baseline;
757
+ gap: 20px;
758
+ flex-wrap: wrap;
759
+ max-width: 1280px;
760
+ margin: 0 auto;
761
+ }
762
+ .colophon a { color: var(--ink-soft); text-decoration: none; border-bottom: 1px dotted var(--ink-mute); }
763
+ .colophon a:hover { color: var(--ink); border-color: var(--ink); }
764
+
765
+ /* =========================================================================
766
+ REDESIGN — night observatory field guide
767
+ A parent is not asking for a dashboard; they are trying to find a careful
768
+ sentence in the dark. The interface should feel like a quiet instrument:
769
+ ink, brass, star maps, and one illuminated page.
770
+ ========================================================================= */
771
+
772
+ :root {
773
+ --night: #081019;
774
+ --night-2: #0d1924;
775
+ --night-3: #132333;
776
+ --mist: #dbe3dd;
777
+ --mist-dim: #aebbb7;
778
+ --vellum: #f1ead7;
779
+ --vellum-2: #fbf5e8;
780
+ --vellum-3: #e4d8bd;
781
+ --ink: #13202a;
782
+ --ink-soft: #314150;
783
+ --ink-mute: #67747d;
784
+ --rule: rgba(210, 188, 139, 0.42);
785
+ --rule-soft: rgba(210, 188, 139, 0.22);
786
+ --forest: #5f8e79;
787
+ --forest-ink: #1d5d4f;
788
+ --wax: #d46a45;
789
+ --wax-ink: #8f381f;
790
+ --gold: #d8b56a;
791
+ --bluefire: #8cc7d8;
792
+ --serif: "Literata", "Iowan Old Style", Georgia, serif;
793
+ --display: "Fraunces", "Literata", Georgia, serif;
794
+ --mono: "Fragment Mono", "JetBrains Mono", ui-monospace, monospace;
795
+ --pad-x: clamp(18px, 4vw, 64px);
796
+ --ease: cubic-bezier(0.16, 1, 0.3, 1);
797
+ --shadow-leaf: 0 34px 90px -48px rgba(0, 0, 0, 0.82), 0 0 0 1px rgba(216,181,106,0.14);
798
+ }
799
+
800
+ html { background: var(--night); }
801
+ body {
802
+ color: var(--mist);
803
+ background:
804
+ radial-gradient(circle at 14% 16%, rgba(140,199,216,0.18) 0 12%, transparent 30%),
805
+ radial-gradient(circle at 86% 8%, rgba(212,106,69,0.16) 0 10%, transparent 28%),
806
+ radial-gradient(circle at 72% 86%, rgba(216,181,106,0.11) 0 11%, transparent 26%),
807
+ linear-gradient(135deg, #060b12 0%, var(--night) 38%, #101b26 100%);
808
+ isolation: isolate;
809
+ }
810
+ body::before {
811
+ opacity: 0.38;
812
+ mix-blend-mode: screen;
813
+ background-image:
814
+ radial-gradient(circle at 20px 24px, rgba(255,255,255,0.72) 0 1px, transparent 1.4px),
815
+ radial-gradient(circle at 82px 68px, rgba(216,181,106,0.56) 0 1px, transparent 1.4px),
816
+ url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='180' height='180'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.78' numOctaves='3' seed='11'/><feColorMatrix values='0 0 0 0 0.80 0 0 0 0 0.78 0 0 0 0 0.68 0 0 0 0.18 0'/></filter><rect width='100%25' height='100%25' filter='url(%23n)'/></svg>");
817
+ background-size: 118px 118px, 172px 172px, 180px 180px;
818
+ }
819
+ body::after {
820
+ content: "";
821
+ position: fixed;
822
+ inset: auto -8vw -18vh auto;
823
+ width: min(62vw, 720px);
824
+ height: min(62vw, 720px);
825
+ pointer-events: none;
826
+ z-index: -1;
827
+ opacity: 0.34;
828
+ border: 1px solid rgba(216,181,106,0.28);
829
+ border-radius: 50%;
830
+ background:
831
+ linear-gradient(90deg, transparent 49.8%, rgba(216,181,106,0.24) 50%, transparent 50.2%),
832
+ linear-gradient(0deg, transparent 49.8%, rgba(216,181,106,0.24) 50%, transparent 50.2%),
833
+ radial-gradient(circle, transparent 0 44%, rgba(216,181,106,0.2) 44.2% 44.6%, transparent 45%),
834
+ radial-gradient(circle, transparent 0 64%, rgba(216,181,106,0.16) 64.2% 64.6%, transparent 65%);
835
+ transform: rotate(-11deg);
836
+ }
837
+
838
+ .imprint {
839
+ position: relative;
840
+ padding: 18px var(--pad-x);
841
+ border-bottom: 1px solid rgba(216,181,106,0.2);
842
+ background: rgba(8,16,25,0.72);
843
+ color: var(--mist-dim);
844
+ backdrop-filter: blur(18px);
845
+ }
846
+ .imprint::after {
847
+ content: "";
848
+ position: absolute;
849
+ left: var(--pad-x);
850
+ right: var(--pad-x);
851
+ bottom: -1px;
852
+ height: 1px;
853
+ background: linear-gradient(90deg, transparent, var(--gold), transparent);
854
+ opacity: 0.56;
855
+ }
856
+ .imprint .wordmark {
857
+ color: var(--vellum-2);
858
+ font-family: var(--display);
859
+ font-size: clamp(20px, 2.2vw, 31px);
860
+ font-style: normal;
861
+ font-variation-settings: "SOFT" 75, "WONK" 1;
862
+ letter-spacing: -0.025em;
863
+ }
864
+ .imprint .wordmark b { color: var(--gold); font-weight: 760; }
865
+ .imprint .meta { color: var(--mist-dim); opacity: 0.92; }
866
+ .imprint .meta span b { color: var(--bluefire); }
867
+
868
+ .stage {
869
+ position: relative;
870
+ max-width: 1380px;
871
+ grid-template-columns: minmax(320px, 0.92fr) minmax(420px, 1.08fr);
872
+ gap: clamp(28px, 5vw, 86px);
873
+ padding-top: clamp(34px, 6vw, 84px);
874
+ }
875
+ .stage::before {
876
+ content: "";
877
+ position: absolute;
878
+ top: 54px;
879
+ left: calc(var(--pad-x) + 17px);
880
+ width: 120px;
881
+ height: 120px;
882
+ opacity: 0.34;
883
+ pointer-events: none;
884
+ border-left: 1px solid var(--gold);
885
+ border-top: 1px solid var(--gold);
886
+ transform: rotate(-7deg);
887
+ }
888
+
889
+ .col-form {
890
+ top: 34px;
891
+ padding: clamp(22px, 3vw, 34px);
892
+ background: linear-gradient(180deg, rgba(13,25,36,0.82), rgba(8,16,25,0.58));
893
+ border: 1px solid rgba(216,181,106,0.22);
894
+ box-shadow: 0 28px 78px -58px rgba(0,0,0,0.86);
895
+ backdrop-filter: blur(16px);
896
+ }
897
+ .col-form::before {
898
+ content: "Parent console / private draft";
899
+ display: block;
900
+ margin-bottom: 18px;
901
+ font-family: var(--mono);
902
+ font-size: 10px;
903
+ letter-spacing: 0.2em;
904
+ text-transform: uppercase;
905
+ color: var(--gold);
906
+ }
907
+ .title-block { margin-bottom: 30px; }
908
+ .title-block h1 {
909
+ color: var(--vellum-2);
910
+ font-family: var(--display);
911
+ font-size: clamp(48px, 6.8vw, 88px);
912
+ line-height: 0.86;
913
+ letter-spacing: -0.055em;
914
+ font-weight: 820;
915
+ font-variation-settings: "SOFT" 64, "WONK" 1;
916
+ max-width: 8.4ch;
917
+ }
918
+ .title-block h1 em {
919
+ color: var(--bluefire);
920
+ font-style: italic;
921
+ font-weight: 430;
922
+ }
923
+ .title-block .lede {
924
+ color: var(--mist-dim);
925
+ font-size: 16px;
926
+ max-width: 45ch;
927
+ }
928
+ form { gap: 20px; }
929
+ .label {
930
+ color: rgba(216,181,106,0.9);
931
+ font-size: 9.5px;
932
+ }
933
+ .label small, .hint { color: rgba(219,227,221,0.56); }
934
+ input[type="text"], textarea {
935
+ color: var(--vellum-2);
936
+ background: rgba(5,10,16,0.42);
937
+ border: 1px solid rgba(216,181,106,0.28);
938
+ box-shadow: inset 0 0 0 1px rgba(255,255,255,0.025);
939
+ border-radius: 18px 18px 18px 4px;
940
+ padding: 14px 16px;
941
+ }
942
+ textarea { min-height: 138px; }
943
+ input[type="text"]::placeholder, textarea::placeholder { color: rgba(219,227,221,0.42); }
944
+ input[type="text"]:focus, textarea:focus {
945
+ border-color: var(--bluefire);
946
+ background: rgba(12,28,40,0.62);
947
+ box-shadow: 0 0 0 4px rgba(140,199,216,0.1), inset 0 0 0 1px rgba(255,255,255,0.04);
948
+ }
949
+ .age .readout { color: var(--vellum-2); font-family: var(--display); font-size: 38px; }
950
+ .age .readout small { color: var(--mist-dim); }
951
+ .age input[type="range"] { height: 2px; background: rgba(216,181,106,0.34); }
952
+ .age input[type="range"]::-webkit-slider-thumb {
953
+ background: var(--bluefire);
954
+ border-color: var(--night);
955
+ box-shadow: 0 0 0 1px var(--bluefire), 0 0 24px rgba(140,199,216,0.52);
956
+ }
957
+ .age input[type="range"]::-moz-range-thumb {
958
+ background: var(--bluefire);
959
+ border-color: var(--night);
960
+ box-shadow: 0 0 0 1px var(--bluefire), 0 0 24px rgba(140,199,216,0.52);
961
+ }
962
+ .tone-row {
963
+ border-color: rgba(216,181,106,0.28);
964
+ border-radius: 999px;
965
+ padding: 4px;
966
+ gap: 4px;
967
+ background: rgba(5,10,16,0.46);
968
+ }
969
+ .tone-row label {
970
+ border: 0;
971
+ border-radius: 999px;
972
+ color: var(--mist-dim);
973
+ padding: 10px 8px;
974
+ }
975
+ .tone-row label.is-on {
976
+ background: var(--vellum);
977
+ color: var(--night);
978
+ box-shadow: 0 7px 24px -14px rgba(251,245,232,0.85);
979
+ }
980
+ .examples { gap: 8px; }
981
+ .ex-chip {
982
+ position: relative;
983
+ overflow: hidden;
984
+ color: rgba(241,234,215,0.86);
985
+ background: linear-gradient(90deg, rgba(19,35,51,0.7), rgba(8,16,25,0.34));
986
+ border-color: rgba(216,181,106,0.16);
987
+ border-radius: 16px 16px 16px 4px;
988
+ padding: 10px 13px 10px 16px;
989
+ }
990
+ .ex-chip::before {
991
+ content: "";
992
+ position: absolute;
993
+ left: 0;
994
+ top: 12px;
995
+ bottom: 12px;
996
+ width: 2px;
997
+ background: var(--gold);
998
+ opacity: 0.6;
999
+ }
1000
+ .ex-chip:hover {
1001
+ color: var(--vellum-2);
1002
+ background: rgba(19,35,51,0.92);
1003
+ border-color: rgba(140,199,216,0.42);
1004
+ transform: translateX(2px);
1005
+ }
1006
+ .ex-chip small { color: var(--bluefire); }
1007
+
1008
+ .btn-primary {
1009
+ color: #10161d;
1010
+ background: linear-gradient(135deg, var(--gold), #f0d99c 48%, #d37a52);
1011
+ border: 0;
1012
+ border-radius: 999px;
1013
+ padding: 14px 22px;
1014
+ box-shadow: 0 15px 36px -22px rgba(216,181,106,0.98), inset 0 1px 0 rgba(255,255,255,0.52);
1015
+ font-family: var(--display);
1016
+ font-weight: 760;
1017
+ }
1018
+ .btn-primary:hover { background: linear-gradient(135deg, #f1cf77, #fff0bd 48%, #e07b51); }
1019
+ .btn-primary:disabled { background: rgba(174,187,183,0.42); color: rgba(8,16,25,0.72); }
1020
+ .btn-ghost, .seed-pill { color: var(--mist-dim); }
1021
+ .btn-ghost:hover { color: var(--bluefire); border-color: var(--bluefire); }
1022
+
1023
+ .col-story { min-height: 66vh; }
1024
+ .book {
1025
+ color: var(--ink);
1026
+ background:
1027
+ linear-gradient(115deg, rgba(255,255,255,0.5), transparent 34%),
1028
+ radial-gradient(circle at 92% 8%, rgba(216,181,106,0.2), transparent 26%),
1029
+ linear-gradient(180deg, var(--vellum-2), var(--vellum));
1030
+ border: 1px solid rgba(255,255,255,0.58);
1031
+ border-radius: 34px 34px 34px 8px;
1032
+ box-shadow: var(--shadow-leaf);
1033
+ padding: clamp(34px, 5vw, 68px) clamp(26px, 4.8vw, 64px);
1034
+ transform: rotate(0.6deg);
1035
+ }
1036
+ .book::before {
1037
+ left: 20px;
1038
+ top: 20px;
1039
+ right: 20px;
1040
+ bottom: 20px;
1041
+ border: 1px solid rgba(143,56,31,0.13);
1042
+ border-radius: 24px 24px 24px 5px;
1043
+ }
1044
+ .book::after {
1045
+ content: "";
1046
+ position: absolute;
1047
+ inset: 0;
1048
+ pointer-events: none;
1049
+ border-radius: inherit;
1050
+ opacity: 0.28;
1051
+ background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='130' height='130'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' seed='8'/><feColorMatrix values='0 0 0 0 0.44 0 0 0 0 0.32 0 0 0 0 0.18 0 0 0 0.12 0'/></filter><rect width='100%25' height='100%25' filter='url(%23n)'/></svg>");
1052
+ }
1053
+ .book .corner { color: rgba(143,56,31,0.46); width: 26px; height: 26px; }
1054
+ .book .corner.tl { top: 14px; left: 14px; }
1055
+ .book .corner.tr { top: 14px; right: 14px; }
1056
+ .book .corner.bl { bottom: 14px; left: 14px; }
1057
+ .book .corner.br { bottom: 14px; right: 14px; }
1058
+ .cover { min-height: 430px; }
1059
+ .cover .folio, .expl .byline, .section .tag, .expl .signoff { color: rgba(19,32,42,0.58); }
1060
+ .cover h2 {
1061
+ font-family: var(--display);
1062
+ color: var(--ink);
1063
+ font-size: clamp(42px, 5.1vw, 72px);
1064
+ line-height: 0.9;
1065
+ letter-spacing: -0.05em;
1066
+ font-style: normal;
1067
+ font-weight: 780;
1068
+ font-variation-settings: "SOFT" 76, "WONK" 1;
1069
+ }
1070
+ .cover h2 b { color: var(--wax-ink); }
1071
+ .cover p { color: var(--ink-soft); font-size: 17px; max-width: 47ch; }
1072
+ .writing { min-height: 430px; }
1073
+ .writing .penline { color: var(--wax-ink); }
1074
+ .writing .quill { background: var(--wax-ink); }
1075
+ .writing .progress {
1076
+ height: 5px;
1077
+ border-radius: 999px;
1078
+ background: linear-gradient(90deg, var(--wax) 0%, var(--gold) var(--p,40%), rgba(19,32,42,0.12) var(--p,40%), rgba(19,32,42,0.12) 100%);
1079
+ }
1080
+ .writing p { color: var(--ink-soft); }
1081
+ .expl { animation: arrive 0.7s var(--ease) both; }
1082
+ @keyframes arrive { from { opacity: 0; transform: translateY(12px) rotate(-0.8deg); filter: blur(6px); } to { opacity: 1; transform: translateY(0) rotate(0); filter: blur(0); } }
1083
+ .expl h2.title {
1084
+ color: var(--ink);
1085
+ font-family: var(--display);
1086
+ font-size: clamp(36px, 4.4vw, 58px);
1087
+ line-height: 0.94;
1088
+ letter-spacing: -0.045em;
1089
+ font-weight: 780;
1090
+ font-variation-settings: "SOFT" 70, "WONK" 1;
1091
+ }
1092
+ .expl h2.title small { color: rgba(19,32,42,0.52); }
1093
+ .section { margin-bottom: 22px; }
1094
+ .section .tag { color: var(--wax-ink); }
1095
+ .section .text { color: var(--ink); font-size: 18.5px; }
1096
+ .section.opener .text {
1097
+ color: #243545;
1098
+ background: rgba(255,255,255,0.34);
1099
+ border-left: 0;
1100
+ border-radius: 18px 18px 18px 4px;
1101
+ padding: 14px 16px;
1102
+ box-shadow: inset 0 0 0 1px rgba(143,56,31,0.09);
1103
+ }
1104
+ .section.closer .text { color: var(--wax-ink); }
1105
+ .section.followup .text { color: rgba(19,32,42,0.62); }
1106
+ .audio-panel {
1107
+ border-top-color: rgba(143,56,31,0.14);
1108
+ background: rgba(255,255,255,0.25);
1109
+ border-radius: 20px 20px 20px 6px;
1110
+ padding: 16px;
1111
+ }
1112
+ .btn-read {
1113
+ color: var(--vellum-2);
1114
+ background: var(--night-2);
1115
+ border: 1px solid rgba(19,32,42,0.88);
1116
+ border-radius: 999px;
1117
+ padding: 10px 14px;
1118
+ }
1119
+ .btn-read:hover { background: var(--wax-ink); color: var(--vellum-2); border-color: var(--wax-ink); }
1120
+ .audio-panel .audio-status { color: rgba(19,32,42,0.62); }
1121
+ .audio-panel audio { filter: sepia(0.18) saturate(0.8); }
1122
+ .banner { color: var(--wax-ink); border-left-color: var(--wax); }
1123
+ .colophon {
1124
+ border-top-color: rgba(216,181,106,0.16);
1125
+ color: rgba(219,227,221,0.54);
1126
+ }
1127
+ .colophon b { color: var(--gold) !important; }
1128
+ .colophon a { color: var(--bluefire); border-bottom-color: rgba(140,199,216,0.42); }
1129
+ .colophon a:hover { color: var(--vellum-2); border-color: var(--vellum-2); }
1130
+
1131
+ @media (max-width: 980px) {
1132
+ .imprint { align-items: flex-start; flex-direction: column; }
1133
+ .imprint .meta { flex-wrap: wrap; gap: 10px 18px; }
1134
+ .stage { grid-template-columns: 1fr; }
1135
+ .col-form { position: static; }
1136
+ .book { transform: none; }
1137
+ }
1138
+
1139
+ @media (max-width: 560px) {
1140
+ .col-form { padding: 20px; }
1141
+ .title-block h1 { max-width: 9ch; }
1142
+ .tone-row { grid-template-columns: 1fr; border-radius: 22px; }
1143
+ .book { border-radius: 24px 24px 24px 6px; }
1144
+ }
1145
+ </style>
1146
+ </head>
1147
+ <body>
1148
+
1149
+ <header class="imprint" role="banner">
1150
+ <div class="wordmark"><b>Fabella</b> &mdash; a quiet instrument for hard questions</div>
1151
+ <div class="meta">
1152
+ <span><b>Track I</b> &middot; Backyard AI</span>
1153
+ <span>Gemma <b>4B</b> &middot; Nemotron <b>4B</b> &middot; VoxCPM2 &middot; Modal</span>
1154
+ </div>
1155
+ </header>
1156
+
1157
+ <main class="stage" role="main">
1158
+
1159
+ <section class="col-form" aria-label="The situation">
1160
+ <div class="title-block">
1161
+ <h1>What's the <em>hard thing</em>?</h1>
1162
+ <p class="lede">Tell Fabella the situation in a sentence or two. She drafts a small, careful script, has another model check it, then can read it back in a calm voice.</p>
1163
+ </div>
1164
+
1165
+ <form id="explain-form" novalidate>
1166
+ <div class="field">
1167
+ <label class="label" for="situation">The situation</label>
1168
+ <textarea id="situation" name="situation" placeholder="e.g. My 7-year-old's grandma is in the hospital for surgery. She keeps asking why grandma won't come home." maxlength="600" required></textarea>
1169
+ <div class="hint">A sentence or two is enough. The more concrete, the better the explanation.</div>
1170
+ </div>
1171
+
1172
+ <div class="field">
1173
+ <span class="label">Or start from an example</span>
1174
+ <div class="examples" id="examples"></div>
1175
+ </div>
1176
+
1177
+ <div class="field">
1178
+ <label class="label" for="age-range">The child's age</label>
1179
+ <div class="age">
1180
+ <input id="age-range" name="age" type="range" min="5" max="12" step="1" value="7" />
1181
+ <div class="readout"><span id="age-readout">7</span><small>years</small></div>
1182
+ </div>
1183
+ </div>
1184
+
1185
+ <div class="field">
1186
+ <label class="label" for="child-name">Child's name <small>(optional)</small></label>
1187
+ <input id="child-name" name="child_name" type="text" placeholder="leave empty to address the parent" maxlength="30" autocomplete="off" />
1188
+ </div>
1189
+
1190
+ <div class="field">
1191
+ <span class="label">Tone</span>
1192
+ <div class="tone-row" id="tone-row" role="radiogroup" aria-label="Tone"></div>
1193
+ </div>
1194
+
1195
+ <div class="actions">
1196
+ <button type="submit" id="submit-btn" class="btn-primary">
1197
+ <span id="submit-label">Draft an explanation</span>
1198
+ <span class="arrow" aria-hidden="true">&rarr;</span>
1199
+ </button>
1200
+ <button type="button" id="regen-btn" class="btn-ghost" disabled>New version</button>
1201
+ <span class="seed-pill" id="seed-pill">N&deg;&nbsp;0</span>
1202
+ </div>
1203
+ </form>
1204
+ </section>
1205
+
1206
+ <section class="col-story" aria-label="The explanation">
1207
+ <article class="book" id="book">
1208
+ <svg class="corner tl" viewBox="0 0 22 22" fill="none" stroke="currentColor" stroke-width="1"><path d="M2 8 L2 2 L8 2 M2 4 Q10 4 10 10"/></svg>
1209
+ <svg class="corner tr" viewBox="0 0 22 22" fill="none" stroke="currentColor" stroke-width="1"><path d="M2 8 L2 2 L8 2 M2 4 Q10 4 10 10"/></svg>
1210
+ <svg class="corner bl" viewBox="0 0 22 22" fill="none" stroke="currentColor" stroke-width="1"><path d="M2 8 L2 2 L8 2 M2 4 Q10 4 10 10"/></svg>
1211
+ <svg class="corner br" viewBox="0 0 22 22" fill="none" stroke="currentColor" stroke-width="1"><path d="M2 8 L2 2 L8 2 M2 4 Q10 4 10 10"/></svg>
1212
+
1213
+ <div class="inner" id="page">
1214
+ <div class="cover" id="cover">
1215
+ <div class="folio">Night folio &middot; private draft</div>
1216
+ <h2>Put the hard thing on the table.<br/><b>Leave with words.</b></h2>
1217
+ <p>Fill in the situation on the left. Fabella writes the first careful version, checks it against a child-language rubric, and keeps VoxCPM2 ready if you want to hear it aloud.</p>
1218
+ </div>
1219
+ </div>
1220
+ </article>
1221
+ </section>
1222
+
1223
+ </main>
1224
+
1225
+ <footer class="colophon">
1226
+ <span>Set in <b>Fraunces</b> and <b>Literata</b> &middot; Spoken by VoxCPM2 on demand</span>
1227
+ <span>Built for the <a href="https://huggingface.co/spaces/build-small-hackathon/README" target="_blank" rel="noopener">Build Small Hackathon</a> &middot; 2026</span>
1228
+ </footer>
1229
+
1230
+ <script>
1231
+ (function () {
1232
+ "use strict";
1233
+
1234
+ const TONE_CHOICES = __TONE_CHOICES__;
1235
+ const EXAMPLES = __EXAMPLES__;
1236
+ const SECTION_SEP = "\x1f";
1237
+
1238
+ // example chips
1239
+ const exEl = document.getElementById("examples");
1240
+ EXAMPLES.forEach((s, i) => {
1241
+ const b = document.createElement("button");
1242
+ b.type = "button";
1243
+ b.className = "ex-chip";
1244
+ b.innerHTML = "<small>Example " + (i + 1) + "</small>" + escapeHTML(s);
1245
+ b.addEventListener("click", () => {
1246
+ document.getElementById("situation").value = s;
1247
+ document.getElementById("situation").focus();
1248
+ });
1249
+ exEl.appendChild(b);
1250
+ });
1251
+
1252
+ // tone
1253
+ const toneRow = document.getElementById("tone-row");
1254
+ TONE_CHOICES.forEach(([val, label], i) => {
1255
+ const lbl = document.createElement("label");
1256
+ lbl.textContent = label;
1257
+ const input = document.createElement("input");
1258
+ input.type = "radio"; input.name = "tone"; input.value = val; input.checked = (i === 0);
1259
+ lbl.appendChild(input);
1260
+ lbl.className = input.checked ? "is-on" : "";
1261
+ lbl.addEventListener("click", () => {
1262
+ toneRow.querySelectorAll("label").forEach(x => x.classList.remove("is-on"));
1263
+ lbl.classList.add("is-on");
1264
+ input.checked = true;
1265
+ });
1266
+ toneRow.appendChild(lbl);
1267
+ });
1268
+
1269
+ // age
1270
+ const ageRange = document.getElementById("age-range");
1271
+ const ageReadout = document.getElementById("age-readout");
1272
+ ageRange.addEventListener("input", () => { ageReadout.textContent = ageRange.value; });
1273
+
1274
+ // form
1275
+ const form = document.getElementById("explain-form");
1276
+ const submitBtn = document.getElementById("submit-btn");
1277
+ const submitLabel = document.getElementById("submit-label");
1278
+ const regenBtn = document.getElementById("regen-btn");
1279
+ const page = document.getElementById("page");
1280
+ const seedPill = document.getElementById("seed-pill");
1281
+ let seed = 0;
1282
+ let lastResult = null;
1283
+ let lastAudioText = "";
1284
+
1285
+ function setBusy(busy) {
1286
+ submitBtn.disabled = busy;
1287
+ regenBtn.disabled = busy || !lastResult;
1288
+ submitLabel.textContent = busy
1289
+ ? "Composing…"
1290
+ : (lastResult ? "Draft another" : "Draft an explanation");
1291
+ }
1292
+
1293
+ function renderWriting() {
1294
+ page.innerHTML =
1295
+ '<div class="writing">' +
1296
+ '<div class="penline"><span class="quill" aria-hidden="true"></span><span>Drafting, with care</span></div>' +
1297
+ '<div class="progress" id="progress" style="--p:8%"></div>' +
1298
+ '<p>Fabella is drafting, then a second small model is checking the explanation against a six-criterion rubric (clarity, age, warmth, no moralizing, no scary content, concrete).</p>' +
1299
+ '</div>';
1300
+ let p = 8;
1301
+ const tick = setInterval(() => {
1302
+ p = Math.min(p + 4 + Math.random() * 6, 88);
1303
+ const el = document.getElementById("progress");
1304
+ if (el) el.style.setProperty("--p", p + "%");
1305
+ else clearInterval(tick);
1306
+ }, 320);
1307
+ return () => clearInterval(tick);
1308
+ }
1309
+
1310
+ function escapeHTML(s) {
1311
+ return String(s).replace(/[&<>"']/g, c => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
1312
+ }
1313
+
1314
+ function renderExplanation(sections) {
1315
+ const opener = sections[0] || "";
1316
+ const body = sections[1] || "";
1317
+ const closer = sections[2] || "";
1318
+ const followup = sections[3] || "";
1319
+ lastAudioText = [opener, body, closer, followup].filter(Boolean).join("\n\n");
1320
+ const childName = (document.getElementById("child-name").value || "").trim();
1321
+ const toneLabel = (document.querySelector('#tone-row input:checked') || {}).value || "gentle";
1322
+ const ageStr = ageRange.value;
1323
+ const dateStr = new Date().toLocaleDateString(undefined, {year:"numeric",month:"short",day:"numeric"});
1324
+
1325
+ const openerHTML = opener
1326
+ ? '<section class="section opener"><span class="tag">Opener — say this first</span><p class="text">' + escapeHTML(opener) + '</p></section>'
1327
+ : "";
1328
+ const bodyHTML = body
1329
+ ? '<section class="section body"><span class="tag">The explanation — read this aloud</span><div class="text">' +
1330
+ body.split(/\n\s*\n/).filter(Boolean).map(p => '<p>' + escapeHTML(p.trim()).replace(/\n/g, "<br/>") + '</p>').join("") +
1331
+ '</div></section>'
1332
+ : "";
1333
+ const closerHTML = closer
1334
+ ? '<section class="section closer"><span class="tag">Closer — say this to land it</span><p class="text">' + escapeHTML(closer) + '</p></section>'
1335
+ : "";
1336
+ const followupHTML = followup
1337
+ ? '<section class="section followup"><span class="tag">If they ask another question</span><p class="text">' + escapeHTML(followup) + '</p></section>'
1338
+ : "";
1339
+
1340
+ page.innerHTML =
1341
+ '<div class="expl">' +
1342
+ '<div class="byline">' +
1343
+ '<span>Folio I</span><span class="dot" aria-hidden="true"></span>' +
1344
+ '<span>For a ' + escapeHTML(ageStr) + '-year-old' + (childName ? ' named ' + escapeHTML(childName) : '') + '</span>' +
1345
+ '<span class="dot" aria-hidden="true"></span>' +
1346
+ '<span>' + escapeHTML(toneLabel) + '</span>' +
1347
+ '</div>' +
1348
+ '<h2 class="title">A short explanation<small>Read aloud &middot; revise if you want</small></h2>' +
1349
+ openerHTML + bodyHTML + closerHTML + followupHTML +
1350
+ '<div class="audio-panel" id="audio-panel">' +
1351
+ '<button type="button" class="btn-read" id="read-aloud">Read aloud</button>' +
1352
+ '<span class="audio-status" id="audio-status">VoxCPM2 narration runs only when you ask for it.</span>' +
1353
+ '<audio id="audio-player" controls hidden></audio>' +
1354
+ '</div>' +
1355
+ '<div class="signoff">' +
1356
+ '<span>End of folio &middot; ' + dateStr + '</span>' +
1357
+ '<button type="button" class="regen" id="regen-inline">New version &rarr;</button>' +
1358
+ '</div>' +
1359
+ '</div>';
1360
+ const ri = document.getElementById("regen-inline");
1361
+ if (ri) ri.addEventListener("click", () => regenBtn.click());
1362
+ const readBtn = document.getElementById("read-aloud");
1363
+ if (readBtn) readBtn.addEventListener("click", readAloud);
1364
+ }
1365
+
1366
+ function renderError(msg) {
1367
+ page.innerHTML =
1368
+ '<div class="expl">' +
1369
+ '<div class="byline"><span>Folio I</span><span class="dot" aria-hidden="true"></span><span>Hold on a moment</span></div>' +
1370
+ '<div class="banner">' + escapeHTML(msg) + '</div>' +
1371
+ '</div>';
1372
+ }
1373
+
1374
+ async function callMakeExplanation(useSeed) {
1375
+ const data = {
1376
+ situation: document.getElementById("situation").value,
1377
+ age: parseInt(ageRange.value, 10),
1378
+ child_name: document.getElementById("child-name").value,
1379
+ tone: (document.querySelector('#tone-row input:checked') || {}).value || "gentle",
1380
+ seed: useSeed,
1381
+ };
1382
+ const res = await fetch("/gradio_api/call/make_explanation", {
1383
+ method: "POST",
1384
+ headers: { "Content-Type": "application/json" },
1385
+ body: JSON.stringify({ data: [data.situation, data.age, data.child_name, data.tone, data.seed] }),
1386
+ });
1387
+ if (!res.ok) {
1388
+ const t = await res.text();
1389
+ throw new Error("HTTP " + res.status + ": " + t.slice(0, 200));
1390
+ }
1391
+ const evt0 = await res.json();
1392
+ const eventId = evt0.event_id;
1393
+ const evt = await fetch("/gradio_api/call/make_explanation/" + eventId, { headers: { Accept: "text/event-stream" } });
1394
+ if (!evt.ok || !evt.body) throw new Error("SSE open failed");
1395
+ const reader = evt.body.getReader();
1396
+ const dec = new TextDecoder();
1397
+ let buf = "";
1398
+ let result = null;
1399
+ let err = null;
1400
+ while (true) {
1401
+ const { value, done } = await reader.read();
1402
+ if (done) break;
1403
+ buf += dec.decode(value, { stream: true });
1404
+ let idx;
1405
+ while ((idx = buf.indexOf("\n\n")) !== -1) {
1406
+ const frame = buf.slice(0, idx);
1407
+ buf = buf.slice(idx + 2);
1408
+ const line = frame.split("\n").find(l => l.startsWith("data: "));
1409
+ if (!line) continue;
1410
+ const payload = line.slice(6).trim();
1411
+ if (payload === "null" || payload === "") continue;
1412
+ try {
1413
+ const obj = JSON.parse(payload);
1414
+ if (obj.msg === "process_completed") {
1415
+ if (obj.success) {
1416
+ const out = obj.output && obj.output.data;
1417
+ let text = null;
1418
+ if (Array.isArray(out)) text = out.find(v => typeof v === "string");
1419
+ else if (typeof out === "string") text = out;
1420
+ if (text) {
1421
+ // 4 sections joined by U+001F
1422
+ result = text.split(SECTION_SEP);
1423
+ if (result.length < 4) {
1424
+ while (result.length < 4) result.push("");
1425
+ }
1426
+ }
1427
+ } else {
1428
+ err = (obj.output && obj.output.error) || "Generation failed";
1429
+ }
1430
+ }
1431
+ } catch (_) {}
1432
+ }
1433
+ }
1434
+ if (err) throw new Error(err);
1435
+ if (!result) throw new Error("No result");
1436
+ return result;
1437
+ }
1438
+
1439
+ async function readGradioString(apiName, data) {
1440
+ const res = await fetch("/gradio_api/call/" + apiName, {
1441
+ method: "POST",
1442
+ headers: { "Content-Type": "application/json" },
1443
+ body: JSON.stringify({ data: data }),
1444
+ });
1445
+ if (!res.ok) {
1446
+ const t = await res.text();
1447
+ throw new Error("HTTP " + res.status + ": " + t.slice(0, 200));
1448
+ }
1449
+ const evt0 = await res.json();
1450
+ const eventId = evt0.event_id;
1451
+ const evt = await fetch("/gradio_api/call/" + apiName + "/" + eventId, { headers: { Accept: "text/event-stream" } });
1452
+ if (!evt.ok || !evt.body) throw new Error("SSE open failed");
1453
+ const reader = evt.body.getReader();
1454
+ const dec = new TextDecoder();
1455
+ let buf = "";
1456
+ let result = null;
1457
+ let err = null;
1458
+ while (true) {
1459
+ const { value, done } = await reader.read();
1460
+ if (done) break;
1461
+ buf += dec.decode(value, { stream: true });
1462
+ let idx;
1463
+ while ((idx = buf.indexOf("\n\n")) !== -1) {
1464
+ const frame = buf.slice(0, idx);
1465
+ buf = buf.slice(idx + 2);
1466
+ const line = frame.split("\n").find(l => l.startsWith("data: "));
1467
+ if (!line) continue;
1468
+ const payload = line.slice(6).trim();
1469
+ if (payload === "null" || payload === "") continue;
1470
+ try {
1471
+ const obj = JSON.parse(payload);
1472
+ if (obj.msg === "process_completed") {
1473
+ if (obj.success) {
1474
+ const out = obj.output && obj.output.data;
1475
+ if (Array.isArray(out)) result = out.find(v => typeof v === "string") || null;
1476
+ else if (typeof out === "string") result = out;
1477
+ } else {
1478
+ err = (obj.output && obj.output.error) || "Request failed";
1479
+ }
1480
+ }
1481
+ } catch (_) {}
1482
+ }
1483
+ }
1484
+ if (err) throw new Error(err);
1485
+ if (!result) throw new Error("No result");
1486
+ return result;
1487
+ }
1488
+
1489
+ async function readAloud() {
1490
+ const btn = document.getElementById("read-aloud");
1491
+ const status = document.getElementById("audio-status");
1492
+ const player = document.getElementById("audio-player");
1493
+ if (!btn || !status || !player || !lastAudioText) return;
1494
+ btn.disabled = true;
1495
+ status.textContent = "Warming VoxCPM2 and preparing narration…";
1496
+ player.hidden = true;
1497
+ player.removeAttribute("src");
1498
+ try {
1499
+ const tone = (document.querySelector('#tone-row input:checked') || {}).value || "gentle";
1500
+ const audioUrl = await readGradioString("make_audio", [lastAudioText, tone]);
1501
+ if (audioUrl.startsWith("ERROR:")) throw new Error(audioUrl.slice(6).trim());
1502
+ player.src = audioUrl;
1503
+ player.hidden = false;
1504
+ status.textContent = "Ready. Press play when you want to listen.";
1505
+ await player.play().catch(() => {});
1506
+ } catch (err) {
1507
+ status.textContent = String(err.message || err);
1508
+ } finally {
1509
+ btn.disabled = false;
1510
+ }
1511
+ }
1512
+
1513
+ form.addEventListener("submit", async (e) => {
1514
+ e.preventDefault();
1515
+ const sitEl = document.getElementById("situation");
1516
+ if (!sitEl.value.trim()) {
1517
+ sitEl.focus();
1518
+ sitEl.style.borderColor = "var(--wax)";
1519
+ setTimeout(() => { sitEl.style.borderColor = ""; }, 1400);
1520
+ return;
1521
+ }
1522
+ setBusy(true);
1523
+ const stopProgress = renderWriting();
1524
+ try {
1525
+ const sections = await callMakeExplanation(seed);
1526
+ lastResult = sections;
1527
+ stopProgress();
1528
+ renderExplanation(sections);
1529
+ } catch (err) {
1530
+ stopProgress();
1531
+ renderError(String(err.message || err));
1532
+ } finally {
1533
+ setBusy(false);
1534
+ }
1535
+ });
1536
+
1537
+ regenBtn.addEventListener("click", async () => {
1538
+ if (!document.getElementById("situation").value.trim()) return;
1539
+ seed += 1;
1540
+ seedPill.innerHTML = "N&deg;&nbsp;" + seed;
1541
+ setBusy(true);
1542
+ const stopProgress = renderWriting();
1543
+ try {
1544
+ const sections = await callMakeExplanation(seed);
1545
+ lastResult = sections;
1546
+ stopProgress();
1547
+ renderExplanation(sections);
1548
+ } catch (err) {
1549
+ stopProgress();
1550
+ renderError(String(err.message || err));
1551
+ } finally {
1552
+ setBusy(false);
1553
+ }
1554
+ });
1555
+ })();
1556
+ </script>
1557
+ </body>
1558
+ </html>
1559
+ """
1560
+
1561
+ INDEX_HTML = (
1562
+ INDEX_HTML
1563
+ .replace("__TONE_CHOICES__", "[" + ",".join('["' + v + '","' + l + '"]' for v, l in TONE_CHOICES) + "]")
1564
+ .replace("__EXAMPLES__", "[" + ",".join('"' + s.replace('"', '\\"') + '"' for s in EXAMPLE_SITUATIONS) + "]")
1565
+ )
1566
+
1567
+
1568
+ @app.get("/", response_class=HTMLResponse)
1569
+ async def homepage():
1570
+ return HTMLResponse(content=INDEX_HTML, status_code=200)
1571
+
1572
+
1573
+ @app.get("/health")
1574
+ async def health():
1575
+ return {"status": "ok"}
1576
 
1577
 
1578
  if __name__ == "__main__":
1579
+ app.launch(
1580
+ server_name="0.0.0.0",
1581
+ server_port=int(os.environ.get("PORT", 7860)),
1582
+ )
generator.py DELETED
@@ -1,30 +0,0 @@
1
- """Dispatcher: routes to mock or real based on USE_MOCK env var."""
2
-
3
- import os
4
- import sys
5
-
6
- sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
7
-
8
- from mock import mock_story
9
- from schema import StoryRequest
10
-
11
-
12
- def _truthy(val: str | None, default: bool = True) -> bool:
13
- if val is None:
14
- return default
15
- return val.strip().lower() not in ("0", "false", "no", "off", "")
16
-
17
-
18
- USE_MOCK = _truthy(os.environ.get("USE_MOCK"), default=True)
19
-
20
-
21
- def generate_story(req: StoryRequest) -> tuple[str, str, str]:
22
- """Returns (title, body, mode_badge_text)."""
23
- if USE_MOCK:
24
- title, body = mock_story(req.name, req.age, req.themes, req.moral, req.length, req.seed)
25
- return title, body, "Mock mode"
26
-
27
- from real import generate_real
28
-
29
- title, body = generate_real(req)
30
- return title, body, "Real model"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
judge.py ADDED
@@ -0,0 +1,173 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Structured judge for Fabella explanations.
2
+
3
+ The judge task is bounded: one rubric, one draft, one structured verdict.
4
+ No tools, no agent loop, no state machine. So we use a single LLM call
5
+ plus Pydantic validation, with one repair retry on failure.
6
+
7
+ The drafter stays on LangGraph (ReAct, validate_explanation tool, revise
8
+ loop). The judge is its own concern.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import re
15
+
16
+ from langchain_core.messages import HumanMessage, SystemMessage
17
+
18
+ from safety import age_bucket
19
+ from schema import JudgeFailed, JudgeVerdict
20
+
21
+
22
+ SYSTEM_PROMPT = """You are a strict editor reviewing an explanation a parent will
23
+ read aloud to their child.
24
+
25
+ You will receive a draft (in the Opener / Body / Closer / optional
26
+ 'If they ask more' shape) and a rubric.
27
+
28
+ You must respond with EXACTLY ONE JSON object, with this exact schema:
29
+
30
+ {
31
+ "ok": true|false, // true if the draft is good enough
32
+ "issues": ["...", ...], // concrete problems; [] if ok=true
33
+ "score": 0.0, // 0.0..1.0, >=0.8 = approve-worthy
34
+ "verdict": "approve" | "revise", // your recommendation
35
+ "reasoning": "..." // one short sentence (max ~30 words)
36
+ }
37
+
38
+ Hard rules:
39
+ - Output ONLY that JSON object. No prose. No markdown. No code fences.
40
+ - "ok" and "verdict" must agree: if ok=true then verdict="approve";
41
+ if ok=false then verdict="revise".
42
+ - Every issue must be a CONCRETE, ACTIONABLE change. "Make it better"
43
+ is not an issue. "Body is too short (39 words, target 60-130)" is.
44
+ - The score is a float in [0.0, 1.0]. Use the full range. 1.0 means
45
+ the draft is exemplary; 0.0 means it's broken or off-topic.
46
+ - Reasoning must be one short sentence, max ~30 words.
47
+ """
48
+
49
+
50
+ REPAIR_PROMPT = """Your previous response was not parseable as the required JSON
51
+ object. The expected schema is:
52
+
53
+ {{ok, issues, score, verdict, reasoning}}
54
+
55
+ Repair instructions:
56
+ - Return ONLY one JSON object. No prose, no markdown, no code fences.
57
+ - "ok" and "verdict" must agree.
58
+ - "score" is a number in [0.0, 1.0].
59
+ - "issues" is a list of strings (empty if ok=true).
60
+ - "reasoning" is a short single sentence.
61
+
62
+ Here was your last response (unparseable):
63
+
64
+ {last}
65
+
66
+ Now respond again with ONLY the JSON object."""
67
+
68
+
69
+ def _build_rubric(req_age: int, req_tone: str, child_name: str, situation: str) -> str:
70
+ bucket = age_bucket(req_age)
71
+ vocab = {
72
+ "young": "very simple sentences (under 12 words each), short paragraphs, no abstract or figurative language",
73
+ "middle": "clear sentences, paragraphs of 3-5 sentences, concrete metaphors are fine",
74
+ "older": "richer vocabulary and slightly longer paragraphs are fine, but keep it direct",
75
+ }[bucket]
76
+ name_hint = (
77
+ f"The child's name is '{child_name}'. Use it naturally once."
78
+ if child_name else "No name was given. Address the parent ('your child') or use 'you'."
79
+ )
80
+ return (
81
+ f"The child is {req_age} years old ({bucket} reader). Target vocabulary: {vocab}.\n"
82
+ f"The tone is {req_tone}.\n"
83
+ f"{name_hint}\n"
84
+ f"The parent's situation (for context, not for inclusion in the explanation):\n"
85
+ f" {situation}\n\n"
86
+ f"Evaluate the draft against this rubric:\n"
87
+ f"1. The Opener, Body, and Closer are all present and clearly labelled.\n"
88
+ f"2. The body addresses the child's likely feeling in the first paragraph.\n"
89
+ f"3. The body explains the situation in concrete, age-appropriate terms. No abstract or vague language.\n"
90
+ f"4. The body is roughly 60-130 words. It does NOT lecture or moralize.\n"
91
+ f"5. The closer feels like something a real parent would say. Not therapist-speak. Not corporate.\n"
92
+ f"6. No scary imagery, no threats, no vivid descriptions of harm. The explanation does not invent facts."
93
+ )
94
+
95
+
96
+ def _extract_json(text: str) -> str | None:
97
+ """Pull the first {...} block from the model's text output.
98
+
99
+ Tolerant of:
100
+ - Leading prose like "We need to evaluate..."
101
+ - Markdown code fences
102
+ - Pretty-printed JSON with newlines
103
+
104
+ Returns the candidate JSON string, or None if no brace block is found.
105
+ """
106
+ if not text:
107
+ return None
108
+ t = text.strip()
109
+ if t.startswith("```"):
110
+ t = re.sub(r"^```(?:json)?\s*", "", t)
111
+ t = re.sub(r"\s*```\s*$", "", t)
112
+ i, j = t.find("{"), t.rfind("}")
113
+ if i < 0 or j < 0 or j <= i:
114
+ return None
115
+ return t[i : j + 1]
116
+
117
+
118
+ def judge_explanation(
119
+ llm,
120
+ draft: str,
121
+ req_age: int,
122
+ req_tone: str,
123
+ child_name: str,
124
+ situation: str,
125
+ ) -> JudgeVerdict:
126
+ """Ask the small judge model for a structured verdict on `draft`.
127
+
128
+ Tries once, then once more with a repair prompt if the first response
129
+ doesn't validate. Raises `JudgeFailed` if both attempts fail; the
130
+ caller is expected to fall back to the rule-based check.
131
+ """
132
+ rubric = _build_rubric(req_age, req_tone, child_name, situation)
133
+ user = (
134
+ f"Draft to evaluate:\n{draft}\n\nRubric:\n{rubric}\n\n"
135
+ f"Respond with ONLY the JSON object, no prose."
136
+ )
137
+ last_text = ""
138
+ for attempt in (1, 2):
139
+ if attempt == 1:
140
+ messages = [
141
+ SystemMessage(content=SYSTEM_PROMPT),
142
+ HumanMessage(content=user),
143
+ ]
144
+ else:
145
+ messages = [
146
+ SystemMessage(content=SYSTEM_PROMPT),
147
+ HumanMessage(content=REPAIR_PROMPT.format(last=last_text)),
148
+ ]
149
+ try:
150
+ resp = llm.invoke(messages)
151
+ except Exception as e:
152
+ if attempt == 2:
153
+ raise JudgeFailed(f"judge LLM call failed: {e}", last_text=last_text) from e
154
+ continue
155
+
156
+ last_text = (resp.content if isinstance(resp.content, str) else str(resp.content)).strip()
157
+ candidate = _extract_json(last_text)
158
+ if candidate is None:
159
+ continue
160
+ try:
161
+ verdict = JudgeVerdict.model_validate_json(candidate)
162
+ except Exception:
163
+ continue
164
+
165
+ # Cross-field consistency: ok and verdict must agree.
166
+ if verdict.ok and verdict.verdict != "approve":
167
+ verdict = verdict.model_copy(update={"verdict": "approve"})
168
+ elif (not verdict.ok) and verdict.verdict != "revise":
169
+ verdict = verdict.model_copy(update={"verdict": "revise"})
170
+
171
+ return verdict
172
+
173
+ raise JudgeFailed("judge output was not parseable JSON", last_text=last_text)
llm.py ADDED
@@ -0,0 +1,252 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FabellaVLLM - LangChain BaseChatModel wrapping vLLM endpoint.
2
+
3
+ Uses vLLM's native tool-calling pipeline for Gemma 4. The server is started
4
+ with ``--enable-auto-tool-choice --tool-call-parser gemma4`` (see
5
+ ``modal_app.py``), which makes vLLM parse the model's native
6
+ ``<|tool_call>...<tool_call|>`` markers into OpenAI-spec ``tool_calls`` JSON.
7
+ This client passes the tool specs in OpenAI format and reads the parsed
8
+ ``tool_calls`` straight off the response.
9
+ """
10
+
11
+ import os
12
+ import sys
13
+ from typing import Any
14
+
15
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
16
+
17
+ from langchain_core.language_models import BaseChatModel
18
+ from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage
19
+ from langchain_core.outputs import ChatGeneration, ChatResult
20
+ from pydantic import Field, PrivateAttr
21
+ from openai import OpenAI
22
+
23
+
24
+ class FabellaVLLM(BaseChatModel):
25
+ """LangChain chat model backed by vLLM OpenAI-compatible API."""
26
+
27
+ base_url: str = Field(default="https://khoitruong071510--fabella-serve-drafter.modal.run")
28
+ model_name: str = "gemma-4"
29
+ temperature: float = 0.9
30
+ top_p: float = 0.95
31
+ max_tokens: int = 4096
32
+ seed: int = 0
33
+
34
+ _client: Any = PrivateAttr(default=None)
35
+ _tools: list[dict] = PrivateAttr(default_factory=list)
36
+ _tool_call_id: int = PrivateAttr(default=0)
37
+
38
+ @property
39
+ def _llm_type(self) -> str:
40
+ return "fabella-vllm"
41
+
42
+ @property
43
+ def _identifying_params(self) -> dict:
44
+ return {
45
+ "base_url": self.base_url,
46
+ "model_name": self.model_name,
47
+ "temperature": self.temperature,
48
+ "top_p": self.top_p,
49
+ "max_tokens": self.max_tokens,
50
+ "seed": self.seed,
51
+ }
52
+
53
+ def _get_client(self) -> OpenAI:
54
+ if self._client is None:
55
+ self._client = OpenAI(
56
+ base_url=f"{self.base_url}/v1",
57
+ api_key="EMPTY",
58
+ )
59
+ return self._client
60
+
61
+ def bind_tools(self, tools: list, **kwargs): # type: ignore[override]
62
+ specs = []
63
+ for t in tools:
64
+ specs.append(_to_openai_tool_spec(t))
65
+ object.__setattr__(self, "_tools", specs)
66
+ object.__setattr__(self, "_tool_call_id", 0)
67
+ return self
68
+
69
+ def _generate(self, messages, stop=None, run_manager=None, **kwargs):
70
+ client = self._get_client()
71
+
72
+ system, non_system = _split_system(messages)
73
+ api_messages = []
74
+ if system:
75
+ api_messages.append({"role": "system", "content": system})
76
+ api_messages.extend(_to_api_messages(non_system))
77
+
78
+ request: dict[str, Any] = {
79
+ "model": self.model_name,
80
+ "messages": api_messages,
81
+ "temperature": self.temperature,
82
+ "top_p": self.top_p,
83
+ "max_tokens": self.max_tokens,
84
+ }
85
+ if self.seed:
86
+ request["seed"] = self.seed
87
+ if self._tools:
88
+ request["tools"] = self._tools
89
+
90
+ response = client.chat.completions.create(**request)
91
+ message = response.choices[0].message
92
+
93
+ ai_message = _parse_response_message(message, state=self)
94
+ return ChatResult(generations=[ChatGeneration(message=ai_message)])
95
+
96
+
97
+ def _split_system(messages) -> tuple[str, list]:
98
+ system_parts: list[str] = []
99
+ rest: list = []
100
+ for m in messages:
101
+ if isinstance(m, SystemMessage):
102
+ content = m.content if isinstance(m.content, str) else str(m.content)
103
+ system_parts.append(content)
104
+ else:
105
+ rest.append(m)
106
+ return "\n".join(system_parts), rest
107
+
108
+
109
+ def _to_api_messages(messages) -> list[dict]:
110
+ """Translate LangChain messages to OpenAI chat-completions format."""
111
+ out: list[dict] = []
112
+ for m in messages:
113
+ if isinstance(m, HumanMessage):
114
+ content = m.content if isinstance(m.content, str) else str(m.content)
115
+ out.append({"role": "user", "content": content})
116
+ elif isinstance(m, AIMessage):
117
+ entry: dict[str, Any] = {"role": "assistant"}
118
+ content = m.content if isinstance(m.content, str) else str(m.content)
119
+ if content:
120
+ entry["content"] = content
121
+ if m.tool_calls:
122
+ entry["tool_calls"] = [
123
+ {
124
+ "id": tc.get("id", f"call_{i}"),
125
+ "type": "function",
126
+ "function": {
127
+ "name": tc.get("name", ""),
128
+ "arguments": _dump_args(tc.get("args", {})),
129
+ },
130
+ }
131
+ for i, tc in enumerate(m.tool_calls)
132
+ ]
133
+ out.append(entry)
134
+ elif isinstance(m, ToolMessage):
135
+ content = m.content if isinstance(m.content, str) else str(m.content)
136
+ entry = {
137
+ "role": "tool",
138
+ "tool_call_id": m.tool_call_id,
139
+ "content": content,
140
+ }
141
+ out.append(entry)
142
+ else:
143
+ content = getattr(m, "content", "")
144
+ content = content if isinstance(content, str) else str(content)
145
+ out.append({"role": "user", "content": content})
146
+ return out
147
+
148
+
149
+ def _parse_response_message(message, *, state: "FabellaVLLM") -> AIMessage:
150
+ content = message.content or ""
151
+ if not message.tool_calls:
152
+ return AIMessage(content=content)
153
+
154
+ tool_calls = []
155
+ for tc in message.tool_calls:
156
+ state._tool_call_id += 1
157
+ raw_args = tc.function.arguments
158
+ args = _loads_args(raw_args)
159
+ tool_calls.append(
160
+ {
161
+ "name": tc.function.name,
162
+ "args": args,
163
+ "id": tc.id or f"call_{state._tool_call_id}",
164
+ "type": "tool_call",
165
+ }
166
+ )
167
+ return AIMessage(content=content, tool_calls=tool_calls)
168
+
169
+
170
+ def _to_openai_tool_spec(tool_obj) -> dict:
171
+ """Build an OpenAI-spec tool entry from a LangChain tool."""
172
+ name = getattr(tool_obj, "name", None) or getattr(tool_obj, "__name__", "tool")
173
+ description = (getattr(tool_obj, "description", "") or (tool_obj.__doc__ or "")).strip()
174
+ parameters = _extract_parameters(tool_obj)
175
+ return {
176
+ "type": "function",
177
+ "function": {
178
+ "name": name,
179
+ "description": description,
180
+ "parameters": parameters,
181
+ },
182
+ }
183
+
184
+
185
+ def _extract_parameters(tool_obj) -> dict:
186
+ try:
187
+ from langchain_core.tools import BaseTool
188
+
189
+ if isinstance(tool_obj, BaseTool):
190
+ schema = tool_obj.args
191
+ properties = {
192
+ name: _normalize_schema(field)
193
+ for name, field in schema.items()
194
+ }
195
+ required = [
196
+ name
197
+ for name, field in schema.items()
198
+ if field.get("type") != "null" and name not in (schema.get("additionalProperties") or {})
199
+ ]
200
+ return {
201
+ "type": "object",
202
+ "properties": properties,
203
+ "required": list(schema.keys()),
204
+ }
205
+ except Exception:
206
+ pass
207
+
208
+ if hasattr(tool_obj, "args_schema") and tool_obj.args_schema is not None:
209
+ try:
210
+ model = tool_obj.args_schema
211
+ from pydantic import BaseModel # type: ignore
212
+
213
+ if isinstance(model, type) and issubclass(model, BaseModel):
214
+ return model.model_json_schema()
215
+ if hasattr(model, "model_json_schema"):
216
+ return model.model_json_schema()
217
+ if hasattr(model, "schema"):
218
+ return model.schema()
219
+ except Exception:
220
+ pass
221
+
222
+ return {"type": "object", "properties": {}}
223
+
224
+
225
+ def _normalize_schema(field: dict) -> dict:
226
+ out = {"type": field.get("type", "string")}
227
+ if "description" in field:
228
+ out["description"] = field["description"]
229
+ if "enum" in field:
230
+ out["enum"] = field["enum"]
231
+ return out
232
+
233
+
234
+ def _dump_args(args: Any) -> str:
235
+ import json
236
+
237
+ if isinstance(args, str):
238
+ return args
239
+ return json.dumps(args, ensure_ascii=False)
240
+
241
+
242
+ def _loads_args(raw: Any) -> Any:
243
+ import json
244
+
245
+ if isinstance(raw, dict):
246
+ return raw
247
+ if not raw:
248
+ return {}
249
+ try:
250
+ return json.loads(raw)
251
+ except (TypeError, ValueError):
252
+ return {"input": raw}
mock.py DELETED
@@ -1,560 +0,0 @@
1
- """Template-based mock story generator. Pure functions, no I/O, no deps."""
2
-
3
- import os
4
- import sys
5
-
6
- sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
7
-
8
- from safety import age_bucket
9
-
10
-
11
- DEFAULT_THEME = "friends"
12
- LENGTH_PARAGRAPHS = {"short": 3, "medium": 4, "long": 5}
13
-
14
-
15
- def _select(theme: str, age: int, seed: int) -> dict:
16
- """Pick a template. Falls back to the default theme if the request is unknown."""
17
- bucket = age_bucket(age)
18
- primary, alt = THEMES.get(theme, THEMES[DEFAULT_THEME]).get(bucket, THEMES[DEFAULT_THEME]["middle"])
19
- return alt if seed % 2 else primary
20
-
21
-
22
- def mock_story(name: str, age: int, themes: list[str], moral: str, length: str, seed: int = 0) -> tuple[str, str]:
23
- name = name or "Friend"
24
- primary = (themes[0] if themes else DEFAULT_THEME).lower()
25
- extras_phrase = ", ".join(themes[1:]) if len(themes) > 1 else "the world around them"
26
-
27
- template = _select(primary, age, seed)
28
- title = template["title"].format(name=name)
29
- body = template["body"].format(
30
- name=name,
31
- extras=extras_phrase,
32
- moral=moral or "being kind to others",
33
- )
34
-
35
- paragraphs = [p.strip() for p in body.split("\n\n") if p.strip()]
36
- paragraphs = paragraphs[: LENGTH_PARAGRAPHS.get(length, 4)]
37
- return title, "\n\n".join(paragraphs)
38
-
39
-
40
- # THEMES: theme_name -> bucket -> (primary_template, alt_template)
41
- # Each template is {"title": str, "body": str} with .format(name, extras, moral) placeholders.
42
- # Two variants per (theme, bucket) drive seed rotation: even seed -> primary, odd -> alt.
43
-
44
- THEMES: dict[str, dict[str, tuple[dict, dict]]] = {
45
- "dinosaurs": {
46
- "young": (
47
- {
48
- "title": "{name} and the Gentle Giant",
49
- "body": (
50
- "One sunny morning, {name} packed a small lunch and walked to the edge of a big green field.\n\n"
51
- "There, behind the tall ferns, a very large dinosaur was sleeping. It was a Brontosaurus, and it had the kindest eyes {name} had ever seen.\n\n"
52
- "The dinosaur opened one eye and whispered hello. {name} sat beside {extras}, and together they watched the clouds turn into shapes.\n\n"
53
- "Before {name} went home, the dinosaur taught a small lesson: {moral}. {name} smiled, and the dinosaur smiled back."
54
- ),
55
- },
56
- {
57
- "title": "{name} and the Singing Egg",
58
- "body": (
59
- "Behind a bush in the garden, {name} found an egg that hummed a tiny tune.\n\n"
60
- "When the egg cracked open, out stepped a small dinosaur with feathers the color of sunrise.\n\n"
61
- "It followed {name} through the yard, peeking at {extras} and chirping happily at the sky.\n\n"
62
- "Before the day was over, the little dinosaur taught {name} a song about {moral}. {name} hummed it all through dinner."
63
- ),
64
- },
65
- ),
66
- "middle": (
67
- {
68
- "title": "{name} and the Dinosaur Who Forgot",
69
- "body": (
70
- "{name} had heard stories about a small valley where a young dinosaur had lost its way.\n\n"
71
- "When {name} arrived, the dinosaur was hiding under a wide leaf, looking very worried. Its name was Pip, and it had forgotten the path back to its family.\n\n"
72
- "Together, {name} and Pip followed the sound of the river, meeting {extras} along the way. Each new friend reminded Pip of a part of the trail.\n\n"
73
- "When they reached the herd, Pip's mother bowed her long neck low. She said that being brave and asking for help was the same thing as {moral}.\n\n"
74
- "{name} waved goodbye and walked home, a little taller than before."
75
- ),
76
- },
77
- {
78
- "title": "{name} and the Trail of Clues",
79
- "body": (
80
- "On a school trip to the museum, {name} noticed a fossil that didn't match the others.\n\n"
81
- "The plaque said it had been found near {extras}, in a place no one visited anymore.\n\n"
82
- "With permission, {name} and the class went to look. They found not just a fossil, but a small footprint, fresh as morning.\n\n"
83
- "Back at school, the teacher said the most important lesson of the day was {moral}, and {name} nodded, already planning the next trip."
84
- ),
85
- },
86
- ),
87
- "older": (
88
- {
89
- "title": "The Day {name} Met the Last Diplodocus",
90
- "body": (
91
- "The map in {name}'s grandparents' attic was old and faded, but the path it described still wound through the hills behind the house.\n\n"
92
- "{name} followed it carefully, past the old oak, past the river that sang, until the trees opened onto a clearing no one else seemed to know.\n\n"
93
- "There, in a circle of soft moss, stood a young Diplodocus, humming to itself. It looked up, surprised but not afraid, and explained that it had been waiting for someone curious enough to find it.\n\n"
94
- "They talked for a long while. The dinosaur spoke of {extras}, and of a quiet truth: {moral}. {name} listened, and learned that some lessons grow slowly, like the tallest trees.\n\n"
95
- "On the walk home, the wind carried a gentle hum, and {name} understood that the valley would always be there, waiting."
96
- ),
97
- },
98
- {
99
- "title": "{name} and the Cartographer of Old Bones",
100
- "body": (
101
- "In the basement of the public library, {name} found a drawer marked DO NOT OPEN in friendly red letters.\n\n"
102
- "Inside were hand-drawn maps, and on the oldest, a star marking a place where the bones of small dinosaurs could still be found.\n\n"
103
- "{name} followed the map across the river, past {extras}, and into a quiet canyon that smelled faintly of rain and stone.\n\n"
104
- "There, beneath a flat rock, lay a single bone, carefully placed, as if waiting. {name} understood, suddenly, the rule of {moral}.\n\n"
105
- "On the walk home, the wind hummed through the canyon, and {name} marked the spot with a small cairn, the way old travelers used to."
106
- ),
107
- },
108
- ),
109
- },
110
- "robots": {
111
- "young": (
112
- {
113
- "title": "{name} and the Little Robot",
114
- "body": (
115
- "Under {name}'s bed sat a small box with one blinking light.\n\n"
116
- "When {name} said hello, the box opened and out climbed a tiny robot no bigger than a kitten. It beeped softly and looked up at {name} with round blue eyes.\n\n"
117
- "The robot wanted to learn about {extras}. {name} showed it how to share a cookie, and the robot's light turned a happy green.\n\n"
118
- "That night, the robot whispered a small idea: {moral}. {name} hugged it, and the light blinked slowly, the way hearts do when they are content."
119
- ),
120
- },
121
- {
122
- "title": "{name} and the Robot Who Sang",
123
- "body": (
124
- "{name} found a small robot in the attic, covered in dust and humming to itself.\n\n"
125
- "It wanted to learn one thing before it went to sleep: what {extras} sounded like.\n\n"
126
- "{name} played a drum, the wind played a whistle, and the robot played along on a single, bright note.\n\n"
127
- "When it finally closed its eyes, it whispered its favorite idea: {moral}. {name} tucked it into a small box and promised to play again tomorrow."
128
- ),
129
- },
130
- ),
131
- "middle": (
132
- {
133
- "title": "{name} Builds a Friend",
134
- "body": (
135
- "{name} found an old instruction manual in the garage, full of diagrams for a small helper robot.\n\n"
136
- "With patience, and a little help from {extras}, {name} built a robot that could listen, fetch, and ask good questions.\n\n"
137
- "One afternoon, the robot asked why some days felt heavier than others. {name} thought, then answered with something close to {moral}.\n\n"
138
- "The robot nodded, its antenna glowing a soft amber. From that day on, it remembered every kindness shown to it, and tried to return each one in its own small way."
139
- ),
140
- },
141
- {
142
- "title": "{name} Repairs a Memory",
143
- "body": (
144
- "The old family robot had stopped working the way it used to, and {name} wanted to bring it back.\n\n"
145
- "Inside, among the wires, {name} found a tiny chip that held a memory of {extras}.\n\n"
146
- "Carefully, {name} cleaned the chip and put it back. The robot woke slowly, blinked twice, and asked, in a small voice, if {name} was still there.\n\n"
147
- "'I am,' said {name}, and the robot said, 'Good. That is what {moral} means, I think.'\n\n"
148
- "From that day on, the robot preferred to work in the kitchen, where the light was warm and the company steady."
149
- ),
150
- },
151
- ),
152
- "older": (
153
- {
154
- "title": "The Robot Who Wanted to Know Why",
155
- "body": (
156
- "{name} had built many robots before, but never one that asked so many questions.\n\n"
157
- "This one wanted to know why leaves change color, why people sing when they are happy, and why kindness sometimes costs the most.\n\n"
158
- "So {name} took the robot on long walks through the neighborhood, into the library, and out to the field where {extras} gather in the late afternoon.\n\n"
159
- "At the end of one long walk, the robot stopped and said, 'I think I understand. It sounds like {moral}.' {name} smiled and replied, 'Yes. Now you really are awake.'\n\n"
160
- "They walked home together, the robot humming a tune it had learned from a passing bird."
161
- ),
162
- },
163
- {
164
- "title": "The Last Robot {name} Will Build",
165
- "body": (
166
- "{name} had built many robots, but never one quite like this.\n\n"
167
- "This one was small and quiet, with a single question programmed into its heart: what is {moral}?\n\n"
168
- "{name} took it on long walks, to the garden, to the river, to the field where {extras} grow in the late summer light.\n\n"
169
- "At the end of one walk, the robot stopped and said, 'I have listened enough. I think I know.' And {name} smiled, and turned the robot off, gently, knowing it was enough.\n\n"
170
- "Some machines, it turns out, are built to be finished."
171
- ),
172
- },
173
- ),
174
- },
175
- "space": {
176
- "young": (
177
- {
178
- "title": "{name} and the Sleepy Moon",
179
- "body": (
180
- "One night, the moon looked very tired, so {name} climbed up the tallest ladder in the yard to say goodnight.\n\n"
181
- "The moon yawned a long, silver yawn. It was worried because it had lost a small, twinkling star.\n\n"
182
- "{name} looked around and found it hiding near {extras}, glowing shyly. {name} carried it back, and the moon smiled wide.\n\n"
183
- "It whispered a sleepy thank you, and a little secret: {moral}. {name} climbed back down and slept soundly until morning."
184
- ),
185
- },
186
- {
187
- "title": "{name} and the Star That Was Lost",
188
- "body": (
189
- "One night, the stars were all in the sky except one, and the moon was worried.\n\n"
190
- "{name} climbed to the roof with a small ladder and a kind voice and called up to the lost star.\n\n"
191
- "The star, it turned out, had been hiding behind {extras}, feeling shy about shining.\n\n"
192
- "{name} told it that even small lights matter, and that {moral} is true for stars too. The star, blushing silver, returned to the sky, where it has shone happily ever since."
193
- ),
194
- },
195
- ),
196
- "middle": (
197
- {
198
- "title": "{name} Among the Stars",
199
- "body": (
200
- "When the rocket's engines hummed, {name} felt the seat press gently against their back, and the sky began to fall away.\n\n"
201
- "Beyond the clouds, a small planet waved hello. On it lived {extras}, who had been waiting for a visitor who asked polite questions.\n\n"
202
- "{name} stayed for tea and listened to the planet sing. Its favorite song was about {moral}, and it offered to teach {name} the chorus.\n\n"
203
- "When the rocket came home, {name} could still hear the song in the wind, like a small bell that never quite stops ringing."
204
- ),
205
- },
206
- {
207
- "title": "{name} and the Map of Quiet Places",
208
- "body": (
209
- "On the spaceship, the captain kept a special map marked with the quietest places in the galaxy.\n\n"
210
- "{name} had been chosen, this trip, to be the one who visited them. Each stop held a small lesson.\n\n"
211
- "On the second planet, the wind sang about {extras}. On the third, the rocks hummed a song about {moral}.\n\n"
212
- "When {name} returned, the captain asked what {name} had learned. 'That quiet places,' said {name}, 'are not empty. They are full of answers we are in a hurry to miss.'\n\n"
213
- "The captain nodded, and pinned the map to the wall, where the whole crew could see it."
214
- ),
215
- },
216
- ),
217
- "older": (
218
- {
219
- "title": "The Comet {name} Named",
220
- "body": (
221
- "Once every hundred years, a small comet swings close enough to be seen from a quiet rooftop in the hills.\n\n"
222
- "{name} had been waiting since the first cold night of autumn, notebook in hand, watching the sky with patient eyes.\n\n"
223
- "When the comet finally appeared, trailing light like a long bright ribbon, {name} whispered a name to it. The comet, as if it had been waiting too, dipped slightly toward the rooftop.\n\n"
224
- "From that night on, astronomers far away would point up and speak of {name}'s comet, a gentle reminder of {moral}.\n\n"
225
- "And on clear nights, if you listened, you could almost hear the wind repeat the name, soft as a secret."
226
- ),
227
- },
228
- {
229
- "title": "{name} and the Telescope at the Edge of the Field",
230
- "body": (
231
- "It was a small telescope, older than {name}'s grandparents, and it lived in a wooden shed at the edge of a field.\n\n"
232
- "On the clearest night of the year, {name} carried it out, set it down, and looked up.\n\n"
233
- "The sky was not empty. It was full of small stories, each one a star, each star a reminder of {extras} and of the slow, patient rule of {moral}.\n\n"
234
- "{name} stayed for a long time, until the cold became part of the night, and then went inside, leaving the telescope pointing up, just in case the stars wanted to look back."
235
- ),
236
- },
237
- ),
238
- },
239
- "fantasy": {
240
- "young": (
241
- {
242
- "title": "{name} and the Door That Wasn't There",
243
- "body": (
244
- "Behind the bookshelf in the hallway, there was a door that only appeared on rainy afternoons.\n\n"
245
- "{name} opened it and stepped into a garden where flowers hummed and rabbits wore tiny vests.\n\n"
246
- "The garden asked {name} to help find a missing petal, and {name} searched with the help of {extras} until they found it resting on a sleeping snail.\n\n"
247
- "The garden bowed its leaves and shared its favorite rule: {moral}. {name} waved, and the door closed softly behind, waiting for next rain."
248
- ),
249
- },
250
- {
251
- "title": "{name} and the Cat Who Could Read",
252
- "body": (
253
- "In the corner of the library, a small cat sat reading a very tiny book.\n\n"
254
- "{name} sat down beside it, and the cat, in a polite voice, read a story about {extras}.\n\n"
255
- "When the story ended, the cat yawned and said its favorite rule was {moral}.\n\n"
256
- "{name} agreed, and the two of them chose the next book together, the way friends do."
257
- ),
258
- },
259
- ),
260
- "middle": (
261
- {
262
- "title": "The Map {name} Drew",
263
- "body": (
264
- "{name} had always been good at drawing maps of places that did not exist, and one Tuesday, one of them began to be true.\n\n"
265
- "The map showed a forest where the trees had names, and a river that ran both up and down. At the center stood a small castle made entirely of books.\n\n"
266
- "{name} walked the path they had drawn, meeting {extras} who offered riddles and warm bread. Each answer pointed to the next part of the journey.\n\n"
267
- "At the castle, the librarian said the only rule worth keeping was {moral}, and gave {name} a blank page in return.\n\n"
268
- "{name} took the page home and began to draw the next map, smiling."
269
- ),
270
- },
271
- {
272
- "title": "{name} and the Well That Sang",
273
- "body": (
274
- "At the bottom of the garden, an old stone well had begun to hum, very softly, in the late afternoons.\n\n"
275
- "{name} leaned in and heard, far down, a small voice singing about {extras}.\n\n"
276
- "It wasn't scary, only lonesome, and so {name} dropped a small message into the well, in a waterproof bottle, that said, 'You are not alone.'\n\n"
277
- "The next day, the well sang a new song, this one about {moral}. {name} smiled, and went to find a grown-up to share the news."
278
- ),
279
- },
280
- ),
281
- "older": (
282
- {
283
- "title": "The Spell {name} Didn't Mean to Cast",
284
- "body": (
285
- "It started, as it often does, with a word spoken at the wrong moment in the right place.\n\n"
286
- "{name} had been reading in the attic when the old spellbook fell open to a page about gentle things, and a small word slipped out, half-remembered.\n\n"
287
- "The room filled with soft light, and from the page stepped a creature made of paper and ink, who bowed politely and asked to be useful.\n\n"
288
- "Together, they tidied the attic, finding {extras} and a long-lost letter. The creature explained that even small magic follows the rule of {moral}.\n\n"
289
- "When the work was done, the creature stepped back into the page, leaving behind a single feather and the feeling that attics, too, have hearts."
290
- ),
291
- },
292
- {
293
- "title": "{name} and the Apprentice Mapmaker",
294
- "body": (
295
- "There was a mapmaker in the village who could draw a road that didn't exist yet, and {name} had been chosen to help.\n\n"
296
- "The first map was for a baker who wanted a shortcut to the river. The second was for a child who dreamed of {extras}.\n\n"
297
- "Each map took a whole afternoon, drawn in ink made from tea, and each one ended with the same small note in the corner: {moral}.\n\n"
298
- "By the end of the week, the village had a dozen new paths, and {name} had learned that making a map is mostly a way of listening."
299
- ),
300
- },
301
- ),
302
- },
303
- "adventure": {
304
- "young": (
305
- {
306
- "title": "{name} and the Big Hill",
307
- "body": (
308
- "There was a hill at the end of {name}'s street, and {name} had never climbed all the way to the top.\n\n"
309
- "One bright morning, {name} packed a snack, waved to {extras}, and started up the path.\n\n"
310
- "The hill was bigger than it looked, but {name} took small steps and rested when tired. At the top, a small flag waited, flapping hello.\n\n"
311
- "It said, in friendly letters, {moral}. {name} planted it firmly and ran down to tell everyone."
312
- ),
313
- },
314
- {
315
- "title": "{name} and the Bridge of Leaves",
316
- "body": (
317
- "A small stream had gotten too wide to jump, and {name} wanted to reach the other side.\n\n"
318
- "With some rope and {extras}, {name} built a small bridge out of leaves and twigs.\n\n"
319
- "It wobbled, but it held, and on the other side, {name} found a tiny garden no one had ever seen.\n\n"
320
- "The garden, in a whisper, said its only rule was {moral}. {name} nodded, and crossed back carefully, already planning to return."
321
- ),
322
- },
323
- ),
324
- "middle": (
325
- {
326
- "title": "{name} and the Forgotten Path",
327
- "body": (
328
- "Grandma had told {name} about a path behind the orchard that led to a waterfall no one visited anymore.\n\n"
329
- "{name} found it on a cool afternoon, half-covered in leaves, and followed it carefully. The forest grew quiet and listening.\n\n"
330
- "Along the way, {name} met {extras} who each shared a clue: a feather pointing north, a smooth stone to mark a turn, a song to hum at a fork.\n\n"
331
- "The waterfall was small, but its sound filled the clearing. {name} sat and remembered the rule of every long walk: {moral}.\n\n"
332
- "On the way home, the path seemed a little less forgotten, as if it were glad to be walked again."
333
- ),
334
- },
335
- {
336
- "title": "{name} and the Cave of Echoes",
337
- "body": (
338
- "On a long hike, {name} found a cave that repeated every word, soft as a sigh.\n\n"
339
- "Inside, {name} spoke carefully, telling the cave about {extras} and asking it to remember.\n\n"
340
- "The cave answered, in many voices, with the same quiet rule: {moral}.\n\n"
341
- "When {name} stepped out into the afternoon, the wind seemed to carry a little of the echo with it, and the hike home felt shorter than it should have."
342
- ),
343
- },
344
- ),
345
- "older": (
346
- {
347
- "title": "The Trail {name} Blazed",
348
- "body": (
349
- "The maps in the library said no trail ran between the two valleys, but the old stories said otherwise.\n\n"
350
- "{name} packed light, said goodbye to {extras}, and started into the woods with a notebook, a compass, and a great deal of stubbornness.\n\n"
351
- "The first day was hard. The second was harder. On the third, {name} found the cairn left by someone long ago, and followed its markers to the pass.\n\n"
352
- "Standing between the two valleys, {name} understood what the old trails always tried to teach: {moral}.\n\n"
353
- "On the way down, {name} marked each turn with a small stone, so the next traveler would not have to start from nothing."
354
- ),
355
- },
356
- {
357
- "title": "{name} and the Trail of Small Signs",
358
- "body": (
359
- "The old guidebook said no trail ran over the mountain, but {name} trusted the small signs.\n\n"
360
- "A red thread tied to a branch. A stack of smooth stones. A single word painted on a rock in friendly letters: onward.\n\n"
361
- "Each sign pointed toward the next, and each one hinted at {extras} and at the patient rule of {moral}.\n\n"
362
- "At the summit, {name} found a small notebook in a tin box, full of names. {name} added one, closed the tin, and started down, leaving the signs for whoever came next."
363
- ),
364
- },
365
- ),
366
- },
367
- "animals": {
368
- "young": (
369
- {
370
- "title": "{name} and the Brave Little Fox",
371
- "body": (
372
- "In the meadow near {name}'s house, there lived a small fox with a bright orange tail.\n\n"
373
- "The fox was shy, but it followed {name} home one afternoon, peeking around the garden gate.\n\n"
374
- "{name} shared a sandwich, and the fox shared a secret: it knew where {extras} liked to play on warm days.\n\n"
375
- "Before it left, the fox whispered a kind truth: {moral}. {name} nodded, and the fox vanished into the long grass with a flick of its tail."
376
- ),
377
- },
378
- {
379
- "title": "{name} and the Cat in the Window",
380
- "body": (
381
- "On the third floor of the apartment building, a small cat watched the street from a wide window.\n\n"
382
- "{name} waved up at it every morning on the way to school, and the cat, in its careful way, waved back.\n\n"
383
- "One Saturday, {name} was invited up for milk and a story. The cat told a quiet one about {extras} and ended with a small idea: {moral}.\n\n"
384
- "{name} went home humming the story, and the cat returned to its window, content to be both a friend and a watcher."
385
- ),
386
- },
387
- ),
388
- "middle": (
389
- {
390
- "title": "The Animal School {name} Found",
391
- "body": (
392
- "Behind the old barn, past the broken fence, there was a clearing where animals held a small school of their own.\n\n"
393
- "{name} discovered it on a quiet morning, and the rabbit teaching arithmetic waved {name} over to join.\n\n"
394
- "The lesson that day was about {extras}, and the importance of listening before speaking. The owl nodded, the mouse took notes, and the fox sharpened a small pencil.\n\n"
395
- "When the bell rang, the rabbit told {name} the school's only rule, which was the same as {moral}.\n\n"
396
- "{name} promised to return, and the clearing seemed to settle a little deeper into its peaceful hum."
397
- ),
398
- },
399
- {
400
- "title": "{name} and the Lost Rabbit",
401
- "body": (
402
- "On the way home from the park, {name} noticed a small rabbit sitting very still by the fence.\n\n"
403
- "It had no collar and looked unsure of where to go. {name} knelt down, very gently, and asked where it lived.\n\n"
404
- "The rabbit blinked and hopped toward {extras}, and {name} followed, slowly, until they reached a small warren at the edge of the garden.\n\n"
405
- "The mother rabbit thanked {name} in the way rabbits do, which is to thump once, gently. The rule of that small family, it seemed, was {moral}."
406
- ),
407
- },
408
- ),
409
- "older": (
410
- {
411
- "title": "{name} and the Council of the Wood",
412
- "body": (
413
- "On the night of the first frost, the animals of the wood hold a council under the old cedar, and this year, they had invited a guest.\n\n"
414
- "{name} arrived wrapped in a warm coat and sat very still as the deer, the badger, and the small, ancient owl spoke of the season ahead.\n\n"
415
- "They talked of {extras}, of food to share, and of the long winter that was beginning to settle over the hills.\n\n"
416
- "At the end, the owl turned to {name} and said, 'We have always taught our young the same thing: {moral}.'\n\n"
417
- "{name} thanked them and walked home through the frost, carrying the council's small, unspoken gift: the sense of being trusted by a place."
418
- ),
419
- },
420
- {
421
- "title": "{name} and the Geese at the Reservoir",
422
- "body": (
423
- "Each autumn, the geese stopped at the reservoir on their way south, and {name} liked to sit and watch them.\n\n"
424
- "They argued and arranged themselves, called to each other in long, patient sentences, and took turns resting.\n\n"
425
- "One afternoon, a single goose waddled up to {name} and stood nearby, not afraid, only tired. {name} sat still, breathing slowly, the way you do around a wild thing.\n\n"
426
- "After a long while, the goose returned to the flock, and {name} understood, without being told, the rule of {moral}. Some lessons arrive on cold wind, and ask nothing in return."
427
- ),
428
- },
429
- ),
430
- },
431
- "ocean": {
432
- "young": (
433
- {
434
- "title": "{name} and the Friendly Whale",
435
- "body": (
436
- "On the beach where {name} liked to collect shells, a great gray whale came very close to the shore one morning.\n\n"
437
- "It opened one eye, the size of a dinner plate, and looked at {name} with surprising gentleness.\n\n"
438
- "{name} offered a small fish, and the whale sang a low, happy note that made the seagulls pause mid-flight. It pointed with a fin toward {extras} in the tide pools.\n\n"
439
- "Before swimming away, the whale hummed a single idea: {moral}. The waves kept humming it long after it had gone."
440
- ),
441
- },
442
- {
443
- "title": "{name} and the Crab Who Shared",
444
- "body": (
445
- "In a tide pool near the rocks, a small crab was guarding a single shiny pebble.\n\n"
446
- "{name} sat down beside it, and the crab, after a long pause, offered to share the pebble for just one minute.\n\n"
447
- "It was heavier than it looked, and very smooth. {name} held it carefully, then gave it back.\n\n"
448
- "The crab, pleased, told {name} the rule of the tide pool: {moral}. {name} bowed politely, and the tide came in, as it always does, right on time."
449
- ),
450
- },
451
- ),
452
- "middle": (
453
- {
454
- "title": "The Lighthouse {name} Tended",
455
- "body": (
456
- "When the old lighthouse keeper hurt his ankle, {name} offered to tend the light for the week.\n\n"
457
- "Each night, {name} climbed the spiral stairs and watched the beam swing across the dark water, guiding boats home through {extras} and weather.\n\n"
458
- "On the third night, a small voice from the radio said a fishing boat was in trouble. {name} kept the light steady, and the boat found its way back.\n\n"
459
- "The keeper said, when he returned, that the light only really works when the keeper remembers {moral}.\n\n"
460
- "{name} nodded, and from then on, the spiral stairs never felt quite as long."
461
- ),
462
- },
463
- {
464
- "title": "{name} and the Sail That Wouldn't Stay Full",
465
- "body": (
466
- "On a borrowed boat, {name} and an older cousin tried to catch a clean wind out of the harbor.\n\n"
467
- "The sail flapped, the boom swung, and the wind seemed to be playing a game.\n\n"
468
- "At last, the wind settled, the sail filled, and the boat slipped past the breakwater into open water. {name} felt the salt on their lips and laughed.\n\n"
469
- "The cousin, in a calm voice, said the sea always teaches the same lesson: {moral}. {name} nodded, and held the tiller steady until the sun began its slow slide toward the water."
470
- ),
471
- },
472
- ),
473
- "older": (
474
- {
475
- "title": "{name} and the Map of Tides",
476
- "body": (
477
- "In the library at the edge of the harbor, there was a chart older than anyone alive, drawn in ink made of seaweed and quiet patience.\n\n"
478
- "{name} had been studying it for months, learning the names of currents and the moods of the moon.\n\n"
479
- "One morning, the harbor master asked for help guiding a sailboat through {extras}, and {name} read the chart aloud, calm and clear, until the sailboat slipped safely into the bay.\n\n"
480
- "The harbor master said the chart's margin, in faded handwriting, held a single rule: {moral}.\n\n"
481
- "{name} copied the rule into a small notebook and walked home along the seawall, listening to the tide remember it too."
482
- ),
483
- },
484
- {
485
- "title": "{name} and the Bell Buoy at Midnight",
486
- "body": (
487
- "The harbor had a small bell buoy that rang only when the tide was high and the night was very still.\n\n"
488
- "{name} had wanted to hear it for years, and one August night, finally, did.\n\n"
489
- "Standing on the dock, with the wind cool and {extras} somewhere in the dark, the bell rang twice, and the water answered, softly, from far out.\n\n"
490
- "{name} stood very still and thought about patience, and about {moral}, and about the fact that some sounds wait years for the right kind of listener."
491
- ),
492
- },
493
- ),
494
- },
495
- "friends": {
496
- "young": (
497
- {
498
- "title": "{name} and the New Friend",
499
- "body": (
500
- "On the first day of a new week, a quiet child moved into the house next door to {name}.\n\n"
501
- "{name} brought over a small cup of lemonade and a drawing of {extras}, just to say hello.\n\n"
502
- "The new child smiled, slowly, like a flower opening, and shared a favorite crayon in return.\n\n"
503
- "Before they waved goodbye, the new child said, in a small voice, that {moral} was the best thing to remember when you are new.\n\n"
504
- "{name} agreed, and the two of them began to plan tomorrow."
505
- ),
506
- },
507
- {
508
- "title": "{name} and the Treehouse Rule",
509
- "body": (
510
- "In the back garden, {name} and a friend built a small treehouse out of blankets and imagination.\n\n"
511
- "They agreed on one rule before they climbed up: that everyone who came inside was safe, and that {extras} were always welcome.\n\n"
512
- "The rule, they decided, was the same as {moral}, just with a roof.\n\n"
513
- "They shared cookies and stories until the streetlights came on, then climbed down carefully, agreeing to build the second room tomorrow."
514
- ),
515
- },
516
- ),
517
- "middle": (
518
- {
519
- "title": "{name} and the Club of Small Wonders",
520
- "body": (
521
- "{name} and three friends had started a club whose only rule was to notice small, good things.\n\n"
522
- "On Tuesdays, they met under the big maple and traded observations: a bird building a nest, a librarian's quiet laugh, a perfectly round pebble.\n\n"
523
- "This week, the topic was {extras}, and each friend brought something to show. There were stories, drawings, and a small, folded letter.\n\n"
524
- "The club's secret rule, which everyone knew but no one said out loud, was {moral}.\n\n"
525
- "When the meeting ended, each member carried a little more of the afternoon home with them."
526
- ),
527
- },
528
- {
529
- "title": "{name} and the Lemonade Stand",
530
- "body": (
531
- "On a long, hot Saturday, {name} and two friends set up a small lemonade stand at the end of the driveway.\n\n"
532
- "The first customer was the mail carrier. The second was a tired dog, who got water instead. The third was a neighbor who told a long, kind story about {extras}.\n\n"
533
- "By the end of the afternoon, the stand had made enough for ice cream, and the friends had made enough for a long memory.\n\n"
534
- "They closed up, sharing the ice cream on the porch, and agreed that {moral} was the best part of the day."
535
- ),
536
- },
537
- ),
538
- "older": (
539
- {
540
- "title": "{name} and the Long Letter",
541
- "body": (
542
- "When {name}'s best friend moved across the country, they decided to write letters the old way, on paper, with pens that needed dipping.\n\n"
543
- "The first letters were easy, full of news and small jokes about {extras}. The later ones grew longer, slower, more honest.\n\n"
544
- "In one letter, the friend wrote that distance is not the same as absence, and that {moral} is what keeps a friendship warm across any number of miles.\n\n"
545
- "{name} read it twice, then sat down to write back, the kettle on, the cat asleep, the room full of the particular quiet of two people thinking of each other.\n\n"
546
- "Some letters, it turns out, are small rooms where friends can sit together, no matter how far."
547
- ),
548
- },
549
- {
550
- "title": "{name} and the Notebook We Share",
551
- "body": (
552
- "On the first day of the new school year, {name} and a friend agreed to share a small notebook, passing it back and forth between classes.\n\n"
553
- "Each wrote a little — a question, a sketch, a small observation about {extras}, a quiet worry.\n\n"
554
- "By the end of the term, the notebook was full, and so were the two friends, in a way that had very little to do with the lessons in class.\n\n"
555
- "They agreed that the notebook's only rule, the one that mattered most, was {moral}, and that some friendships, like some books, are better when they're written in more than one handwriting."
556
- ),
557
- },
558
- ),
559
- },
560
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
modal_app.py ADDED
@@ -0,0 +1,337 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Fabella inference servers on Modal.
2
+
3
+ Three independent web_servers in one app, each on its own A10G:
4
+
5
+ serve_drafter (port 8000) — Gemma 4 E4B-IT (4B). Generates explanations.
6
+ serve_judge (port 8001) — Nemotron-3 Nano 4B. Scores the draft against
7
+ the request and returns a structured verdict.
8
+ serve_tts (port 8002) — VoxCPM2. Synthesizes read-aloud WAV audio.
9
+
10
+ The judge runs after the drafter; if the verdict is "revise", the
11
+ drafter is re-invoked. This is the cheapest way to get model-driven
12
+ quality control without a parallel-multi-agent setup.
13
+
14
+ All models live on the same Modal Volume (fabella-models) with distinct
15
+ sub-directories so we only pay for one download per model.
16
+ """
17
+
18
+ import os
19
+ import subprocess
20
+ from pathlib import Path
21
+
22
+ import modal
23
+
24
+ app = modal.App("fabella")
25
+
26
+ model_volume = modal.Volume.from_name("fabella-models", create_if_missing=True)
27
+ vllm_cache_volume = modal.Volume.from_name("fabella-vllm-cache", create_if_missing=True)
28
+
29
+ MODEL_PATH = "/models"
30
+
31
+ DRAFTER_REPO = "google/gemma-4-E4B-it"
32
+ DRAFTER_DIR = "gemma-4-E4B-it"
33
+ DRAFTER_SERVED_NAME = "gemma-4"
34
+ DRAFTER_PORT = 8000
35
+
36
+ JUDGE_REPO = "nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16"
37
+ JUDGE_DIR = "NVIDIA-Nemotron-3-Nano-4B-BF16"
38
+ JUDGE_SERVED_NAME = "nemotron-3-4b"
39
+ JUDGE_PORT = 8001
40
+
41
+ TTS_REPO = "openbmb/VoxCPM2"
42
+ TTS_DIR = "VoxCPM2"
43
+ TTS_PORT = 8002
44
+
45
+ # --- Images ---------------------------------------------------------------
46
+
47
+ download_image = (
48
+ modal.Image.debian_slim(python_version="3.11")
49
+ .pip_install("huggingface_hub[hf_xet]>=0.24")
50
+ .env({"HF_HUB_CACHE": MODEL_PATH})
51
+ )
52
+
53
+ vllm_image = (
54
+ modal.Image.from_registry("nvidia/cuda:12.9.0-devel-ubuntu22.04", add_python="3.11")
55
+ .entrypoint([])
56
+ .pip_install("vllm>=0.22", "huggingface_hub[hf_xet]>=0.24")
57
+ .env({"HF_HUB_CACHE": MODEL_PATH})
58
+ )
59
+
60
+ # VoxCPM2 is a tokenizer-free diffusion-autoregressive TTS model (MiniCPM-4
61
+ # backbone + AudioVAE V2). It's served by the official `voxcpm` Python
62
+ # library, NOT vLLM. The image is therefore a separate CUDA image with
63
+ # torch + voxcpm installed and a tiny FastAPI wrapper that calls
64
+ # VoxCPM.from_pretrained(...).generate(...) and returns audio/wav bytes.
65
+ tts_image = (
66
+ modal.Image.from_registry("nvidia/cuda:12.9.0-devel-ubuntu22.04", add_python="3.11")
67
+ .entrypoint([])
68
+ .pip_install(
69
+ # VoxCPM2 + its torch/torchaudio deps. We pin a major range
70
+ # compatible with the README's "torch>=2.5.0, CUDA>=12.0" claim.
71
+ "voxcpm>=1.0",
72
+ "torch>=2.5.0",
73
+ "torchaudio>=2.5.0",
74
+ "soundfile",
75
+ "fastapi>=0.110",
76
+ "uvicorn[standard]>=0.27",
77
+ )
78
+ .env({"HF_HUB_CACHE": MODEL_PATH})
79
+ )
80
+
81
+
82
+ # --- Model download (one entry per model) --------------------------------
83
+
84
+
85
+ @app.function(image=download_image, volumes={MODEL_PATH: model_volume}, timeout=60 * 60)
86
+ def download_drafter(force: bool = False):
87
+ """Pull Gemma 4 E4B-IT weights to the Volume (run once)."""
88
+ from huggingface_hub import snapshot_download
89
+ target = Path(MODEL_PATH) / DRAFTER_DIR
90
+ if target.exists() and any(target.iterdir()) and not force:
91
+ print(f"Drafter model already at {target}; skipping")
92
+ return
93
+ print(f"Downloading {DRAFTER_REPO} to {target}...")
94
+ snapshot_download(
95
+ repo_id=DRAFTER_REPO,
96
+ local_dir=str(target),
97
+ allow_patterns=[
98
+ "config.json", "generation_config.json", "chat_template.jinja",
99
+ "tokenizer.json", "tokenizer_config.json",
100
+ "preprocessor_config.json", "processor_config.json",
101
+ "model*.safetensors", "*.py",
102
+ ],
103
+ )
104
+ model_volume.commit()
105
+ print("Drafter download complete")
106
+
107
+
108
+ @app.function(image=download_image, volumes={MODEL_PATH: model_volume}, timeout=60 * 60)
109
+ def download_judge(force: bool = False):
110
+ """Pull Nemotron-Nano-9B-v2 weights to the Volume (run once)."""
111
+ from huggingface_hub import snapshot_download
112
+ target = Path(MODEL_PATH) / JUDGE_DIR
113
+ if target.exists() and any(target.iterdir()) and not force:
114
+ print(f"Judge model already at {target}; skipping")
115
+ return
116
+ print(f"Downloading {JUDGE_REPO} to {target}...")
117
+ snapshot_download(
118
+ repo_id=JUDGE_REPO,
119
+ local_dir=str(target),
120
+ allow_patterns=[
121
+ "config.json", "generation_config.json", "chat_template.jinja",
122
+ "tokenizer.json", "tokenizer_config.json",
123
+ "preprocessor_config.json", "processor_config.json",
124
+ "model*.safetensors", "*.py",
125
+ ],
126
+ )
127
+ model_volume.commit()
128
+ print("Judge download complete")
129
+
130
+
131
+ # --- vLLM servers --------------------------------------------------------
132
+
133
+ MINUTES = 60
134
+
135
+
136
+ def _vllm_cmd(model_dir: Path, served_name: str, port: int, extra: list[str]) -> list[str]:
137
+ return [
138
+ "vllm", "serve",
139
+ str(model_dir),
140
+ "--host", "0.0.0.0",
141
+ "--port", str(port),
142
+ "--served-model-name", served_name,
143
+ "--uvicorn-log-level", "info",
144
+ "--max-model-len", "8192",
145
+ "--gpu-memory-utilization", "0.90",
146
+ *extra,
147
+ ]
148
+
149
+
150
+ @app.function(
151
+ image=vllm_image,
152
+ gpu="A10G",
153
+ scaledown_window=10 * MINUTES,
154
+ timeout=10 * MINUTES,
155
+ volumes={MODEL_PATH: model_volume, "/root/.cache/vllm": vllm_cache_volume},
156
+ )
157
+ @modal.concurrent(max_inputs=10)
158
+ @modal.web_server(port=DRAFTER_PORT, startup_timeout=10 * MINUTES)
159
+ def serve_drafter():
160
+ """Gemma 4 E4B-IT — the story drafter.
161
+
162
+ Tool-calling is native via vLLM's gemma4 parser (the model's chat
163
+ template uses <|tool_call|>...<tool_call|> markers).
164
+ """
165
+ model_dir = Path(MODEL_PATH) / DRAFTER_DIR
166
+ cmd = _vllm_cmd(model_dir, DRAFTER_SERVED_NAME, DRAFTER_PORT, extra=[
167
+ "--language-model-only", # skip multimodal processor
168
+ "--enable-auto-tool-choice",
169
+ "--tool-call-parser", "gemma4",
170
+ ])
171
+ print(f"Starting drafter vLLM: {' '.join(cmd)}", flush=True)
172
+ subprocess.Popen(cmd)
173
+
174
+
175
+ @app.function(
176
+ image=vllm_image,
177
+ gpu="A10G",
178
+ scaledown_window=10 * MINUTES,
179
+ timeout=10 * MINUTES,
180
+ volumes={MODEL_PATH: model_volume, "/root/.cache/vllm": vllm_cache_volume},
181
+ )
182
+ @modal.concurrent(max_inputs=10)
183
+ @modal.web_server(port=JUDGE_PORT, startup_timeout=10 * MINUTES)
184
+ def serve_judge():
185
+ """Nemotron-3-Nano-4B-BF16 — the multi-criteria story judge.
186
+
187
+ No tool-calling flags on the server side: the judge prompt in
188
+ llm.py asks for plain JSON in `content` and the client parses it.
189
+ This dodges the chat-template tool-dialect dance entirely.
190
+ """
191
+ model_dir = Path(MODEL_PATH) / JUDGE_DIR
192
+ cmd = _vllm_cmd(model_dir, JUDGE_SERVED_NAME, JUDGE_PORT, extra=[])
193
+ print(f"Starting judge vLLM: {' '.join(cmd)}", flush=True)
194
+ subprocess.Popen(cmd)
195
+
196
+
197
+ # --- VoxCPM2 TTS ----------------------------------------------------------
198
+
199
+ TTS_SERVER_PY = '''
200
+ """VoxCPM2 TTS server for Fabella.
201
+
202
+ Wraps the official `voxcpm` library in a tiny FastAPI app that exposes
203
+ POST /synthesize. Accepts JSON {text, voice_description, cfg_value,
204
+ inference_timesteps} and returns audio/wav bytes. The model is loaded
205
+ once on import (Modal keeps the container warm while traffic is hot).
206
+ """
207
+
208
+ import io
209
+ import os
210
+ import sys
211
+ import traceback
212
+
213
+ # Pin HF cache before voxcpm / torch import so model weights land in
214
+ # the shared Modal Volume, not the container overlay.
215
+ os.environ.setdefault("HF_HUB_CACHE", "/models")
216
+ MODEL_DIR = "/models/VoxCPM2"
217
+
218
+ import numpy as np
219
+ import soundfile as sf
220
+ from fastapi import FastAPI, HTTPException, Response
221
+
222
+
223
+ print("[tts] importing voxcpm", flush=True)
224
+ try:
225
+ from voxcpm import VoxCPM
226
+ except Exception as e:
227
+ print(f"[tts] voxcpm import failed: {type(e).__name__}: {e}", flush=True)
228
+ raise
229
+
230
+ print(f"[tts] loading VoxCPM2 from {MODEL_DIR}", flush=True)
231
+ _model = VoxCPM.from_pretrained(MODEL_DIR, load_denoiser=False)
232
+ print(f"[tts] loaded; sample_rate = {_model.tts_model.sample_rate}", flush=True)
233
+
234
+ app = FastAPI()
235
+
236
+
237
+ @app.get("/health")
238
+ async def health():
239
+ return {"status": "ok", "sample_rate": int(_model.tts_model.sample_rate)}
240
+
241
+
242
+ @app.post("/synthesize")
243
+ async def synthesize(payload: dict):
244
+ text = (payload.get("text") or "").strip()
245
+ if not text:
246
+ raise HTTPException(status_code=400, detail="text is required")
247
+ voice_description = (payload.get("voice_description") or "").strip() or None
248
+ cfg_value = float(payload.get("cfg_value") or 2.0)
249
+ inference_timesteps = int(payload.get("inference_timesteps") or 10)
250
+ normalize = bool(payload.get("normalize", True))
251
+ denoise = bool(payload.get("denoise", True))
252
+
253
+ # VoxCPM2 voice-design convention: put the description in parens at
254
+ # the start of `text` when no reference audio is provided.
255
+ if voice_description and not payload.get("reference_wav_path"):
256
+ text = f"({voice_description}){text}"
257
+
258
+ try:
259
+ wav = _model.generate(
260
+ text=text,
261
+ cfg_value=cfg_value,
262
+ inference_timesteps=inference_timesteps,
263
+ normalize=normalize,
264
+ denoise=denoise,
265
+ prompt_wav_path=payload.get("prompt_wav_path") or None,
266
+ prompt_text=payload.get("prompt_text") or None,
267
+ reference_wav_path=payload.get("reference_wav_path") or None,
268
+ )
269
+ except Exception as e:
270
+ print(f"[tts] generate failed: {type(e).__name__}: {e}", flush=True)
271
+ traceback.print_exc()
272
+ raise HTTPException(status_code=500, detail=f"generate failed: {e}")
273
+
274
+ # wav is a 1-D numpy array at model.tts_model.sample_rate
275
+ sr = int(_model.tts_model.sample_rate)
276
+ buf = io.BytesIO()
277
+ sf.write(buf, np.asarray(wav, dtype=np.float32), sr, format="WAV", subtype="PCM_16")
278
+ return Response(content=buf.getvalue(), media_type="audio/wav")
279
+ '''
280
+
281
+
282
+ @app.function(image=download_image, volumes={MODEL_PATH: model_volume}, timeout=60 * 60)
283
+ def download_tts(force: bool = False):
284
+ """Pull VoxCPM2 weights to the Volume (run once)."""
285
+ from huggingface_hub import snapshot_download
286
+ target = Path(MODEL_PATH) / TTS_DIR
287
+ if target.exists() and any(target.iterdir()) and not force:
288
+ print(f"TTS model already at {target}; skipping")
289
+ return
290
+ print(f"Downloading {TTS_REPO} to {target}...")
291
+ snapshot_download(
292
+ repo_id=TTS_REPO,
293
+ local_dir=str(target),
294
+ allow_patterns=[
295
+ "config.json", "configuration_*.py", "modeling_*.py",
296
+ "generation_config.json", "chat_template.jinja",
297
+ "tokenizer.json", "tokenizer_config.json",
298
+ "preprocessor_config.json", "processor_config.json",
299
+ "audio_vae_config.json", "audiovae_*", "audiovae.pth", "audiovae.safetensors",
300
+ "model*.safetensors", "*.py",
301
+ "*.json",
302
+ ],
303
+ )
304
+ model_volume.commit()
305
+ print("TTS download complete")
306
+
307
+
308
+ @app.function(
309
+ image=tts_image,
310
+ gpu="A10G",
311
+ scaledown_window=10 * MINUTES,
312
+ timeout=10 * MINUTES,
313
+ volumes={MODEL_PATH: model_volume},
314
+ )
315
+ @modal.concurrent(max_inputs=10)
316
+ @modal.web_server(port=TTS_PORT, startup_timeout=10 * MINUTES)
317
+ def serve_tts():
318
+ """VoxCPM2 — read-aloud narration for Fabella explanations.
319
+
320
+ Wrapped in a small FastAPI app. The drafter's text is sent to
321
+ `/synthesize` and the result is a `audio/wav` blob. The HF Space
322
+ frontend renders the audio inline via a standard `<audio>` element.
323
+ """
324
+ # Write the FastAPI server source into the container and run it.
325
+ server_path = "/root/voxcpm_server.py"
326
+ with open(server_path, "w") as f:
327
+ f.write(TTS_SERVER_PY)
328
+ print(f"[tts] wrote server to {server_path}", flush=True)
329
+ cmd = [
330
+ "uvicorn", "voxcpm_server:app",
331
+ "--app-dir", "/root",
332
+ "--host", "0.0.0.0",
333
+ "--port", str(TTS_PORT),
334
+ "--log-level", "info",
335
+ ]
336
+ print(f"Starting TTS: {' '.join(cmd)}", flush=True)
337
+ subprocess.Popen(cmd)
modal_app_gemma.py ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Fabella vLLM server on Modal.
2
+
3
+ Serves gemma-4-E4B-it via OpenAI-compatible API.
4
+ Model weights cached in Modal Volume for fast cold starts.
5
+ """
6
+
7
+ import os
8
+ import subprocess
9
+ from pathlib import Path
10
+
11
+ import modal
12
+
13
+ # --- App & volumes ---
14
+ app = modal.App("fabella")
15
+
16
+ model_volume = modal.Volume.from_name("fabella-models", create_if_missing=True)
17
+ vllm_cache_volume = modal.Volume.from_name("fabella-vllm-cache", create_if_missing=True)
18
+
19
+ MODEL_PATH = "/models"
20
+ MODEL_NAME = "google/gemma-4-E4B-it"
21
+ VLLM_PORT = 8000
22
+
23
+ # --- Images ---
24
+ download_image = (
25
+ modal.Image.debian_slim(python_version="3.11")
26
+ .pip_install("huggingface_hub[hf_xet]>=0.24")
27
+ .env({"HF_HUB_CACHE": MODEL_PATH})
28
+ )
29
+
30
+ vllm_image = (
31
+ modal.Image.from_registry("nvidia/cuda:12.9.0-devel-ubuntu22.04", add_python="3.11")
32
+ .entrypoint([])
33
+ .pip_install("vllm>=0.22", "huggingface_hub[hf_xet]>=0.24")
34
+ .env({"HF_HUB_CACHE": MODEL_PATH})
35
+ )
36
+
37
+
38
+ # --- Model download ---
39
+ @app.function(
40
+ image=download_image,
41
+ volumes={MODEL_PATH: model_volume},
42
+ timeout=60 * 60,
43
+ )
44
+ def download_model(force: bool = False):
45
+ """Download gemma-4-E4B-it to the Modal Volume. Run once."""
46
+ from huggingface_hub import snapshot_download
47
+
48
+ target = Path(MODEL_PATH) / "gemma-4-E4B-it"
49
+ if target.exists() and any(target.iterdir()) and not force:
50
+ print(f"Model already exists at {target}, skipping download")
51
+ print("Run with --force to re-download")
52
+ return
53
+
54
+ print(f"Downloading {MODEL_NAME} to {target}...")
55
+ snapshot_download(
56
+ repo_id=MODEL_NAME,
57
+ local_dir=str(target),
58
+ allow_patterns=[
59
+ "config.json",
60
+ "generation_config.json",
61
+ "chat_template.jinja",
62
+ "tokenizer.json",
63
+ "tokenizer_config.json",
64
+ "preprocessor_config.json",
65
+ "processor_config.json",
66
+ "model*.safetensors",
67
+ "*.py",
68
+ ],
69
+ )
70
+ model_volume.commit()
71
+ print("Download complete")
72
+
73
+
74
+ # --- vLLM server ---
75
+ MINUTES = 60
76
+
77
+
78
+ @app.function(
79
+ image=vllm_image,
80
+ gpu="A10G",
81
+ scaledown_window=10 * MINUTES,
82
+ timeout=10 * MINUTES,
83
+ volumes={
84
+ MODEL_PATH: model_volume,
85
+ "/root/.cache/vllm": vllm_cache_volume,
86
+ },
87
+ )
88
+ @modal.concurrent(max_inputs=10)
89
+ @modal.web_server(port=VLLM_PORT, startup_timeout=10 * MINUTES)
90
+ def serve():
91
+ """vLLM OpenAI-compatible server for gemma-4-E4B-it."""
92
+ model_dir = Path(MODEL_PATH) / "gemma-4-E4B-it"
93
+
94
+ cmd = [
95
+ "vllm", "serve",
96
+ str(model_dir),
97
+ "--host", "0.0.0.0",
98
+ "--port", str(VLLM_PORT),
99
+ "--served-model-name", "gemma-4",
100
+ "--uvicorn-log-level", "info",
101
+ "--max-model-len", "8192",
102
+ "--gpu-memory-utilization", "0.90",
103
+ "--language-model-only",
104
+ "--enable-auto-tool-choice",
105
+ "--tool-call-parser", "gemma4",
106
+ ]
107
+
108
+ print(f"Starting vLLM: {' '.join(cmd)}", flush=True)
109
+ subprocess.Popen(cmd)
prompts.py DELETED
@@ -1,49 +0,0 @@
1
- """Prompt builders for the real model path."""
2
-
3
- import os
4
- import sys
5
-
6
- sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
7
-
8
- from safety import age_bucket, length_to_words
9
-
10
-
11
- def system_prompt() -> str:
12
- return (
13
- "You are Fabella, a gentle storyteller for children aged 6 to 10. "
14
- "You write warm, magical, age-appropriate short stories. "
15
- "Never include scary content for children under 8. "
16
- "Never mention real public figures by name. "
17
- "Never include violence beyond gentle peril. "
18
- "Never include romantic content. "
19
- "Always end with a clear resolution that reflects the moral lesson. "
20
- "Always begin your response with a line in the exact format: Title: <the story title> "
21
- "Then a blank line, then the story body. "
22
- "Do not include any other text, labels, or commentary."
23
- )
24
-
25
-
26
- def user_prompt(name: str, age: int, themes: list[str], moral: str, length: str) -> str:
27
- bucket = age_bucket(age)
28
- min_w, max_w = length_to_words(length)
29
- themes_str = ", ".join(themes) if themes else "everyday adventures"
30
- moral_str = moral or "being kind to others"
31
-
32
- vocab = {
33
- "young": "Use very simple sentences. Use short paragraphs. Keep language concrete and warm.",
34
- "middle": "Use clear sentences with some descriptive language. Paragraphs of 3 to 5 sentences.",
35
- "older": "Use richer vocabulary and longer paragraphs. Keep the tone gentle and kind.",
36
- }[bucket]
37
-
38
- return (
39
- f"Write a personalized children's story.\n"
40
- f"Child's name: {name}\n"
41
- f"Age: {age}\n"
42
- f"Favorite themes: {themes_str}\n"
43
- f"Moral lesson: {moral_str}\n"
44
- f"Story length: about {min_w} to {max_w} words.\n"
45
- f"{vocab}\n"
46
- f"The child's name must appear at least once. "
47
- f"Each favorite theme must appear at least once. "
48
- f"The moral must be clearly resolved in the ending."
49
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
real.py DELETED
@@ -1,85 +0,0 @@
1
- """Real model path. Wired with @spaces.GPU; polish deferred to Phase 2."""
2
-
3
- import os
4
- import sys
5
-
6
- sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
7
-
8
- from prompts import system_prompt, user_prompt
9
- from schema import StoryRequest
10
-
11
- MODEL_ID = os.environ.get("FABELLA_MODEL_ID", "google/gemma-4-E4B-it")
12
-
13
- _run_gpu = None # lazily wrapped by spaces.GPU on first real call
14
-
15
-
16
- def _parse_title_and_body(text: str) -> tuple[str, str]:
17
- text = (text or "").strip()
18
- if not text:
19
- return "A Small Story", ""
20
- lines = text.splitlines()
21
- title = "A Small Story"
22
- body_lines = list(lines)
23
- for i, line in enumerate(lines):
24
- stripped = line.strip()
25
- if stripped.lower().startswith("title:"):
26
- title = stripped.split(":", 1)[1].strip() or title
27
- body_lines = lines[i + 1:]
28
- break
29
- body = "\n".join(body_lines).strip()
30
- return (title, body) if body else (title, text)
31
-
32
-
33
- def _run_with_spaces_gpu(req: StoryRequest) -> tuple[str, str]:
34
- import torch
35
- from transformers import AutoModelForCausalLM, AutoProcessor
36
-
37
- processor = AutoProcessor.from_pretrained(MODEL_ID)
38
- model = AutoModelForCausalLM.from_pretrained(
39
- MODEL_ID,
40
- torch_dtype=torch.bfloat16,
41
- device_map="cuda",
42
- )
43
-
44
- messages = [
45
- {"role": "system", "content": system_prompt()},
46
- {"role": "user", "content": user_prompt(req.name, req.age, req.themes, req.moral, req.length)},
47
- ]
48
- text = processor.apply_chat_template(
49
- messages,
50
- tokenize=False,
51
- add_generation_prompt=True,
52
- enable_thinking=False,
53
- )
54
- inputs = processor(text=text, return_tensors="pt").to(model.device)
55
- input_len = inputs["input_ids"].shape[-1]
56
-
57
- with torch.no_grad():
58
- output = model.generate(
59
- **inputs,
60
- max_new_tokens=900,
61
- temperature=1.0,
62
- top_p=0.95,
63
- top_k=64,
64
- do_sample=True,
65
- )
66
- raw = processor.decode(output[0][input_len:], skip_special_tokens=True)
67
- return _parse_title_and_body(raw)
68
-
69
-
70
- def _get_run_gpu():
71
- """Wrap _run_with_spaces_gpu with @spaces.GPU(duration=60) on first call."""
72
- global _run_gpu
73
- if _run_gpu is None:
74
- import spaces
75
-
76
- _run_gpu = spaces.GPU(duration=60)(_run_with_spaces_gpu)
77
- return _run_gpu
78
-
79
-
80
- def generate_real(req: StoryRequest) -> tuple[str, str]:
81
- """Real-model call. Best-effort; surfaces a user-visible message on any failure."""
82
- try:
83
- return _get_run_gpu()(req)
84
- except Exception as e:
85
- return "Fabella (real model error)", f"_Real model call failed: {e}._"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
requirements.txt CHANGED
@@ -1,5 +1,5 @@
1
  gradio>=4.0,<7.0
2
- spaces>=0.18.0
3
- transformers>=4.40.0
4
- torch>=2.0.0
5
- accelerate>=0.27.0
 
1
  gradio>=4.0,<7.0
2
+ langchain>=1.0
3
+ langchain-core>=1.0
4
+ langchain-openai>=0.3
5
+ openai>=1.76
safety.py CHANGED
@@ -3,9 +3,8 @@
3
  import re
4
 
5
  MAX_NAME_LEN = 30
6
- MAX_THEMES = 3
7
- MAX_THEME_LEN = 20
8
- MAX_MORAL_LEN = 120
9
 
10
  CONTROL_CHARS = re.compile(r"[\x00-\x1f\x7f]")
11
  PROFANITY = {
@@ -27,30 +26,13 @@ def sanitize_name(raw: str) -> str:
27
  return clean_text(raw, MAX_NAME_LEN)
28
 
29
 
30
- def sanitize_themes(raw) -> list[str]:
31
- if not raw:
32
- return []
33
- if isinstance(raw, str):
34
- items = [raw]
35
- else:
36
- items = list(raw)
37
- out = []
38
- seen = set()
39
- for t in items:
40
- t = clean_text(str(t), MAX_THEME_LEN)
41
- if not t:
42
- continue
43
- key = t.lower()
44
- if key in seen:
45
- continue
46
- seen.add(key)
47
- out.append(t)
48
- if len(out) >= MAX_THEMES:
49
- break
50
- return out
51
 
52
 
53
  def sanitize_moral(raw: str) -> str:
 
54
  return clean_text(raw, MAX_MORAL_LEN)
55
 
56
 
@@ -71,9 +53,26 @@ def age_bucket(age: int) -> str:
71
 
72
 
73
  def length_to_words(length: str) -> tuple[int, int]:
74
- """Return (min_words, max_words) for a length label."""
 
 
 
 
75
  return {
76
  "short": (120, 220),
77
  "medium": (280, 420),
78
  "long": (500, 800),
79
  }.get(length, (280, 420))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  import re
4
 
5
  MAX_NAME_LEN = 30
6
+ MAX_SITUATION_LEN = 600 # a few sentences of context from the parent
7
+ MAX_MORAL_LEN = 120 # kept for any lingering legacy call sites
 
8
 
9
  CONTROL_CHARS = re.compile(r"[\x00-\x1f\x7f]")
10
  PROFANITY = {
 
26
  return clean_text(raw, MAX_NAME_LEN)
27
 
28
 
29
+ def sanitize_situation(raw: str) -> str:
30
+ """The freeform situation the parent describes."""
31
+ return clean_text(raw, MAX_SITUATION_LEN)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
 
33
 
34
  def sanitize_moral(raw: str) -> str:
35
+ """Kept for legacy call sites. No longer used in the new explainer."""
36
  return clean_text(raw, MAX_MORAL_LEN)
37
 
38
 
 
53
 
54
 
55
  def length_to_words(length: str) -> tuple[int, int]:
56
+ """Return (min_words, max_words) for a length label.
57
+
58
+ Kept for legacy call sites. The new explainer uses its own
59
+ `explain_to_words()` mapping by tone.
60
+ """
61
  return {
62
  "short": (120, 220),
63
  "medium": (280, 420),
64
  "long": (500, 800),
65
  }.get(length, (280, 420))
66
+
67
+
68
+ def explain_to_words(tone: str) -> tuple[int, int]:
69
+ """Target word count (min, max) for the explanation body, by tone.
70
+
71
+ Explanations are deliberately short — 60-160 words depending on
72
+ tone. Parents skim these before reading aloud; long is a bug.
73
+ """
74
+ return {
75
+ "gentle": (60, 110),
76
+ "matter-of-fact": (70, 130),
77
+ "playful": (50, 100),
78
+ }.get(tone, (60, 110))
schema.py CHANGED
@@ -1,13 +1,88 @@
1
- """Typed inputs for Fabella story generation."""
2
 
3
  from dataclasses import dataclass
 
 
 
4
 
5
 
6
  @dataclass
7
- class StoryRequest:
8
- name: str
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  age: int
10
- themes: list[str]
11
- moral: str
12
- length: str
13
  seed: int = 0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Typed inputs and outputs for Fabella the small-words-for-big-questions tool."""
2
 
3
  from dataclasses import dataclass
4
+ from typing import Literal
5
+
6
+ from pydantic import BaseModel, Field, field_validator
7
 
8
 
9
  @dataclass
10
+ class ExplainRequest:
11
+ """What a parent tells Fabella to explain to their child.
12
+
13
+ Attributes:
14
+ situation: A 1-3 sentence freeform description of the situation the
15
+ parent needs help explaining ("We're moving to a new house in
16
+ 3 weeks", "Why is grandma in the hospital?").
17
+ age: The child's age in years (5-12 range supported).
18
+ child_name: Optional name. If set, the explanation addresses the
19
+ child directly. If empty, the parent is addressed ("your child").
20
+ tone: "gentle" | "matter-of-fact" | "playful". Controls the
21
+ register of the explanation.
22
+ seed: Determinism for the drafter (the judge is temperature=0).
23
+ """
24
+
25
+ situation: str
26
  age: int
27
+ child_name: str = ""
28
+ tone: str = "gentle"
 
29
  seed: int = 0
30
+
31
+
32
+ # --- Judge output ---------------------------------------------------------
33
+
34
+
35
+ Verdict = Literal["approve", "revise"]
36
+
37
+
38
+ class JudgeVerdict(BaseModel):
39
+ """Structured output of the small Nemotron judge.
40
+
41
+ The judge receives a draft explanation and a 6-criterion rubric, and
42
+ returns one of these. Validated by Pydantic so that any deviation
43
+ from the schema is caught immediately.
44
+ """
45
+
46
+ ok: bool = Field(
47
+ description="True iff the draft is good enough to ship as-is."
48
+ )
49
+ issues: list[str] = Field(
50
+ default_factory=list,
51
+ description="Concrete, actionable problems with the draft. Empty if ok=true.",
52
+ )
53
+ score: float = Field(
54
+ ge=0.0,
55
+ le=1.0,
56
+ description="0..1 quality score. >=0.8 is generally approve-worthy.",
57
+ )
58
+ verdict: Verdict = Field(
59
+ description='"approve" if the draft is ready, "revise" if the drafter should rewrite.'
60
+ )
61
+ reasoning: str = Field(
62
+ default="",
63
+ description="One short sentence explaining the verdict.",
64
+ )
65
+
66
+ @field_validator("issues")
67
+ @classmethod
68
+ def _issues_short(cls, v: list[str]) -> list[str]:
69
+ # Strip and bound each issue to 200 chars so a runaway model can't
70
+ # blow up the drafter's context window.
71
+ return [str(i).strip()[:200] for i in v if str(i).strip()]
72
+
73
+ @field_validator("reasoning")
74
+ @classmethod
75
+ def _reasoning_short(cls, v: str) -> str:
76
+ return (v or "").strip()[:300]
77
+
78
+
79
+ class JudgeFailed(Exception):
80
+ """Raised when the judge output cannot be parsed after a retry.
81
+
82
+ The caller (the validate_explanation tool) should fall back to the
83
+ rule-based check.
84
+ """
85
+
86
+ def __init__(self, message: str, last_text: str = ""):
87
+ super().__init__(message)
88
+ self.last_text = last_text