Skip to content

Phase 2: Visit scribe auto-fills GAD-7 / PHQ-9 - #17

Open
goelalex wants to merge 9 commits into
mainfrom
phase-2-visit-scribe
Open

Phase 2: Visit scribe auto-fills GAD-7 / PHQ-9#17
goelalex wants to merge 9 commits into
mainfrom
phase-2-visit-scribe

Conversation

@goelalex

@goelalex goelalex commented Jul 6, 2026

Copy link
Copy Markdown

Summary

Phase 2 of the six-phase refresh. During a visit, a clinician captures a transcript — dictated (browser mic → PhenoML transcribe API) or pasted — and the scribe-fill bot pre-fills GAD-7 and PHQ-9 with scored answers. The clinician reviews/edits the answers in editable QuestionnaireForms and saves each as a QuestionnaireResponse linked to the encounter. Mirrors the Phase 1 review-before-persist pattern; nothing is saved until the clinician clicks Save.

QuestionnaireResponse conformance (profile-driven)

Rather than prompt-stuffing the whole Questionnaire and hand-reconciling the model's answers, scribe-fill now uses a "build a QR profile, then conform outputs to it" flow:

  1. src/bots/buildProfile.ts — deterministic, pure, server-only (no LLM). buildQuestionnaireResponseProfile(questionnaire) walks the item tree (recursing into groups, skipping group/display) and synthesizes a StructureDefinition that profiles QuestionnaireResponse: item sliced with discriminator = pattern on linkId / rules: "open", each answerable leaf getting a fixed linkId/text, a constrained answer.value[x] (mapped from the item type via valueTypesFor — boolean→boolean, choice+coding→Coding, …), and a required binding whose description carries the allowed codes (codesDescription). min:0 everywhere so the model is never forced to fabricate an answer. Profile id is content-derived — qr-<title-slug>-<sha256(items)[:8]> — so rebuilding the same form yields the same id (idempotent). Also exports composeExtractionText (the compact "question key" — - [linkId] text (type; allowed codes) — inlined ahead of the transcript).
  2. Cache in Medplum on the fly. The bot looks the profile up by its canonical url; if no profile matches deterministically, it createResources the StructureDefinition into Medplum so it's cached and inspectable.
  3. Register with PhenoML. The profile is base64-encoded and uploaded via lang2Fhir.uploadProfile into the medplum_questionnaires implementation guide (once per warm session; a "profile already exists" response is treated as success).
  4. Conform via lang2fhir. lang2Fhir.create({ resource: <profileId>, text }) targets the custom profile so the returned resource conforms to it. Falls back to the generic questionnaireresponse profile (same text) if the custom profile can't be used (e.g. an account tier without custom profiles), so the flow always returns a usable response.
  5. src/bots/postValidate.ts — treat LLM output as untrusted. Guards the resourceType, compares each answer against the deterministic question map and collects warnings (codes outside the allowed set, invented linkIds, type mismatches — tolerating valueString flex / numeric-as-Quantity), normalizes a matched code onto the canonical answerOption Coding (so the Medplum QuestionnaireForm renders and scores it), and stamps the FHIR-required fields (status, authored, questionnaire, subject, encounter). It does not rebuild from the form or invent answers — unmatched items are left for the clinician to correct in the editable form.

Behavior note: because the profile is min:0 and the prompt says so, questions with no evidence in the transcript are now omitted rather than defaulted to lowest-severity. The bot's input/output contract is unchanged, so the UI is untouched.

What's included

  • Seed datasrc/data/screening-questionnaires-bundle.json: net-new transaction bundle with GAD-7 / PHQ-9 Questionnaires (LOINC codes, ordinalValue-scored answer options), loaded from Get Started → Import Screening Questionnaires like existing seed data (idempotent via ifNoneExist).
  • scribe-fill botsrc/bots/scribe-fill.ts: thin orchestrator over the profile-driven flow above (build → cache in Medplum → uploadProfile → create targeting the profile → postValidate). Does not persist — returns the QR for review. Helpers live in buildProfile.ts / postValidate.ts (co-located under src/bots since bots can't import from src/utils).
  • voice-transcribe botsrc/bots/voice-transcribe.ts: transcription uses the PhenoML voice API (client.voice.voice.transcribe, https://developer.pheno.ml/reference/transcribe) instead of an in-browser model. PhenoML credentials must stay server-side, so the browser posts the recorded audio (base64) to this bot, which returns { transcript }. The @huggingface/transformers browser-Whisper dependency has been removed.
  • Reusable dictationScribeTextarea (src/components/ScribeTextarea.tsx): a Mantine Textarea with a built-in mic (backed by useScribeTranscription). Dropped onto any free text box — the chart note, the screening transcript, and the lang2fhir create page.
  • UI — the screening scribe lives inside the "Note & Tasks" tab (ScribePanel, not a separate tab): transcript capture (dictate or paste) → Generate → editable forms with a live score badge → Save to encounter.
  • Shared pieces for Phases 3–6useScribeTranscription hook, executeBotByName helper, screening.ts (canonical URLs + scoreQuestionnaireResponse), and the deterministic buildProfile.ts profile builder.
  • Bots — both new bots are registered in deploy-bots.ts. All PhenoML bots use PHENOML_CLIENT_ID / PHENOML_CLIENT_SECRET / optional PHENOML_BASE_URL (not EMAIL/PASSWORD).
  • Tests — screening scoring, executeBotByName, voice-transcribe, the end-to-end ScribePanel flow, and the profile-driven path with the PhenoML SDK mocked: buildProfile (deterministic id, slice/binding/value-type generation, question key), postValidate (code enrichment, warnings, guards), and scribe-fill (profile build → Medplum cache-on-the-fly → upload dedup/"already exists" tolerance → create targeting the profile → generic fallback).

Automated checks

  • npx vitest run — all new/existing suites pass. (Pre-existing SearchPage / ResourcePage / SignInPage failures reproduce on main — an unrelated localStorage env issue.)
  • npm run build:bots — succeeds; scribe-fill and voice-transcribe are bundled into data/example/example-bots.json.
  • App + bots typecheck clean (only the pre-existing UploadDataPage.tsx errors remain on main); lint clean.

Manual testing

Prerequisites

  • An active PhenoML subscription. Set the Medplum Project secrets PHENOML_CLIENT_ID and PHENOML_CLIENT_SECRET (and PHENOML_BASE_URL if your env isn't the default experiment.app.pheno.ml). Project secrets are shared by all bots, so scribe-fill and voice-transcribe both pick them up.
  • Custom profiles require a PhenoML develop or launch tier. Without it, scribe-fill transparently falls back to the generic questionnaireresponse profile (still steered by the inlined question key).

Setup

  1. npm run dev (runs build:bots then Vite). If the dev server was already running from before this branch, restart it so the regenerated bot bundle includes the latest scribe-fill and voice-transcribe.
  2. Upload Data → Deploy Bots — creates/deploys all bots, including scribe-fill and voice-transcribe. (Confirm via a Bot?name=voice-transcribe search if needed.)
  3. Get Started → Import Screening Questionnaires — seeds GAD-7 and PHQ-9.

Run the flow
4. Open (or create) an encounter → Note & Tasks tab → Screening scribe section.
5. Provide a transcript one of two ways:

  • Dictate: click the mic on the transcript box (or the chart note) and speak; on stop, the audio is posted to voice-transcribe → PhenoML /transcribe → the text appears in the box.
  • Paste: copy from sample-visit-transcript.txtOption A (live clinician/patient dialogue) or Option B (single-speaker post-visit dictation).
  1. Click Generate screening questionnaires → GAD-7 and PHQ-9 render pre-filled with scored answers; each card shows a Score badge (sum of ordinalValues).
  2. Edit any answer, then click Save to encounter on each card → a QuestionnaireResponse (status: completed, encounter set, subject = patient) is persisted.
  3. Verify persistence — e.g. search QuestionnaireResponse?encounter=Encounter/<id> or view the encounter/patient. You can also confirm the generated profile was cached: StructureDefinition?url=https://www.medplum.com/fhir/StructureDefinition/qr-*.

Expected: with either sample transcript (both cover every item), GAD-7 scores high and PHQ-9 scores moderately high but not maxed (its psychomotor and self-harm items are "not at all"). Questions the transcript doesn't mention are left blank for the clinician to fill.

🤖 Generated with Claude Code

Prompt

Work only on Phase 2 of a six-phase refresh of this repo
(medplum-provider-lang2fhir), on a dedicated branch. Do not build
Phases 3 to 6, but do not make choices that block them.

Phase 1 (referral intake: fax/upload PDF to FHIR via referral-intake.ts,
review, then persist at sign-off) is complete and merged to main. Study
it and match its conventions.

Before writing any code, read these and summarize back to me: the bot
auth pattern and secret names, the scribe/transcription mechanism, and
how bots are registered and invoked. Then propose a plan and wait for my
confirmation.

  • src/bots/referral-intake.ts
  • src/bots/lang2fhir-create.ts
  • src/scripts/deploy-bots.ts
  • src/components/encounter/EncounterChart.tsx
  • src/pages/resource/ResourceLang2FHIRCreatePage.tsx

Phase 2 goal: during a visit, a transcript auto-fills a screening
questionnaire for clinician review and save.

  • Seed GAD-7 and PHQ-9 as FHIR Questionnaire resources (net-new seed
    JSON, loaded like existing seed data).
  • Transcript capture: reuse the existing browser Whisper scribe from
    ResourceLang2FHIRCreatePage.tsx (@huggingface/transformers,
    whisper-tiny). Also accept pasted transcript text so the flow works
    without a microphone. Do not depend on any hosted transcribe API; none
    exists in this repo.
  • Bot: reuse lang2fhir-create with resourceType QuestionnaireResponse,
    passing the target Questionnaire context plus the transcript. Add a
    thin scribe-fill wrapper only if extra prompt shaping is needed. Use
    the SDK auth pattern and the PHENOML_CLIENT_ID / PHENOML_CLIENT_SECRET
    / optional PHENOML_BASE_URL secrets that the existing bots use. Do not
    use PHENOML_EMAIL/PASSWORD.
  • UI: add a Scribe action in EncounterChart.tsx (it uses a tab/activeTab
    structure). Run the bot, render the returned QuestionnaireResponse in
    an editable form (Medplum's QuestionnaireForm or the existing
    TaskQuestionnaireForm) for review and edit, then save linked to the
    encounter. Mirror the Phase 1 review-before-persist pattern.
  • Register any new bot in deploy-bots.ts.

Constraints:

  • Keep all existing tests passing and add tests for new code in the
    repo's existing style.
  • Do not change Phase 1 behavior.
  • Build shared pieces (questionnaire seeding, scribe capture, bot
    invocation helper) so Phases 3 to 6 can reuse them.

Acceptance: paste or speak a psychiatry visit note, GAD-7 and PHQ-9
pre-fill with scored answers, the clinician can edit, and saving
persists a QuestionnaireResponse linked to the encounter.

Branch name: phase-2-visit-scribe. Open a PR with a short description.

goelalex and others added 7 commits July 6, 2026 11:45
During a visit, a clinician captures a transcript (browser Whisper or
pasted text) and the scribe-fill bot pre-fills GAD-7 and PHQ-9 with scored
answers for review, edit, and save linked to the encounter.

- Seed GAD-7 / PHQ-9 Questionnaires (net-new transaction bundle, loaded
  from Get Started like existing seed data), with ordinalValue-scored
  answer options.
- New scribe-fill bot: thin wrapper over lang2fhir/create that shapes a
  prompt from the target Questionnaire + transcript and reconciles the
  model's answers back onto the questionnaire's linkIds/codes. Uses the
  PHENOML_CLIENT_ID / PHENOML_CLIENT_SECRET / optional PHENOML_BASE_URL
  secrets and never persists (review-before-save, mirroring Phase 1).
  Registered in deploy-bots.ts.
- Shared pieces for later phases: useScribeTranscription hook (extracted
  from ResourceLang2FHIRCreatePage, which now consumes it), executeBotByName
  helper, and screening.ts (canonical URLs + scoring).
- New Scribe tab in EncounterChart (added to the EncounterHeader tab
  control) rendering editable QuestionnaireForms and saving each response
  to the encounter.
- Tests: screening scoring, executeBotByName, scribe-fill bot (prompt
  shaping + reconciliation, SDK mocked), and the ScribeTab flow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Address review feedback:
- Remove the separate "Scribe" encounter tab. The screening scribe now
  renders inside the Note & Tasks tab (ScribeTab -> ScribePanel).
- Add a reusable ScribeTextarea (Textarea + built-in dictation mic) so the
  voice-to-text scribe can live on any free text box. Applied to the chart
  note, the screening transcript, and the lang2fhir create page.
- useScribeTranscription is now callback-based and loads the Whisper model
  lazily on first mic click (not on mount), so rendering a scribe-enabled
  text box no longer downloads a model.
- Seed the chart note into local state once the ClinicalImpression loads
  (controlled ScribeTextarea no longer has defaultValue), guarded so it
  never clobbers in-progress edits.
- Tests updated: ScribePanel.test, EncounterChart.test (scoped chart-note
  textarea lookups; stub transformers).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the in-browser @huggingface/transformers Whisper model with the
PhenoML voice transcribe API (https://developer.pheno.ml/reference/transcribe).

- New voice-transcribe bot: decodes base64 audio and calls
  client.voice.voice.transcribe(...), returning { transcript }. PhenoML
  credentials must stay server-side, so the browser posts recorded audio to
  this bot rather than calling the API directly. Uses the same
  PHENOML_CLIENT_ID / PHENOML_CLIENT_SECRET / optional PHENOML_BASE_URL
  secrets as the other bots. Registered in deploy-bots.ts.
- useScribeTranscription now records audio (MediaRecorder, WebM/Opus) and
  sends it to the voice-transcribe bot via executeBotByName; no model loads
  in the browser.
- Remove the @huggingface/transformers dependency and its test mocks.
- Add voice-transcribe bot test (SDK mocked).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rebuild data/example/example-bots.json via build:bots.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add Option B: a single-speaker, clinician-dictated post-visit summary
(alongside the existing live dialogue) so the scribe can be tested the way
a clinician would record a note from memory after the patient leaves. Still
covers all GAD-7/PHQ-9 items with PHQ/GAD frequency language.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@goelalex goelalex self-assigned this Jul 6, 2026
@goelalex
goelalex requested a review from kerbearasaurus July 6, 2026 21:28
Replace the scribe-fill prompt-stuffing + hand-rolled reconciliation with
a "build a QR profile, then conform outputs to it" flow:

- buildProfile.ts: deterministically synthesize a StructureDefinition that
  profiles QuestionnaireResponse from the source Questionnaire (per-question
  slices discriminated by linkId, fixed linkId/text, constrained
  answer.value[x], required binding carrying allowed codes, min:0). Content-
  derived id qr-<slug>-<sha256[:8]>. Pure/server-only. Also composeExtractionText.
- postValidate.ts: treat LLM output as untrusted — guard resourceType, warn on
  bad codes/invented linkIds/type mismatches, normalize matched codes onto the
  canonical answerOption coding, stamp required fields. No rebuild-from-form.
- scribe-fill.ts: build profile -> look it up in Medplum by canonical url and
  create it on the fly if none matches -> uploadProfile to PhenoML (dedup,
  tolerate "already exists") -> create({ resource: profileId, text }) ->
  postValidate. Falls back to the generic profile if the custom one is unusable.

Bot I/O contract unchanged; UI untouched. Regenerate example-bots.json.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment thread src/bots/postValidate.ts Outdated

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need this? it's a lot of post-processing that in practice I dont think we need

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed!

…-fill

The QuestionnaireResponse is now conformed to the auto-built profile by
lang2fhir, so the separate post-validation module is redundant. Fold the
still-needed behavior into scribe-fill's finalizeResponse:
- enrich each answer to its canonical answerOption Coding (so the
  QuestionnaireForm pre-selects it and scoring resolves), and
- stamp the required fields (status, questionnaire, authored) + link
  subject/encounter.

Drops the warnings/validation machinery. Deletes postValidate.ts and its
test; scribe-fill.test.ts (which asserts coding enrichment) still passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants