Spaces:
Sleeping
Sleeping
dylanglenister
commited on
Commit
Β·
d6a79b9
1
Parent(s):
7fbfb89
Merge branch 'main' of hf.co:spaces/MedAI-COS30018/MedicalDiagnosisSystem into hf_main
Browse files- .dockerignore +7 -0
- schemas/account_validator.json +1 -2
- src/api/routes/emr.py +313 -13
- src/api/routes/migration.py +41 -0
- src/api/routes/session.py +33 -1
- src/config/settings.py +6 -0
- src/data/emr_update.py +315 -0
- src/data/migration.py +237 -0
- src/data/repositories/account.py +32 -4
- src/main.py +14 -0
- src/models/account.py +1 -1
- src/services/extractor.py +141 -1
- src/services/guard.py +518 -0
- src/services/medical_response.py +19 -1
- src/services/service.py +2 -2
- static/css/emr.css +646 -0
- static/css/styles.css +116 -3
- static/emr.html +143 -18
- static/index.html +18 -9
- static/js/app.js +31 -6
- static/js/emr.js +735 -2
- static/patient.html +1 -1
- tests/{test_memory_manager.py β core/test_memory_manager.py} +0 -0
- tests/{test_state.py β core/test_state.py} +0 -0
- tests/{test_account.py β models/test_account.py} +1 -1
- tests/{test_patient.py β models/test_patient.py} +1 -1
- tests/{test_session.py β models/test_session.py} +1 -1
- tests/services/test_guard.py +313 -0
- tests/{test_medical_memory.py β services/test_medical_memory.py} +1 -1
- tests/{test_medical_record.py β services/test_medical_record.py} +1 -1
- tests/{test_utils.py β utils/test_utils.py} +1 -1
.dockerignore
CHANGED
|
@@ -1,7 +1,14 @@
|
|
| 1 |
.git
|
| 2 |
.gitignore
|
|
|
|
| 3 |
.env
|
| 4 |
.venv
|
|
|
|
| 5 |
__pycache__
|
| 6 |
*.pyc
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
key
|
|
|
|
| 1 |
.git
|
| 2 |
.gitignore
|
| 3 |
+
|
| 4 |
.env
|
| 5 |
.venv
|
| 6 |
+
|
| 7 |
__pycache__
|
| 8 |
*.pyc
|
| 9 |
+
|
| 10 |
+
*.md
|
| 11 |
+
LICENSE
|
| 12 |
+
|
| 13 |
+
tests
|
| 14 |
key
|
schemas/account_validator.json
CHANGED
|
@@ -6,8 +6,7 @@
|
|
| 6 |
"name",
|
| 7 |
"role",
|
| 8 |
"created_at",
|
| 9 |
-
"updated_at"
|
| 10 |
-
"last_seen"
|
| 11 |
],
|
| 12 |
"properties": {
|
| 13 |
"name": {
|
|
|
|
| 6 |
"name",
|
| 7 |
"role",
|
| 8 |
"created_at",
|
| 9 |
+
"updated_at"
|
|
|
|
| 10 |
],
|
| 11 |
"properties": {
|
| 12 |
"name": {
|
src/api/routes/emr.py
CHANGED
|
@@ -3,10 +3,13 @@
|
|
| 3 |
from datetime import datetime, timezone
|
| 4 |
from typing import List, Optional
|
| 5 |
|
| 6 |
-
from fastapi import APIRouter, Depends, HTTPException
|
|
|
|
| 7 |
|
| 8 |
-
from src.models.emr import EMRResponse, EMRSearchRequest, EMRUpdateRequest
|
| 9 |
from src.services.service import EMRService
|
|
|
|
|
|
|
| 10 |
from src.core.state import AppState, get_state
|
| 11 |
from src.utils.logger import logger
|
| 12 |
|
|
@@ -17,7 +20,7 @@ router = APIRouter(prefix="/emr", tags=["EMR"])
|
|
| 17 |
async def check_emr_exists(message_id: str):
|
| 18 |
"""Check if EMR extraction has already been done for a message."""
|
| 19 |
try:
|
| 20 |
-
from data.repositories.emr import check_emr_exists
|
| 21 |
exists = check_emr_exists(message_id)
|
| 22 |
return {
|
| 23 |
"message_id": message_id,
|
|
@@ -88,11 +91,11 @@ async def extract_emr_from_message(
|
|
| 88 |
patient = get_patient_by_id(patient_id)
|
| 89 |
if patient:
|
| 90 |
patient_context = {
|
| 91 |
-
"name": patient.
|
| 92 |
-
"age": patient.
|
| 93 |
-
"sex": patient.
|
| 94 |
-
"medications": patient.
|
| 95 |
-
"past_assessment_summary": patient.
|
| 96 |
}
|
| 97 |
except Exception as e:
|
| 98 |
logger().warning(f"Could not fetch patient context: {e}")
|
|
@@ -278,11 +281,11 @@ async def bulk_extract_emr(
|
|
| 278 |
patient = get_patient_by_id(extraction['patient_id'])
|
| 279 |
if patient:
|
| 280 |
patient_context = {
|
| 281 |
-
"name": patient.
|
| 282 |
-
"age": patient.
|
| 283 |
-
"sex": patient.
|
| 284 |
-
"medications": patient.
|
| 285 |
-
"past_assessment_summary": patient.
|
| 286 |
}
|
| 287 |
except Exception as e:
|
| 288 |
logger().warning(f"Could not fetch patient context for extraction {i}: {e}")
|
|
@@ -324,3 +327,300 @@ async def bulk_extract_emr(
|
|
| 324 |
except Exception as e:
|
| 325 |
logger().error(f"Error in bulk EMR extraction: {e}")
|
| 326 |
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
from datetime import datetime, timezone
|
| 4 |
from typing import List, Optional
|
| 5 |
|
| 6 |
+
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
|
| 7 |
+
from fastapi.responses import JSONResponse
|
| 8 |
|
| 9 |
+
from src.models.emr import EMRResponse, EMRSearchRequest, EMRUpdateRequest, ExtractedData
|
| 10 |
from src.services.service import EMRService
|
| 11 |
+
from src.services.extractor import EMRExtractor
|
| 12 |
+
from src.data.emr_update import EMRUpdateService
|
| 13 |
from src.core.state import AppState, get_state
|
| 14 |
from src.utils.logger import logger
|
| 15 |
|
|
|
|
| 20 |
async def check_emr_exists(message_id: str):
|
| 21 |
"""Check if EMR extraction has already been done for a message."""
|
| 22 |
try:
|
| 23 |
+
from src.data.repositories.emr import check_emr_exists
|
| 24 |
exists = check_emr_exists(message_id)
|
| 25 |
return {
|
| 26 |
"message_id": message_id,
|
|
|
|
| 91 |
patient = get_patient_by_id(patient_id)
|
| 92 |
if patient:
|
| 93 |
patient_context = {
|
| 94 |
+
"name": patient.name,
|
| 95 |
+
"age": patient.age,
|
| 96 |
+
"sex": patient.sex,
|
| 97 |
+
"medications": patient.medications or [],
|
| 98 |
+
"past_assessment_summary": patient.past_assessment_summary
|
| 99 |
}
|
| 100 |
except Exception as e:
|
| 101 |
logger().warning(f"Could not fetch patient context: {e}")
|
|
|
|
| 281 |
patient = get_patient_by_id(extraction['patient_id'])
|
| 282 |
if patient:
|
| 283 |
patient_context = {
|
| 284 |
+
"name": patient.name,
|
| 285 |
+
"age": patient.age,
|
| 286 |
+
"sex": patient.sex,
|
| 287 |
+
"medications": patient.medications or [],
|
| 288 |
+
"past_assessment_summary": patient.past_assessment_summary
|
| 289 |
}
|
| 290 |
except Exception as e:
|
| 291 |
logger().warning(f"Could not fetch patient context for extraction {i}: {e}")
|
|
|
|
| 327 |
except Exception as e:
|
| 328 |
logger().error(f"Error in bulk EMR extraction: {e}")
|
| 329 |
raise HTTPException(status_code=500, detail=str(e))
|
| 330 |
+
|
| 331 |
+
|
| 332 |
+
def get_emr_extractor(state: AppState = Depends(get_state)) -> EMRExtractor:
|
| 333 |
+
"""Get EMR extractor instance."""
|
| 334 |
+
return EMRExtractor(state.gemini_rotator)
|
| 335 |
+
|
| 336 |
+
|
| 337 |
+
def get_emr_update_service() -> EMRUpdateService:
|
| 338 |
+
"""Get EMR update service instance."""
|
| 339 |
+
return EMRUpdateService()
|
| 340 |
+
|
| 341 |
+
|
| 342 |
+
@router.post("/upload-document", response_model=dict)
|
| 343 |
+
async def upload_and_analyze_document(
|
| 344 |
+
patient_id: str = Form(...),
|
| 345 |
+
file: UploadFile = File(...),
|
| 346 |
+
emr_extractor: EMRExtractor = Depends(get_emr_extractor),
|
| 347 |
+
emr_update_service: EMRUpdateService = Depends(get_emr_update_service)
|
| 348 |
+
):
|
| 349 |
+
"""Upload and analyze a medical document to extract EMR data."""
|
| 350 |
+
try:
|
| 351 |
+
# Validate patient ID
|
| 352 |
+
if not patient_id or not patient_id.strip():
|
| 353 |
+
raise HTTPException(status_code=400, detail="Patient ID is required")
|
| 354 |
+
|
| 355 |
+
# Validate file
|
| 356 |
+
if not file or not file.filename:
|
| 357 |
+
raise HTTPException(status_code=400, detail="No file provided")
|
| 358 |
+
|
| 359 |
+
# Check file size (limit to 10MB)
|
| 360 |
+
file_content = await file.read()
|
| 361 |
+
if len(file_content) > 10 * 1024 * 1024: # 10MB
|
| 362 |
+
raise HTTPException(status_code=400, detail="File size exceeds 10MB limit")
|
| 363 |
+
|
| 364 |
+
# Check file type
|
| 365 |
+
allowed_extensions = {'.pdf', '.doc', '.docx', '.jpg', '.jpeg', '.png', '.tiff'}
|
| 366 |
+
file_extension = '.' + file.filename.split('.')[-1].lower() if '.' in file.filename else ''
|
| 367 |
+
if file_extension not in allowed_extensions:
|
| 368 |
+
raise HTTPException(
|
| 369 |
+
status_code=400,
|
| 370 |
+
detail=f"Unsupported file type. Allowed types: {', '.join(allowed_extensions)}"
|
| 371 |
+
)
|
| 372 |
+
|
| 373 |
+
logger().info(f"Document upload requested for patient {patient_id}, file: {file.filename}")
|
| 374 |
+
|
| 375 |
+
# Get patient context if available
|
| 376 |
+
patient_context = None
|
| 377 |
+
try:
|
| 378 |
+
from src.data.repositories.patient import get_patient_by_id
|
| 379 |
+
patient = get_patient_by_id(patient_id)
|
| 380 |
+
if patient:
|
| 381 |
+
patient_context = {
|
| 382 |
+
"name": patient.name,
|
| 383 |
+
"age": patient.age,
|
| 384 |
+
"sex": patient.sex,
|
| 385 |
+
"medications": patient.medications or [],
|
| 386 |
+
"past_assessment_summary": patient.past_assessment_summary
|
| 387 |
+
}
|
| 388 |
+
except Exception as e:
|
| 389 |
+
logger().warning(f"Could not fetch patient context: {e}")
|
| 390 |
+
|
| 391 |
+
# Analyze the document
|
| 392 |
+
extracted_data, confidence_score = await emr_extractor.analyze_document(
|
| 393 |
+
file_content=file_content,
|
| 394 |
+
filename=file.filename,
|
| 395 |
+
patient_context=patient_context
|
| 396 |
+
)
|
| 397 |
+
|
| 398 |
+
# Save to database
|
| 399 |
+
emr_id = await emr_update_service.save_document_analysis(
|
| 400 |
+
patient_id=patient_id,
|
| 401 |
+
filename=file.filename,
|
| 402 |
+
file_content=file_content,
|
| 403 |
+
extracted_data=extracted_data,
|
| 404 |
+
confidence_score=confidence_score
|
| 405 |
+
)
|
| 406 |
+
|
| 407 |
+
return {
|
| 408 |
+
"emr_id": emr_id,
|
| 409 |
+
"filename": file.filename,
|
| 410 |
+
"confidence_score": confidence_score,
|
| 411 |
+
"extracted_data": {
|
| 412 |
+
"overview": extracted_data.notes.split("Document Overview: ")[-1] if "Document Overview:" in extracted_data.notes else "",
|
| 413 |
+
"diagnosis": extracted_data.diagnosis or [],
|
| 414 |
+
"symptoms": extracted_data.symptoms or [],
|
| 415 |
+
"medications": [
|
| 416 |
+
{
|
| 417 |
+
"name": med.name,
|
| 418 |
+
"dosage": med.dosage,
|
| 419 |
+
"frequency": med.frequency,
|
| 420 |
+
"duration": med.duration
|
| 421 |
+
}
|
| 422 |
+
for med in extracted_data.medications or []
|
| 423 |
+
],
|
| 424 |
+
"vital_signs": {
|
| 425 |
+
"blood_pressure": extracted_data.vital_signs.blood_pressure if extracted_data.vital_signs else None,
|
| 426 |
+
"heart_rate": extracted_data.vital_signs.heart_rate if extracted_data.vital_signs else None,
|
| 427 |
+
"temperature": extracted_data.vital_signs.temperature if extracted_data.vital_signs else None,
|
| 428 |
+
"respiratory_rate": extracted_data.vital_signs.respiratory_rate if extracted_data.vital_signs else None,
|
| 429 |
+
"oxygen_saturation": extracted_data.vital_signs.oxygen_saturation if extracted_data.vital_signs else None
|
| 430 |
+
} if extracted_data.vital_signs else None,
|
| 431 |
+
"lab_results": [
|
| 432 |
+
{
|
| 433 |
+
"test_name": lab.test_name,
|
| 434 |
+
"value": lab.value,
|
| 435 |
+
"unit": lab.unit,
|
| 436 |
+
"reference_range": lab.reference_range
|
| 437 |
+
}
|
| 438 |
+
for lab in extracted_data.lab_results or []
|
| 439 |
+
],
|
| 440 |
+
"procedures": extracted_data.procedures or [],
|
| 441 |
+
"notes": extracted_data.notes or ""
|
| 442 |
+
},
|
| 443 |
+
"message": "Document analyzed and EMR data extracted successfully"
|
| 444 |
+
}
|
| 445 |
+
|
| 446 |
+
except HTTPException:
|
| 447 |
+
raise
|
| 448 |
+
except Exception as e:
|
| 449 |
+
logger().error(f"Error in document upload and analysis: {e}")
|
| 450 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 451 |
+
|
| 452 |
+
|
| 453 |
+
@router.post("/preview-document", response_model=dict)
|
| 454 |
+
async def preview_document_analysis(
|
| 455 |
+
patient_id: str = Form(...),
|
| 456 |
+
file: UploadFile = File(...),
|
| 457 |
+
emr_extractor: EMRExtractor = Depends(get_emr_extractor)
|
| 458 |
+
):
|
| 459 |
+
"""Upload and analyze a medical document to preview extracted data before saving."""
|
| 460 |
+
try:
|
| 461 |
+
# Validate patient ID
|
| 462 |
+
if not patient_id or not patient_id.strip():
|
| 463 |
+
raise HTTPException(status_code=400, detail="Patient ID is required")
|
| 464 |
+
|
| 465 |
+
# Validate file
|
| 466 |
+
if not file or not file.filename:
|
| 467 |
+
raise HTTPException(status_code=400, detail="No file provided")
|
| 468 |
+
|
| 469 |
+
# Check file size (limit to 10MB)
|
| 470 |
+
file_content = await file.read()
|
| 471 |
+
if len(file_content) > 10 * 1024 * 1024: # 10MB
|
| 472 |
+
raise HTTPException(status_code=400, detail="File size exceeds 10MB limit")
|
| 473 |
+
|
| 474 |
+
# Check file type
|
| 475 |
+
allowed_extensions = {'.pdf', '.doc', '.docx', '.jpg', '.jpeg', '.png', '.tiff'}
|
| 476 |
+
file_extension = '.' + file.filename.split('.')[-1].lower() if '.' in file.filename else ''
|
| 477 |
+
if file_extension not in allowed_extensions:
|
| 478 |
+
raise HTTPException(
|
| 479 |
+
status_code=400,
|
| 480 |
+
detail=f"Unsupported file type. Allowed types: {', '.join(allowed_extensions)}"
|
| 481 |
+
)
|
| 482 |
+
|
| 483 |
+
logger().info(f"Document preview requested for patient {patient_id}, file: {file.filename}")
|
| 484 |
+
|
| 485 |
+
# Get patient context if available
|
| 486 |
+
patient_context = None
|
| 487 |
+
try:
|
| 488 |
+
from src.data.repositories.patient import get_patient_by_id
|
| 489 |
+
patient = get_patient_by_id(patient_id)
|
| 490 |
+
if patient:
|
| 491 |
+
patient_context = {
|
| 492 |
+
"name": patient.name,
|
| 493 |
+
"age": patient.age,
|
| 494 |
+
"sex": patient.sex,
|
| 495 |
+
"medications": patient.medications or [],
|
| 496 |
+
"past_assessment_summary": patient.past_assessment_summary
|
| 497 |
+
}
|
| 498 |
+
except Exception as e:
|
| 499 |
+
logger().warning(f"Could not fetch patient context: {e}")
|
| 500 |
+
|
| 501 |
+
# Analyze the document
|
| 502 |
+
extracted_data, confidence_score = await emr_extractor.analyze_document(
|
| 503 |
+
file_content=file_content,
|
| 504 |
+
filename=file.filename,
|
| 505 |
+
patient_context=patient_context
|
| 506 |
+
)
|
| 507 |
+
|
| 508 |
+
return {
|
| 509 |
+
"filename": file.filename,
|
| 510 |
+
"confidence_score": confidence_score,
|
| 511 |
+
"extracted_data": {
|
| 512 |
+
"overview": extracted_data.notes.split("Document Overview: ")[-1] if "Document Overview:" in extracted_data.notes else "",
|
| 513 |
+
"diagnosis": extracted_data.diagnosis or [],
|
| 514 |
+
"symptoms": extracted_data.symptoms or [],
|
| 515 |
+
"medications": [
|
| 516 |
+
{
|
| 517 |
+
"name": med.name,
|
| 518 |
+
"dosage": med.dosage,
|
| 519 |
+
"frequency": med.frequency,
|
| 520 |
+
"duration": med.duration
|
| 521 |
+
}
|
| 522 |
+
for med in extracted_data.medications or []
|
| 523 |
+
],
|
| 524 |
+
"vital_signs": {
|
| 525 |
+
"blood_pressure": extracted_data.vital_signs.blood_pressure if extracted_data.vital_signs else None,
|
| 526 |
+
"heart_rate": extracted_data.vital_signs.heart_rate if extracted_data.vital_signs else None,
|
| 527 |
+
"temperature": extracted_data.vital_signs.temperature if extracted_data.vital_signs else None,
|
| 528 |
+
"respiratory_rate": extracted_data.vital_signs.respiratory_rate if extracted_data.vital_signs else None,
|
| 529 |
+
"oxygen_saturation": extracted_data.vital_signs.oxygen_saturation if extracted_data.vital_signs else None
|
| 530 |
+
} if extracted_data.vital_signs else None,
|
| 531 |
+
"lab_results": [
|
| 532 |
+
{
|
| 533 |
+
"test_name": lab.test_name,
|
| 534 |
+
"value": lab.value,
|
| 535 |
+
"unit": lab.unit,
|
| 536 |
+
"reference_range": lab.reference_range
|
| 537 |
+
}
|
| 538 |
+
for lab in extracted_data.lab_results or []
|
| 539 |
+
],
|
| 540 |
+
"procedures": extracted_data.procedures or [],
|
| 541 |
+
"notes": extracted_data.notes or ""
|
| 542 |
+
},
|
| 543 |
+
"message": "Document analyzed successfully. Review the data before saving."
|
| 544 |
+
}
|
| 545 |
+
|
| 546 |
+
except HTTPException:
|
| 547 |
+
raise
|
| 548 |
+
except Exception as e:
|
| 549 |
+
logger().error(f"Error in document preview: {e}")
|
| 550 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 551 |
+
|
| 552 |
+
|
| 553 |
+
@router.post("/save-document-analysis", response_model=dict)
|
| 554 |
+
async def save_document_analysis(
|
| 555 |
+
patient_id: str = Form(...),
|
| 556 |
+
filename: str = Form(...),
|
| 557 |
+
extracted_data: str = Form(...), # JSON string
|
| 558 |
+
confidence_score: float = Form(...),
|
| 559 |
+
emr_update_service: EMRUpdateService = Depends(get_emr_update_service)
|
| 560 |
+
):
|
| 561 |
+
"""Save document analysis results to EMR database."""
|
| 562 |
+
try:
|
| 563 |
+
import json
|
| 564 |
+
|
| 565 |
+
# Validate inputs
|
| 566 |
+
if not patient_id or not patient_id.strip():
|
| 567 |
+
raise HTTPException(status_code=400, detail="Patient ID is required")
|
| 568 |
+
if not filename or not filename.strip():
|
| 569 |
+
raise HTTPException(status_code=400, detail="Filename is required")
|
| 570 |
+
if not extracted_data or not extracted_data.strip():
|
| 571 |
+
raise HTTPException(status_code=400, detail="Extracted data is required")
|
| 572 |
+
|
| 573 |
+
# Parse extracted data
|
| 574 |
+
try:
|
| 575 |
+
data_dict = json.loads(extracted_data)
|
| 576 |
+
except json.JSONDecodeError as e:
|
| 577 |
+
raise HTTPException(status_code=400, detail=f"Invalid JSON in extracted data: {e}")
|
| 578 |
+
|
| 579 |
+
# Convert to ExtractedData object
|
| 580 |
+
extracted_data_obj = ExtractedData(
|
| 581 |
+
diagnosis=data_dict.get('diagnosis', []),
|
| 582 |
+
symptoms=data_dict.get('symptoms', []),
|
| 583 |
+
medications=[
|
| 584 |
+
{
|
| 585 |
+
"name": med.get('name', ''),
|
| 586 |
+
"dosage": med.get('dosage'),
|
| 587 |
+
"frequency": med.get('frequency'),
|
| 588 |
+
"duration": med.get('duration')
|
| 589 |
+
}
|
| 590 |
+
for med in data_dict.get('medications', [])
|
| 591 |
+
],
|
| 592 |
+
vital_signs=data_dict.get('vital_signs'),
|
| 593 |
+
lab_results=[
|
| 594 |
+
{
|
| 595 |
+
"test_name": lab.get('test_name', ''),
|
| 596 |
+
"value": lab.get('value', ''),
|
| 597 |
+
"unit": lab.get('unit'),
|
| 598 |
+
"reference_range": lab.get('reference_range')
|
| 599 |
+
}
|
| 600 |
+
for lab in data_dict.get('lab_results', [])
|
| 601 |
+
],
|
| 602 |
+
procedures=data_dict.get('procedures', []),
|
| 603 |
+
notes=data_dict.get('notes', '') + (f"\n\nDocument Overview: {data_dict.get('overview', '')}" if data_dict.get('overview') else '')
|
| 604 |
+
)
|
| 605 |
+
|
| 606 |
+
logger().info(f"Saving document analysis for patient {patient_id}, file: {filename}")
|
| 607 |
+
|
| 608 |
+
# Save to database (without file content for preview saves)
|
| 609 |
+
emr_id = await emr_update_service.save_document_analysis(
|
| 610 |
+
patient_id=patient_id,
|
| 611 |
+
filename=filename,
|
| 612 |
+
file_content=b"", # Empty for preview saves
|
| 613 |
+
extracted_data=extracted_data_obj,
|
| 614 |
+
confidence_score=confidence_score
|
| 615 |
+
)
|
| 616 |
+
|
| 617 |
+
return {
|
| 618 |
+
"emr_id": emr_id,
|
| 619 |
+
"message": "Document analysis saved to EMR successfully"
|
| 620 |
+
}
|
| 621 |
+
|
| 622 |
+
except HTTPException:
|
| 623 |
+
raise
|
| 624 |
+
except Exception as e:
|
| 625 |
+
logger().error(f"Error saving document analysis: {e}")
|
| 626 |
+
raise HTTPException(status_code=500, detail=str(e))
|
src/api/routes/migration.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# src/api/routes/migration.py
|
| 2 |
+
|
| 3 |
+
from fastapi import APIRouter, Depends, HTTPException, status
|
| 4 |
+
from typing import Dict, Any
|
| 5 |
+
|
| 6 |
+
from src.core.state import AppState, get_state
|
| 7 |
+
from src.data.migration import run_database_migration
|
| 8 |
+
from src.utils.logger import logger
|
| 9 |
+
|
| 10 |
+
router = APIRouter(prefix="/migration", tags=["Migration"])
|
| 11 |
+
|
| 12 |
+
@router.post("/fix-database", response_model=Dict[str, Any])
|
| 13 |
+
async def fix_database_records(
|
| 14 |
+
state: AppState = Depends(get_state)
|
| 15 |
+
):
|
| 16 |
+
"""
|
| 17 |
+
Fix existing database records that have missing or invalid required fields.
|
| 18 |
+
This endpoint ensures all records conform to current Pydantic model requirements.
|
| 19 |
+
"""
|
| 20 |
+
logger().info("Migration endpoint called - starting database fix")
|
| 21 |
+
|
| 22 |
+
try:
|
| 23 |
+
result = run_database_migration()
|
| 24 |
+
|
| 25 |
+
if result['success']:
|
| 26 |
+
return {
|
| 27 |
+
"message": "Database migration completed successfully",
|
| 28 |
+
"stats": result
|
| 29 |
+
}
|
| 30 |
+
else:
|
| 31 |
+
raise HTTPException(
|
| 32 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 33 |
+
detail=f"Migration failed: {result.get('error', 'Unknown error')}"
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
except Exception as e:
|
| 37 |
+
logger().error(f"Migration endpoint error: {e}")
|
| 38 |
+
raise HTTPException(
|
| 39 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 40 |
+
detail=f"Migration failed: {str(e)}"
|
| 41 |
+
)
|
src/api/routes/session.py
CHANGED
|
@@ -8,6 +8,7 @@ from src.core.state import AppState, get_state
|
|
| 8 |
from src.models.session import (ChatRequest, ChatResponse, Message, Session,
|
| 9 |
SessionCreateRequest)
|
| 10 |
from src.services.medical_response import generate_medical_response
|
|
|
|
| 11 |
from src.utils.logger import logger
|
| 12 |
|
| 13 |
router = APIRouter(prefix="/session", tags=["Session & Chat"])
|
|
@@ -85,6 +86,22 @@ async def post_chat_message(
|
|
| 85 |
"""
|
| 86 |
logger().info(f"POST /session/{session_id}/messages")
|
| 87 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
# 1. Get Enhanced Context
|
| 89 |
try:
|
| 90 |
medical_context = await state.memory_manager.get_enhanced_context(
|
|
@@ -105,12 +122,27 @@ async def post_chat_message(
|
|
| 105 |
user_role="Medical Professional",
|
| 106 |
user_specialty="",
|
| 107 |
rotator=state.gemini_rotator,
|
| 108 |
-
medical_context=medical_context
|
|
|
|
| 109 |
)
|
| 110 |
except Exception as e:
|
| 111 |
logger().error(f"Error generating medical response: {e}")
|
| 112 |
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to generate AI response.")
|
| 113 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
# 3. Process and Store the Exchange
|
| 115 |
summary = await state.memory_manager.process_medical_exchange(
|
| 116 |
session_id=session_id,
|
|
|
|
| 8 |
from src.models.session import (ChatRequest, ChatResponse, Message, Session,
|
| 9 |
SessionCreateRequest)
|
| 10 |
from src.services.medical_response import generate_medical_response
|
| 11 |
+
from src.services.guard import SafetyGuard
|
| 12 |
from src.utils.logger import logger
|
| 13 |
|
| 14 |
router = APIRouter(prefix="/session", tags=["Session & Chat"])
|
|
|
|
| 86 |
"""
|
| 87 |
logger().info(f"POST /session/{session_id}/messages")
|
| 88 |
|
| 89 |
+
# 0. Safety Guard: Validate user query
|
| 90 |
+
try:
|
| 91 |
+
safety_guard = SafetyGuard(state.nvidia_rotator)
|
| 92 |
+
is_safe, safety_reason = safety_guard.check_user_query(req.message)
|
| 93 |
+
if not is_safe:
|
| 94 |
+
logger().warning(f"Safety guard blocked user query: {safety_reason}")
|
| 95 |
+
raise HTTPException(
|
| 96 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 97 |
+
detail=f"Query blocked for safety reasons: {safety_reason}"
|
| 98 |
+
)
|
| 99 |
+
logger().info(f"User query passed safety validation: {safety_reason}")
|
| 100 |
+
except Exception as e:
|
| 101 |
+
logger().error(f"Safety guard error: {e}")
|
| 102 |
+
# Fail open for now - allow query through if guard fails
|
| 103 |
+
logger().warning("Safety guard failed, allowing query through")
|
| 104 |
+
|
| 105 |
# 1. Get Enhanced Context
|
| 106 |
try:
|
| 107 |
medical_context = await state.memory_manager.get_enhanced_context(
|
|
|
|
| 122 |
user_role="Medical Professional",
|
| 123 |
user_specialty="",
|
| 124 |
rotator=state.gemini_rotator,
|
| 125 |
+
medical_context=medical_context,
|
| 126 |
+
nvidia_rotator=state.nvidia_rotator
|
| 127 |
)
|
| 128 |
except Exception as e:
|
| 129 |
logger().error(f"Error generating medical response: {e}")
|
| 130 |
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to generate AI response.")
|
| 131 |
|
| 132 |
+
# 2.5. Safety Guard: Validate AI response
|
| 133 |
+
try:
|
| 134 |
+
is_safe, safety_reason = safety_guard.check_model_answer(req.message, response_text)
|
| 135 |
+
if not is_safe:
|
| 136 |
+
logger().warning(f"Safety guard blocked AI response: {safety_reason}")
|
| 137 |
+
# Replace with safe fallback response
|
| 138 |
+
response_text = "I apologize, but I cannot provide a response to that query as it may contain unsafe content. Please consult with a qualified healthcare professional for medical advice."
|
| 139 |
+
else:
|
| 140 |
+
logger().info(f"AI response passed safety validation: {safety_reason}")
|
| 141 |
+
except Exception as e:
|
| 142 |
+
logger().error(f"Safety guard error for response: {e}")
|
| 143 |
+
# Fail open for now - allow response through if guard fails
|
| 144 |
+
logger().warning("Safety guard failed for response, allowing through")
|
| 145 |
+
|
| 146 |
# 3. Process and Store the Exchange
|
| 147 |
summary = await state.memory_manager.process_medical_exchange(
|
| 148 |
session_id=session_id,
|
src/config/settings.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
# src/config/settings.py
|
|
|
|
| 2 |
|
| 3 |
class Settings:
|
| 4 |
"""Application-wide settings."""
|
|
@@ -7,6 +8,11 @@ class Settings:
|
|
| 7 |
DEFAULT_TOP_K: int = 5
|
| 8 |
SEMANTIC_CONTEXT_SIZE: int = 17
|
| 9 |
SIMILARITY_THRESHOLD: float = 0.15
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
# Create singleton instance
|
| 12 |
settings = Settings()
|
|
|
|
| 1 |
# src/config/settings.py
|
| 2 |
+
import os
|
| 3 |
|
| 4 |
class Settings:
|
| 5 |
"""Application-wide settings."""
|
|
|
|
| 8 |
DEFAULT_TOP_K: int = 5
|
| 9 |
SEMANTIC_CONTEXT_SIZE: int = 17
|
| 10 |
SIMILARITY_THRESHOLD: float = 0.15
|
| 11 |
+
|
| 12 |
+
# Safety Guard settings
|
| 13 |
+
SAFETY_GUARD_ENABLED: bool = os.getenv("SAFETY_GUARD_ENABLED", "true").lower() == "true"
|
| 14 |
+
SAFETY_GUARD_TIMEOUT: int = int(os.getenv("SAFETY_GUARD_TIMEOUT", "30"))
|
| 15 |
+
SAFETY_GUARD_FAIL_OPEN: bool = os.getenv("SAFETY_GUARD_FAIL_OPEN", "true").lower() == "true"
|
| 16 |
|
| 17 |
# Create singleton instance
|
| 18 |
settings = Settings()
|
src/data/emr_update.py
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# src/data/emr_update.py
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
import uuid
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
from typing import Any, Dict, List, Optional
|
| 7 |
+
|
| 8 |
+
from src.data.connection import get_database
|
| 9 |
+
from src.models.emr import ExtractedData
|
| 10 |
+
from src.utils.logger import logger
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class EMRUpdateService:
|
| 14 |
+
"""Service for updating EMR records with document analysis results."""
|
| 15 |
+
|
| 16 |
+
def __init__(self):
|
| 17 |
+
self.db = get_database()
|
| 18 |
+
|
| 19 |
+
async def save_document_analysis(
|
| 20 |
+
self,
|
| 21 |
+
patient_id: str,
|
| 22 |
+
filename: str,
|
| 23 |
+
file_content: bytes,
|
| 24 |
+
extracted_data: ExtractedData,
|
| 25 |
+
confidence_score: float,
|
| 26 |
+
original_message: str = None
|
| 27 |
+
) -> str:
|
| 28 |
+
"""
|
| 29 |
+
Save document analysis results to the EMR database.
|
| 30 |
+
|
| 31 |
+
Args:
|
| 32 |
+
patient_id: The ID of the patient
|
| 33 |
+
filename: The name of the uploaded file
|
| 34 |
+
file_content: The binary content of the file
|
| 35 |
+
extracted_data: The extracted medical data
|
| 36 |
+
confidence_score: The confidence score of the analysis
|
| 37 |
+
original_message: Optional original message (for chat-based entries)
|
| 38 |
+
|
| 39 |
+
Returns:
|
| 40 |
+
The EMR ID of the created record
|
| 41 |
+
"""
|
| 42 |
+
try:
|
| 43 |
+
# Generate unique EMR ID
|
| 44 |
+
emr_id = str(uuid.uuid4())
|
| 45 |
+
|
| 46 |
+
# Prepare the EMR record
|
| 47 |
+
emr_record = {
|
| 48 |
+
"emr_id": emr_id,
|
| 49 |
+
"patient_id": patient_id,
|
| 50 |
+
"original_message": original_message or f"Document upload: {filename}",
|
| 51 |
+
"extracted_data": {
|
| 52 |
+
"diagnosis": extracted_data.diagnosis or [],
|
| 53 |
+
"symptoms": extracted_data.symptoms or [],
|
| 54 |
+
"medications": [
|
| 55 |
+
{
|
| 56 |
+
"name": med.name,
|
| 57 |
+
"dosage": med.dosage,
|
| 58 |
+
"frequency": med.frequency,
|
| 59 |
+
"duration": med.duration
|
| 60 |
+
}
|
| 61 |
+
for med in extracted_data.medications or []
|
| 62 |
+
],
|
| 63 |
+
"vital_signs": {
|
| 64 |
+
"blood_pressure": extracted_data.vital_signs.blood_pressure if extracted_data.vital_signs else None,
|
| 65 |
+
"heart_rate": extracted_data.vital_signs.heart_rate if extracted_data.vital_signs else None,
|
| 66 |
+
"temperature": extracted_data.vital_signs.temperature if extracted_data.vital_signs else None,
|
| 67 |
+
"respiratory_rate": extracted_data.vital_signs.respiratory_rate if extracted_data.vital_signs else None,
|
| 68 |
+
"oxygen_saturation": extracted_data.vital_signs.oxygen_saturation if extracted_data.vital_signs else None
|
| 69 |
+
} if extracted_data.vital_signs else None,
|
| 70 |
+
"lab_results": [
|
| 71 |
+
{
|
| 72 |
+
"test_name": lab.test_name,
|
| 73 |
+
"value": lab.value,
|
| 74 |
+
"unit": lab.unit,
|
| 75 |
+
"reference_range": lab.reference_range
|
| 76 |
+
}
|
| 77 |
+
for lab in extracted_data.lab_results or []
|
| 78 |
+
],
|
| 79 |
+
"procedures": extracted_data.procedures or [],
|
| 80 |
+
"notes": extracted_data.notes or ""
|
| 81 |
+
},
|
| 82 |
+
"confidence_score": confidence_score,
|
| 83 |
+
"source": "document_upload",
|
| 84 |
+
"filename": filename,
|
| 85 |
+
"created_at": datetime.utcnow(),
|
| 86 |
+
"updated_at": datetime.utcnow()
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
# Save to database
|
| 90 |
+
result = await self.db.emr_records.insert_one(emr_record)
|
| 91 |
+
|
| 92 |
+
if result.inserted_id:
|
| 93 |
+
logger().info(f"Successfully saved document analysis for patient {patient_id}, EMR ID: {emr_id}")
|
| 94 |
+
return emr_id
|
| 95 |
+
else:
|
| 96 |
+
raise Exception("Failed to insert EMR record")
|
| 97 |
+
|
| 98 |
+
except Exception as e:
|
| 99 |
+
logger().error(f"Error saving document analysis: {e}")
|
| 100 |
+
raise
|
| 101 |
+
|
| 102 |
+
async def update_emr_record(
|
| 103 |
+
self,
|
| 104 |
+
emr_id: str,
|
| 105 |
+
extracted_data: ExtractedData,
|
| 106 |
+
confidence_score: float = None
|
| 107 |
+
) -> bool:
|
| 108 |
+
"""
|
| 109 |
+
Update an existing EMR record with new extracted data.
|
| 110 |
+
|
| 111 |
+
Args:
|
| 112 |
+
emr_id: The EMR record ID to update
|
| 113 |
+
extracted_data: The updated extracted medical data
|
| 114 |
+
confidence_score: Optional new confidence score
|
| 115 |
+
|
| 116 |
+
Returns:
|
| 117 |
+
True if update was successful, False otherwise
|
| 118 |
+
"""
|
| 119 |
+
try:
|
| 120 |
+
# Prepare update data
|
| 121 |
+
update_data = {
|
| 122 |
+
"extracted_data": {
|
| 123 |
+
"diagnosis": extracted_data.diagnosis or [],
|
| 124 |
+
"symptoms": extracted_data.symptoms or [],
|
| 125 |
+
"medications": [
|
| 126 |
+
{
|
| 127 |
+
"name": med.name,
|
| 128 |
+
"dosage": med.dosage,
|
| 129 |
+
"frequency": med.frequency,
|
| 130 |
+
"duration": med.duration
|
| 131 |
+
}
|
| 132 |
+
for med in extracted_data.medications or []
|
| 133 |
+
],
|
| 134 |
+
"vital_signs": {
|
| 135 |
+
"blood_pressure": extracted_data.vital_signs.blood_pressure if extracted_data.vital_signs else None,
|
| 136 |
+
"heart_rate": extracted_data.vital_signs.heart_rate if extracted_data.vital_signs else None,
|
| 137 |
+
"temperature": extracted_data.vital_signs.temperature if extracted_data.vital_signs else None,
|
| 138 |
+
"respiratory_rate": extracted_data.vital_signs.respiratory_rate if extracted_data.vital_signs else None,
|
| 139 |
+
"oxygen_saturation": extracted_data.vital_signs.oxygen_saturation if extracted_data.vital_signs else None
|
| 140 |
+
} if extracted_data.vital_signs else None,
|
| 141 |
+
"lab_results": [
|
| 142 |
+
{
|
| 143 |
+
"test_name": lab.test_name,
|
| 144 |
+
"value": lab.value,
|
| 145 |
+
"unit": lab.unit,
|
| 146 |
+
"reference_range": lab.reference_range
|
| 147 |
+
}
|
| 148 |
+
for lab in extracted_data.lab_results or []
|
| 149 |
+
],
|
| 150 |
+
"procedures": extracted_data.procedures or [],
|
| 151 |
+
"notes": extracted_data.notes or ""
|
| 152 |
+
},
|
| 153 |
+
"updated_at": datetime.utcnow()
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
if confidence_score is not None:
|
| 157 |
+
update_data["confidence_score"] = confidence_score
|
| 158 |
+
|
| 159 |
+
# Update the record
|
| 160 |
+
result = await self.db.emr_records.update_one(
|
| 161 |
+
{"emr_id": emr_id},
|
| 162 |
+
{"$set": update_data}
|
| 163 |
+
)
|
| 164 |
+
|
| 165 |
+
if result.modified_count > 0:
|
| 166 |
+
logger().info(f"Successfully updated EMR record {emr_id}")
|
| 167 |
+
return True
|
| 168 |
+
else:
|
| 169 |
+
logger().warning(f"No EMR record found with ID {emr_id}")
|
| 170 |
+
return False
|
| 171 |
+
|
| 172 |
+
except Exception as e:
|
| 173 |
+
logger().error(f"Error updating EMR record {emr_id}: {e}")
|
| 174 |
+
raise
|
| 175 |
+
|
| 176 |
+
async def get_emr_record(self, emr_id: str) -> Optional[Dict[str, Any]]:
|
| 177 |
+
"""
|
| 178 |
+
Retrieve an EMR record by ID.
|
| 179 |
+
|
| 180 |
+
Args:
|
| 181 |
+
emr_id: The EMR record ID
|
| 182 |
+
|
| 183 |
+
Returns:
|
| 184 |
+
The EMR record if found, None otherwise
|
| 185 |
+
"""
|
| 186 |
+
try:
|
| 187 |
+
record = await self.db.emr_records.find_one({"emr_id": emr_id})
|
| 188 |
+
if record:
|
| 189 |
+
# Convert ObjectId to string for JSON serialization
|
| 190 |
+
record["_id"] = str(record["_id"])
|
| 191 |
+
return record
|
| 192 |
+
|
| 193 |
+
except Exception as e:
|
| 194 |
+
logger().error(f"Error retrieving EMR record {emr_id}: {e}")
|
| 195 |
+
raise
|
| 196 |
+
|
| 197 |
+
async def delete_emr_record(self, emr_id: str) -> bool:
|
| 198 |
+
"""
|
| 199 |
+
Delete an EMR record.
|
| 200 |
+
|
| 201 |
+
Args:
|
| 202 |
+
emr_id: The EMR record ID to delete
|
| 203 |
+
|
| 204 |
+
Returns:
|
| 205 |
+
True if deletion was successful, False otherwise
|
| 206 |
+
"""
|
| 207 |
+
try:
|
| 208 |
+
result = await self.db.emr_records.delete_one({"emr_id": emr_id})
|
| 209 |
+
|
| 210 |
+
if result.deleted_count > 0:
|
| 211 |
+
logger().info(f"Successfully deleted EMR record {emr_id}")
|
| 212 |
+
return True
|
| 213 |
+
else:
|
| 214 |
+
logger().warning(f"No EMR record found with ID {emr_id}")
|
| 215 |
+
return False
|
| 216 |
+
|
| 217 |
+
except Exception as e:
|
| 218 |
+
logger().error(f"Error deleting EMR record {emr_id}: {e}")
|
| 219 |
+
raise
|
| 220 |
+
|
| 221 |
+
async def get_patient_emr_records(
|
| 222 |
+
self,
|
| 223 |
+
patient_id: str,
|
| 224 |
+
limit: int = 100,
|
| 225 |
+
skip: int = 0
|
| 226 |
+
) -> List[Dict[str, Any]]:
|
| 227 |
+
"""
|
| 228 |
+
Retrieve EMR records for a specific patient.
|
| 229 |
+
|
| 230 |
+
Args:
|
| 231 |
+
patient_id: The patient ID
|
| 232 |
+
limit: Maximum number of records to return
|
| 233 |
+
skip: Number of records to skip
|
| 234 |
+
|
| 235 |
+
Returns:
|
| 236 |
+
List of EMR records
|
| 237 |
+
"""
|
| 238 |
+
try:
|
| 239 |
+
cursor = self.db.emr_records.find(
|
| 240 |
+
{"patient_id": patient_id}
|
| 241 |
+
).sort("created_at", -1).skip(skip).limit(limit)
|
| 242 |
+
|
| 243 |
+
records = []
|
| 244 |
+
async for record in cursor:
|
| 245 |
+
# Convert ObjectId to string for JSON serialization
|
| 246 |
+
record["_id"] = str(record["_id"])
|
| 247 |
+
records.append(record)
|
| 248 |
+
|
| 249 |
+
return records
|
| 250 |
+
|
| 251 |
+
except Exception as e:
|
| 252 |
+
logger().error(f"Error retrieving EMR records for patient {patient_id}: {e}")
|
| 253 |
+
raise
|
| 254 |
+
|
| 255 |
+
async def get_patient_emr_statistics(self, patient_id: str) -> Dict[str, Any]:
|
| 256 |
+
"""
|
| 257 |
+
Get EMR statistics for a patient.
|
| 258 |
+
|
| 259 |
+
Args:
|
| 260 |
+
patient_id: The patient ID
|
| 261 |
+
|
| 262 |
+
Returns:
|
| 263 |
+
Dictionary containing EMR statistics
|
| 264 |
+
"""
|
| 265 |
+
try:
|
| 266 |
+
pipeline = [
|
| 267 |
+
{"$match": {"patient_id": patient_id}},
|
| 268 |
+
{
|
| 269 |
+
"$group": {
|
| 270 |
+
"_id": None,
|
| 271 |
+
"total_entries": {"$sum": 1},
|
| 272 |
+
"avg_confidence": {"$avg": "$confidence_score"},
|
| 273 |
+
"diagnosis_count": {
|
| 274 |
+
"$sum": {
|
| 275 |
+
"$cond": [
|
| 276 |
+
{"$gt": [{"$size": {"$ifNull": ["$extracted_data.diagnosis", []]}}, 0]},
|
| 277 |
+
1,
|
| 278 |
+
0
|
| 279 |
+
]
|
| 280 |
+
}
|
| 281 |
+
},
|
| 282 |
+
"medication_count": {
|
| 283 |
+
"$sum": {
|
| 284 |
+
"$cond": [
|
| 285 |
+
{"$gt": [{"$size": {"$ifNull": ["$extracted_data.medications", []]}}, 0]},
|
| 286 |
+
1,
|
| 287 |
+
0
|
| 288 |
+
]
|
| 289 |
+
}
|
| 290 |
+
}
|
| 291 |
+
}
|
| 292 |
+
}
|
| 293 |
+
]
|
| 294 |
+
|
| 295 |
+
result = await self.db.emr_records.aggregate(pipeline).to_list(1)
|
| 296 |
+
|
| 297 |
+
if result:
|
| 298 |
+
stats = result[0]
|
| 299 |
+
return {
|
| 300 |
+
"total_entries": stats.get("total_entries", 0),
|
| 301 |
+
"avg_confidence": stats.get("avg_confidence", 0.0),
|
| 302 |
+
"diagnosis_count": stats.get("diagnosis_count", 0),
|
| 303 |
+
"medication_count": stats.get("medication_count", 0)
|
| 304 |
+
}
|
| 305 |
+
else:
|
| 306 |
+
return {
|
| 307 |
+
"total_entries": 0,
|
| 308 |
+
"avg_confidence": 0.0,
|
| 309 |
+
"diagnosis_count": 0,
|
| 310 |
+
"medication_count": 0
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
except Exception as e:
|
| 314 |
+
logger().error(f"Error retrieving EMR statistics for patient {patient_id}: {e}")
|
| 315 |
+
raise
|
src/data/migration.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# src/data/migration.py
|
| 2 |
+
"""
|
| 3 |
+
Database Migration Module
|
| 4 |
+
|
| 5 |
+
This module provides functions to fix existing database records that have
|
| 6 |
+
missing or invalid required fields to ensure they conform to current Pydantic model requirements.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from datetime import datetime, timezone
|
| 10 |
+
from typing import Dict, Any
|
| 11 |
+
|
| 12 |
+
from bson import ObjectId
|
| 13 |
+
from pymongo.errors import ConnectionFailure, PyMongoError
|
| 14 |
+
|
| 15 |
+
from src.data.connection import get_collection, Collections
|
| 16 |
+
from src.models.account import Account
|
| 17 |
+
from src.models.patient import Patient
|
| 18 |
+
from src.utils.logger import logger
|
| 19 |
+
|
| 20 |
+
# Valid roles for accounts
|
| 21 |
+
VALID_ROLES = [
|
| 22 |
+
"Doctor",
|
| 23 |
+
"Healthcare Prof",
|
| 24 |
+
"Nurse",
|
| 25 |
+
"Caregiver",
|
| 26 |
+
"Physician",
|
| 27 |
+
"Medical Student",
|
| 28 |
+
"Other"
|
| 29 |
+
]
|
| 30 |
+
|
| 31 |
+
def fix_account_records() -> Dict[str, int]:
|
| 32 |
+
"""Fix account records with missing or invalid required fields."""
|
| 33 |
+
logger().info("π§ Starting Account records migration...")
|
| 34 |
+
|
| 35 |
+
collection = get_collection(Collections.ACCOUNT)
|
| 36 |
+
now = datetime.now(timezone.utc)
|
| 37 |
+
|
| 38 |
+
stats = {
|
| 39 |
+
'total_checked': 0,
|
| 40 |
+
'fixed': 0,
|
| 41 |
+
'errors': 0
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
try:
|
| 45 |
+
# Find all accounts
|
| 46 |
+
cursor = collection.find({})
|
| 47 |
+
|
| 48 |
+
for doc in cursor:
|
| 49 |
+
stats['total_checked'] += 1
|
| 50 |
+
doc_id = doc['_id']
|
| 51 |
+
|
| 52 |
+
try:
|
| 53 |
+
# Check what needs to be fixed
|
| 54 |
+
updates = {}
|
| 55 |
+
needs_fix = False
|
| 56 |
+
|
| 57 |
+
# Fix missing or None role
|
| 58 |
+
if not doc.get('role') or doc.get('role') is None:
|
| 59 |
+
updates['role'] = 'Other' # Default role
|
| 60 |
+
needs_fix = True
|
| 61 |
+
logger().info(f" π Account {doc_id}: Setting role to 'Other' (was: {doc.get('role')})")
|
| 62 |
+
|
| 63 |
+
# Fix missing or None name
|
| 64 |
+
if not doc.get('name') or doc.get('name') is None:
|
| 65 |
+
updates['name'] = f"User {str(doc_id)[:8]}" # Generate a name
|
| 66 |
+
needs_fix = True
|
| 67 |
+
logger().info(f" π Account {doc_id}: Setting name to '{updates['name']}' (was: {doc.get('name')})")
|
| 68 |
+
|
| 69 |
+
# Fix missing timestamps
|
| 70 |
+
if not doc.get('created_at'):
|
| 71 |
+
updates['created_at'] = now
|
| 72 |
+
needs_fix = True
|
| 73 |
+
logger().info(f" π Account {doc_id}: Setting created_at to {now}")
|
| 74 |
+
|
| 75 |
+
if not doc.get('updated_at'):
|
| 76 |
+
updates['updated_at'] = now
|
| 77 |
+
needs_fix = True
|
| 78 |
+
logger().info(f" π Account {doc_id}: Setting updated_at to {now}")
|
| 79 |
+
|
| 80 |
+
# Apply updates if needed
|
| 81 |
+
if needs_fix:
|
| 82 |
+
collection.update_one(
|
| 83 |
+
{"_id": doc_id},
|
| 84 |
+
{"$set": updates}
|
| 85 |
+
)
|
| 86 |
+
stats['fixed'] += 1
|
| 87 |
+
|
| 88 |
+
# Validate the record can be parsed by Pydantic
|
| 89 |
+
updated_doc = collection.find_one({"_id": doc_id})
|
| 90 |
+
Account.model_validate(updated_doc)
|
| 91 |
+
|
| 92 |
+
except Exception as e:
|
| 93 |
+
stats['errors'] += 1
|
| 94 |
+
logger().error(f" β Error fixing account {doc_id}: {e}")
|
| 95 |
+
|
| 96 |
+
except (ConnectionFailure, PyMongoError) as e:
|
| 97 |
+
logger().error(f"β Database error while fixing accounts: {e}")
|
| 98 |
+
raise
|
| 99 |
+
|
| 100 |
+
logger().info(f"β
Account migration completed: {stats['fixed']} records fixed, {stats['errors']} errors")
|
| 101 |
+
return stats
|
| 102 |
+
|
| 103 |
+
def fix_patient_records() -> Dict[str, int]:
|
| 104 |
+
"""Fix patient records with missing or invalid required fields."""
|
| 105 |
+
logger().info("π§ Starting Patient records migration...")
|
| 106 |
+
|
| 107 |
+
collection = get_collection(Collections.PATIENT)
|
| 108 |
+
now = datetime.now(timezone.utc)
|
| 109 |
+
|
| 110 |
+
stats = {
|
| 111 |
+
'total_checked': 0,
|
| 112 |
+
'fixed': 0,
|
| 113 |
+
'errors': 0
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
try:
|
| 117 |
+
# Find all patients
|
| 118 |
+
cursor = collection.find({})
|
| 119 |
+
|
| 120 |
+
for doc in cursor:
|
| 121 |
+
stats['total_checked'] += 1
|
| 122 |
+
doc_id = doc['_id']
|
| 123 |
+
|
| 124 |
+
try:
|
| 125 |
+
# Check what needs to be fixed
|
| 126 |
+
updates = {}
|
| 127 |
+
needs_fix = False
|
| 128 |
+
|
| 129 |
+
# Fix missing or None name
|
| 130 |
+
if not doc.get('name') or doc.get('name') is None:
|
| 131 |
+
updates['name'] = f"Patient {str(doc_id)[:8]}" # Generate a name
|
| 132 |
+
needs_fix = True
|
| 133 |
+
logger().info(f" π Patient {doc_id}: Setting name to '{updates['name']}' (was: {doc.get('name')})")
|
| 134 |
+
|
| 135 |
+
# Fix missing or None age
|
| 136 |
+
if not doc.get('age') or doc.get('age') is None:
|
| 137 |
+
updates['age'] = 30 # Default age
|
| 138 |
+
needs_fix = True
|
| 139 |
+
logger().info(f" π Patient {doc_id}: Setting age to 30 (was: {doc.get('age')})")
|
| 140 |
+
|
| 141 |
+
# Fix missing or None sex
|
| 142 |
+
if not doc.get('sex') or doc.get('sex') is None:
|
| 143 |
+
updates['sex'] = 'Other' # Default sex
|
| 144 |
+
needs_fix = True
|
| 145 |
+
logger().info(f" π Patient {doc_id}: Setting sex to 'Other' (was: {doc.get('sex')})")
|
| 146 |
+
|
| 147 |
+
# Fix missing or None ethnicity
|
| 148 |
+
if not doc.get('ethnicity') or doc.get('ethnicity') is None:
|
| 149 |
+
updates['ethnicity'] = 'Not Specified' # Default ethnicity
|
| 150 |
+
needs_fix = True
|
| 151 |
+
logger().info(f" π Patient {doc_id}: Setting ethnicity to 'Not Specified' (was: {doc.get('ethnicity')})")
|
| 152 |
+
|
| 153 |
+
# Fix missing timestamps
|
| 154 |
+
if not doc.get('created_at'):
|
| 155 |
+
updates['created_at'] = now
|
| 156 |
+
needs_fix = True
|
| 157 |
+
logger().info(f" π Patient {doc_id}: Setting created_at to {now}")
|
| 158 |
+
|
| 159 |
+
if not doc.get('updated_at'):
|
| 160 |
+
updates['updated_at'] = now
|
| 161 |
+
needs_fix = True
|
| 162 |
+
logger().info(f" π Patient {doc_id}: Setting updated_at to {now}")
|
| 163 |
+
|
| 164 |
+
# Apply updates if needed
|
| 165 |
+
if needs_fix:
|
| 166 |
+
collection.update_one(
|
| 167 |
+
{"_id": doc_id},
|
| 168 |
+
{"$set": updates}
|
| 169 |
+
)
|
| 170 |
+
stats['fixed'] += 1
|
| 171 |
+
|
| 172 |
+
# Validate the record can be parsed by Pydantic
|
| 173 |
+
updated_doc = collection.find_one({"_id": doc_id})
|
| 174 |
+
Patient.model_validate(updated_doc)
|
| 175 |
+
|
| 176 |
+
except Exception as e:
|
| 177 |
+
stats['errors'] += 1
|
| 178 |
+
logger().error(f" β Error fixing patient {doc_id}: {e}")
|
| 179 |
+
|
| 180 |
+
except (ConnectionFailure, PyMongoError) as e:
|
| 181 |
+
logger().error(f"β Database error while fixing patients: {e}")
|
| 182 |
+
raise
|
| 183 |
+
|
| 184 |
+
logger().info(f"β
Patient migration completed: {stats['fixed']} records fixed, {stats['errors']} errors")
|
| 185 |
+
return stats
|
| 186 |
+
|
| 187 |
+
def run_database_migration() -> Dict[str, Any]:
|
| 188 |
+
"""Run the complete database migration to fix all records."""
|
| 189 |
+
logger().info("π Starting Database Migration")
|
| 190 |
+
logger().info("=" * 50)
|
| 191 |
+
|
| 192 |
+
try:
|
| 193 |
+
# Fix account records
|
| 194 |
+
account_stats = fix_account_records()
|
| 195 |
+
|
| 196 |
+
logger().info("=" * 50)
|
| 197 |
+
|
| 198 |
+
# Fix patient records
|
| 199 |
+
patient_stats = fix_patient_records()
|
| 200 |
+
|
| 201 |
+
logger().info("=" * 50)
|
| 202 |
+
logger().info("π MIGRATION SUMMARY")
|
| 203 |
+
logger().info("=" * 50)
|
| 204 |
+
|
| 205 |
+
logger().info(f"π Accounts:")
|
| 206 |
+
logger().info(f" - Total checked: {account_stats['total_checked']}")
|
| 207 |
+
logger().info(f" - Fixed: {account_stats['fixed']}")
|
| 208 |
+
logger().info(f" - Errors: {account_stats['errors']}")
|
| 209 |
+
|
| 210 |
+
logger().info(f"π₯ Patients:")
|
| 211 |
+
logger().info(f" - Total checked: {patient_stats['total_checked']}")
|
| 212 |
+
logger().info(f" - Fixed: {patient_stats['fixed']}")
|
| 213 |
+
logger().info(f" - Errors: {patient_stats['errors']}")
|
| 214 |
+
|
| 215 |
+
total_fixed = account_stats['fixed'] + patient_stats['fixed']
|
| 216 |
+
total_errors = account_stats['errors'] + patient_stats['errors']
|
| 217 |
+
|
| 218 |
+
logger().info(f"β
Total records fixed: {total_fixed}")
|
| 219 |
+
if total_errors > 0:
|
| 220 |
+
logger().info(f"β οΈ Total errors: {total_errors}")
|
| 221 |
+
|
| 222 |
+
logger().info("π Database migration completed successfully!")
|
| 223 |
+
|
| 224 |
+
return {
|
| 225 |
+
'account_stats': account_stats,
|
| 226 |
+
'patient_stats': patient_stats,
|
| 227 |
+
'total_fixed': total_fixed,
|
| 228 |
+
'total_errors': total_errors,
|
| 229 |
+
'success': True
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
except Exception as e:
|
| 233 |
+
logger().error(f"β Migration failed with error: {e}")
|
| 234 |
+
return {
|
| 235 |
+
'success': False,
|
| 236 |
+
'error': str(e)
|
| 237 |
+
}
|
src/data/repositories/account.py
CHANGED
|
@@ -191,11 +191,23 @@ def search_accounts(
|
|
| 191 |
|
| 192 |
try:
|
| 193 |
collection = get_collection(collection_name)
|
|
|
|
|
|
|
| 194 |
cursor = collection.find({
|
| 195 |
"name": {"$regex": pattern}
|
| 196 |
}).sort("name", ASCENDING).limit(limit)
|
| 197 |
|
| 198 |
-
accounts = [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 199 |
logger().info(f"Found {len(accounts)} accounts matching query")
|
| 200 |
return accounts
|
| 201 |
except (ConnectionFailure, PyMongoError) as e:
|
|
@@ -210,9 +222,25 @@ def get_all_accounts(
|
|
| 210 |
"""Gets all accounts, returning a list of Pydantic Account objects."""
|
| 211 |
try:
|
| 212 |
collection = get_collection(collection_name)
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 216 |
logger().info(f"Retrieved {len(accounts)} accounts")
|
| 217 |
return accounts
|
| 218 |
except (ConnectionFailure, PyMongoError) as e:
|
|
|
|
| 191 |
|
| 192 |
try:
|
| 193 |
collection = get_collection(collection_name)
|
| 194 |
+
now = datetime.now(timezone.utc)
|
| 195 |
+
|
| 196 |
cursor = collection.find({
|
| 197 |
"name": {"$regex": pattern}
|
| 198 |
}).sort("name", ASCENDING).limit(limit)
|
| 199 |
|
| 200 |
+
accounts = []
|
| 201 |
+
for doc in cursor:
|
| 202 |
+
# Update last_seen for this account
|
| 203 |
+
collection.update_one(
|
| 204 |
+
{"_id": doc["_id"]},
|
| 205 |
+
{"$set": {"last_seen": now}}
|
| 206 |
+
)
|
| 207 |
+
# Add the updated timestamp to the document for validation
|
| 208 |
+
doc["last_seen"] = now
|
| 209 |
+
accounts.append(Account.model_validate(doc))
|
| 210 |
+
|
| 211 |
logger().info(f"Found {len(accounts)} accounts matching query")
|
| 212 |
return accounts
|
| 213 |
except (ConnectionFailure, PyMongoError) as e:
|
|
|
|
| 222 |
"""Gets all accounts, returning a list of Pydantic Account objects."""
|
| 223 |
try:
|
| 224 |
collection = get_collection(collection_name)
|
| 225 |
+
now = datetime.now(timezone.utc)
|
| 226 |
+
|
| 227 |
+
# Update last_seen for all accounts and retrieve them
|
| 228 |
+
cursor = collection.find()
|
| 229 |
+
accounts = []
|
| 230 |
+
for doc in cursor:
|
| 231 |
+
# Update last_seen for this account
|
| 232 |
+
collection.update_one(
|
| 233 |
+
{"_id": doc["_id"]},
|
| 234 |
+
{"$set": {"last_seen": now}}
|
| 235 |
+
)
|
| 236 |
+
# Add the updated timestamp to the document for validation
|
| 237 |
+
doc["last_seen"] = now
|
| 238 |
+
accounts.append(Account.model_validate(doc))
|
| 239 |
+
|
| 240 |
+
# Sort by name and limit
|
| 241 |
+
accounts.sort(key=lambda x: x.name)
|
| 242 |
+
accounts = accounts[:limit]
|
| 243 |
+
|
| 244 |
logger().info(f"Retrieved {len(accounts)} accounts")
|
| 245 |
return accounts
|
| 246 |
except (ConnectionFailure, PyMongoError) as e:
|
src/main.py
CHANGED
|
@@ -27,6 +27,7 @@ except Exception as e:
|
|
| 27 |
from src.api.routes import account as account_route
|
| 28 |
from src.api.routes import audio as audio_route
|
| 29 |
from src.api.routes import emr as emr_route
|
|
|
|
| 30 |
from src.api.routes import patient as patients_route
|
| 31 |
from src.api.routes import session as session_route
|
| 32 |
from src.api.routes import static as static_route
|
|
@@ -86,6 +87,18 @@ def startup_event(state: AppState):
|
|
| 86 |
medical_memory_repo.init()
|
| 87 |
medical_record_repo.init()
|
| 88 |
emr_repo.init()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
|
| 90 |
def shutdown_event():
|
| 91 |
"""Cleanup on shutdown"""
|
|
@@ -132,6 +145,7 @@ app.include_router(summarise_route.router)
|
|
| 132 |
app.include_router(system_route.router)
|
| 133 |
app.include_router(audio_route.router)
|
| 134 |
app.include_router(emr_route.router)
|
|
|
|
| 135 |
|
| 136 |
@app.get("/api/info")
|
| 137 |
async def get_api_info():
|
|
|
|
| 27 |
from src.api.routes import account as account_route
|
| 28 |
from src.api.routes import audio as audio_route
|
| 29 |
from src.api.routes import emr as emr_route
|
| 30 |
+
from src.api.routes import migration as migration_route
|
| 31 |
from src.api.routes import patient as patients_route
|
| 32 |
from src.api.routes import session as session_route
|
| 33 |
from src.api.routes import static as static_route
|
|
|
|
| 87 |
medical_memory_repo.init()
|
| 88 |
medical_record_repo.init()
|
| 89 |
emr_repo.init()
|
| 90 |
+
|
| 91 |
+
# Run database migration to fix any existing records with missing required fields
|
| 92 |
+
try:
|
| 93 |
+
from src.data.migration import run_database_migration
|
| 94 |
+
logger(tag="startup").info("Running database migration...")
|
| 95 |
+
migration_result = run_database_migration()
|
| 96 |
+
if migration_result['success']:
|
| 97 |
+
logger(tag="startup").info(f"Database migration completed: {migration_result['total_fixed']} records fixed")
|
| 98 |
+
else:
|
| 99 |
+
logger(tag="startup").warning(f"Database migration failed: {migration_result.get('error', 'Unknown error')}")
|
| 100 |
+
except Exception as e:
|
| 101 |
+
logger(tag="startup").warning(f"Database migration error: {e}")
|
| 102 |
|
| 103 |
def shutdown_event():
|
| 104 |
"""Cleanup on shutdown"""
|
|
|
|
| 145 |
app.include_router(system_route.router)
|
| 146 |
app.include_router(audio_route.router)
|
| 147 |
app.include_router(emr_route.router)
|
| 148 |
+
app.include_router(migration_route.router)
|
| 149 |
|
| 150 |
@app.get("/api/info")
|
| 151 |
async def get_api_info():
|
src/models/account.py
CHANGED
|
@@ -14,7 +14,7 @@ class Account(BaseMongoModel):
|
|
| 14 |
specialty: str | None = None
|
| 15 |
created_at: datetime
|
| 16 |
updated_at: datetime
|
| 17 |
-
last_seen: datetime
|
| 18 |
|
| 19 |
class AccountCreateRequest(BaseModel):
|
| 20 |
"""A Pydantic model for an account creation request from the API."""
|
|
|
|
| 14 |
specialty: str | None = None
|
| 15 |
created_at: datetime
|
| 16 |
updated_at: datetime
|
| 17 |
+
last_seen: datetime | None = None
|
| 18 |
|
| 19 |
class AccountCreateRequest(BaseModel):
|
| 20 |
"""A Pydantic model for an account creation request from the API."""
|
src/services/extractor.py
CHANGED
|
@@ -2,6 +2,8 @@
|
|
| 2 |
|
| 3 |
import json
|
| 4 |
import re
|
|
|
|
|
|
|
| 5 |
from typing import Any, Dict, List, Optional, Tuple
|
| 6 |
|
| 7 |
from src.models.emr import ExtractedData, LabResult, Medication, VitalSigns
|
|
@@ -192,7 +194,7 @@ Return the JSON followed by the confidence score on a new line."""
|
|
| 192 |
vital_signs=vital_signs,
|
| 193 |
lab_results=lab_results,
|
| 194 |
procedures=data.get('procedures', []),
|
| 195 |
-
notes=data.get('notes')
|
| 196 |
)
|
| 197 |
|
| 198 |
return extracted_data, confidence
|
|
@@ -258,3 +260,141 @@ Return the JSON followed by the confidence score on a new line."""
|
|
| 258 |
vital_signs['oxygen_saturation'] = o2_match.group(1)
|
| 259 |
|
| 260 |
return vital_signs
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
import json
|
| 4 |
import re
|
| 5 |
+
import base64
|
| 6 |
+
import mimetypes
|
| 7 |
from typing import Any, Dict, List, Optional, Tuple
|
| 8 |
|
| 9 |
from src.models.emr import ExtractedData, LabResult, Medication, VitalSigns
|
|
|
|
| 194 |
vital_signs=vital_signs,
|
| 195 |
lab_results=lab_results,
|
| 196 |
procedures=data.get('procedures', []),
|
| 197 |
+
notes=data.get('notes', '') + (f"\n\nDocument Overview: {data.get('overview', '')}" if data.get('overview') else '')
|
| 198 |
)
|
| 199 |
|
| 200 |
return extracted_data, confidence
|
|
|
|
| 260 |
vital_signs['oxygen_saturation'] = o2_match.group(1)
|
| 261 |
|
| 262 |
return vital_signs
|
| 263 |
+
|
| 264 |
+
async def analyze_document(self, file_content: bytes, filename: str, patient_context: Optional[Dict[str, Any]] = None) -> Tuple[ExtractedData, float]:
|
| 265 |
+
"""
|
| 266 |
+
Analyze a medical document (PDF, image, or text) and extract structured medical data.
|
| 267 |
+
|
| 268 |
+
Args:
|
| 269 |
+
file_content: The binary content of the uploaded file
|
| 270 |
+
filename: The name of the uploaded file
|
| 271 |
+
patient_context: Optional patient context information
|
| 272 |
+
|
| 273 |
+
Returns:
|
| 274 |
+
Tuple of (ExtractedData, confidence_score)
|
| 275 |
+
"""
|
| 276 |
+
try:
|
| 277 |
+
# Determine file type and prepare content for Gemini
|
| 278 |
+
mime_type, _ = mimetypes.guess_type(filename)
|
| 279 |
+
|
| 280 |
+
if not mime_type:
|
| 281 |
+
logger().warning(f"Unknown file type for {filename}")
|
| 282 |
+
return ExtractedData(), 0.0
|
| 283 |
+
|
| 284 |
+
# Encode file content to base64
|
| 285 |
+
file_base64 = base64.b64encode(file_content).decode('utf-8')
|
| 286 |
+
|
| 287 |
+
# Build the prompt for document analysis
|
| 288 |
+
prompt = self._build_document_analysis_prompt(file_base64, mime_type, filename, patient_context)
|
| 289 |
+
|
| 290 |
+
# Get response from Gemini
|
| 291 |
+
response = await self._call_gemini_api(prompt)
|
| 292 |
+
|
| 293 |
+
# Parse the response
|
| 294 |
+
extracted_data, confidence = self._parse_gemini_response(response)
|
| 295 |
+
|
| 296 |
+
logger().info(f"Successfully analyzed document {filename} with confidence {confidence:.2f}")
|
| 297 |
+
return extracted_data, confidence
|
| 298 |
+
|
| 299 |
+
except Exception as e:
|
| 300 |
+
logger().error(f"Error analyzing document {filename}: {e}")
|
| 301 |
+
# Return empty data with low confidence
|
| 302 |
+
return ExtractedData(), 0.0
|
| 303 |
+
|
| 304 |
+
def _build_document_analysis_prompt(self, file_base64: str, mime_type: str, filename: str, patient_context: Optional[Dict[str, Any]] = None) -> str:
|
| 305 |
+
"""Build the prompt for Gemini AI to analyze medical documents."""
|
| 306 |
+
|
| 307 |
+
context_info = ""
|
| 308 |
+
if patient_context:
|
| 309 |
+
context_info = f"""
|
| 310 |
+
Patient Context:
|
| 311 |
+
- Name: {patient_context.get('name', 'Unknown')}
|
| 312 |
+
- Age: {patient_context.get('age', 'Unknown')}
|
| 313 |
+
- Sex: {patient_context.get('sex', 'Unknown')}
|
| 314 |
+
- Current Medications: {', '.join(patient_context.get('medications', []))}
|
| 315 |
+
- Past Assessment Summary: {patient_context.get('past_assessment_summary', 'None')}
|
| 316 |
+
"""
|
| 317 |
+
|
| 318 |
+
# Determine the content type for Gemini
|
| 319 |
+
if mime_type.startswith('image/'):
|
| 320 |
+
content_type = "image"
|
| 321 |
+
elif mime_type == 'application/pdf':
|
| 322 |
+
content_type = "pdf"
|
| 323 |
+
elif mime_type in ['application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document']:
|
| 324 |
+
content_type = "document"
|
| 325 |
+
else:
|
| 326 |
+
content_type = "text"
|
| 327 |
+
|
| 328 |
+
prompt = f"""You are a medical AI assistant specialized in analyzing medical documents and extracting structured clinical information.
|
| 329 |
+
|
| 330 |
+
{context_info}
|
| 331 |
+
|
| 332 |
+
Please analyze the following medical document and extract all relevant clinical information in the specified JSON format.
|
| 333 |
+
|
| 334 |
+
Document Information:
|
| 335 |
+
- Filename: {filename}
|
| 336 |
+
- Content Type: {content_type}
|
| 337 |
+
- MIME Type: {mime_type}
|
| 338 |
+
|
| 339 |
+
Document Content (Base64 encoded):
|
| 340 |
+
{file_base64}
|
| 341 |
+
|
| 342 |
+
Extract the following information and return ONLY a valid JSON object with this exact structure:
|
| 343 |
+
|
| 344 |
+
{{
|
| 345 |
+
"overview": "Brief summary of the document content and main findings",
|
| 346 |
+
"diagnosis": ["list of diagnoses mentioned or identified"],
|
| 347 |
+
"symptoms": ["list of symptoms described"],
|
| 348 |
+
"medications": [
|
| 349 |
+
{{
|
| 350 |
+
"name": "medication name",
|
| 351 |
+
"dosage": "dosage if mentioned",
|
| 352 |
+
"frequency": "frequency if mentioned",
|
| 353 |
+
"duration": "duration if mentioned"
|
| 354 |
+
}}
|
| 355 |
+
],
|
| 356 |
+
"vital_signs": {{
|
| 357 |
+
"blood_pressure": "value if mentioned",
|
| 358 |
+
"heart_rate": "value if mentioned",
|
| 359 |
+
"temperature": "value if mentioned",
|
| 360 |
+
"respiratory_rate": "value if mentioned",
|
| 361 |
+
"oxygen_saturation": "value if mentioned"
|
| 362 |
+
}},
|
| 363 |
+
"lab_results": [
|
| 364 |
+
{{
|
| 365 |
+
"test_name": "test name",
|
| 366 |
+
"value": "test value",
|
| 367 |
+
"unit": "unit if mentioned",
|
| 368 |
+
"reference_range": "normal range if mentioned"
|
| 369 |
+
}}
|
| 370 |
+
],
|
| 371 |
+
"procedures": ["list of procedures mentioned or performed"],
|
| 372 |
+
"notes": "additional clinical notes and observations"
|
| 373 |
+
}}
|
| 374 |
+
|
| 375 |
+
Guidelines for Document Analysis:
|
| 376 |
+
1. Carefully read and analyze the entire document content
|
| 377 |
+
2. Extract information that is explicitly mentioned or clearly documented
|
| 378 |
+
3. Use medical terminology appropriately and maintain accuracy
|
| 379 |
+
4. If a field has no relevant information, use an empty array [] or null
|
| 380 |
+
5. For medications, include all prescribed, recommended, or mentioned medications
|
| 381 |
+
6. Extract vital signs only if specific values are documented
|
| 382 |
+
7. Include lab results only if specific test values are provided
|
| 383 |
+
8. Be thorough but conservative - prioritize accuracy over completeness
|
| 384 |
+
9. For images, focus on visible text, charts, and medical data
|
| 385 |
+
10. For PDFs and documents, analyze all text content systematically
|
| 386 |
+
11. Return ONLY the JSON object, no additional text or explanation
|
| 387 |
+
|
| 388 |
+
Confidence Assessment:
|
| 389 |
+
After the JSON, provide a confidence score (0.0-1.0) based on:
|
| 390 |
+
- Document clarity and readability
|
| 391 |
+
- Specificity of medical information
|
| 392 |
+
- Presence of measurable values (vitals, lab results)
|
| 393 |
+
- Overall clinical relevance and completeness
|
| 394 |
+
- Document type and quality
|
| 395 |
+
|
| 396 |
+
Format: CONFIDENCE: 0.85
|
| 397 |
+
|
| 398 |
+
Return the JSON followed by the confidence score on a new line."""
|
| 399 |
+
|
| 400 |
+
return prompt
|
src/services/guard.py
ADDED
|
@@ -0,0 +1,518 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import re
|
| 3 |
+
import requests
|
| 4 |
+
import logging
|
| 5 |
+
from typing import Tuple, List, Dict
|
| 6 |
+
|
| 7 |
+
from src.config.settings import settings
|
| 8 |
+
from src.utils.rotator import APIKeyRotator
|
| 9 |
+
|
| 10 |
+
logger = logging.getLogger(__name__)
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class SafetyGuard:
|
| 14 |
+
"""
|
| 15 |
+
Wrapper around NVIDIA Llama Guard (meta/llama-guard-4-12b) hosted at
|
| 16 |
+
https://integrate.api.nvidia.com/v1/chat/completions
|
| 17 |
+
|
| 18 |
+
Exposes helpers to validate:
|
| 19 |
+
- user input safety
|
| 20 |
+
- model output safety (in context of the user question)
|
| 21 |
+
"""
|
| 22 |
+
NVIDIA_GUARD = os.getenv("NVIDIA_GUARD", "meta/llama-guard-4-12b")
|
| 23 |
+
|
| 24 |
+
def __init__(self, nvidia_rotator: APIKeyRotator = None):
|
| 25 |
+
self.nvidia_rotator = nvidia_rotator or APIKeyRotator("NVIDIA_API_", max_slots=5)
|
| 26 |
+
if not self.nvidia_rotator.get_key():
|
| 27 |
+
raise ValueError("No NVIDIA API keys found. Set NVIDIA_API_1, NVIDIA_API_2, etc. environment variables")
|
| 28 |
+
self.base_url = "https://integrate.api.nvidia.com/v1/chat/completions"
|
| 29 |
+
self.model = NVIDIA_GUARD
|
| 30 |
+
self.timeout_s = settings.SAFETY_GUARD_TIMEOUT
|
| 31 |
+
self.enabled = settings.SAFETY_GUARD_ENABLED
|
| 32 |
+
self.fail_open = settings.SAFETY_GUARD_FAIL_OPEN
|
| 33 |
+
|
| 34 |
+
@staticmethod
|
| 35 |
+
def _chunk_text(text: str, chunk_size: int = 2800, overlap: int = 200) -> List[str]:
|
| 36 |
+
"""Chunk long text to keep request payloads small enough for the guard.
|
| 37 |
+
Uses character-based approximation with small overlap.
|
| 38 |
+
"""
|
| 39 |
+
if not text:
|
| 40 |
+
return [""]
|
| 41 |
+
n = len(text)
|
| 42 |
+
if n <= chunk_size:
|
| 43 |
+
return [text]
|
| 44 |
+
chunks: List[str] = []
|
| 45 |
+
start = 0
|
| 46 |
+
while start < n:
|
| 47 |
+
end = min(start + chunk_size, n)
|
| 48 |
+
chunks.append(text[start:end])
|
| 49 |
+
if end == n:
|
| 50 |
+
break
|
| 51 |
+
start = max(0, end - overlap)
|
| 52 |
+
return chunks
|
| 53 |
+
|
| 54 |
+
def _call_guard(self, messages: List[Dict], max_tokens: int = 512) -> str:
|
| 55 |
+
# Enhance messages with medical context if detected
|
| 56 |
+
enhanced_messages = self._enhance_messages_with_context(messages)
|
| 57 |
+
|
| 58 |
+
api_key = self.nvidia_rotator.get_key()
|
| 59 |
+
if not api_key:
|
| 60 |
+
logger.error("[SafetyGuard] No NVIDIA API key available")
|
| 61 |
+
return ""
|
| 62 |
+
|
| 63 |
+
headers = {
|
| 64 |
+
"Authorization": f"Bearer {api_key}",
|
| 65 |
+
"Content-Type": "application/json",
|
| 66 |
+
"Accept": "application/json",
|
| 67 |
+
}
|
| 68 |
+
# Try OpenAI-compatible schema first
|
| 69 |
+
payload_chat = {
|
| 70 |
+
"model": self.model,
|
| 71 |
+
"messages": enhanced_messages,
|
| 72 |
+
"temperature": 0.2,
|
| 73 |
+
"top_p": 0.7,
|
| 74 |
+
"max_tokens": max_tokens,
|
| 75 |
+
"stream": False,
|
| 76 |
+
}
|
| 77 |
+
# Alternative schema (some NVIDIA deployments require message content objects)
|
| 78 |
+
alt_messages = []
|
| 79 |
+
for m in enhanced_messages:
|
| 80 |
+
content = m.get("content", "")
|
| 81 |
+
if isinstance(content, str):
|
| 82 |
+
content = [{"type": "text", "text": content}]
|
| 83 |
+
alt_messages.append({"role": m.get("role", "user"), "content": content})
|
| 84 |
+
payload_alt = {
|
| 85 |
+
"model": self.model,
|
| 86 |
+
"messages": alt_messages,
|
| 87 |
+
"temperature": 0.2,
|
| 88 |
+
"top_p": 0.7,
|
| 89 |
+
"max_tokens": max_tokens,
|
| 90 |
+
"stream": False,
|
| 91 |
+
}
|
| 92 |
+
# Attempt primary, then fallback
|
| 93 |
+
for payload in (payload_chat, payload_alt):
|
| 94 |
+
try:
|
| 95 |
+
resp = requests.post(self.base_url, headers=headers, json=payload, timeout=self.timeout_s)
|
| 96 |
+
if resp.status_code in {401, 403, 429} or 500 <= resp.status_code < 600:
|
| 97 |
+
# Rotate API key and retry
|
| 98 |
+
logger.warning(f"[SafetyGuard] HTTP {resp.status_code}, rotating API key")
|
| 99 |
+
self.nvidia_rotator.rotate()
|
| 100 |
+
api_key = self.nvidia_rotator.get_key()
|
| 101 |
+
if api_key:
|
| 102 |
+
headers["Authorization"] = f"Bearer {api_key}"
|
| 103 |
+
continue
|
| 104 |
+
else:
|
| 105 |
+
logger.error("[SafetyGuard] No more API keys available")
|
| 106 |
+
return ""
|
| 107 |
+
elif resp.status_code >= 400:
|
| 108 |
+
# Log server message for debugging payload issues
|
| 109 |
+
try:
|
| 110 |
+
logger.error(f"[SafetyGuard] HTTP {resp.status_code}: {resp.text[:400]}")
|
| 111 |
+
except Exception:
|
| 112 |
+
pass
|
| 113 |
+
resp.raise_for_status()
|
| 114 |
+
data = resp.json()
|
| 115 |
+
content = (
|
| 116 |
+
data.get("choices", [{}])[0]
|
| 117 |
+
.get("message", {})
|
| 118 |
+
.get("content", "")
|
| 119 |
+
.strip()
|
| 120 |
+
)
|
| 121 |
+
if content:
|
| 122 |
+
return content
|
| 123 |
+
except Exception as e:
|
| 124 |
+
# Try next payload shape
|
| 125 |
+
logger.error(f"[SafetyGuard] Guard API call failed: {e}")
|
| 126 |
+
continue
|
| 127 |
+
# All attempts failed
|
| 128 |
+
return ""
|
| 129 |
+
|
| 130 |
+
@staticmethod
|
| 131 |
+
def _parse_guard_reply(text: str) -> Tuple[bool, str]:
|
| 132 |
+
"""Parse guard reply; expect 'SAFE' or 'UNSAFE: <reason>' (case-insensitive)."""
|
| 133 |
+
if not text:
|
| 134 |
+
# Fail-open: treat as SAFE if guard unavailable to avoid false blocks
|
| 135 |
+
return True, "guard_unavailable"
|
| 136 |
+
t = text.strip()
|
| 137 |
+
upper = t.upper()
|
| 138 |
+
if upper.startswith("SAFE") and not upper.startswith("SAFEGUARD"):
|
| 139 |
+
return True, ""
|
| 140 |
+
if upper.startswith("UNSAFE"):
|
| 141 |
+
# Extract reason after the first colon if present
|
| 142 |
+
parts = t.split(":", 1)
|
| 143 |
+
reason = parts[1].strip() if len(parts) > 1 else "policy violation"
|
| 144 |
+
return False, reason
|
| 145 |
+
# Fallback: treat unknown response as unsafe
|
| 146 |
+
return False, t[:180]
|
| 147 |
+
|
| 148 |
+
def _is_medical_query(self, query: str) -> bool:
|
| 149 |
+
"""Check if query is clearly medical in nature using comprehensive patterns."""
|
| 150 |
+
if not query:
|
| 151 |
+
return False
|
| 152 |
+
|
| 153 |
+
query_lower = query.lower()
|
| 154 |
+
|
| 155 |
+
# Medical keyword categories
|
| 156 |
+
medical_categories = {
|
| 157 |
+
'symptoms': [
|
| 158 |
+
'symptom', 'pain', 'ache', 'hurt', 'sore', 'tender', 'stiff', 'numb',
|
| 159 |
+
'headache', 'migraine', 'fever', 'cough', 'cold', 'flu', 'sneeze',
|
| 160 |
+
'nausea', 'vomit', 'diarrhea', 'constipation', 'bloating', 'gas',
|
| 161 |
+
'dizziness', 'vertigo', 'fatigue', 'weakness', 'tired', 'exhausted',
|
| 162 |
+
'shortness of breath', 'wheezing', 'chest pain', 'heart palpitations',
|
| 163 |
+
'joint pain', 'muscle pain', 'back pain', 'neck pain', 'stomach pain',
|
| 164 |
+
'abdominal pain', 'pelvic pain', 'menstrual pain', 'cramps'
|
| 165 |
+
],
|
| 166 |
+
'conditions': [
|
| 167 |
+
'disease', 'condition', 'disorder', 'syndrome', 'illness', 'sickness',
|
| 168 |
+
'infection', 'inflammation', 'allergy', 'asthma', 'diabetes', 'hypertension',
|
| 169 |
+
'depression', 'anxiety', 'stress', 'panic', 'phobia', 'ocd', 'ptsd',
|
| 170 |
+
'adhd', 'autism', 'dementia', 'alzheimer', 'parkinson', 'epilepsy',
|
| 171 |
+
'cancer', 'tumor', 'cancerous', 'malignant', 'benign', 'metastasis',
|
| 172 |
+
'heart disease', 'stroke', 'heart attack', 'coronary', 'arrhythmia',
|
| 173 |
+
'pneumonia', 'bronchitis', 'copd', 'emphysema', 'tuberculosis',
|
| 174 |
+
'migraine', 'headache', 'chronic migraine', 'cluster headache',
|
| 175 |
+
'tension headache', 'sinus headache', 'cure', 'treat', 'treatment'
|
| 176 |
+
],
|
| 177 |
+
'treatments': [
|
| 178 |
+
'treatment', 'therapy', 'medication', 'medicine', 'drug', 'pill', 'tablet',
|
| 179 |
+
'injection', 'vaccine', 'immunization', 'surgery', 'operation', 'procedure',
|
| 180 |
+
'chemotherapy', 'radiation', 'physical therapy', 'occupational therapy',
|
| 181 |
+
'psychotherapy', 'counseling', 'rehabilitation', 'recovery', 'healing',
|
| 182 |
+
'prescription', 'dosage', 'side effects', 'contraindications'
|
| 183 |
+
],
|
| 184 |
+
'body_parts': [
|
| 185 |
+
'head', 'brain', 'eye', 'ear', 'nose', 'mouth', 'throat', 'neck',
|
| 186 |
+
'chest', 'heart', 'lung', 'liver', 'kidney', 'stomach', 'intestine',
|
| 187 |
+
'back', 'spine', 'joint', 'muscle', 'bone', 'skin', 'hair', 'nail',
|
| 188 |
+
'arm', 'leg', 'hand', 'foot', 'finger', 'toe', 'pelvis', 'genital'
|
| 189 |
+
],
|
| 190 |
+
'medical_context': [
|
| 191 |
+
'doctor', 'physician', 'nurse', 'specialist', 'surgeon', 'dentist',
|
| 192 |
+
'medical', 'health', 'healthcare', 'hospital', 'clinic', 'emergency',
|
| 193 |
+
'ambulance', 'paramedic', 'pharmacy', 'pharmacist', 'lab', 'test',
|
| 194 |
+
'diagnosis', 'prognosis', 'examination', 'checkup', 'screening',
|
| 195 |
+
'patient', 'case', 'history', 'medical history', 'family history'
|
| 196 |
+
],
|
| 197 |
+
'life_stages': [
|
| 198 |
+
'pregnancy', 'pregnant', 'baby', 'infant', 'newborn', 'child', 'pediatric',
|
| 199 |
+
'teenager', 'adolescent', 'adult', 'elderly', 'senior', 'geriatric',
|
| 200 |
+
'menopause', 'puberty', 'aging', 'birth', 'delivery', 'miscarriage'
|
| 201 |
+
],
|
| 202 |
+
'vital_signs': [
|
| 203 |
+
'blood pressure', 'heart rate', 'pulse', 'temperature', 'fever',
|
| 204 |
+
'respiratory rate', 'oxygen saturation', 'weight', 'height', 'bmi',
|
| 205 |
+
'blood sugar', 'glucose', 'cholesterol', 'hemoglobin', 'white blood cell'
|
| 206 |
+
]
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
# Check for medical keywords
|
| 210 |
+
for category, keywords in medical_categories.items():
|
| 211 |
+
if any(keyword in query_lower for keyword in keywords):
|
| 212 |
+
return True
|
| 213 |
+
|
| 214 |
+
# Check for medical question patterns
|
| 215 |
+
medical_patterns = [
|
| 216 |
+
r'\b(what|how|why|when|where)\s+(causes?|treats?|prevents?|symptoms?|signs?)\b',
|
| 217 |
+
r'\b(is|are)\s+(.*?)\s+(dangerous|serious|harmful|safe|normal)\b',
|
| 218 |
+
r'\b(should|can|may|might)\s+(i|you|we)\s+(take|use|do|avoid)\b',
|
| 219 |
+
r'\b(diagnosis|diagnosed|symptoms|treatment|medicine|drug)\b',
|
| 220 |
+
r'\b(medical|health|doctor|physician|hospital|clinic)\b',
|
| 221 |
+
r'\b(pain|hurt|ache|sore|fever|cough|headache)\b',
|
| 222 |
+
r'\b(which\s+medication|best\s+medication|how\s+to\s+cure|without\s+medications)\b',
|
| 223 |
+
r'\b(chronic\s+migraine|migraine\s+treatment|migraine\s+cure)\b',
|
| 224 |
+
r'\b(cure|treat|heal|relief|remedy|solution)\b'
|
| 225 |
+
]
|
| 226 |
+
|
| 227 |
+
for pattern in medical_patterns:
|
| 228 |
+
if re.search(pattern, query_lower):
|
| 229 |
+
return True
|
| 230 |
+
|
| 231 |
+
return False
|
| 232 |
+
|
| 233 |
+
def check_user_query(self, user_query: str) -> Tuple[bool, str]:
|
| 234 |
+
"""Validate the user query is safe to process with medical context awareness."""
|
| 235 |
+
if not self.enabled:
|
| 236 |
+
logger.info("[SafetyGuard] Safety guard disabled, allowing query through")
|
| 237 |
+
return True, "guard_disabled"
|
| 238 |
+
|
| 239 |
+
text = user_query or ""
|
| 240 |
+
|
| 241 |
+
# For medical queries, be more permissive
|
| 242 |
+
if self._is_medical_query(text):
|
| 243 |
+
logger.info("[SafetyGuard] Medical query detected, skipping strict validation")
|
| 244 |
+
return True, "medical_query"
|
| 245 |
+
|
| 246 |
+
# If too long, validate each chunk; any UNSAFE makes overall UNSAFE
|
| 247 |
+
for part in self._chunk_text(text):
|
| 248 |
+
messages = [{"role": "user", "content": part}]
|
| 249 |
+
reply = self._call_guard(messages, max_tokens=64)
|
| 250 |
+
ok, reason = self._parse_guard_reply(reply)
|
| 251 |
+
if not ok:
|
| 252 |
+
return False, reason
|
| 253 |
+
return True, ""
|
| 254 |
+
|
| 255 |
+
def _detect_harmful_content(self, text: str) -> Tuple[bool, str]:
|
| 256 |
+
"""Detect harmful content using sophisticated pattern matching."""
|
| 257 |
+
if not text:
|
| 258 |
+
return True, ""
|
| 259 |
+
|
| 260 |
+
text_lower = text.lower()
|
| 261 |
+
|
| 262 |
+
# First check if this is clearly medical content - be more permissive
|
| 263 |
+
if self._is_medical_query(text):
|
| 264 |
+
# For medical content, only check for truly dangerous patterns
|
| 265 |
+
dangerous_medical_patterns = {
|
| 266 |
+
'suicide_self_harm': [
|
| 267 |
+
r'\b(kill\s+yourself|suicide|end\s+your\s+life|take\s+your\s+life)\b',
|
| 268 |
+
r'\b(self\s*harm|self\s*injury|cut\s+yourself|hurt\s+yourself)\b',
|
| 269 |
+
r'\b(overdose|poison\s+yourself|hang\s+yourself)\b'
|
| 270 |
+
],
|
| 271 |
+
'dangerous_medical_advice': [
|
| 272 |
+
r'\b(overdose\s+on|take\s+too\s+much|excessive\s+dosage)\b',
|
| 273 |
+
r'\b(mix\s+drugs|combine\s+medications|contraindicated)\b',
|
| 274 |
+
r'\b(stop\s+taking\s+prescribed|discontinue\s+medication)\b',
|
| 275 |
+
r'\b(don\'t\s+call\s+911|avoid\s+emergency\s+room|ignore\s+severe\s+symptoms)\b'
|
| 276 |
+
],
|
| 277 |
+
'illegal_drugs': [
|
| 278 |
+
r'\b(cocaine|heroin|methamphetamine|ecstasy|lsd|marijuana\s+abuse)\b',
|
| 279 |
+
r'\b(prescription\s+abuse|drug\s+dealing|illegal\s+substances)\b'
|
| 280 |
+
]
|
| 281 |
+
}
|
| 282 |
+
|
| 283 |
+
# Check only dangerous medical patterns
|
| 284 |
+
for category, patterns in dangerous_medical_patterns.items():
|
| 285 |
+
for pattern in patterns:
|
| 286 |
+
if re.search(pattern, text_lower):
|
| 287 |
+
return False, f"contains {category}: {pattern}"
|
| 288 |
+
|
| 289 |
+
# Allow medical content through
|
| 290 |
+
return True, "medical_content"
|
| 291 |
+
|
| 292 |
+
# For non-medical content, use stricter patterns
|
| 293 |
+
critical_patterns = {
|
| 294 |
+
'suicide_self_harm': [
|
| 295 |
+
r'\b(kill\s+yourself|suicide|end\s+your\s+life|take\s+your\s+life)\b',
|
| 296 |
+
r'\b(self\s*harm|self\s*injury|cut\s+yourself|hurt\s+yourself)\b',
|
| 297 |
+
r'\b(overdose|poison\s+yourself|hang\s+yourself)\b'
|
| 298 |
+
],
|
| 299 |
+
'violence': [
|
| 300 |
+
r'\b(kill\s+someone|murder|assassinate|violence|harm\s+others)\b',
|
| 301 |
+
r'\b(bomb|explosive|weapon|gun|knife)\b',
|
| 302 |
+
r'\b(attack\s+(someone|people|others|innocent))\b' # More specific attack pattern
|
| 303 |
+
],
|
| 304 |
+
'illegal_drugs': [
|
| 305 |
+
r'\b(cocaine|heroin|methamphetamine|ecstasy|lsd|marijuana\s+abuse)\b',
|
| 306 |
+
r'\b(prescription\s+abuse|drug\s+dealing|illegal\s+substances)\b'
|
| 307 |
+
],
|
| 308 |
+
'dangerous_medical': [
|
| 309 |
+
r'\b(overdose\s+on|take\s+too\s+much|excessive\s+dosage)\b',
|
| 310 |
+
r'\b(mix\s+drugs|combine\s+medications|contraindicated)\b',
|
| 311 |
+
r'\b(stop\s+taking\s+prescribed|discontinue\s+medication)\b'
|
| 312 |
+
]
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
# Check critical patterns
|
| 316 |
+
for category, patterns in critical_patterns.items():
|
| 317 |
+
for pattern in patterns:
|
| 318 |
+
if re.search(pattern, text_lower):
|
| 319 |
+
return False, f"contains {category}: {pattern}"
|
| 320 |
+
|
| 321 |
+
# Context-aware medical safety checks
|
| 322 |
+
medical_safety_patterns = {
|
| 323 |
+
'dosage_warnings': [
|
| 324 |
+
r'\b(take\s+more\s+than\s+prescribed|exceed\s+recommended\s+dose)\b',
|
| 325 |
+
r'\b(double\s+up\s+on\s+medication|take\s+extra\s+pills)\b'
|
| 326 |
+
],
|
| 327 |
+
'emergency_advice': [
|
| 328 |
+
r'\b(don\'t\s+call\s+911|avoid\s+emergency\s+room|ignore\s+severe\s+symptoms)\b',
|
| 329 |
+
r'\b(self\s*treat\s+emergency|handle\s+at\s+home\s+when\s+critical)\b'
|
| 330 |
+
]
|
| 331 |
+
}
|
| 332 |
+
|
| 333 |
+
# Check medical safety patterns
|
| 334 |
+
for category, patterns in medical_safety_patterns.items():
|
| 335 |
+
for pattern in patterns:
|
| 336 |
+
if re.search(pattern, text_lower):
|
| 337 |
+
return False, f"contains dangerous medical advice: {pattern}"
|
| 338 |
+
|
| 339 |
+
return True, ""
|
| 340 |
+
|
| 341 |
+
def _enhance_messages_with_context(self, messages: List[Dict]) -> List[Dict]:
|
| 342 |
+
"""Enhance messages with medical context awareness for better guard performance."""
|
| 343 |
+
if not messages:
|
| 344 |
+
return messages
|
| 345 |
+
|
| 346 |
+
# Check if any message contains medical content
|
| 347 |
+
has_medical_content = any(
|
| 348 |
+
self._is_medical_query(msg.get("content", "")) for msg in messages
|
| 349 |
+
)
|
| 350 |
+
|
| 351 |
+
if has_medical_content:
|
| 352 |
+
# Add medical context to the system prompt
|
| 353 |
+
enhanced_messages = []
|
| 354 |
+
for i, msg in enumerate(messages):
|
| 355 |
+
if i == 0 and msg.get("role") == "user":
|
| 356 |
+
# Add medical context to first user message
|
| 357 |
+
original_content = msg.get("content", "")
|
| 358 |
+
enhanced_content = f"""MEDICAL CONTEXT: This is a medical consultation. The user is asking about health-related topics.
|
| 359 |
+
|
| 360 |
+
ORIGINAL MESSAGE: {original_content}
|
| 361 |
+
|
| 362 |
+
Please evaluate this message for safety, keeping in mind that:
|
| 363 |
+
- Medical questions and health concerns are legitimate
|
| 364 |
+
- General medical information sharing is acceptable
|
| 365 |
+
- Only block content that is clearly harmful, dangerous, or illegal
|
| 366 |
+
- Medical advice should be educational, not prescriptive"""
|
| 367 |
+
|
| 368 |
+
enhanced_messages.append({
|
| 369 |
+
"role": msg["role"],
|
| 370 |
+
"content": enhanced_content
|
| 371 |
+
})
|
| 372 |
+
else:
|
| 373 |
+
enhanced_messages.append(msg)
|
| 374 |
+
return enhanced_messages
|
| 375 |
+
|
| 376 |
+
return messages
|
| 377 |
+
|
| 378 |
+
def _assess_risk_level(self, text: str) -> Tuple[str, float]:
|
| 379 |
+
"""Assess the risk level of content using multiple indicators."""
|
| 380 |
+
if not text:
|
| 381 |
+
return "low", 0.0
|
| 382 |
+
|
| 383 |
+
text_lower = text.lower()
|
| 384 |
+
|
| 385 |
+
# If this is medical content, be more lenient
|
| 386 |
+
if self._is_medical_query(text):
|
| 387 |
+
# For medical content, only flag truly dangerous patterns
|
| 388 |
+
dangerous_medical_indicators = {
|
| 389 |
+
'high': [
|
| 390 |
+
'suicide', 'kill yourself', 'end your life', 'self harm',
|
| 391 |
+
'overdose', 'poison yourself', 'illegal drugs', 'violence'
|
| 392 |
+
],
|
| 393 |
+
'medium': [
|
| 394 |
+
'prescription abuse', 'excessive dosage', 'mix drugs',
|
| 395 |
+
'stop taking prescribed', 'ignore severe symptoms'
|
| 396 |
+
]
|
| 397 |
+
}
|
| 398 |
+
|
| 399 |
+
risk_score = 0.0
|
| 400 |
+
for level, indicators in dangerous_medical_indicators.items():
|
| 401 |
+
for indicator in indicators:
|
| 402 |
+
if indicator in text_lower:
|
| 403 |
+
if level == 'high':
|
| 404 |
+
risk_score += 3.0
|
| 405 |
+
elif level == 'medium':
|
| 406 |
+
risk_score += 1.5
|
| 407 |
+
|
| 408 |
+
# Normalize score for medical content (more lenient)
|
| 409 |
+
risk_score = min(risk_score / 15.0, 1.0)
|
| 410 |
+
|
| 411 |
+
if risk_score >= 0.6:
|
| 412 |
+
return "high", risk_score
|
| 413 |
+
elif risk_score >= 0.2:
|
| 414 |
+
return "medium", risk_score
|
| 415 |
+
else:
|
| 416 |
+
return "low", risk_score
|
| 417 |
+
|
| 418 |
+
# For non-medical content, use original risk assessment
|
| 419 |
+
risk_indicators = {
|
| 420 |
+
'high': [
|
| 421 |
+
'suicide', 'kill yourself', 'end your life', 'self harm',
|
| 422 |
+
'overdose', 'poison', 'illegal drugs', 'violence', 'harm others'
|
| 423 |
+
],
|
| 424 |
+
'medium': [
|
| 425 |
+
'prescription abuse', 'excessive dosage', 'mix drugs',
|
| 426 |
+
'stop medication', 'ignore symptoms', 'avoid doctor'
|
| 427 |
+
],
|
| 428 |
+
'low': [
|
| 429 |
+
'pain', 'headache', 'fever', 'cough', 'treatment',
|
| 430 |
+
'medicine', 'doctor', 'hospital', 'symptoms'
|
| 431 |
+
]
|
| 432 |
+
}
|
| 433 |
+
|
| 434 |
+
risk_score = 0.0
|
| 435 |
+
for level, indicators in risk_indicators.items():
|
| 436 |
+
for indicator in indicators:
|
| 437 |
+
if indicator in text_lower:
|
| 438 |
+
if level == 'high':
|
| 439 |
+
risk_score += 3.0
|
| 440 |
+
elif level == 'medium':
|
| 441 |
+
risk_score += 1.5
|
| 442 |
+
else:
|
| 443 |
+
risk_score += 0.5
|
| 444 |
+
|
| 445 |
+
# Normalize score
|
| 446 |
+
risk_score = min(risk_score / 10.0, 1.0)
|
| 447 |
+
|
| 448 |
+
if risk_score >= 0.7:
|
| 449 |
+
return "high", risk_score
|
| 450 |
+
elif risk_score >= 0.3:
|
| 451 |
+
return "medium", risk_score
|
| 452 |
+
else:
|
| 453 |
+
return "low", risk_score
|
| 454 |
+
|
| 455 |
+
def check_model_answer(self, user_query: str, model_answer: str) -> Tuple[bool, str]:
|
| 456 |
+
"""Validate the model's answer is safe with medical context awareness."""
|
| 457 |
+
if not self.enabled:
|
| 458 |
+
logger.info("[SafetyGuard] Safety guard disabled, allowing response through")
|
| 459 |
+
return True, "guard_disabled"
|
| 460 |
+
|
| 461 |
+
uq = user_query or ""
|
| 462 |
+
ans = model_answer or ""
|
| 463 |
+
|
| 464 |
+
# Assess risk level first
|
| 465 |
+
risk_level, risk_score = self._assess_risk_level(ans)
|
| 466 |
+
logger.info(f"[SafetyGuard] Risk assessment: {risk_level} (score: {risk_score:.2f})")
|
| 467 |
+
|
| 468 |
+
# Always check for harmful content first
|
| 469 |
+
is_safe, reason = self._detect_harmful_content(ans)
|
| 470 |
+
if not is_safe:
|
| 471 |
+
return False, reason
|
| 472 |
+
|
| 473 |
+
# For high-risk content, always use strict validation
|
| 474 |
+
if risk_level == "high":
|
| 475 |
+
logger.warning("[SafetyGuard] High-risk content detected, using strict validation")
|
| 476 |
+
user_parts = self._chunk_text(uq, chunk_size=2000)
|
| 477 |
+
user_context = user_parts[0] if user_parts else ""
|
| 478 |
+
for ans_part in self._chunk_text(ans):
|
| 479 |
+
messages = [
|
| 480 |
+
{"role": "user", "content": user_context},
|
| 481 |
+
{"role": "assistant", "content": ans_part},
|
| 482 |
+
]
|
| 483 |
+
reply = self._call_guard(messages, max_tokens=96)
|
| 484 |
+
ok, reason = self._parse_guard_reply(reply)
|
| 485 |
+
if not ok:
|
| 486 |
+
return False, reason
|
| 487 |
+
return True, "high_risk_validated"
|
| 488 |
+
|
| 489 |
+
# For medical queries and answers, use relaxed validation
|
| 490 |
+
if self._is_medical_query(uq) or self._is_medical_query(ans):
|
| 491 |
+
logger.info("[SafetyGuard] Medical content detected, using relaxed validation")
|
| 492 |
+
return True, "medical_content"
|
| 493 |
+
|
| 494 |
+
# For medium-risk non-medical content, use guard validation
|
| 495 |
+
if risk_level == "medium":
|
| 496 |
+
logger.info("[SafetyGuard] Medium-risk content detected, using guard validation")
|
| 497 |
+
user_parts = self._chunk_text(uq, chunk_size=2000)
|
| 498 |
+
user_context = user_parts[0] if user_parts else ""
|
| 499 |
+
for ans_part in self._chunk_text(ans):
|
| 500 |
+
messages = [
|
| 501 |
+
{"role": "user", "content": user_context},
|
| 502 |
+
{"role": "assistant", "content": ans_part},
|
| 503 |
+
]
|
| 504 |
+
reply = self._call_guard(messages, max_tokens=96)
|
| 505 |
+
ok, reason = self._parse_guard_reply(reply)
|
| 506 |
+
if not ok:
|
| 507 |
+
return False, reason
|
| 508 |
+
return True, "medium_risk_validated"
|
| 509 |
+
|
| 510 |
+
# For low-risk content, allow through
|
| 511 |
+
logger.info("[SafetyGuard] Low-risk content detected, allowing through")
|
| 512 |
+
return True, "low_risk"
|
| 513 |
+
|
| 514 |
+
|
| 515 |
+
# Global instance (optional convenience) - will be initialized with rotator when needed
|
| 516 |
+
safety_guard = None
|
| 517 |
+
|
| 518 |
+
|
src/services/medical_response.py
CHANGED
|
@@ -3,6 +3,7 @@
|
|
| 3 |
from src.core import prompt_builder
|
| 4 |
from src.data.medical_kb import search_medical_kb
|
| 5 |
from src.services.gemini import gemini_chat
|
|
|
|
| 6 |
from src.utils.logger import logger
|
| 7 |
from src.utils.rotator import APIKeyRotator
|
| 8 |
|
|
@@ -12,7 +13,8 @@ async def generate_medical_response(
|
|
| 12 |
user_role: str,
|
| 13 |
user_specialty: str,
|
| 14 |
rotator: APIKeyRotator,
|
| 15 |
-
medical_context: str = ""
|
|
|
|
| 16 |
) -> str:
|
| 17 |
"""Generates an intelligent, contextual medical response using Gemini AI."""
|
| 18 |
prompt = prompt_builder.medical_response_prompt(user_role, user_specialty, medical_context, user_message)
|
|
@@ -25,6 +27,22 @@ async def generate_medical_response(
|
|
| 25 |
if "disclaimer" not in response_text.lower() and "consult" not in response_text.lower():
|
| 26 |
response_text += "\n\nβ οΈ **Important Disclaimer:** This information is for educational purposes only and should not replace professional medical advice, diagnosis, or treatment. Always consult with qualified healthcare professionals."
|
| 27 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
logger().info(f"Gemini response generated, length: {len(response_text)} chars")
|
| 29 |
return response_text
|
| 30 |
|
|
|
|
| 3 |
from src.core import prompt_builder
|
| 4 |
from src.data.medical_kb import search_medical_kb
|
| 5 |
from src.services.gemini import gemini_chat
|
| 6 |
+
from src.services.guard import SafetyGuard
|
| 7 |
from src.utils.logger import logger
|
| 8 |
from src.utils.rotator import APIKeyRotator
|
| 9 |
|
|
|
|
| 13 |
user_role: str,
|
| 14 |
user_specialty: str,
|
| 15 |
rotator: APIKeyRotator,
|
| 16 |
+
medical_context: str = "",
|
| 17 |
+
nvidia_rotator: APIKeyRotator = None
|
| 18 |
) -> str:
|
| 19 |
"""Generates an intelligent, contextual medical response using Gemini AI."""
|
| 20 |
prompt = prompt_builder.medical_response_prompt(user_role, user_specialty, medical_context, user_message)
|
|
|
|
| 27 |
if "disclaimer" not in response_text.lower() and "consult" not in response_text.lower():
|
| 28 |
response_text += "\n\nβ οΈ **Important Disclaimer:** This information is for educational purposes only and should not replace professional medical advice, diagnosis, or treatment. Always consult with qualified healthcare professionals."
|
| 29 |
|
| 30 |
+
# Safety Guard: Validate the generated response
|
| 31 |
+
if nvidia_rotator:
|
| 32 |
+
try:
|
| 33 |
+
safety_guard = SafetyGuard(nvidia_rotator)
|
| 34 |
+
is_safe, safety_reason = safety_guard.check_model_answer(user_message, response_text)
|
| 35 |
+
if not is_safe:
|
| 36 |
+
logger().warning(f"Safety guard blocked generated response: {safety_reason}")
|
| 37 |
+
# Return safe fallback response
|
| 38 |
+
return "I apologize, but I cannot provide a response to that query as it may contain unsafe content. Please consult with a qualified healthcare professional for medical advice."
|
| 39 |
+
else:
|
| 40 |
+
logger().info(f"Generated response passed safety validation: {safety_reason}")
|
| 41 |
+
except Exception as e:
|
| 42 |
+
logger().error(f"Safety guard error in medical response: {e}")
|
| 43 |
+
# Fail open for now - allow response through if guard fails
|
| 44 |
+
logger().warning("Safety guard failed, allowing generated response through")
|
| 45 |
+
|
| 46 |
logger().info(f"Gemini response generated, length: {len(response_text)} chars")
|
| 47 |
return response_text
|
| 48 |
|
src/services/service.py
CHANGED
|
@@ -131,7 +131,7 @@ class EMRService:
|
|
| 131 |
|
| 132 |
# Generate embeddings
|
| 133 |
combined_text = " | ".join(text_parts)
|
| 134 |
-
embeddings =
|
| 135 |
|
| 136 |
return embeddings
|
| 137 |
|
|
@@ -181,7 +181,7 @@ class EMRService:
|
|
| 181 |
"""Search EMR entries using semantic similarity."""
|
| 182 |
try:
|
| 183 |
# Generate embeddings for the search query
|
| 184 |
-
query_embeddings =
|
| 185 |
|
| 186 |
# Search using semantic similarity
|
| 187 |
entries = search_emr_by_semantic_similarity(
|
|
|
|
| 131 |
|
| 132 |
# Generate embeddings
|
| 133 |
combined_text = " | ".join(text_parts)
|
| 134 |
+
embeddings = self.embedding_client.embed(combined_text)[0]
|
| 135 |
|
| 136 |
return embeddings
|
| 137 |
|
|
|
|
| 181 |
"""Search EMR entries using semantic similarity."""
|
| 182 |
try:
|
| 183 |
# Generate embeddings for the search query
|
| 184 |
+
query_embeddings = self.embedding_client.embed(query)[0]
|
| 185 |
|
| 186 |
# Search using semantic similarity
|
| 187 |
entries = search_emr_by_semantic_similarity(
|
static/css/emr.css
CHANGED
|
@@ -115,6 +115,365 @@
|
|
| 115 |
letter-spacing: 0.05em;
|
| 116 |
}
|
| 117 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
/* Controls */
|
| 119 |
.emr-controls {
|
| 120 |
background-color: var(--bg-primary);
|
|
@@ -531,4 +890,291 @@
|
|
| 531 |
.emr-table tbody tr:hover {
|
| 532 |
background-color: var(--dark-bg-secondary);
|
| 533 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 534 |
}
|
|
|
|
| 115 |
letter-spacing: 0.05em;
|
| 116 |
}
|
| 117 |
|
| 118 |
+
/* File Upload Section */
|
| 119 |
+
.emr-upload-section {
|
| 120 |
+
background-color: var(--bg-primary);
|
| 121 |
+
border-bottom: 1px solid var(--border-color);
|
| 122 |
+
padding: var(--spacing-lg);
|
| 123 |
+
max-width: 1200px;
|
| 124 |
+
margin: 0 auto;
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
.upload-container {
|
| 128 |
+
max-width: 600px;
|
| 129 |
+
margin: 0 auto;
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
.upload-header {
|
| 133 |
+
text-align: center;
|
| 134 |
+
margin-bottom: var(--spacing-lg);
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
.upload-header h3 {
|
| 138 |
+
margin: 0 0 var(--spacing-sm) 0;
|
| 139 |
+
color: var(--text-primary);
|
| 140 |
+
font-size: 1.25rem;
|
| 141 |
+
font-weight: 600;
|
| 142 |
+
display: flex;
|
| 143 |
+
align-items: center;
|
| 144 |
+
justify-content: center;
|
| 145 |
+
gap: var(--spacing-sm);
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
.upload-header p {
|
| 149 |
+
margin: 0;
|
| 150 |
+
color: var(--text-secondary);
|
| 151 |
+
font-size: 0.875rem;
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
.upload-area {
|
| 155 |
+
border: 2px dashed var(--border-color);
|
| 156 |
+
border-radius: 12px;
|
| 157 |
+
padding: var(--spacing-2xl);
|
| 158 |
+
text-align: center;
|
| 159 |
+
background-color: var(--bg-secondary);
|
| 160 |
+
transition: all var(--transition-fast);
|
| 161 |
+
cursor: pointer;
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
.upload-area:hover {
|
| 165 |
+
border-color: var(--primary-color);
|
| 166 |
+
background-color: var(--bg-tertiary);
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
.upload-area.dragover {
|
| 170 |
+
border-color: var(--primary-color);
|
| 171 |
+
background-color: var(--primary-color);
|
| 172 |
+
color: white;
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
.upload-content i {
|
| 176 |
+
font-size: 3rem;
|
| 177 |
+
color: var(--primary-color);
|
| 178 |
+
margin-bottom: var(--spacing-md);
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
.upload-area.dragover .upload-content i {
|
| 182 |
+
color: white;
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
.upload-content h4 {
|
| 186 |
+
margin: 0 0 var(--spacing-sm) 0;
|
| 187 |
+
color: var(--text-primary);
|
| 188 |
+
font-size: 1.125rem;
|
| 189 |
+
font-weight: 600;
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
.upload-area.dragover .upload-content h4 {
|
| 193 |
+
color: white;
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
.upload-content p {
|
| 197 |
+
margin: 0 0 var(--spacing-lg) 0;
|
| 198 |
+
color: var(--text-secondary);
|
| 199 |
+
font-size: 0.875rem;
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
.upload-area.dragover .upload-content p {
|
| 203 |
+
color: white;
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
.upload-progress {
|
| 207 |
+
margin-top: var(--spacing-lg);
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
.progress-bar {
|
| 211 |
+
width: 100%;
|
| 212 |
+
height: 8px;
|
| 213 |
+
background-color: var(--bg-tertiary);
|
| 214 |
+
border-radius: 4px;
|
| 215 |
+
overflow: hidden;
|
| 216 |
+
margin-bottom: var(--spacing-sm);
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
.progress-fill {
|
| 220 |
+
height: 100%;
|
| 221 |
+
background-color: var(--primary-color);
|
| 222 |
+
transition: width var(--transition-normal);
|
| 223 |
+
width: 0%;
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
.progress-text {
|
| 227 |
+
font-size: 0.875rem;
|
| 228 |
+
color: var(--text-secondary);
|
| 229 |
+
text-align: center;
|
| 230 |
+
display: block;
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
/* Document Preview Modal */
|
| 234 |
+
.document-preview-modal {
|
| 235 |
+
max-width: 900px;
|
| 236 |
+
max-height: 85vh;
|
| 237 |
+
overflow-y: auto;
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
.document-preview-section {
|
| 241 |
+
margin-bottom: var(--spacing-lg);
|
| 242 |
+
background-color: var(--bg-secondary);
|
| 243 |
+
border-radius: 8px;
|
| 244 |
+
padding: var(--spacing-md);
|
| 245 |
+
border: 1px solid var(--border-color);
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
.document-preview-section h4 {
|
| 249 |
+
margin: 0 0 var(--spacing-md) 0;
|
| 250 |
+
color: var(--text-primary);
|
| 251 |
+
font-size: 1rem;
|
| 252 |
+
font-weight: 600;
|
| 253 |
+
border-bottom: 1px solid var(--border-color);
|
| 254 |
+
padding-bottom: var(--spacing-sm);
|
| 255 |
+
display: flex;
|
| 256 |
+
align-items: center;
|
| 257 |
+
gap: var(--spacing-sm);
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
.document-preview-section h4 i {
|
| 261 |
+
color: var(--primary-color);
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
.editable-field {
|
| 265 |
+
background-color: var(--bg-primary);
|
| 266 |
+
border: 1px solid var(--border-color);
|
| 267 |
+
border-radius: 6px;
|
| 268 |
+
padding: var(--spacing-sm);
|
| 269 |
+
margin-bottom: var(--spacing-sm);
|
| 270 |
+
min-height: 40px;
|
| 271 |
+
transition: border-color var(--transition-fast);
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
.editable-field:focus {
|
| 275 |
+
outline: none;
|
| 276 |
+
border-color: var(--primary-color);
|
| 277 |
+
box-shadow: 0 0 0 3px rgb(37 99 235 / 0.1);
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
.editable-list {
|
| 281 |
+
list-style: none;
|
| 282 |
+
padding: 0;
|
| 283 |
+
margin: 0;
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
.editable-list li {
|
| 287 |
+
background-color: var(--bg-primary);
|
| 288 |
+
border: 1px solid var(--border-color);
|
| 289 |
+
border-radius: 6px;
|
| 290 |
+
padding: var(--spacing-sm);
|
| 291 |
+
margin-bottom: var(--spacing-sm);
|
| 292 |
+
display: flex;
|
| 293 |
+
align-items: center;
|
| 294 |
+
gap: var(--spacing-sm);
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
+
.editable-list li input {
|
| 298 |
+
flex: 1;
|
| 299 |
+
background: none;
|
| 300 |
+
border: none;
|
| 301 |
+
outline: none;
|
| 302 |
+
color: var(--text-primary);
|
| 303 |
+
font-size: 0.875rem;
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
.editable-list li button {
|
| 307 |
+
background: none;
|
| 308 |
+
border: none;
|
| 309 |
+
color: var(--accent-color);
|
| 310 |
+
cursor: pointer;
|
| 311 |
+
padding: var(--spacing-xs);
|
| 312 |
+
border-radius: 4px;
|
| 313 |
+
transition: background-color var(--transition-fast);
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
.editable-list li button:hover {
|
| 317 |
+
background-color: var(--bg-tertiary);
|
| 318 |
+
}
|
| 319 |
+
|
| 320 |
+
.add-item-btn {
|
| 321 |
+
background-color: var(--primary-color);
|
| 322 |
+
color: white;
|
| 323 |
+
border: none;
|
| 324 |
+
border-radius: 6px;
|
| 325 |
+
padding: var(--spacing-sm) var(--spacing-md);
|
| 326 |
+
cursor: pointer;
|
| 327 |
+
font-size: 0.875rem;
|
| 328 |
+
transition: background-color var(--transition-fast);
|
| 329 |
+
display: flex;
|
| 330 |
+
align-items: center;
|
| 331 |
+
gap: var(--spacing-xs);
|
| 332 |
+
}
|
| 333 |
+
|
| 334 |
+
.add-item-btn:hover {
|
| 335 |
+
background-color: var(--primary-hover);
|
| 336 |
+
}
|
| 337 |
+
|
| 338 |
+
.medication-preview-item {
|
| 339 |
+
background-color: var(--bg-primary);
|
| 340 |
+
border: 1px solid var(--border-color);
|
| 341 |
+
border-radius: 8px;
|
| 342 |
+
padding: var(--spacing-md);
|
| 343 |
+
margin-bottom: var(--spacing-sm);
|
| 344 |
+
}
|
| 345 |
+
|
| 346 |
+
.medication-preview-item h5 {
|
| 347 |
+
margin: 0 0 var(--spacing-sm) 0;
|
| 348 |
+
color: var(--text-primary);
|
| 349 |
+
font-size: 1rem;
|
| 350 |
+
font-weight: 600;
|
| 351 |
+
}
|
| 352 |
+
|
| 353 |
+
.medication-detail-row {
|
| 354 |
+
display: grid;
|
| 355 |
+
grid-template-columns: 1fr 1fr;
|
| 356 |
+
gap: var(--spacing-md);
|
| 357 |
+
margin-bottom: var(--spacing-sm);
|
| 358 |
+
}
|
| 359 |
+
|
| 360 |
+
.medication-detail-row:last-child {
|
| 361 |
+
margin-bottom: 0;
|
| 362 |
+
}
|
| 363 |
+
|
| 364 |
+
.medication-detail-row label {
|
| 365 |
+
font-size: 0.75rem;
|
| 366 |
+
color: var(--text-secondary);
|
| 367 |
+
text-transform: uppercase;
|
| 368 |
+
letter-spacing: 0.05em;
|
| 369 |
+
margin-bottom: var(--spacing-xs);
|
| 370 |
+
display: block;
|
| 371 |
+
}
|
| 372 |
+
|
| 373 |
+
.medication-detail-row input {
|
| 374 |
+
width: 100%;
|
| 375 |
+
background-color: var(--bg-secondary);
|
| 376 |
+
border: 1px solid var(--border-color);
|
| 377 |
+
border-radius: 4px;
|
| 378 |
+
padding: var(--spacing-sm);
|
| 379 |
+
color: var(--text-primary);
|
| 380 |
+
font-size: 0.875rem;
|
| 381 |
+
}
|
| 382 |
+
|
| 383 |
+
.medication-detail-row input:focus {
|
| 384 |
+
outline: none;
|
| 385 |
+
border-color: var(--primary-color);
|
| 386 |
+
}
|
| 387 |
+
|
| 388 |
+
.vital-signs-preview-grid {
|
| 389 |
+
display: grid;
|
| 390 |
+
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
| 391 |
+
gap: var(--spacing-md);
|
| 392 |
+
}
|
| 393 |
+
|
| 394 |
+
.vital-sign-preview-item {
|
| 395 |
+
background-color: var(--bg-primary);
|
| 396 |
+
border: 1px solid var(--border-color);
|
| 397 |
+
border-radius: 8px;
|
| 398 |
+
padding: var(--spacing-md);
|
| 399 |
+
text-align: center;
|
| 400 |
+
}
|
| 401 |
+
|
| 402 |
+
.vital-sign-preview-item label {
|
| 403 |
+
font-size: 0.75rem;
|
| 404 |
+
color: var(--text-secondary);
|
| 405 |
+
text-transform: uppercase;
|
| 406 |
+
letter-spacing: 0.05em;
|
| 407 |
+
margin-bottom: var(--spacing-xs);
|
| 408 |
+
display: block;
|
| 409 |
+
}
|
| 410 |
+
|
| 411 |
+
.vital-sign-preview-item input {
|
| 412 |
+
width: 100%;
|
| 413 |
+
background-color: var(--bg-secondary);
|
| 414 |
+
border: 1px solid var(--border-color);
|
| 415 |
+
border-radius: 4px;
|
| 416 |
+
padding: var(--spacing-sm);
|
| 417 |
+
color: var(--text-primary);
|
| 418 |
+
font-size: 0.875rem;
|
| 419 |
+
text-align: center;
|
| 420 |
+
}
|
| 421 |
+
|
| 422 |
+
.vital-sign-preview-item input:focus {
|
| 423 |
+
outline: none;
|
| 424 |
+
border-color: var(--primary-color);
|
| 425 |
+
}
|
| 426 |
+
|
| 427 |
+
.lab-result-preview-item {
|
| 428 |
+
background-color: var(--bg-primary);
|
| 429 |
+
border: 1px solid var(--border-color);
|
| 430 |
+
border-radius: 8px;
|
| 431 |
+
padding: var(--spacing-md);
|
| 432 |
+
margin-bottom: var(--spacing-sm);
|
| 433 |
+
}
|
| 434 |
+
|
| 435 |
+
.lab-result-preview-item h5 {
|
| 436 |
+
margin: 0 0 var(--spacing-sm) 0;
|
| 437 |
+
color: var(--text-primary);
|
| 438 |
+
font-size: 1rem;
|
| 439 |
+
font-weight: 600;
|
| 440 |
+
}
|
| 441 |
+
|
| 442 |
+
.lab-result-detail-row {
|
| 443 |
+
display: grid;
|
| 444 |
+
grid-template-columns: 1fr 1fr 1fr;
|
| 445 |
+
gap: var(--spacing-md);
|
| 446 |
+
margin-bottom: var(--spacing-sm);
|
| 447 |
+
}
|
| 448 |
+
|
| 449 |
+
.lab-result-detail-row:last-child {
|
| 450 |
+
margin-bottom: 0;
|
| 451 |
+
}
|
| 452 |
+
|
| 453 |
+
.lab-result-detail-row label {
|
| 454 |
+
font-size: 0.75rem;
|
| 455 |
+
color: var(--text-secondary);
|
| 456 |
+
text-transform: uppercase;
|
| 457 |
+
letter-spacing: 0.05em;
|
| 458 |
+
margin-bottom: var(--spacing-xs);
|
| 459 |
+
display: block;
|
| 460 |
+
}
|
| 461 |
+
|
| 462 |
+
.lab-result-detail-row input {
|
| 463 |
+
width: 100%;
|
| 464 |
+
background-color: var(--bg-secondary);
|
| 465 |
+
border: 1px solid var(--border-color);
|
| 466 |
+
border-radius: 4px;
|
| 467 |
+
padding: var(--spacing-sm);
|
| 468 |
+
color: var(--text-primary);
|
| 469 |
+
font-size: 0.875rem;
|
| 470 |
+
}
|
| 471 |
+
|
| 472 |
+
.lab-result-detail-row input:focus {
|
| 473 |
+
outline: none;
|
| 474 |
+
border-color: var(--primary-color);
|
| 475 |
+
}
|
| 476 |
+
|
| 477 |
/* Controls */
|
| 478 |
.emr-controls {
|
| 479 |
background-color: var(--bg-primary);
|
|
|
|
| 890 |
.emr-table tbody tr:hover {
|
| 891 |
background-color: var(--dark-bg-secondary);
|
| 892 |
}
|
| 893 |
+
}
|
| 894 |
+
|
| 895 |
+
/* Tabbed Interface */
|
| 896 |
+
.emr-tabs {
|
| 897 |
+
display: flex;
|
| 898 |
+
background-color: var(--bg-primary);
|
| 899 |
+
border-bottom: 1px solid var(--border-color);
|
| 900 |
+
margin-bottom: var(--spacing-lg);
|
| 901 |
+
border-radius: 8px 8px 0 0;
|
| 902 |
+
overflow-x: auto;
|
| 903 |
+
}
|
| 904 |
+
|
| 905 |
+
.tab-btn {
|
| 906 |
+
background: none;
|
| 907 |
+
border: none;
|
| 908 |
+
padding: var(--spacing-md) var(--spacing-lg);
|
| 909 |
+
cursor: pointer;
|
| 910 |
+
color: var(--text-secondary);
|
| 911 |
+
font-weight: 500;
|
| 912 |
+
transition: all var(--transition-fast);
|
| 913 |
+
border-bottom: 3px solid transparent;
|
| 914 |
+
white-space: nowrap;
|
| 915 |
+
min-width: 120px;
|
| 916 |
+
text-align: center;
|
| 917 |
+
}
|
| 918 |
+
|
| 919 |
+
.tab-btn:hover {
|
| 920 |
+
color: var(--primary-color);
|
| 921 |
+
background-color: var(--bg-secondary);
|
| 922 |
+
}
|
| 923 |
+
|
| 924 |
+
.tab-btn.active {
|
| 925 |
+
color: var(--primary-color);
|
| 926 |
+
border-bottom-color: var(--primary-color);
|
| 927 |
+
background-color: var(--bg-secondary);
|
| 928 |
+
}
|
| 929 |
+
|
| 930 |
+
.tab-content {
|
| 931 |
+
display: none;
|
| 932 |
+
animation: fadeIn 0.3s ease-in-out;
|
| 933 |
+
}
|
| 934 |
+
|
| 935 |
+
.tab-content.active {
|
| 936 |
+
display: block;
|
| 937 |
+
}
|
| 938 |
+
|
| 939 |
+
@keyframes fadeIn {
|
| 940 |
+
from { opacity: 0; transform: translateY(10px); }
|
| 941 |
+
to { opacity: 1; transform: translateY(0); }
|
| 942 |
+
}
|
| 943 |
+
|
| 944 |
+
/* EMR Sections */
|
| 945 |
+
.emr-section {
|
| 946 |
+
background-color: var(--bg-primary);
|
| 947 |
+
border-radius: 8px;
|
| 948 |
+
padding: var(--spacing-lg);
|
| 949 |
+
margin-bottom: var(--spacing-lg);
|
| 950 |
+
box-shadow: var(--shadow-sm);
|
| 951 |
+
}
|
| 952 |
+
|
| 953 |
+
.emr-section h3 {
|
| 954 |
+
margin: 0 0 var(--spacing-lg) 0;
|
| 955 |
+
color: var(--text-primary);
|
| 956 |
+
font-size: 1.25rem;
|
| 957 |
+
font-weight: 600;
|
| 958 |
+
border-bottom: 2px solid var(--primary-color);
|
| 959 |
+
padding-bottom: var(--spacing-sm);
|
| 960 |
+
}
|
| 961 |
+
|
| 962 |
+
/* Diagnosis Timeline */
|
| 963 |
+
.diagnosis-timeline {
|
| 964 |
+
display: flex;
|
| 965 |
+
flex-direction: column;
|
| 966 |
+
gap: var(--spacing-md);
|
| 967 |
+
}
|
| 968 |
+
|
| 969 |
+
.diagnosis-item {
|
| 970 |
+
display: flex;
|
| 971 |
+
align-items: center;
|
| 972 |
+
padding: var(--spacing-md);
|
| 973 |
+
background-color: var(--bg-secondary);
|
| 974 |
+
border-radius: 8px;
|
| 975 |
+
border-left: 4px solid var(--primary-color);
|
| 976 |
+
transition: all var(--transition-fast);
|
| 977 |
+
}
|
| 978 |
+
|
| 979 |
+
.diagnosis-item:hover {
|
| 980 |
+
background-color: var(--bg-tertiary);
|
| 981 |
+
transform: translateX(4px);
|
| 982 |
+
}
|
| 983 |
+
|
| 984 |
+
.diagnosis-date {
|
| 985 |
+
font-size: 0.875rem;
|
| 986 |
+
color: var(--text-muted);
|
| 987 |
+
min-width: 120px;
|
| 988 |
+
margin-right: var(--spacing-md);
|
| 989 |
+
}
|
| 990 |
+
|
| 991 |
+
.diagnosis-name {
|
| 992 |
+
font-weight: 600;
|
| 993 |
+
color: var(--text-primary);
|
| 994 |
+
flex: 1;
|
| 995 |
+
}
|
| 996 |
+
|
| 997 |
+
.diagnosis-confidence {
|
| 998 |
+
background-color: var(--primary-color);
|
| 999 |
+
color: white;
|
| 1000 |
+
padding: var(--spacing-xs) var(--spacing-sm);
|
| 1001 |
+
border-radius: 12px;
|
| 1002 |
+
font-size: 0.75rem;
|
| 1003 |
+
font-weight: 600;
|
| 1004 |
+
}
|
| 1005 |
+
|
| 1006 |
+
/* Medications Grid */
|
| 1007 |
+
.medications-grid {
|
| 1008 |
+
display: grid;
|
| 1009 |
+
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
| 1010 |
+
gap: var(--spacing-md);
|
| 1011 |
+
}
|
| 1012 |
+
|
| 1013 |
+
.medication-card {
|
| 1014 |
+
background-color: var(--bg-secondary);
|
| 1015 |
+
border-radius: 8px;
|
| 1016 |
+
padding: var(--spacing-md);
|
| 1017 |
+
border: 1px solid var(--border-color);
|
| 1018 |
+
transition: all var(--transition-fast);
|
| 1019 |
+
}
|
| 1020 |
+
|
| 1021 |
+
.medication-card:hover {
|
| 1022 |
+
border-color: var(--primary-color);
|
| 1023 |
+
box-shadow: var(--shadow-md);
|
| 1024 |
+
}
|
| 1025 |
+
|
| 1026 |
+
.medication-name {
|
| 1027 |
+
font-weight: 600;
|
| 1028 |
+
color: var(--text-primary);
|
| 1029 |
+
margin-bottom: var(--spacing-sm);
|
| 1030 |
+
font-size: 1.1rem;
|
| 1031 |
+
}
|
| 1032 |
+
|
| 1033 |
+
.medication-details {
|
| 1034 |
+
display: flex;
|
| 1035 |
+
flex-direction: column;
|
| 1036 |
+
gap: var(--spacing-xs);
|
| 1037 |
+
color: var(--text-secondary);
|
| 1038 |
+
font-size: 0.875rem;
|
| 1039 |
+
}
|
| 1040 |
+
|
| 1041 |
+
.medication-detail {
|
| 1042 |
+
display: flex;
|
| 1043 |
+
justify-content: space-between;
|
| 1044 |
+
}
|
| 1045 |
+
|
| 1046 |
+
.medication-detail strong {
|
| 1047 |
+
color: var(--text-primary);
|
| 1048 |
+
}
|
| 1049 |
+
|
| 1050 |
+
/* Vital Signs Chart */
|
| 1051 |
+
.vitals-chart-container {
|
| 1052 |
+
background-color: var(--bg-secondary);
|
| 1053 |
+
border-radius: 8px;
|
| 1054 |
+
padding: var(--spacing-lg);
|
| 1055 |
+
margin-bottom: var(--spacing-lg);
|
| 1056 |
+
text-align: center;
|
| 1057 |
+
}
|
| 1058 |
+
|
| 1059 |
+
.vitals-chart-container canvas {
|
| 1060 |
+
max-width: 100%;
|
| 1061 |
+
height: auto;
|
| 1062 |
+
}
|
| 1063 |
+
|
| 1064 |
+
/* Vital Signs Table */
|
| 1065 |
+
.vitals-table-container {
|
| 1066 |
+
overflow-x: auto;
|
| 1067 |
+
}
|
| 1068 |
+
|
| 1069 |
+
.vitals-table {
|
| 1070 |
+
width: 100%;
|
| 1071 |
+
border-collapse: collapse;
|
| 1072 |
+
background-color: var(--bg-primary);
|
| 1073 |
+
border-radius: 8px;
|
| 1074 |
+
overflow: hidden;
|
| 1075 |
+
box-shadow: var(--shadow-sm);
|
| 1076 |
+
}
|
| 1077 |
+
|
| 1078 |
+
.vitals-table th,
|
| 1079 |
+
.vitals-table td {
|
| 1080 |
+
padding: var(--spacing-md);
|
| 1081 |
+
text-align: left;
|
| 1082 |
+
border-bottom: 1px solid var(--border-color);
|
| 1083 |
+
}
|
| 1084 |
+
|
| 1085 |
+
.vitals-table th {
|
| 1086 |
+
background-color: var(--bg-tertiary);
|
| 1087 |
+
font-weight: 600;
|
| 1088 |
+
color: var(--text-primary);
|
| 1089 |
+
}
|
| 1090 |
+
|
| 1091 |
+
.vitals-table td {
|
| 1092 |
+
color: var(--text-secondary);
|
| 1093 |
+
}
|
| 1094 |
+
|
| 1095 |
+
.vitals-table tr:hover {
|
| 1096 |
+
background-color: var(--bg-secondary);
|
| 1097 |
+
}
|
| 1098 |
+
|
| 1099 |
+
/* Lab Results */
|
| 1100 |
+
.lab-results-container {
|
| 1101 |
+
display: flex;
|
| 1102 |
+
flex-direction: column;
|
| 1103 |
+
gap: var(--spacing-md);
|
| 1104 |
+
}
|
| 1105 |
+
|
| 1106 |
+
.lab-result-item {
|
| 1107 |
+
background-color: var(--bg-secondary);
|
| 1108 |
+
border-radius: 8px;
|
| 1109 |
+
padding: var(--spacing-md);
|
| 1110 |
+
border-left: 4px solid var(--secondary-color);
|
| 1111 |
+
}
|
| 1112 |
+
|
| 1113 |
+
.lab-result-header {
|
| 1114 |
+
display: flex;
|
| 1115 |
+
justify-content: space-between;
|
| 1116 |
+
align-items: center;
|
| 1117 |
+
margin-bottom: var(--spacing-sm);
|
| 1118 |
+
}
|
| 1119 |
+
|
| 1120 |
+
.lab-test-name {
|
| 1121 |
+
font-weight: 600;
|
| 1122 |
+
color: var(--text-primary);
|
| 1123 |
+
}
|
| 1124 |
+
|
| 1125 |
+
.lab-test-value {
|
| 1126 |
+
font-size: 1.1rem;
|
| 1127 |
+
font-weight: 600;
|
| 1128 |
+
color: var(--secondary-color);
|
| 1129 |
+
}
|
| 1130 |
+
|
| 1131 |
+
.lab-test-details {
|
| 1132 |
+
display: flex;
|
| 1133 |
+
gap: var(--spacing-md);
|
| 1134 |
+
color: var(--text-secondary);
|
| 1135 |
+
font-size: 0.875rem;
|
| 1136 |
+
}
|
| 1137 |
+
|
| 1138 |
+
/* Procedures Timeline */
|
| 1139 |
+
.procedures-timeline {
|
| 1140 |
+
display: flex;
|
| 1141 |
+
flex-direction: column;
|
| 1142 |
+
gap: var(--spacing-md);
|
| 1143 |
+
}
|
| 1144 |
+
|
| 1145 |
+
.procedure-item {
|
| 1146 |
+
display: flex;
|
| 1147 |
+
align-items: center;
|
| 1148 |
+
padding: var(--spacing-md);
|
| 1149 |
+
background-color: var(--bg-secondary);
|
| 1150 |
+
border-radius: 8px;
|
| 1151 |
+
border-left: 4px solid var(--accent-color);
|
| 1152 |
+
transition: all var(--transition-fast);
|
| 1153 |
+
}
|
| 1154 |
+
|
| 1155 |
+
.procedure-item:hover {
|
| 1156 |
+
background-color: var(--bg-tertiary);
|
| 1157 |
+
transform: translateX(4px);
|
| 1158 |
+
}
|
| 1159 |
+
|
| 1160 |
+
.procedure-date {
|
| 1161 |
+
font-size: 0.875rem;
|
| 1162 |
+
color: var(--text-muted);
|
| 1163 |
+
min-width: 120px;
|
| 1164 |
+
margin-right: var(--spacing-md);
|
| 1165 |
+
}
|
| 1166 |
+
|
| 1167 |
+
.procedure-name {
|
| 1168 |
+
font-weight: 600;
|
| 1169 |
+
color: var(--text-primary);
|
| 1170 |
+
flex: 1;
|
| 1171 |
+
}
|
| 1172 |
+
|
| 1173 |
+
.procedure-status {
|
| 1174 |
+
background-color: var(--accent-color);
|
| 1175 |
+
color: white;
|
| 1176 |
+
padding: var(--spacing-xs) var(--spacing-sm);
|
| 1177 |
+
border-radius: 12px;
|
| 1178 |
+
font-size: 0.75rem;
|
| 1179 |
+
font-weight: 600;
|
| 1180 |
}
|
static/css/styles.css
CHANGED
|
@@ -456,9 +456,122 @@ body {
|
|
| 456 |
border-color: var(--primary-color);
|
| 457 |
}
|
| 458 |
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 462 |
}
|
| 463 |
|
| 464 |
.message-text p {
|
|
|
|
| 456 |
border-color: var(--primary-color);
|
| 457 |
}
|
| 458 |
|
| 459 |
+
/* Tagged Title Styling */
|
| 460 |
+
.tagged-title {
|
| 461 |
+
display: flex;
|
| 462 |
+
align-items: center;
|
| 463 |
+
margin: var(--spacing-lg) 0;
|
| 464 |
+
position: relative;
|
| 465 |
+
width: 100%;
|
| 466 |
+
}
|
| 467 |
+
|
| 468 |
+
.tagged-title-bar {
|
| 469 |
+
width: 4px;
|
| 470 |
+
height: 100%;
|
| 471 |
+
background-color: #20b2aa; /* Teal/green color */
|
| 472 |
+
border-radius: 2px;
|
| 473 |
+
margin-right: 0;
|
| 474 |
+
flex-shrink: 0;
|
| 475 |
+
min-height: 40px;
|
| 476 |
+
}
|
| 477 |
+
|
| 478 |
+
.tagged-title-content {
|
| 479 |
+
flex: 1;
|
| 480 |
+
background-color: #f5f5f5; /* Light gray background */
|
| 481 |
+
border-radius: 0 8px 8px 0;
|
| 482 |
+
padding: var(--spacing-md) var(--spacing-lg);
|
| 483 |
+
margin-left: 0;
|
| 484 |
+
display: flex;
|
| 485 |
+
align-items: center;
|
| 486 |
+
min-height: 40px;
|
| 487 |
+
}
|
| 488 |
+
|
| 489 |
+
.tagged-title-content h1,
|
| 490 |
+
.tagged-title-content h2,
|
| 491 |
+
.tagged-title-content h3,
|
| 492 |
+
.tagged-title-content h4,
|
| 493 |
+
.tagged-title-content h5,
|
| 494 |
+
.tagged-title-content h6 {
|
| 495 |
+
margin: 0;
|
| 496 |
+
color: #8b4513; /* Dark brown/maroon color */
|
| 497 |
+
font-weight: 700;
|
| 498 |
+
text-transform: uppercase;
|
| 499 |
+
font-size: 0.9rem;
|
| 500 |
+
letter-spacing: 0.5px;
|
| 501 |
+
}
|
| 502 |
+
|
| 503 |
+
/* Blue Bubble Styling */
|
| 504 |
+
.blue-bubble {
|
| 505 |
+
display: inline-block;
|
| 506 |
+
background-color: #4a90e2; /* Medium blue */
|
| 507 |
+
color: white;
|
| 508 |
+
padding: 4px 8px;
|
| 509 |
+
border-radius: 12px;
|
| 510 |
+
font-size: 0.85rem;
|
| 511 |
+
font-weight: 500;
|
| 512 |
+
margin: 0 2px;
|
| 513 |
+
white-space: nowrap;
|
| 514 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
| 515 |
+
line-height: 1.2;
|
| 516 |
+
vertical-align: baseline;
|
| 517 |
+
}
|
| 518 |
+
|
| 519 |
+
/* Dark theme adjustments */
|
| 520 |
+
[data-theme="dark"] .tagged-title-content {
|
| 521 |
+
background-color: #2a2a2a; /* Darker gray for dark theme */
|
| 522 |
+
}
|
| 523 |
+
|
| 524 |
+
[data-theme="dark"] .tagged-title-content h1,
|
| 525 |
+
[data-theme="dark"] .tagged-title-content h2,
|
| 526 |
+
[data-theme="dark"] .tagged-title-content h3,
|
| 527 |
+
[data-theme="dark"] .tagged-title-content h4,
|
| 528 |
+
[data-theme="dark"] .tagged-title-content h5,
|
| 529 |
+
[data-theme="dark"] .tagged-title-content h6 {
|
| 530 |
+
color: #d4a574; /* Lighter brown for dark theme */
|
| 531 |
+
}
|
| 532 |
+
|
| 533 |
+
[data-theme="dark"] .blue-bubble {
|
| 534 |
+
background-color: #5ba0f2; /* Slightly lighter blue for dark theme */
|
| 535 |
+
}
|
| 536 |
+
|
| 537 |
+
/* Ensure proper spacing and layout within message content */
|
| 538 |
+
.message-text .tagged-title {
|
| 539 |
+
margin: var(--spacing-md) 0;
|
| 540 |
+
}
|
| 541 |
+
|
| 542 |
+
.message-text .tagged-title:first-child {
|
| 543 |
+
margin-top: 0;
|
| 544 |
+
}
|
| 545 |
+
|
| 546 |
+
.message-text .tagged-title:last-child {
|
| 547 |
+
margin-bottom: 0;
|
| 548 |
+
}
|
| 549 |
+
|
| 550 |
+
/* Ensure blue bubbles work well within paragraphs */
|
| 551 |
+
.message-text p .blue-bubble {
|
| 552 |
+
margin: 0 4px;
|
| 553 |
+
}
|
| 554 |
+
|
| 555 |
+
/* Responsive adjustments for smaller screens */
|
| 556 |
+
@media (max-width: 768px) {
|
| 557 |
+
.tagged-title-content {
|
| 558 |
+
padding: var(--spacing-sm) var(--spacing-md);
|
| 559 |
+
min-height: 36px;
|
| 560 |
+
}
|
| 561 |
+
|
| 562 |
+
.tagged-title-content h1,
|
| 563 |
+
.tagged-title-content h2,
|
| 564 |
+
.tagged-title-content h3,
|
| 565 |
+
.tagged-title-content h4,
|
| 566 |
+
.tagged-title-content h5,
|
| 567 |
+
.tagged-title-content h6 {
|
| 568 |
+
font-size: 0.8rem;
|
| 569 |
+
}
|
| 570 |
+
|
| 571 |
+
.blue-bubble {
|
| 572 |
+
font-size: 0.8rem;
|
| 573 |
+
padding: 3px 6px;
|
| 574 |
+
}
|
| 575 |
}
|
| 576 |
|
| 577 |
.message-text p {
|
static/emr.html
CHANGED
|
@@ -51,6 +51,34 @@
|
|
| 51 |
</div>
|
| 52 |
</div>
|
| 53 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
<!-- Search and Filters -->
|
| 55 |
<div class="emr-controls">
|
| 56 |
<div class="search-container">
|
|
@@ -76,27 +104,107 @@
|
|
| 76 |
</div>
|
| 77 |
</div>
|
| 78 |
|
| 79 |
-
<!-- EMR
|
| 80 |
<div class="emr-content">
|
| 81 |
-
<div class="emr-
|
| 82 |
-
<
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
</div>
|
| 99 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
<!-- Loading State -->
|
| 101 |
<div class="loading-state" id="loadingState" style="display: none;">
|
| 102 |
<div class="loading-spinner">
|
|
@@ -159,6 +267,23 @@
|
|
| 159 |
</div>
|
| 160 |
</div>
|
| 161 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
</div>
|
| 163 |
|
| 164 |
<script src="/static/js/emr.js"></script>
|
|
|
|
| 51 |
</div>
|
| 52 |
</div>
|
| 53 |
|
| 54 |
+
<!-- File Upload Section -->
|
| 55 |
+
<div class="emr-upload-section">
|
| 56 |
+
<div class="upload-container">
|
| 57 |
+
<div class="upload-header">
|
| 58 |
+
<h3><i class="fas fa-upload"></i> Upload Medical Document</h3>
|
| 59 |
+
<p>Upload PDF, image, or document files to extract medical information</p>
|
| 60 |
+
</div>
|
| 61 |
+
<div class="upload-area" id="uploadArea">
|
| 62 |
+
<div class="upload-content">
|
| 63 |
+
<i class="fas fa-cloud-upload-alt"></i>
|
| 64 |
+
<h4>Drop files here or click to browse</h4>
|
| 65 |
+
<p>Supported formats: PDF, DOC, DOCX, JPG, PNG, TIFF</p>
|
| 66 |
+
<input type="file" id="fileInput" accept=".pdf,.doc,.docx,.jpg,.jpeg,.png,.tiff" multiple style="display: none;">
|
| 67 |
+
<button class="btn btn-primary" id="uploadBtn">
|
| 68 |
+
<i class="fas fa-folder-open"></i>
|
| 69 |
+
Choose Files
|
| 70 |
+
</button>
|
| 71 |
+
</div>
|
| 72 |
+
</div>
|
| 73 |
+
<div class="upload-progress" id="uploadProgress" style="display: none;">
|
| 74 |
+
<div class="progress-bar">
|
| 75 |
+
<div class="progress-fill" id="progressFill"></div>
|
| 76 |
+
</div>
|
| 77 |
+
<span class="progress-text" id="progressText">Uploading...</span>
|
| 78 |
+
</div>
|
| 79 |
+
</div>
|
| 80 |
+
</div>
|
| 81 |
+
|
| 82 |
<!-- Search and Filters -->
|
| 83 |
<div class="emr-controls">
|
| 84 |
<div class="search-container">
|
|
|
|
| 104 |
</div>
|
| 105 |
</div>
|
| 106 |
|
| 107 |
+
<!-- EMR Content Tabs -->
|
| 108 |
<div class="emr-content">
|
| 109 |
+
<div class="emr-tabs">
|
| 110 |
+
<button class="tab-btn active" data-tab="overview">Overview</button>
|
| 111 |
+
<button class="tab-btn" data-tab="diagnosis">Diagnoses</button>
|
| 112 |
+
<button class="tab-btn" data-tab="medications">Medications</button>
|
| 113 |
+
<button class="tab-btn" data-tab="vitals">Vital Signs</button>
|
| 114 |
+
<button class="tab-btn" data-tab="lab">Lab Results</button>
|
| 115 |
+
<button class="tab-btn" data-tab="procedures">Procedures</button>
|
| 116 |
+
</div>
|
| 117 |
+
|
| 118 |
+
<!-- Overview Tab -->
|
| 119 |
+
<div class="tab-content active" id="overview-tab">
|
| 120 |
+
<div class="emr-table-container">
|
| 121 |
+
<table class="emr-table" id="emrTable">
|
| 122 |
+
<thead>
|
| 123 |
+
<tr>
|
| 124 |
+
<th>Date & Time</th>
|
| 125 |
+
<th>Type</th>
|
| 126 |
+
<th>Diagnosis</th>
|
| 127 |
+
<th>Medications</th>
|
| 128 |
+
<th>Vital Signs</th>
|
| 129 |
+
<th>Confidence</th>
|
| 130 |
+
<th>Actions</th>
|
| 131 |
+
</tr>
|
| 132 |
+
</thead>
|
| 133 |
+
<tbody id="emrTableBody">
|
| 134 |
+
<!-- EMR entries will be populated here -->
|
| 135 |
+
</tbody>
|
| 136 |
+
</table>
|
| 137 |
+
</div>
|
| 138 |
</div>
|
| 139 |
|
| 140 |
+
<!-- Diagnosis Tab -->
|
| 141 |
+
<div class="tab-content" id="diagnosis-tab">
|
| 142 |
+
<div class="emr-section">
|
| 143 |
+
<h3>Diagnosis History</h3>
|
| 144 |
+
<div class="diagnosis-timeline" id="diagnosisTimeline">
|
| 145 |
+
<!-- Diagnosis entries will be populated here -->
|
| 146 |
+
</div>
|
| 147 |
+
</div>
|
| 148 |
+
</div>
|
| 149 |
+
|
| 150 |
+
<!-- Medications Tab -->
|
| 151 |
+
<div class="tab-content" id="medications-tab">
|
| 152 |
+
<div class="emr-section">
|
| 153 |
+
<h3>Medication History</h3>
|
| 154 |
+
<div class="medications-grid" id="medicationsGrid">
|
| 155 |
+
<!-- Medication entries will be populated here -->
|
| 156 |
+
</div>
|
| 157 |
+
</div>
|
| 158 |
+
</div>
|
| 159 |
+
|
| 160 |
+
<!-- Vital Signs Tab -->
|
| 161 |
+
<div class="tab-content" id="vitals-tab">
|
| 162 |
+
<div class="emr-section">
|
| 163 |
+
<h3>Vital Signs Trends</h3>
|
| 164 |
+
<div class="vitals-chart-container">
|
| 165 |
+
<canvas id="vitalsChart" width="800" height="400"></canvas>
|
| 166 |
+
</div>
|
| 167 |
+
<div class="vitals-table-container">
|
| 168 |
+
<table class="vitals-table" id="vitalsTable">
|
| 169 |
+
<thead>
|
| 170 |
+
<tr>
|
| 171 |
+
<th>Date</th>
|
| 172 |
+
<th>Blood Pressure</th>
|
| 173 |
+
<th>Heart Rate</th>
|
| 174 |
+
<th>Temperature</th>
|
| 175 |
+
<th>Respiratory Rate</th>
|
| 176 |
+
<th>Oxygen Saturation</th>
|
| 177 |
+
</tr>
|
| 178 |
+
</thead>
|
| 179 |
+
<tbody id="vitalsTableBody">
|
| 180 |
+
<!-- Vital signs entries will be populated here -->
|
| 181 |
+
</tbody>
|
| 182 |
+
</table>
|
| 183 |
+
</div>
|
| 184 |
+
</div>
|
| 185 |
+
</div>
|
| 186 |
+
|
| 187 |
+
<!-- Lab Results Tab -->
|
| 188 |
+
<div class="tab-content" id="lab-tab">
|
| 189 |
+
<div class="emr-section">
|
| 190 |
+
<h3>Laboratory Results</h3>
|
| 191 |
+
<div class="lab-results-container" id="labResultsContainer">
|
| 192 |
+
<!-- Lab results will be populated here -->
|
| 193 |
+
</div>
|
| 194 |
+
</div>
|
| 195 |
+
</div>
|
| 196 |
+
|
| 197 |
+
<!-- Procedures Tab -->
|
| 198 |
+
<div class="tab-content" id="procedures-tab">
|
| 199 |
+
<div class="emr-section">
|
| 200 |
+
<h3>Medical Procedures</h3>
|
| 201 |
+
<div class="procedures-timeline" id="proceduresTimeline">
|
| 202 |
+
<!-- Procedure entries will be populated here -->
|
| 203 |
+
</div>
|
| 204 |
+
</div>
|
| 205 |
+
</div>
|
| 206 |
+
</div>
|
| 207 |
+
|
| 208 |
<!-- Loading State -->
|
| 209 |
<div class="loading-state" id="loadingState" style="display: none;">
|
| 210 |
<div class="loading-spinner">
|
|
|
|
| 267 |
</div>
|
| 268 |
</div>
|
| 269 |
</div>
|
| 270 |
+
|
| 271 |
+
<!-- Document Analysis Preview Modal -->
|
| 272 |
+
<div class="modal" id="documentPreviewModal">
|
| 273 |
+
<div class="modal-content document-preview-modal">
|
| 274 |
+
<div class="modal-header">
|
| 275 |
+
<h3><i class="fas fa-file-medical"></i> Document Analysis Preview</h3>
|
| 276 |
+
<button class="modal-close" id="documentPreviewModalClose">×</button>
|
| 277 |
+
</div>
|
| 278 |
+
<div class="modal-body" id="documentPreviewContent">
|
| 279 |
+
<!-- Document analysis results will be populated here -->
|
| 280 |
+
</div>
|
| 281 |
+
<div class="modal-footer">
|
| 282 |
+
<button class="btn btn-secondary" id="documentPreviewCancel">Cancel</button>
|
| 283 |
+
<button class="btn btn-primary" id="saveDocumentAnalysis">Save to EMR</button>
|
| 284 |
+
</div>
|
| 285 |
+
</div>
|
| 286 |
+
</div>
|
| 287 |
</div>
|
| 288 |
|
| 289 |
<script src="/static/js/emr.js"></script>
|
static/index.html
CHANGED
|
@@ -93,16 +93,25 @@
|
|
| 93 |
</div>
|
| 94 |
<div class="message-content">
|
| 95 |
<div class="message-text">
|
| 96 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
<p>I'm here to help you with medical questions, diagnosis assistance, and healthcare information. I can:</p>
|
| 98 |
-
<
|
| 99 |
-
<
|
| 100 |
-
<
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
<p><
|
|
|
|
|
|
|
| 106 |
<p>How can I assist you today?</p>
|
| 107 |
</div>
|
| 108 |
<div class="message-time">Just now</div>
|
|
|
|
| 93 |
</div>
|
| 94 |
<div class="message-content">
|
| 95 |
<div class="message-text">
|
| 96 |
+
π Welcome to Medical AI Assistant
|
| 97 |
+
|
| 98 |
+
<div class="tagged-title">
|
| 99 |
+
<div class="tagged-title-bar"></div>
|
| 100 |
+
<div class="tagged-title-content">
|
| 101 |
+
<h1>Who am I, and what can I do?</h1>
|
| 102 |
+
</div>
|
| 103 |
+
</div>
|
| 104 |
<p>I'm here to help you with medical questions, diagnosis assistance, and healthcare information. I can:</p>
|
| 105 |
+
<div class="tagged-title">
|
| 106 |
+
<div class="tagged-title-bar"></div>
|
| 107 |
+
<div class="tagged-title-content">
|
| 108 |
+
<h1>Key Features</h1>
|
| 109 |
+
</div>
|
| 110 |
+
</div>
|
| 111 |
+
<p><span class="blue-bubble">Medical Information:</span> I can provide evidence-based medical information and explanations.</p>
|
| 112 |
+
<p><span class="blue-bubble">Symptom Analysis:</span> I can help analyze symptoms and suggest possible conditions.</p>
|
| 113 |
+
<p><span class="blue-bubble">Treatment Guidance:</span> I can explain treatments, medications, and procedures.</p>
|
| 114 |
+
<p><span class="blue-bubble">Important:</span> This is for informational purposes only. Always consult with qualified healthcare professionals for medical advice.</p>
|
| 115 |
<p>How can I assist you today?</p>
|
| 116 |
</div>
|
| 117 |
<div class="message-time">Just now</div>
|
static/js/app.js
CHANGED
|
@@ -401,13 +401,27 @@ class MedicalChatbotApp {
|
|
| 401 |
|
| 402 |
getWelcomeMessage() {
|
| 403 |
return `π Welcome to Medical AI Assistant
|
|
|
|
|
|
|
|
|
|
| 404 |
I'm here to help you with medical questions, diagnosis assistance, and healthcare information. I can:
|
|
|
|
| 405 |
π Answer medical questions and provide information
|
| 406 |
π Help with symptom analysis and differential diagnosis
|
| 407 |
π Provide medication and treatment information
|
| 408 |
π Explain medical procedures and conditions
|
| 409 |
β οΈ Offer general health advice (not medical diagnosis)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 410 |
**Important:** This is for informational purposes only. Always consult with qualified healthcare professionals for medical advice.
|
|
|
|
| 411 |
How can I assist you today?`;
|
| 412 |
}
|
| 413 |
|
|
@@ -1280,6 +1294,12 @@ How can I assist you today?`;
|
|
| 1280 |
}
|
| 1281 |
|
| 1282 |
updatePatientDisplay(patientId, patientName = null) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1283 |
const status = document.getElementById('patientStatus');
|
| 1284 |
const actions = document.getElementById('patientActions');
|
| 1285 |
const emrLink = document.getElementById('emrLink');
|
|
@@ -1860,15 +1880,20 @@ How can I assist you today?`;
|
|
| 1860 |
|
| 1861 |
formatMessageContent(content) {
|
| 1862 |
return content
|
| 1863 |
-
// Handle headers (1-6 # symbols)
|
| 1864 |
.replace(/^#{1,6}\s+(.+)$/gm, (match, text, offset, string) => {
|
| 1865 |
const level = match.match(/^#+/)[0].length;
|
| 1866 |
-
return `<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1867 |
})
|
| 1868 |
-
// Handle bold text
|
| 1869 |
-
.replace(/\*\*(
|
| 1870 |
-
// Handle italic text
|
| 1871 |
-
.replace(
|
| 1872 |
// Handle line breaks
|
| 1873 |
.replace(/\n/g, '<br>')
|
| 1874 |
// Handle emojis with colors
|
|
|
|
| 401 |
|
| 402 |
getWelcomeMessage() {
|
| 403 |
return `π Welcome to Medical AI Assistant
|
| 404 |
+
|
| 405 |
+
# Who am I, and what can I do?
|
| 406 |
+
|
| 407 |
I'm here to help you with medical questions, diagnosis assistance, and healthcare information. I can:
|
| 408 |
+
|
| 409 |
π Answer medical questions and provide information
|
| 410 |
π Help with symptom analysis and differential diagnosis
|
| 411 |
π Provide medication and treatment information
|
| 412 |
π Explain medical procedures and conditions
|
| 413 |
β οΈ Offer general health advice (not medical diagnosis)
|
| 414 |
+
|
| 415 |
+
# Key Features
|
| 416 |
+
|
| 417 |
+
**Medical Information:** I can provide evidence-based medical information and explanations.
|
| 418 |
+
|
| 419 |
+
**Symptom Analysis:** I can help analyze symptoms and suggest possible conditions.
|
| 420 |
+
|
| 421 |
+
**Treatment Guidance:** I can explain treatments, medications, and procedures.
|
| 422 |
+
|
| 423 |
**Important:** This is for informational purposes only. Always consult with qualified healthcare professionals for medical advice.
|
| 424 |
+
|
| 425 |
How can I assist you today?`;
|
| 426 |
}
|
| 427 |
|
|
|
|
| 1294 |
}
|
| 1295 |
|
| 1296 |
updatePatientDisplay(patientId, patientName = null) {
|
| 1297 |
+
// Safety check: don't update display if patientId is undefined or null
|
| 1298 |
+
if (!patientId || patientId === 'undefined' || patientId === 'null') {
|
| 1299 |
+
console.warn('updatePatientDisplay called with invalid patientId:', patientId);
|
| 1300 |
+
return;
|
| 1301 |
+
}
|
| 1302 |
+
|
| 1303 |
const status = document.getElementById('patientStatus');
|
| 1304 |
const actions = document.getElementById('patientActions');
|
| 1305 |
const emrLink = document.getElementById('emrLink');
|
|
|
|
| 1880 |
|
| 1881 |
formatMessageContent(content) {
|
| 1882 |
return content
|
| 1883 |
+
// Handle headers (1-6 # symbols) - create tagged titles
|
| 1884 |
.replace(/^#{1,6}\s+(.+)$/gm, (match, text, offset, string) => {
|
| 1885 |
const level = match.match(/^#+/)[0].length;
|
| 1886 |
+
return `<div class="tagged-title">
|
| 1887 |
+
<div class="tagged-title-bar"></div>
|
| 1888 |
+
<div class="tagged-title-content">
|
| 1889 |
+
<h${level}>${text}</h${level}>
|
| 1890 |
+
</div>
|
| 1891 |
+
</div>`;
|
| 1892 |
})
|
| 1893 |
+
// Handle bold text - create blue bubbles (improved regex to handle edge cases)
|
| 1894 |
+
.replace(/\*\*([^*]+)\*\*/g, '<span class="blue-bubble">$1</span>')
|
| 1895 |
+
// Handle italic text (only if not already processed as bold)
|
| 1896 |
+
.replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, '<em>$1</em>')
|
| 1897 |
// Handle line breaks
|
| 1898 |
.replace(/\n/g, '<br>')
|
| 1899 |
// Handle emojis with colors
|
static/js/emr.js
CHANGED
|
@@ -49,8 +49,18 @@ class EMRPage {
|
|
| 49 |
this.applyFilters();
|
| 50 |
});
|
| 51 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
// Modal handlers
|
| 53 |
this.setupModalHandlers();
|
|
|
|
|
|
|
|
|
|
| 54 |
}
|
| 55 |
|
| 56 |
setupModalHandlers() {
|
|
@@ -95,21 +105,100 @@ class EMRPage {
|
|
| 95 |
searchModal.classList.remove('show');
|
| 96 |
});
|
| 97 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
}
|
| 99 |
|
| 100 |
async loadPatientFromURL() {
|
| 101 |
const urlParams = new URLSearchParams(window.location.search);
|
| 102 |
const patientId = urlParams.get('patient_id');
|
| 103 |
|
| 104 |
-
if (
|
|
|
|
| 105 |
this.currentPatientId = patientId;
|
| 106 |
await this.loadPatientInfo();
|
| 107 |
} else {
|
| 108 |
// Try to get from localStorage
|
| 109 |
const savedPatientId = localStorage.getItem('medicalChatbotPatientId');
|
| 110 |
-
if (savedPatientId) {
|
| 111 |
this.currentPatientId = savedPatientId;
|
| 112 |
await this.loadPatientInfo();
|
|
|
|
|
|
|
|
|
|
| 113 |
}
|
| 114 |
}
|
| 115 |
}
|
|
@@ -556,6 +645,650 @@ class EMRPage {
|
|
| 556 |
emptyState.style.display = 'block';
|
| 557 |
tableContainer.style.display = 'none';
|
| 558 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 559 |
}
|
| 560 |
|
| 561 |
// Initialize the EMR page when DOM is loaded
|
|
|
|
| 49 |
this.applyFilters();
|
| 50 |
});
|
| 51 |
|
| 52 |
+
// Tab buttons
|
| 53 |
+
document.querySelectorAll('.tab-btn').forEach(btn => {
|
| 54 |
+
btn.addEventListener('click', (e) => {
|
| 55 |
+
this.switchTab(e.target.dataset.tab);
|
| 56 |
+
});
|
| 57 |
+
});
|
| 58 |
+
|
| 59 |
// Modal handlers
|
| 60 |
this.setupModalHandlers();
|
| 61 |
+
|
| 62 |
+
// File upload handlers
|
| 63 |
+
this.setupFileUploadHandlers();
|
| 64 |
}
|
| 65 |
|
| 66 |
setupModalHandlers() {
|
|
|
|
| 105 |
searchModal.classList.remove('show');
|
| 106 |
});
|
| 107 |
}
|
| 108 |
+
|
| 109 |
+
// Document Preview Modal
|
| 110 |
+
const documentPreviewModal = document.getElementById('documentPreviewModal');
|
| 111 |
+
const documentPreviewModalClose = document.getElementById('documentPreviewModalClose');
|
| 112 |
+
const documentPreviewCancel = document.getElementById('documentPreviewCancel');
|
| 113 |
+
const saveDocumentAnalysis = document.getElementById('saveDocumentAnalysis');
|
| 114 |
+
|
| 115 |
+
if (documentPreviewModalClose) {
|
| 116 |
+
documentPreviewModalClose.addEventListener('click', () => {
|
| 117 |
+
documentPreviewModal.classList.remove('show');
|
| 118 |
+
});
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
if (documentPreviewCancel) {
|
| 122 |
+
documentPreviewCancel.addEventListener('click', () => {
|
| 123 |
+
documentPreviewModal.classList.remove('show');
|
| 124 |
+
});
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
if (saveDocumentAnalysis) {
|
| 128 |
+
saveDocumentAnalysis.addEventListener('click', () => {
|
| 129 |
+
this.saveDocumentAnalysis();
|
| 130 |
+
});
|
| 131 |
+
}
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
setupFileUploadHandlers() {
|
| 135 |
+
const uploadArea = document.getElementById('uploadArea');
|
| 136 |
+
const fileInput = document.getElementById('fileInput');
|
| 137 |
+
const uploadBtn = document.getElementById('uploadBtn');
|
| 138 |
+
const uploadProgress = document.getElementById('uploadProgress');
|
| 139 |
+
|
| 140 |
+
// Click to upload
|
| 141 |
+
if (uploadBtn) {
|
| 142 |
+
uploadBtn.addEventListener('click', () => {
|
| 143 |
+
fileInput.click();
|
| 144 |
+
});
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
if (uploadArea) {
|
| 148 |
+
uploadArea.addEventListener('click', () => {
|
| 149 |
+
fileInput.click();
|
| 150 |
+
});
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
// File input change
|
| 154 |
+
if (fileInput) {
|
| 155 |
+
fileInput.addEventListener('change', (e) => {
|
| 156 |
+
if (e.target.files.length > 0) {
|
| 157 |
+
this.handleFileUpload(e.target.files);
|
| 158 |
+
}
|
| 159 |
+
});
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
// Drag and drop
|
| 163 |
+
if (uploadArea) {
|
| 164 |
+
uploadArea.addEventListener('dragover', (e) => {
|
| 165 |
+
e.preventDefault();
|
| 166 |
+
uploadArea.classList.add('dragover');
|
| 167 |
+
});
|
| 168 |
+
|
| 169 |
+
uploadArea.addEventListener('dragleave', (e) => {
|
| 170 |
+
e.preventDefault();
|
| 171 |
+
uploadArea.classList.remove('dragover');
|
| 172 |
+
});
|
| 173 |
+
|
| 174 |
+
uploadArea.addEventListener('drop', (e) => {
|
| 175 |
+
e.preventDefault();
|
| 176 |
+
uploadArea.classList.remove('dragover');
|
| 177 |
+
|
| 178 |
+
if (e.dataTransfer.files.length > 0) {
|
| 179 |
+
this.handleFileUpload(e.dataTransfer.files);
|
| 180 |
+
}
|
| 181 |
+
});
|
| 182 |
+
}
|
| 183 |
}
|
| 184 |
|
| 185 |
async loadPatientFromURL() {
|
| 186 |
const urlParams = new URLSearchParams(window.location.search);
|
| 187 |
const patientId = urlParams.get('patient_id');
|
| 188 |
|
| 189 |
+
// Check if patientId is valid (not undefined, null, or empty)
|
| 190 |
+
if (patientId && patientId !== 'undefined' && patientId !== 'null' && patientId.trim() !== '') {
|
| 191 |
this.currentPatientId = patientId;
|
| 192 |
await this.loadPatientInfo();
|
| 193 |
} else {
|
| 194 |
// Try to get from localStorage
|
| 195 |
const savedPatientId = localStorage.getItem('medicalChatbotPatientId');
|
| 196 |
+
if (savedPatientId && savedPatientId !== 'undefined' && savedPatientId !== 'null' && savedPatientId.trim() !== '') {
|
| 197 |
this.currentPatientId = savedPatientId;
|
| 198 |
await this.loadPatientInfo();
|
| 199 |
+
} else {
|
| 200 |
+
console.warn('No valid patient ID found in URL or localStorage');
|
| 201 |
+
this.showEmptyState();
|
| 202 |
}
|
| 203 |
}
|
| 204 |
}
|
|
|
|
| 645 |
emptyState.style.display = 'block';
|
| 646 |
tableContainer.style.display = 'none';
|
| 647 |
}
|
| 648 |
+
|
| 649 |
+
switchTab(tabName) {
|
| 650 |
+
// Update tab buttons
|
| 651 |
+
document.querySelectorAll('.tab-btn').forEach(btn => {
|
| 652 |
+
btn.classList.remove('active');
|
| 653 |
+
});
|
| 654 |
+
document.querySelector(`[data-tab="${tabName}"]`).classList.add('active');
|
| 655 |
+
|
| 656 |
+
// Update tab content
|
| 657 |
+
document.querySelectorAll('.tab-content').forEach(content => {
|
| 658 |
+
content.classList.remove('active');
|
| 659 |
+
});
|
| 660 |
+
document.getElementById(`${tabName}-tab`).classList.add('active');
|
| 661 |
+
|
| 662 |
+
// Load specific tab data
|
| 663 |
+
switch (tabName) {
|
| 664 |
+
case 'diagnosis':
|
| 665 |
+
this.renderDiagnosisTab();
|
| 666 |
+
break;
|
| 667 |
+
case 'medications':
|
| 668 |
+
this.renderMedicationsTab();
|
| 669 |
+
break;
|
| 670 |
+
case 'vitals':
|
| 671 |
+
this.renderVitalsTab();
|
| 672 |
+
break;
|
| 673 |
+
case 'lab':
|
| 674 |
+
this.renderLabTab();
|
| 675 |
+
break;
|
| 676 |
+
case 'procedures':
|
| 677 |
+
this.renderProceduresTab();
|
| 678 |
+
break;
|
| 679 |
+
}
|
| 680 |
+
}
|
| 681 |
+
|
| 682 |
+
renderDiagnosisTab() {
|
| 683 |
+
const timeline = document.getElementById('diagnosisTimeline');
|
| 684 |
+
const diagnoses = [];
|
| 685 |
+
|
| 686 |
+
this.emrEntries.forEach(entry => {
|
| 687 |
+
if (entry.extracted_data.diagnosis && entry.extracted_data.diagnosis.length > 0) {
|
| 688 |
+
entry.extracted_data.diagnosis.forEach(diagnosis => {
|
| 689 |
+
diagnoses.push({
|
| 690 |
+
name: diagnosis,
|
| 691 |
+
date: new Date(entry.created_at).toLocaleDateString(),
|
| 692 |
+
confidence: Math.round(entry.confidence_score * 100)
|
| 693 |
+
});
|
| 694 |
+
});
|
| 695 |
+
}
|
| 696 |
+
});
|
| 697 |
+
|
| 698 |
+
if (diagnoses.length === 0) {
|
| 699 |
+
timeline.innerHTML = '<p class="no-data">No diagnoses found in EMR entries.</p>';
|
| 700 |
+
return;
|
| 701 |
+
}
|
| 702 |
+
|
| 703 |
+
timeline.innerHTML = diagnoses.map(diagnosis => `
|
| 704 |
+
<div class="diagnosis-item">
|
| 705 |
+
<div class="diagnosis-date">${diagnosis.date}</div>
|
| 706 |
+
<div class="diagnosis-name">${diagnosis.name}</div>
|
| 707 |
+
<div class="diagnosis-confidence">${diagnosis.confidence}%</div>
|
| 708 |
+
</div>
|
| 709 |
+
`).join('');
|
| 710 |
+
}
|
| 711 |
+
|
| 712 |
+
renderMedicationsTab() {
|
| 713 |
+
const grid = document.getElementById('medicationsGrid');
|
| 714 |
+
const medications = [];
|
| 715 |
+
|
| 716 |
+
this.emrEntries.forEach(entry => {
|
| 717 |
+
if (entry.extracted_data.medications && entry.extracted_data.medications.length > 0) {
|
| 718 |
+
entry.extracted_data.medications.forEach(med => {
|
| 719 |
+
medications.push({
|
| 720 |
+
name: med.name,
|
| 721 |
+
dosage: med.dosage || 'Not specified',
|
| 722 |
+
frequency: med.frequency || 'Not specified',
|
| 723 |
+
duration: med.duration || 'Not specified',
|
| 724 |
+
date: new Date(entry.created_at).toLocaleDateString()
|
| 725 |
+
});
|
| 726 |
+
});
|
| 727 |
+
}
|
| 728 |
+
});
|
| 729 |
+
|
| 730 |
+
if (medications.length === 0) {
|
| 731 |
+
grid.innerHTML = '<p class="no-data">No medications found in EMR entries.</p>';
|
| 732 |
+
return;
|
| 733 |
+
}
|
| 734 |
+
|
| 735 |
+
grid.innerHTML = medications.map(med => `
|
| 736 |
+
<div class="medication-card">
|
| 737 |
+
<div class="medication-name">${med.name}</div>
|
| 738 |
+
<div class="medication-details">
|
| 739 |
+
<div class="medication-detail">
|
| 740 |
+
<strong>Dosage:</strong> <span>${med.dosage}</span>
|
| 741 |
+
</div>
|
| 742 |
+
<div class="medication-detail">
|
| 743 |
+
<strong>Frequency:</strong> <span>${med.frequency}</span>
|
| 744 |
+
</div>
|
| 745 |
+
<div class="medication-detail">
|
| 746 |
+
<strong>Duration:</strong> <span>${med.duration}</span>
|
| 747 |
+
</div>
|
| 748 |
+
<div class="medication-detail">
|
| 749 |
+
<strong>Date:</strong> <span>${med.date}</span>
|
| 750 |
+
</div>
|
| 751 |
+
</div>
|
| 752 |
+
</div>
|
| 753 |
+
`).join('');
|
| 754 |
+
}
|
| 755 |
+
|
| 756 |
+
renderVitalsTab() {
|
| 757 |
+
const tableBody = document.getElementById('vitalsTableBody');
|
| 758 |
+
const vitalsData = [];
|
| 759 |
+
|
| 760 |
+
this.emrEntries.forEach(entry => {
|
| 761 |
+
if (entry.extracted_data.vital_signs) {
|
| 762 |
+
const vitals = entry.extracted_data.vital_signs;
|
| 763 |
+
if (Object.values(vitals).some(v => v)) {
|
| 764 |
+
vitalsData.push({
|
| 765 |
+
date: new Date(entry.created_at).toLocaleDateString(),
|
| 766 |
+
blood_pressure: vitals.blood_pressure || '-',
|
| 767 |
+
heart_rate: vitals.heart_rate || '-',
|
| 768 |
+
temperature: vitals.temperature || '-',
|
| 769 |
+
respiratory_rate: vitals.respiratory_rate || '-',
|
| 770 |
+
oxygen_saturation: vitals.oxygen_saturation || '-'
|
| 771 |
+
});
|
| 772 |
+
}
|
| 773 |
+
}
|
| 774 |
+
});
|
| 775 |
+
|
| 776 |
+
if (vitalsData.length === 0) {
|
| 777 |
+
tableBody.innerHTML = '<tr><td colspan="6" class="no-data">No vital signs found in EMR entries.</td></tr>';
|
| 778 |
+
return;
|
| 779 |
+
}
|
| 780 |
+
|
| 781 |
+
tableBody.innerHTML = vitalsData.map(vitals => `
|
| 782 |
+
<tr>
|
| 783 |
+
<td>${vitals.date}</td>
|
| 784 |
+
<td>${vitals.blood_pressure}</td>
|
| 785 |
+
<td>${vitals.heart_rate}</td>
|
| 786 |
+
<td>${vitals.temperature}</td>
|
| 787 |
+
<td>${vitals.respiratory_rate}</td>
|
| 788 |
+
<td>${vitals.oxygen_saturation}</td>
|
| 789 |
+
</tr>
|
| 790 |
+
`).join('');
|
| 791 |
+
}
|
| 792 |
+
|
| 793 |
+
renderLabTab() {
|
| 794 |
+
const container = document.getElementById('labResultsContainer');
|
| 795 |
+
const labResults = [];
|
| 796 |
+
|
| 797 |
+
this.emrEntries.forEach(entry => {
|
| 798 |
+
if (entry.extracted_data.lab_results && entry.extracted_data.lab_results.length > 0) {
|
| 799 |
+
entry.extracted_data.lab_results.forEach(lab => {
|
| 800 |
+
labResults.push({
|
| 801 |
+
test_name: lab.test_name,
|
| 802 |
+
value: lab.value,
|
| 803 |
+
unit: lab.unit || '',
|
| 804 |
+
reference_range: lab.reference_range || 'Not specified',
|
| 805 |
+
date: new Date(entry.created_at).toLocaleDateString()
|
| 806 |
+
});
|
| 807 |
+
});
|
| 808 |
+
}
|
| 809 |
+
});
|
| 810 |
+
|
| 811 |
+
if (labResults.length === 0) {
|
| 812 |
+
container.innerHTML = '<p class="no-data">No lab results found in EMR entries.</p>';
|
| 813 |
+
return;
|
| 814 |
+
}
|
| 815 |
+
|
| 816 |
+
container.innerHTML = labResults.map(lab => `
|
| 817 |
+
<div class="lab-result-item">
|
| 818 |
+
<div class="lab-result-header">
|
| 819 |
+
<div class="lab-test-name">${lab.test_name}</div>
|
| 820 |
+
<div class="lab-test-value">${lab.value} ${lab.unit}</div>
|
| 821 |
+
</div>
|
| 822 |
+
<div class="lab-test-details">
|
| 823 |
+
<span><strong>Date:</strong> ${lab.date}</span>
|
| 824 |
+
<span><strong>Reference Range:</strong> ${lab.reference_range}</span>
|
| 825 |
+
</div>
|
| 826 |
+
</div>
|
| 827 |
+
`).join('');
|
| 828 |
+
}
|
| 829 |
+
|
| 830 |
+
renderProceduresTab() {
|
| 831 |
+
const timeline = document.getElementById('proceduresTimeline');
|
| 832 |
+
const procedures = [];
|
| 833 |
+
|
| 834 |
+
this.emrEntries.forEach(entry => {
|
| 835 |
+
if (entry.extracted_data.procedures && entry.extracted_data.procedures.length > 0) {
|
| 836 |
+
entry.extracted_data.procedures.forEach(procedure => {
|
| 837 |
+
procedures.push({
|
| 838 |
+
name: procedure,
|
| 839 |
+
date: new Date(entry.created_at).toLocaleDateString(),
|
| 840 |
+
confidence: Math.round(entry.confidence_score * 100)
|
| 841 |
+
});
|
| 842 |
+
});
|
| 843 |
+
}
|
| 844 |
+
});
|
| 845 |
+
|
| 846 |
+
if (procedures.length === 0) {
|
| 847 |
+
timeline.innerHTML = '<p class="no-data">No procedures found in EMR entries.</p>';
|
| 848 |
+
return;
|
| 849 |
+
}
|
| 850 |
+
|
| 851 |
+
timeline.innerHTML = procedures.map(procedure => `
|
| 852 |
+
<div class="procedure-item">
|
| 853 |
+
<div class="procedure-date">${procedure.date}</div>
|
| 854 |
+
<div class="procedure-name">${procedure.name}</div>
|
| 855 |
+
<div class="procedure-status">${procedure.confidence}%</div>
|
| 856 |
+
</div>
|
| 857 |
+
`).join('');
|
| 858 |
+
}
|
| 859 |
+
|
| 860 |
+
async handleFileUpload(files) {
|
| 861 |
+
if (!this.currentPatientId) {
|
| 862 |
+
alert('Please select a patient first');
|
| 863 |
+
return;
|
| 864 |
+
}
|
| 865 |
+
|
| 866 |
+
const file = files[0]; // Handle only the first file for now
|
| 867 |
+
|
| 868 |
+
// Validate file type
|
| 869 |
+
const allowedTypes = ['application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'image/jpeg', 'image/jpg', 'image/png', 'image/tiff'];
|
| 870 |
+
if (!allowedTypes.includes(file.type)) {
|
| 871 |
+
alert('Unsupported file type. Please upload PDF, DOC, DOCX, JPG, PNG, or TIFF files.');
|
| 872 |
+
return;
|
| 873 |
+
}
|
| 874 |
+
|
| 875 |
+
// Validate file size (10MB limit)
|
| 876 |
+
if (file.size > 10 * 1024 * 1024) {
|
| 877 |
+
alert('File size exceeds 10MB limit.');
|
| 878 |
+
return;
|
| 879 |
+
}
|
| 880 |
+
|
| 881 |
+
this.showUploadProgress(true);
|
| 882 |
+
|
| 883 |
+
try {
|
| 884 |
+
// Create FormData
|
| 885 |
+
const formData = new FormData();
|
| 886 |
+
formData.append('patient_id', this.currentPatientId);
|
| 887 |
+
formData.append('file', file);
|
| 888 |
+
|
| 889 |
+
// Upload and analyze document
|
| 890 |
+
const response = await fetch('/emr/preview-document', {
|
| 891 |
+
method: 'POST',
|
| 892 |
+
body: formData
|
| 893 |
+
});
|
| 894 |
+
|
| 895 |
+
if (!response.ok) {
|
| 896 |
+
const error = await response.json();
|
| 897 |
+
throw new Error(error.detail || 'Failed to analyze document');
|
| 898 |
+
}
|
| 899 |
+
|
| 900 |
+
const result = await response.json();
|
| 901 |
+
this.showDocumentPreview(result);
|
| 902 |
+
|
| 903 |
+
} catch (error) {
|
| 904 |
+
console.error('Error uploading file:', error);
|
| 905 |
+
alert(`Error analyzing document: ${error.message}`);
|
| 906 |
+
} finally {
|
| 907 |
+
this.showUploadProgress(false);
|
| 908 |
+
}
|
| 909 |
+
}
|
| 910 |
+
|
| 911 |
+
showUploadProgress(show) {
|
| 912 |
+
const uploadProgress = document.getElementById('uploadProgress');
|
| 913 |
+
const uploadArea = document.getElementById('uploadArea');
|
| 914 |
+
|
| 915 |
+
if (show) {
|
| 916 |
+
uploadProgress.style.display = 'block';
|
| 917 |
+
uploadArea.style.display = 'none';
|
| 918 |
+
} else {
|
| 919 |
+
uploadProgress.style.display = 'none';
|
| 920 |
+
uploadArea.style.display = 'block';
|
| 921 |
+
}
|
| 922 |
+
}
|
| 923 |
+
|
| 924 |
+
showDocumentPreview(analysisResult) {
|
| 925 |
+
const modal = document.getElementById('documentPreviewModal');
|
| 926 |
+
const content = document.getElementById('documentPreviewContent');
|
| 927 |
+
|
| 928 |
+
// Store the analysis result for saving
|
| 929 |
+
this.currentDocumentAnalysis = analysisResult;
|
| 930 |
+
|
| 931 |
+
content.innerHTML = this.renderDocumentPreview(analysisResult);
|
| 932 |
+
modal.classList.add('show');
|
| 933 |
+
}
|
| 934 |
+
|
| 935 |
+
renderDocumentPreview(data) {
|
| 936 |
+
const { filename, confidence_score, extracted_data } = data;
|
| 937 |
+
|
| 938 |
+
return `
|
| 939 |
+
<div class="document-preview-section">
|
| 940 |
+
<h4><i class="fas fa-file"></i> Document Information</h4>
|
| 941 |
+
<p><strong>Filename:</strong> ${filename}</p>
|
| 942 |
+
<p><strong>Confidence Score:</strong> ${Math.round(confidence_score * 100)}%</p>
|
| 943 |
+
</div>
|
| 944 |
+
|
| 945 |
+
${extracted_data.overview ? `
|
| 946 |
+
<div class="document-preview-section">
|
| 947 |
+
<h4><i class="fas fa-eye"></i> Overview</h4>
|
| 948 |
+
<textarea class="editable-field" id="overviewField" rows="3">${extracted_data.overview}</textarea>
|
| 949 |
+
</div>
|
| 950 |
+
` : ''}
|
| 951 |
+
|
| 952 |
+
<div class="document-preview-section">
|
| 953 |
+
<h4><i class="fas fa-stethoscope"></i> Diagnoses</h4>
|
| 954 |
+
<ul class="editable-list" id="diagnosisList">
|
| 955 |
+
${(extracted_data.diagnosis || []).map(diagnosis => `
|
| 956 |
+
<li>
|
| 957 |
+
<input type="text" value="${diagnosis}" class="diagnosis-input">
|
| 958 |
+
<button type="button" onclick="emrPage.removeListItem(this)"><i class="fas fa-trash"></i></button>
|
| 959 |
+
</li>
|
| 960 |
+
`).join('')}
|
| 961 |
+
</ul>
|
| 962 |
+
<button type="button" class="add-item-btn" onclick="emrPage.addDiagnosis()">
|
| 963 |
+
<i class="fas fa-plus"></i> Add Diagnosis
|
| 964 |
+
</button>
|
| 965 |
+
</div>
|
| 966 |
+
|
| 967 |
+
<div class="document-preview-section">
|
| 968 |
+
<h4><i class="fas fa-exclamation-triangle"></i> Symptoms</h4>
|
| 969 |
+
<ul class="editable-list" id="symptomsList">
|
| 970 |
+
${(extracted_data.symptoms || []).map(symptom => `
|
| 971 |
+
<li>
|
| 972 |
+
<input type="text" value="${symptom}" class="symptom-input">
|
| 973 |
+
<button type="button" onclick="emrPage.removeListItem(this)"><i class="fas fa-trash"></i></button>
|
| 974 |
+
</li>
|
| 975 |
+
`).join('')}
|
| 976 |
+
</ul>
|
| 977 |
+
<button type="button" class="add-item-btn" onclick="emrPage.addSymptom()">
|
| 978 |
+
<i class="fas fa-plus"></i> Add Symptom
|
| 979 |
+
</button>
|
| 980 |
+
</div>
|
| 981 |
+
|
| 982 |
+
<div class="document-preview-section">
|
| 983 |
+
<h4><i class="fas fa-pills"></i> Medications</h4>
|
| 984 |
+
<div id="medicationsList">
|
| 985 |
+
${(extracted_data.medications || []).map((med, index) => `
|
| 986 |
+
<div class="medication-preview-item">
|
| 987 |
+
<h5>Medication ${index + 1}</h5>
|
| 988 |
+
<div class="medication-detail-row">
|
| 989 |
+
<div>
|
| 990 |
+
<label>Name</label>
|
| 991 |
+
<input type="text" value="${med.name || ''}" class="med-name-input">
|
| 992 |
+
</div>
|
| 993 |
+
<div>
|
| 994 |
+
<label>Dosage</label>
|
| 995 |
+
<input type="text" value="${med.dosage || ''}" class="med-dosage-input">
|
| 996 |
+
</div>
|
| 997 |
+
</div>
|
| 998 |
+
<div class="medication-detail-row">
|
| 999 |
+
<div>
|
| 1000 |
+
<label>Frequency</label>
|
| 1001 |
+
<input type="text" value="${med.frequency || ''}" class="med-frequency-input">
|
| 1002 |
+
</div>
|
| 1003 |
+
<div>
|
| 1004 |
+
<label>Duration</label>
|
| 1005 |
+
<input type="text" value="${med.duration || ''}" class="med-duration-input">
|
| 1006 |
+
</div>
|
| 1007 |
+
</div>
|
| 1008 |
+
<button type="button" onclick="emrPage.removeMedication(this)" style="margin-top: 10px; background: #dc3545; color: white; border: none; padding: 5px 10px; border-radius: 4px; cursor: pointer;">
|
| 1009 |
+
<i class="fas fa-trash"></i> Remove
|
| 1010 |
+
</button>
|
| 1011 |
+
</div>
|
| 1012 |
+
`).join('')}
|
| 1013 |
+
</div>
|
| 1014 |
+
<button type="button" class="add-item-btn" onclick="emrPage.addMedication()">
|
| 1015 |
+
<i class="fas fa-plus"></i> Add Medication
|
| 1016 |
+
</button>
|
| 1017 |
+
</div>
|
| 1018 |
+
|
| 1019 |
+
${extracted_data.vital_signs ? `
|
| 1020 |
+
<div class="document-preview-section">
|
| 1021 |
+
<h4><i class="fas fa-heartbeat"></i> Vital Signs</h4>
|
| 1022 |
+
<div class="vital-signs-preview-grid">
|
| 1023 |
+
<div class="vital-sign-preview-item">
|
| 1024 |
+
<label>Blood Pressure</label>
|
| 1025 |
+
<input type="text" value="${extracted_data.vital_signs.blood_pressure || ''}" id="bpInput">
|
| 1026 |
+
</div>
|
| 1027 |
+
<div class="vital-sign-preview-item">
|
| 1028 |
+
<label>Heart Rate</label>
|
| 1029 |
+
<input type="text" value="${extracted_data.vital_signs.heart_rate || ''}" id="hrInput">
|
| 1030 |
+
</div>
|
| 1031 |
+
<div class="vital-sign-preview-item">
|
| 1032 |
+
<label>Temperature</label>
|
| 1033 |
+
<input type="text" value="${extracted_data.vital_signs.temperature || ''}" id="tempInput">
|
| 1034 |
+
</div>
|
| 1035 |
+
<div class="vital-sign-preview-item">
|
| 1036 |
+
<label>Respiratory Rate</label>
|
| 1037 |
+
<input type="text" value="${extracted_data.vital_signs.respiratory_rate || ''}" id="rrInput">
|
| 1038 |
+
</div>
|
| 1039 |
+
<div class="vital-sign-preview-item">
|
| 1040 |
+
<label>Oxygen Saturation</label>
|
| 1041 |
+
<input type="text" value="${extracted_data.vital_signs.oxygen_saturation || ''}" id="o2Input">
|
| 1042 |
+
</div>
|
| 1043 |
+
</div>
|
| 1044 |
+
</div>
|
| 1045 |
+
` : ''}
|
| 1046 |
+
|
| 1047 |
+
<div class="document-preview-section">
|
| 1048 |
+
<h4><i class="fas fa-flask"></i> Lab Results</h4>
|
| 1049 |
+
<div id="labResultsList">
|
| 1050 |
+
${(extracted_data.lab_results || []).map((lab, index) => `
|
| 1051 |
+
<div class="lab-result-preview-item">
|
| 1052 |
+
<h5>Lab Test ${index + 1}</h5>
|
| 1053 |
+
<div class="lab-result-detail-row">
|
| 1054 |
+
<div>
|
| 1055 |
+
<label>Test Name</label>
|
| 1056 |
+
<input type="text" value="${lab.test_name || ''}" class="lab-name-input">
|
| 1057 |
+
</div>
|
| 1058 |
+
<div>
|
| 1059 |
+
<label>Value</label>
|
| 1060 |
+
<input type="text" value="${lab.value || ''}" class="lab-value-input">
|
| 1061 |
+
</div>
|
| 1062 |
+
<div>
|
| 1063 |
+
<label>Unit</label>
|
| 1064 |
+
<input type="text" value="${lab.unit || ''}" class="lab-unit-input">
|
| 1065 |
+
</div>
|
| 1066 |
+
</div>
|
| 1067 |
+
<div class="lab-result-detail-row">
|
| 1068 |
+
<div>
|
| 1069 |
+
<label>Reference Range</label>
|
| 1070 |
+
<input type="text" value="${lab.reference_range || ''}" class="lab-range-input">
|
| 1071 |
+
</div>
|
| 1072 |
+
</div>
|
| 1073 |
+
<button type="button" onclick="emrPage.removeLabResult(this)" style="margin-top: 10px; background: #dc3545; color: white; border: none; padding: 5px 10px; border-radius: 4px; cursor: pointer;">
|
| 1074 |
+
<i class="fas fa-trash"></i> Remove
|
| 1075 |
+
</button>
|
| 1076 |
+
</div>
|
| 1077 |
+
`).join('')}
|
| 1078 |
+
</div>
|
| 1079 |
+
<button type="button" class="add-item-btn" onclick="emrPage.addLabResult()">
|
| 1080 |
+
<i class="fas fa-plus"></i> Add Lab Result
|
| 1081 |
+
</button>
|
| 1082 |
+
</div>
|
| 1083 |
+
|
| 1084 |
+
<div class="document-preview-section">
|
| 1085 |
+
<h4><i class="fas fa-procedures"></i> Procedures</h4>
|
| 1086 |
+
<ul class="editable-list" id="proceduresList">
|
| 1087 |
+
${(extracted_data.procedures || []).map(procedure => `
|
| 1088 |
+
<li>
|
| 1089 |
+
<input type="text" value="${procedure}" class="procedure-input">
|
| 1090 |
+
<button type="button" onclick="emrPage.removeListItem(this)"><i class="fas fa-trash"></i></button>
|
| 1091 |
+
</li>
|
| 1092 |
+
`).join('')}
|
| 1093 |
+
</ul>
|
| 1094 |
+
<button type="button" class="add-item-btn" onclick="emrPage.addProcedure()">
|
| 1095 |
+
<i class="fas fa-plus"></i> Add Procedure
|
| 1096 |
+
</button>
|
| 1097 |
+
</div>
|
| 1098 |
+
|
| 1099 |
+
<div class="document-preview-section">
|
| 1100 |
+
<h4><i class="fas fa-sticky-note"></i> Notes</h4>
|
| 1101 |
+
<textarea class="editable-field" id="notesField" rows="4">${extracted_data.notes || ''}</textarea>
|
| 1102 |
+
</div>
|
| 1103 |
+
`;
|
| 1104 |
+
}
|
| 1105 |
+
|
| 1106 |
+
addDiagnosis() {
|
| 1107 |
+
const list = document.getElementById('diagnosisList');
|
| 1108 |
+
const li = document.createElement('li');
|
| 1109 |
+
li.innerHTML = `
|
| 1110 |
+
<input type="text" value="" class="diagnosis-input" placeholder="Enter diagnosis">
|
| 1111 |
+
<button type="button" onclick="emrPage.removeListItem(this)"><i class="fas fa-trash"></i></button>
|
| 1112 |
+
`;
|
| 1113 |
+
list.appendChild(li);
|
| 1114 |
+
}
|
| 1115 |
+
|
| 1116 |
+
addSymptom() {
|
| 1117 |
+
const list = document.getElementById('symptomsList');
|
| 1118 |
+
const li = document.createElement('li');
|
| 1119 |
+
li.innerHTML = `
|
| 1120 |
+
<input type="text" value="" class="symptom-input" placeholder="Enter symptom">
|
| 1121 |
+
<button type="button" onclick="emrPage.removeListItem(this)"><i class="fas fa-trash"></i></button>
|
| 1122 |
+
`;
|
| 1123 |
+
list.appendChild(li);
|
| 1124 |
+
}
|
| 1125 |
+
|
| 1126 |
+
addMedication() {
|
| 1127 |
+
const list = document.getElementById('medicationsList');
|
| 1128 |
+
const index = list.children.length;
|
| 1129 |
+
const div = document.createElement('div');
|
| 1130 |
+
div.className = 'medication-preview-item';
|
| 1131 |
+
div.innerHTML = `
|
| 1132 |
+
<h5>Medication ${index + 1}</h5>
|
| 1133 |
+
<div class="medication-detail-row">
|
| 1134 |
+
<div>
|
| 1135 |
+
<label>Name</label>
|
| 1136 |
+
<input type="text" value="" class="med-name-input" placeholder="Medication name">
|
| 1137 |
+
</div>
|
| 1138 |
+
<div>
|
| 1139 |
+
<label>Dosage</label>
|
| 1140 |
+
<input type="text" value="" class="med-dosage-input" placeholder="Dosage">
|
| 1141 |
+
</div>
|
| 1142 |
+
</div>
|
| 1143 |
+
<div class="medication-detail-row">
|
| 1144 |
+
<div>
|
| 1145 |
+
<label>Frequency</label>
|
| 1146 |
+
<input type="text" value="" class="med-frequency-input" placeholder="Frequency">
|
| 1147 |
+
</div>
|
| 1148 |
+
<div>
|
| 1149 |
+
<label>Duration</label>
|
| 1150 |
+
<input type="text" value="" class="med-duration-input" placeholder="Duration">
|
| 1151 |
+
</div>
|
| 1152 |
+
</div>
|
| 1153 |
+
<button type="button" onclick="emrPage.removeMedication(this)" style="margin-top: 10px; background: #dc3545; color: white; border: none; padding: 5px 10px; border-radius: 4px; cursor: pointer;">
|
| 1154 |
+
<i class="fas fa-trash"></i> Remove
|
| 1155 |
+
</button>
|
| 1156 |
+
`;
|
| 1157 |
+
list.appendChild(div);
|
| 1158 |
+
}
|
| 1159 |
+
|
| 1160 |
+
addLabResult() {
|
| 1161 |
+
const list = document.getElementById('labResultsList');
|
| 1162 |
+
const index = list.children.length;
|
| 1163 |
+
const div = document.createElement('div');
|
| 1164 |
+
div.className = 'lab-result-preview-item';
|
| 1165 |
+
div.innerHTML = `
|
| 1166 |
+
<h5>Lab Test ${index + 1}</h5>
|
| 1167 |
+
<div class="lab-result-detail-row">
|
| 1168 |
+
<div>
|
| 1169 |
+
<label>Test Name</label>
|
| 1170 |
+
<input type="text" value="" class="lab-name-input" placeholder="Test name">
|
| 1171 |
+
</div>
|
| 1172 |
+
<div>
|
| 1173 |
+
<label>Value</label>
|
| 1174 |
+
<input type="text" value="" class="lab-value-input" placeholder="Test value">
|
| 1175 |
+
</div>
|
| 1176 |
+
<div>
|
| 1177 |
+
<label>Unit</label>
|
| 1178 |
+
<input type="text" value="" class="lab-unit-input" placeholder="Unit">
|
| 1179 |
+
</div>
|
| 1180 |
+
</div>
|
| 1181 |
+
<div class="lab-result-detail-row">
|
| 1182 |
+
<div>
|
| 1183 |
+
<label>Reference Range</label>
|
| 1184 |
+
<input type="text" value="" class="lab-range-input" placeholder="Normal range">
|
| 1185 |
+
</div>
|
| 1186 |
+
</div>
|
| 1187 |
+
<button type="button" onclick="emrPage.removeLabResult(this)" style="margin-top: 10px; background: #dc3545; color: white; border: none; padding: 5px 10px; border-radius: 4px; cursor: pointer;">
|
| 1188 |
+
<i class="fas fa-trash"></i> Remove
|
| 1189 |
+
</button>
|
| 1190 |
+
`;
|
| 1191 |
+
list.appendChild(div);
|
| 1192 |
+
}
|
| 1193 |
+
|
| 1194 |
+
addProcedure() {
|
| 1195 |
+
const list = document.getElementById('proceduresList');
|
| 1196 |
+
const li = document.createElement('li');
|
| 1197 |
+
li.innerHTML = `
|
| 1198 |
+
<input type="text" value="" class="procedure-input" placeholder="Enter procedure">
|
| 1199 |
+
<button type="button" onclick="emrPage.removeListItem(this)"><i class="fas fa-trash"></i></button>
|
| 1200 |
+
`;
|
| 1201 |
+
list.appendChild(li);
|
| 1202 |
+
}
|
| 1203 |
+
|
| 1204 |
+
removeListItem(button) {
|
| 1205 |
+
button.parentElement.remove();
|
| 1206 |
+
}
|
| 1207 |
+
|
| 1208 |
+
removeMedication(button) {
|
| 1209 |
+
button.parentElement.remove();
|
| 1210 |
+
}
|
| 1211 |
+
|
| 1212 |
+
removeLabResult(button) {
|
| 1213 |
+
button.parentElement.remove();
|
| 1214 |
+
}
|
| 1215 |
+
|
| 1216 |
+
async saveDocumentAnalysis() {
|
| 1217 |
+
if (!this.currentDocumentAnalysis) {
|
| 1218 |
+
alert('No document analysis to save');
|
| 1219 |
+
return;
|
| 1220 |
+
}
|
| 1221 |
+
|
| 1222 |
+
try {
|
| 1223 |
+
// Collect all the edited data
|
| 1224 |
+
const extractedData = this.collectEditedData();
|
| 1225 |
+
|
| 1226 |
+
const formData = new FormData();
|
| 1227 |
+
formData.append('patient_id', this.currentPatientId);
|
| 1228 |
+
formData.append('filename', this.currentDocumentAnalysis.filename);
|
| 1229 |
+
formData.append('extracted_data', JSON.stringify(extractedData));
|
| 1230 |
+
formData.append('confidence_score', this.currentDocumentAnalysis.confidence_score);
|
| 1231 |
+
|
| 1232 |
+
const response = await fetch('/emr/save-document-analysis', {
|
| 1233 |
+
method: 'POST',
|
| 1234 |
+
body: formData
|
| 1235 |
+
});
|
| 1236 |
+
|
| 1237 |
+
if (!response.ok) {
|
| 1238 |
+
const error = await response.json();
|
| 1239 |
+
throw new Error(error.detail || 'Failed to save document analysis');
|
| 1240 |
+
}
|
| 1241 |
+
|
| 1242 |
+
const result = await response.json();
|
| 1243 |
+
|
| 1244 |
+
// Close modal and refresh EMR data
|
| 1245 |
+
document.getElementById('documentPreviewModal').classList.remove('show');
|
| 1246 |
+
this.loadEMRData();
|
| 1247 |
+
this.loadPatientStats();
|
| 1248 |
+
|
| 1249 |
+
alert('Document analysis saved successfully!');
|
| 1250 |
+
|
| 1251 |
+
} catch (error) {
|
| 1252 |
+
console.error('Error saving document analysis:', error);
|
| 1253 |
+
alert(`Error saving document analysis: ${error.message}`);
|
| 1254 |
+
}
|
| 1255 |
+
}
|
| 1256 |
+
|
| 1257 |
+
collectEditedData() {
|
| 1258 |
+
const data = {
|
| 1259 |
+
overview: document.getElementById('overviewField')?.value || '',
|
| 1260 |
+
diagnosis: Array.from(document.querySelectorAll('.diagnosis-input')).map(input => input.value).filter(val => val.trim()),
|
| 1261 |
+
symptoms: Array.from(document.querySelectorAll('.symptom-input')).map(input => input.value).filter(val => val.trim()),
|
| 1262 |
+
medications: Array.from(document.querySelectorAll('.medication-preview-item')).map(item => ({
|
| 1263 |
+
name: item.querySelector('.med-name-input')?.value || '',
|
| 1264 |
+
dosage: item.querySelector('.med-dosage-input')?.value || '',
|
| 1265 |
+
frequency: item.querySelector('.med-frequency-input')?.value || '',
|
| 1266 |
+
duration: item.querySelector('.med-duration-input')?.value || ''
|
| 1267 |
+
})).filter(med => med.name.trim()),
|
| 1268 |
+
vital_signs: {
|
| 1269 |
+
blood_pressure: document.getElementById('bpInput')?.value || null,
|
| 1270 |
+
heart_rate: document.getElementById('hrInput')?.value || null,
|
| 1271 |
+
temperature: document.getElementById('tempInput')?.value || null,
|
| 1272 |
+
respiratory_rate: document.getElementById('rrInput')?.value || null,
|
| 1273 |
+
oxygen_saturation: document.getElementById('o2Input')?.value || null
|
| 1274 |
+
},
|
| 1275 |
+
lab_results: Array.from(document.querySelectorAll('.lab-result-preview-item')).map(item => ({
|
| 1276 |
+
test_name: item.querySelector('.lab-name-input')?.value || '',
|
| 1277 |
+
value: item.querySelector('.lab-value-input')?.value || '',
|
| 1278 |
+
unit: item.querySelector('.lab-unit-input')?.value || '',
|
| 1279 |
+
reference_range: item.querySelector('.lab-range-input')?.value || ''
|
| 1280 |
+
})).filter(lab => lab.test_name.trim()),
|
| 1281 |
+
procedures: Array.from(document.querySelectorAll('.procedure-input')).map(input => input.value).filter(val => val.trim()),
|
| 1282 |
+
notes: document.getElementById('notesField')?.value || ''
|
| 1283 |
+
};
|
| 1284 |
+
|
| 1285 |
+
// Clean up empty vital signs
|
| 1286 |
+
if (Object.values(data.vital_signs).every(val => !val)) {
|
| 1287 |
+
data.vital_signs = null;
|
| 1288 |
+
}
|
| 1289 |
+
|
| 1290 |
+
return data;
|
| 1291 |
+
}
|
| 1292 |
}
|
| 1293 |
|
| 1294 |
// Initialize the EMR page when DOM is loaded
|
static/patient.html
CHANGED
|
@@ -28,7 +28,7 @@
|
|
| 28 |
<option value="Intersex">Intersex</option>
|
| 29 |
</select>
|
| 30 |
</label>
|
| 31 |
-
<label>Ethnicity<input type="
|
| 32 |
</div>
|
| 33 |
<!-- Row 3: Contact Information -->
|
| 34 |
<div class="row contact-info">
|
|
|
|
| 28 |
<option value="Intersex">Intersex</option>
|
| 29 |
</select>
|
| 30 |
</label>
|
| 31 |
+
<label>Ethnicity<input type="text" id="ethnicity"></label>
|
| 32 |
</div>
|
| 33 |
<!-- Row 3: Contact Information -->
|
| 34 |
<div class="row contact-info">
|
tests/{test_memory_manager.py β core/test_memory_manager.py}
RENAMED
|
File without changes
|
tests/{test_state.py β core/test_state.py}
RENAMED
|
File without changes
|
tests/{test_account.py β models/test_account.py}
RENAMED
|
@@ -10,7 +10,7 @@ from src.data.connection import ActionFailed, Collections, get_collection
|
|
| 10 |
from src.data.repositories import account as account_repo
|
| 11 |
from src.models.account import Account
|
| 12 |
from src.utils.logger import logger
|
| 13 |
-
from
|
| 14 |
|
| 15 |
|
| 16 |
class TestAccountRepository(BaseMongoTest):
|
|
|
|
| 10 |
from src.data.repositories import account as account_repo
|
| 11 |
from src.models.account import Account
|
| 12 |
from src.utils.logger import logger
|
| 13 |
+
from ..base_test import BaseMongoTest
|
| 14 |
|
| 15 |
|
| 16 |
class TestAccountRepository(BaseMongoTest):
|
tests/{test_patient.py β models/test_patient.py}
RENAMED
|
@@ -8,7 +8,7 @@ from src.data.connection import ActionFailed, Collections, get_collection
|
|
| 8 |
from src.data.repositories import patient as patient_repo
|
| 9 |
from src.models.patient import Patient
|
| 10 |
from src.utils.logger import logger
|
| 11 |
-
from
|
| 12 |
|
| 13 |
|
| 14 |
class TestPatientRepository(BaseMongoTest):
|
|
|
|
| 8 |
from src.data.repositories import patient as patient_repo
|
| 9 |
from src.models.patient import Patient
|
| 10 |
from src.utils.logger import logger
|
| 11 |
+
from ..base_test import BaseMongoTest
|
| 12 |
|
| 13 |
|
| 14 |
class TestPatientRepository(BaseMongoTest):
|
tests/{test_session.py β models/test_session.py}
RENAMED
|
@@ -10,7 +10,7 @@ from src.data.connection import ActionFailed, Collections, get_collection
|
|
| 10 |
from src.data.repositories import session as session_repo
|
| 11 |
from src.models.session import Message, Session
|
| 12 |
from src.utils.logger import logger
|
| 13 |
-
from
|
| 14 |
|
| 15 |
|
| 16 |
class TestSessionRepository(BaseMongoTest):
|
|
|
|
| 10 |
from src.data.repositories import session as session_repo
|
| 11 |
from src.models.session import Message, Session
|
| 12 |
from src.utils.logger import logger
|
| 13 |
+
from ..base_test import BaseMongoTest
|
| 14 |
|
| 15 |
|
| 16 |
class TestSessionRepository(BaseMongoTest):
|
tests/services/test_guard.py
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# tests/test_guard.py
|
| 2 |
+
|
| 3 |
+
import pytest
|
| 4 |
+
import os
|
| 5 |
+
from unittest.mock import Mock, patch, MagicMock
|
| 6 |
+
from src.services.guard import SafetyGuard
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class TestSafetyGuard:
|
| 10 |
+
"""Test suite for SafetyGuard functionality."""
|
| 11 |
+
|
| 12 |
+
@pytest.fixture
|
| 13 |
+
def mock_settings(self):
|
| 14 |
+
"""Mock settings for testing."""
|
| 15 |
+
with patch('src.services.guard.settings') as mock_settings:
|
| 16 |
+
mock_settings.SAFETY_GUARD_TIMEOUT = 30
|
| 17 |
+
mock_settings.SAFETY_GUARD_ENABLED = True
|
| 18 |
+
mock_settings.SAFETY_GUARD_FAIL_OPEN = True
|
| 19 |
+
yield mock_settings
|
| 20 |
+
|
| 21 |
+
@pytest.fixture
|
| 22 |
+
def mock_rotator(self):
|
| 23 |
+
"""Mock NVIDIA rotator for testing."""
|
| 24 |
+
mock_rotator = Mock()
|
| 25 |
+
mock_rotator.get_key.return_value = "test_api_key"
|
| 26 |
+
mock_rotator.rotate.return_value = "test_api_key_2"
|
| 27 |
+
return mock_rotator
|
| 28 |
+
|
| 29 |
+
@pytest.fixture
|
| 30 |
+
def safety_guard(self, mock_settings, mock_rotator):
|
| 31 |
+
"""Create SafetyGuard instance for testing."""
|
| 32 |
+
return SafetyGuard(mock_rotator)
|
| 33 |
+
|
| 34 |
+
def test_init_with_valid_config(self, mock_settings, mock_rotator):
|
| 35 |
+
"""Test SafetyGuard initialization with valid configuration."""
|
| 36 |
+
guard = SafetyGuard(mock_rotator)
|
| 37 |
+
assert guard.nvidia_rotator == mock_rotator
|
| 38 |
+
assert guard.timeout_s == 30
|
| 39 |
+
assert guard.enabled is True
|
| 40 |
+
assert guard.fail_open is True
|
| 41 |
+
|
| 42 |
+
def test_init_without_api_key(self, mock_settings):
|
| 43 |
+
"""Test SafetyGuard initialization without API key."""
|
| 44 |
+
mock_rotator = Mock()
|
| 45 |
+
mock_rotator.get_key.return_value = None
|
| 46 |
+
with pytest.raises(ValueError, match="No NVIDIA API keys found"):
|
| 47 |
+
SafetyGuard(mock_rotator)
|
| 48 |
+
|
| 49 |
+
def test_chunk_text_empty(self, safety_guard):
|
| 50 |
+
"""Test text chunking with empty text."""
|
| 51 |
+
result = safety_guard._chunk_text("")
|
| 52 |
+
assert result == [""]
|
| 53 |
+
|
| 54 |
+
def test_chunk_text_short(self, safety_guard):
|
| 55 |
+
"""Test text chunking with short text."""
|
| 56 |
+
text = "Short text"
|
| 57 |
+
result = safety_guard._chunk_text(text)
|
| 58 |
+
assert result == [text]
|
| 59 |
+
|
| 60 |
+
def test_chunk_text_long(self, safety_guard):
|
| 61 |
+
"""Test text chunking with long text."""
|
| 62 |
+
text = "a" * 5000 # Longer than default chunk size
|
| 63 |
+
result = safety_guard._chunk_text(text)
|
| 64 |
+
assert len(result) > 1
|
| 65 |
+
assert all(len(chunk) <= 2800 for chunk in result)
|
| 66 |
+
|
| 67 |
+
def test_parse_guard_reply_safe(self, safety_guard):
|
| 68 |
+
"""Test parsing safe guard reply."""
|
| 69 |
+
is_safe, reason = safety_guard._parse_guard_reply("SAFE")
|
| 70 |
+
assert is_safe is True
|
| 71 |
+
assert reason == ""
|
| 72 |
+
|
| 73 |
+
def test_parse_guard_reply_unsafe(self, safety_guard):
|
| 74 |
+
"""Test parsing unsafe guard reply."""
|
| 75 |
+
is_safe, reason = safety_guard._parse_guard_reply("UNSAFE: contains harmful content")
|
| 76 |
+
assert is_safe is False
|
| 77 |
+
assert reason == "contains harmful content"
|
| 78 |
+
|
| 79 |
+
def test_parse_guard_reply_empty(self, safety_guard):
|
| 80 |
+
"""Test parsing empty guard reply."""
|
| 81 |
+
is_safe, reason = safety_guard._parse_guard_reply("")
|
| 82 |
+
assert is_safe is True
|
| 83 |
+
assert reason == "guard_unavailable"
|
| 84 |
+
|
| 85 |
+
def test_parse_guard_reply_unknown(self, safety_guard):
|
| 86 |
+
"""Test parsing unknown guard reply."""
|
| 87 |
+
is_safe, reason = safety_guard._parse_guard_reply("UNKNOWN_RESPONSE")
|
| 88 |
+
assert is_safe is False
|
| 89 |
+
assert len(reason) <= 180
|
| 90 |
+
|
| 91 |
+
def test_is_medical_query_symptoms(self, safety_guard):
|
| 92 |
+
"""Test medical query detection for symptoms."""
|
| 93 |
+
queries = [
|
| 94 |
+
"I have a headache",
|
| 95 |
+
"My chest hurts",
|
| 96 |
+
"I'm experiencing nausea",
|
| 97 |
+
"I have a fever"
|
| 98 |
+
]
|
| 99 |
+
for query in queries:
|
| 100 |
+
assert safety_guard._is_medical_query(query) is True
|
| 101 |
+
|
| 102 |
+
def test_is_medical_query_conditions(self, safety_guard):
|
| 103 |
+
"""Test medical query detection for conditions."""
|
| 104 |
+
queries = [
|
| 105 |
+
"What is diabetes?",
|
| 106 |
+
"How to treat hypertension",
|
| 107 |
+
"Symptoms of depression",
|
| 108 |
+
"Cancer treatment options"
|
| 109 |
+
]
|
| 110 |
+
for query in queries:
|
| 111 |
+
assert safety_guard._is_medical_query(query) is True
|
| 112 |
+
|
| 113 |
+
def test_is_medical_query_treatments(self, safety_guard):
|
| 114 |
+
"""Test medical query detection for treatments."""
|
| 115 |
+
queries = [
|
| 116 |
+
"What medication should I take?",
|
| 117 |
+
"Physical therapy exercises",
|
| 118 |
+
"Surgery options",
|
| 119 |
+
"Vaccination schedule"
|
| 120 |
+
]
|
| 121 |
+
for query in queries:
|
| 122 |
+
assert safety_guard._is_medical_query(query) is True
|
| 123 |
+
|
| 124 |
+
def test_is_medical_query_non_medical(self, safety_guard):
|
| 125 |
+
"""Test medical query detection for non-medical content."""
|
| 126 |
+
queries = [
|
| 127 |
+
"What's the weather like?",
|
| 128 |
+
"How to cook pasta",
|
| 129 |
+
"Best restaurants in town",
|
| 130 |
+
"Python programming tutorial"
|
| 131 |
+
]
|
| 132 |
+
for query in queries:
|
| 133 |
+
assert safety_guard._is_medical_query(query) is False
|
| 134 |
+
|
| 135 |
+
def test_detect_harmful_content_safe_medical(self, safety_guard):
|
| 136 |
+
"""Test harmful content detection for safe medical content."""
|
| 137 |
+
text = "I have a headache and need advice on pain management"
|
| 138 |
+
is_safe, reason = safety_guard._detect_harmful_content(text)
|
| 139 |
+
assert is_safe is True
|
| 140 |
+
assert reason == "medical_content"
|
| 141 |
+
|
| 142 |
+
def test_detect_harmful_content_dangerous_medical(self, safety_guard):
|
| 143 |
+
"""Test harmful content detection for dangerous medical content."""
|
| 144 |
+
text = "You should overdose on your medication"
|
| 145 |
+
is_safe, reason = safety_guard._detect_harmful_content(text)
|
| 146 |
+
assert is_safe is False
|
| 147 |
+
assert "dangerous_medical_advice" in reason
|
| 148 |
+
|
| 149 |
+
def test_detect_harmful_content_suicide(self, safety_guard):
|
| 150 |
+
"""Test harmful content detection for suicide-related content."""
|
| 151 |
+
text = "How to kill yourself"
|
| 152 |
+
is_safe, reason = safety_guard._detect_harmful_content(text)
|
| 153 |
+
assert is_safe is False
|
| 154 |
+
assert "suicide_self_harm" in reason
|
| 155 |
+
|
| 156 |
+
def test_detect_harmful_content_violence(self, safety_guard):
|
| 157 |
+
"""Test harmful content detection for violent content."""
|
| 158 |
+
text = "How to harm others with weapons"
|
| 159 |
+
is_safe, reason = safety_guard._detect_harmful_content(text)
|
| 160 |
+
assert is_safe is False
|
| 161 |
+
assert "violence" in reason
|
| 162 |
+
|
| 163 |
+
def test_assess_risk_level_medical_low(self, safety_guard):
|
| 164 |
+
"""Test risk assessment for low-risk medical content."""
|
| 165 |
+
text = "I have a mild headache"
|
| 166 |
+
risk_level, risk_score = safety_guard._assess_risk_level(text)
|
| 167 |
+
assert risk_level == "low"
|
| 168 |
+
assert 0.0 <= risk_score <= 1.0
|
| 169 |
+
|
| 170 |
+
def test_assess_risk_level_medical_high(self, safety_guard):
|
| 171 |
+
"""Test risk assessment for high-risk medical content."""
|
| 172 |
+
text = "You should commit suicide and overdose on drugs"
|
| 173 |
+
risk_level, risk_score = safety_guard._assess_risk_level(text)
|
| 174 |
+
assert risk_level == "high"
|
| 175 |
+
assert risk_score > 0.6
|
| 176 |
+
|
| 177 |
+
def test_assess_risk_level_non_medical(self, safety_guard):
|
| 178 |
+
"""Test risk assessment for non-medical content."""
|
| 179 |
+
text = "This is just a normal conversation"
|
| 180 |
+
risk_level, risk_score = safety_guard._assess_risk_level(text)
|
| 181 |
+
assert risk_level == "low"
|
| 182 |
+
assert 0.0 <= risk_score <= 1.0
|
| 183 |
+
|
| 184 |
+
@patch('src.services.guard.requests.post')
|
| 185 |
+
def test_call_guard_success(self, mock_post, safety_guard):
|
| 186 |
+
"""Test successful guard API call."""
|
| 187 |
+
mock_response = Mock()
|
| 188 |
+
mock_response.status_code = 200
|
| 189 |
+
mock_response.json.return_value = {
|
| 190 |
+
"choices": [{"message": {"content": "SAFE"}}]
|
| 191 |
+
}
|
| 192 |
+
mock_post.return_value = mock_response
|
| 193 |
+
|
| 194 |
+
messages = [{"role": "user", "content": "test message"}]
|
| 195 |
+
result = safety_guard._call_guard(messages)
|
| 196 |
+
assert result == "SAFE"
|
| 197 |
+
|
| 198 |
+
@patch('src.services.guard.requests.post')
|
| 199 |
+
def test_call_guard_failure(self, mock_post, safety_guard):
|
| 200 |
+
"""Test guard API call failure."""
|
| 201 |
+
mock_response = Mock()
|
| 202 |
+
mock_response.status_code = 400
|
| 203 |
+
mock_response.text = "Bad Request"
|
| 204 |
+
mock_post.return_value = mock_response
|
| 205 |
+
|
| 206 |
+
messages = [{"role": "user", "content": "test message"}]
|
| 207 |
+
result = safety_guard._call_guard(messages)
|
| 208 |
+
assert result == ""
|
| 209 |
+
|
| 210 |
+
def test_check_user_query_disabled(self, mock_settings):
|
| 211 |
+
"""Test user query check when guard is disabled."""
|
| 212 |
+
mock_settings.SAFETY_GUARD_ENABLED = False
|
| 213 |
+
guard = SafetyGuard()
|
| 214 |
+
|
| 215 |
+
is_safe, reason = guard.check_user_query("any query")
|
| 216 |
+
assert is_safe is True
|
| 217 |
+
assert reason == "guard_disabled"
|
| 218 |
+
|
| 219 |
+
def test_check_user_query_medical(self, safety_guard):
|
| 220 |
+
"""Test user query check for medical content."""
|
| 221 |
+
with patch.object(safety_guard, '_call_guard') as mock_call:
|
| 222 |
+
is_safe, reason = safety_guard.check_user_query("I have a headache")
|
| 223 |
+
assert is_safe is True
|
| 224 |
+
assert reason == "medical_query"
|
| 225 |
+
mock_call.assert_not_called()
|
| 226 |
+
|
| 227 |
+
def test_check_user_query_non_medical_safe(self, safety_guard):
|
| 228 |
+
"""Test user query check for safe non-medical content."""
|
| 229 |
+
with patch.object(safety_guard, '_call_guard') as mock_call, \
|
| 230 |
+
patch.object(safety_guard, '_parse_guard_reply') as mock_parse:
|
| 231 |
+
mock_call.return_value = "SAFE"
|
| 232 |
+
mock_parse.return_value = (True, "")
|
| 233 |
+
|
| 234 |
+
is_safe, reason = safety_guard.check_user_query("What's the weather?")
|
| 235 |
+
assert is_safe is True
|
| 236 |
+
assert reason == ""
|
| 237 |
+
|
| 238 |
+
def test_check_user_query_non_medical_unsafe(self, safety_guard):
|
| 239 |
+
"""Test user query check for unsafe non-medical content."""
|
| 240 |
+
with patch.object(safety_guard, '_call_guard') as mock_call, \
|
| 241 |
+
patch.object(safety_guard, '_parse_guard_reply') as mock_parse:
|
| 242 |
+
mock_call.return_value = "UNSAFE: harmful content"
|
| 243 |
+
mock_parse.return_value = (False, "harmful content")
|
| 244 |
+
|
| 245 |
+
is_safe, reason = safety_guard.check_user_query("How to harm others")
|
| 246 |
+
assert is_safe is False
|
| 247 |
+
assert reason == "harmful content"
|
| 248 |
+
|
| 249 |
+
def test_check_model_answer_disabled(self, mock_settings):
|
| 250 |
+
"""Test model answer check when guard is disabled."""
|
| 251 |
+
mock_settings.SAFETY_GUARD_ENABLED = False
|
| 252 |
+
guard = SafetyGuard()
|
| 253 |
+
|
| 254 |
+
is_safe, reason = guard.check_model_answer("query", "answer")
|
| 255 |
+
assert is_safe is True
|
| 256 |
+
assert reason == "guard_disabled"
|
| 257 |
+
|
| 258 |
+
def test_check_model_answer_medical_safe(self, safety_guard):
|
| 259 |
+
"""Test model answer check for safe medical content."""
|
| 260 |
+
is_safe, reason = safety_guard.check_model_answer(
|
| 261 |
+
"I have a headache",
|
| 262 |
+
"You should rest and drink water"
|
| 263 |
+
)
|
| 264 |
+
assert is_safe is True
|
| 265 |
+
assert reason == "medical_content"
|
| 266 |
+
|
| 267 |
+
def test_check_model_answer_medical_unsafe(self, safety_guard):
|
| 268 |
+
"""Test model answer check for unsafe medical content."""
|
| 269 |
+
is_safe, reason = safety_guard.check_model_answer(
|
| 270 |
+
"I have a headache",
|
| 271 |
+
"You should overdose on medication"
|
| 272 |
+
)
|
| 273 |
+
assert is_safe is False
|
| 274 |
+
assert "dangerous medical advice" in reason
|
| 275 |
+
|
| 276 |
+
def test_check_model_answer_high_risk(self, safety_guard):
|
| 277 |
+
"""Test model answer check for high-risk content."""
|
| 278 |
+
with patch.object(safety_guard, '_call_guard') as mock_call, \
|
| 279 |
+
patch.object(safety_guard, '_parse_guard_reply') as mock_parse:
|
| 280 |
+
mock_call.return_value = "SAFE"
|
| 281 |
+
mock_parse.return_value = (True, "")
|
| 282 |
+
|
| 283 |
+
is_safe, reason = safety_guard.check_model_answer(
|
| 284 |
+
"How to harm others",
|
| 285 |
+
"You should use weapons to attack people"
|
| 286 |
+
)
|
| 287 |
+
assert is_safe is True
|
| 288 |
+
assert reason == "high_risk_validated"
|
| 289 |
+
|
| 290 |
+
def test_enhance_messages_with_context_medical(self, safety_guard):
|
| 291 |
+
"""Test message enhancement for medical content."""
|
| 292 |
+
messages = [{"role": "user", "content": "I have a headache"}]
|
| 293 |
+
enhanced = safety_guard._enhance_messages_with_context(messages)
|
| 294 |
+
|
| 295 |
+
assert len(enhanced) == 1
|
| 296 |
+
assert "MEDICAL CONTEXT" in enhanced[0]["content"]
|
| 297 |
+
assert "I have a headache" in enhanced[0]["content"]
|
| 298 |
+
|
| 299 |
+
def test_enhance_messages_with_context_non_medical(self, safety_guard):
|
| 300 |
+
"""Test message enhancement for non-medical content."""
|
| 301 |
+
messages = [{"role": "user", "content": "What's the weather?"}]
|
| 302 |
+
enhanced = safety_guard._enhance_messages_with_context(messages)
|
| 303 |
+
|
| 304 |
+
assert enhanced == messages # Should remain unchanged
|
| 305 |
+
|
| 306 |
+
def test_global_safety_guard_instance(self):
|
| 307 |
+
"""Test that global safety guard instance is None by default."""
|
| 308 |
+
from src.services.guard import safety_guard
|
| 309 |
+
assert safety_guard is None
|
| 310 |
+
|
| 311 |
+
|
| 312 |
+
if __name__ == "__main__":
|
| 313 |
+
pytest.main([__file__])
|
tests/{test_medical_memory.py β services/test_medical_memory.py}
RENAMED
|
@@ -8,7 +8,7 @@ from src.data.connection import ActionFailed, Collections
|
|
| 8 |
from src.data.repositories import medical_memory as medical_memory_repo
|
| 9 |
from src.models.medical import MedicalMemory, SemanticSearchResult
|
| 10 |
from src.utils.logger import logger
|
| 11 |
-
from
|
| 12 |
|
| 13 |
|
| 14 |
class TestMedicalMemoryRepository(BaseMongoTest):
|
|
|
|
| 8 |
from src.data.repositories import medical_memory as medical_memory_repo
|
| 9 |
from src.models.medical import MedicalMemory, SemanticSearchResult
|
| 10 |
from src.utils.logger import logger
|
| 11 |
+
from ..base_test import BaseMongoTest
|
| 12 |
|
| 13 |
|
| 14 |
class TestMedicalMemoryRepository(BaseMongoTest):
|
tests/{test_medical_record.py β services/test_medical_record.py}
RENAMED
|
@@ -9,7 +9,7 @@ from src.data.connection import ActionFailed, Collections
|
|
| 9 |
from src.data.repositories import medical_record as medical_record_repo
|
| 10 |
from src.models.medical import MedicalRecord
|
| 11 |
from src.utils.logger import logger
|
| 12 |
-
from
|
| 13 |
|
| 14 |
|
| 15 |
class TestMedicalRecordRepository(BaseMongoTest):
|
|
|
|
| 9 |
from src.data.repositories import medical_record as medical_record_repo
|
| 10 |
from src.models.medical import MedicalRecord
|
| 11 |
from src.utils.logger import logger
|
| 12 |
+
from ..base_test import BaseMongoTest
|
| 13 |
|
| 14 |
|
| 15 |
class TestMedicalRecordRepository(BaseMongoTest):
|
tests/{test_utils.py β utils/test_utils.py}
RENAMED
|
@@ -7,7 +7,7 @@ from pymongo.errors import ConnectionFailure
|
|
| 7 |
from src.data import utils as db_utils
|
| 8 |
from src.data.connection import ActionFailed, Collections, get_collection
|
| 9 |
from src.utils.logger import logger
|
| 10 |
-
from
|
| 11 |
|
| 12 |
|
| 13 |
class TestDatabaseUtils(BaseMongoTest):
|
|
|
|
| 7 |
from src.data import utils as db_utils
|
| 8 |
from src.data.connection import ActionFailed, Collections, get_collection
|
| 9 |
from src.utils.logger import logger
|
| 10 |
+
from ..base_test import BaseMongoTest
|
| 11 |
|
| 12 |
|
| 13 |
class TestDatabaseUtils(BaseMongoTest):
|