@@ -410,18 +392,18 @@ function App() {
useEffect(() => {
// Check for low-performance devices and disable animations if needed
checkPerformance();
-
+
// Refresh animations on window resize with debounce for performance
const handleResize = () => {
refreshAnimations();
};
-
+
window.addEventListener('resize', handleResize);
window.addEventListener('orientationchange', handleResize);
-
+
// Initial refresh
refreshAnimations();
-
+
return () => {
window.removeEventListener('resize', handleResize);
window.removeEventListener('orientationchange', handleResize);
@@ -469,7 +451,7 @@ function App() {
};
i18n.on('languageChanged', handleLanguageChange);
-
+
return () => {
i18n.off('languageChanged', handleLanguageChange);
};
diff --git a/client/src/components/ReportList.jsx b/client/src/components/ReportList.jsx
new file mode 100644
index 0000000..43dc66a
--- /dev/null
+++ b/client/src/components/ReportList.jsx
@@ -0,0 +1,349 @@
+import React, { useState, useEffect } from 'react';
+import api from '../utils/api';
+
+// Reports List Component
+export const ReportsList = ({ onSelectReport }) => {
+ const [reports, setReports] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ fetchReports();
+ }, []);
+
+ const fetchReports = async () => {
+ try {
+ setLoading(true);
+ const response = await api.get('/reports');
+ setReports(response.data);
+ setError(null);
+ } catch (err) {
+ setError('Failed to load reports');
+ console.error(err);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleDelete = async (reportId) => {
+ if (window.confirm('Delete this report?')) {
+ try {
+ await api.delete(`/reports/${reportId}`);
+ setReports(reports.filter(report => report._id !== reportId));
+ } catch (err) {
+ alert('Failed to delete report');
+ }
+ }
+ };
+
+ if (loading) {
+ return
Loading reports...
;
+ }
+
+ if (error) {
+ return
{error}
;
+ }
+
+ if (reports.length === 0) {
+ return
No reports found. Upload one to get started.
;
+ }
+
+ return (
+
+
My Reports
+
+ {reports.map((reportItem) => (
+
+
+
+ {reportItem.filename}
+
+
+ {new Date(reportItem.createdAt).toLocaleDateString()} | {reportItem.healthParameters.length} parameters
+
+
+ Status:
+ {reportItem.aiInsights?.riskLevel || 'N/A'}
+
+
+
+
+
+
+
+
+ ))}
+
+
+ );
+};
+
+// Report Detail Component
+export const ReportDetail = ({ reportId, onBack }) => {
+ const [report, setReport] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ fetchReport();
+ }, [reportId]);
+
+ const fetchReport = async () => {
+ try {
+ setLoading(true);
+ const response = await api.get(`/reports/${reportId}`);
+ setReport(response.data);
+ setError(null);
+ } catch (err) {
+ setError('Failed to load report');
+ console.error(err);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ if (loading) {
+ return
Loading report...
;
+ }
+
+ if (error) {
+ return
{error}
;
+ }
+
+ if (!report) {
+ return
Report not found
;
+ }
+
+ const getStatusColor = (status) => {
+ switch (status) {
+ case 'Normal': return '#10b981';
+ case 'High': return '#ef4444';
+ case 'Low': return '#f97316';
+ case 'Abnormal': return '#dc2626';
+ default: return '#6b7280';
+ }
+ };
+
+ const getStatusBg = (status) => {
+ switch (status) {
+ case 'Normal': return '#f0fdf4';
+ case 'High':
+ case 'Abnormal': return '#fef2f2';
+ case 'Low': return '#fff7ed';
+ default: return '#f9fafb';
+ }
+ };
+
+ const groupedParams = report.healthParameters.reduce((accumulator, param) => {
+ const category = param.category || 'Other';
+ if (!accumulator[category]) accumulator[category] = [];
+ accumulator[category].push(param);
+ return accumulator;
+ }, {});
+
+ const categories = Object.keys(groupedParams).sort();
+ const abnormalParams = report.healthParameters.filter(p => ['High', 'Low', 'Abnormal'].includes(p.status));
+
+ return (
+
+
+
+
+
Medical Report
+
+ {new Date(report.createdAt).toLocaleDateString()} | {report.filename}
+
+
+
+ {report.patientInfo && (
+
+
Patient Information
+
+ {report.patientInfo.name &&
Name: {report.patientInfo.name}
}
+ {report.patientInfo.age &&
Age: {report.patientInfo.age}
}
+ {report.patientInfo.gender &&
Gender: {report.patientInfo.gender}
}
+ {report.patientInfo.testDate &&
Test Date: {report.patientInfo.testDate}
}
+ {report.patientInfo.hospital &&
Hospital: {report.patientInfo.hospital}
}
+
+
+ )}
+
+
+
+
{report.healthParameters.length}
+
Total Parameters
+
+ {report.aiInsights && (
+
+
{report.aiInsights.riskLevel || 'N/A'}
+
Risk Level
+
+ )}
+
+
+ {report.aiInsights && (
+
+
AI Insights
+
+ {report.aiInsights.summary && (
+
+
Summary:
+
{report.aiInsights.summary}
+
+ )}
+
+ {report.aiInsights.outliers && report.aiInsights.outliers.length > 0 && (
+
+
Outliers:
+
+ {report.aiInsights.outliers.map((outlier, index) => {
+ const isNumeric = typeof outlier.value === 'number' && outlier.value !== 0;
+ return (
+
+
{outlier.parameter}
+ {isNumeric && (
+
+ {outlier.value} {outlier.normalRange && `(Normal: ${outlier.normalRange})`}
+
+ )}
+
{outlier.concern}
+ {outlier.recommendation &&
→ {outlier.recommendation}
}
+
+ );
+ })}
+
+
+ )}
+
+ {report.aiInsights.recommendations && report.aiInsights.recommendations.length > 0 && (
+
+
Recommendations:
+
+ {report.aiInsights.recommendations.map((recommendation, index) => (
+ - {recommendation}
+ ))}
+
+
+ )}
+
+ {report.aiInsights.positiveFindings && report.aiInsights.positiveFindings.length > 0 && (
+
+
Positive Findings:
+
+ {report.aiInsights.positiveFindings.map((finding, index) => (
+ - ✓ {finding}
+ ))}
+
+
+ )}
+
+ )}
+
+
+
Health Parameters
+
+ {categories.map(category => (
+
+
+ {category}
+
+
+ {groupedParams[category].map((param, paramIndex) => (
+
+
+
{param.name}
+
+ {param.status}
+
+
+
+
+ {param.value} {param.unit}
+
+
+
+ Normal: {param.normalRange}
+
+
+ {param.textValue && (
+
+ {param.textValue}
+
+ )}
+
+ ))}
+
+
+ ))}
+
+
+
+
+ Extraction Method: {report.extractionMethod} |
+ Processed: {new Date(report.createdAt).toLocaleString()}
+ {report.geminiMetadata && ` | Confidence: ${(report.geminiMetadata.confidence * 100).toFixed(0)}%`}
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/client/src/components/ReportTable.jsx b/client/src/components/ReportTable.jsx
deleted file mode 100644
index 14156c9..0000000
--- a/client/src/components/ReportTable.jsx
+++ /dev/null
@@ -1,256 +0,0 @@
-import React, { useState, useEffect } from 'react';
-import { fetchTrendData } from '../utils/api';
-import AOS from 'aos';
-
-const ReportTable = ({ data, onTrendData }) => {
- const [sortBy, setSortBy] = useState('category');
- const [filterBy, setFilterBy] = useState('all');
- const [showTrends, setShowTrends] = useState(false);
-
- const { healthParameters } = data;
-
- useEffect(() => {
- if (showTrends && data.reportId) {
- loadTrendData();
- }
-
- // Refresh AOS animations
- AOS.refresh();
-
- // Add staggered animation to table rows
- const addRowAnimations = () => {
- const parameterCards = document.querySelectorAll('.parameter-card');
- parameterCards.forEach((card, index) => {
- card.setAttribute('data-aos', 'fade-up');
- card.setAttribute('data-aos-delay', (150 + index * 50).toString());
- });
- AOS.refresh();
- };
-
- // Small delay to ensure DOM elements are ready
- setTimeout(addRowAnimations, 100);
- }, [showTrends, data.reportId, filterBy, sortBy]);
-
- const loadTrendData = async () => {
- try {
- const trends = await fetchTrendData(data.reportId);
- onTrendData(trends);
- } catch (error) {
- // Trend data loading failed silently
- }
- };
-
- const handleToggleTrends = () => {
- const newShowTrends = !showTrends;
- setShowTrends(newShowTrends);
-
- // Clear trend data when hiding trends
- if (!newShowTrends) {
- onTrendData(null);
- }
- };
-
- const getStatusIcon = (status) => {
- switch (status) {
- case 'Normal': return '✅';
- case 'High': return '⚠️';
- case 'Low': return '🔻';
- default: return '❓';
- }
- };
-
- const getStatusClass = (status) => {
- switch (status) {
- case 'Normal': return 'status-normal';
- case 'High': return 'status-high';
- case 'Low': return 'status-low';
- default: return 'status-unknown';
- }
- };
-
- const filteredAndSortedData = healthParameters
- .filter(param => {
- if (filterBy === 'all') return true;
- if (filterBy === 'abnormal') return param.status !== 'Normal';
- return param.status.toLowerCase() === filterBy;
- })
- .sort((a, b) => {
- switch (sortBy) {
- case 'name':
- return a.name.localeCompare(b.name);
- case 'category':
- return a.category.localeCompare(b.category);
- case 'status':
- return a.status.localeCompare(b.status);
- default:
- return 0;
- }
- });
-
- const groupedByCategory = filteredAndSortedData.reduce((groups, param) => {
- const category = param.category || 'Other';
- if (!groups[category]) {
- groups[category] = [];
- }
- groups[category].push(param);
- return groups;
- }, {});
-
- const abnormalCount = healthParameters.filter(p => p.status !== 'Normal').length;
- const unknownCount = healthParameters.filter(p => p.status === 'UNKNOWN').length;
-
-
-return (
-
-
-
-
- 📊 {healthParameters.length}
-
- Total Parameters
-
-
-
- ✅ {healthParameters.length - abnormalCount - unknownCount}
-
- Normal
-
-
-
- ⚠️ {abnormalCount}
-
- Needs Attention
-
-
-
- ❓ {unknownCount}
-
- Data Unavailable
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {Object.entries(groupedByCategory).map(([category, parameters]) => (
-
-
{category}
-
-
- {parameters.map((param, index) => {
- let cardStyle = {};
- let statusText = param.status;
- if (param.status === 'Normal') {
- cardStyle = { backgroundColor: '#d1fae5', border: '1px solid #065f46', color: '#065f46' };
- } else if (param.status === 'Needs Attention' || param.status === 'High' || param.status === 'Low') {
- cardStyle = { backgroundColor: '#fee2e2', border: '1px solid #991b1b', color: '#991b1b' };
- } else if (param.status === 'UNKNOWN') {
- cardStyle = { backgroundColor: '#fff7cd', border: '1px solid #92400e', color: '#92400e' };
- statusText = '❓ Data Unavailable';
- }
-
- return (
-
-
- {param.name}
- {getStatusIcon(param.status)}
-
-
-
- {param.value}
- {param.unit}
-
-
-
- Normal: {param.normalRange}
-
-
-
- {statusText}
-
-
- );
- })}
-
-
- ))}
-
-
- {filteredAndSortedData.length === 0 && (
-
-
No parameters match the current filter.
-
- )}
-
-);
-
-
-};
-
-export default ReportTable;
diff --git a/client/src/utils/api.js b/client/src/utils/api.js
index 76ca8a9..e25928f 100644
--- a/client/src/utils/api.js
+++ b/client/src/utils/api.js
@@ -1,15 +1,15 @@
import axios from 'axios';
-const API_BASE_URL = import.meta.env.VITE_API_URL || '/api';
+const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:5001/api';
-// Create axios instance
const api = axios.create({
baseURL: API_BASE_URL,
- timeout: 600000, // 600 seconds (10 minutes) for file uploads with OCR
-
+ headers: {
+ 'Content-Type': 'application/json',
+ },
});
-// Add token to requests
+// Add auth token to requests
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem('token');
@@ -19,216 +19,104 @@ api.interceptors.request.use(
return config;
},
(error) => {
- console.error('Request interceptor error:', error);
return Promise.reject(error);
}
-
);
// Handle auth errors
api.interceptors.response.use(
(response) => response,
(error) => {
- // Handle authentication errors
if (error.response?.status === 401) {
localStorage.removeItem('token');
localStorage.removeItem('user');
- window.location.reload();
- }
-
- // Log all errors for debugging
- console.error('API error:', error?.response?.data || error.message);
-
- // Network errors
- if (error.message === 'Network Error' && !navigator.onLine) {
- console.error('No internet connection');
- }
-
- // Timeout errors
- if (error.code === 'ECONNABORTED' && error.message.includes('timeout')) {
- console.error('Request timeout');
+ window.location.href = '/login';
}
-
return Promise.reject(error);
}
);
-// Auth endpoints
-export const login = async (email, password) => {
- try {
- const response = await api.post('/auth/login', { email, password });
- return response.data;
- } catch (error) {
- throw new Error(
- error.response?.data?.error || 'Login failed'
- );
- }
+// Helper function to get current user
+export const getCurrentUser = async () => {
+ const response = await api.get('/auth/me');
+ return response.data;
};
-export const googleAuth = async (userData) => {
+// Login function
+export const login = async (email, password) => {
try {
- const response = await api.post('/auth/google-auth', userData);
+ const response = await api.post('/auth/login', { email, password });
return response.data;
} catch (error) {
- throw new Error(
- error.response?.data?.error || 'Google authentication failed'
- );
+ return error.response?.data || { success: false, error: 'Login failed' };
}
};
-
-
+// Register function
export const register = async (userData) => {
try {
const response = await api.post('/auth/register', userData);
return response.data;
} catch (error) {
- throw new Error(
- error.response?.data?.error || 'Registration failed'
- );
+ return error.response?.data || { success: false, error: 'Registration failed' };
}
};
-export const getCurrentUser = async () => {
+// Google Auth function
+export const googleAuth = async (user) => {
try {
- const response = await api.get('/auth/me');
+ const response = await api.post('/auth/google', user);
return response.data;
} catch (error) {
- throw new Error(
- error.response?.data?.error || 'Failed to get user info'
- );
+ return error.response?.data || { success: false, error: 'Google authentication failed' };
}
};
-// Upload file and process
-export const uploadFile = async (file, onProgress) => {
- const formData = new FormData();
- formData.append('file', file);
-
+// File upload function
+export const uploadFile = async (file) => {
try {
+ const formData = new FormData();
+ formData.append('file', file);
+
const response = await api.post('/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
- onUploadProgress: (progressEvent) => {
- const progress = Math.round(
- (progressEvent.loaded * 100) / progressEvent.total
- );
- if (onProgress) {
- onProgress(progress);
- }
- },
});
- //Add Normalize response
- const raw= response.data;
- const normalized={
- reportId:raw.reportId||raw._id||raw.id||Date.now().toString(),
- filename:raw.filename||'',
- createdAt:raw.createdAt||null,
- healthParameters:(raw.healthParameters||[]).map((p)=>({
- name:p.name,
- value:p.value,
- unit:p.unit||'',
- normalRange:p.normalRange||'N/A',
- status:determineStatus(p),
- category:p.category||'General'
- })),
- isScannedDocument: raw.isScannedDocument || false,
- requiresManualReview: raw.requiresManualReview || false,
- };
- return normalized;
- } catch (error) {
- console.error('Upload error details:', error.response?.data);
- const errorMessage =
- error.response?.data?.error ||
- error.response?.data?.details ||
- 'Failed to upload file';
- throw new Error(errorMessage);
- }
-};
-// Helper for status calculation
-function determineStatus(param){
- if(!param.value||!param.normalRange) return "UNKNOWN";
- const rangeMatch=param.reference?.match(/(\d+\.?\d*)-(\d+\.?\d*)/);
- if(rangeMatch){
- const [,low,high]=rangeMatch;
- const val=parseFloat(param.value);
- if(val
parseFloat(high)) return "High";
- return "Normal";
- }
- return "UNKNOWN";
-}
-// Fetch all reports
-export const fetchReports = async () => {
- try {
- const response = await api.get('/reports');
- return response.data.map(normalizedReport);
- } catch (error) {
- throw new Error(
- error.response?.data?.error || 'Failed to fetch reports'
- );
- }
-};
-
-// Fetch specific report
-export const fetchReport = async (reportId) => {
- try {
- const response = await api.get(`/reports/${reportId}`);
- return normalizeReport(response.data);
+ return response.data;
} catch (error) {
- throw new Error(
- error.response?.data?.error || 'Failed to fetch report'
- );
+ return error.response?.data || { success: false, error: 'File upload failed' };
}
};
-// Fetch trend data for a report
+// Fetch trend data function
export const fetchTrendData = async (reportId) => {
try {
const response = await api.get(`/reports/${reportId}/trends`);
return response.data;
} catch (error) {
- throw new Error(
- error.response?.data?.error || 'Failed to fetch trend data'
- );
- }
-};
-
-// Health check
-export const healthCheck = async () => {
- try {
- const response = await api.get('/health');
- return response.data;
- } catch (error) {
- throw new Error('API health check failed');
+ return error.response?.data || { success: false, error: 'Failed to fetch trend data' };
}
};
-// Forgot password - request reset link
+// Forgot password function
export const forgotPassword = async (email) => {
try {
const response = await api.post('/auth/forgot-password', { email });
return response.data;
} catch (error) {
- console.error('Forgot password error:', error.response?.data);
- throw new Error(
- error.response?.data?.message ||
- error.response?.data?.details ||
- error.response?.data?.error ||
- 'Failed to send reset link'
- );
+ return error.response?.data || { success: false, error: 'Failed to send reset email' };
}
};
-// Reset password using token
-export const resetPassword = async (token, password) => {
+// Reset password function
+export const resetPassword = async (token, newPassword) => {
try {
- const response = await api.post(`/auth/reset-password/${token}`, { password });
+ const response = await api.post('/auth/reset-password', { token, newPassword });
return response.data;
} catch (error) {
- throw new Error(
- error.response?.data?.error || 'Failed to reset password'
- );
+ return error.response?.data || { success: false, error: 'Failed to reset password' };
}
-};
\ No newline at end of file
+};
+
+export default api;
\ No newline at end of file
diff --git a/server/models/Report.js b/server/models/Report.js
index a03fa89..0cd0986 100644
--- a/server/models/Report.js
+++ b/server/models/Report.js
@@ -5,29 +5,89 @@ const healthParameterSchema = new mongoose.Schema({
type: String,
required: true
},
+ // CHANGED: Support both numeric and text values
value: {
- type: Number,
+ type: mongoose.Schema.Types.Mixed, // Can be Number or String
required: true
},
+ // CHANGED: Made optional for categorical parameters
unit: {
type: String,
- required: true
+ required: false,
+ default: ''
},
+ // CHANGED: Made optional
normalRange: {
type: String,
- required: true
+ required: false,
+ default: 'N/A'
},
+ // CHANGED: Expanded enum to handle more cases
status: {
type: String,
- enum: ['Normal', 'High', 'Low', 'Unknown'],
- required: true
+ enum: ['Normal', 'High', 'Low', 'Abnormal', 'Unknown', 'Present', 'Absent', 'N/A'],
+ default: 'Unknown'
},
category: {
type: String,
required: false
+ },
+ // NEW: Track if this is a categorical or numeric parameter
+ parameterType: {
+ type: String,
+ enum: ['numeric', 'categorical', 'boolean', 'text'],
+ default: 'numeric'
+ },
+ // NEW: For categorical values, store the raw text
+ textValue: {
+ type: String,
+ required: false
}
});
+// NEW: AI Insights Schema
+const outlierSchema = new mongoose.Schema({
+ parameter: String,
+ value: mongoose.Schema.Types.Mixed, // Support both number and string
+ normalRange: String,
+ severity: {
+ type: String,
+ enum: ['Mild', 'Moderate', 'Severe', 'Unknown', 'Low', 'High'] // Added Low/High
+ },
+ concern: String,
+ recommendation: String
+}, { _id: false });
+
+const aiInsightsSchema = new mongoose.Schema({
+ summary: String,
+ outliers: [outlierSchema],
+ recommendations: [String],
+ riskLevel: {
+ type: String,
+ enum: ['Low', 'Moderate', 'High', 'Unknown']
+ },
+ positiveFindings: [String],
+ trendsToMonitor: [String],
+ lifestyle: {
+ diet: [String],
+ exercise: [String],
+ habits: [String]
+ },
+ generatedAt: {
+ type: Date,
+ default: Date.now
+ }
+}, { _id: false });
+
+// NEW: Patient Info Schema (extracted by Gemini)
+const patientInfoSchema = new mongoose.Schema({
+ name: String,
+ age: String,
+ gender: String,
+ testDate: String,
+ hospital: String
+}, { _id: false });
+
const reportSchema = new mongoose.Schema({
userId: {
type: mongoose.Schema.Types.ObjectId,
@@ -41,7 +101,7 @@ const reportSchema = new mongoose.Schema({
extractedText: {
type: String,
required: false,
- default: '' // Allow empty string for unprocessable documents
+ default: ''
},
healthParameters: [healthParameterSchema],
fileSize: {
@@ -52,6 +112,47 @@ const reportSchema = new mongoose.Schema({
type: String,
required: true
},
+
+ // NEW: Extraction method tracking
+ extractionMethod: {
+ type: String,
+ enum: ['gemini', 'ocr', 'manual', 'failed'],
+ default: 'ocr'
+ },
+
+ // NEW: AI-generated insights
+ aiInsights: {
+ type: aiInsightsSchema,
+ required: false
+ },
+
+ // NEW: Patient information (if extracted)
+ patientInfo: {
+ type: patientInfoSchema,
+ required: false
+ },
+
+ // NEW: Gemini metadata
+ geminiMetadata: {
+ model: String,
+ processingTime: Number,
+ confidence: Number,
+ parameterCount: Number
+ },
+
+ // NEW: Extraction log for debugging
+ extractionLog: {
+ attempts: [{
+ method: String,
+ success: Boolean,
+ processingTime: Number
+ }],
+ finalMethod: String,
+ totalTime: Number,
+ error: String
+ },
+
+ // Existing fields
isScannedDocument: {
type: Boolean,
default: false
@@ -64,22 +165,94 @@ const reportSchema = new mongoose.Schema({
type: String,
enum: ['processing', 'completed', 'failed', 'manual_entry_needed', 'manual_entry_completed'],
default: 'completed'
+ },
+
+ // NEW: Track if insights were regenerated
+ insightsRegeneratedAt: {
+ type: Date,
+ required: false
}
}, {
timestamps: true
});
-// Index for faster queries
+// Indexes for faster queries
reportSchema.index({ userId: 1, createdAt: -1 });
+reportSchema.index({ extractionMethod: 1 });
+reportSchema.index({ 'aiInsights.riskLevel': 1 });
-// Virtual for parameter count
-reportSchema.virtual('parameterCount').get(function() {
+// Virtuals
+reportSchema.virtual('parameterCount').get(function () {
return this.healthParameters.length;
});
-// Virtual for abnormal parameters count
-reportSchema.virtual('abnormalCount').get(function() {
- return this.healthParameters.filter(p => p.status !== 'Normal').length;
+reportSchema.virtual('abnormalCount').get(function () {
+ return this.healthParameters.filter(p =>
+ ['High', 'Low', 'Abnormal'].includes(p.status)
+ ).length;
+});
+
+reportSchema.virtual('hasAiInsights').get(function () {
+ return !!this.aiInsights && !!this.aiInsights.summary;
});
-module.exports = mongoose.model('Report', reportSchema);
+reportSchema.virtual('outlierCount').get(function () {
+ return this.aiInsights?.outliers?.length || 0;
+});
+
+// Methods
+reportSchema.methods.needsInsightsUpdate = function () {
+ // Check if insights are older than 30 days or don't exist
+ if (!this.aiInsights || !this.aiInsights.generatedAt) {
+ return true;
+ }
+
+ const daysSinceGeneration = (Date.now() - this.aiInsights.generatedAt) / (1000 * 60 * 60 * 24);
+ return daysSinceGeneration > 30;
+};
+
+reportSchema.methods.getExtractionQuality = function () {
+ const quality = {
+ method: this.extractionMethod,
+ hasParameters: this.healthParameters.length > 0,
+ hasInsights: this.hasAiInsights,
+ parametersComplete: this.healthParameters.every(p =>
+ p.name && p.value !== null && p.value !== undefined
+ ),
+ processingTime: this.extractionLog?.totalTime || this.geminiMetadata?.processingTime
+ };
+
+ // Calculate quality score (0-100)
+ let score = 0;
+ if (quality.method === 'gemini') score += 40;
+ else if (quality.method === 'ocr') score += 20;
+
+ if (quality.hasParameters) score += 30;
+ if (quality.hasInsights) score += 20;
+ if (quality.parametersComplete) score += 10;
+
+ quality.score = score;
+ quality.grade = score >= 80 ? 'Excellent' : score >= 60 ? 'Good' : score >= 40 ? 'Fair' : 'Poor';
+
+ return quality;
+};
+
+// Static methods
+reportSchema.statics.getAnalytics = async function (userId) {
+ const reports = await this.find({ userId }).lean();
+
+ return {
+ totalReports: reports.length,
+ methodBreakdown: {
+ gemini: reports.filter(r => r.extractionMethod === 'gemini').length,
+ ocr: reports.filter(r => r.extractionMethod === 'ocr').length,
+ manual: reports.filter(r => r.extractionMethod === 'manual').length
+ },
+ withInsights: reports.filter(r => r.aiInsights).length,
+ averageParameters: reports.reduce((sum, r) => sum + r.healthParameters.length, 0) / reports.length || 0,
+ highRiskReports: reports.filter(r => r.aiInsights?.riskLevel === 'High').length
+ };
+};
+
+// IMPORTANT: Check if model exists before compiling (fixes nodemon hot-reload issue)
+module.exports = mongoose.models.Report || mongoose.model('Report', reportSchema);
\ No newline at end of file
diff --git a/server/package-lock.json b/server/package-lock.json
index 6c872f5..b264c11 100644
--- a/server/package-lock.json
+++ b/server/package-lock.json
@@ -8,6 +8,7 @@
"name": "health-analyzer-server",
"version": "1.0.0",
"dependencies": {
+ "@google/generative-ai": "^0.24.1",
"bcryptjs": "^2.4.3",
"compromise": "^14.14.4",
"cors": "^2.8.5",
@@ -40,6 +41,15 @@
"tslib": "^2.4.0"
}
},
+ "node_modules/@google/generative-ai": {
+ "version": "0.24.1",
+ "resolved": "https://registry.npmjs.org/@google/generative-ai/-/generative-ai-0.24.1.tgz",
+ "integrity": "sha512-MqO+MLfM6kjxcKoy0p1wRzG3b4ZZXtPI+z2IE26UogS2Cm/XHO+7gGRBh6gcJsOiIVoH93UwKvW4HdgiOZCy9Q==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.34.2",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.2.tgz",
diff --git a/server/package.json b/server/package.json
index 3e14b5b..4e28950 100644
--- a/server/package.json
+++ b/server/package.json
@@ -9,6 +9,7 @@
"build": "echo 'No build step needed for server'"
},
"dependencies": {
+ "@google/generative-ai": "^0.24.1",
"bcryptjs": "^2.4.3",
"compromise": "^14.14.4",
"cors": "^2.8.5",
diff --git a/server/routes/analysis.js b/server/routes/analysis.js
new file mode 100644
index 0000000..d89301f
--- /dev/null
+++ b/server/routes/analysis.js
@@ -0,0 +1,321 @@
+const express = require('express');
+const authMiddleware = require('../utils/authMiddleware');
+const Report = require('../models/Report');
+const { reanalyzeReport, generateTrendAnalysis } = require('../services/geminiService');
+
+const router = express.Router();
+
+/**
+ * POST /api/analysis/:reportId/regenerate
+ * Regenerate AI insights for an existing report
+ * Useful for: updating old reports, getting fresh insights, or when Gemini wasn't used initially
+ */
+router.post('/:reportId/regenerate', authMiddleware, async (req, res) => {
+ try {
+ const report = await Report.findOne({
+ _id: req.params.reportId,
+ userId: req.user.id
+ });
+
+ if (!report) {
+ return res.status(404).json({ error: 'Report not found' });
+ }
+
+ // Check if report has parameters to analyze
+ if (!report.healthParameters || report.healthParameters.length === 0) {
+ return res.status(400).json({
+ error: 'Cannot analyze report with no health parameters',
+ suggestion: 'Please add parameters manually first'
+ });
+ }
+
+ console.log(`🔄 Re-analyzing report ${report._id} with Gemini...`);
+
+ // Use Gemini to regenerate insights
+ const analysisResult = await reanalyzeReport(report);
+
+ if (!analysisResult.success) {
+ return res.status(500).json({
+ error: 'Failed to regenerate insights',
+ details: analysisResult.error
+ });
+ }
+
+ // Update report with new insights
+ report.aiInsights = analysisResult.insights;
+ report.insightsRegeneratedAt = new Date();
+
+ await report.save();
+
+ console.log(`✅ Insights regenerated for report ${report._id}`);
+
+ res.json({
+ success: true,
+ reportId: report._id,
+ insights: analysisResult.insights,
+ regeneratedAt: report.insightsRegeneratedAt
+ });
+
+ } catch (error) {
+ console.error('Failed to regenerate insights:', error);
+ res.status(500).json({
+ error: 'Failed to regenerate insights',
+ details: process.env.NODE_ENV === 'development' ? error.message : undefined
+ });
+ }
+});
+
+/**
+ * GET /api/analysis/:reportId/insights
+ * Get detailed insights for a specific report
+ */
+router.get('/:reportId/insights', authMiddleware, async (req, res) => {
+ try {
+ const report = await Report.findOne({
+ _id: req.params.reportId,
+ userId: req.user.id
+ }).lean();
+
+ if (!report) {
+ return res.status(404).json({ error: 'Report not found' });
+ }
+
+ // If no insights exist, suggest regeneration
+ if (!report.aiInsights) {
+ return res.json({
+ hasInsights: false,
+ message: 'No insights available. Use /regenerate endpoint to generate them.',
+ reportId: report._id,
+ parameterCount: report.healthParameters.length
+ });
+ }
+
+ res.json({
+ hasInsights: true,
+ reportId: report._id,
+ insights: report.aiInsights,
+ generatedAt: report.aiInsights.generatedAt,
+ regeneratedAt: report.insightsRegeneratedAt,
+ extractionMethod: report.extractionMethod
+ });
+
+ } catch (error) {
+ console.error('Failed to get insights:', error);
+ res.status(500).json({ error: 'Failed to get insights' });
+ }
+});
+
+/**
+ * POST /api/analysis/trends
+ * Generate trend analysis across multiple reports
+ * Body: { reportIds: [id1, id2, ...] } or leave empty for all user reports
+ */
+router.post('/trends', authMiddleware, async (req, res) => {
+ try {
+ let reports;
+
+ if (req.body.reportIds && req.body.reportIds.length > 0) {
+ // Analyze specific reports
+ reports = await Report.find({
+ _id: { $in: req.body.reportIds },
+ userId: req.user.id
+ })
+ .sort({ createdAt: 1 }) // Oldest first for trend analysis
+ .lean();
+ } else {
+ // Analyze all user reports
+ reports = await Report.find({ userId: req.user.id })
+ .sort({ createdAt: 1 })
+ .lean();
+ }
+
+ if (reports.length < 2) {
+ return res.status(400).json({
+ error: 'Need at least 2 reports for trend analysis',
+ reportCount: reports.length
+ });
+ }
+
+ console.log(`📊 Generating trend analysis for ${reports.length} reports...`);
+
+ // Use Gemini to analyze trends
+ const trendResult = await generateTrendAnalysis(reports);
+
+ if (!trendResult.success) {
+ return res.status(500).json({
+ error: 'Failed to generate trend analysis',
+ details: trendResult.error
+ });
+ }
+
+ res.json({
+ success: true,
+ reportCount: reports.length,
+ dateRange: trendResult.dateRange,
+ analysis: trendResult.analysis
+ });
+
+ } catch (error) {
+ console.error('Failed to generate trend analysis:', error);
+ res.status(500).json({
+ error: 'Failed to generate trend analysis',
+ details: process.env.NODE_ENV === 'development' ? error.message : undefined
+ });
+ }
+});
+
+/**
+ * GET /api/analysis/summary
+ * Get overall health summary for user based on all reports
+ */
+router.get('/summary', authMiddleware, async (req, res) => {
+ try {
+ const reports = await Report.find({
+ userId: req.user.id,
+ 'healthParameters.0': { $exists: true } // Only reports with parameters
+ })
+ .sort({ createdAt: -1 })
+ .limit(10)
+ .lean();
+
+ if (reports.length === 0) {
+ return res.json({
+ hasReports: false,
+ message: 'No reports available for analysis'
+ });
+ }
+
+ // Calculate summary statistics
+ const latestReport = reports[0];
+ const totalParameters = reports.reduce((sum, r) => sum + r.healthParameters.length, 0);
+ const reportsWithInsights = reports.filter(r => r.aiInsights).length;
+
+ // Get all outliers from reports with insights
+ const allOutliers = reports
+ .filter(r => r.aiInsights?.outliers)
+ .flatMap(r => r.aiInsights.outliers);
+
+ // Get high-risk parameters (appearing multiple times as outliers)
+ const outlierFrequency = {};
+ allOutliers.forEach(o => {
+ outlierFrequency[o.parameter] = (outlierFrequency[o.parameter] || 0) + 1;
+ });
+
+ const persistentOutliers = Object.entries(outlierFrequency)
+ .filter(([_, count]) => count >= 2)
+ .map(([param, count]) => ({ parameter: param, occurrences: count }));
+
+ // Risk assessment
+ const highRiskCount = reports.filter(r => r.aiInsights?.riskLevel === 'High').length;
+ const overallRisk = highRiskCount > 0 ? 'High' :
+ allOutliers.length > 5 ? 'Moderate' : 'Low';
+
+ res.json({
+ hasReports: true,
+ summary: {
+ totalReports: reports.length,
+ latestReportDate: latestReport.createdAt,
+ totalParameters: totalParameters,
+ reportsWithInsights: reportsWithInsights,
+ overallRisk: overallRisk,
+ persistentOutliers: persistentOutliers,
+ latestOutliers: latestReport.aiInsights?.outliers || [],
+ latestRecommendations: latestReport.aiInsights?.recommendations || []
+ },
+ latestReport: {
+ id: latestReport._id,
+ date: latestReport.createdAt,
+ parameterCount: latestReport.healthParameters.length,
+ riskLevel: latestReport.aiInsights?.riskLevel,
+ extractionMethod: latestReport.extractionMethod
+ }
+ });
+
+ } catch (error) {
+ console.error('Failed to generate summary:', error);
+ res.status(500).json({ error: 'Failed to generate summary' });
+ }
+});
+
+/**
+ * GET /api/analysis/compare
+ * Compare two specific reports
+ * Query: ?report1=id&report2=id
+ */
+router.get('/compare', authMiddleware, async (req, res) => {
+ try {
+ const { report1, report2 } = req.query;
+
+ if (!report1 || !report2) {
+ return res.status(400).json({
+ error: 'Please provide both report IDs',
+ example: '/api/analysis/compare?report1=id1&report2=id2'
+ });
+ }
+
+ const reports = await Report.find({
+ _id: { $in: [report1, report2] },
+ userId: req.user.id
+ })
+ .sort({ createdAt: 1 })
+ .lean();
+
+ if (reports.length !== 2) {
+ return res.status(404).json({ error: 'One or both reports not found' });
+ }
+
+ // Compare parameters
+ const [older, newer] = reports;
+ const comparison = {
+ olderReport: {
+ id: older._id,
+ date: older.createdAt,
+ parameterCount: older.healthParameters.length
+ },
+ newerReport: {
+ id: newer._id,
+ date: newer.createdAt,
+ parameterCount: newer.healthParameters.length
+ },
+ changes: []
+ };
+
+ // Find common parameters and calculate changes
+ older.healthParameters.forEach(oldParam => {
+ const newParam = newer.healthParameters.find(p =>
+ p.name.toLowerCase() === oldParam.name.toLowerCase()
+ );
+
+ if (newParam) {
+ const change = newParam.value - oldParam.value;
+ const percentChange = ((change / oldParam.value) * 100).toFixed(1);
+
+ comparison.changes.push({
+ parameter: oldParam.name,
+ oldValue: oldParam.value,
+ newValue: newParam.value,
+ change: change,
+ percentChange: parseFloat(percentChange),
+ unit: oldParam.unit,
+ trend: change > 0 ? 'increased' : change < 0 ? 'decreased' : 'stable',
+ statusChange: {
+ old: oldParam.status,
+ new: newParam.status,
+ improved: (oldParam.status !== 'Normal' && newParam.status === 'Normal')
+ }
+ });
+ }
+ });
+
+ // Sort by absolute change
+ comparison.changes.sort((a, b) => Math.abs(b.percentChange) - Math.abs(a.percentChange));
+
+ res.json(comparison);
+
+ } catch (error) {
+ console.error('Failed to compare reports:', error);
+ res.status(500).json({ error: 'Failed to compare reports' });
+ }
+});
+
+module.exports = router;
\ No newline at end of file
diff --git a/server/routes/reports.js b/server/routes/reports.js
index bada21e..c3ac27c 100644
--- a/server/routes/reports.js
+++ b/server/routes/reports.js
@@ -27,9 +27,9 @@ router.get('/:id', authMiddleware, async (req, res) => {
_id: req.params.id,
userId: req.user.id
})
- .select('-extractedText')
- .lean();
-
+ .select('-extractedText')
+ .lean();
+
if (!report) {
return res.status(404).json({ error: 'Report not found' });
}
@@ -48,14 +48,14 @@ router.get('/:id/trends', authMiddleware, async (req, res) => {
_id: req.params.id,
userId: req.user.id
}).lean();
-
+
if (!currentReport) {
return res.status(404).json({ error: 'Report not found' });
}
// Generate trend data with dummy historical data
const trendData = generateTrendData(currentReport.healthParameters);
-
+
res.json(trendData);
} catch (error) {
console.error('Failed to generate trend data');
@@ -70,14 +70,14 @@ router.delete('/:id', authMiddleware, async (req, res) => {
_id: req.params.id,
userId: req.user.id
});
-
+
if (!report) {
return res.status(404).json({ error: 'Report not found' });
}
- res.json({
- success: true,
- message: 'Report deleted successfully'
+ res.json({
+ success: true,
+ message: 'Report deleted successfully'
});
} catch (error) {
console.error('Failed to delete report');
diff --git a/server/routes/upload.js b/server/routes/upload.js
index fe5b966..a2a34d9 100644
--- a/server/routes/upload.js
+++ b/server/routes/upload.js
@@ -1,457 +1,202 @@
const express = require('express');
const multer = require('multer');
-const pdfParse = require('pdf-parse');
-const Tesseract = require('tesseract.js');
-const sharp = require('sharp');
const authMiddleware = require('../utils/authMiddleware');
const Report = require('../models/Report');
-const { extractHealthParameters } = require('../utils/parameterExtractor');
+const { extractHealthData, validateExtractionResults, getExtractionStats } = require('../services/extractionService');
-//const pdfjsLib=require('pdfjs-dist');
const router = express.Router();
-// extra helper to clean and structure OCR text
-function parseHealthParameters(text){
- const lines=text.split('\n').map(l=>l.trim()).filter(Boolean);
- const parameters=[];
- lines.forEach(line=>{
- //match patterns
- const match=line.match(/([A-Za-z ]+)[\:\-\=]\s*(\d+\.?\d*)\s*([A-Za-z\/%]*)?/);
- if(match){
- parameters.push({
- name:match[1].trim(),
- value:match[2],
- unit:match[3],
- normalRange:'N/A',
- status:'UNKNOWN',
- category:'General'
- });
- }
- });
- return parameters;
-}
-
-// Configure multer for in-memory file uploads (no disk storage)
+// Configure multer
const upload = multer({
- storage: multer.memoryStorage(), // Store files in memory instead of disk
- limits: {
- fileSize: 10 * 1024 * 1024 // 10MB limit
- },
- fileFilter: function (req, file, cb) {
- const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'application/pdf'];
-
- if (allowedTypes.includes(file.mimetype)) {
- return cb(null, true);
+ storage: multer.memoryStorage(),
+ limits: { fileSize: 10 * 1024 * 1024 }, // 10MB
+ fileFilter: (req, file, cb) => {
+ const allowed = ['image/jpeg', 'image/jpg', 'image/png', 'application/pdf'];
+ if (allowed.includes(file.mimetype)) {
+ cb(null, true);
} else {
cb(new Error('Only PDF and image files are allowed'));
}
}
});
-// Upload and process file endpoint
+/**
+ * POST /api/upload
+ * Upload and process medical report
+ * Uses Gemini (primary) → OCR (fallback) → Manual entry
+ */
router.post('/', authMiddleware, upload.single('file'), async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded' });
}
- const fileBuffer = req.file.buffer; // File is now in memory as buffer
- let extractedText = '';
-
- try {
- // Extract text based on file type
- if (req.file.mimetype === 'application/pdf') {
- extractedText = await extractTextFromPDFBuffer(fileBuffer);
- } else {
- extractedText = await extractTextFromImageBuffer(fileBuffer);
- }
+ console.log(`\nProcessing upload: ${req.file.originalname} (${req.file.mimetype})`);
+
+ // Extract options from query/body
+ const options = {
+ forceMethod: req.query.method || req.body.method || null, // 'gemini', 'ocr', or null
+ preferGemini: req.query.preferGemini !== 'false', // Default true
+ includeInsights: req.query.insights !== 'false' // Default true
+ };
+
+ // Extract health data using hybrid service
+ const extractionResult = await extractHealthData(
+ req.file.buffer,
+ req.file.mimetype,
+ options
+ );
+
+ // Validate extraction results
+ const validation = validateExtractionResults(extractionResult);
+ if (!validation.isValid && !extractionResult.requiresManualEntry) {
+ console.warn('⚠️ Extraction validation issues:', validation.issues);
+ }
- // Allow processing with minimal or no text, but warn the user
- const hasMinimalText = extractedText && extractedText.trim().length > 0 && extractedText.trim().length < 50;
- const hasNoText = !extractedText || extractedText.trim().length === 0;
-
- let isScannedDocument = false;
-
- if (hasNoText) {
- console.log('No text extracted from file'); // For scanned documents with no text, offer manual entry instead of blocking
- isScannedDocument = true;
- extractedText = ' '; // Use a space character to avoid validation errors
- } else if (hasMinimalText) {
- console.log('Minimal text extracted, likely a scanned document');
- isScannedDocument = true;
+ // Get extraction statistics for logging
+ const stats = getExtractionStats(extractionResult);
+ console.log('📊 Extraction stats:', stats);
+
+ // Prepare report data
+ const reportData = {
+ userId: req.user.id,
+ filename: req.file.originalname,
+ fileType: req.file.mimetype,
+ fileSize: req.file.size,
+ extractedText: extractionResult.extractedText || ' ',
+ healthParameters: extractionResult.healthParameters || [],
+ extractionMethod: extractionResult.method,
+ isScannedDocument: extractionResult.isScannedDocument || false,
+ requiresManualEntry: extractionResult.requiresManualEntry || false,
+ processingStatus: extractionResult.requiresManualEntry ? 'manual_entry_needed' : 'completed',
+ extractionLog: extractionResult.extractionLog,
+ createdAt: new Date()
+ };
+
+ // Add Gemini-specific data if available
+ if (extractionResult.method === 'gemini') {
+ if (extractionResult.aiInsights) {
+ reportData.aiInsights = extractionResult.aiInsights;
}
-
- console.log('Extracted text length:', extractedText.length);
- if (extractedText.length > 0) {
- console.log('First 500 characters:', extractedText.substring(0, 500));
+ if (extractionResult.patientInfo) {
+ reportData.patientInfo = extractionResult.patientInfo;
}
-
- // Extract health parameters from the text
- const healthParameters = extractedText.length > 0 ? extractHealthParameters(extractedText) : [];
-
- //fallback parser if nothing found
- if(healthParameters.length===0){
- const parsedParams=parseHealthParameters(extractedText);
- if(parsedParams.length>0){
- console.log('Fallback parser extracted parameters:',parsedParams);
- healthParameters.push(...parsedParams);
- }
+ if (extractionResult.metadata) {
+ reportData.geminiMetadata = extractionResult.metadata;
}
- console.log('Extracted parameters:', healthParameters);
+ } else if (extractionResult.aiInsights) {
+ // OCR with basic insights
+ reportData.aiInsights = extractionResult.aiInsights;
+ }
-
- // If no parameters found and it's not a scanned document, reject
- if ((!healthParameters || healthParameters.length === 0) && !isScannedDocument) {
- console.log('No health parameters found in extracted text');
- return res.status(400).json({
- error: 'No health parameters found in the document. Please ensure this is a valid lab report.'
- });
- }
-
- // For scanned documents with no parameters, we'll store it anyway but mark it
- const isEmptyReport = healthParameters.length === 0;
+ // Save report to database
+ const report = new Report(reportData);
+ const savedReport = await report.save();
+
+ console.log(`✅ Report saved: ${savedReport._id} (${extractionResult.method})`);
+
+ // Prepare response based on extraction method
+ const response = {
+ success: true,
+ reportId: savedReport._id,
+ filename: req.file.originalname,
+ extractionMethod: extractionResult.method,
+ healthParameters: extractionResult.healthParameters,
+ extractedParameterCount: extractionResult.healthParameters?.length || 0,
+ processingTime: stats.processingTime
+ };
+
+ // Add insights if available
+ if (extractionResult.aiInsights) {
+ response.aiInsights = {
+ summary: extractionResult.aiInsights.summary,
+ riskLevel: extractionResult.aiInsights.riskLevel,
+ outlierCount: extractionResult.aiInsights.outliers?.length || 0,
+ recommendationCount: extractionResult.aiInsights.recommendations?.length || 0
+ };
+ }
- // Save report to database (without file path since we don't store files)
- const report = new Report({
- userId: req.user.id,
- filename: req.file.originalname,
- fileType: req.file.mimetype,
- fileSize: req.file.size,
- extractedText: extractedText,
- healthParameters: healthParameters,
- isScannedDocument: isScannedDocument,
- requiresManualEntry: isEmptyReport,
- createdAt: new Date()
- });
+ // Add warnings/messages based on extraction result
+ if (extractionResult.requiresManualEntry) {
+ response.requiresManualEntry = true;
+ response.message = extractionResult.method === 'failed'
+ ? "Could not automatically extract data. Please enter data manually."
+ : "Limited data extracted. You may need to verify and complete manually.";
+ } else if (extractionResult.isScannedDocument) {
+ response.isScannedDocument = true;
+ response.message = `Extracted ${response.extractedParameterCount} parameters from scanned document. Please verify accuracy.`;
+ } else {
+ response.message = extractionResult.method === 'gemini'
+ ? `Successfully processed with AI. Found ${response.extractedParameterCount} parameters with insights.`
+ : `Successfully processed with OCR. Found ${response.extractedParameterCount} parameters.`;
+ }
- const savedReport = await report.save();
+ // Add extraction log for debugging (only in development)
+ if (process.env.NODE_ENV === 'development') {
+ response.extractionLog = extractionResult.extractionLog;
+ }
- // Send appropriate response based on document type
- if (isScannedDocument) {
- res.json({
- success: true,
- reportId: savedReport._id,
- filename: req.file.originalname,
- isScannedDocument: true,
- healthParameters: healthParameters,
- extractedParameterCount: healthParameters.length,
- requiresManualEntry: isEmptyReport,
- message: isEmptyReport ?
- "This appears to be a scanned document. No health parameters were automatically detected. You may need to enter data manually." :
- "This appears to be a scanned document. Some health parameters were detected, but you may need to verify and complete the data."
- });
- } else {
- res.json({
- success: true,
- reportId: savedReport._id,
- filename: req.file.originalname,
- healthParameters: healthParameters,
- extractedParameterCount: healthParameters.length
- });
- }
+ res.json(response);
+
+ } catch (error) {
+ console.error('❌ Upload processing error:', error);
- } catch (extractionError) {
- console.error('File processing error:', extractionError);
-
- // Instead of throwing the error, handle it gracefully
- // by setting empty text and marking as requiring manual entry
- extractedText = ' '; // Use a space to avoid empty string validation errors
- isScannedDocument = true;
- isEmptyReport = true;
-
- // Continue with report creation despite extraction failure
- const report = new Report({
+ // Try to save a failed report for tracking
+ try {
+ const failedReport = new Report({
userId: req.user.id,
filename: req.file.originalname,
fileType: req.file.mimetype,
fileSize: req.file.size,
- extractedText: extractedText,
+ extractedText: ' ',
healthParameters: [],
+ extractionMethod: 'failed',
isScannedDocument: true,
requiresManualEntry: true,
- processingStatus: 'manual_entry_needed',
+ processingStatus: 'failed',
+ extractionLog: {
+ error: error.message,
+ totalTime: 0
+ },
createdAt: new Date()
});
-
- try {
- const savedReport = await report.save();
-
- return res.json({
- success: true,
- reportId: savedReport._id,
- filename: req.file.originalname,
- isScannedDocument: true,
- healthParameters: [],
- extractedParameterCount: 0,
- requiresManualEntry: true,
- message: "We couldn't process this document automatically. You'll need to enter the data manually."
- });
- } catch (saveError) {
- console.error('Failed to save report after extraction error:', saveError);
- return res.status(500).json({
- error: 'Failed to process and save the report. Please try again.'
- });
- }
+
+ const savedReport = await failedReport.save();
+
+ return res.status(200).json({
+ success: true,
+ reportId: savedReport._id,
+ filename: req.file.originalname,
+ requiresManualEntry: true,
+ healthParameters: [],
+ extractedParameterCount: 0,
+ message: 'Processing failed. Please enter data manually.',
+ error: process.env.NODE_ENV === 'development' ? error.message : undefined
+ });
+
+ } catch (saveError) {
+ console.error('❌ Failed to save error report:', saveError);
}
- } catch (error) {
- console.error('Upload processing error:', error);
- res.status(500).json({
- error: error.message || 'Failed to process uploaded file'
+ res.status(500).json({
+ error: 'Failed to process uploaded file',
+ details: process.env.NODE_ENV === 'development' ? error.message : undefined
});
}
});
-// Helper function to extract text from PDF buffer
-async function extractTextFromPDFBuffer(buffer) {
+/**
+ * GET /api/upload/stats
+ * Get upload/extraction statistics for the user
+ */
+router.get('/stats', authMiddleware, async (req, res) => {
try {
- const data = await pdfParse(buffer);
- console.log('PDF text extraction successful, length:', data.text.length);
-
- // If PDF has almost no text, it's probably a scanned document
- if (data.text.trim().length < 10) {
- console.log('PDF appears to be scanned/image-based with minimal text content.');
- console.log('Attempting to use OCR on the PDF...');
-
- try {
- // Skip PDFDocument loading to save memory - go straight to OCR
- console.log('Attempting image-based OCR for scanned PDF');
-
- try {
- // Process directly without additional PDF parsing
- const ocrText = await extractTextFromImageBuffer(buffer);
- if (ocrText && ocrText.trim().length > 0) {
- return ocrText;
- } else {
- // If OCR didn't find anything, return a space to avoid validation errors
- return ' ';
- }
- } catch (innerOcrError) {
- console.error('PDF OCR extraction failed:', innerOcrError);
- return ' '; // Return space to avoid validation errors
- }
- } catch (ocrError) {
- console.error('PDF OCR fallback failed:', ocrError);
- return ' '; // Return space to avoid validation errors
- }
- }
-
- // Return the text, ensuring it's not empty
- return data.text || ' ';
+ const analytics = await Report.getAnalytics(req.user.id);
+ res.json(analytics);
} catch (error) {
- console.error('PDF extraction error:', error);
- // Return a space instead of throwing an error
- return ' ';
+ console.error('Failed to get upload stats:', error);
+ res.status(500).json({ error: 'Failed to get statistics' });
}
-}
-
-// Helper function to extract text from image buffer using Tesseract OCR
-async function extractTextFromImageBuffer(buffer) {
- try {
- console.log('Starting OCR processing...');
-
- const processedBuffer=await sharp(buffer)
- .resize({width:2500,height:2500,fit:'inside',withoutEnlargement:true})
- .grayscale()
- .normalize()
- .sharpen()
- .threshold(120)
- .toBuffer();
- let bestText='';
- let bestScore=0;
- const {data:{text,confidence}}=await Tesseract.recognize(processedBuffer,'eng',{
- logger:m=>{
- if(m.status==='recognizing text' && m.progress===0){
- console.log('OCR started with enhanced preprocessing...');
- }
- }
- });
- //simple scoring
- const charCount=text.length;
- const medicalTerms=(text.match(/(glucose|cholesterol|hemoglobin|mg\/dl|mmol\/l)/gi)||[]).length;
- const score=(charCount*0.1)+(confidence*0.3)+(medicalTerms*5);
- if(score>bestScore){
- bestScore=score;
- bestText=text;
- }
-
-
- // Get image metadata for better processing decisions
- const metadata = await sharp(buffer).metadata();
-
- // Determine optimal processing based on image size
- // For large images, we'll use more aggressive downsampling
- const isLargeImage = metadata.width > 2000 || metadata.height > 2000;
- const maxDimension = isLargeImage ? 2000 : Math.max(metadata.width, metadata.height);
-
- // Use fewer preprocessing methods to save time and memory
- const preprocessingMethods = [
- // Method 1: Optimized for medical documents with tables
- {
- name: 'Medical Document',
- process: () => sharp(buffer)
- .resize({
- width: maxDimension,
- height: maxDimension,
- fit: 'inside',
- withoutEnlargement: false
- })
- .grayscale()
- .normalize() // Auto-adjust contrast
- .linear(1.3, -35) // Enhance contrast specifically for text
- .threshold(128) // Clean binary threshold
- .png({ compressionLevel: 6 }) // Use compression to save memory
- .toBuffer()
- },
- // Method 2: Table structure preservation
- {
- name: 'Table Preserving',
- process: () => sharp(buffer)
- .resize({ width: maxDimension, height: maxDimension, fit: 'inside', withoutEnlargement: false })
- .grayscale()
- .sharpen()
- .normalize()
- .modulate({ brightness: 1.15 })
- .linear(1.4, -40) // Strong contrast
- .png({ compressionLevel: 6 })
- .toBuffer()
- },
- // Method 3: Adaptive enhancement for poor quality scans
- {
- name: 'Adaptive Enhancement',
- process: () => sharp(buffer)
- .resize({ width: 3000, height: 3000, fit: 'inside' })
- .grayscale()
- .clahe({ width: 64, height: 64, maxSlope: 3 }) // Local contrast enhancement
- .gamma(1.2)
- .sharpen({ sigma: 1, m1: 0.5, m2: 2, x1: 2, y2: 10, y3: 20 })
- .threshold(110) // Slightly more aggressive threshold
- .negate() // Try inverted
- .negate() // And back to normal
- .png()
- .toBuffer()
- },
- // Method 4: Conservative approach for already clean images
- {
- name: 'Conservative Clean',
- process: () => sharp(buffer)
- .resize({ width: 2500, height: 2500, fit: 'inside', withoutEnlargement: true })
- .grayscale()
- .normalize()
- .sharpen()
- .png({ quality: 95 })
- .toBuffer()
- }
- ];
-
- let bestResult = { text: '', confidence: 0, method: '', processedCount: 0 };
- let allResults = [];
-
- // Try each preprocessing method with optimized OCR configuration
- for (const method of preprocessingMethods) {
- try {
- console.log(`Processing with method: ${method.name}`);
- const processedBuffer = await method.process();
-
- // Use a single optimized OCR config instead of multiple passes
- const ocrConfigs = [
- // Single optimized config for medical/lab reports
- {
- name: 'Optimized',
- options: {
- tessedit_pageseg_mode: Tesseract.PSM.AUTO,
- tessedit_ocr_engine_mode: Tesseract.OEM.LSTM_ONLY,
- tessedit_char_whitelist: '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz.,()-/:% <>',
- preserve_interword_spaces: '1',
- tessedit_write_images: '0'
- }
- }
- ];
-
- for (const config of ocrConfigs) {
- try {
- const { data: { text, confidence } } = await Tesseract.recognize(processedBuffer, 'eng', {
- logger: m => {
- if (m.status === 'recognizing text') {
- // Only log start and completion to reduce console output
- if (m.progress === 0) {
- console.log(`OCR started for ${method.name}`);
- } else if (m.progress >= 0.99) {
- console.log(`OCR completed for ${method.name}`);
- }
- }
- },
- ...config.options
- });
-
- bestResult.processedCount++;
-
- // Enhanced quality scoring
- const charCount = text.length;
- const wordCount = text.split(/\s+/).length;
- const numberCount = (text.match(/\d/g) || []).length;
- const medicalTermCount = (text.match(/(?:cholesterol|glucose|hemoglobin|blood|count|triglyceride|creatinine|bilirubin|protein|albumin|globulin|ratio|urea|sodium|potassium|chloride|test|result|value|reference|range|normal|high|low|mg|dl|mmol|gm)/gi) || []).length;
-
- // Look for table-like patterns
- const tablePatterns = (text.match(/\w+\s*[:\-]\s*\d+/g) || []).length;
- const unitPatterns = (text.match(/\d+\.?\d*\s*(mg\/dl|mmol\/l|g\/dl|%|\w+\/\w+)/gi) || []).length;
-
- // Quality score calculation
- const qualityScore =
- (charCount * 0.1) +
- (confidence * 0.3) +
- (numberCount * 2) +
- (medicalTermCount * 10) +
- (tablePatterns * 8) +
- (unitPatterns * 12) +
- (wordCount > 10 ? 20 : 0) +
- (charCount > 100 ? 30 : 0);
-
- console.log(`[${method.name}-${config.name}] Score: ${qualityScore.toFixed(1)}, Chars: ${charCount}, Medical terms: ${medicalTermCount}, Units: ${unitPatterns}`);
-
- allResults.push({
- text,
- confidence,
- qualityScore,
- method: `${method.name}-${config.name}`,
- stats: { charCount, medicalTermCount, unitPatterns, tablePatterns }
- });
-
- if (qualityScore > bestResult.confidence) {
- bestResult = { text, confidence: qualityScore, method: `${method.name}-${config.name}` };
- }
-
- } catch (configError) {
- console.error(`OCR config ${config.name} failed:`, configError.message);
- continue;
- }
- }
-
- } catch (methodError) {
- console.error(`Preprocessing method ${method.name} failed:`, methodError.message);
- continue;
- }
- }
-
- // Skip desperate fallback attempt to save memory and processing time
- // If we have any reasonable text, just use it
-
- // Simple completion log
- console.log(`OCR processing complete. Text length: ${bestResult.text.length}`);
-
- // Return early if no meaningful text was found
- if (bestResult.text.length < 10) {
- return ' '; // Return space to avoid validation errors
- }
-
- return bestResult.text;
-
- } catch (error) {
- console.error('OCR extraction error:', error);
- // Instead of throwing an error, return a space character
- // This allows the document to be stored with manual entry requirement
- return ' ';
- }
-}
+});
-module.exports = router;
+module.exports = router;
\ No newline at end of file
diff --git a/server/server.js b/server/server.js
index 55860bf..f0bb70d 100644
--- a/server/server.js
+++ b/server/server.js
@@ -12,38 +12,33 @@ const PORT = process.env.PORT || 5001;
// Set server timeout to 5 minutes for OCR processing
app.timeout = 300000;
-//Ensure Express handles large payloads for OCR images
-app.use(express.json({ limit: '20mb' }));
-app.use(express.urlencoded({ extended: true, limit: '20mb' }));
-
-//Ensure Express handles large payloads for OCR images
-app.use(express.json({ limit: '20mb' }));
-app.use(express.urlencoded({ extended: true, limit: '20mb' }));
-
// Configure CORS for frontend communication
const corsOptions = {
- origin: process.env.NODE_ENV === 'production'
+ origin: process.env.NODE_ENV === 'production'
? ['https://health-report-analyzer.vercel.app', 'https://health-report-analyzer.onrender.com']
: ['http://localhost:3000', 'http://localhost:5173'],
credentials: true
};
app.use(cors(corsOptions));
-app.use(express.json({ limit: '10mb' }));
-app.use(express.urlencoded({ extended: true, limit: '10mb' }));
+
+// Ensure Express handles large payloads for OCR images
+app.use(express.json({ limit: '20mb' }));
+app.use(express.urlencoded({ extended: true, limit: '20mb' }));
// API routes
app.use('/api/auth', require('./routes/auth'));
app.use('/api/upload', require('./routes/upload'));
app.use('/api/reports', require('./routes/reports'));
+app.use('/api/analysis', require('./routes/analysis'));
// Health check endpoint
app.get('/api/health', (req, res) => {
const mongoose = require('mongoose');
const dbConnected = mongoose.connection.readyState === 1;
-
- res.json({
+
+ res.json({
message: 'Health Report Analyzer API is running!',
database: dbConnected ? 'Connected' : 'Disconnected',
features: {
diff --git a/server/services/extractionService.js b/server/services/extractionService.js
new file mode 100644
index 0000000..824b620
--- /dev/null
+++ b/server/services/extractionService.js
@@ -0,0 +1,230 @@
+const { extractWithGemini } = require('./geminiService');
+const { extractWithOCR } = require('./ocrService');
+
+/**
+ * Smart hybrid extraction service
+ * Tries Gemini first, falls back to OCR if needed
+ *
+ * This is the MAIN entry point for report extraction
+ */
+
+/**
+ * Extract health data from uploaded file
+ * @param {Buffer} fileBuffer - File buffer
+ * @param {string} mimeType - File MIME type
+ * @param {Object} options - Extraction options
+ * @returns {Promise