Phase 2: Visit scribe auto-fills GAD-7 / PHQ-9 - #17
Open
goelalex wants to merge 9 commits into
Open
Conversation
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>
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>
Member
There was a problem hiding this comment.
do we need this? it's a lot of post-processing that in practice I dont think we need
…-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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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-fillbot pre-fills GAD-7 and PHQ-9 with scored answers. The clinician reviews/edits the answers in editableQuestionnaireForms and saves each as aQuestionnaireResponselinked 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-fillnow uses a "build a QR profile, then conform outputs to it" flow:src/bots/buildProfile.ts— deterministic, pure, server-only (no LLM).buildQuestionnaireResponseProfile(questionnaire)walks the item tree (recursing into groups, skipping group/display) and synthesizes aStructureDefinitionthat profilesQuestionnaireResponse:itemsliced withdiscriminator = pattern on linkId/rules: "open", each answerable leaf getting a fixedlinkId/text, a constrainedanswer.value[x](mapped from the item type viavalueTypesFor— boolean→boolean, choice+coding→Coding, …), and a required binding whose description carries the allowed codes (codesDescription).min:0everywhere 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 exportscomposeExtractionText(the compact "question key" —- [linkId] text (type; allowed codes)— inlined ahead of the transcript).createResources theStructureDefinitioninto Medplum so it's cached and inspectable.lang2Fhir.uploadProfileinto themedplum_questionnairesimplementation guide (once per warm session; a "profile already exists" response is treated as success).lang2Fhir.create({ resource: <profileId>, text })targets the custom profile so the returned resource conforms to it. Falls back to the genericquestionnaireresponseprofile (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.src/bots/postValidate.ts— treat LLM output as untrusted. Guards theresourceType, compares each answer against the deterministic question map and collects warnings (codes outside the allowed set, invented linkIds, type mismatches — toleratingvalueStringflex / numeric-as-Quantity), normalizes a matched code onto the canonical answerOptionCoding(so the MedplumQuestionnaireFormrenders 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:0and 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
src/data/screening-questionnaires-bundle.json: net-new transaction bundle with GAD-7 / PHQ-9Questionnaires (LOINC codes,ordinalValue-scored answer options), loaded from Get Started → Import Screening Questionnaires like existing seed data (idempotent viaifNoneExist).scribe-fillbot —src/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 inbuildProfile.ts/postValidate.ts(co-located undersrc/botssince bots can't import fromsrc/utils).voice-transcribebot —src/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/transformersbrowser-Whisper dependency has been removed.ScribeTextarea(src/components/ScribeTextarea.tsx): a MantineTextareawith a built-in mic (backed byuseScribeTranscription). Dropped onto any free text box — the chart note, the screening transcript, and the lang2fhir create page.ScribePanel, not a separate tab): transcript capture (dictate or paste) → Generate → editable forms with a live score badge → Save to encounter.useScribeTranscriptionhook,executeBotByNamehelper,screening.ts(canonical URLs +scoreQuestionnaireResponse), and the deterministicbuildProfile.tsprofile builder.deploy-bots.ts. All PhenoML bots usePHENOML_CLIENT_ID/PHENOML_CLIENT_SECRET/ optionalPHENOML_BASE_URL(not EMAIL/PASSWORD).executeBotByName,voice-transcribe, the end-to-endScribePanelflow, 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), andscribe-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-existingSearchPage/ResourcePage/SignInPagefailures reproduce onmain— an unrelatedlocalStorageenv issue.)npm run build:bots— succeeds;scribe-fillandvoice-transcribeare bundled intodata/example/example-bots.json.UploadDataPage.tsxerrors remain onmain); lint clean.Manual testing
Prerequisites
PHENOML_CLIENT_IDandPHENOML_CLIENT_SECRET(andPHENOML_BASE_URLif your env isn't the defaultexperiment.app.pheno.ml). Project secrets are shared by all bots, soscribe-fillandvoice-transcribeboth pick them up.scribe-filltransparently falls back to the genericquestionnaireresponseprofile (still steered by the inlined question key).Setup
npm run dev(runsbuild:botsthen Vite). If the dev server was already running from before this branch, restart it so the regenerated bot bundle includes the latestscribe-fillandvoice-transcribe.scribe-fillandvoice-transcribe. (Confirm via aBot?name=voice-transcribesearch if needed.)Run the flow
4. Open (or create) an encounter → Note & Tasks tab → Screening scribe section.
5. Provide a transcript one of two ways:
voice-transcribe→ PhenoML/transcribe→ the text appears in the box.sample-visit-transcript.txt— Option A (live clinician/patient dialogue) or Option B (single-speaker post-visit dictation).ordinalValues).QuestionnaireResponse(status: completed,encounterset,subject= patient) is persisted.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.
Phase 2 goal: during a visit, a transcript auto-fills a screening
questionnaire for clinician review and save.
JSON, loaded like existing seed data).
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.
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.
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.
Constraints:
repo's existing style.
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.