Spaces:
Runtime error
A newer version of the Gradio SDK is available: 6.22.0
AGENTS.md
Quick orientation for future OpenCode sessions working on Fabella.
What this is
A Gradio app for parents who need to explain hard things to their child in kid-appropriate language. The parent types a sentence or two about the situation; Fabella drafts a short explanation (Opener / Body / Closer / optional follow-up) that's reviewed by a second small model before the parent sees it. After generation, the parent can optionally click Read aloud to synthesize the explanation with VoxCPM2.
Built for the Build Small Hackathon Β· Track I Β· Backyard AI ("useful for someone the maker actually knows").
The product design has two distinct execution layers, each tuned to its job:
- Drafter on LangGraph. The drafter is a
create_agentReAct loop with one tool (validate_explanation) and a custom middleware. 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. No LangGraph loop, no
LangChain agent, no state machine.
judge.pycalls the judge endpoint through the OpenAI-compatible client, validates withJudgeVerdict.model_validate_json(), and has one direct JSON repair retry. Cross-field consistency is enforced in code.
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 Direct Pydantic verdict audio/wav when requested
validate_explanation tool one invoke + one repair retry
middleware jumps to "end" on OK
- HF Space env vars:
MODAL_DRAFTER_URL,MODAL_JUDGE_URL, andMODAL_TTS_URL. - HF OAuth is enabled (
hf_oauth: true) for personalization; unsigned users fall back to browser-local anonymous sessions. - The mounted HF bucket stores minimal SQLite history at
/models/fabella-data/history.sqlite3. This is not PostgreSQL; use external Postgres later if multi-replica writes are needed. - Modal uses the
nvidia/cuda:12.9.0-devel-ubuntu22.04base image (provides nvcc for FlashInfer). - Drafter vLLM flags:
--language-model-only --enable-auto-tool-choice --tool-call-parser gemma4. - Judge vLLM flags: none (the judge emits raw JSON in
content; Pydantic parses). - TTS is separate from drafter/judge and only called from
make_audiowhen the user clicks Read aloud. - Drafter and judge use
min_containers=0andscaledown_window=2 * MINUTESso containers fall to zero when idle. This keeps the GPU bill under control for the 3-day demo. There is no warmup ping on Space import: every Space restart (code push, env-var change, periodic rebalance) would have paid for an A10G cold start whether or not a parent ever arrived. The first real request after a quiet period pays the 30-60s cold start (image-baked weights, eager mode, AOT compile cache, deep-gemm warmup skip), and the 2-minute scaledown window keeps a parent who reads the welcome screen and clicks a chip on a warm container for free. - TTS also uses
min_containers=0(same policy) and runs onL4instead of A10G because VoxCPM2 is small enough for a cheaper/newer GPU class. - Drafter and judge vLLM flags include
--enforce-eager --safetensors-load-strategy eager --gpu-memory-utilization 0.85. Drafter--max-model-len 8192, judge--max-model-len 4096. Eager mode skips CUDA-graph capture (saves 20-40s of cold start). Smaller per-server max-model-len keeps the warmup profile tight. Safetensors eager load avoids an mmap-fault stall on first request. - The drafter prompt is aggressively summarized:
_build_user_promptinagent.pykeeps the last 2 turns verbatim and compresses everything older into a single short line capped at 320 chars. This is what lets us run the drafter at--max-model-len 8192instead of the model's nominal 32k, and it directly reduces per-request drafter token cost. - vLLM env vars:
VLLM_DEEP_GEMM_WARMUP=skip(our 4B models are dense, so the JIT warmup is pure startup cost),VLLM_USE_AOT_COMPILE=1+VLLM_CACHE_ROOT=/root/.cache/vllm(compile artifacts persist across cold starts via the cache volume). The drafter, judge, and TTS weights are baked into the vLLM image viaImage.run_function(download_*)so cold start is image-pull + eager-mode init + load-to-VRAM.
Live URLs
- HF Space: https://build-small-hackathon-fabella.hf.space
- Modal drafter: https://khoitruong071510--fabella-serve-drafter.modal.run
- Modal judge: https://khoitruong071510--fabella-serve-judge.modal.run
- Modal TTS: 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
Run / verify
Local dev uses uv, not pip:
uv venv .venv
uv pip install --python .venv/bin/python -r requirements.txt
.venv/bin/python app.py # http://localhost:7860
The custom frontend runs on CPU locally. For story generation to work,
the MODAL_DRAFTER_URL, MODAL_JUDGE_URL, and MODAL_TTS_URL env vars
must point to live Modal deploys. Use the deployed HF Space for
end-to-end testing.
app.py is the only entrypoint. No test suite exists yet.
File map
app.pyβgradio.Server(FastAPI subclass) app. ImportsFabellaVLLMfromllm.pyand callsrun_agentfromagent.py. Serves a hand-coded HTML+CSS+JS page.@app.api()endpointmake_explanationreturns Opener/Body/Closer/Follow-up joined by U+001F.make_audioproxies VoxCPM2 and returns a base64 WAV data URL./api/me,/api/history,/api/history/append, and/api/history/clearstore minimal chat history/preferences in bucket-backed SQLite with HF OAuth identity when available. Contains a no-op@spaces.GPUplaceholder function (HF Spaces runtime scans for at least one during import; all inference runs on Modal).agent.pyβ LangChain ReAct agent.build_agent(llm, req, judge_llm=None)returns(agent, user_prompt). One tool:validate_explanation(calls the Pydantic judge ifjudge_llmis given, else falls back to a rule check).FabellaAgentMiddleware.before_modeljumps toendonce validation passes or aftermax_tool_calls=2.extract_explanation(messages)parses the four 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/OpenAIresponse_formatwithJudgeVerdict.model_json_schema(), then falls back to prompt-only JSON plus one repair retry before raisingJudgeFailed. Tolerant of markdown fences and pretty-printed JSON.schema.pyβExplainRequestdataclass (situation, age, child_name, tone, seed),JudgeVerdictPydantic model (ok, issues, score, verdict, reasoning),JudgeFailedexception.safety.pyβ input sanitization (sanitize_situation,sanitize_name,has_profanity),explain_to_words(tone),age_bucket(age).llm.pyβFabellaVLLM, aBaseChatModelsubclass wrapping vLLM's OpenAI-compatible API.bind_toolsbuilds an OpenAI-spectools=[...]payload,_generatepasses it on the request and readsresponse.choices[0].message.tool_callsfrom the response. Replay of priorAIMessage.tool_callsandToolMessageresults into next-turn messages uses the OpenAI chat-completions shape.modal_app.pyβ Modal deployment.download_drafter,download_judge, anddownload_ttswrite weights to thefabella-modelsVolume.serve_drafterruns vLLM with--language-model-only --enable-auto-tool-choice --tool-call-parser gemma4on port 8000 (A10G).serve_judgeruns vLLM with no tool-calling flags on port 8001 (A10G).serve_ttsruns a tiny VoxCPM2 FastAPI app on port 8002 (L4). One Modal app, three web_server functions.modal_app_gemma.pyβ (removed) Legacy single-model Modal deploy from the previous session. Not the live deploy. Reference only.
Non-obvious gotchas
No-op
@spaces.GPUinapp.py. The HF Spaces runtime scans for at least one@spaces.GPUfunction at module 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.Bucket per-user JSON files, not PostgreSQL or a separate database cloud API. The mounted HF bucket is file/object storage, so Fabella stores one minimal JSON file per parent at
/data/fabella-data/user-<owner_key>.json(signed-in users keyed by HF username, anonymous users keyed by alocalStoragesession id). The file holds the last ~80 messages plus a small profile (child name/age, preferred tone). No external database cloud API is used. If the Space is scaled to multiple replicas or needs analytics, move this to external Postgres viaDATABASE_URL.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. Nemotron-3-Nano-4B's chat template 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 rule check. The judge path intentionally bypasses LangChain message invocation on the deployed path; LangGraph stays only in the drafter loop.@app.apireturns a single string. Gradio Server's@app.apihas no output components, so tuples get dropped silently. The handler concatenates the four sections with\x1f(Unit Separator) and the frontend splits. Don't use\nas a separator β body text can contain newlines legitimately.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. 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.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).
When editing
- Adding a new tone preset β add to
TONE_CHOICESinapp.py(gentle / matter-of-fact / playful is the current set). - Adding an example situation β add to
EXAMPLE_SITUATIONSinapp.py. They appear as one-click chips on the left column. - Changing the drafter's tool set β edit
make_validate_toolinagent.py(it builds the tool closure per request). - Changing the judge's rubric β edit
judge.py::_build_rubricand theSYSTEM_PROMPTin the same file. The output schema is inschema.py::JudgeVerdictβ change both. - Changing the agent's max tool calls β pass
FabellaAgentMiddleware(max_tool_calls=N)tocreate_agentinagent.py::build_agent. - Adding a new example chip β add to
EXAMPLE_SITUATIONSinapp.py. - History, accounts, image upload are out of scope unless reopened.
Deployment
# 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 image, rolls out both 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 spaces variables add build-small-hackathon/Fabella \
--env MODAL_ASR_URL=https://khoitruong071510--fabella-asr-experiment-serve-asr.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