# 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 `localStorage` session id. Minimal chat history and parent preferences persist in SQLite at `/models/fabella-data/history.sqlite3` on 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_agent` ReAct loop with one tool (`validate_explanation`) and a custom middleware that jumps to `end` after 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.py` calls the judge endpoint through the OpenAI-compatible client, first tries vLLM/OpenAI `response_format` with `JudgeVerdict.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=0` and a 2-minute `scaledown_window` so 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_audio` API posts explanation text to `/synthesize`, receives `audio/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 (`...` markers that vLLM's `hermes` parser 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|>...` markers into OpenAI-spec `tool_calls` JSON, which the client reads off `response.choices[0].message.tool_calls` directly. - **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.api` has 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 retry - `modal_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_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. - `schema.py` — `StoryRequest` replaced with `ExplainRequest` (situation, age, child_name, tone, seed). Added `JudgeVerdict` (Pydantic) and `JudgeFailed`. - `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** with `make_audio` proxy 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-4` model name). ### Removed earlier - `multi_agent.py` — earlier multi-agent design (3 parallel drafters + judge) was reverted - `nemotron3_tool_parser.py` — custom XML tool parser for the (also removed) 30B Nemotron path - `prompts.py`, `generator.py`, `mock.py`, `real.py` — legacy files ## Non-obvious gotchas - **No-op `@spaces.GPU` in `app.py`.** HF Spaces runtime scans for at least one `@spaces.GPU` function at import and raises `RUNTIME_ERROR: No @spaces.GPU function detected` if none exists. The placeholder is a 1-second no-op. Do not delete it. - **`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. - **Pydantic disallows `_`-prefixed fields.** In `llm.py`, the runtime-mutable state (OpenAI client, tools, call counter) is declared with `PrivateAttr`, not `Field`. - **Three Modal endpoints, three env vars.** HF Space reads `MODAL_DRAFTER_URL`, `MODAL_JUDGE_URL`, and `MODAL_TTS_URL`. The old `MODAL_VLLM_URL` is 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}` markers into OpenAI-spec `tool_calls` JSON. The client passes real `tools=[{type:"function", function:{name, description, parameters:JSON-schema}}]` on each request and reads `response.choices[0].message.tool_calls` directly. If the model emits no tool call, `content` is 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 `...` 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 in `content`, and `judge.py` parses that with Pydantic. - **Pydantic judge schema in `schema.py`.** `JudgeVerdict` has 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 in `judge_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 a `REPAIR_PROMPT` that shows the previous bad response. If both fail, `JudgeFailed` is 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.04` base provides nvcc, which vLLM/FlashInfer need. `debian_slim` crashes during vLLM startup. - **Drafter flag `--language-model-only` is 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: 1. `--language-model-only` is 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. 2. 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=0` with a 2-minute `scaledown_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_tts` uses `gpu="L4"` plus `min_containers=0` instead of A10G. If L4 availability or latency is bad, switch back to A10G or try Modal GPU fallbacks. - **VoxCPM2 TTS is not vLLM.** `serve_tts` writes a generated FastAPI server into the container and runs `uvicorn --app-dir /root`. It returns `audio/wav` from `/synthesize`; `app.py::make_audio` converts that to a base64 data URL for the browser. - **Do not switch to `nanovllm-voxcpm` for this demo.** It is faster, but it needs `flash-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_sep` is U+001F (Unit Separator).** The `@app.api` endpoint 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_PATH` env var** is no longer consulted on the deployed path. Modal's `download_drafter` hardcodes `google/gemma-4-E4B-it` (Apache 2.0, not gated). Do not swap to `gemma-3-4b-it` (gated — would break the no-API-key rule). ## Deployment Commands ```bash # 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 1. **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=1` to each Modal serve() to keep warm (costs ~$1.60/hr idle). 2. **No test suite** — No automated tests exist. Manual smoke-tests are in this handoff (search "Live test" or "Smoke-test"). 3. **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. 4. **Drafter at temperature 0.9** — produces creative variety but the judge sometimes rejects a perfectly good draft on style grounds. The `seed` UI control lets parents re-roll for variety. ## Suggested Skills - `hf-cli` — Manage HF Space: variables, logs, uploads, restarts - `find-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 Space - `handoff` — If handing off again after further work ## Next Steps (if continuing) 1. Add a `min_containers=1` warmup to both Modal serves for zero cold-start latency 2. Add basic test suite: judge parsing (valid / repair / fallback), validate tool, explanation extraction, end-to-end agent with stubs 3. Stream the explanation token-by-token as the drafter writes it (the API contract would change from one-shot to SSE) 4. Cache common patterns (the same situation often comes up — "moving", "new baby", "death of grandparent") so warm requests skip the LLM 5. Polish the HF Space card README to match the new Backyard AI framing before the hackathon submission deadline