"""Fabella - small words for big questions. A Gradio Server (FastAPI subclass) serves a custom HTML+CSS+JS page. The parent describes a hard-to-explain situation; Fabella drafts a short, kind, age-appropriate explanation, validated by a second small model. Architecture (see modal_app.py for the server side): Gemma 4 E4B (drafter, A10G) - writes the explanation Nemotron-3 Nano 4B (judge, A10G) - multi-criteria review """ import os import sys import asyncio import traceback import base64 import json import urllib.error import urllib.request import re import uuid import warnings from datetime import datetime, timezone from pathlib import Path from threading import Lock sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) os.environ.setdefault("HF_HUB_DISABLE_EXPERIMENTAL_WARNING", "1") warnings.filterwarnings( "ignore", message=".*HTTP_422_UNPROCESSABLE_ENTITY.*", ) warnings.filterwarnings( "ignore", message="OAuth is not supported outside of a Space environment.*", ) def _silence_asyncio_invalid_fd_warning() -> None: import asyncio original_del = asyncio.BaseEventLoop.__del__ def safe_del(self): try: original_del(self) except ValueError as exc: if "Invalid file descriptor" not in str(exc): raise asyncio.BaseEventLoop.__del__ = safe_del _silence_asyncio_invalid_fd_warning() from fastapi import Request from fastapi.responses import HTMLResponse, JSONResponse, Response from agent import run_agent from schema import ExplainRequest import memory as memory_layer from safety import ( has_profanity, sanitize_name, sanitize_situation, ) MODAL_DRAFTER_URL = os.environ.get( "MODAL_DRAFTER_URL", "https://khoitruong071510--fabella-serve-drafter.modal.run", ) MODAL_JUDGE_URL = os.environ.get( "MODAL_JUDGE_URL", "https://khoitruong071510--fabella-serve-judge.modal.run", ) MODAL_TTS_URL = os.environ.get( "MODAL_TTS_URL", "https://khoitruong071510--fabella-serve-tts.modal.run", ) MODAL_ASR_URL = os.environ.get( "MODAL_ASR_URL", "https://khoitruong071510--fabella-asr-experiment-serve-asr.modal.run", ) # HF Spaces bucket mount. The current Space has a writable bucket mounted at # /models. We avoid a separate database cloud API by storing one minimal JSON # file per parent (HF user or anonymous browser session) inside that bucket. # This keeps persistence tied to HF infrastructure only. DATA_DIR = Path(os.environ.get("FABELLA_DATA_DIR", "/data/fabella-data")) _history_lock = Lock() _MAX_MESSAGES = 80 _SAFE_KEY = re.compile(r"[^a-zA-Z0-9._-]+") try: from huggingface_hub import attach_huggingface_oauth, parse_huggingface_oauth except Exception: attach_huggingface_oauth = None parse_huggingface_oauth = None try: import spaces @spaces.GPU(duration=1) def _gpu_placeholder() -> str: return "ok" except ImportError: pass from gradio import Server app = Server() demo = app if attach_huggingface_oauth is not None: try: attach_huggingface_oauth(app) except Exception as e: print(f"[auth] OAuth endpoints disabled: {type(e).__name__}: {e}", flush=True) # Start the trace publisher. It no-ops on local dev (no HF_TOKEN) and is # safe to call when HF_TOKEN is missing. See trace.py for the schema, # anonymization rules, and opt-out semantics. try: import trace as _trace _trace.publisher.start() except Exception as e: print(f"[traces] publisher failed to start: {type(e).__name__}: {e}", flush=True) # The previous deployment ran a warmup ping to ``/health`` on each # Modal endpoint at Space startup, so the first parent click would land # on a warm container. We removed that ping: the Space restarts several # times per day (code pushes, env-var changes, HF rebalances), and each # restart paid for an A10G cold start whether or not a parent ever # arrived. With the ping gone, the first real request after a quiet # period still pays a 30-60s cold start (image-baked weights, eager # mode, AOT compile cache, deep-gemm warmup skip) but every subsequent # request within the Modal scaledown window lands on a warm container # for free. The 2-minute scaledown window is wide enough that a parent # who reads the welcome screen and clicks a chip pays zero extra. def _now_iso() -> str: return datetime.now(timezone.utc).isoformat() def _oauth_user(request: Request) -> dict: if parse_huggingface_oauth is None: return {"signed_in": False} try: info = parse_huggingface_oauth(request) except Exception as e: print(f"[auth] parse failed: {type(e).__name__}: {e}", flush=True) return {"signed_in": False} if info is None: return {"signed_in": False} user = getattr(info, "user_info", None) username = getattr(user, "preferred_username", None) or getattr(user, "name", None) or getattr(user, "sub", None) if not username: return {"signed_in": False} return { "signed_in": True, "username": str(username), "display_name": str(getattr(user, "name", "") or username), "avatar_url": str(getattr(user, "picture", "") or ""), } def _owner_from_request(request: Request, session_id: str) -> tuple[str, str | None, str]: user = _oauth_user(request) clean_session = (session_id or "").strip()[:80] if not clean_session: clean_session = str(uuid.uuid4()) if user.get("signed_in"): return f"hf:{user['username']}", user["username"], clean_session return f"anon:{clean_session}", None, clean_session def _safe_slug(value: str) -> str: cleaned = _SAFE_KEY.sub("-", value).strip("-") return (cleaned or "anon")[:80] def _history_path(owner_key: str) -> Path: return DATA_DIR / f"user-{_safe_slug(owner_key)}.json" def _empty_history() -> dict: return {"messages": [], "profile": None, "updated_at": None} def _read_history(owner_key: str) -> dict: path = _history_path(owner_key) if not path.exists(): return _empty_history() try: data = json.loads(path.read_text(encoding="utf-8")) except Exception as e: print(f"[history] read failed for {owner_key}: {type(e).__name__}: {e}", flush=True) return _empty_history() if not isinstance(data, dict): return _empty_history() data.setdefault("messages", []) data.setdefault("profile", None) return data def _write_history(owner_key: str, data: dict) -> None: DATA_DIR.mkdir(parents=True, exist_ok=True) path = _history_path(owner_key) tmp = path.with_suffix(path.suffix + ".tmp") tmp.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8") tmp.replace(path) def _public_messages(messages: list) -> list: return [ { "role": m.get("role"), "content": m.get("content", ""), "age": m.get("age"), "child_name": m.get("child_name") or "", "tone": m.get("tone") or "", "created_at": m.get("created_at", ""), } for m in messages[-_MAX_MESSAGES:] ] def _sectioned_to_text(sectioned: str) -> str: parts = (sectioned or "").split(SECTION_SEP) labels = ["Opener", "Body", "Closer", "If they ask more"] lines = [] for label, part in zip(labels, parts): text = (part or "").strip() if text: lines.append(f"{label}: {text}") return "\n\n".join(lines) def _make_drafter(seed: int = 0): from llm import FabellaVLLM return FabellaVLLM(base_url=MODAL_DRAFTER_URL, model_name="gemma-4", seed=seed) def _make_judge(seed: int = 0): from llm import FabellaVLLM return FabellaVLLM( base_url=MODAL_JUDGE_URL, model_name="nemotron-3-4b", temperature=0.0, top_p=1.0, max_tokens=1024, seed=seed, ) # Sections joined with U+001F (Unit Separator) so the frontend can split # reliably on a character that never appears in natural text. SECTION_SEP = "\x1f" def _make_explanation_sync(situation: str, age: int, child_name: str, tone: str, seed: int, history: list | None = None, owner_key: str = "", session_id: str = "", share_trace: bool = True) -> str: clean_situation = sanitize_situation(situation) clean_name = sanitize_name(child_name) clean_tone = (tone or "gentle").strip().lower() if clean_tone not in ("gentle", "matter-of-fact", "playful"): clean_tone = "gentle" if has_profanity(clean_situation) or has_profanity(clean_name): return SECTION_SEP.join([ "Fabella (safety)", "Some of the words used aren't allowed. Please try different words.", "", "", ]) if not clean_situation: return SECTION_SEP.join([ "Fabella (empty)", "Tell me about the situation first - what's going on, in a sentence or two?", "", "", ]) if not (5 <= int(age) <= 12): age = 7 # default for out-of-range clean_history = [] if isinstance(history, list): for item in history[-6:]: if not isinstance(item, dict): continue role = (item.get("role") or "").strip().lower() content = (item.get("content") or "").strip() if role in ("parent", "fabella") and content: clean_history.append({"role": role, "content": content[:1200]}) # Merge in durable memory from the bucket so the drafter can use it. if not owner_key: owner_key = f"anon:{(session_id or 'anon')[:80]}" mem = memory_layer.read_memory(owner_key) mem_block = memory_layer.memory_context_block(mem) if mem_block: clean_history = [{"role": "memory", "content": mem_block}] + clean_history req = ExplainRequest( situation=clean_situation, age=int(age), child_name=clean_name, tone=clean_tone, seed=int(seed or 0), history=clean_history, share_trace=bool(share_trace), ) try: print( f"[app] request: age={req.age} tone={req.tone} name='{req.child_name}' situation='{req.situation[:60]}…'", flush=True, ) drafter = _make_drafter(seed=req.seed) judge = _make_judge(seed=req.seed) result = run_agent(drafter, req, judge_llm=judge) return SECTION_SEP.join([ result.get("opener", "") or "", result.get("body", "") or "", result.get("closer", "") or "", result.get("followup", "") or "", ]) except Exception as e: print(f"[app] handler error: {type(e).__name__}: {e}", flush=True) return SECTION_SEP.join([ "Fabella (error)", f"_Generation failed: {type(e).__name__}: {e}_", "", "", ]) @app.api(name="make_explanation") def make_explanation( situation: str, age: int, child_name: str, tone: str, seed: int, history: list | None = None, owner_key: str = "", session_id: str = "", share_trace: bool = True, ) -> str: """Draft a short, kind, age-appropriate explanation. Returns four sections joined by U+001F: 0: opener (one sentence the parent can say to start) 1: body (1-3 short paragraphs) 2: closer (one sentence the parent can say to land) 3: follow-up (optional one sentence if the child has another question) `history` is a list of recent parent/Fabella turns so follow-up questions in the same conversation can be answered with the previous explanation in context. The drafter sees the last 6 turns. Long-term memory from the bucket (summary, preferences, durable facts) is folded in automatically when `owner_key` is provided. `share_trace` (default True) opts the request into the public, anonymized agent-trace dataset (see trace.py). Pass False to skip publishing this request. The global kill switch is FABELLA_SHARE_TRACES=0. """ return _make_explanation_sync( situation, age, child_name, tone, seed, history, owner_key, session_id, share_trace ) def _clean_audio_text(text: str) -> str: clean = " ".join((text or "").split()) if len(clean) > 1600: clean = clean[:1600].rsplit(" ", 1)[0].strip() return clean def _narration_from_sections(opener: str, body: str, closer: str, followup: str) -> str: """Join the four sections into one plain conversational narration. The TTS must never speak the structural labels ("Opener", "Body", "Closer", "If they ask more"). It must never include a child's name. We address the listener as "you" only. """ parts = [] for piece in (opener, body, closer, followup): piece = (piece or "").strip() if piece: parts.append(piece) return " ".join(parts) def _strip_labeled_draft(text: str) -> str: """Fallback: if a client sends the raw labeled draft, strip the labels.""" if not text: return "" out = [] for line in text.splitlines(): stripped = re.sub( r"^\s*(Opener|Body|Closer|If they ask more|If they ask another question)\s*:\s*", "", line, flags=re.IGNORECASE, ) if stripped.strip(): out.append(stripped.strip()) return " ".join(out).strip() def _make_audio_sync(text: str, tone: str, opener: str = "", body: str = "", closer: str = "", followup: str = "") -> str: if opener or body or closer or followup: clean_text = _narration_from_sections(opener, body, closer, followup) else: clean_text = _strip_labeled_draft(text) clean_text = _clean_audio_text(clean_text) if not clean_text: return "ERROR: Nothing to read aloud yet." clean_tone = (tone or "gentle").strip().lower() voice_by_tone = { "gentle": "A calm adult woman with a soft, warm, reassuring voice, speaking slowly and clearly for a child.", "matter-of-fact": "A calm adult woman with a clear, steady, practical voice, speaking plainly and kindly for a child.", "playful": "A friendly adult woman with a lightly playful, bright, reassuring voice, speaking clearly for a child.", } payload = { "text": clean_text, "voice_description": voice_by_tone.get(clean_tone, voice_by_tone["gentle"]), "cfg_value": 2.0, "inference_timesteps": 10, "normalize": True, "denoise": True, } req = urllib.request.Request( MODAL_TTS_URL.rstrip("/") + "/synthesize", data=json.dumps(payload).encode("utf-8"), headers={"Content-Type": "application/json", "Accept": "audio/wav"}, method="POST", ) try: with urllib.request.urlopen(req, timeout=180) as res: audio = res.read() except urllib.error.HTTPError as e: detail = e.read().decode("utf-8", errors="replace")[:300] print(f"[app] tts HTTP error: {e.code}: {detail}", flush=True) return f"ERROR: Read-aloud failed: HTTP {e.code}" except Exception as e: print(f"[app] tts error: {type(e).__name__}: {e}", flush=True) return f"ERROR: Read-aloud failed: {type(e).__name__}: {e}" if not audio: return "ERROR: Read-aloud returned no audio." return "data:audio/wav;base64," + base64.b64encode(audio).decode("ascii") @app.api(name="make_audio") def make_audio(text: str, tone: str, opener: str = "", body: str = "", closer: str = "", followup: str = "") -> str: """Synthesize a Fabella explanation as a base64 WAV data URL. The frontend should pass the four sections explicitly so the server can join them into a clean conversational narration. `text` is accepted as a legacy fallback and its labels are stripped before TTS. """ return _make_audio_sync(text, tone, opener, body, closer, followup) def _validate_audio_data_url(data_url: str) -> None: if not data_url.startswith("data:audio/") or "," not in data_url: raise ValueError("Expected a browser audio recording data URL.") header, payload = data_url.split(",", 1) if ";base64" not in header: raise ValueError("Audio recording must be base64 encoded.") # Validate and cap size before proxying. Browser MediaRecorder output is # compact; 8 MB is plenty for a short parent voice note. audio = base64.b64decode(payload, validate=True) if len(audio) > 8 * 1024 * 1024: raise ValueError("Voice note is too large. Please keep it under about 30 seconds.") if len(audio) < 256: raise ValueError("Voice note was empty.") def _transcribe_audio_sync(data_url: str, target_lang: str = "en-US") -> str: clean_lang = (target_lang or "en-US").strip() if clean_lang != "auto" and not re.match(r"^[a-z]{2}-[A-Z]{2}$", clean_lang): clean_lang = "en-US" try: _validate_audio_data_url(data_url) except Exception as e: return f"ERROR: {e}" payload = json.dumps({"audio_data_url": data_url, "target_lang": clean_lang}).encode("utf-8") req = urllib.request.Request( MODAL_ASR_URL.rstrip("/") + "/transcribe", data=payload, headers={"Content-Type": "application/json", "Accept": "application/json"}, method="POST", ) try: with urllib.request.urlopen(req, timeout=600) as res: body = json.loads(res.read().decode("utf-8")) except urllib.error.HTTPError as e: detail = e.read().decode("utf-8", errors="replace")[:300] print(f"[asr] modal HTTP error: {e.code}: {detail}", flush=True) return f"ERROR: Voice transcription failed: HTTP {e.code}" except Exception as e: print(f"[asr] modal error: {type(e).__name__}: {e}", flush=True) return f"ERROR: Voice transcription failed: {type(e).__name__}: {e}" if not body.get("ok"): return f"ERROR: Voice transcription failed: {body.get('error') or 'unknown error'}" text = str(body.get("text") or "").strip() return text or "ERROR: Nemotron ASR returned an empty transcript." @app.api(name="transcribe_audio") def transcribe_audio(data_url: str, target_lang: str = "en-US") -> str: """Transcribe a short browser-recorded voice note via Modal T4 ASR. The ASR runtime is deliberately isolated in ``modal_asr_app.py`` so NeMo, ffmpeg, and CUDA dependencies cannot break the HF Space build. """ return _transcribe_audio_sync(data_url, target_lang) @app.get("/api/me") async def api_me(request: Request): user = _oauth_user(request) return JSONResponse(user) @app.get("/api/history") async def api_history(request: Request, session_id: str = ""): DATA_DIR.mkdir(parents=True, exist_ok=True) owner_key, _, session_id = _owner_from_request(request, session_id) with _history_lock: data = _read_history(owner_key) return JSONResponse({ "session_id": session_id, "profile": data.get("profile"), "messages": _public_messages(data.get("messages", [])), }) @app.post("/api/history/append") async def api_history_append(request: Request): DATA_DIR.mkdir(parents=True, exist_ok=True) payload = await request.json() session_id = str(payload.get("session_id") or "") owner_key, _, session_id = _owner_from_request(request, session_id) parent = sanitize_situation(payload.get("parent") or "") fabella = _clean_audio_text(payload.get("fabella") or "") if not parent and not fabella: return JSONResponse({"ok": False, "error": "nothing to store"}, status_code=400) try: age = int(payload.get("age") or 0) or None except Exception: age = None child_name = sanitize_name(payload.get("child_name") or "") tone = (payload.get("tone") or "gentle").strip().lower() if tone not in ("gentle", "matter-of-fact", "playful"): tone = "gentle" now = _now_iso() with _history_lock: data = _read_history(owner_key) if parent: data["messages"].append({ "role": "parent", "content": parent, "age": age, "child_name": child_name, "tone": tone, "created_at": now, }) if fabella: data["messages"].append({ "role": "fabella", "content": fabella, "age": age, "child_name": child_name, "tone": tone, "created_at": now, }) data["messages"] = data["messages"][-_MAX_MESSAGES:] profile = data.get("profile") or {} profile.update({ "child_name": child_name or profile.get("child_name", ""), "child_age": age or profile.get("child_age"), "preferred_tone": tone or profile.get("preferred_tone", "gentle"), "updated_at": now, }) data["profile"] = profile data["updated_at"] = now _write_history(owner_key, data) return JSONResponse({"ok": True, "session_id": session_id}) @app.post("/api/history/clear") async def api_history_clear(request: Request): DATA_DIR.mkdir(parents=True, exist_ok=True) payload = await request.json() session_id = str(payload.get("session_id") or "") owner_key, _, session_id = _owner_from_request(request, session_id) with _history_lock: path = _history_path(owner_key) if path.exists(): try: path.unlink() except Exception as e: print(f"[history] clear failed for {owner_key}: {type(e).__name__}: {e}", flush=True) return JSONResponse({"ok": True, "session_id": session_id}) @app.get("/api/history/download") async def api_history_download(request: Request, session_id: str = ""): """Bundle the parent's full local data into a single JSON download. The download contains every chat turn stored in the parent's bucket-backed history file, the durable memory, and a small "trace-publication" section that tells them: * how many of their generations opted into the public trace dataset, * which dataset the rows go to (or would go to, on capture failure), * a copy of the anonymization rules so they can verify what was and was not recorded. Trace rows themselves are not in this file -- they live in the public dataset and contain only the redacted/anonymized data, not any of the raw material the parent typed. The download proves the point: there is nothing in here that is not also in the user's own browser, and the public rows cannot leak anything more. The response is ``Content-Disposition: attachment`` so the frontend can trigger a file save via a hidden ````. """ DATA_DIR.mkdir(parents=True, exist_ok=True) owner_key, _, session_id = _owner_from_request(request, session_id) with _history_lock: history = _read_history(owner_key) mem = memory_layer.read_memory(owner_key) # Best-effort estimate of "how many turns went to the public trace # dataset": the per-parent bucket does not store the trace # submission receipts, so this is an upper bound (the number of # turns that *had share_trace* at the time of generation, which # the JS records as ``shared`` in the local message). If the # client did not record that flag we fall back to total message # count, which is a soft upper bound. turn_count = len(history.get("messages") or []) shared_count = sum( 1 for m in history.get("messages", []) if m.get("shared") is True ) bundle = { "schema": "fabella.history-bundle.v1", "exported_at": _now_iso(), "owner_key": owner_key, "session_id": session_id, "signed_in": not owner_key.startswith("anon:"), "profile": history.get("profile"), "messages": _public_messages(history.get("messages", [])), "memory": memory_layer.public_view(mem), "trace_publication": { "dataset": "build-small-hackathon/fabella-traces", "url": "https://huggingface.co/datasets/build-small-hackathon/fabella-traces", "this_session_max_published_rows": shared_count, "this_session_max_turns": turn_count, "anonymization": [ "Child name is dropped from the request and replaced with [name] in the draft.", "Raw situation text is never stored; only its SHA-256 hash, the first 60 chars, and its length are kept.", "Freeform history turns are replaced with role + length counts in the published row.", "The drafter's static system prompt is shipped in full (it's a public string in this repo).", "Each row is its own data/.json file (race-free across Space replicas).", ], }, } body = json.dumps(bundle, ensure_ascii=False, indent=2).encode("utf-8") filename = f"fabella-history-{session_id[:24] or 'anon'}.json" return Response( content=body, media_type="application/json; charset=utf-8", headers={"Content-Disposition": f'attachment; filename="{filename}"'}, ) # --- Memory endpoints ------------------------------------------------------- @app.get("/api/memory") async def api_memory(request: Request, session_id: str = ""): owner_key, _, session_id = _owner_from_request(request, session_id) mem = memory_layer.read_memory(owner_key) return JSONResponse({ "session_id": session_id, "memory": memory_layer.public_view(mem), }) @app.post("/api/memory/append") async def api_memory_append(request: Request): payload = await request.json() session_id = str(payload.get("session_id") or "") owner_key, _, session_id = _owner_from_request(request, session_id) parent = sanitize_situation(payload.get("parent") or "") fabella = _clean_audio_text(payload.get("fabella") or "") preferences = {} if "child_name" in payload: preferences["child_name"] = sanitize_name(payload.get("child_name") or "") if "child_age" in payload: try: age = int(payload.get("child_age") or 0) if 3 <= age <= 18: preferences["child_age"] = age except Exception: pass if "preferred_tone" in payload: tone = (payload.get("preferred_tone") or "").strip().lower() if tone in ("gentle", "matter-of-fact", "playful"): preferences["preferred_tone"] = tone if not parent and not fabella: return JSONResponse({"ok": False, "error": "nothing to store"}, status_code=400) res = memory_layer.append_turn(owner_key, parent, fabella, preferences=preferences) return JSONResponse({ "ok": True, "session_id": session_id, "extraction": res.extraction, "memory": memory_layer.public_view(res.memory), }) @app.post("/api/memory/clear") async def api_memory_clear(request: Request): payload = await request.json() session_id = str(payload.get("session_id") or "") owner_key, _, session_id = _owner_from_request(request, session_id) memory_layer.clear_memory(owner_key) return JSONResponse({"ok": True, "session_id": session_id}) # --- HTML page -------------------------------------------------------------- TONE_CHOICES = [ ("gentle", "Gentle"), ("matter-of-fact", "Matter-of-fact"), ("playful", "Playful"), ] EXAMPLE_SITUATIONS = [ ("My 7-year-old's grandma is in the hospital for surgery. She asked why grandma is there."), ("We're moving to a new house in 3 weeks. My kid is worried about leaving her friends."), ("My child's dog died yesterday. She keeps asking when the dog is coming back."), ("It's time to start sharing toys at preschool. My son refuses and has started hitting."), ("My 9-year-old wants a phone. All her friends have one and I said no."), ("There's a new baby coming in 4 months. My first grader is acting out and being mean to me."), ] INDEX_HTML = r""" Fabella - small words for big questions
Fabella checks every draft against a six-criterion rubric. Record uses Nemotron 3.5 ASR on demand.

Settings

7 years

Your data, on this device

Your chat history and memory are stored in this Space's bucket, keyed to you. When you opt in, redacted copies of the drafter/judge trace are published to a public dataset — they contain no raw situation text, no child name, and no trace that links back to you.

""" INDEX_HTML = ( INDEX_HTML # rebuilt for Phase 3 chat UI .replace("__TONE_CHOICES__", "[" + ",".join('["' + v + '","' + l + '"]' for v, l in TONE_CHOICES) + "]") .replace("__EXAMPLES__", "[" + ",".join('"' + s.replace('"', '\\"') + '"' for s in EXAMPLE_SITUATIONS) + "]") ) @app.get("/", response_class=HTMLResponse) async def homepage(): return HTMLResponse(content=INDEX_HTML, status_code=200) @app.get("/health") async def health(): return {"status": "ok"} if __name__ == "__main__": app.launch( server_name="0.0.0.0", server_port=int(os.environ.get("PORT", 7860)), )