diff --git a/client/src/App.jsx b/client/src/App.jsx index b6464d8..09d01c8 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -11,14 +11,12 @@ import { useLocation } from "react-router-dom"; import { ToastContainer, toast } from "react-toastify"; -import AOS from 'aos'; import { refreshAnimations, checkPerformance } from './utils/animationUtils'; import "react-toastify/dist/ReactToastify.css"; import { useTranslation } from 'react-i18next'; import AuthForm from "./components/AuthForm"; import UserProfile from "./components/UserProfile"; import FileUpload from "./components/FileUpload"; -import ReportTable from "./components/ReportTable"; import TrendChart from "./components/TrendChart"; import LoadingSpinner from "./components/LoadingSpinner"; import ForgotPassword from "./components/ForgotPassword"; @@ -33,14 +31,13 @@ import FAQ from "./components/FAQ"; import { FileText, Menu, X, LogOut } from "lucide-react"; import DarkModeToggle from "./components/DarkModeToggle"; import { useLoading } from "./context/LoadingContext.jsx"; -import Stats from "./components/Stats.jsx"; +import { ReportsList, ReportDetail } from "./components/ReportList"; - -// Dashboard Component - Main authenticated app function Dashboard({ user, setUser }) { const { t } = useTranslation(); const navigate = useNavigate(); - const [reportData, setReportData] = useState(null); + const [uploadedReportId, setUploadedReportId] = useState(null); + const [viewingReportId, setViewingReportId] = useState(null); const [trendData, setTrendData] = useState(null); const [error, setError] = useState(null); const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); @@ -51,7 +48,8 @@ function Dashboard({ user, setUser }) { localStorage.removeItem("token"); localStorage.removeItem("user"); setUser(null); - setReportData(null); + setUploadedReportId(null); + setViewingReportId(null); setTrendData(null); setError(null); toast.success(t('toast.logout_success')); @@ -66,7 +64,8 @@ function Dashboard({ user, setUser }) { }; const handleFileProcessed = (data) => { - setReportData(data); + setUploadedReportId(data.reportId || data._id); + setViewingReportId(data.reportId || data._id); setError(null); toast.success(t('toast.upload_success')); }; @@ -77,13 +76,15 @@ function Dashboard({ user, setUser }) { const handleError = (errorMessage) => { setError(errorMessage); - setReportData(null); + setUploadedReportId(null); + setViewingReportId(null); setTrendData(null); toast.error(t('toast.upload_error')); }; const handleReset = () => { - setReportData(null); + setUploadedReportId(null); + setViewingReportId(null); setTrendData(null); setError(null); }; @@ -107,7 +108,6 @@ function Dashboard({ user, setUser }) { - {/* Desktop Navigation */}
{t('nav.home')} @@ -131,13 +131,11 @@ function Dashboard({ user, setUser }) {
- {/* Mobile Hamburger Button */} - {/* Mobile Menu Overlay */} {isMobileMenuOpen && (
e.stopPropagation()}> @@ -147,13 +145,13 @@ function Dashboard({ user, setUser }) {
- +
- - - - - +
@@ -194,62 +192,46 @@ function Dashboard({ user, setUser }) {
- {!reportData && !loading && !error && ( -
-

{t('app.welcome')}, {user.firstName}!

-

{t('dashboard.upload_first')}

-
- )} - {error && ( -
+
{error} -
)} - {!reportData && !loading && ( - + {/* Viewing a specific report */} + {viewingReportId && ( +
+ +
)} - {reportData && ( -
-
-

📊 {t('reports.analysis_complete')}

- -
+ {/* Viewing reports list */} + {!viewingReportId && !uploadedReportId && ( + <> + {!loading && ( +
+

{t('app.welcome')}, {user.firstName}!

+

{t('dashboard.upload_first')}

+
+ )} - + - {trendData && ( - - )} -
+ + )}
-
+
@@ -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(valparseFloat(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} Extraction results + */ +async function extractHealthData(fileBuffer, mimeType, options = {}) { + const { + forceMethod = null, // 'gemini', 'ocr', or null for auto + preferGemini = true, // Use Gemini by default + includeInsights = true // Generate AI insights + } = options; + + console.log('Starting hybrid extraction...'); + console.log(` Method: ${forceMethod || 'auto (Gemini → OCR fallback)'}`); + console.log(` File: ${mimeType}, ${(fileBuffer.length / 1024).toFixed(1)}KB`); + + const extractionLog = { + attempts: [], + finalMethod: null, + totalTime: 0 + }; + + const startTime = Date.now(); + + try { + // Force specific method if requested + if (forceMethod === 'ocr') { + console.log('⚡ Forced OCR extraction'); + const result = await extractWithOCR(fileBuffer, mimeType); + extractionLog.attempts.push({ method: 'ocr', success: result.success }); + extractionLog.finalMethod = 'ocr'; + extractionLog.totalTime = Date.now() - startTime; + + return { + ...result, + extractionLog + }; + } + + if (forceMethod === 'gemini') { + console.log('⚡ Forced Gemini extraction'); + const result = await extractWithGemini(fileBuffer, mimeType); + extractionLog.attempts.push({ method: 'gemini', success: result.success }); + + if (!result.success) { + throw new Error('Gemini extraction failed (forced mode)'); + } + + extractionLog.finalMethod = 'gemini'; + extractionLog.totalTime = Date.now() - startTime; + + return { + ...result, + extractionLog + }; + } + + // AUTO MODE: Try Gemini first, fallback to OCR + if (preferGemini && process.env.GEMINI_API_KEY) { + console.log('Attempting Gemini extraction (primary)...'); + + const geminiResult = await extractWithGemini(fileBuffer, mimeType); + extractionLog.attempts.push({ + method: 'gemini', + success: geminiResult.success, + processingTime: geminiResult.metadata?.processingTime + }); + + // If Gemini succeeds and found parameters, use it + if (geminiResult.success && geminiResult.healthParameters.length > 0) { + console.log('✅ Gemini extraction successful!'); + extractionLog.finalMethod = 'gemini'; + extractionLog.totalTime = Date.now() - startTime; + + return { + ...geminiResult, + extractionLog + }; + } + + // If Gemini failed or found nothing, try OCR + console.log('⚠️ Gemini found no parameters, falling back to OCR...'); + } else { + console.log('⚠️ Gemini API key not configured, using OCR directly'); + } + + // Fallback to OCR + console.log('Attempting OCR extraction (fallback)...'); + const ocrResult = await extractWithOCR(fileBuffer, mimeType); + extractionLog.attempts.push({ + method: 'ocr', + success: ocrResult.success, + processingTime: ocrResult.metadata?.processingTime + }); + + if (!ocrResult.success) { + throw new Error('Both Gemini and OCR extraction failed'); + } + + console.log('✅ OCR extraction successful!'); + extractionLog.finalMethod = 'ocr'; + extractionLog.totalTime = Date.now() - startTime; + + // OCR doesn't provide insights, so add empty structure + return { + ...ocrResult, + aiInsights: includeInsights ? generateBasicInsights(ocrResult.healthParameters) : null, + extractionLog + }; + + } catch (error) { + console.error('❌ All extraction methods failed:', error.message); + extractionLog.totalTime = Date.now() - startTime; + extractionLog.error = error.message; + + // Return failure result that allows manual entry + return { + success: false, + method: 'failed', + extractedText: ' ', + healthParameters: [], + isScannedDocument: true, + requiresManualEntry: true, + error: error.message, + extractionLog + }; + } +} + +/** + * Generate basic insights when OCR is used (no AI) + * This provides minimal insights for OCR extractions + */ +function generateBasicInsights(healthParameters) { + const outliers = healthParameters.filter(p => + p.status === 'High' || p.status === 'Low' + ); + + const recommendations = []; + if (outliers.length > 0) { + recommendations.push('Some parameters are outside normal range - consult your healthcare provider'); + } else if (healthParameters.length > 0) { + recommendations.push('All measured parameters appear to be within normal ranges'); + } + + return { + summary: outliers.length > 0 + ? `${outliers.length} parameter(s) outside normal range detected` + : 'All parameters within normal ranges', + outliers: outliers.map(p => ({ + parameter: p.name, + value: p.value, + normalRange: p.normalRange, + severity: 'Unknown', + concern: `${p.name} is ${p.status.toLowerCase()}` + })), + recommendations: recommendations, + riskLevel: outliers.length > 2 ? 'Moderate' : outliers.length > 0 ? 'Low' : 'Low', + positiveFindings: healthParameters + .filter(p => p.status === 'Normal') + .map(p => `${p.name} is within normal range`) + }; +} + +/** + * Validate extraction results + * Ensures data quality before saving to database + */ +function validateExtractionResults(results) { + const issues = []; + + if (!results.success) { + issues.push('Extraction not successful'); + } + + if (!results.healthParameters || results.healthParameters.length === 0) { + if (!results.requiresManualEntry) { + issues.push('No parameters extracted but manual entry not flagged'); + } + } + + // Validate each parameter + results.healthParameters?.forEach((param, index) => { + if (!param.name || param.name.trim() === '') { + issues.push(`Parameter ${index}: Missing name`); + } + if (param.value === null || param.value === undefined || isNaN(param.value)) { + issues.push(`Parameter ${index} (${param.name}): Invalid value`); + } + }); + + return { + isValid: issues.length === 0, + issues: issues + }; +} + +/** + * Get extraction statistics for monitoring + */ +function getExtractionStats(results) { + return { + method: results.method, + success: results.success, + parameterCount: results.healthParameters?.length || 0, + processingTime: results.metadata?.processingTime || results.extractionLog?.totalTime, + hasInsights: !!results.aiInsights, + requiresManualEntry: results.requiresManualEntry, + attemptedMethods: results.extractionLog?.attempts?.map(a => a.method) || [] + }; +} + +module.exports = { + extractHealthData, + validateExtractionResults, + getExtractionStats, + generateBasicInsights +}; \ No newline at end of file diff --git a/server/services/geminiService.js b/server/services/geminiService.js new file mode 100644 index 0000000..d5d8345 --- /dev/null +++ b/server/services/geminiService.js @@ -0,0 +1,445 @@ +const { GoogleGenerativeAI } = require('@google/generative-ai'); + +// Initialize Gemini API +const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY); + +/** + * Extracts health parameters and generates insights from medical report image/PDF + * @param {Buffer} fileBuffer - The file buffer (image or PDF) + * @param {string} mimeType - File MIME type + * @returns {Promise} Extracted data with insights + */ +async function extractWithGemini(fileBuffer, mimeType) { + const startTime = Date.now(); + + try { + console.log('Starting Gemini extraction...'); + + // Use Gemini Flash for cost-efficiency + const model = genAI.getGenerativeModel({ model: 'gemini-2.5-flash' }); + + // Convert buffer to base64 for Gemini + const base64Data = fileBuffer.toString('base64'); + + // UPDATED PROMPT: Support both numeric and categorical data + const prompt = `You are a medical lab report analyzer. Extract ALL health parameters from this lab report image. + +IMPORTANT INSTRUCTIONS: +1. Extract EVERY parameter you can find (blood tests, vitals, medical history, symptoms, diagnoses, etc.) +2. For NUMERIC parameters (lab values, vitals): provide value as a number +3. For CATEGORICAL/TEXT parameters (symptoms, conditions, history): provide value as 0 and include the actual text in "textValue" +4. Determine status: "Normal", "High", "Low", "Abnormal", or "Unknown" +5. Set parameterType: "numeric" for numbers, "categorical" for yes/no/conditions, "text" for descriptions +6. Extract patient information if visible +7. Provide medical insights with outliers and recommendations + +Return ONLY valid JSON in this EXACT format (no markdown, no code blocks): +{ + "patientInfo": { + "name": "patient name or null", + "age": "age or null", + "gender": "gender or null", + "testDate": "date or null", + "hospital": "hospital name or null" + }, + "parameters": [ + { + "name": "Parameter Name", + "value": 123.45, + "unit": "mg/dL", + "normalRange": "70-100", + "status": "Normal", + "category": "Lipid Panel", + "parameterType": "numeric", + "textValue": null + }, + { + "name": "Hypertension Status", + "value": 0, + "unit": "", + "normalRange": "N/A", + "status": "Abnormal", + "category": "Medical History", + "parameterType": "categorical", + "textValue": "Diagnosed 3 years ago, managing with medication" + } + ], + "insights": { + "summary": "Brief overall health summary", + "outliers": [ + { + "parameter": "Cholesterol", + "value": 250, + "normalRange": "125-200", + "severity": "Moderate", + "concern": "Elevated cholesterol increases cardiovascular risk", + "recommendation": "Consult cardiologist for management plan" + } + ], + "recommendations": [ + "Consult doctor about elevated cholesterol", + "Consider dietary modifications" + ], + "riskLevel": "Low", + "positiveFindings": [ + "Blood pressure within optimal range" + ] + }, + "extractedText": "full text content from the report", + "confidence": 0.95 +} + +VALID STATUS VALUES: "Normal", "High", "Low", "Abnormal", "Unknown" +VALID SEVERITY VALUES: "Mild", "Moderate", "Severe" +VALID RISK LEVELS: "Low", "Moderate", "High" +VALID PARAMETER TYPES: "numeric", "categorical", "text", "boolean"`; + + // Generate content with image + const result = await model.generateContent([ + prompt, + { + inlineData: { + mimeType: mimeType, + data: base64Data + } + } + ]); + + const response = await result.response; + const text = response.text(); + + console.log('Gemini raw response length:', text.length); + + // Parse JSON response (handle potential markdown code blocks) + let jsonText = text.trim(); + + // Remove markdown code blocks if present + if (jsonText.startsWith('```json')) { + jsonText = jsonText.replace(/```json\n?/g, '').replace(/```\n?/g, ''); + } else if (jsonText.startsWith('```')) { + jsonText = jsonText.replace(/```\n?/g, ''); + } + + const parsedData = JSON.parse(jsonText); + + // Validate the response structure + if (!parsedData.parameters || !Array.isArray(parsedData.parameters)) { + throw new Error('Invalid response structure from Gemini'); + } + + const processingTime = Date.now() - startTime; + console.log(`✅ Gemini extraction successful: ${parsedData.parameters.length} parameters in ${processingTime}ms`); + + // Transform and sanitize parameters + const healthParameters = parsedData.parameters.map(p => { + const param = { + name: p.name || 'Unknown', + unit: p.unit || '', + normalRange: p.normalRange || 'N/A', + category: p.category || 'General', + parameterType: p.parameterType || 'numeric' + }; + + // Handle numeric vs categorical values + if (p.parameterType === 'categorical' || p.parameterType === 'text' || p.parameterType === 'boolean') { + // For categorical/text, store the text and use 0 as placeholder + param.value = 0; + param.textValue = p.textValue || String(p.value) || 'N/A'; + } else { + // For numeric values, parse as number + const numValue = parseFloat(p.value); + param.value = isNaN(numValue) ? 0 : numValue; + param.textValue = p.textValue || null; + } + + // Sanitize status to match enum + param.status = sanitizeStatus(p.status); + + return param; + }); + + // Sanitize insights + const insights = parsedData.insights || {}; + const aiInsights = { + summary: insights.summary || '', + outliers: (insights.outliers || []).map(o => ({ + parameter: o.parameter || '', + value: parseFloat(o.value) || 0, + normalRange: o.normalRange || '', + severity: sanitizeSeverity(o.severity), + concern: o.concern || '', + recommendation: o.recommendation || '' + })), + recommendations: insights.recommendations || [], + riskLevel: sanitizeRiskLevel(insights.riskLevel), + positiveFindings: insights.positiveFindings || [], + trendsToMonitor: insights.trendsToMonitor || [], + lifestyle: insights.lifestyle || {} + }; + + // Return standardized format + return { + success: true, + method: 'gemini', + extractedText: parsedData.extractedText || '', + patientInfo: parsedData.patientInfo || {}, + healthParameters: healthParameters, + aiInsights: aiInsights, + metadata: { + model: 'gemini-2.5-flash', + processingTime: processingTime, + confidence: parsedData.confidence || 0.8, + parameterCount: healthParameters.length + } + }; + + } catch (error) { + console.error('❌ Gemini extraction failed:', error.message); + + // Return structured error for fallback handling + return { + success: false, + method: 'gemini', + error: error.message, + processingTime: Date.now() - startTime + }; + } +} + +/** + * Sanitize status values to match schema enum + */ +function sanitizeStatus(status) { + if (!status) return 'Unknown'; + + const normalized = String(status).toLowerCase().trim(); + + // Map various inputs to valid enum values + if (normalized.includes('normal') || normalized === 'within range') return 'Normal'; + if (normalized.includes('high') || normalized.includes('elevated')) return 'High'; + if (normalized.includes('low') || normalized.includes('decreased')) return 'Low'; + if (normalized.includes('abnormal') || normalized.includes('diagnosed')) return 'Abnormal'; + if (normalized.includes('present')) return 'Present'; + if (normalized.includes('absent') || normalized.includes('none')) return 'Absent'; + + return 'Unknown'; +} + +/** + * Sanitize severity values to match schema enum + */ +function sanitizeSeverity(severity) { + if (!severity) return 'Unknown'; + + const normalized = String(severity).toLowerCase().trim(); + + if (normalized === 'mild' || normalized === 'low') return 'Mild'; + if (normalized === 'moderate') return 'Moderate'; + if (normalized === 'severe' || normalized === 'high' || normalized === 'critical') return 'Severe'; + + return 'Unknown'; +} + +/** + * Sanitize risk level to match schema enum + */ +function sanitizeRiskLevel(riskLevel) { + if (!riskLevel) return 'Unknown'; + + const normalized = String(riskLevel).toLowerCase().trim(); + + if (normalized === 'low') return 'Low'; + if (normalized === 'moderate' || normalized === 'medium') return 'Moderate'; + if (normalized === 'high' || normalized === 'severe') return 'High'; + + return 'Unknown'; +} + +/** + * Re-analyze an existing report to generate new insights + * Useful for updating old reports with new AI capabilities + * @param {Object} report - Existing report from database + * @returns {Promise} New insights + */ +async function reanalyzeReport(report) { + try { + console.log('🔄 Re-analyzing report with Gemini...'); + + const model = genAI.getGenerativeModel({ model: 'gemini-2.5-flash' }); + + const prompt = `You are a medical advisor. Analyze these lab test results and provide insights. + +Lab Results: +${JSON.stringify(report.healthParameters, null, 2)} + +Patient Context: +- Previous reports: ${report.previousReportCount || 0} +- Test date: ${report.createdAt} + +Provide detailed analysis in JSON format: +{ + "summary": "overall health assessment", + "outliers": [ + { + "parameter": "name", + "value": 123, + "normalRange": "range", + "severity": "Mild", + "concern": "medical explanation", + "recommendation": "specific action" + } + ], + "recommendations": [ + "specific actionable recommendations" + ], + "riskLevel": "Low", + "positiveFindings": ["things that are good"], + "trendsToMonitor": ["parameters to watch"], + "lifestyle": { + "diet": ["dietary suggestions"], + "exercise": ["exercise suggestions"], + "habits": ["lifestyle modifications"] + } +} + +VALID SEVERITY: "Mild", "Moderate", "Severe" +VALID RISK LEVEL: "Low", "Moderate", "High"`; + + const result = await model.generateContent(prompt); + const response = await result.response; + let text = response.text().trim(); + + // Clean markdown + if (text.startsWith('```json')) { + text = text.replace(/```json\n?/g, '').replace(/```\n?/g, ''); + } else if (text.startsWith('```')) { + text = text.replace(/```\n?/g, ''); + } + + const insights = JSON.parse(text); + + // Sanitize the insights + const sanitizedInsights = { + summary: insights.summary || '', + outliers: (insights.outliers || []).map(o => ({ + parameter: o.parameter || '', + value: o.value || 0, + normalRange: o.normalRange || '', + severity: sanitizeSeverity(o.severity), + concern: o.concern || '', + recommendation: o.recommendation || '' + })), + recommendations: insights.recommendations || [], + riskLevel: sanitizeRiskLevel(insights.riskLevel), + positiveFindings: insights.positiveFindings || [], + trendsToMonitor: insights.trendsToMonitor || [], + lifestyle: insights.lifestyle || {} + }; + + console.log('✅ Re-analysis complete'); + + return { + success: true, + insights: sanitizedInsights, + analyzedAt: new Date() + }; + + } catch (error) { + console.error('❌ Re-analysis failed:', error.message); + return { + success: false, + error: error.message + }; + } +} + +/** + * Generate comparative analysis between multiple reports + * @param {Array} reports - Array of user reports (sorted by date) + * @returns {Promise} Trend analysis + */ +async function generateTrendAnalysis(reports) { + try { + if (reports.length < 2) { + return { + success: false, + error: 'Need at least 2 reports for trend analysis' + }; + } + + console.log(`📊 Analyzing trends across ${reports.length} reports...`); + + const model = genAI.getGenerativeModel({ model: 'gemini-2.5-flash' }); + + // Prepare data for analysis + const reportData = reports.map(r => ({ + date: r.createdAt, + parameters: r.healthParameters + })); + + const prompt = `You are a medical data analyst. Analyze these lab results over time and identify trends. + +Historical Data: +${JSON.stringify(reportData, null, 2)} + +Provide trend analysis in JSON: +{ + "overallTrend": "improving/stable/declining", + "parameterTrends": [ + { + "parameter": "name", + "trend": "increasing/decreasing/stable", + "changePercent": 15.5, + "concern": "explanation if concerning", + "action": "recommended action" + } + ], + "insights": [ + "key insights about health trajectory" + ], + "warnings": [ + "any concerning patterns" + ], + "recommendations": [ + "long-term health suggestions" + ] +}`; + + const result = await model.generateContent(prompt); + const response = await result.response; + let text = response.text().trim(); + + if (text.startsWith('```json')) { + text = text.replace(/```json\n?/g, '').replace(/```\n?/g, ''); + } else if (text.startsWith('```')) { + text = text.replace(/```\n?/g, ''); + } + + const analysis = JSON.parse(text); + + console.log('✅ Trend analysis complete'); + + return { + success: true, + analysis: analysis, + reportCount: reports.length, + dateRange: { + from: reports[0].createdAt, + to: reports[reports.length - 1].createdAt + } + }; + + } catch (error) { + console.error('❌ Trend analysis failed:', error.message); + return { + success: false, + error: error.message + }; + } +} + +module.exports = { + extractWithGemini, + reanalyzeReport, + generateTrendAnalysis +}; \ No newline at end of file diff --git a/server/services/ocrService.js b/server/services/ocrService.js new file mode 100644 index 0000000..585cf7e --- /dev/null +++ b/server/services/ocrService.js @@ -0,0 +1,265 @@ +const pdfParse = require('pdf-parse'); +const Tesseract = require('tesseract.js'); +const sharp = require('sharp'); +const { extractHealthParameters } = require('../utils/parameterExtractor'); + +/** + * Extract text and parameters using traditional OCR (Tesseract) + * This is the fallback when Gemini fails or is unavailable + */ + +// Auto-rotate detection +async function deskewImage(buffer) { + try { + const { data: { text } } = await Tesseract.recognize(buffer, 'eng', { + logger: () => { }, + tessedit_pageseg_mode: Tesseract.PSM.AUTO_ONLY, + tessedit_ocr_engine_mode: Tesseract.OEM.DEFAULT + }); + + if (text.length < 50) { + const rotations = [90, 180, 270]; + let bestRotation = 0; + let bestLength = text.length; + + for (const angle of rotations) { + const rotatedBuffer = await sharp(buffer).rotate(angle).toBuffer(); + const { data: { text: rotatedText } } = await Tesseract.recognize(rotatedBuffer, 'eng', { + logger: () => { }, + tessedit_pageseg_mode: Tesseract.PSM.AUTO_ONLY + }); + + if (rotatedText.length > bestLength) { + bestLength = rotatedText.length; + bestRotation = angle; + } + } + + if (bestRotation > 0) { + console.log(`Auto-rotating image by ${bestRotation}°`); + return sharp(buffer).rotate(bestRotation).toBuffer(); + } + } + + return buffer; + } catch (error) { + console.warn('Deskew detection failed:', error.message); + return buffer; + } +} + +// Quality scoring +function calculateQualityScore(text, confidence) { + const charCount = text.length; + const medicalTerms = ['glucose', 'cholesterol', 'hemoglobin', 'triglyceride', 'creatinine']; + const medicalTermCount = medicalTerms.filter(term => text.toLowerCase().includes(term)).length; + const unitPatterns = (text.match(/\d+\.?\d*\s*(mg\/dl|mmol\/l|g\/dl|%)/gi) || []).length; + const tablePatterns = (text.match(/\w+\s*[:\-]\s*\d+/g) || []).length; + + const score = { + content: charCount > 100 ? 50 : charCount * 0.3, + structure: tablePatterns * 8, + medicalContent: medicalTermCount * 15, + units: unitPatterns * 12, + confidence: confidence * 0.4 + }; + + return { + total: Object.values(score).reduce((a, b) => a + b, 0), + breakdown: score, + metrics: { charCount, medicalTermCount, unitPatterns, tablePatterns } + }; +} + +// Preprocessing methods +const PREPROCESSING_METHODS = [ + { + name: 'High-Quality Medical', + process: async (buffer) => sharp(buffer) + .resize({ width: 3000, height: 3000, fit: 'inside', withoutEnlargement: false }) + .grayscale() + .normalize() + .linear(1.4, -40) + .sharpen({ sigma: 1.5 }) + .threshold(120) + .png({ compressionLevel: 6 }) + .toBuffer() + }, + { + name: 'Adaptive CLAHE', + process: async (buffer) => sharp(buffer) + .resize({ width: 3000, height: 3000, fit: 'inside' }) + .grayscale() + .clahe({ width: 64, height: 64, maxSlope: 3 }) + .gamma(1.3) + .sharpen({ sigma: 1 }) + .png({ compressionLevel: 6 }) + .toBuffer() + } +]; + +/** + * Main OCR extraction function + */ +async function extractTextFromImageBuffer(buffer) { + const startTime = Date.now(); + + try { + console.log('🔍 Starting OCR extraction...'); + + // Auto-rotate + const deskewedBuffer = await deskewImage(buffer); + + // Quick scan first + const quickBuffer = await sharp(deskewedBuffer) + .resize({ width: 1500, height: 1500, fit: 'inside' }) + .grayscale() + .normalize() + .toBuffer(); + + const { data: { text: quickText, confidence: quickConf } } = await Tesseract.recognize( + quickBuffer, 'eng', { + logger: () => { }, + tessedit_pageseg_mode: Tesseract.PSM.AUTO_ONLY + } + ); + + const quickScore = calculateQualityScore(quickText, quickConf); + console.log(`Quick scan: ${quickScore.total.toFixed(1)} pts`); + + // If quick scan is good, use it + if (quickScore.total > 150 && quickScore.metrics.medicalTermCount > 3) { + console.log(`✅ OCR quick scan successful in ${Date.now() - startTime}ms`); + return quickText; + } + + // Enhanced processing + console.log('Running enhanced OCR preprocessing...'); + const processingPromises = PREPROCESSING_METHODS.slice(0, 2).map(async (method) => { + try { + const processedBuffer = await method.process(deskewedBuffer); + + const { data: { text, confidence } } = await Tesseract.recognize( + processedBuffer, 'eng', { + logger: (m) => { + if (m.status === 'recognizing text' && m.progress === 0) { + console.log(`OCR: ${method.name}`); + } + }, + tessedit_pageseg_mode: Tesseract.PSM.AUTO, + tessedit_ocr_engine_mode: Tesseract.OEM.LSTM_ONLY, + preserve_interword_spaces: '1', + tessedit_char_whitelist: '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz.,()-/:% <>=±μ' + } + ); + + const score = calculateQualityScore(text, confidence); + return { text, score, method: method.name }; + + } catch (error) { + console.error(`Method ${method.name} failed:`, error.message); + return { text: '', score: { total: 0 }, method: method.name }; + } + }); + + const results = await Promise.all(processingPromises); + const bestResult = results.reduce((best, current) => + current.score.total > best.score.total ? current : best + ); + + const elapsedTime = Date.now() - startTime; + console.log(`✅ OCR complete: ${bestResult.method}, ${elapsedTime}ms`); + + return bestResult.text.length > 10 ? bestResult.text : ' '; + + } catch (error) { + console.error('❌ OCR extraction error:', error); + return ' '; + } +} + +/** + * PDF text extraction with OCR fallback + */ +async function extractTextFromPDFBuffer(buffer) { + try { + const data = await pdfParse(buffer); + console.log(`📄 PDF text extracted: ${data.text.length} chars`); + + if (data.text.trim().length > 100) { + return data.text; + } + + console.log('PDF appears to be scanned, using OCR...'); + return await extractTextFromImageBuffer(buffer); + + } catch (error) { + console.error('PDF extraction error:', error); + return ' '; + } +} + +/** + * Main OCR extraction with parameter extraction + */ +async function extractWithOCR(fileBuffer, mimeType) { + const startTime = Date.now(); + + try { + console.log('🔍 Starting OCR fallback extraction...'); + + let extractedText = ''; + + if (mimeType === 'application/pdf') { + extractedText = await extractTextFromPDFBuffer(fileBuffer); + } else { + extractedText = await extractTextFromImageBuffer(fileBuffer); + } + + const hasMinimalText = extractedText.trim().length > 0 && extractedText.trim().length < 50; + const hasNoText = extractedText.trim().length === 0; + const isScannedDocument = hasMinimalText || hasNoText; + + if (hasNoText) { + extractedText = ' '; + } + + // Extract parameters + let healthParameters = extractedText.length > 50 + ? extractHealthParameters(extractedText) + : []; + + const processingTime = Date.now() - startTime; + console.log(`✅ OCR extraction complete: ${healthParameters.length} parameters in ${processingTime}ms`); + + return { + success: healthParameters.length > 0 || isScannedDocument, + method: 'ocr', + extractedText: extractedText, + healthParameters: healthParameters, + isScannedDocument: isScannedDocument, + requiresManualEntry: healthParameters.length === 0, + metadata: { + processingTime: processingTime, + textLength: extractedText.length, + parameterCount: healthParameters.length + } + }; + + } catch (error) { + console.error('❌ OCR extraction failed:', error.message); + + return { + success: false, + method: 'ocr', + error: error.message, + processingTime: Date.now() - startTime + }; + } +} + +module.exports = { + extractWithOCR, + extractTextFromImageBuffer, + extractTextFromPDFBuffer +}; \ No newline at end of file