Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 113 additions & 23 deletions data/example/example-bots.json

Large diffs are not rendered by default.

12 changes: 8 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"type": "module",
"scripts": {
"build": "tsc && vite build",
"build:bots": "npm run clean && npm run lint && tsc --project tsconfig-bots.json && node --loader ts-node/esm src/scripts/deploy-bots.ts",
"build:bots": "npm run clean && npm run lint && node src/scripts/build-bots.mjs && node --loader ts-node/esm src/scripts/deploy-bots.ts",
"clean": "rimraf dist",
"dev": "npm run build:bots && vite",
"lint": "eslint .",
Expand All @@ -28,17 +28,20 @@
]
},
"devDependencies": {
"@medplum/bot-layer": "4.3.10",
"esbuild": "^0.24.0",
"fast-glob": "^3.3.2",
"@mantine/core": "7.17.8",
"@mantine/hooks": "7.17.8",
"@mantine/notifications": "7.17.8",
"@medplum/core": "4.3.10",
"@medplum/dosespot-react": "4.3.10",
"@medplum/eslint-config": "4.3.10",
"@medplum/fhirtypes": "4.3.10",
"@medplum/mock": "4.3.10",
"@medplum/react": "4.3.10",
"@medplum/health-gorilla-core": "4.3.10",
"@medplum/health-gorilla-react": "4.3.10",
"@medplum/mock": "4.3.10",
"@medplum/react": "4.3.10",
"@tabler/icons-react": "3.34.1",
"@testing-library/dom": "10.4.1",
"@testing-library/jest-dom": "6.6.4",
Expand Down Expand Up @@ -67,6 +70,7 @@
"node": "^20.19.0 || >=22.12.0"
},
"dependencies": {
"@huggingface/transformers": "^3.7.1"
"@huggingface/transformers": "^3.7.1",
"phenoml": "^0.0.20"
}
}
92 changes: 28 additions & 64 deletions src/bots/lang2fhir-create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,21 @@
// SPDX-License-Identifier: Apache-2.0
import { BotEvent, MedplumClient } from '@medplum/core';
import { QuestionnaireResponse, Observation, Procedure, Condition, Patient, MedicationRequest, CarePlan, PlanDefinition, Questionnaire, ResearchStudy } from '@medplum/fhirtypes';
import { Buffer } from 'buffer';
import { PhenoMLClient, phenoml } from 'phenoml';

/**
* A Medplum Bot that processes documents using the lang2fhir API.
*
*
* The bot will:
* 1. Send the text to the lang2fhir API
* 2. Create a FHIR resource of the type specified in the input
* 3. Add the patient reference to the resource (if the resource is patient-dependent)
*
*
* Required bot secrets: (You need to have an active PhenoML subscription to use this bot)
* - PHENOML_EMAIL: Your PhenoML API email
* - PHENOML_PASSWORD: Your PhenoML API password
*/

interface CreateRequest {
text: string;
version: string;
resource: string;
}

interface CreateBotInput {
text: string;
resourceType: 'QuestionnaireResponse' | 'Observation' | 'Procedure' | 'Condition' | 'MedicationRequest' | 'CarePlan' | 'PlanDefinition' | 'Questionnaire' | 'ResearchStudy';
Expand All @@ -32,10 +26,23 @@ interface CreateBotInput {
type AllowedResourceTypes = QuestionnaireResponse | Observation | Procedure | Condition | MedicationRequest | CarePlan | PlanDefinition | Questionnaire | ResearchStudy;

const PATIENT_INDEPENDENT_RESOURCES = ['PlanDefinition', 'Questionnaire', 'ResearchStudy'] as const;
const PHENOML_API_URL = "https://experiment.app.pheno.ml";

// Maps input resource types to SDK resource profile types
// Uses 'auto' for types not explicitly supported by the SDK's type system
const RESOURCE_PROFILE_MAP: Record<string, phenoml.lang2Fhir.CreateRequest.Resource> = {
'questionnaireresponse': 'questionnaireresponse',
'observation': 'simple-observation',
'procedure': 'procedure',
'condition': 'condition-encounter-diagnosis',
'medicationrequest': 'medicationrequest',
'careplan': 'careplan',
'plandefinition': 'auto',
'questionnaire': 'questionnaire',
'researchstudy': 'auto',
};

export async function handler(
medplum: MedplumClient,
medplum: MedplumClient,
event: BotEvent<CreateBotInput>
): Promise<AllowedResourceTypes> {
try {
Expand All @@ -59,73 +66,30 @@ export async function handler(
throw new Error(`Unsupported resource type: ${inputResourceType}`);
}

const targetResourceType = inputResourceType.toLowerCase();

// Transform to specific profiles
let targetResourceProfile: string;
switch (targetResourceType) {
case 'observation':
targetResourceProfile = 'simple-observation';
break;
case 'condition':
targetResourceProfile = 'condition-encounter-diagnosis';
break;
default:
targetResourceProfile = targetResourceType;
const targetResourceProfile = RESOURCE_PROFILE_MAP[inputResourceType.toLowerCase()];
if (!targetResourceProfile) {
throw new Error(`No profile mapping found for resource type: ${inputResourceType}`);
}

const email = event.secrets["PHENOML_EMAIL"].valueString as string;
const password = event.secrets["PHENOML_PASSWORD"].valueString as string;

// Auth handling remains the same
const credentials = Buffer.from(`${email}:${password}`).toString('base64');
const authResponse = await fetch(PHENOML_API_URL + '/auth/token', {
method: 'POST',
headers: {
'Accept': 'application/json',
'Authorization': `Basic ${credentials}`
},
}).catch(error => {
throw new Error(`Failed to connect to PhenoML API: ${error.message}`);
});

if (!authResponse.ok) {
throw new Error(`Authentication failed: ${authResponse.status} ${authResponse.statusText}`);
}
// Initialize PhenoML client with automatic auth handling
const phenomlClient = new PhenoMLClient({ username: email, password });
Copy link

Choose a reason for hiding this comment

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

Inconsistent baseUrl configuration in PhenoML client initialization

Medium Severity

The refactored bots (lang2fhir-create.ts, lang2fhir-document.ts, phenoml-cohort.ts) initialize PhenoMLClient without a baseUrl, while the new bots (phenoml-workflow.ts, phenoml-ips-summary.ts) explicitly set baseUrl: 'https://experiment.app.pheno.ml'. The old code being replaced explicitly used this URL via PHENOML_API_URL. If the SDK's default differs from this experiment URL, the refactored bots will fail or hit an incorrect endpoint while the new bots work correctly.

Additional Locations (2)

Fix in Cursor Fix in Web


const { token: bearerToken } = await authResponse.json() as { token: string };
if (!bearerToken) {
throw new Error('No token received from auth response');
}

const createRequest: CreateRequest = {
// Call lang2fhir create endpoint using SDK
const generatedResource = await phenomlClient.lang2Fhir.create({
version: 'R4',
resource: targetResourceProfile,
text: inputText
};

const createResponse = await fetch(PHENOML_API_URL + '/lang2fhir/create', {
method: "POST",
body: JSON.stringify(createRequest),
headers: {
'Authorization': `Bearer ${bearerToken}`,
'Content-Type': 'application/json',
'Accept': 'application/json'
},
});

if (!createResponse.ok) {
throw new Error(`Create failed: ${createResponse.status} ${createResponse.statusText}`);
}

const generatedResource = await createResponse.json();

// Only add patient reference for patient-dependent resources
if (requiresPatient && patient) {
addPatientReference(generatedResource, patient);
}

return generatedResource as AllowedResourceTypes;
return generatedResource as unknown as AllowedResourceTypes;
} catch (error) {
throw new Error(`Bot execution failed: ${error instanceof Error ? error.message : String(error)}`);
}
Expand All @@ -135,9 +99,9 @@ function addPatientReference(resource: any, patient: Patient): void {
if (!['QuestionnaireResponse', 'Observation', 'Procedure', 'Condition', 'MedicationRequest', 'CarePlan'].includes(resource.resourceType)) {
throw new Error(`Unsupported resource type for patient reference: ${resource.resourceType}`);
}

resource.subject = {
reference: `Patient/${patient.id}`,
display: patient.name?.[0]?.text || `Patient/${patient.id}`
};
}
}
93 changes: 26 additions & 67 deletions src/bots/lang2fhir-document.ts
Original file line number Diff line number Diff line change
@@ -1,46 +1,44 @@
// SPDX-FileCopyrightText: Copyright Orangebot, Inc. and Medplum contributors
// SPDX-License-Identifier: Apache-2.0
import { BotEvent, MedplumClient } from '@medplum/core';
import { DocumentReference, Resource, Questionnaire, QuestionnaireResponse } from '@medplum/fhirtypes';
import { DocumentReference, Questionnaire, QuestionnaireResponse } from '@medplum/fhirtypes';
import { Buffer } from 'buffer';
import { PhenoMLClient, phenoml } from 'phenoml';

/**
* A Medplum Bot that processes documents using the lang2fhir API.
*
*
* The bot will:
* 1. Download the document from the provided URL
* 2. Send the document to the lang2fhir API
* 3. Create a FHIR resource of the type specified in the input
*
*
* Required bot secrets: (You need to have an active PhenoML subscription to use this bot)
* - PHENOML_EMAIL: Your PhenoML API email
* - PHENOML_PASSWORD: Your PhenoML API password
*/

interface DocumentRequest {
version: string;
resource: string;
content: string;
fileType: string;
}

interface DocBotInput {
docref: DocumentReference;
resourceType: 'Questionnaire' | 'QuestionnaireResponse';
}

const PHENOML_API_URL = "https://experiment.app.pheno.ml";

// Maps content types to SDK file types
const FILE_TYPE_MAP: Record<string, phenoml.lang2Fhir.DocumentRequest.FileType> = {
'application/pdf': 'application/pdf',
'image/png': 'image/png',
'image/jpeg': 'image/jpeg',
'image/jpg': 'image/jpg',
};

export async function handler(
medplum: MedplumClient,
medplum: MedplumClient,
event: BotEvent<DocBotInput>
): Promise<Resource> {
): Promise<Questionnaire | QuestionnaireResponse> {
try {

const inputDocRef = event.input.docref;
const inputResourceType = event.input.resourceType;

if (!inputDocRef) {
throw new Error('No media input provided to bot');
}
Expand All @@ -51,76 +49,37 @@ export async function handler(
if (!['Questionnaire', 'QuestionnaireResponse'].includes(inputResourceType)) {
throw new Error(`Unsupported resource type: ${inputResourceType}`);
}

if (!inputDocRef.content?.[0].attachment?.url) {
throw new Error('DocumentReference resource must have content.url');
}

const targetResourceType = inputResourceType.toLowerCase();
const targetResourceType = inputResourceType.toLowerCase() as phenoml.lang2Fhir.DocumentRequest.Resource;

// Download the file content from the pre-signed URL
const blob = await medplum.download(inputDocRef.content?.[0].attachment?.url);
const arrayBuffer = await blob.arrayBuffer();
const content = Buffer.from(arrayBuffer).toString('base64');

const contentType = inputDocRef.content?.[0].attachment?.contentType || 'application/pdf';
const fileType = FILE_TYPE_MAP[contentType] || 'application/pdf';
Copy link

Choose a reason for hiding this comment

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

Unsupported file types silently misrepresented as PDF

Medium Severity

When a document has a content type not in FILE_TYPE_MAP (e.g., 'image/gif', 'image/tiff', 'application/msword'), the code silently falls back to 'application/pdf'. This sends the actual file bytes to the SDK while claiming it's a PDF, which will cause incorrect processing or confusing parsing errors. The old code would pass the actual content type, letting the API validate and reject unsupported types with a clear error. Missing validation for unsupported file types before calling the SDK.

Fix in Cursor Fix in Web

Copy link

Choose a reason for hiding this comment

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

Unsupported file types silently misrepresented as PDF

Medium Severity

When a document has a content type not in FILE_TYPE_MAP (e.g., 'image/gif', 'image/tiff', 'application/msword'), the code silently falls back to 'application/pdf'. This sends the actual file bytes to the SDK while claiming it's a PDF, which will cause incorrect processing or confusing parsing errors. The old code would pass the actual content type, letting the API validate and reject unsupported types with a clear error. Missing validation for unsupported file types before calling the SDK.

Fix in Cursor Fix in Web


const email = event.secrets["PHENOML_EMAIL"].valueString as string;
const password = event.secrets["PHENOML_PASSWORD"].valueString as string;

// Create base64 encoded credentials for Basic Auth
const credentials = Buffer.from(`${email}:${password}`).toString('base64');
// Get auth token using Basic Auth
const authResponse = await fetch(PHENOML_API_URL + '/auth/token', {
method: 'POST',
headers: {
'Accept': 'application/json',
'Authorization': `Basic ${credentials}`
},
}).catch(error => {
throw new Error(`Failed to connect to PhenoML API: ${error.message}`);
});

if (!authResponse.ok) {
throw new Error(`Authentication failed: ${authResponse.status} ${authResponse.statusText}`);
}
// Initialize PhenoML client with automatic auth handling
const phenomlClient = new PhenoMLClient({ username: email, password });

const { token: bearerToken } = await authResponse.json() as { token: string };
if (!bearerToken) {
throw new Error('No token received from auth response');
}

// Prepare document request
const documentRequest: DocumentRequest = {
version: 'R4', // FHIR R4
// Call lang2fhir document endpoint using SDK
const generatedResource = await phenomlClient.lang2Fhir.document({
version: 'R4',
resource: targetResourceType,
content: content,
fileType: inputDocRef.content?.[0].attachment?.contentType || 'application/pdf'
};

// Call lang2fhir/document endpoint
const documentResponse = await fetch(PHENOML_API_URL + '/lang2fhir/document', {
method: "POST",
body: JSON.stringify(documentRequest),
headers: {
'Authorization': `Bearer ${bearerToken}`,
'Content-Type': 'application/json',
'Accept': 'application/json'
},
}).catch(error => {
throw new Error(`Failed to connect to Lang2FHIR Document API: ${error.message}`);
fileType: fileType
});

if (!documentResponse.ok) {
const errorText = await documentResponse.text().catch(() => 'No error details available');
throw new Error(`Document processing failed: ${documentResponse.status} ${documentResponse.statusText} - ${errorText}`);
}

const generatedResource = await documentResponse.json().catch(error => {
throw new Error(`Failed to parse document response: ${error.message}`);
});

return generatedResource as Questionnaire | QuestionnaireResponse;
return generatedResource as unknown as Questionnaire | QuestionnaireResponse;
} catch (error) {
throw new Error(`Bot execution failed: ${error instanceof Error ? error.message : String(error)}`);
}
}

Loading