Field Notes: Patient Virtuel

The Real Person Behind the App

My wife is a dentist. She practiced for years in our home country, then we moved to Switzerland. When our son was born, she stepped back from her career to take care of him. Now that he is older, she is ready to return to the workforce. But in a new country and a new language.

Her French is intermediate. She learned it in high school and can hold a conversation, but she never learned the specific vocabulary of a dental hygiene consultation. In Switzerland, dental hygienists must communicate clearly, empathetically, and precisely with patients: explaining procedures, giving instructions, asking about sensitivity. Getting that language wrong is not just embarrassing. It affects patient trust.

The traditional approach would be a textbook or a tutor. A tutor is expensive and inflexible. A textbook cannot react to what you say. What she needed was a patient who could respond to her naturally, in real time, with the vocabulary and flow of a real Swiss dental appointment.

Why an LLM?

A scripted chatbot would not work. Every session needs to be different, or she would memorize the lines and lose the challenge of thinking on her feet. An LLM provides:

One model, two roles. No second service needed.

Architecture

┌─────────────────────────────────────────────────┐
│  Browser (custom HTML/CSS/JS)                   │
│  ← @gradio/client ↔ gr.Server (FastAPI) →      │
├─────────────────────────────────────────────────┤
│  HF Space (CPU only)                            │
│                                                  │
│  src/server_app.py  ← API endpoints             │
│  src/core.py        ← session logic             │
│  src/tts_engine.py  ← piper-tts (local, fr_FR)  │
│  src/prompts.py     ← system prompt + phase     │
│  src/parse_feedback.py ← structured recap       │
│                                                  │
├──────────────────┬──────────────────────────────┤
│  Modal app 1     │  Modal app 2                  │
│  L4 GPU          │  T4 GPU                       │
│  llama.cpp       │  faster-whisper               │
│  Gemma 4 26B     │  large-v3-turbo               │
│  /warmup + /chat │  / (STT endpoint)             │
└──────────────────┴──────────────────────────────┘

Request flow (one turn)

  1. User presses mic, browser records WAV, sends to gr.Server API.
  2. process_turn() in core.py: a. Sends audio to Modal Whisper endpoint, returns transcript. b. Appends user message to chat history, sends to Modal Gemma 4 endpoint, returns reply. c. Strips stray markdown from LLM output, feeds to local piper-tts, returns WAV bytes.
  3. Browser receives {chat, audio_url, state}, renders bubble, plays audio.

The split between Modal (GPU) and HF Space (CPU) comes from the free-tier constraints of HF Spaces: no GPU, limited RAM. Heavy compute (LLM inference, Whisper transcription) runs on Modal's serverless GPU tier. Piper TTS runs locally because it is lightweight (~50 MB model) and eliminates one network hop for audio latency.

Cost Control on Modal

I run two Modal apps: one with an L4 GPU for the LLM and one with a T4 GPU for Whisper. Both use Modal's serverless containers with a scaledown_window of 90 seconds. This means containers scale to zero when idle and I only pay for actual compute time.

Key cost decisions:

Total Modal cost for development and testing: roughly $15 over two months. For production use (a few sessions per week), I expect under $5/month.

Component Choices

LLM: Gemma 4 26B-A4B via llama.cpp on Modal L4

STT: faster-whisper-large-v3-turbo on Modal T4

The turbo variant of Whisper large-v3 offers near-parity accuracy at roughly half the compute. Running on a T4, it transcribes a 10-second audio clip in under 2 seconds. Dental French vocabulary (détartrage, aéropolissage, bavette, etc.) is handled accurately by Whisper's multilingual training.

TTS: piper-tts (local, fr_FR-siwis-medium)

The biggest pivot in the project. I originally used Microsoft's edge-tts cloud API on the free tier. I switched to piper-tts to claim the Off the Grid badge with zero third-party cloud APIs.

Piper TTS runs entirely on the HF Space CPU. The fr_FR-siwis-medium voice (Sie bavarde, Sie ne tait) is a neural model that produces natural French prosody for ~50 MB.

One gotcha: Piper v1.x changed synthesize() to return a generator of AudioChunk objects instead of (audio_bytes, sample_rate). You access the audio via chunk.audio_int16_bytes in a loop.

Frontend: gr.Server with custom HTML/CSS/JS

Gradio's default UI components are great for demos but inflexible for conversational UIs. I wanted a chat interface that looks like a messaging app, not a Jupyter notebook.

gr.Server (formerly gradio.Server) is a thin wrapper around FastAPI that registers Gradio-compatible API routes. You get the full @gradio/client JS library for calling those routes, plus total freedom over the HTML.

The frontend is vanilla: no React, no build step. The custom_index.html is served directly by FastAPI and uses import { Client } from "https://cdn.jsdelivr.net/npm/@gradio/client/+esm" for API calls.

Lessons Learned

1. gr.Server is Gradio's secret weapon

Most people know Gradio for gr.Interface and gr.Blocks. Those auto-generate UI. gr.Server strips away all UI and exposes Gradio as a pure API framework with FastAPI underneath. You can register endpoints with @app.api(name="..."), serve custom HTML with @app.get("/"), and call everything from JavaScript using the same @gradio/client library.

This was the single biggest discovery of the project. You get Gradio's easy API serialization, HF Spaces auto-deployment, and @gradio/client streaming without being locked into the default UI.

2. Piper TTS v1.x generator API

The tts-engine rewrite from edge-tts to piper-tts hit an API change between versions. The docs showed voice.synthesize(text)[0], but at v1.2 synthesize() returns a generator. Debugging this required reading the actual source:

for chunk in voice.synthesize(text):
    frames.extend(chunk.audio_int16_bytes)

Lesson: always check the return type of the version you actually have installed.

3. Modal cold starts need a warmup strategy

Modal containers scale to zero after a configurable scaledown_window. I use 90 seconds. The first request after idle triggers a cold start: container boot plus model load. For a 26B model, that is roughly 25 seconds of wait time.

My solution: fire a warmup request on page load. Both the STT and LLM endpoints have lightweight warmup handlers that trigger model loading without doing meaningful work.

The warmups are fire-and-forget (daemon threads) so they do not block the frontend. By the time the user presses the mic for the first turn, both containers are warm.

4. Prompt engineering for dual roleplay + feedback

The same model serves two roles: patient during the session and French coach after. The trick is the phase switch.

During roleplay, the model stays in character because the system prompt tells it to. When the user says "Fin de la séance" (detected by a regex), I append a PHASE_SWITCH_REMINDER message:

Reminder: you are now a French coach. Do NOT continue the patient roleplay.
Respond with exactly 2 spoken sentences in French (encouragement + priority),
then output --- on its own line, then provide the written recap with
Points forts, Citation/Correction/Pourquoi, Vocabulaire dentaire utile,
Priorité, and Bilan scores. Use plain text only.

Without this reminder, the model often stayed in character and produced a patient-voice recap. That was both confusing and unusable for the learner.

4b. Making the patient feel real

Early versions of the prompt produced a patient who agreed with everything and never initiated. Every response was "D'accord" — technically correct but useless for practicing patient communication. Real patients ask questions, express concerns, and react to sensations.

The fix was behavioral directives with probabilistic targets: - Patient must have mild dental anxiety and opinions about cost/procedures - Patient must ask questions, express concerns, or react unprompted in roughly 1 in 3 turns - Response length must vary: 1 word during active work, 2-3 sentences when initiating - Few-shot examples in the prompt show the desired style

The key insight: listing behaviors ("ask questions") is not enough. The model needs concrete examples and probability targets to avoid collapsing into a single repetitive pattern. Adding a safety valve instruction ("if the conversation stalls, de-escalate and let the hygienist lead") prevents the patient from becoming uncooperative.

5. Edge cases that matter

6. What did not work

7. Previous llama.cpp experience made Modal adoption easier

This was my first time working with a service like Modal. What helped me get started quickly was the hours I already spent tuning llama.cpp at home on my old Quadro RTX 3000 with 6 GB VRAM. I had already gone through the pain of getting decent versions of Qwen 3.6 and Gemma 4 to run within that constraint: quantizing, tweaking batch sizes, managing KV cache, understanding context limits. By the time I moved to Modal with an L4, I knew exactly what parameters to adjust and what to expect from the runtime. The home setup taught me the fundamentals, Modal just gave me more VRAM.

Quantitative Results

Measured from production logs (HF Space CPU + Modal L4/T4):

Stage Median time Warm time Notes
STT (Whisper T4) 1.8 s 10 s audio, French, VAD filtering
LLM (Gemma 4 L4) 4.0 s 2.2 s ~150 token response, llama.cpp inference
TTS (piper CPU) 0.6 s ~80 chars, fr_FR-siwis-medium
Total per turn 6.4 s 4.6 s cold start excluded

Cold start adds 14–20 s on first turn (container boot + model load into VRAM). Warmup fires on page load, reducing first-turn latency to match warm times. After warmup, subsequent turns average under 7 s end to end.

Result

The app is used by my wife frequently. She reports that the variability in patient responses keeps each session fresh. The structured feedback at the end helps her focus on specific grammar patterns she struggles with, like subjonctif after "il faut que" and preposition usage with "a" versus "de" for dental instruments.

Most importantly, she feels more confident walking into a real consultation. I consider this as the real achievement.


Technical Stack Summary

Component Technology Runtime
LLM Gemma 4 26B-A4B via llama.cpp Modal L4 GPU
STT faster-whisper large-v3-turbo Modal T4 GPU
TTS piper-tts (fr_FR-siwis) HF Space CPU
Frontend Vanilla HTML/CSS/JS + @gradio/client HF Space
Backend gr.Server (FastAPI) HF Space
Deployment HF Spaces + Modal Auto-scaling