Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
1 change: 0 additions & 1 deletion backend/src/app.module.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,6 @@ describe('AppModule', () => {
.useValue({
generateResponse: vi.fn().mockResolvedValue('test response'),
analyzeMedicalDocument: vi.fn().mockResolvedValue({
keyMedicalTerms: [],
labValues: [],
diagnoses: [],
metadata: {
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 All @@ -35,11 +48,17 @@ export class DocumentProcessorController {
}

// Validate file type
const validMimeTypes = ['image/jpeg', 'image/png', 'image/tiff', 'application/pdf'];
const validMimeTypes = [
'image/jpeg',
'image/png',
'image/heic',
'image/heif',
'application/pdf',
];

if (!validMimeTypes.includes(file.mimetype)) {
throw new BadRequestException(
`Invalid file type: ${file.mimetype}. Supported types: JPEG, PNG, TIFF, and PDF.`,
`Invalid file type: ${file.mimetype}. Supported types: JPEG, PNG, HEIC, HEIF, and PDF.`,
);
}

Expand All @@ -63,7 +82,6 @@ export class DocumentProcessorController {
// Process the document
const result = await this.documentProcessorService.processDocument(
file.buffer,
file.mimetype,
effectiveUserId,
);

Expand Down Expand Up @@ -95,6 +113,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 Expand Up @@ -229,8 +379,8 @@ export class DocumentProcessorController {

<form id="uploadForm" enctype="multipart/form-data">
<div class="form-group">
<label for="file">Select File (PDF, JPEG, PNG, TIFF):</label>
<input type="file" id="file" name="file" accept=".pdf,.jpg,.jpeg,.png,.tiff">
<label for="file">Select File (PDF, JPEG, PNG, HEIC, HEIF):</label>
<input type="file" id="file" name="file" accept=".pdf,.jpg,.jpeg,.png,.heic,.heif">
</div>

<div id="filePreview"></div>
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
44 changes: 29 additions & 15 deletions backend/src/document-processor/services/aws-bedrock.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,17 +65,19 @@ vi.mock('@aws-sdk/client-bedrock-runtime', () => {
{
type: 'text',
text: JSON.stringify({
keyMedicalTerms: [
{ term: 'RBC', definition: 'Red Blood Cells' },
{ term: 'WBC', definition: 'White Blood Cells' },
],
title: 'Blood Test Results',
category: 'general',
labValues: [
{
name: 'Hemoglobin',
value: '14.2',
unit: 'g/dL',
normalRange: '13.5-17.5',
isAbnormal: false,
status: 'normal',
isCritical: false,
conclusion:
'Normal hemoglobin levels indicate adequate oxygen-carrying capacity.',
suggestions: 'Continue regular health maintenance.',
},
],
diagnoses: [],
Expand Down Expand Up @@ -126,17 +128,18 @@ describe('AwsBedrockService', () => {
`;

const mockMedicalAnalysis: MedicalDocumentAnalysis = {
keyMedicalTerms: [
{ term: 'RBC', definition: 'Red Blood Cells' },
{ term: 'WBC', definition: 'White Blood Cells' },
],
title: 'Blood Test Results',
category: 'general',
labValues: [
{
name: 'Hemoglobin',
value: '14.2',
unit: 'g/dL',
normalRange: '13.5-17.5',
isAbnormal: false,
status: 'normal',
isCritical: false,
conclusion: 'Normal hemoglobin levels indicate adequate oxygen-carrying capacity.',
suggestions: 'Continue regular health maintenance.',
},
],
diagnoses: [],
Expand Down Expand Up @@ -290,10 +293,8 @@ describe('AwsBedrockService', () => {
const invalidResponses = [
null,
{},
{ keyMedicalTerms: 'not an array' },
{ keyMedicalTerms: [], labValues: [], diagnoses: [] }, // Missing metadata
{ labValues: [], diagnoses: [] }, // Missing metadata
{
keyMedicalTerms: [],
labValues: [],
diagnoses: [],
metadata: { isMedicalReport: 'not a boolean', confidence: 0.5, missingInformation: [] },
Expand All @@ -309,8 +310,21 @@ describe('AwsBedrockService', () => {

// Test a valid response
const validResponse: MedicalDocumentAnalysis = {
keyMedicalTerms: [],
labValues: [],
title: 'Test Report',
category: 'general',
labValues: [
// Adding an empty lab value with required properties
{
name: 'Sample Test',
value: '0',
unit: 'units',
normalRange: '0-1',
status: 'normal',
isCritical: false,
conclusion: 'Normal test result',
suggestions: 'No action needed',
},
],
diagnoses: [],
metadata: {
isMedicalReport: true,
Expand Down
Loading
Loading