Skip to content
148 changes: 119 additions & 29 deletions data/example/example-bots.json

Large diffs are not rendered by default.

1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,6 @@
"node": "^22.18.0 || >=24.2.0"
},
"dependencies": {
"@huggingface/transformers": "^3.7.1",
"phenoml": "^17.7.0"
}
}
92 changes: 92 additions & 0 deletions sample-visit-transcript.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
Recommended test transcript — psychiatry visit (GAD-7 / PHQ-9 scribe demo)
==========================================================================

How to use:
Open an encounter → "Note & Tasks" tab → "Screening scribe" section.
Paste one of the transcripts below (or dictate it with the mic) and click
"Generate screening questionnaires". GAD-7 and PHQ-9 should pre-fill with
scored answers that you can review, edit, and save to the encounter.

Both notes deliberately use PHQ/GAD frequency language ("not at all",
"several days", "more than half the days", "nearly every day") so the
model can map each symptom to a scored answer option.

OPTION A — live visit dialogue (clinician + patient).
OPTION B — post-visit dictation, spoken by the clinician alone. Use this
when the clinician wants to record a summary from memory after the patient
has left, rather than transcribing the conversation in real time.

--------------------------------------------------------------------------
OPTION A — TRANSCRIPT (live visit dialogue)
--------------------------------------------------------------------------

Clinician: Good morning. Thanks for coming in today. How have things been
over the last two weeks?

Patient: Honestly, not great. I've been feeling nervous and on edge nearly
every day. My mind just won't switch off.

Clinician: Tell me more about the worrying.

Patient: I can't stop or control the worrying — it's there nearly every
day. I worry too much about all kinds of different things, more than half
the days I'd say. Trouble relaxing is constant, pretty much every day. I've
been so restless that it's hard to sit still on several days. I get easily
annoyed or irritable more than half the days. And several days I feel
afraid, like something awful might happen.

Clinician: That sounds exhausting. How has your mood been?

Patient: Low. I've had little interest or pleasure in doing things nearly
every day. I feel down, depressed, and hopeless more than half the days.

Clinician: How's your sleep and energy?

Patient: Trouble falling and staying asleep nearly every day, and I'm tired
with little energy nearly every day too.

Clinician: What about appetite?

Patient: My appetite is poor — that's been happening several days.

Clinician: How do you feel about yourself lately?

Patient: I feel bad about myself, like I'm a failure, more than half the
days. Concentrating on things like reading or watching TV is hard on
several days.

Clinician: Have you noticed any change in how you move or speak?

Patient: Not at all, really. No one's mentioned me being slowed down or
fidgety.

Clinician: I have to ask — any thoughts that you'd be better off dead, or
of hurting yourself?

Patient: No, not at all. Nothing like that.

Clinician: Thank you for being so open. Let's go through a couple of brief
screening questionnaires together and talk about next steps.

--------------------------------------------------------------------------
OPTION B — POST-VISIT DICTATION (clinician, single speaker)
--------------------------------------------------------------------------

Post-visit note, dictated. This is a 34-year-old patient seen today for a
follow-up of anxiety and low mood, reporting on the last two weeks.

On the anxiety side: the patient has been feeling nervous, anxious, or on
edge, and reports being unable to stop or control the
worrying nearly every day. They describe worrying too much about different
things more than half the days, with trouble relaxing nearly every day.
They've been so restless that it's hard to sit still on several days, are
easily annoyed or irritable more than half the days, and feel afraid as if
something awful might happen on several days.

On mood: little interest or pleasure in doing things nearly every day, and
feeling down, depressed, or hopeless more than half the days. Sleep is
disrupted — trouble falling and staying asleep nearly every day — with
fatigue and low energy nearly every day. Appetite is poor on several days.
The patient feels bad about themselves, like a failure, more than half the
days, and has trouble concentrating on things such as reading or watching
television on several days. No psychomotor slowing or agitation noted. Denies any thoughts of being better off dead or of self-harm.
113 changes: 113 additions & 0 deletions src/bots/buildProfile.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
// SPDX-FileCopyrightText: Copyright Orangebot, Inc. and Medplum contributors
// SPDX-License-Identifier: Apache-2.0
import type { Questionnaire } from '@medplum/fhirtypes';
import { describe, expect, test } from 'vitest';
import {
buildQuestionnaireResponseProfile,
composeExtractionText,
getAnswerableItems,
profileIdFor,
valueTypesFor,
} from './buildProfile';

const ORDINAL = 'http://hl7.org/fhir/StructureDefinition/ordinalValue';

const questionnaire: Questionnaire = {
resourceType: 'Questionnaire',
status: 'active',
url: 'https://www.medplum.com/questionnaire/gad-7',
title: 'GAD-7',
item: [
{
linkId: 'group-1',
text: 'Over the last 2 weeks',
type: 'group',
item: [
{
linkId: 'gad7-q1',
text: 'Feeling nervous',
type: 'choice',
answerOption: [
{ valueCoding: { system: 'http://loinc.org', code: 'LA6568-5', display: 'Not at all' }, extension: [{ url: ORDINAL, valueDecimal: 0 }] },
{ valueCoding: { system: 'http://loinc.org', code: 'LA6569-3', display: 'Several days' }, extension: [{ url: ORDINAL, valueDecimal: 1 }] },
],
},
],
},
{ linkId: 'note', text: 'Clinician note', type: 'text' },
{ linkId: 'display-1', text: 'Instructions', type: 'display' },
],
};

describe('getAnswerableItems', () => {
test('recurses into groups and skips group/display items', () => {
const items = getAnswerableItems(questionnaire);
expect(items.map((i) => i.linkId)).toEqual(['gad7-q1', 'note']);
});
});

describe('valueTypesFor', () => {
test('maps item types to FHIR value[x] types', () => {
expect(valueTypesFor({ linkId: 'a', type: 'boolean' })).toEqual(['boolean']);
expect(valueTypesFor({ linkId: 'b', type: 'decimal' })).toEqual(['decimal']);
expect(valueTypesFor({ linkId: 'c', type: 'text' })).toEqual(['string']);
expect(valueTypesFor({ linkId: 'd', type: 'choice', answerOption: [{ valueCoding: { code: 'x' } }] })).toEqual(['Coding']);
expect(valueTypesFor({ linkId: 'e', type: 'choice', answerOption: [{ valueString: 'x' }] })).toEqual(['string']);
});
});

describe('profileIdFor', () => {
test('is deterministic and content-derived', () => {
expect(profileIdFor(questionnaire)).toBe(profileIdFor(questionnaire));
expect(profileIdFor(questionnaire)).toMatch(/^qr-gad-7-[0-9a-f]{8}$/);
});

test('changes when the question set changes', () => {
const changed: Questionnaire = { ...questionnaire, item: (questionnaire.item ?? []).slice(0, 1) };
expect(profileIdFor(changed)).not.toBe(profileIdFor(questionnaire));
});
});

describe('buildQuestionnaireResponseProfile', () => {
test('produces a QuestionnaireResponse StructureDefinition with per-question slices', () => {
const sd = buildQuestionnaireResponseProfile(questionnaire);
expect(sd.resourceType).toBe('StructureDefinition');
expect(sd.type).toBe('QuestionnaireResponse');
expect(sd.derivation).toBe('constraint');
expect(sd.baseDefinition).toBe('http://hl7.org/fhir/StructureDefinition/QuestionnaireResponse');
expect(sd.id).toMatch(/^qr-gad-7-/);
expect(sd.url).toContain(sd.id as string);

const elements = sd.snapshot?.element ?? [];
// Slicing on item by linkId, rules open.
const itemSlice = elements.find((e) => e.id === 'QuestionnaireResponse.item');
expect(itemSlice?.slicing?.discriminator?.[0]).toEqual({ type: 'pattern', path: 'linkId' });
expect(itemSlice?.slicing?.rules).toBe('open');

// Fixed linkId for the answerable question slice.
const fixedLinkId = elements.find((e) => e.id === 'QuestionnaireResponse.item:gad7-q1.linkId');
expect(fixedLinkId?.fixedString).toBe('gad7-q1');
expect(fixedLinkId?.min).toBe(1);

// Constrained answer value type + required binding carrying the allowed codes.
const value = elements.find((e) => e.id === 'QuestionnaireResponse.item:gad7-q1.answer.value[x]');
expect(value?.type).toEqual([{ code: 'Coding' }]);
expect(value?.binding?.strength).toBe('required');
expect(value?.binding?.description).toContain('LA6569-3');

// min:0 everywhere on the slices so nothing is forced.
expect(elements.find((e) => e.id === 'QuestionnaireResponse.item:gad7-q1')?.min).toBe(0);
expect(value?.min).toBe(0);
});
});

describe('composeExtractionText', () => {
test('inlines a compact question key ahead of the transcript', () => {
const text = composeExtractionText('patient reports feeling nervous several days', questionnaire);
expect(text).toContain('- [gad7-q1] Feeling nervous (choice; allowed codes: LA6568-5=Not at all, LA6569-3=Several days)');
expect(text).toContain('patient reports feeling nervous several days');
// Only answerable leaves appear in the key.
expect(text).not.toContain('[group-1]');
expect(text).not.toContain('[display-1]');
});
});
Loading