โ How it works ๐ Results ๐ผ Screenshots
01 Overview
A single-page web app that turns a pasted or uploaded dental clinical note into a focused pre-appointment briefing a dentist or hygienist can read in under a minute. Three appointment categories โ hygiene, operative, and emergency โ drive different prompt overlays, and every summary ends with a structured pre-medication flags block so a pre-med decision is never buried in prose. It sits alongside my insurance-narrative tooling but points the other way: patient prep before the chair, not documentation after the procedure.
Single summary
One chart in, one briefing out. Category, verbosity, and model are one-click; radiograph and perio-charting status sit at the top, pre-med panel always last.
Batch mode
A full day of patients at once โ individual files, a ZIP folder per patient, or separator-pasted charts โ streamed over SSE with per-patient pass/fail so one bad note never stalls the run.
Morning-meeting digest
A short cross-patient digest organized by the practice's six schedule columns, with pre-med flags surfaced first โ read from a phone during morning prep.
02 Why I built it
Prepping for a day of patients means skimming long charts for the few things that actually change how the appointment goes โ recent radiographs, perio status, and above all whether a patient needs to pre-medicate. Those signals are easy to miss in a wall of clinical text, and a missed pre-med flag is the kind of mistake that matters. The tool's job is narrow on purpose: compress each chart to what a clinician needs before the chair, and make the pre-med call structured rather than prose so it can't get lost. It runs for a real family dental practice, reachable over the tailnet during morning prep.
03 What I built & how it works
A small FastAPI service and a vanilla-JS front end, with a privacy scrub between the note and the model.
Fig. 1 โ one request path; the PHI scrub runs before anything leaves the box, and the model is reached only through a self-hosted LiteLLM proxy.
- Paste or drop โ a pasted chart, or a set of files (one per patient), plus a category and a verbosity level.
- Scrub โ a regex pass strips EHR-header, labeled, and honorific patient names before the note leaves the box, and the UI shows exactly what it replaced.
- Prompt โ a base rule set plus a category overlay and a verbosity budget assemble the system prompt, including in-prompt radiograph and perio-charting cadence rules.
- Summarize โ the note routes through the LiteLLM proxy by alias; a length-aware retry re-runs with a doubled budget if a chart truncates mid-sentence, keeping the longer response.
- Flag โ a required fenced-JSON block becomes the red/yellow/green pre-medication panel; an unknown severity rounds up to yellow.
- Store โ the summary lands in a date-bucketed JSON store keyed to a chart ID read from the note's content, never the filename, with collision-safe suffixes.
04 ๐ Skills & tech used
05 Notable challenges & decisions
Most of the engineering is in the seams โ privacy, model failure modes, and identity.
Three layers of defense, not a compliance badge
Names are stripped three ways before a note leaves the box โ EHR-header lines, labeled fields, and honorifics โ with a credential lookahead that preserves provider sign-offs (DDS / DMD / RDH), and "Dr." left intact on purpose. A prompt-level name ban with negative examples backs it up, and the request log carries no PHI at all. The scrubber's own docstring is honest about its ceiling: bare narrative names would need an NER model and are out of scope. It is defense-in-depth over a de-identified-inputs contract, not a HIPAA control.
A truncated summary is worse than an empty one
A full 25-patient batch quietly truncated mid-sentence and dropped the trailing pre-med JSON โ clinically worse than the cost of a retry. The fix retries on a length finish reason, doubles the token budget, and keeps the longer response; verbosity budgets were roughly doubled to tripled (800/1500/3000 โ 2000/4000/6000) and the timeout raised from 45 s to 180 s so a long chart finishes cleanly.
Filenames lie, so identity comes from content
Doc1-Patient1 and Hyg1-Patient1 both derived to PT-001 and overwrote each other. Patient identity now parses a chart ID from the note's own header, with collision-safe suffixes, so a day's record is never silently clobbered โ filenames are treated as display labels only.
One config value swaps the model
The LLM endpoint is a single setting, so the runnable clone bundles LiteLLM as an opt-in profile and defaults to a key-free local Ollama โ it runs with no API keys. In the same spirit, the WeasyPrint PDF export is a lazy import that answers 501 rather than breaking startup on a machine without the native libraries.
De-identified by contract, scrubbed in depth. The design assumes de-identified inputs; the scrubber is a safety net on top, not a compliance mechanism. There is no BAA and no encryption at rest โ so the honest label is PHI-scrubbed / de-identified, not HIPAA-safe.
06 Results
Sources: home server data directory (as of 2026-07-30), git log, and the test suite. Observed summary latency ~15–28 s at $0.00 logged cost on the free Gemini tier; the spec's cost model estimates โ $3–5/month for a 20-patient practice.
07 Screenshots
No product screenshots are shared here โ real day-buckets contain clinical text. These are placeholders for captures made with synthetic notes.
"Processing 3 of 12" with stacking โ / โ rows
checkbox roster + column badges โ digest
08 Honest status
The app is in day-to-day production use for a family dental practice, running as a systemd service on my home server behind HTTP Basic Auth, with a public entry point over Tailscale Funnel. It was built in a short burst in June 2026 and has had no code changes since โ it does what it needs to and has been left alone. A three-tier runnable clone exists (documented in a rebuild recipe) that defaults to a key-free local model, so it can be reproduced without any accounts or secrets. Honest limitations: it is designed for de-identified / PHI-scrubbed inputs and is not HIPAA-compliant โ there is no BAA, no encryption at rest, and no access audit log. The repository is a single local copy with no remote, and an automated backup of the data directory was specified but never wired up. It is a focused practice utility, not a hardened clinical product.
note-summary โ Build Recipe
Take a bare machine to a running copy of note-summary โ a dental clinical-notes summarizer that turns pasted/uploaded notes into category-aware pre-appointment briefings with a structured red/yellow/green pre-medication flags block, a whole-day batch mode, and a morning-meeting digest. FastAPI + vanilla JS, no build step; ~105 tests.
Status: โ Verified 2026-07-31 โ built from a clean checkout on this laptop and tested against a freshly-installed local Ollama (no API key, nothing wired to any server) โ the exact zero-key default a reviewer gets. The 105-test suite passes (2 graceful skips โ WeasyPrint PDF on Windows, env-gated real-proxy), the image builds, and
docker compose upโ a live/summarizecall through localllama3.2returned a real briefing (radiograph/perio top-lines + findings + pre-med section). Also spot-checked against a hosted OpenAI-compatible endpoint, which โ being a frontier model โ produced cleaner structured pre-med flags (see Notes). The clean clone lives inclone/.Update 2026-08-01: added a bundled LiteLLM opt-in profile โ
docker compose --profile llm upreproduces brand-name Claude/Gemini routing through a local proxy (keys in.env), while the defaultdocker compose upstays key-free (Ollama). Verified the profile's proxy boots healthy (/health/livelinessโ alive) with note-summary serving alongside it; the LiteLLM service is hidden from the defaultup.Sensitive data: none shipped. No patient
data/, no.env, no keys. The hardcoded LiteLLM master key in the original was removed; the clone ships only.env.examplewith placeholders. The app is designed for de-identified inputs and is not HIPAA-compliant (no BAA, no encryption-at-rest) โ PHI-scrubbing assistance, not compliance.
What it is
A FastAPI web app (port 8240) that calls any OpenAI-compatible LLM to summarize dental notes. The LLM coupling is one config value โ there is no training and no dataset; it's an LLM-application, so "reproducing" it is just running it against an endpoint of your choice.
Prerequisites
New machine? Install the base tools first โ see ../SETUP.md (Docker; Python 3.11+
for the bare-metal tier). Then, per tier:
| Tier | You need |
|---|---|
| 1 โ prebuilt container | Docker (see ../SETUP.md) + an LLM endpoint (below). |
| 2 โ build from source | Same as Tier 1, plus the source bundle. |
| 3 โ bare-metal | Python 3.11+, plus (for PDF export only) WeasyPrint's native libs. |
Configuration โ the LLM endpoint (the one thing you set)
Copy clone/.env.example to clone/.env and pick an endpoint. Zero-key default = local Ollama
(nothing leaves your machine โ ideal for clinical text):
# install Ollama (https://ollama.com), then:
ollama pull llama3.2 # + `ollama pull llama3.2-vision` for image uploads
cp .env.example .env # already set for local Ollama
From Docker, the host's Ollama is at host.docker.internal:11434 (not localhost) โ the
.env.example shows the swap. Hosted alternatives (need a key): OpenAI, the free Groq/OpenRouter
tiers, or your own LiteLLM gateway โ all in .env.example. (Google Gemini's free API works too, but
its OpenAI path is /v1beta/openai/, not the /v1/ this app expects, so it needs a small proxy/tweak.)
To reproduce the author's production routing โ brand-name Claude/Gemini through a local proxy โ the
clone bundles a LiteLLM service as an opt-in Compose profile. Put your Anthropic/Google keys in
.env and run docker compose --profile llm up -d --build; the app then points at http://litellm:4000
(see .env.example). The default docker compose up never starts it โ the app stays on your key-free
local Ollama. (This is the standard "bundle LiteLLM as opt-in, not a hard dependency" pattern.)
Tier 1 โ Run the prebuilt container (recommended)
Availability: the prebuilt image bundle is available on request โ it is not published or linked anywhere. Ask Kevin for it, or build everything from source via Tier 2/3 below.
docker load -i note-summary-image.tar # offline tarball
cd clone
cp .env.example .env # set your LLM endpoint
docker compose up -d # โ http://localhost:8240
curl http://localhost:8240/health # โ {"ok":true,"proxy":"ok"}
Tier 2 โ Build the image from source
cd clone
cp .env.example .env
docker compose up -d --build # builds (installs WeasyPrint native libs) then runs
The Dockerfile and docker-compose.yml are the build documentation (both clean, no home-server config).
Tier 3 โ Bare-metal (no Docker)
cd clone
python -m venv .venv && . .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements.txt # (WeasyPrint may need OS libs on Windows โ PDF export
# then 501s; everything else works)
cp .env.example .env # set your LLM endpoint
uvicorn src.main:app --host 0.0.0.0 --port 8240
Verify
curl http://localhost:8240/health
# โ {"ok":true,"proxy":"ok"} (proxy="down" if the LLM endpoint is unreachable โ the app still runs)
curl -s -X POST http://localhost:8240/summarize \
-F "patient_id=" -F "category=HYGIENE" -F "verbosity=2" -F "model_alias=auto" \
-F "notes=62yo, atrial fibrillation on warfarin (INR 2.4), penicillin allergy, prosthetic mitral valve 2019, routine hygiene."
# โ JSON with summary_md + premed_flags[] (prosthetic valve โ red, penicillin โ red, warfarin โ yellow)
Run the tests (bare-metal):
pip install pytest pytest-asyncio respx # dev deps
pytest -q # ~103 pass, 2 skip (WeasyPrint PDF, real-proxy)
Notes & gotchas
- PDF export needs WeasyPrint native libs. Present in the Docker image; on bare-metal Windows they
usually aren't, so
/export/pdfanswers 501 (lazy-imported, doesn't affect startup or anything else). - The model dropdown is empty on non-LiteLLM endpoints. It's populated from a LiteLLM-style
/model/infocall; against Ollama/OpenAI that returns nothing, so the UI just uses the model you set in.env(works fine โ the dropdown is a convenience, not required). /healthshows"proxy":"down"against Ollama/OpenAI. Same root cause โ the health probe pings that LiteLLM-style/model/infoendpoint, which plain Ollama/OpenAI don't serve. Summaries still work (the/v1/chat/completionscall is separate); the indicator is just cosmetically wrong.- Structured pre-med flags need a capable model. The red/yellow/green flags are extracted from a
fenced
```jsonblock the model must emit exactly. Frontier models (OpenAI/Gemini) do this reliably; a small local model likellama3.2(3B) sometimes uses a bare```fence, so the top-levelpremed_flagsarray comes back empty (the content is still visible in the summary text). For reliable structured flags, point.envat a stronger model. - Reaching a local Ollama from Docker: Ollama must listen on all interfaces โ
OLLAMA_HOST=0.0.0.0โ and the container reaches it athost.docker.internal:11434(notlocalhost, which is the container itself). Bare-metal (Tier 3) just useslocalhost:11434. - PHI scrubber scope. It strips names in structured EHR-header (
LAST, FIRST (chartid)) andPatient:-label formats, plus honorifics โ not free-narrative names (that would need an NER model, as its docstring states). Feed it de-identified inputs. - Single LLM call โ a few k tokens. The verify run used ~3k tokens / ~14 s via a free Gemini tier ($0). Local Ollama latency depends on your machine/model.
Provenance โ model / LLM (no training data, no GPU)
note-summary does not train anything โ it calls an external OpenAI-compatible LLM that you supply. There are no datasets and no GPU involved. In the author's production it routed through a self-hosted LiteLLM gateway to Gemini/Claude; the clone is endpoint-agnostic (Ollama by default). No model weights are shipped.
What was stripped from the author's home-server version (recipe minimalism in action)
- The hardcoded LiteLLM master key (a
sk-โฆdefault baked intoconfig.py) โ removed; key now comes from.env(placeholder only). - Home-server-specific LLM config โ the hardcoded internal proxy URL (shape:
http://<home-server>:4000) and the author's private model aliases (free-chat/free-vision/deep-planning) โ generic defaults (local Ollama), all endpoints documented in.env.example. - Home-server deploy tooling โ the server bootstrap/start/stop/deploy scripts (hardcoded to the author's server hostname and mount paths), the README's deploy section + alias table, and an "LLM proxy unreachable" UI banner naming the internal proxy host โ removed/genericized.
- The app was not containerized (it ran as a systemd
uvicornon the author's server) โ the clone adds a cleanDockerfile+docker-compose.yml.