Spaces:
Running
A newer version of the Gradio SDK is available: 6.20.0
Fabella Handoff β Explanation + Read-Aloud Pipeline on Modal
Context
Fabella is a Gradio children's-storytelling app in /home/khoi/fabella,
now pivoted to Track I Β· Backyard AI ("useful for someone the maker
actually knows"). It solves a specific real problem parents face: how
do I explain a hard thing to my kid in their own language?
The parent describes a situation in a sentence or two. Fabella drafts a short, kind, age-appropriate explanation in an Opener / Body / Closer / follow-up shape. A second small model checks the draft against a 6-criterion rubric before the parent sees it. After generation, the parent can optionally click Read aloud to synthesize the explanation with VoxCPM2.
Live URLs:
- HF Space: https://build-small-hackathon-fabella.hf.space
- Modal drafter (Gemma 4 E4B-IT): https://khoitruong071510--fabella-serve-drafter.modal.run
- Modal judge (Nemotron-3 Nano 4B): https://khoitruong071510--fabella-serve-judge.modal.run
- Modal TTS (VoxCPM2): https://khoitruong071510--fabella-serve-tts.modal.run
- Modal app: https://modal.com/apps/khoitruong071510/main/deployed/fabella
- HF Space repo: https://huggingface.co/spaces/build-small-hackathon/Fabella
Current Architecture
HF Space (CPU, custom HTML+CSS+JS)
|
| POST /gradio_api/call/make_explanation
| -> SSE stream with 4-section string
v
app.py (gradio.Server / FastAPI)
|
+--------------------+--------------------+--------------------+
| | |
v v v
Modal drafter (A10G, Gemma 4 E4B-IT) Modal judge (A10G, Nemotron-3) Modal TTS (A10G, VoxCPM2)
--tool-call-parser gemma4 (no tool-calling flags) FastAPI /synthesize
ReAct via LangChain Pydantic JSON verdict audio/wav when requested
validate_explanation tool one invoke + one repair retry
middleware jumps to "end" on OK
Key design decisions:
HF OAuth + bucket per-user JSON history. Signed-in users are keyed by Hugging Face username; unsigned users use a browser
localStoragesession id. Minimal chat history and parent preferences persist in SQLite at/models/fabella-data/history.sqlite3on the mounted HF bucket. This is intentionally not PostgreSQL. Use external Postgres later if multi-replica writes or richer queries are needed.Drafter on LangGraph. The drafter is a
create_agentReAct loop with one tool (validate_explanation) and a custom middleware that jumps toendafter a successful validation or after a hard cap of two tool calls. State machine, conditional edges, tool-call plumbing β that's LangGraph's job, and it works.Judge on direct OpenAI + Pydantic. The judge task is bounded β one rubric, one draft, one structured verdict β so it doesn't need an agent loop.
judge.pycalls the judge endpoint through the OpenAI-compatible client, first tries vLLM/OpenAIresponse_formatwithJudgeVerdict.model_json_schema(), then falls back to prompt-only JSON plus one repair retry. Cross-field consistency (okβverdict) is enforced in code, not in the prompt.Three separate Modal web_servers. Drafter and judge run on A10G with
min_containers=0and a 2-minutescaledown_windowso they scale to zero when idle. TTS runs separately on L4 with the same scale-to-zero policy.TTS only runs on demand. VoxCPM2 is a separate FastAPI wrapper, not vLLM. The HF Space
make_audioAPI posts explanation text to/synthesize, receivesaudio/wav, and returns a base64 data URL to the browser. It cold-starts on demand after an idle period.The judge has NO tool-calling flags on the server side. Its prompt asks for raw JSON in
content; the Pydantic parser does the rest. This dodges Nemotron-3-Nano's chat-template tool-dialect (<tool_call>...</tool_call>markers that vLLM'shermesparser doesn't recognize) entirely.The drafter DOES use tool calling. vLLM is launched with
--enable-auto-tool-choice --tool-call-parser gemma4; the server parses Gemma 4's<|tool_call|>...<tool_call|>markers into OpenAI-spectool_callsJSON, which the client reads offresponse.choices[0].message.tool_callsdirectly.HF Space runs a custom
gradio.Server(FastAPI subclass). No default Gradio chrome. Storybook design, single hand-coded page.API contract for the frontend: the @app.api endpoint returns one string with sections joined by U+001F (Unit Separator) β Opener, Body, Closer, Follow-up. The frontend splits on that. (Gradio Server
@app.apihas no output components, so tuples get dropped β single string is the simplest workaround.)
File Map
| File | Purpose |
|---|---|
app.py |
gradio.Server (FastAPI subclass) app, custom HTML+CSS+JS, make_explanation API, make_audio TTS proxy, HF OAuth-aware history APIs, per-user JSON files in the HF Bucket, no-op @spaces.GPU placeholder for HF runtime |
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. |
judge.py |
Direct OpenAI-compatible + Pydantic-validated judge. judge_explanation(llm, draft, req_age, req_tone, child_name, situation) -> JudgeVerdict. First tries vLLM/OpenAI response_format with JudgeVerdict.model_json_schema(), then falls back to prompt-only JSON plus one repair retry before raising JudgeFailed. Tolerant of markdown fences and pretty-printed JSON. |
schema.py |
ExplainRequest dataclass + JudgeVerdict Pydantic model + JudgeFailed exception. |
safety.py |
Input sanitization, profanity block, sanitize_situation, explain_to_words(tone), age_bucket(age). |
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. |
modal_app.py |
Modal deployment: download_drafter + download_judge + download_tts; serve_drafter (port 8000), serve_judge (port 8001), serve_tts (port 8002). Drafter/judge on A10G; TTS on L4. |
modal_app_gemma.py |
(removed) Legacy: a previous-session single-model Modal deploy, kept for reference. Not the live deploy. |
What Changed This Session
The most recent session (pivot to Backyard AI) changed:
New files
judge.pyβ Pydantic-validated judge with repair retrymodal_app_gemma.pyβ (removed) kept as reference for the prior single-model deploy
Substantially rewritten
agent.pyβ story-generation agent replaced with explanation-generation agent.make_validate_toolnow optionally takesjudge_llmand routes throughjudge_explanation(). The drafter's output format changed from "Title: / body" to "Opener: / Body: / Closer: / (optional) If they ask more:".extract_explanationparses these four sections.schema.pyβStoryRequestreplaced withExplainRequest(situation, age, child_name, tone, seed). AddedJudgeVerdict(Pydantic) andJudgeFailed.safety.pyβsanitize_situation,explain_to_words(tone). Legacy theme/moral/length functions kept for compat.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 withmake_audioproxy to VoxCPM2.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.llm.pyβ defaults updated to point at the drafter endpoint (gemma-4model name).
Removed earlier
multi_agent.pyβ earlier multi-agent design (3 parallel drafters + judge) was revertednemotron3_tool_parser.pyβ custom XML tool parser for the (also removed) 30B Nemotron pathprompts.py,generator.py,mock.py,real.pyβ legacy files
Non-obvious gotchas
- No-op
@spaces.GPUinapp.py. HF Spaces runtime scans for at least one@spaces.GPUfunction at import and raisesRUNTIME_ERROR: No @spaces.GPU function detectedif none exists. The placeholder is a 1-second no-op. Do not delete it. sys.pathhack in every module. Each file doessys.path.insert(0, os.path.dirname(...))so imports work when run aspython app.pyfrom the package root. Don't refactor to relative imports.- Pydantic disallows
_-prefixed fields. Inllm.py, the runtime-mutable state (OpenAI client, tools, call counter) is declared withPrivateAttr, notField. - Three Modal endpoints, three env vars. HF Space reads
MODAL_DRAFTER_URL,MODAL_JUDGE_URL, andMODAL_TTS_URL. The oldMODAL_VLLM_URLis dead β delete it if it's still there. - The drafter uses native tool calling via vLLM. vLLM is started
with
--enable-auto-tool-choice --tool-call-parser gemma4; the server parses Gemma 4's native<|tool_call|>call:name{args}<tool_call|>markers into OpenAI-spectool_callsJSON. The client passes realtools=[{type:"function", function:{name, description, parameters:JSON-schema}}]on each request and readsresponse.choices[0].message.tool_callsdirectly. If the model emits no tool call,contentis returned as the final answer. - The judge does NOT use tool calling. The chat template for
Nemotron-3-Nano-4B emits tool calls in a custom XML dialect inside
<tool_call>...</tool_call>markers that vLLM's built-in parsers don't recognize. The judge server runs with no tool-calling flags; the judge prompt asks for raw JSON incontent, andjudge.pyparses that with Pydantic. - Pydantic judge schema in
schema.py.JudgeVerdicthas five fields:ok(bool),issues(list[str], each capped at 200 chars),score(float in [0, 1]),verdict(Literal["approve", "revise"]),reasoning(str, capped at 300 chars). Cross-field consistency (okβverdict) is enforced injudge_explanation()β the model is asked to agree, and the code normalizes if it doesn't. - Judge retry-on-failure. If the first response isn't parseable
JSON,
judge_explanation()retries once with aREPAIR_PROMPTthat shows the previous bad response. If both fail,JudgeFailedis raised and the validate tool falls back to the deterministic rule check. The deployed judge path intentionally bypasses LangChain message invocation; LangGraph stays only in the drafter loop. - Middleware
@hook_config(can_jump_to=["end"])is required. Without it, LangGraph never creates the conditional edge and the early-exit silently does nothing. - Modal uses CUDA devel image. The
nvidia/cuda:12.9.0-devel-ubuntu22.04base provides nvcc, which vLLM/FlashInfer need.debian_slimcrashes during vLLM startup. - Drafter flag
--language-model-onlyis required. Gemma 4's multimodal processor pulls heavy deps and crashes the vLLM server on text-only requests. This flag tells vLLM to skip processor init. The judge (Nemotron-Nano-4B) is text-only and does NOT need this flag. - Gemma 4 E4B is multimodal β it can take audio input. This
matters in two ways:
--language-model-onlyis correct today because Fabella's drafter only ever receives text. If you later add a feature where the parent records a 30s voice memo and the drafter transcribes it (Whisper-style), the vLLM flag will need to change to support audio inputs. The model supports it natively.- The audio side of Gemma 4 is a separate path from the VoxCPM2 TTS endpoint. They are independent: VoxCPM2 reads text and produces 48 kHz audio; Gemma 4 could (if enabled) read audio and produce text. Don't conflate them when debugging.
- Critical-path LLMs scale to zero. Drafter and judge use
min_containers=0with a 2-minutescaledown_window, so the first generation after idle pays a Modal/vLLM cold start but the demo does not bill continuously while nobody is using it. TTS follows the same policy on L4. - TTS runs on L4. VoxCPM2 is ~2B and fits smaller GPUs, so
serve_ttsusesgpu="L4"plusmin_containers=0instead of A10G. If L4 availability or latency is bad, switch back to A10G or try Modal GPU fallbacks. - VoxCPM2 TTS is not vLLM.
serve_ttswrites a generated FastAPI server into the container and runsuvicorn --app-dir /root. It returnsaudio/wavfrom/synthesize;app.py::make_audioconverts that to a base64 data URL for the browser. - Do not switch to
nanovllm-voxcpmfor this demo. It is faster, but it needsflash-attn, changes the API (target_text, streamed MP3), and is not worth the integration risk with one day left and a tight GPU budget. Keep the stable official VoxCPM2 server. section_sepis U+001F (Unit Separator). The@app.apiendpoint returns Opener, Body, Closer, Follow-up joined by\x1f. The frontend splits on it. Don't use\nβ body text can contain newlines legitimately.FABELLA_MODEL_PATHenv var is no longer consulted on the deployed path. Modal'sdownload_drafterhardcodesgoogle/gemma-4-E4B-it(Apache 2.0, not gated). Do not swap togemma-3-4b-it(gated β would break the no-API-key rule).
Deployment Commands
# Modal: download weights (run once per model)
.venv/bin/modal run modal_app.py::download_drafter
.venv/bin/modal run modal_app.py::download_judge
.venv/bin/modal run modal_app.py::download_tts
# Modal: deploy (rebuilds the image, rolls out all web_servers)
.venv/bin/modal deploy modal_app.py
# HF Space: env vars
hf spaces variables add build-small-hackathon/Fabella \
--env MODAL_DRAFTER_URL=https://khoitruong071510--fabella-serve-drafter.modal.run
hf spaces variables add build-small-hackathon/Fabella \
--env MODAL_JUDGE_URL=https://khoitruong071510--fabella-serve-judge.modal.run
hf spaces variables add build-small-hackathon/Fabella \
--env MODAL_TTS_URL=https://khoitruong071510--fabella-serve-tts.modal.run
# HF Space: upload code
hf upload build-small-hackathon/Fabella app.py --type space
hf upload build-small-hackathon/Fabella agent.py --type space
hf upload build-small-hackathon/Fabella judge.py --type space
hf upload build-small-hackathon/Fabella llm.py --type space
hf upload build-small-hackathon/Fabella schema.py --type space
hf upload build-small-hackathon/Fabella safety.py --type space
hf upload build-small-hackathon/Fabella requirements.txt --type space
# HF Space: restart to pick up new code
hf spaces restart build-small-hackathon/Fabella
Cost
- Drafter: 1Γ A10G while active, $0.80/hr, 2-minute scaledown
- Judge: 1Γ A10G while active, $0.80/hr, 2-minute scaledown
- TTS: 1Γ L4 while active,
min_containers=0, only used after Read aloud - At idle: $0/hr (scaledown)
- Typical demo session: a few minutes warm = ~$0.03-0.05
Known Issues / Open Questions
- Cold start latency β First request after 2 min idle triggers
vLLM cold start (~2 min per container for model load + torch.compile
- CUDA graph capture). Both containers cold-start in sequence on
the first request of a new session. Could add
min_containers=1to each Modal serve() to keep warm (costs ~$1.60/hr idle).
- CUDA graph capture). Both containers cold-start in sequence on
the first request of a new session. Could add
- No test suite β No automated tests exist. Manual smoke-tests are in this handoff (search "Live test" or "Smoke-test").
- Judge occasionally emits unparseable thinking-traces. The
judge_explanation()repair prompt fixes this most of the time. When both attempts fail, the validate tool falls back to the rule check, so the system never hard-errors. The model is a reasoning model; a--default-chat-template-kwargs '{"enable_thinking": false}'flag could be added to the judge server to make outputs shorter, but the retry handles it well enough. - Drafter at temperature 0.9 β produces creative variety but the
judge sometimes rejects a perfectly good draft on style grounds. The
seedUI control lets parents re-roll for variety.
Suggested Skills
hf-cliβ Manage HF Space: variables, logs, uploads, restartsfind-docsβ For Modal, vLLM, Gradio, LangChain, Pydantic API questions (use ctx7 CLI)diagnoseβ If runtime errors occur (vLLM startup, agent failures, judge parsing)agent-browserβ For end-to-end testing of the live HF Spacehandoffβ If handing off again after further work
Next Steps (if continuing)
- Add a
min_containers=1warmup to both Modal serves for zero cold-start latency - Add basic test suite: judge parsing (valid / repair / fallback), validate tool, explanation extraction, end-to-end agent with stubs
- Stream the explanation token-by-token as the drafter writes it (the API contract would change from one-shot to SSE)
- Cache common patterns (the same situation often comes up β "moving", "new baby", "death of grandparent") so warm requests skip the LLM
- Polish the HF Space card README to match the new Backyard AI framing before the hackathon submission deadline