Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
9e05244
Add title and category fields to MedicalDocumentAnalysis and update t…
adamrefaey Apr 14, 2025
971a5a3
Refactor MedicalReport interface to rename 'date' to 'createdAt' and …
adamrefaey Apr 14, 2025
b13a0cc
Refactor document processing to remove file type parameter and enhanc…
adamrefaey Apr 14, 2025
5768f6e
Refactor MedicalDocumentAnalysis to replace 'isAbnormal' with 'isNorm…
adamrefaey Apr 14, 2025
1aef272
Enhance MedicalDocumentAnalysis interface by adding conclusion and su…
adamrefaey Apr 14, 2025
3b69ff3
Add isProcessed, labValues, and summary fields to Report model and in…
adamrefaey Apr 14, 2025
3bfc945
feat: Add S3 file retrieval and processing functionality
adamrefaey Apr 14, 2025
9c912f9
chore: Update TypeScript ESLint dependencies to version 8.30.1 in pac…
adamrefaey Apr 14, 2025
10ee8c3
feat: Add dynamodbReportsTable configuration and update ReportsServic…
adamrefaey Apr 14, 2025
e38f70b
feat: Configure S3 upload bucket in configuration and refactor Docume…
adamrefaey Apr 14, 2025
ca1f9c9
feat: Enhance AWS client configuration to support optional credential…
adamrefaey Apr 14, 2025
418db7c
feat: Update labValues structure in Report model and handle potential…
adamrefaey Apr 14, 2025
924fbb2
refactor: Rename 'isNormal' field to 'status' in MedicalDocumentAnaly…
adamrefaey Apr 15, 2025
5101128
refactor: Remove 'keyMedicalTerms' from MedicalDocumentAnalysis and r…
adamrefaey Apr 15, 2025
6833cde
feat: Add 'isCritical' field to MedicalDocumentAnalysis and Report mo…
adamrefaey Apr 15, 2025
7183764
cleanup
adamrefaey Apr 15, 2025
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
1,595 changes: 1,227 additions & 368 deletions backend/package-lock.json

Large diffs are not rendered by default.

5 changes: 3 additions & 2 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"@aws-sdk/client-bedrock": "^3.782.0",
"@aws-sdk/client-bedrock-runtime": "^3.782.0",
"@aws-sdk/client-dynamodb": "^3.758.0",
"@aws-sdk/client-s3": "^3.787.0",
"@aws-sdk/client-secrets-manager": "^3.758.0",
"@aws-sdk/client-textract": "^3.782.0",
"@aws-sdk/util-dynamodb": "^3.758.0",
Expand Down Expand Up @@ -74,8 +75,8 @@
"@types/jwk-to-pem": "^2.0.2",
"@types/multer": "^1.4.12",
"@types/node": "^20.12.7",
"@typescript-eslint/eslint-plugin": "^7.9.0",
"@typescript-eslint/parser": "^7.9.0",
"@typescript-eslint/eslint-plugin": "^8.30.1",
"@typescript-eslint/parser": "^8.30.1",
"@vitest/coverage-v8": "^3.1.1",
"aws-cdk": "2.139.0",
"aws-cdk-lib": "^2.185.0",
Expand Down
9 changes: 8 additions & 1 deletion backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,13 @@ import { DocumentProcessorModule } from './document-processor/document-processor
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(AuthMiddleware).exclude('document-processor/(.*)').forRoutes('*');
consumer
.apply(AuthMiddleware)
.exclude(
'document-processor/upload',
'document-processor/test',
'document-processor/test-form',
)
.forRoutes('*');
}
}
5 changes: 5 additions & 0 deletions backend/src/config/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ export default () => ({
environment: process.env.NODE_ENV || 'development',
aws: {
region: process.env.AWS_REGION || 'us-east-1',
s3: {
uploadBucket: process.env.S3_UPLOAD_BUCKET || '',
},
cognito: {
userPoolId: process.env.AWS_COGNITO_USER_POOL_ID,
clientId: process.env.AWS_COGNITO_CLIENT_ID,
Expand Down Expand Up @@ -35,4 +38,6 @@ export default () => ({
model: process.env.PERPLEXITY_MODEL || 'sonar',
maxTokens: parseInt(process.env.PERPLEXITY_MAX_TOKENS || '2048', 10),
},
dynamodbReportsTable:
process.env.DYNAMODB_REPORTS_TABLE || 'AIMedicalReportReportsTabledevelopment',
});
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ import {
Logger,
Get,
Res,
Req,
UnauthorizedException,
NotFoundException,
InternalServerErrorException,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import {
Expand All @@ -16,12 +20,21 @@ import {
} from '../services/document-processor.service';
import { Express } from 'express';
import { Response } from 'express';
import { ReportsService } from '../../reports/reports.service';
import { RequestWithUser } from '../../auth/auth.middleware';
import { Readable } from 'stream';
import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3';
import { ConfigService } from '@nestjs/config';

@Controller('document-processor')
export class DocumentProcessorController {
private readonly logger = new Logger(DocumentProcessorController.name);

constructor(private readonly documentProcessorService: DocumentProcessorService) {}
constructor(
private readonly documentProcessorService: DocumentProcessorService,
private readonly reportsService: ReportsService,
private readonly configService: ConfigService,
) {}

@Post('upload')
@UseInterceptors(FileInterceptor('file'))
Expand Down Expand Up @@ -63,7 +76,6 @@ export class DocumentProcessorController {
// Process the document
const result = await this.documentProcessorService.processDocument(
file.buffer,
file.mimetype,
effectiveUserId,
);

Expand Down Expand Up @@ -95,6 +107,138 @@ export class DocumentProcessorController {
}
}

@Post('process-file')
async processFileFromPath(
@Body('filePath') filePath: string,
@Req() request: RequestWithUser,
): Promise<ProcessedDocumentResult | any> {
if (!filePath) {
throw new BadRequestException('No filePath provided');
}

// Extract userId from the request (attached by auth middleware)
const userId = request.user?.sub;
if (!userId) {
throw new UnauthorizedException('User ID not found in request');
}

this.logger.log(`Processing document from file path: ${filePath}`);

try {
// Fetch the associated report record from DynamoDB
const report = await this.reportsService.findByFilePath(filePath, userId);
if (!report) {
throw new NotFoundException(`Report with filePath ${filePath} not found`);
}

// Get the file from S3
const fileBuffer = await this.getFileFromS3(filePath);

// Process the document
const result = await this.documentProcessorService.processDocument(fileBuffer, userId);

// Update the report with analysis results
report.title = result.analysis.title || 'Untitled Report';
report.category = result.analysis.category || 'general';
report.isProcessed = true;

// Extract lab values
report.labValues = result.analysis.labValues || [];

// Create summary from simplified explanation or diagnoses
report.summary =
result.simplifiedExplanation ||
result.analysis.diagnoses.map(d => d.condition).join(', ') ||
'No summary available';

report.updatedAt = new Date().toISOString();

// Update the report in DynamoDB
await this.reportsService.updateReport(report);

return {
success: true,
reportId: report.id,
analysis: result.analysis,
};
} catch (error: unknown) {
this.logger.error(
`Error processing document from path ${filePath}: ${error instanceof Error ? error.message : 'Unknown error'}`,
);
throw error;
}
}

/**
* Retrieves a file from S3 storage
* @param filePath - The S3 key of the file
* @returns Buffer containing the file data
*/
private async getFileFromS3(filePath: string): Promise<Buffer> {
try {
const bucketName = this.configService.get<string>('aws.s3.uploadBucket');
if (!bucketName) {
throw new InternalServerErrorException('S3 bucket name not configured');
}

const region = this.configService.get<string>('aws.region') || 'us-east-1';

// Get optional AWS credentials if they exist
const accessKeyId = this.configService.get<string>('aws.aws.accessKeyId');
const secretAccessKey = this.configService.get<string>('aws.aws.secretAccessKey');
const sessionToken = this.configService.get<string>('aws.aws.sessionToken');

// Create S3 client with credentials if they exist
const s3ClientOptions: any = { region };

if (accessKeyId && secretAccessKey) {
s3ClientOptions.credentials = {
accessKeyId,
secretAccessKey,
...(sessionToken && { sessionToken }),
};
}

const s3Client = new S3Client(s3ClientOptions);

const command = new GetObjectCommand({
Bucket: bucketName,
Key: filePath,
});

const response = await s3Client.send(command);

// Check if response.Body exists before converting
if (!response.Body) {
throw new InternalServerErrorException('Empty response from S3');
}

// Convert the readable stream to a buffer
return await this.streamToBuffer(response.Body as Readable);
} catch (error) {
this.logger.error(
`Error retrieving file from S3: ${error instanceof Error ? error.message : 'Unknown error'}`,
);
throw new InternalServerErrorException(
`Failed to retrieve file from S3: ${error instanceof Error ? error.message : 'Unknown error'}`,
);
}
}

/**
* Converts a readable stream to a buffer
* @param stream - The readable stream from S3
* @returns Buffer containing the stream data
*/
private async streamToBuffer(stream: Readable): Promise<Buffer> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
stream.on('data', chunk => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
stream.on('end', () => resolve(Buffer.concat(chunks)));
stream.on('error', reject);
});
}

@Get('test')
getTestStatus(): { status: string } {
return { status: 'DocumentProcessorController is working' };
Expand Down
3 changes: 2 additions & 1 deletion backend/src/document-processor/document-processor.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@ import { AwsBedrockService } from './services/aws-bedrock.service';
import { DocumentProcessorController } from './controllers/document-processor.controller';
import { PerplexityService } from '../services/perplexity.service';
import { AwsSecretsService } from '../services/aws-secrets.service';
import { ReportsModule } from '../reports/reports.module';

@Module({
imports: [ConfigModule],
imports: [ConfigModule, ReportsModule],
controllers: [DocumentProcessorController],
providers: [
DocumentProcessorService,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ vi.mock('@aws-sdk/client-bedrock-runtime', () => {
{
type: 'text',
text: JSON.stringify({
title: 'Blood Test Results',
category: 'general',
keyMedicalTerms: [
{ term: 'RBC', definition: 'Red Blood Cells' },
{ term: 'WBC', definition: 'White Blood Cells' },
Expand All @@ -75,7 +77,10 @@ vi.mock('@aws-sdk/client-bedrock-runtime', () => {
value: '14.2',
unit: 'g/dL',
normalRange: '13.5-17.5',
isAbnormal: false,
isNormal: 'normal',
conclusion:
'Normal hemoglobin levels indicate adequate oxygen-carrying capacity.',
suggestions: 'Continue regular health maintenance.',
},
],
diagnoses: [],
Expand Down Expand Up @@ -126,6 +131,8 @@ describe('AwsBedrockService', () => {
`;

const mockMedicalAnalysis: MedicalDocumentAnalysis = {
title: 'Blood Test Results',
category: 'general',
keyMedicalTerms: [
{ term: 'RBC', definition: 'Red Blood Cells' },
{ term: 'WBC', definition: 'White Blood Cells' },
Expand All @@ -136,7 +143,9 @@ describe('AwsBedrockService', () => {
value: '14.2',
unit: 'g/dL',
normalRange: '13.5-17.5',
isAbnormal: false,
isNormal: 'normal',
conclusion: 'Normal hemoglobin levels indicate adequate oxygen-carrying capacity.',
suggestions: 'Continue regular health maintenance.',
},
],
diagnoses: [],
Expand Down Expand Up @@ -309,6 +318,8 @@ describe('AwsBedrockService', () => {

// Test a valid response
const validResponse: MedicalDocumentAnalysis = {
title: 'Test Report',
category: 'general',
keyMedicalTerms: [],
labValues: [],
diagnoses: [],
Expand Down
45 changes: 38 additions & 7 deletions backend/src/document-processor/services/aws-bedrock.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,17 @@ import { createHash } from 'crypto';
* Interface for medical document analysis result
*/
export interface MedicalDocumentAnalysis {
title: string;
category: string;
keyMedicalTerms: Array<{ term: string; definition: string }>;
labValues: Array<{
name: string;
value: string;
unit: string;
normalRange: string;
isAbnormal: boolean;
isNormal: 'normal' | 'high' | 'low';
conclusion: string;
suggestions: string;
}>;
diagnoses: Array<{ condition: string; details: string; recommendations: string }>;
metadata: {
Expand All @@ -44,17 +48,21 @@ export class AwsBedrockService {
private readonly medicalAnalysisPrompt = `Please analyze this medical document carefully, with specific attention to medical lab reports.

Look for and extract the following information:
1. Key medical terms visible in the document with their definitions
2. Lab test values with their normal ranges and whether they are abnormal (particularly important for blood work, metabolic panels, etc.)
3. Any diagnoses, findings, or medical observations with details and recommendations
4. Analyze if this is a medical document (lab report, test result, medical chart, prescription, etc.) and provide confidence level
1. Document title or main subject based on content
2. Document category based on organ system focus
3. Key medical terms visible in the document with their definitions
4. Lab test values with their normal ranges and whether they are normal, high, or low (particularly important for blood work, metabolic panels, etc.)
5. Any diagnoses, findings, or medical observations with details and recommendations
6. Analyze if this is a medical document (lab report, test result, medical chart, prescription, etc.) and provide confidence level

This document may be a lab report showing blood work or other test results, so please pay special attention to tables, numeric values, reference ranges, and medical terminology.

Format the response as a JSON object with the following structure:
{
"title": string,
"category": string,
"keyMedicalTerms": [{"term": string, "definition": string}],
"labValues": [{"name": string, "value": string, "unit": string, "normalRange": string, "isAbnormal": boolean}],
"labValues": [{"name": string, "value": string, "unit": string, "normalRange": string, "isNormal": "normal" | "high" | "low", "conclusion": string, "suggestions": string}],
"diagnoses": [{"condition": string, "details": string, "recommendations": string}],
"metadata": {
"isMedicalReport": boolean,
Expand All @@ -63,6 +71,12 @@ Format the response as a JSON object with the following structure:
}
}

For the title field, create a concise title that summarizes what the document is about (e.g., "Complete Blood Count Results", "Liver Function Test", "MRI Report").
For the category field, you MUST choose exactly one of these three values:
- "heart" - if the document focuses primarily on cardiac/cardiovascular issues or tests
- "brain" - if the document focuses primarily on neurological issues or brain-related tests
- "general" - for all other medical documents, or when the focus spans multiple systems

Set isMedicalReport to true if you see ANY medical content such as lab values, medical terminology, doctor's notes, or prescription information.
Set confidence between 0 and 1 based on document clarity and how confident you are about the medical nature of the document.

Expand All @@ -72,6 +86,9 @@ This is extremely important: If you see ANY lab values, numbers with units, or m
When extracting lab values:
1. Look for tables with numeric values and reference ranges
2. Include any values even if you're not sure of the meaning
3. For each lab value, use "isNormal" field with values "normal", "high", or "low" based on whether the value falls within, above, or below the normal range
4. Include a "conclusion" field that provides a brief interpretation of what this value indicates about the patient's health
5. Include a "suggestions" field that provides brief recommendations based on this value

EXTREMELY IMPORTANT FORMATTING INSTRUCTIONS:
1. ABSOLUTELY DO NOT START YOUR RESPONSE WITH ANY TEXT. Begin immediately with the JSON object.
Expand Down Expand Up @@ -115,11 +132,23 @@ INCORRECT RESPONSE FORMATS (DO NOT DO THESE):

CORRECT FORMAT (DO THIS):
{
"title": "Complete Blood Count Results",
"category": "heart",
"keyMedicalTerms": [
{"term": "RBC", "definition": "Red blood cells"},
{"term": "WBC", "definition": "White blood cells"}
],
"labValues": [...],
"labValues": [
{
"name": "Hemoglobin",
"value": "14.2",
"unit": "g/dL",
"normalRange": "13.5-17.5",
"isNormal": "normal",
"conclusion": "Normal hemoglobin levels indicate adequate oxygen-carrying capacity.",
"suggestions": "Continue regular health maintenance."
}
],
"diagnoses": [...],
"metadata": {...}
}
Expand Down Expand Up @@ -401,6 +430,8 @@ Document text:
// Check if response has all required properties
if (
!response ||
typeof response.title !== 'string' ||
typeof response.category !== 'string' ||
!Array.isArray(response.keyMedicalTerms) ||
!Array.isArray(response.labValues) ||
!Array.isArray(response.diagnoses) ||
Expand Down
Loading