dylanglenister
FIX: Improve local llm loading
f73c316
# src/services/extractor.py
import base64
import json
import mimetypes
import re
from typing import Any, Dict, List, Optional, Tuple
from src.models.emr import ExtractedData, LabResult, Medication, VitalSigns
from src.services import local_llm_service
from src.services.gemini import gemini_chat
from src.utils.logger import logger
from src.utils.rotator import APIKeyRotator
class EMRExtractor:
"""Service for extracting structured medical data from chat messages using Gemini AI."""
def __init__(self, gemini_rotator: APIKeyRotator):
self.gemini_rotator = gemini_rotator
async def extract_medical_data(self, message: str, patient_context: Optional[Dict[str, Any]] = None) -> Tuple[ExtractedData, float]:
"""
Extract structured medical data from a chat message using Gemini AI.
Args:
message: The chat message to analyze
patient_context: Optional patient context information
Returns:
Tuple of (ExtractedData, confidence_score)
"""
try:
# Prepare the prompt for Gemini
prompt = self._build_extraction_prompt(message, patient_context)
if local_llm_service.model_loaded:
response = local_llm_service.get_inference(prompt=prompt)
else:
# Get response from Gemini
response = await self._call_gemini_api(prompt)
# Parse the response
extracted_data, confidence = self._parse_gemini_response(response)
logger().info(f"Successfully extracted medical data with confidence {confidence:.2f}")
return extracted_data, confidence
except Exception as e:
logger().error(f"Error extracting medical data: {e}")
# Return empty data with low confidence
return ExtractedData(), 0.0
def _build_extraction_prompt(self, message: str, patient_context: Optional[Dict[str, Any]] = None) -> str:
"""Build the prompt for Gemini AI to extract medical data."""
context_info = ""
if patient_context:
context_info = f"""
Patient Context:
- Name: {patient_context.get('name', 'Unknown')}
- Age: {patient_context.get('age', 'Unknown')}
- Sex: {patient_context.get('sex', 'Unknown')}
- Current Medications: {', '.join(patient_context.get('medications', []))}
- Past Assessment Summary: {patient_context.get('past_assessment_summary', 'None')}
"""
prompt = f"""You are a medical AI assistant specialized in extracting structured medical data from clinical conversations.
{context_info}
Please analyze the following medical message and extract all relevant clinical information in the specified JSON format:
Message: "{message}"
Extract the following information and return ONLY a valid JSON object with this exact structure:
{{
"diagnosis": ["list of diagnoses mentioned"],
"symptoms": ["list of symptoms described"],
"medications": [
{{
"name": "medication name",
"dosage": "dosage if mentioned",
"frequency": "frequency if mentioned",
"duration": "duration if mentioned"
}}
],
"vital_signs": {{
"blood_pressure": "value if mentioned",
"heart_rate": "value if mentioned",
"temperature": "value if mentioned",
"respiratory_rate": "value if mentioned",
"oxygen_saturation": "value if mentioned"
}},
"lab_results": [
{{
"test_name": "test name",
"value": "test value",
"unit": "unit if mentioned",
"reference_range": "normal range if mentioned"
}}
],
"procedures": ["list of procedures mentioned"],
"notes": "additional clinical notes and observations"
}}
Guidelines:
1. Only extract information that is explicitly mentioned or clearly implied
2. Use medical terminology appropriately
3. If a field has no relevant information, use an empty array [] or null
4. For medications, only include those that are prescribed, recommended, or mentioned as current
5. Extract vital signs only if specific values are mentioned
6. Include lab results only if specific test values are provided
7. Be conservative - it's better to miss something than to hallucinate information
8. Return ONLY the JSON object, no additional text or explanation
Confidence Assessment:
After the JSON, provide a confidence score (0.0-1.0) based on:
- Clarity of medical information in the message
- Specificity of clinical details
- Presence of measurable values (vitals, lab results)
- Overall clinical relevance
Format: CONFIDENCE: 0.85
Return the JSON followed by the confidence score on a new line."""
return prompt
async def _call_gemini_api(self, prompt: str) -> str:
"""Call the Gemini API with the extraction prompt."""
try:
# Use the gemini_chat function with the rotator
response = await gemini_chat(prompt, self.gemini_rotator)
return response
except Exception as e:
logger().error(f"Error calling Gemini API: {e}")
raise
def _parse_gemini_response(self, response: str) -> Tuple[ExtractedData, float]:
"""Parse the Gemini response to extract structured data and confidence score."""
try:
# Extract confidence score
confidence = 0.5 # Default confidence
confidence_match = re.search(r'CONFIDENCE:\s*([0-9.]+)', response)
if confidence_match:
confidence = float(confidence_match.group(1))
# Extract JSON from response
json_match = re.search(r'\{.*\}', response, re.DOTALL)
if not json_match:
logger().warning("No JSON found in Gemini response")
return ExtractedData(), confidence
json_str = json_match.group(0)
data = json.loads(json_str)
# Parse medications
medications = []
for med_data in data.get('medications', []):
if isinstance(med_data, dict):
medications.append(Medication(
name=med_data.get('name', ''),
dosage=med_data.get('dosage'),
frequency=med_data.get('frequency'),
duration=med_data.get('duration')
))
# Parse vital signs
vital_signs_data = data.get('vital_signs', {})
vital_signs = None
if vital_signs_data and any(vital_signs_data.values()):
vital_signs = VitalSigns(
blood_pressure=vital_signs_data.get('blood_pressure'),
heart_rate=vital_signs_data.get('heart_rate'),
temperature=vital_signs_data.get('temperature'),
respiratory_rate=vital_signs_data.get('respiratory_rate'),
oxygen_saturation=vital_signs_data.get('oxygen_saturation')
)
# Parse lab results
lab_results = []
for lab_data in data.get('lab_results', []):
if isinstance(lab_data, dict):
lab_results.append(LabResult(
test_name=lab_data.get('test_name', ''),
value=lab_data.get('value', ''),
unit=lab_data.get('unit'),
reference_range=lab_data.get('reference_range')
))
# Create ExtractedData object
extracted_data = ExtractedData(
diagnosis=data.get('diagnosis', []),
symptoms=data.get('symptoms', []),
medications=medications,
vital_signs=vital_signs,
lab_results=lab_results,
procedures=data.get('procedures', []),
notes=data.get('notes', '') + (f"\n\nDocument Overview: {data.get('overview', '')}" if data.get('overview') else '')
)
return extracted_data, confidence
except json.JSONDecodeError as e:
logger().error(f"Error parsing JSON from Gemini response: {e}")
return ExtractedData(), 0.0
except Exception as e:
logger().error(f"Error parsing Gemini response: {e}")
return ExtractedData(), 0.0
def extract_medications_from_text(self, text: str) -> List[str]:
"""Extract medication names from text using pattern matching."""
# Common medication patterns
medication_patterns = [
r'\b(?:acetaminophen|tylenol|ibuprofen|advil|motrin|aspirin|naproxen|aleve)\b',
r'\b(?:metformin|insulin|glipizide|metoprolol|lisinopril|amlodipine|atorvastatin|simvastatin)\b',
r'\b(?:omeprazole|pantoprazole|ranitidine|famotidine|sertraline|fluoxetine|paroxetine)\b',
r'\b(?:prednisone|hydrocortisone|dexamethasone|methylprednisolone)\b',
r'\b(?:warfarin|heparin|clopidogrel|aspirin)\b',
r'\b(?:furosemide|hydrochlorothiazide|spironolactone|triamterene)\b'
]
medications = set()
for pattern in medication_patterns:
matches = re.findall(pattern, text, re.IGNORECASE)
medications.update(matches)
return list(medications)
def extract_vital_signs_from_text(self, text: str) -> Dict[str, str]:
"""Extract vital signs from text using pattern matching."""
vital_signs = {}
# Blood pressure patterns
bp_pattern = r'(?:blood pressure|bp|pressure)\s*:?\s*(\d{2,3}/\d{2,3})'
bp_match = re.search(bp_pattern, text, re.IGNORECASE)
if bp_match:
vital_signs['blood_pressure'] = bp_match.group(1)
# Heart rate patterns
hr_pattern = r'(?:heart rate|hr|pulse)\s*:?\s*(\d{2,3})\s*(?:bpm|beats per minute)?'
hr_match = re.search(hr_pattern, text, re.IGNORECASE)
if hr_match:
vital_signs['heart_rate'] = hr_match.group(1)
# Temperature patterns
temp_pattern = r'(?:temperature|temp|fever)\s*:?\s*(\d{2,3}(?:\.\d)?)\s*(?:°?[fc])?'
temp_match = re.search(temp_pattern, text, re.IGNORECASE)
if temp_match:
vital_signs['temperature'] = temp_match.group(1)
# Respiratory rate patterns
rr_pattern = r'(?:respiratory rate|rr|breathing rate)\s*:?\s*(\d{1,2})\s*(?:breaths per minute|bpm)?'
rr_match = re.search(rr_pattern, text, re.IGNORECASE)
if rr_match:
vital_signs['respiratory_rate'] = rr_match.group(1)
# Oxygen saturation patterns
o2_pattern = r'(?:oxygen saturation|o2 sat|spo2)\s*:?\s*(\d{2,3})\s*%?'
o2_match = re.search(o2_pattern, text, re.IGNORECASE)
if o2_match:
vital_signs['oxygen_saturation'] = o2_match.group(1)
return vital_signs
async def analyze_document(self, file_content: bytes, filename: str, patient_context: Optional[Dict[str, Any]] = None) -> Tuple[ExtractedData, float]:
"""
Analyze a medical document (PDF, image, or text) and extract structured medical data.
Args:
file_content: The binary content of the uploaded file
filename: The name of the uploaded file
patient_context: Optional patient context information
Returns:
Tuple of (ExtractedData, confidence_score)
"""
try:
# Determine file type and prepare content for Gemini
mime_type, _ = mimetypes.guess_type(filename)
if not mime_type:
logger().warning(f"Unknown file type for {filename}")
return ExtractedData(), 0.0
# Encode file content to base64
file_base64 = base64.b64encode(file_content).decode('utf-8')
# Build the prompt for document analysis
prompt = self._build_document_analysis_prompt(file_base64, mime_type, filename, patient_context)
if local_llm_service.model_loaded:
response = local_llm_service.get_inference(prompt=prompt)
else:
# Get response from Gemini
response = await self._call_gemini_api(prompt)
# Parse the response
extracted_data, confidence = self._parse_gemini_response(response)
logger().info(f"Successfully analyzed document {filename} with confidence {confidence:.2f}")
return extracted_data, confidence
except Exception as e:
logger().error(f"Error analyzing document {filename}: {e}")
# Return empty data with low confidence
return ExtractedData(), 0.0
def _build_document_analysis_prompt(self, file_base64: str, mime_type: str, filename: str, patient_context: Optional[Dict[str, Any]] = None) -> str:
"""Build the prompt for Gemini AI to analyze medical documents."""
context_info = ""
if patient_context:
context_info = f"""
Patient Context:
- Name: {patient_context.get('name', 'Unknown')}
- Age: {patient_context.get('age', 'Unknown')}
- Sex: {patient_context.get('sex', 'Unknown')}
- Current Medications: {', '.join(patient_context.get('medications', []))}
- Past Assessment Summary: {patient_context.get('past_assessment_summary', 'None')}
"""
# Determine the content type for Gemini
if mime_type.startswith('image/'):
content_type = "image"
elif mime_type == 'application/pdf':
content_type = "pdf"
elif mime_type in ['application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document']:
content_type = "document"
else:
content_type = "text"
prompt = f"""You are a medical AI assistant specialized in analyzing medical documents and extracting structured clinical information.
{context_info}
Please analyze the following medical document and extract all relevant clinical information in the specified JSON format.
Document Information:
- Filename: {filename}
- Content Type: {content_type}
- MIME Type: {mime_type}
Document Content (Base64 encoded):
{file_base64}
Extract the following information and return ONLY a valid JSON object with this exact structure:
{{
"overview": "Brief summary of the document content and main findings",
"diagnosis": ["list of diagnoses mentioned or identified"],
"symptoms": ["list of symptoms described"],
"medications": [
{{
"name": "medication name",
"dosage": "dosage if mentioned",
"frequency": "frequency if mentioned",
"duration": "duration if mentioned"
}}
],
"vital_signs": {{
"blood_pressure": "value if mentioned",
"heart_rate": "value if mentioned",
"temperature": "value if mentioned",
"respiratory_rate": "value if mentioned",
"oxygen_saturation": "value if mentioned"
}},
"lab_results": [
{{
"test_name": "test name",
"value": "test value",
"unit": "unit if mentioned",
"reference_range": "normal range if mentioned"
}}
],
"procedures": ["list of procedures mentioned or performed"],
"notes": "additional clinical notes and observations"
}}
Guidelines for Document Analysis:
1. Carefully read and analyze the entire document content
2. Extract information that is explicitly mentioned or clearly documented
3. Use medical terminology appropriately and maintain accuracy
4. If a field has no relevant information, use an empty array [] or null
5. For medications, include all prescribed, recommended, or mentioned medications
6. Extract vital signs only if specific values are documented
7. Include lab results only if specific test values are provided
8. Be thorough but conservative - prioritize accuracy over completeness
9. For images, focus on visible text, charts, and medical data
10. For PDFs and documents, analyze all text content systematically
11. Return ONLY the JSON object, no additional text or explanation
Confidence Assessment:
After the JSON, provide a confidence score (0.0-1.0) based on:
- Document clarity and readability
- Specificity of medical information
- Presence of measurable values (vitals, lab results)
- Overall clinical relevance and completeness
- Document type and quality
Format: CONFIDENCE: 0.85
Return the JSON followed by the confidence score on a new line."""
return prompt