HealthSteward / Technical Design Doc
← project site github
Snapshot as of 2026-08-19

HealthSteward — Technical Design Document

A point-in-time architecture snapshot, written up for anyone who wants to go deep on how it's built. Re-written on architectural shifts, not on every change; see the note at the bottom of this page.

Problem & motivation

Patients managing fragmented care — multiple specialists, no shared record system — carry an unpaid coordination job: remembering what changed since the last visit, which labs are pending, what to raise with which doctor. This falls hardest on people with the least capacity to carry it (mid-flare, mid-crisis), and nobody on the clinical side owns it.

Most tools treat this as a records problem: store the documents, make them searchable, maybe chat with them. HealthSteward treats it as a coordination problem instead — ingest documents providers already give you, track what's changed and what's still open, and turn that into something concrete for the next visit. Locally, by default, because a tool holding this much health history shouldn't require sending it to a server to be useful.

What it does

Profile

Health profile management

Conditions (ICD-10 coded), medications, doctors, appointments in one place.

Prep

AI visit preparation

An agentic loop drafts personalized questions for an upcoming visit — pulling relevant history and calling tools before finalizing. Runs fully local via Ollama by default; Claude API and other OpenAI-compatible providers are opt-in.

Ingest

After-visit summary parsing

Upload the PDF from a visit. Parsed locally, reviewed by you, then applied to your profile.

Follow-up

Proactive action items

Follow-ups to book, labs to get done, referrals to schedule — surfaced at the moment of engagement and again until resolved.

Goals

  • Generate genuinely useful, specialty-relevant visit-prep questions from the patient's own data
  • Keep health data local by default; anonymize anything that must leave the machine
  • Turn parsed AVS data into closed-loop action, not just storage
  • Degrade gracefully — a failing LLM call should never block the user from getting something useful

Explicit non-goals

  • Not a clinical decision-support tool. Generated questions are prompts for a conversation with a real clinician — not diagnostic or treatment guidance. No automated clinical-safety validation exists or is planned; human review is the only safety mechanism today.
  • Not multi-user yet. Family sharing is deferred pending a decision, not built toward.
  • Not HIPAA-scoped. Personal/family use, not a covered entity.
  • No A/B testing, canary rollout, or drift-monitoring infrastructure. Single-user local app, no population to canary against — a deliberate scope cut, not an oversight.

Tech stack

Backend

FastAPI + SQLAlchemy (async) + SQLite, migrations via Alembic

Frontend

React 19 + TypeScript + Tailwind CSS + Vite

AI (agentic)

Pluggable backend — Ollama (llama3.2 by default) locally, or Claude API (Sonnet), or any OpenAI-compatible provider — for visit prep's tool-use loop, switchable at runtime from Settings. llama3.2 specifically for reliable multi-turn tool-calling, since this loop is conversational.

AI (local)

Ollama handles two distinct local jobs on two different models: qwen2.5:7b for AVS PDF parsing (strong at structured text→JSON extraction at this size), and llama3.2 by default for context-selection relevance scoring, the same model the agentic loop uses. Both sized to run reliably on 8GB of RAM with 4-bit quantization.

Architecture

System Design

Two LLMs, two trust boundaries. Almost everything runs inside the dashed local boundary below; the only thing outside it is an optional external LLM call, made only after anonymization.

Data flow

Your machine — everything in this box runs locally by default
Ingest
AVS PDFdata/avs/
Ollamalocal, qwen2.5:7b default
Review & confirmuser
4-stage context selection pipeline
Select
SQLitepast visits
Rules filtersame doctor, PCP, related specialty
Ollama scoringraw, pre-anonymization · llama3.2 default · capped @15, if >5 visits left
Token budgetpriority-packed · pinned visit packed first
Anonymize
Deterministic replacementname omitted, DOB → age
Regex patternsphone, email, SSN
spaCy NERnames in free text
Anonymized context
Orchestrate
Agentic tool-use loopbounded turn count
Query DB, anonymize resulttool calls
available Medication details lookup Past-visit lookup
roadmap Upcoming-appointments lookup Doctor details lookup drug interaction checker lab + imaging results lookup procedures/hospitalizations lookup
⇄ optionally calls out to Claude or a custom provider — see right
Backend
Ollamalocal, llama3.2 default — stays inside this box
Serve
SQLiteVisitPrep
FastAPI
React UI
Claude API or custom provideroptional, leaves this box

Only if LLM_PROVIDER=claude or custom — anonymized context and tool results only. Response returns to Orchestrate; Serve continues either way.

local process local AI call (Ollama) data store anonymization / external boundary available tool roadmap tool, not built

Two trust boundaries

Stays on device — default

Ollama, local

PDF parsing always runs on a local model, and visit prep's agentic loop does too by default. Raw records never touch the network — enforced by a hard localhost-only safety check, not just convention.

Anonymized first

Pluggable backend (Claude or any custom provider)

Only ever receives already-anonymized data, when opted into. Anonymization happens before the agentic loop starts, and every tool result fed back into the loop is anonymized the same way, regardless of backend.

Reliability design

The agentic tool-use loop is fallback-not-hard-failure by construction, three layers deep: if it can't converge within its turn budget (default 6) or a backend produces a malformed tool call, it falls back to the pre-existing single-shot prompt-in/JSON-out call; if that fails too — for any reason, LLM-related or not — an outer catch-all returns a fixed set of generic questions rather than an error. That hardcoded fallback, not the single-shot call, is the real floor: no functional regression is possible by design, only degraded quality on failure, and the visit-prep flow never surfaces a raw error to the user. That outer fallback is flagged, not silent — the result is marked as a fallback, and the UI shows a warning banner pointing at Settings when it happens, so a misconfigured or unreachable backend no longer looks like a normal successful generation.

Component internals

Deep Dives

Expand any section — these are written to be answerable as "walk me through how X works" in a technical conversation.

Ingest
AVS PDF parsing +

Section-routing architecture: rather than sending the whole document to an LLM, the text is split into named sections and each one is handled by whichever strategy actually fits it — deterministic parsing where the shape of the data is predictable (a medication list, a header line), a focused local-LLM call only where it isn't (freeform vitals prose, referral mentions).

AVS parsing pipeline
Raw PDF text
Section splitterfinds section boundaries
Section routerpicks a strategy per section
Ollamaonly for unstructured sections
Safety post-check

The document is split into named sections (visit summary, medications, labs, assessment, vitals, and so on) using structural cues — headers, layout patterns — that hold across the two clinical-export formats this has been built and tested against so far. Each section is then capped to a focused chunk of text, so a downstream LLM call never has to search a bloated block of the document for the one fact it needs.

  • Section boundaries are found once per document and reused — several parsing steps read overlapping sections (vitals data, for instance, can appear in more than one place in a visit note), so the split only has to run one pass.
  • Splitting and routing are decoupled layers — the piece that finds section boundaries doesn't know or care which sections get handled deterministically vs. by the local model; it just answers "give me the text under this header."

The strategy per section is a fixed table, decided up front by a human reading real export samples from the clinical systems this app has actually been tested against — not inferred at runtime:

SectionStrategyWhy
patient_providerdeterministicHeader line has a fixed shape: date, facility, phone.
medication_changesdeterministicStructured med-change section, cross-referenced against the medication list.
follow_updeterministicRegex for "recheck/follow-up in N weeks/months", target date computed from the visit date.
upcoming_appointmentsdeterministicSemi-tabular block keyed off month abbreviations, walked line by line.
diagnosesdeterministic → llmRegex looks for an Assessment section formatted as Condition (ICD-code); only calls the LLM if that section is missing — the one runtime (not format-time) branch in the pipeline.
vitalsllmBuried in freeform "Today's Visit" / physical-exam prose, no fixed shape.
lab_ordersllmSame — unstructured "tests ordered" narrative text.
noteshybridDeterministic extraction first, LLM supplements, results de-duped by word-overlap before merging.
referralsllmReferral mentions are scattered through instructions/plan/impression text.
Diagnoses: assessment section found

Matched directly against a predictable "Condition (ICD-code)" pattern. No LLM call needed for this section at all.

Diagnoses: no assessment section

When that structure isn't present, the router falls back to the local LLM for this section instead.

Each section that needs the LLM only receives the specific chunk of text it needs — never the full document — kept small and focused rather than re-feeding a bloated block of the visit note.

  • Local-only, enforced not just documented — a hard safety check blocks any non-localhost model endpoint, so a misconfiguration can't silently route health data externally.
  • Two document formats, one pipeline — parsing carries parallel fallback logic for the two clinical-export formats it's been tested against, since they're structured differently.
  • Review before apply — parsed items are always presented for user confirmation with per-section checkboxes; nothing is auto-applied to the profile.
  • Deduplication — diagnoses dedupe by name on apply (existing conditions get ICD-10 backfilled if missing); medication stops match by name.
Select
4-stage context selection +

Before generating visit-prep questions, relevant past-visit history is selected via a 4-stage pipeline:

runs once per visit prep, in order
1. Rules-based filter
2. Local LLM scoring
3. Token budget check
4. Anonymize
↺ stage 4 is the only point data crosses into what the external-capable backend will see
Stage 2 runs

More than 5 visits remain after stage 1, and Ollama is available. Each is scored 1–10; ≥7 survives, plus the pinned visit regardless of score.

Stage 2 skipped

≤5 visits remain, or Ollama is unavailable — stage 3 packs by recency instead of score, since none exists.

  1. Rules-based filter (instant, free) — always include same-doctor's last visit and PCP/Internal Medicine visits; apply a specialty-relevance mapping (e.g. Endocrinology ↔ Cardiology, Nephrology); exclude doctors flagged out of prep context. The same-doctor visit is pinned — its id is tracked through stages 2 and 3 so it can't be capped, score-filtered, or budget-dropped later, unlike every other candidate.
  2. Local LLM relevance scoring (Ollama) — only runs if more than 5 visits remain after stage 1; scores each 1-10, keeps ≥7 (pinned visit kept regardless). Only the most recent 15 candidates (pinned visit exempt) are even considered at this stage — one Ollama call per candidate makes this a real cost, not just a formality.
  3. Token budget check — packs visits by priority: pinned first, then stage 2's relevance score (highest first), then recency as a fallback when no score exists (stage 2 was skipped). Visits that don't fit are dropped, not summarized — see the callout below.
  4. Anonymize — the only stage where data crosses into what the external-capable backend will see.

Stage 2's relevance scoring deliberately runs on raw, pre-anonymization text — it's a local-only call, so there's no privacy reason to anonymize before it, and doing so first would degrade the local model's ability to judge relevance from real names/context.

Why is this a separate deterministic pipeline instead of an agentic-loop tool call? Small local models are unreliable at multi-turn tool-calling, so anything load-bearing for output quality — like which past visits matter — shouldn't depend on the agent choosing to call it correctly within its turn budget; it runs deterministically before the loop starts instead. The loop also needs some context in its first prompt to reason from at all, so full agent-driven retrieval would still need a bootstrapping mechanism of its own. The agentic loop's history-lookup tool exists to go beyond this baseline, not duplicate it — it excludes whatever this pipeline already selected, so the two layers can't redundantly re-fetch the same visit.

Known gap

Stage 3 doesn't summarize — visits that don't fit the token budget are dropped (priority-ordered, with the count surfaced and logged), not condensed. That's deliberate: a lossy or hallucinated summary from a small quantized local model is a worse failure mode for medical context than an honestly-truncated list. The candidate cap ahead of stage 2 is a placeholder, not a measured value, and grounding it in real latency (plus evaluating batched scoring) is open follow-up work.

Anonymize
PII anonymization +

Hybrid approach: structured fields get deterministic handling (patient name omitted entirely — not substituted, just never included; DOB → exact age; doctor name → "your [specialty]"; prescribing doctor → "Prescribing physician"; doctor phone/email dropped, clinic kept), free text goes through regex (phone/email/SSN patterns) plus spaCy NER for names.

  • Documented as best-effort on free text, not a guarantee — NER can miss names in unusual phrasing. This is stated explicitly rather than implied, since silently overstating a privacy guarantee is worse than none.
  • The free-text date regex is deliberately over-inclusive — it redacts anything MM/DD/YYYY-shaped, not just birthdates, so a visit date mentioned inside notes prose gets swept up too. Same safe-by-default philosophy as this doc's other fail-open designs: better to over-redact than risk a real date slipping through.
  • Applies uniformly regardless of provider — even when running fully local Ollama (where anonymization is technically unnecessary since nothing leaves the machine), the same code path is used for consistency and to avoid two divergent implementations.
  • Tool results are anonymized too, not just the initial context — a result from lookup_past_visits mid-loop goes through the same Anonymizer before re-entering the conversation.
Orchestrate
Agentic tool-use loop +

Send context + tool specs to the backend → if it returns tool calls, execute each against the DB, anonymize the result, append to the conversation, repeat → if it returns final text with no tool calls, that's the answer → if the turn budget is exhausted, or the model calls a tool name that doesn't exist, fall back to single-shot generation instead.

repeats up to a bounded turn count (default 6)
Model responds
Tool calls?
Execute, anonymize result
Append, loop again
↺ no tool calls in the response → loop exits, that response is the final answer
Converges

Model returns plain text before the turn cap. Logged to ConversationLog, parsed as the final JSON questions.

Fails or times out

Parse error, turn cap hit, or an unrecognized tool name → caught, and visit prep falls back to a single-shot call.

get_medication_details

On-demand structured lookup — dosage, frequency, purpose, side effects — for one or all current medications. Not a real interaction checker; that's a separate, bigger feature on the roadmap.

lookup_past_visits

Deeper visit-history query beyond what's already in the context, filterable by specialty/keyword.

  • Why bounded, not open-ended: an unbounded loop against a small/unreliable local model can spin. A hard turn cap makes worst-case latency and cost predictable.
  • Why Ollama as default, not an external API: constrained hardware (8GB RAM) can only run 4-bit quantized 7-8B models, and small quantized models produce unreliable tool-calling — malformed JSON, wrong tool selection, non-convergence — which is exactly why the fallback-to-single-shot behavior matters. But defaulting the primary, most-used flow to an external API sat awkwardly next to the project's local-first pitch. Claude, and any custom OpenAI-compatible provider, remain fully supported as explicit opt-ins from Settings rather than the default.
  • Two tools today, deliberately bounded scope for v1 — a real drug-interaction checker and a user-facing pause-to-ask-clarifying-questions flow were explicitly descoped as separate, bigger features.
  • This loop's fallback isn't the last line of defense — if single-shot generation fails too, an outer catch-all returns a fixed set of generic questions rather than an error. See Reliability Design in System Design for the full three-layer chain.
Specialty-aware prompting +

Early on, visit prep generated the same questions regardless of which specialist the appointment was with — it would suggest discussing a dermatology cream with a cardiologist. Fixed with two system prompt templates (a specialty-aware one and a generic fallback) plus data enrichment that tags conditions and medications with the specialty that actually manages them.

ICD-10 prefix/rangeMapped specialty
E08–E13Endocrinology (diabetes)
I00–I99Cardiology
L00–L99Dermatology
E28Endocrinology + Gynecology (e.g. PCOS)
  • ICD-10 → specialty mapping — exact codes checked before broader ranges, so a specific code like E28 doesn't get swallowed by a wider range it happens to fall inside.
  • Medication → prescriber specialty tagging — matches a medication's prescribing doctor against the profile's doctor records to label it (e.g. "prescribed for Dermatology"), so the model can tell which meds are actually this specialist's business.
  • Clinic-name inference fallback — keyword-matches the clinic name (e.g. "Endocrinology Associates") when a doctor's specialty isn't set explicitly.
  • The prompt itself draws the line explicitly: don't suggest unrelated-specialty medications, but do surface real cross-condition interactions relevant to this specialty (its own worked example: Hashimoto's and PCOS interact hormonally, which matters for an endocrinologist even if a gynecologist made the original diagnosis).
Backend
Pluggable LLM backend +

A common backend interface with three implementations — one wrapping Claude's native tool-use API, one calling Ollama's chat API with OpenAI-style function-calling tool specs, and one for any endpoint speaking OpenAI's chat-completion + tool-calling format (OpenAI, OpenRouter, Groq, Together, a self-hosted server, etc.). All three normalize into the same result shape, so the agentic loop never branches on which provider it's talking to — it just calls the backend and reads a uniform result.

  • One canonical tool spec, reused wire format: tool definitions are declared once and adapted per provider — Ollama's chat API already mirrors OpenAI's function-calling shape, so the custom-provider backend needed no separate adapter.
  • Why this matters beyond the agentic loop: it's what makes the local-Ollama default a real, fully-local path rather than a stub — the same tool-use loop, same tools, same anonymization boundary, just a different backend underneath.
  • Selected via a factory keyed off the configured provider, with one shared classification used everywhere that split matters — instead of the same string-equality check duplicated in multiple places, which could quietly drift out of sync if a provider were added later.
  • Runtime-editable, not just env-configured: the active provider is stored in the database, not just a startup environment variable, so switching providers from the Settings page takes effect on the next request — no config edit or restart needed.
Cross-cutting
Proactive action items & nudging +

Closes the loop between "document parsed" and "patient acts on what the doctor ordered." Two surfaces: a post-AVS action panel (fires at the moment of highest engagement, right after confirming a parsed document) and a persistent "Needs Attention" overview section (catches items accumulated from past visits, always visible).

  • Snooze (1w/2w/1m) and completion state persist per item, plus a shared table for computed nudges that have no natural record of their own to attach state to (e.g. "past-due appointment").
  • Five computed nudge types drive the overview section: past-due appointments, upcoming appointments without prep (scheduled within the next 30 days, no prep generated yet), completed visits with no AVS uploaded within 14 days of the appointment date, adverse vitals trends (meaningful directional change in weight/BMI/blood pressure/heart rate across two or more recorded visits), and follow-ups, lab orders, and referrals that remain active since they were logged. All five respect per-item snooze state so a dismissed nudge doesn't resurface until it expires.
  • Snoozing is recoverable and visible, not a silent hide: a dedicated view lets a patient see everything they've snoozed, an "Un-snooze now" action reverses it early, and an undo banner appears for 8 seconds after any snooze action.
  • Scheduled push notifications for genuinely disengaged patients — reaching someone who isn't currently in the app — were considered and explicitly deferred; it's new infrastructure (a scheduler + notification channel) that arguably conflicts with the local-first architecture, not a pure addition on existing data like the two shipped surfaces are.
Data model & schema design +

HealthProfile sits at the root, with Condition, Medication, Doctor, and Appointment hanging off it. AVS-parsed data nests under DocumentVitals (1:1), LabOrder/Referral/FollowUp (1:many) — so every parsed item traces back to the source PDF it came from. VisitPrep and ConversationLog record generated output and anonymized LLM call history; NudgeState persists snooze/dismiss state for the nudges that have no natural row of their own.

HealthProfile
↓ has many
Conditionicd_10
Medicationprescribing_doctor
Doctorspecialty
Appointmentprep/visit notes
Document
↓ every parsed item traces back to its source PDF
Vitals1:1
LabOrder1:many
Referral1:many
FollowUp1:many
supporting / computed — no direct FK from the two roots above
VisitPrepgenerated output
ConversationLoganonymized LLM history
NudgeStatesnooze/dismiss state
  • UUID primary keys, not auto-increment integers — a sequential ID leaks how many records exist and lets rows be enumerated by guessing. UUIDs cost a little storage/perf, which is a fine trade for health data specifically.
  • Profile-nested API routes (/api/profiles/{id}/conditions/, etc.) — ownership is enforced by the URL structure itself, not left to an ad-hoc check in each handler.
  • ICD-10 coding on conditions was added specifically to enable specialty-aware prompting — the schema changed because a feature needed it, not speculatively ahead of time.
  • Async throughout — SQLAlchemy 2.0 async sessions, aiosqlite, matching the async FastAPI route handlers end to end rather than mixing sync DB calls into an async app.
Alternatives considered

Decisions & Tradeoffs

A curated set of the calls with real engineering tension — not the full decision log, just the ones worth defending in a conversation.

AI / LLM

Agentic loop framework: native tool use, not Agent SDK or LangGraph

OptionWhy not
Anthropic Agent SDKNew dependency and learning curve for a single-agent workflow that doesn't need its structured primitives
LangGraphExplicit state machine is overkill — heavy abstraction for one agent, one loop
Chose: Claude API's native tool use. Already integrated, no new deps, a simple send/loop/execute pattern that's easy to upgrade to a framework later if the loop ever needs to coordinate multiple agents.
AI / LLM · Privacy

Local Ollama vs. an external API for the agentic backend

OptionTradeoff
Local Ollama (7-8B, quantized)Free, maximum privacy — but constrained hardware (8GB RAM) only fits 4-bit quantized models, and those produce unreliable tool-calling (malformed JSON, wrong tools, loops)
Claude API (Sonnet)Reliable tool use out of the box, negligible cost for personal use (~$1/month) — but leaves the local-first ideal for this one call path
Chose: Ollama as the default, fully local agentic backend; Claude API and any custom OpenAI-compatible provider (OpenAI, OpenRouter, Groq, a self-hosted server, etc.) remain fully supported as explicit opt-ins, switchable at runtime from a Settings page — no restart or config edit needed. The privacy-first default held even though it means more installs run into the small-model tool-calling unreliability documented above; that's exactly what the fallback-to-single-shot design below exists to absorb.
Data

SQLite vs. PostgreSQL

OptionTradeoff
SQLiteZero setup, file-based, portable — but single-writer, not ideal if multi-user ever ships
PostgreSQLProduction-ready, concurrent — but requires a running server for a currently single-user local app
Chose: SQLite for the current single-user phase; the database abstraction layer keeps a later move to Postgres cheap if/when multi-user sharing (deferred, not currently built toward) actually ships.
Data · Privacy

UUID vs. auto-increment integer primary keys

OptionTradeoff
Auto-increment integersSimple, compact, fast — but leaks record count and lets IDs be enumerated/guessed
UUIDsLarger, marginally slower — but private and trivially portable across databases
Chose: UUIDs. Health data specifically warrants the extra privacy consideration over the minor storage/perf cost.
Privacy

PDF parsing: local-only Ollama vs. cloud OCR/vision

OptionTradeoff
Claude Vision / cloud OCRBest accuracy on scanned/image PDFs — but sends raw medical documents to a third party
Local Ollama + deterministic parsersMaximum privacy, section-routing keeps accuracy reasonable on structured sections — but weaker on genuinely unstructured, image-heavy documents
Chose: Local-only Ollama, enforced with a hard safety check, not just a default. For a tool whose entire pitch is "your health data never leaves your machine," an accuracy/privacy tradeoff here isn't really optional.
AI / LLM

Agentic loop scope: two read-only tools, not a full feature set

DescopedWhy
Real drug-interaction checkerNeeds a licensed external interaction-database API — a separate, bigger feature (tracked as its own issue)
User-facing clarifying-question pauseNeeds new DB state, a new API endpoint, and new frontend UI to resume a paused conversation (tracked as its own issue)
Chose: ship the smallest version of the loop that's actually useful — two tools buildable entirely from existing schema data, both read-only, both anonymized — rather than block the whole agentic architecture on the bigger features.
Data

FHIR bundle import: file upload only, two resource types, reconciliation deferred

OptionTradeoff
Full FHIR ingestion + live OAuth sync + cross-source reconciliation in one passSolves the whole problem at once — but reconciling a manually-entered record against a FHIR-coded one (e.g. "Type 2 Diabetes" vs. SNOMED-coded "Type 2 Diabetes Mellitus") is itself unscoped work, and blocking import on it delays a useful feature for an open-ended one
File-upload import, narrow resource scope, deterministic fuzzy-match flagShips something genuinely useful now — patients already have export bundles from other apps — without pretending to solve reconciliation it doesn't yet solve
Chose: one-time file-upload import for two resource types, with a deterministic fuzzy name-match check flagging likely duplicates against existing records in the review UI. Nothing auto-merges — the patient decides merge/skip/add-new. Full reconciliation across sources is a known, separately-tracked gap, not a silent one.
Privacy

Free-text redaction: scoped per-entity tokens, not a flat category label

OptionTradeoff
Flat category replacement (every name → "[NAME]")Simple — but collapses distinct people into one indistinguishable token, which can itself confuse or mislead a model reasoning over multiple doctors/relatives in the same text
Scoped per-entity tokensThe same doctor mentioned twice in one field maps to the same token, and distinct entities get distinct tokens — preserves the structure of the text without exposing the underlying value
Chose: scoped, per-entity tokens. Tokens are guarded on output and never re-hydrated back to the real value — the model can reason about "the same person mentioned earlier" without ever seeing who that person actually is.
Deterministic checks + groundedness LLM-judge shipped · relevance/usefulness tier still proposed

Evaluation Plan

A deterministic evaluation harness runs the real pipeline end-to-end against a real LLM backend, not mocks. It catches gross regressions — hallucination, scope violations, malformed output, retrieval rule breaks — and it already caught and fixed one real prompt regression on its first real run. A separate LLM-judge pass (DEC-042) now measures the harder property the deterministic checks can't reach on their own: whether each generated claim is actually supported by the patient's data, not just correctly-shaped. What's still not measured is the fuzzier "is this actually good" tier — relevance, usefulness, non-redundancy — described as still-proposed below.

Two eval surfaces inside visit prep

Retrieval and generation are separate AI decisions that fail differently — a generation eval can't tell you retrieval missed something, because the LLM never saw it to begin with. They need separate ground truth and separate metrics.

Retrieval — context selection, stages 1-2

Which past visits/facts reach the prompt. Fails by omission (a relevant visit never reaches the LLM) or dilution (irrelevant history crowds the 2000-token budget).

Generation — visit-prep question generation

The questions themselves. Fails by hallucination (states a fact not in the anonymized context) or scope violation (asks the endocrinologist about a dermatology cream, which the system prompt explicitly forbids).

Retrieval eval

Stage 1 is a pure function of the specialty-mapping rules — checkable by exact assertion, not fuzzy eval. Stage 2's LLM scoring is where real judgment is needed.

Judge?WhatGround truthMetricMethod
deterministic Rules-based filtering Pure function of specialty mapping + flags — exact, not fuzzy Pass/fail assertion Unit test, no LLM involved
deterministic End-to-end selection Synthetic profiles, human-labeled expected picks Recall (primary — the token budget makes recall the scarce resource), precision (secondary) Deterministic replay against fixtures — the full pipeline's output, not one stage in isolation
deterministic Stage-attribution Same labels as above Which stage discarded each gold visit — rule miss, low score, or budget cutoff Turns "recall is 70%" into a root cause instead of a single number
local llm ± judge Relevance-scoring calibration Borderline visits with human relevance labels Score distribution for true-relevant vs. true-irrelevant — catches a badly-calibrated local model before it silently degrades recall Offline scoring against the real local model, or a stronger model re-scoring the same visits as a cheap proxy without full human labels

Generation eval

The generation prompt specifies several independent correctness properties — scoring them as one "quality" number hides which one is actually breaking.

Judge?DimensionGround truthMetricMethod
det ± judge — shipped Groundedness The anonymized context actually sent — the safety-critical dimension Unsupported-claim rate (unsupported facts / total factual claims) Cheap pass (score_groundedness): entity-substring match — kept as a fast free smoke test, not the paper-citable number, since it can't tell a safe general question from a genuinely unsupported one. Real measure (DEC-042, eval/judge.py): a separate, stronger-tier judge model enumerates every distinct factual claim per output and verdicts each against the patient's actual data, allowing paraphrase rather than requiring literal string match — scored by a different model than generated it to avoid self-grading bias. Paired with a deterministic post-generation guardrail (output_guardrails.py) that strips content presupposing a test, referral, or medication not on file, plus specialty-management-convention/named-authority claims this app has no source for — the two together took the measured rate from an initial double-digit percentage down to near-zero on the current fixture set.
deterministic Specialty scope The target appointment's specialty vs. each referenced condition/med's tagged specialty Violation rate (out-of-scope questions / total) Fully programmatic, no judge needed — reuses the specialty-tagging logic already computed elsewhere in the app
llm judge Relevance/usefulness No cheap proxy — needs a rubric 1-5 rubric score, rubric anchored with 2-3 example ratings for cross-run comparability LLM-judge, supplemented by periodic human spot-check (the dimension most likely to drift from what a judge model rewards)
det ± judge Non-redundancy The generated question set itself Duplicate rate (near-duplicate pairs / total questions) Embedding similarity or judge — cheap to check programmatically
deterministic Format validity The response schema (JSON, 8-15 questions, one of 5 categories) Pass/fail Simple assertion, near-zero cost

AVS parsing & PII anonymization eval

Ingest

AVS parsing

Golden-set PDFs, hand-labeled JSON, diffed per field. Only needs to cover the llm/hybrid section-routing rows (vitals, lab orders, notes, referrals, diagnoses-without-Assessment) — deterministic sections are exact-match by construction.

Anonymize

PII anonymization

Synthetic notes with PII injected at known character offsets — recall (missed PII) and precision (over-redaction) are a span-overlap computation, no judge needed. The one component where ground truth is fully knowable.

How the harness runs

The harness runs the real pipeline end-to-end — real database rows, the real visit-prep agent, the real context selector — against whichever backend is actually configured, not a mock. Each run's report is diffed against the most recent prior run, since "better or worse than last time" is the operative question for a prompt-change review, rather than a fixed pass/fail bar.

Offline vs. online

Single-user local app, not a service with traffic — "online" doesn't mean A/B-testing a population (already a non-goal). Reinterpreted at this project's actual scale:

Run on-demand

Offline — the fixture harness

Synthetic labeled data, run whenever prompts or context-selection logic change. This is where recall/precision/hallucination-rate/scope-violation-rate live as repeatable numbers.

Passive, on real usage

"Online" — conversation log monitoring

Currently write-only. Planned: a fallback-rate metric (how often the agentic loop hits single-shot instead of completing), retroactive judge sampling on real logged conversations, and a human-in-the-loop signal — noting a wrong/off-scope/fabricated question and feeding it back as a new regression fixture. The realistic substitute for population-scale online eval at single-user scale.

What's built vs. proposed

Deterministic checks — format validity, specialty-scope violations, retrieval rule assertions, and a cheap groundedness pass via entity matching — are implemented and run today, alongside a real LLM-judge groundedness pass and a deterministic output guardrail that acts on its findings (DEC-042). What's still proposed but not yet built is the harder tier: an LLM-judge pass for relevance and usefulness (properties with no cheap deterministic proxy), a non-redundancy check on the generated question set, and formal retrieval recall/precision scoring against a larger labeled fixture set.

Example run

Walkthrough

⚠ synthetic example — fabricated patient, not real data

There's no captured real-patient output in the repo to pull from (by design — nothing leaves a real user's machine). This walkthrough is a constructed, illustrative example showing what actually happens end-to-end for one visit-prep run.

1

Input: upcoming appointment + existing profile data

Patient has an upcoming Endocrinology visit. Profile has two conditions with a real cross-specialty interaction worth surfacing: Type 2 Diabetes (E11) and Hashimoto's thyroiditis (E06.3) — both endocrine, but historically diagnosed by different specialists (PCP and an OB-GYN, respectively).

Conditions:
- Type 2 Diabetes Mellitus (E11.9) [active] — typically managed by: Endocrinology
- Hashimoto's Thyroiditis (E06.3) [active] — typically managed by: Endocrinology
Medications:
- Metformin 500mg — twice daily [prescribed for Endocrinology]
- Levothyroxine 75mcg — once daily, morning [prescribed for Endocrinology]
Lab Orders:
- HbA1c (ordered 2026-05-02)
- TSH (ordered 2026-05-02)
2

Context selection + anonymization

4-stage pipeline includes the same-doctor's last visit and PCP visits automatically; a past OB-GYN visit surfaces via the Endocrinology ↔ Gynecology specialty-relevance mapping (PCOS/thyroid cross-relevance) rather than being excluded as off-specialty. Before this reaches the LLM, patient name is omitted entirely, DOB → "39 years old", doctor names → "your Endocrinologist".

3

Agentic loop: a tool call mid-generation

Model calls get_medication_details with no filter to double-check dosing/timing before finalizing a question about levothyroxine-metformin timing overlap — a real absorption interaction worth asking about. Result is anonymized (prescriber name redacted) and fed back into the conversation.

→ tool_call: get_medication_details({})
← result: "- Metformin (500mg) — twice daily
  Purpose: Blood sugar control
- Levothyroxine (75mcg) — once daily, morning
  Purpose: Thyroid hormone replacement
  Prescribed by: your Endocrinologist"
4

Final output

Loop converges (no further tool calls) within 2 turns. Output parsed as JSON, returned to the UI:

Condition Management
  • How does having both Hashimoto's and Type 2 Diabetes affect your target A1c range?
  • Is my current thyroid control (TSH) affecting how well Metformin is managing my blood sugar?
Medication Review
  • Should I space out Levothyroxine and Metformin — I take them close together in the morning?
Lab Results & Monitoring
  • My HbA1c and TSH were both ordered last visit — what results would prompt a dosage change on either medication?

Context summary: "Patient manages co-occurring Type 2 Diabetes and Hashimoto's Thyroiditis, both under active medication management with pending labs to assess control."

Honest assessment

Risks & Open Gaps

What's actually unsolved right now — shown deliberately, not glossed over.

Risks

Clinical safety

Generated output is health-adjacent guidance. The only safety mechanism is the patient reading it before a real appointment — no automated check exists for a plausible-but-wrong suggestion (e.g. a hallucinated interaction, a misread lab trend). Framed as a permanent human-in-the-loop requirement, not a gap with a "done" state.

External dependency risk

Local model versions are referenced by tag, not pinned digest — a silent upstream update could change parsing/scoring/visit-prep behavior with no code change to explain why, and this now affects the default agentic backend directly, not just an opt-in path. Anthropic API and any custom provider's pricing/availability changes only matter to whoever has opted into them from Settings.

Prompt-change management — resolved

Every prompt in the codebase is versioned, with content changes logged alongside eval evidence where the harness's fixtures cover the change. The two visit-prep generation prompts additionally log their version per-run for traceability. Residual gap: the eval harness only covers those generation prompts today — the relevance-scoring prompt and the AVS parser's extraction prompts are versioned for traceability but have no before/after quality signal yet.

Known gaps

No quality evaluation of generated output — mostly resolved for groundedness

A deterministic eval harness runs the real pipeline against a real backend and catches format/scope/retrieval-rule regressions — it already caught and fixed one real prompt regression on its first run. Deeper inferential groundedness (a simple entity-match can't verify, e.g. a question referencing a trend or prior visit without repeating the entity name) is now covered by a real LLM-judge pass plus a deterministic output guardrail acting on what it finds (DEC-042). What's still not measured: relevance, usefulness, and non-redundancy — no cheap deterministic proxy exists for any of the three, so this remains proposed work.

No visibility into agentic-loop fallback rate — resolved

Every prepare_visit() run now records how it was actually produced — agentic loop, or single-shot fallback and why — readable via GET /api/diagnostics/visit-prep-fallback. Still a read-on-demand number rather than an alert: nothing notices a rising fallback rate unless someone looks.

Frontend test coverage — partial

Backend has 500+ tests; frontend has 4 test files (38 tests) covering the Needs Attention panel, parsed-document review, and visit-prep version history — real but thin relative to the backend, and most frontend surfaces still have none.

See the GitHub repository for the full open-issue backlog and to start a discussion.

Reference

Glossary

Terms as used specifically in this codebase — some have a more specific meaning here than the general ML/LLM usage.

Agentic loop
The bounded tool-use conversation loop that drives visit prep — the model can call tools before producing a final answer, up to a fixed turn budget, with automatic fallback to single-shot generation if it doesn't converge.
AVS
After-Visit Summary — the PDF a patient is typically handed at the end of a doctor's visit, containing vitals, orders, diagnoses, and notes.
Anonymization boundary
The point in the pipeline (after context selection, before the agentic loop starts) past which no unredacted PII should exist — a hard constraint, not a best-effort nicety.
Specialty-aware prompting
System prompts that constrain generated questions to what's relevant to the specific doctor's specialty, using ICD-10 → specialty mapping and medication → prescriber tagging.
Section-routing
The AVS parser's architecture: deterministic parsers handle structured PDF sections, local LLM calls handle unstructured ones — routed by section type, not one monolithic LLM call over the whole document.
Pluggable backend
An abstraction that normalizes Ollama, Claude API, and any custom OpenAI-compatible provider's tool-calling into one interface, so the agentic loop code doesn't branch on provider.
Nudge state
Persisted snooze/dismiss state for computed action items that have no natural record of their own to attach state to (e.g. "appointment is past-due").
Reference

FAQ

Why not just use a real drug-interaction API from day one?

It needs a licensed external service (RxNorm/DrugBank/FDB-class), which is a bigger, separate integration than what shipped with the agentic loop's first version — deliberately descoped rather than blocking the rest of the architecture on it. Tracked as its own follow-up.

Why does the agentic loop fall back instead of just erroring?

Because small local models are known to produce unreliable tool-calling, and the single-shot path already worked before the agentic loop existed. Falling back preserves that guarantee — a user should never get a worse experience than pre-agentic HealthSteward, only a potentially less-refined one on failure.

Is any of this HIPAA-compliant?

Not scoped as HIPAA-compliant — it's personal/family use, not a covered entity under HIPAA. The privacy design (local-first, anonymization, no cloud storage) is motivated by good practice for sensitive personal data, not a regulatory compliance target.

Why two different local models (Ollama for parsing vs. relevance scoring) instead of one shared path?

Same local Ollama server, different model tags: PDF parsing always uses qwen2.5:7b, chosen for structured extraction, while relevance scoring reuses whichever model the agentic loop is already configured with (llama3.2 by default). They're kept as distinct call sites because they have different failure-handling needs: parsing failure surfaces to the user for review; scoring failure silently falls back to rules-based selection.

What happens if Ollama isn't running at all?

PDF parsing simply can't proceed (surfaced to the user). Context-selection relevance scoring skips that stage and falls back to rules-based filtering plus truncation. Visit prep — Ollama by default — falls back to its own single-shot path; switching to Claude or a custom provider in Settings avoids the dependency on Ollama for visit prep specifically.