โ† Kevin Yoder
Digital Dentistry

Clinical Note Summarizer

Turns a full dental chart into a one-minute, category-aware pre-appointment briefing โ€” with a red/yellow/green pre-medication flags block, a whole-day batch mode, and a morning-meeting digest.

In production use Built Jun 2026 3-tier runnable clone De-identified inputs

โš™ 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.

per patient

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.

whole day

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.

the huddle

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.

Browser index.html / meeting.html ยท vanilla JS ยท no build paste / upload ยท multipart ยท JSON ยท SSE FastAPI ยท src.main:app home server :8240 ยท optional Basic-Auth parse .txt / .pdf / .png / .jpg ยท pymupdf ยท PIL scrub EHR-header ยท labeled ยท honorific names prompt BASE + category overlay + verbosity budget LLM routed over HTTP โ†’ premed fenced JSON โ†’ red / yellow / green flags HTTP LiteLLM proxy :4000 ยท self-hosted free-chat ยท free-vision deep-planning date-bucketed JSON write Filesystem store data/YYYY-MM-DD/PT-NNN.json ยท SHA1 image dedup

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.

  1. Paste or drop โ€” a pasted chart, or a set of files (one per patient), plus a category and a verbosity level.
  2. 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.
  3. 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.
  4. 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.
  5. Flag โ€” a required fenced-JSON block becomes the red/yellow/green pre-medication panel; an unknown severity rounds up to yellow.
  6. 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

Languages
Python 3.11+JavaScript (ES6, vanilla)HTML / CSSBashTOML
ML / AI
LiteLLM alias routingmulti-model orchestrationlayered prompt engineeringstructured-output (fenced JSON)anti-hallucination rulesvision via base64 data-URLmap-reduce meta-summary
Infra / Ops
systemd serviceTailscale Funnel + Basic Authrsync deployuv + lockfileENOSPC โ†’ 507 resiliencePHI-free TSV logging
Frontend
hand-rolled SSE consumerclient-side ZIP directory parserescape-first micro-markdownprint-CSS modesdrag-drop dropzones
Data
filesystem-as-DB date bucketsSHA1 image deduppymupdf PDF extractionencoding forensics (UTF-16 / cp1252)
Techniques
strict TDDregex PHI scrub w/ credential lookaheaddefense-in-depth privacypath-traversal validationspec โ†’ plan โ†’ build workflow

05 Notable challenges & decisions

Most of the engineering is in the seams โ€” privacy, model failure modes, and identity.

Privacy

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.

LLM failure modes

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.

Data integrity

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.

Portability

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

87
patient summaries generated in production
20
day-buckets (2026-06 โ†’ 07)
8
morning-meeting digests
105
tests (~1,570 test LOC)
~4,980
lines of first-party code
49
commits over ~25 hours

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.

The Note Summary single-summary form filled with a synthetic hygiene note: patient ID, category radios, schedule column, verbosity slider, model selector, notes textarea, and the honest 'LLM endpoint unreachable' banner shown when no model is configured.
The single-summary form (captured from the clone, with a synthetic note): patient/category/column controls and the notes field; the banner is the honest state when no LLM endpoint is configured.
[ batch stream ]
"Processing 3 of 12" with stacking โœ“ / โœ— rows
Batch mode over SSE: a spinner row, a running counter, and per-patient pass/fail as each chart completes.
[ morning meeting ]
checkbox roster + column badges โ†’ digest
The digest page: a roster with severity dots and schedule-column badges, and a rendered digest that leads with pre-med flags.

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 /summarize call through local llama3.2 returned 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 in clone/.

Update 2026-08-01: added a bundled LiteLLM opt-in profile โ€” docker compose --profile llm up reproduces brand-name Claude/Gemini routing through a local proxy (keys in .env), while the default docker compose up stays 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 default up.

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.example with 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/pdf answers 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/info call; 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).
  • /health shows "proxy":"down" against Ollama/OpenAI. Same root cause โ€” the health probe pings that LiteLLM-style /model/info endpoint, which plain Ollama/OpenAI don't serve. Summaries still work (the /v1/chat/completions call 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 ```json block the model must emit exactly. Frontier models (OpenAI/Gemini) do this reliably; a small local model like llama3.2 (3B) sometimes uses a bare ``` fence, so the top-level premed_flags array comes back empty (the content is still visible in the summary text). For reliable structured flags, point .env at 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 at host.docker.internal:11434 (not localhost, which is the container itself). Bare-metal (Tier 3) just uses localhost:11434.
  • PHI scrubber scope. It strips names in structured EHR-header (LAST, FIRST (chartid)) and Patient:-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 into config.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 uvicorn on the author's server) โ€” the clone adds a clean Dockerfile + docker-compose.yml.