Spaces:
Sleeping
Sleeping
Merge remote-tracking branch 'space/emr' into hf_main
Browse files- .dockerignore +1 -0
- .gitignore +2 -1
- schemas/emr_validator.json +118 -0
- src/emr/__init__.py +2 -0
- src/emr/models/__init__.py +1 -0
- src/emr/models/emr.py +71 -0
- src/emr/repositories/__init__.py +1 -0
- src/emr/repositories/emr.py +285 -0
- src/emr/routes/__init__.py +1 -0
- src/emr/routes/emr.py +326 -0
- src/emr/services/__init__.py +1 -0
- src/emr/services/extractor.py +260 -0
- src/emr/services/service.py +263 -0
- src/main.py +4 -0
- static/css/emr.css +384 -205
- static/css/styles.css +90 -0
- static/emr.html +138 -113
- static/index.html +1 -1
- static/js/app.js +139 -1
- static/js/emr.js +474 -201
.dockerignore
CHANGED
|
@@ -4,3 +4,4 @@
|
|
| 4 |
.venv
|
| 5 |
__pycache__
|
| 6 |
*.pyc
|
|
|
|
|
|
| 4 |
.venv
|
| 5 |
__pycache__
|
| 6 |
*.pyc
|
| 7 |
+
key
|
.gitignore
CHANGED
|
@@ -9,4 +9,5 @@
|
|
| 9 |
**/.env
|
| 10 |
**.DS_STORE
|
| 11 |
|
| 12 |
-
review_vi.txt
|
|
|
|
|
|
| 9 |
**/.env
|
| 10 |
**.DS_STORE
|
| 11 |
|
| 12 |
+
review_vi.txt
|
| 13 |
+
key
|
schemas/emr_validator.json
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"$jsonSchema": {
|
| 3 |
+
"bsonType": "object",
|
| 4 |
+
"title": "EMR validator",
|
| 5 |
+
"required": [
|
| 6 |
+
"patient_id",
|
| 7 |
+
"doctor_id",
|
| 8 |
+
"message_id",
|
| 9 |
+
"session_id",
|
| 10 |
+
"extracted_data",
|
| 11 |
+
"created_at",
|
| 12 |
+
"updated_at"
|
| 13 |
+
],
|
| 14 |
+
"properties": {
|
| 15 |
+
"patient_id": {
|
| 16 |
+
"bsonType": "string",
|
| 17 |
+
"description": "ID of the patient this EMR entry belongs to"
|
| 18 |
+
},
|
| 19 |
+
"doctor_id": {
|
| 20 |
+
"bsonType": "string",
|
| 21 |
+
"description": "ID of the doctor who triggered the EMR extraction"
|
| 22 |
+
},
|
| 23 |
+
"message_id": {
|
| 24 |
+
"bsonType": "string",
|
| 25 |
+
"description": "ID of the message that was analyzed for EMR extraction"
|
| 26 |
+
},
|
| 27 |
+
"session_id": {
|
| 28 |
+
"bsonType": "string",
|
| 29 |
+
"description": "ID of the chat session this EMR entry belongs to"
|
| 30 |
+
},
|
| 31 |
+
"original_message": {
|
| 32 |
+
"bsonType": "string",
|
| 33 |
+
"description": "The original message content that was analyzed"
|
| 34 |
+
},
|
| 35 |
+
"extracted_data": {
|
| 36 |
+
"bsonType": "object",
|
| 37 |
+
"properties": {
|
| 38 |
+
"diagnosis": {
|
| 39 |
+
"bsonType": "array",
|
| 40 |
+
"items": {"bsonType": "string"},
|
| 41 |
+
"description": "List of diagnoses identified"
|
| 42 |
+
},
|
| 43 |
+
"symptoms": {
|
| 44 |
+
"bsonType": "array",
|
| 45 |
+
"items": {"bsonType": "string"},
|
| 46 |
+
"description": "List of symptoms mentioned"
|
| 47 |
+
},
|
| 48 |
+
"medications": {
|
| 49 |
+
"bsonType": "array",
|
| 50 |
+
"items": {
|
| 51 |
+
"bsonType": "object",
|
| 52 |
+
"properties": {
|
| 53 |
+
"name": {"bsonType": "string"},
|
| 54 |
+
"dosage": {"bsonType": "string"},
|
| 55 |
+
"frequency": {"bsonType": "string"},
|
| 56 |
+
"duration": {"bsonType": "string"}
|
| 57 |
+
}
|
| 58 |
+
},
|
| 59 |
+
"description": "List of medications prescribed or mentioned"
|
| 60 |
+
},
|
| 61 |
+
"vital_signs": {
|
| 62 |
+
"bsonType": "object",
|
| 63 |
+
"properties": {
|
| 64 |
+
"blood_pressure": {"bsonType": "string"},
|
| 65 |
+
"heart_rate": {"bsonType": "string"},
|
| 66 |
+
"temperature": {"bsonType": "string"},
|
| 67 |
+
"respiratory_rate": {"bsonType": "string"},
|
| 68 |
+
"oxygen_saturation": {"bsonType": "string"}
|
| 69 |
+
},
|
| 70 |
+
"description": "Vital signs mentioned in the message"
|
| 71 |
+
},
|
| 72 |
+
"lab_results": {
|
| 73 |
+
"bsonType": "array",
|
| 74 |
+
"items": {
|
| 75 |
+
"bsonType": "object",
|
| 76 |
+
"properties": {
|
| 77 |
+
"test_name": {"bsonType": "string"},
|
| 78 |
+
"value": {"bsonType": "string"},
|
| 79 |
+
"unit": {"bsonType": "string"},
|
| 80 |
+
"reference_range": {"bsonType": "string"}
|
| 81 |
+
}
|
| 82 |
+
},
|
| 83 |
+
"description": "Laboratory results mentioned"
|
| 84 |
+
},
|
| 85 |
+
"procedures": {
|
| 86 |
+
"bsonType": "array",
|
| 87 |
+
"items": {"bsonType": "string"},
|
| 88 |
+
"description": "Medical procedures mentioned or performed"
|
| 89 |
+
},
|
| 90 |
+
"notes": {
|
| 91 |
+
"bsonType": "string",
|
| 92 |
+
"description": "Additional clinical notes and observations"
|
| 93 |
+
}
|
| 94 |
+
},
|
| 95 |
+
"description": "Structured medical data extracted from the message"
|
| 96 |
+
},
|
| 97 |
+
"embeddings": {
|
| 98 |
+
"bsonType": "array",
|
| 99 |
+
"items": {"bsonType": "number"},
|
| 100 |
+
"description": "Vector embeddings for semantic search"
|
| 101 |
+
},
|
| 102 |
+
"confidence_score": {
|
| 103 |
+
"bsonType": "number",
|
| 104 |
+
"minimum": 0,
|
| 105 |
+
"maximum": 1,
|
| 106 |
+
"description": "Confidence score of the extraction (0-1)"
|
| 107 |
+
},
|
| 108 |
+
"created_at": {
|
| 109 |
+
"bsonType": "date",
|
| 110 |
+
"description": "Timestamp when the EMR entry was created"
|
| 111 |
+
},
|
| 112 |
+
"updated_at": {
|
| 113 |
+
"bsonType": "date",
|
| 114 |
+
"description": "Timestamp when the EMR entry was last updated"
|
| 115 |
+
}
|
| 116 |
+
}
|
| 117 |
+
}
|
| 118 |
+
}
|
src/emr/__init__.py
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# EMR Module
|
| 2 |
+
# Electronic Medical Records functionality for the Medical AI Assistant
|
src/emr/models/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# EMR Models
|
src/emr/models/emr.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# emr/models/emr.py
|
| 2 |
+
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
from typing import Any, Dict, List, Optional
|
| 5 |
+
from pydantic import BaseModel, Field
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class Medication(BaseModel):
|
| 9 |
+
name: str
|
| 10 |
+
dosage: Optional[str] = None
|
| 11 |
+
frequency: Optional[str] = None
|
| 12 |
+
duration: Optional[str] = None
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class VitalSigns(BaseModel):
|
| 16 |
+
blood_pressure: Optional[str] = None
|
| 17 |
+
heart_rate: Optional[str] = None
|
| 18 |
+
temperature: Optional[str] = None
|
| 19 |
+
respiratory_rate: Optional[str] = None
|
| 20 |
+
oxygen_saturation: Optional[str] = None
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class LabResult(BaseModel):
|
| 24 |
+
test_name: str
|
| 25 |
+
value: str
|
| 26 |
+
unit: Optional[str] = None
|
| 27 |
+
reference_range: Optional[str] = None
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class ExtractedData(BaseModel):
|
| 31 |
+
diagnosis: List[str] = Field(default_factory=list)
|
| 32 |
+
symptoms: List[str] = Field(default_factory=list)
|
| 33 |
+
medications: List[Medication] = Field(default_factory=list)
|
| 34 |
+
vital_signs: Optional[VitalSigns] = None
|
| 35 |
+
lab_results: List[LabResult] = Field(default_factory=list)
|
| 36 |
+
procedures: List[str] = Field(default_factory=list)
|
| 37 |
+
notes: Optional[str] = None
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class EMRCreateRequest(BaseModel):
|
| 41 |
+
patient_id: str
|
| 42 |
+
doctor_id: str
|
| 43 |
+
message_id: str
|
| 44 |
+
session_id: str
|
| 45 |
+
original_message: str
|
| 46 |
+
extracted_data: ExtractedData
|
| 47 |
+
confidence_score: float = Field(ge=0, le=1)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
class EMRResponse(BaseModel):
|
| 51 |
+
emr_id: str
|
| 52 |
+
patient_id: str
|
| 53 |
+
doctor_id: str
|
| 54 |
+
message_id: str
|
| 55 |
+
session_id: str
|
| 56 |
+
original_message: str
|
| 57 |
+
extracted_data: ExtractedData
|
| 58 |
+
confidence_score: float
|
| 59 |
+
created_at: datetime
|
| 60 |
+
updated_at: datetime
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
class EMRSearchRequest(BaseModel):
|
| 64 |
+
patient_id: str
|
| 65 |
+
query: Optional[str] = None
|
| 66 |
+
limit: int = Field(default=20, ge=1, le=100)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
class EMRUpdateRequest(BaseModel):
|
| 70 |
+
extracted_data: Optional[ExtractedData] = None
|
| 71 |
+
confidence_score: Optional[float] = Field(None, ge=0, le=1)
|
src/emr/repositories/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# EMR Repositories
|
src/emr/repositories/emr.py
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# emr/repositories/emr.py
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
from datetime import datetime, timezone
|
| 5 |
+
from typing import Any, Dict, List, Optional
|
| 6 |
+
|
| 7 |
+
from bson import ObjectId
|
| 8 |
+
from pymongo import ASCENDING, DESCENDING
|
| 9 |
+
from pymongo.errors import ConnectionFailure, OperationFailure, PyMongoError
|
| 10 |
+
|
| 11 |
+
from src.data.connection import ActionFailed, create_collection, get_collection
|
| 12 |
+
from src.utils.logger import logger
|
| 13 |
+
from src.emr.models.emr import EMRCreateRequest, EMRResponse, ExtractedData
|
| 14 |
+
|
| 15 |
+
EMR_COLLECTION = "emr"
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def create():
|
| 19 |
+
"""Create the EMR collection with validation schema."""
|
| 20 |
+
try:
|
| 21 |
+
create_collection(EMR_COLLECTION, "schemas/emr_validator.json")
|
| 22 |
+
# Create indexes for better performance
|
| 23 |
+
collection = get_collection(EMR_COLLECTION)
|
| 24 |
+
collection.create_index("patient_id")
|
| 25 |
+
collection.create_index("doctor_id")
|
| 26 |
+
collection.create_index("session_id")
|
| 27 |
+
collection.create_index("message_id")
|
| 28 |
+
collection.create_index("created_at")
|
| 29 |
+
collection.create_index([("patient_id", ASCENDING), ("created_at", DESCENDING)])
|
| 30 |
+
collection.create_index([("patient_id", ASCENDING), ("doctor_id", ASCENDING)])
|
| 31 |
+
collection.create_index([("session_id", ASCENDING), ("created_at", DESCENDING)])
|
| 32 |
+
collection.create_index("confidence_score")
|
| 33 |
+
logger().info("EMR collection created successfully with indexes")
|
| 34 |
+
except Exception as e:
|
| 35 |
+
logger().error(f"Error creating EMR collection: {e}")
|
| 36 |
+
raise
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def create_emr_entry(emr_data: EMRCreateRequest, embeddings: List[float]) -> str:
|
| 40 |
+
"""Create a new EMR entry in the database."""
|
| 41 |
+
try:
|
| 42 |
+
collection = get_collection(EMR_COLLECTION)
|
| 43 |
+
|
| 44 |
+
# Check if EMR entry already exists for this message
|
| 45 |
+
existing = collection.find_one({"message_id": emr_data.message_id})
|
| 46 |
+
if existing:
|
| 47 |
+
logger().warning(f"EMR entry already exists for message {emr_data.message_id}")
|
| 48 |
+
return str(existing["_id"])
|
| 49 |
+
|
| 50 |
+
now = datetime.now(timezone.utc)
|
| 51 |
+
|
| 52 |
+
doc = {
|
| 53 |
+
"patient_id": emr_data.patient_id,
|
| 54 |
+
"doctor_id": emr_data.doctor_id,
|
| 55 |
+
"message_id": emr_data.message_id,
|
| 56 |
+
"session_id": emr_data.session_id,
|
| 57 |
+
"original_message": emr_data.original_message,
|
| 58 |
+
"extracted_data": emr_data.extracted_data.model_dump(),
|
| 59 |
+
"embeddings": embeddings,
|
| 60 |
+
"confidence_score": emr_data.confidence_score,
|
| 61 |
+
"created_at": now,
|
| 62 |
+
"updated_at": now
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
result = collection.insert_one(doc)
|
| 66 |
+
logger().info(f"Created EMR entry for patient {emr_data.patient_id}, message {emr_data.message_id}")
|
| 67 |
+
return str(result.inserted_id)
|
| 68 |
+
|
| 69 |
+
except Exception as e:
|
| 70 |
+
logger().error(f"Error creating EMR entry: {e}")
|
| 71 |
+
raise
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def get_emr_by_id(emr_id: str) -> Optional[Dict[str, Any]]:
|
| 75 |
+
"""Get an EMR entry by its ID."""
|
| 76 |
+
try:
|
| 77 |
+
collection = get_collection(EMR_COLLECTION)
|
| 78 |
+
result = collection.find_one({"_id": ObjectId(emr_id)})
|
| 79 |
+
if result:
|
| 80 |
+
result["_id"] = str(result["_id"])
|
| 81 |
+
return result
|
| 82 |
+
except Exception as e:
|
| 83 |
+
logger().error(f"Error getting EMR by ID {emr_id}: {e}")
|
| 84 |
+
return None
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def get_patient_emr_entries(
|
| 88 |
+
patient_id: str,
|
| 89 |
+
limit: int = 20,
|
| 90 |
+
offset: int = 0
|
| 91 |
+
) -> List[Dict[str, Any]]:
|
| 92 |
+
"""Get EMR entries for a specific patient, ordered by creation date."""
|
| 93 |
+
try:
|
| 94 |
+
collection = get_collection(EMR_COLLECTION)
|
| 95 |
+
cursor = collection.find(
|
| 96 |
+
{"patient_id": patient_id}
|
| 97 |
+
).sort("created_at", DESCENDING).skip(offset).limit(limit)
|
| 98 |
+
|
| 99 |
+
results = []
|
| 100 |
+
for doc in cursor:
|
| 101 |
+
doc["_id"] = str(doc["_id"])
|
| 102 |
+
results.append(doc)
|
| 103 |
+
|
| 104 |
+
logger().info(f"Retrieved {len(results)} EMR entries for patient {patient_id}")
|
| 105 |
+
return results
|
| 106 |
+
|
| 107 |
+
except Exception as e:
|
| 108 |
+
logger().error(f"Error getting patient EMR entries: {e}")
|
| 109 |
+
return []
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def search_emr_by_semantic_similarity(
|
| 113 |
+
patient_id: str,
|
| 114 |
+
query_embeddings: List[float],
|
| 115 |
+
limit: int = 10,
|
| 116 |
+
threshold: float = 0.7
|
| 117 |
+
) -> List[Dict[str, Any]]:
|
| 118 |
+
"""Search EMR entries using semantic similarity with embeddings."""
|
| 119 |
+
try:
|
| 120 |
+
collection = get_collection(EMR_COLLECTION)
|
| 121 |
+
|
| 122 |
+
# Use MongoDB's vector search capabilities if available
|
| 123 |
+
# For now, we'll implement a simple cosine similarity search
|
| 124 |
+
pipeline = [
|
| 125 |
+
{"$match": {"patient_id": patient_id}},
|
| 126 |
+
{
|
| 127 |
+
"$addFields": {
|
| 128 |
+
"similarity": {
|
| 129 |
+
"$let": {
|
| 130 |
+
"vars": {
|
| 131 |
+
"dotProduct": {
|
| 132 |
+
"$reduce": {
|
| 133 |
+
"input": {"$range": [0, {"$size": "$embeddings"}]},
|
| 134 |
+
"initialValue": 0,
|
| 135 |
+
"in": {
|
| 136 |
+
"$add": [
|
| 137 |
+
"$$value",
|
| 138 |
+
{
|
| 139 |
+
"$multiply": [
|
| 140 |
+
{"$arrayElemAt": ["$embeddings", "$$this"]},
|
| 141 |
+
{"$arrayElemAt": [query_embeddings, "$$this"]}
|
| 142 |
+
]
|
| 143 |
+
}
|
| 144 |
+
]
|
| 145 |
+
}
|
| 146 |
+
}
|
| 147 |
+
},
|
| 148 |
+
"magnitudeA": {
|
| 149 |
+
"$sqrt": {
|
| 150 |
+
"$reduce": {
|
| 151 |
+
"input": "$embeddings",
|
| 152 |
+
"initialValue": 0,
|
| 153 |
+
"in": {"$add": ["$$value", {"$multiply": ["$$this", "$$this"]}]}
|
| 154 |
+
}
|
| 155 |
+
}
|
| 156 |
+
},
|
| 157 |
+
"magnitudeB": {
|
| 158 |
+
"$sqrt": {
|
| 159 |
+
"$reduce": {
|
| 160 |
+
"input": query_embeddings,
|
| 161 |
+
"initialValue": 0,
|
| 162 |
+
"in": {"$add": ["$$value", {"$multiply": ["$$this", "$$this"]}]}
|
| 163 |
+
}
|
| 164 |
+
}
|
| 165 |
+
}
|
| 166 |
+
},
|
| 167 |
+
"in": {
|
| 168 |
+
"$divide": [
|
| 169 |
+
"$$dotProduct",
|
| 170 |
+
{"$multiply": ["$$magnitudeA", "$$magnitudeB"]}
|
| 171 |
+
]
|
| 172 |
+
}
|
| 173 |
+
}
|
| 174 |
+
}
|
| 175 |
+
}
|
| 176 |
+
},
|
| 177 |
+
{"$match": {"similarity": {"$gte": threshold}}},
|
| 178 |
+
{"$sort": {"similarity": DESCENDING}},
|
| 179 |
+
{"$limit": limit}
|
| 180 |
+
]
|
| 181 |
+
|
| 182 |
+
results = list(collection.aggregate(pipeline))
|
| 183 |
+
for doc in results:
|
| 184 |
+
doc["_id"] = str(doc["_id"])
|
| 185 |
+
|
| 186 |
+
logger().info(f"Found {len(results)} similar EMR entries for patient {patient_id}")
|
| 187 |
+
return results
|
| 188 |
+
|
| 189 |
+
except Exception as e:
|
| 190 |
+
logger().error(f"Error searching EMR by similarity: {e}")
|
| 191 |
+
return []
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
def update_emr_entry(emr_id: str, updates: Dict[str, Any]) -> bool:
|
| 195 |
+
"""Update an EMR entry."""
|
| 196 |
+
try:
|
| 197 |
+
collection = get_collection(EMR_COLLECTION)
|
| 198 |
+
updates["updated_at"] = datetime.now(timezone.utc)
|
| 199 |
+
|
| 200 |
+
result = collection.update_one(
|
| 201 |
+
{"_id": ObjectId(emr_id)},
|
| 202 |
+
{"$set": updates}
|
| 203 |
+
)
|
| 204 |
+
|
| 205 |
+
success = result.modified_count > 0
|
| 206 |
+
if success:
|
| 207 |
+
logger().info(f"Updated EMR entry {emr_id}")
|
| 208 |
+
else:
|
| 209 |
+
logger().warning(f"No EMR entry found with ID {emr_id}")
|
| 210 |
+
|
| 211 |
+
return success
|
| 212 |
+
|
| 213 |
+
except Exception as e:
|
| 214 |
+
logger().error(f"Error updating EMR entry {emr_id}: {e}")
|
| 215 |
+
return False
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
def delete_emr_entry(emr_id: str) -> bool:
|
| 219 |
+
"""Delete an EMR entry."""
|
| 220 |
+
try:
|
| 221 |
+
collection = get_collection(EMR_COLLECTION)
|
| 222 |
+
result = collection.delete_one({"_id": ObjectId(emr_id)})
|
| 223 |
+
|
| 224 |
+
success = result.deleted_count > 0
|
| 225 |
+
if success:
|
| 226 |
+
logger().info(f"Deleted EMR entry {emr_id}")
|
| 227 |
+
else:
|
| 228 |
+
logger().warning(f"No EMR entry found with ID {emr_id}")
|
| 229 |
+
|
| 230 |
+
return success
|
| 231 |
+
|
| 232 |
+
except Exception as e:
|
| 233 |
+
logger().error(f"Error deleting EMR entry {emr_id}: {e}")
|
| 234 |
+
return False
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
def check_emr_exists(message_id: str) -> bool:
|
| 238 |
+
"""Check if an EMR entry already exists for a message."""
|
| 239 |
+
try:
|
| 240 |
+
collection = get_collection(EMR_COLLECTION)
|
| 241 |
+
existing = collection.find_one({"message_id": message_id})
|
| 242 |
+
return existing is not None
|
| 243 |
+
except Exception as e:
|
| 244 |
+
logger().error(f"Error checking EMR existence: {e}")
|
| 245 |
+
return False
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
def get_emr_statistics(patient_id: str) -> Dict[str, Any]:
|
| 249 |
+
"""Get EMR statistics for a patient."""
|
| 250 |
+
try:
|
| 251 |
+
collection = get_collection(EMR_COLLECTION)
|
| 252 |
+
|
| 253 |
+
pipeline = [
|
| 254 |
+
{"$match": {"patient_id": patient_id}},
|
| 255 |
+
{
|
| 256 |
+
"$group": {
|
| 257 |
+
"_id": None,
|
| 258 |
+
"total_entries": {"$sum": 1},
|
| 259 |
+
"avg_confidence": {"$avg": "$confidence_score"},
|
| 260 |
+
"latest_entry": {"$max": "$created_at"},
|
| 261 |
+
"diagnosis_count": {
|
| 262 |
+
"$sum": {"$size": "$extracted_data.diagnosis"}
|
| 263 |
+
},
|
| 264 |
+
"medication_count": {
|
| 265 |
+
"$sum": {"$size": "$extracted_data.medications"}
|
| 266 |
+
}
|
| 267 |
+
}
|
| 268 |
+
}
|
| 269 |
+
]
|
| 270 |
+
|
| 271 |
+
result = list(collection.aggregate(pipeline))
|
| 272 |
+
if result:
|
| 273 |
+
return result[0]
|
| 274 |
+
else:
|
| 275 |
+
return {
|
| 276 |
+
"total_entries": 0,
|
| 277 |
+
"avg_confidence": 0,
|
| 278 |
+
"latest_entry": None,
|
| 279 |
+
"diagnosis_count": 0,
|
| 280 |
+
"medication_count": 0
|
| 281 |
+
}
|
| 282 |
+
|
| 283 |
+
except Exception as e:
|
| 284 |
+
logger().error(f"Error getting EMR statistics: {e}")
|
| 285 |
+
return {}
|
src/emr/routes/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# EMR Routes
|
src/emr/routes/emr.py
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# emr/routes/emr.py
|
| 2 |
+
|
| 3 |
+
from datetime import datetime, timezone
|
| 4 |
+
from typing import List, Optional
|
| 5 |
+
|
| 6 |
+
from fastapi import APIRouter, HTTPException, Depends
|
| 7 |
+
|
| 8 |
+
from src.core.state import MedicalState, get_state
|
| 9 |
+
from src.emr.models.emr import EMRResponse, EMRSearchRequest, EMRUpdateRequest
|
| 10 |
+
from src.emr.services.service import EMRService
|
| 11 |
+
from src.utils.logger import logger
|
| 12 |
+
|
| 13 |
+
router = APIRouter(prefix="/emr", tags=["EMR"])
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@router.get("/check/{message_id}", response_model=dict)
|
| 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 src.emr.repositories.emr import check_emr_exists
|
| 21 |
+
exists = check_emr_exists(message_id)
|
| 22 |
+
return {
|
| 23 |
+
"message_id": message_id,
|
| 24 |
+
"emr_exists": exists
|
| 25 |
+
}
|
| 26 |
+
except Exception as e:
|
| 27 |
+
logger().error(f"Error checking EMR existence: {e}")
|
| 28 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
@router.get("/health", response_model=dict)
|
| 32 |
+
async def emr_health_check():
|
| 33 |
+
"""Health check endpoint for EMR service."""
|
| 34 |
+
try:
|
| 35 |
+
from src.data.connection import get_collection
|
| 36 |
+
collection = get_collection("emr")
|
| 37 |
+
# Try to count documents to verify collection exists and is accessible
|
| 38 |
+
count = collection.count_documents({})
|
| 39 |
+
return {
|
| 40 |
+
"status": "healthy",
|
| 41 |
+
"collection": "emr",
|
| 42 |
+
"document_count": count,
|
| 43 |
+
"timestamp": datetime.now(timezone.utc).isoformat()
|
| 44 |
+
}
|
| 45 |
+
except Exception as e:
|
| 46 |
+
logger().error(f"EMR health check failed: {e}")
|
| 47 |
+
return {
|
| 48 |
+
"status": "unhealthy",
|
| 49 |
+
"error": str(e),
|
| 50 |
+
"timestamp": datetime.now(timezone.utc).isoformat()
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def get_emr_service(state: MedicalState = Depends(get_state)) -> EMRService:
|
| 55 |
+
"""Get EMR service instance."""
|
| 56 |
+
return EMRService(state.gemini_rotator, state.embedding_client)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
@router.post("/extract", response_model=dict)
|
| 60 |
+
async def extract_emr_from_message(
|
| 61 |
+
patient_id: str,
|
| 62 |
+
doctor_id: str,
|
| 63 |
+
message_id: str,
|
| 64 |
+
session_id: str,
|
| 65 |
+
message: str,
|
| 66 |
+
emr_service: EMRService = Depends(get_emr_service)
|
| 67 |
+
):
|
| 68 |
+
"""Extract and store EMR data from a chat message."""
|
| 69 |
+
try:
|
| 70 |
+
# Input validation
|
| 71 |
+
if not patient_id or not patient_id.strip():
|
| 72 |
+
raise HTTPException(status_code=400, detail="Patient ID is required")
|
| 73 |
+
if not doctor_id or not doctor_id.strip():
|
| 74 |
+
raise HTTPException(status_code=400, detail="Doctor ID is required")
|
| 75 |
+
if not message_id or not message_id.strip():
|
| 76 |
+
raise HTTPException(status_code=400, detail="Message ID is required")
|
| 77 |
+
if not session_id or not session_id.strip():
|
| 78 |
+
raise HTTPException(status_code=400, detail="Session ID is required")
|
| 79 |
+
if not message or not message.strip():
|
| 80 |
+
raise HTTPException(status_code=400, detail="Message content is required")
|
| 81 |
+
|
| 82 |
+
logger().info(f"EMR extraction requested for patient {patient_id}, message {message_id}")
|
| 83 |
+
|
| 84 |
+
# Get patient context if available
|
| 85 |
+
patient_context = None
|
| 86 |
+
try:
|
| 87 |
+
from src.data.repositories.patient import get_patient_by_id
|
| 88 |
+
patient = get_patient_by_id(patient_id)
|
| 89 |
+
if patient:
|
| 90 |
+
patient_context = {
|
| 91 |
+
"name": patient.get("name"),
|
| 92 |
+
"age": patient.get("age"),
|
| 93 |
+
"sex": patient.get("sex"),
|
| 94 |
+
"medications": patient.get("medications", []),
|
| 95 |
+
"past_assessment_summary": patient.get("past_assessment_summary")
|
| 96 |
+
}
|
| 97 |
+
except Exception as e:
|
| 98 |
+
logger().warning(f"Could not fetch patient context: {e}")
|
| 99 |
+
|
| 100 |
+
# Extract and store EMR data
|
| 101 |
+
emr_id = await emr_service.extract_and_store_emr(
|
| 102 |
+
patient_id=patient_id,
|
| 103 |
+
doctor_id=doctor_id,
|
| 104 |
+
message_id=message_id,
|
| 105 |
+
session_id=session_id,
|
| 106 |
+
message=message,
|
| 107 |
+
patient_context=patient_context
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
return {
|
| 111 |
+
"emr_id": emr_id,
|
| 112 |
+
"message": "EMR data extracted and stored successfully"
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
except Exception as e:
|
| 116 |
+
logger().error(f"Error in EMR extraction endpoint: {e}")
|
| 117 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
@router.get("/patient/{patient_id}", response_model=List[EMRResponse])
|
| 121 |
+
async def get_patient_emr(
|
| 122 |
+
patient_id: str,
|
| 123 |
+
limit: int = 20,
|
| 124 |
+
offset: int = 0,
|
| 125 |
+
emr_service: EMRService = Depends(get_emr_service)
|
| 126 |
+
):
|
| 127 |
+
"""Get EMR entries for a specific patient."""
|
| 128 |
+
try:
|
| 129 |
+
logger().info(f"Getting EMR entries for patient {patient_id}")
|
| 130 |
+
|
| 131 |
+
entries = await emr_service.get_patient_emr(patient_id, limit, offset)
|
| 132 |
+
return entries
|
| 133 |
+
|
| 134 |
+
except Exception as e:
|
| 135 |
+
logger().error(f"Error getting patient EMR: {e}")
|
| 136 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
@router.get("/search/{patient_id}", response_model=List[EMRResponse])
|
| 140 |
+
async def search_patient_emr(
|
| 141 |
+
patient_id: str,
|
| 142 |
+
query: str,
|
| 143 |
+
limit: int = 10,
|
| 144 |
+
emr_service: EMRService = Depends(get_emr_service)
|
| 145 |
+
):
|
| 146 |
+
"""Search EMR entries for a patient using semantic similarity."""
|
| 147 |
+
try:
|
| 148 |
+
logger().info(f"Searching EMR for patient {patient_id} with query: {query}")
|
| 149 |
+
|
| 150 |
+
entries = await emr_service.search_emr_semantic(patient_id, query, limit)
|
| 151 |
+
return entries
|
| 152 |
+
|
| 153 |
+
except Exception as e:
|
| 154 |
+
logger().error(f"Error searching patient EMR: {e}")
|
| 155 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
@router.get("/{emr_id}", response_model=EMRResponse)
|
| 159 |
+
async def get_emr_by_id(
|
| 160 |
+
emr_id: str,
|
| 161 |
+
emr_service: EMRService = Depends(get_emr_service)
|
| 162 |
+
):
|
| 163 |
+
"""Get a specific EMR entry by ID."""
|
| 164 |
+
try:
|
| 165 |
+
logger().info(f"Getting EMR entry {emr_id}")
|
| 166 |
+
|
| 167 |
+
entry = await emr_service.get_emr_by_id(emr_id)
|
| 168 |
+
if not entry:
|
| 169 |
+
raise HTTPException(status_code=404, detail="EMR entry not found")
|
| 170 |
+
|
| 171 |
+
return entry
|
| 172 |
+
|
| 173 |
+
except HTTPException:
|
| 174 |
+
raise
|
| 175 |
+
except Exception as e:
|
| 176 |
+
logger().error(f"Error getting EMR by ID: {e}")
|
| 177 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
@router.patch("/{emr_id}", response_model=dict)
|
| 181 |
+
async def update_emr(
|
| 182 |
+
emr_id: str,
|
| 183 |
+
request: EMRUpdateRequest,
|
| 184 |
+
emr_service: EMRService = Depends(get_emr_service)
|
| 185 |
+
):
|
| 186 |
+
"""Update an EMR entry."""
|
| 187 |
+
try:
|
| 188 |
+
logger().info(f"Updating EMR entry {emr_id}")
|
| 189 |
+
|
| 190 |
+
updates = {}
|
| 191 |
+
if request.extracted_data:
|
| 192 |
+
updates["extracted_data"] = request.extracted_data.model_dump()
|
| 193 |
+
if request.confidence_score is not None:
|
| 194 |
+
updates["confidence_score"] = request.confidence_score
|
| 195 |
+
|
| 196 |
+
success = await emr_service.update_emr(emr_id, updates)
|
| 197 |
+
if not success:
|
| 198 |
+
raise HTTPException(status_code=404, detail="EMR entry not found")
|
| 199 |
+
|
| 200 |
+
return {"message": "EMR entry updated successfully"}
|
| 201 |
+
|
| 202 |
+
except HTTPException:
|
| 203 |
+
raise
|
| 204 |
+
except Exception as e:
|
| 205 |
+
logger().error(f"Error updating EMR: {e}")
|
| 206 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
@router.delete("/{emr_id}", response_model=dict)
|
| 210 |
+
async def delete_emr(
|
| 211 |
+
emr_id: str,
|
| 212 |
+
emr_service: EMRService = Depends(get_emr_service)
|
| 213 |
+
):
|
| 214 |
+
"""Delete an EMR entry."""
|
| 215 |
+
try:
|
| 216 |
+
logger().info(f"Deleting EMR entry {emr_id}")
|
| 217 |
+
|
| 218 |
+
success = await emr_service.delete_emr(emr_id)
|
| 219 |
+
if not success:
|
| 220 |
+
raise HTTPException(status_code=404, detail="EMR entry not found")
|
| 221 |
+
|
| 222 |
+
return {"message": "EMR entry deleted successfully"}
|
| 223 |
+
|
| 224 |
+
except HTTPException:
|
| 225 |
+
raise
|
| 226 |
+
except Exception as e:
|
| 227 |
+
logger().error(f"Error deleting EMR: {e}")
|
| 228 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
@router.get("/statistics/{patient_id}", response_model=dict)
|
| 232 |
+
async def get_emr_statistics(
|
| 233 |
+
patient_id: str,
|
| 234 |
+
emr_service: EMRService = Depends(get_emr_service)
|
| 235 |
+
):
|
| 236 |
+
"""Get EMR statistics for a patient."""
|
| 237 |
+
try:
|
| 238 |
+
logger().info(f"Getting EMR statistics for patient {patient_id}")
|
| 239 |
+
|
| 240 |
+
stats = await emr_service.get_emr_statistics(patient_id)
|
| 241 |
+
return stats
|
| 242 |
+
|
| 243 |
+
except Exception as e:
|
| 244 |
+
logger().error(f"Error getting EMR statistics: {e}")
|
| 245 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
@router.post("/bulk-extract", response_model=dict)
|
| 249 |
+
async def bulk_extract_emr(
|
| 250 |
+
extractions: List[dict],
|
| 251 |
+
emr_service: EMRService = Depends(get_emr_service)
|
| 252 |
+
):
|
| 253 |
+
"""Extract EMR data from multiple messages in bulk."""
|
| 254 |
+
try:
|
| 255 |
+
if not extractions or len(extractions) == 0:
|
| 256 |
+
raise HTTPException(status_code=400, detail="No extractions provided")
|
| 257 |
+
|
| 258 |
+
if len(extractions) > 10:
|
| 259 |
+
raise HTTPException(status_code=400, detail="Maximum 10 extractions allowed per request")
|
| 260 |
+
|
| 261 |
+
logger().info(f"Bulk EMR extraction requested for {len(extractions)} messages")
|
| 262 |
+
|
| 263 |
+
results = []
|
| 264 |
+
errors = []
|
| 265 |
+
|
| 266 |
+
for i, extraction in enumerate(extractions):
|
| 267 |
+
try:
|
| 268 |
+
# Validate required fields
|
| 269 |
+
required_fields = ['patient_id', 'doctor_id', 'message_id', 'session_id', 'message']
|
| 270 |
+
for field in required_fields:
|
| 271 |
+
if field not in extraction or not extraction[field]:
|
| 272 |
+
raise ValueError(f"Missing or empty {field}")
|
| 273 |
+
|
| 274 |
+
# Get patient context
|
| 275 |
+
patient_context = None
|
| 276 |
+
try:
|
| 277 |
+
from src.data.repositories.patient import get_patient_by_id
|
| 278 |
+
patient = get_patient_by_id(extraction['patient_id'])
|
| 279 |
+
if patient:
|
| 280 |
+
patient_context = {
|
| 281 |
+
"name": patient.get("name"),
|
| 282 |
+
"age": patient.get("age"),
|
| 283 |
+
"sex": patient.get("sex"),
|
| 284 |
+
"medications": patient.get("medications", []),
|
| 285 |
+
"past_assessment_summary": patient.get("past_assessment_summary")
|
| 286 |
+
}
|
| 287 |
+
except Exception as e:
|
| 288 |
+
logger().warning(f"Could not fetch patient context for extraction {i}: {e}")
|
| 289 |
+
|
| 290 |
+
# Extract and store EMR data
|
| 291 |
+
emr_id = await emr_service.extract_and_store_emr(
|
| 292 |
+
patient_id=extraction['patient_id'],
|
| 293 |
+
doctor_id=extraction['doctor_id'],
|
| 294 |
+
message_id=extraction['message_id'],
|
| 295 |
+
session_id=extraction['session_id'],
|
| 296 |
+
message=extraction['message'],
|
| 297 |
+
patient_context=patient_context
|
| 298 |
+
)
|
| 299 |
+
|
| 300 |
+
results.append({
|
| 301 |
+
"index": i,
|
| 302 |
+
"message_id": extraction['message_id'],
|
| 303 |
+
"emr_id": emr_id,
|
| 304 |
+
"status": "success"
|
| 305 |
+
})
|
| 306 |
+
|
| 307 |
+
except Exception as e:
|
| 308 |
+
logger().error(f"Error in bulk extraction {i}: {e}")
|
| 309 |
+
errors.append({
|
| 310 |
+
"index": i,
|
| 311 |
+
"message_id": extraction.get('message_id', 'unknown'),
|
| 312 |
+
"error": str(e),
|
| 313 |
+
"status": "failed"
|
| 314 |
+
})
|
| 315 |
+
|
| 316 |
+
return {
|
| 317 |
+
"message": f"Bulk extraction completed. {len(results)} successful, {len(errors)} failed.",
|
| 318 |
+
"results": results,
|
| 319 |
+
"errors": errors
|
| 320 |
+
}
|
| 321 |
+
|
| 322 |
+
except HTTPException:
|
| 323 |
+
raise
|
| 324 |
+
except Exception as e:
|
| 325 |
+
logger().error(f"Error in bulk EMR extraction: {e}")
|
| 326 |
+
raise HTTPException(status_code=500, detail=str(e))
|
src/emr/services/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# EMR Services
|
src/emr/services/extractor.py
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# emr/services/extractor.py
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import re
|
| 5 |
+
from typing import Any, Dict, List, Optional, Tuple
|
| 6 |
+
|
| 7 |
+
from src.emr.models.emr import ExtractedData, Medication, VitalSigns, LabResult
|
| 8 |
+
from src.services.gemini import gemini_chat
|
| 9 |
+
from src.utils.rotator import APIKeyRotator
|
| 10 |
+
from src.utils.logger import logger
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class EMRExtractor:
|
| 14 |
+
"""Service for extracting structured medical data from chat messages using Gemini AI."""
|
| 15 |
+
|
| 16 |
+
def __init__(self, gemini_rotator: APIKeyRotator):
|
| 17 |
+
self.gemini_rotator = gemini_rotator
|
| 18 |
+
|
| 19 |
+
async def extract_medical_data(self, message: str, patient_context: Optional[Dict[str, Any]] = None) -> Tuple[ExtractedData, float]:
|
| 20 |
+
"""
|
| 21 |
+
Extract structured medical data from a chat message using Gemini AI.
|
| 22 |
+
|
| 23 |
+
Args:
|
| 24 |
+
message: The chat message to analyze
|
| 25 |
+
patient_context: Optional patient context information
|
| 26 |
+
|
| 27 |
+
Returns:
|
| 28 |
+
Tuple of (ExtractedData, confidence_score)
|
| 29 |
+
"""
|
| 30 |
+
try:
|
| 31 |
+
# Prepare the prompt for Gemini
|
| 32 |
+
prompt = self._build_extraction_prompt(message, patient_context)
|
| 33 |
+
|
| 34 |
+
# Get response from Gemini
|
| 35 |
+
response = await self._call_gemini_api(prompt)
|
| 36 |
+
|
| 37 |
+
# Parse the response
|
| 38 |
+
extracted_data, confidence = self._parse_gemini_response(response)
|
| 39 |
+
|
| 40 |
+
logger().info(f"Successfully extracted medical data with confidence {confidence:.2f}")
|
| 41 |
+
return extracted_data, confidence
|
| 42 |
+
|
| 43 |
+
except Exception as e:
|
| 44 |
+
logger().error(f"Error extracting medical data: {e}")
|
| 45 |
+
# Return empty data with low confidence
|
| 46 |
+
return ExtractedData(), 0.0
|
| 47 |
+
|
| 48 |
+
def _build_extraction_prompt(self, message: str, patient_context: Optional[Dict[str, Any]] = None) -> str:
|
| 49 |
+
"""Build the prompt for Gemini AI to extract medical data."""
|
| 50 |
+
|
| 51 |
+
context_info = ""
|
| 52 |
+
if patient_context:
|
| 53 |
+
context_info = f"""
|
| 54 |
+
Patient Context:
|
| 55 |
+
- Name: {patient_context.get('name', 'Unknown')}
|
| 56 |
+
- Age: {patient_context.get('age', 'Unknown')}
|
| 57 |
+
- Sex: {patient_context.get('sex', 'Unknown')}
|
| 58 |
+
- Current Medications: {', '.join(patient_context.get('medications', []))}
|
| 59 |
+
- Past Assessment Summary: {patient_context.get('past_assessment_summary', 'None')}
|
| 60 |
+
"""
|
| 61 |
+
|
| 62 |
+
prompt = f"""You are a medical AI assistant specialized in extracting structured medical data from clinical conversations.
|
| 63 |
+
|
| 64 |
+
{context_info}
|
| 65 |
+
|
| 66 |
+
Please analyze the following medical message and extract all relevant clinical information in the specified JSON format:
|
| 67 |
+
|
| 68 |
+
Message: "{message}"
|
| 69 |
+
|
| 70 |
+
Extract the following information and return ONLY a valid JSON object with this exact structure:
|
| 71 |
+
|
| 72 |
+
{{
|
| 73 |
+
"diagnosis": ["list of diagnoses mentioned"],
|
| 74 |
+
"symptoms": ["list of symptoms described"],
|
| 75 |
+
"medications": [
|
| 76 |
+
{{
|
| 77 |
+
"name": "medication name",
|
| 78 |
+
"dosage": "dosage if mentioned",
|
| 79 |
+
"frequency": "frequency if mentioned",
|
| 80 |
+
"duration": "duration if mentioned"
|
| 81 |
+
}}
|
| 82 |
+
],
|
| 83 |
+
"vital_signs": {{
|
| 84 |
+
"blood_pressure": "value if mentioned",
|
| 85 |
+
"heart_rate": "value if mentioned",
|
| 86 |
+
"temperature": "value if mentioned",
|
| 87 |
+
"respiratory_rate": "value if mentioned",
|
| 88 |
+
"oxygen_saturation": "value if mentioned"
|
| 89 |
+
}},
|
| 90 |
+
"lab_results": [
|
| 91 |
+
{{
|
| 92 |
+
"test_name": "test name",
|
| 93 |
+
"value": "test value",
|
| 94 |
+
"unit": "unit if mentioned",
|
| 95 |
+
"reference_range": "normal range if mentioned"
|
| 96 |
+
}}
|
| 97 |
+
],
|
| 98 |
+
"procedures": ["list of procedures mentioned"],
|
| 99 |
+
"notes": "additional clinical notes and observations"
|
| 100 |
+
}}
|
| 101 |
+
|
| 102 |
+
Guidelines:
|
| 103 |
+
1. Only extract information that is explicitly mentioned or clearly implied
|
| 104 |
+
2. Use medical terminology appropriately
|
| 105 |
+
3. If a field has no relevant information, use an empty array [] or null
|
| 106 |
+
4. For medications, only include those that are prescribed, recommended, or mentioned as current
|
| 107 |
+
5. Extract vital signs only if specific values are mentioned
|
| 108 |
+
6. Include lab results only if specific test values are provided
|
| 109 |
+
7. Be conservative - it's better to miss something than to hallucinate information
|
| 110 |
+
8. Return ONLY the JSON object, no additional text or explanation
|
| 111 |
+
|
| 112 |
+
Confidence Assessment:
|
| 113 |
+
After the JSON, provide a confidence score (0.0-1.0) based on:
|
| 114 |
+
- Clarity of medical information in the message
|
| 115 |
+
- Specificity of clinical details
|
| 116 |
+
- Presence of measurable values (vitals, lab results)
|
| 117 |
+
- Overall clinical relevance
|
| 118 |
+
|
| 119 |
+
Format: CONFIDENCE: 0.85
|
| 120 |
+
|
| 121 |
+
Return the JSON followed by the confidence score on a new line."""
|
| 122 |
+
|
| 123 |
+
return prompt
|
| 124 |
+
|
| 125 |
+
async def _call_gemini_api(self, prompt: str) -> str:
|
| 126 |
+
"""Call the Gemini API with the extraction prompt."""
|
| 127 |
+
try:
|
| 128 |
+
# Use the gemini_chat function with the rotator
|
| 129 |
+
response = await gemini_chat(prompt, self.gemini_rotator)
|
| 130 |
+
return response
|
| 131 |
+
except Exception as e:
|
| 132 |
+
logger().error(f"Error calling Gemini API: {e}")
|
| 133 |
+
raise
|
| 134 |
+
|
| 135 |
+
def _parse_gemini_response(self, response: str) -> Tuple[ExtractedData, float]:
|
| 136 |
+
"""Parse the Gemini response to extract structured data and confidence score."""
|
| 137 |
+
try:
|
| 138 |
+
# Extract confidence score
|
| 139 |
+
confidence = 0.5 # Default confidence
|
| 140 |
+
confidence_match = re.search(r'CONFIDENCE:\s*([0-9.]+)', response)
|
| 141 |
+
if confidence_match:
|
| 142 |
+
confidence = float(confidence_match.group(1))
|
| 143 |
+
|
| 144 |
+
# Extract JSON from response
|
| 145 |
+
json_match = re.search(r'\{.*\}', response, re.DOTALL)
|
| 146 |
+
if not json_match:
|
| 147 |
+
logger().warning("No JSON found in Gemini response")
|
| 148 |
+
return ExtractedData(), confidence
|
| 149 |
+
|
| 150 |
+
json_str = json_match.group(0)
|
| 151 |
+
data = json.loads(json_str)
|
| 152 |
+
|
| 153 |
+
# Parse medications
|
| 154 |
+
medications = []
|
| 155 |
+
for med_data in data.get('medications', []):
|
| 156 |
+
if isinstance(med_data, dict):
|
| 157 |
+
medications.append(Medication(
|
| 158 |
+
name=med_data.get('name', ''),
|
| 159 |
+
dosage=med_data.get('dosage'),
|
| 160 |
+
frequency=med_data.get('frequency'),
|
| 161 |
+
duration=med_data.get('duration')
|
| 162 |
+
))
|
| 163 |
+
|
| 164 |
+
# Parse vital signs
|
| 165 |
+
vital_signs_data = data.get('vital_signs', {})
|
| 166 |
+
vital_signs = None
|
| 167 |
+
if vital_signs_data and any(vital_signs_data.values()):
|
| 168 |
+
vital_signs = VitalSigns(
|
| 169 |
+
blood_pressure=vital_signs_data.get('blood_pressure'),
|
| 170 |
+
heart_rate=vital_signs_data.get('heart_rate'),
|
| 171 |
+
temperature=vital_signs_data.get('temperature'),
|
| 172 |
+
respiratory_rate=vital_signs_data.get('respiratory_rate'),
|
| 173 |
+
oxygen_saturation=vital_signs_data.get('oxygen_saturation')
|
| 174 |
+
)
|
| 175 |
+
|
| 176 |
+
# Parse lab results
|
| 177 |
+
lab_results = []
|
| 178 |
+
for lab_data in data.get('lab_results', []):
|
| 179 |
+
if isinstance(lab_data, dict):
|
| 180 |
+
lab_results.append(LabResult(
|
| 181 |
+
test_name=lab_data.get('test_name', ''),
|
| 182 |
+
value=lab_data.get('value', ''),
|
| 183 |
+
unit=lab_data.get('unit'),
|
| 184 |
+
reference_range=lab_data.get('reference_range')
|
| 185 |
+
))
|
| 186 |
+
|
| 187 |
+
# Create ExtractedData object
|
| 188 |
+
extracted_data = ExtractedData(
|
| 189 |
+
diagnosis=data.get('diagnosis', []),
|
| 190 |
+
symptoms=data.get('symptoms', []),
|
| 191 |
+
medications=medications,
|
| 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
|
| 199 |
+
|
| 200 |
+
except json.JSONDecodeError as e:
|
| 201 |
+
logger().error(f"Error parsing JSON from Gemini response: {e}")
|
| 202 |
+
return ExtractedData(), 0.0
|
| 203 |
+
except Exception as e:
|
| 204 |
+
logger().error(f"Error parsing Gemini response: {e}")
|
| 205 |
+
return ExtractedData(), 0.0
|
| 206 |
+
|
| 207 |
+
def extract_medications_from_text(self, text: str) -> List[str]:
|
| 208 |
+
"""Extract medication names from text using pattern matching."""
|
| 209 |
+
# Common medication patterns
|
| 210 |
+
medication_patterns = [
|
| 211 |
+
r'\b(?:acetaminophen|tylenol|ibuprofen|advil|motrin|aspirin|naproxen|aleve)\b',
|
| 212 |
+
r'\b(?:metformin|insulin|glipizide|metoprolol|lisinopril|amlodipine|atorvastatin|simvastatin)\b',
|
| 213 |
+
r'\b(?:omeprazole|pantoprazole|ranitidine|famotidine|sertraline|fluoxetine|paroxetine)\b',
|
| 214 |
+
r'\b(?:prednisone|hydrocortisone|dexamethasone|methylprednisolone)\b',
|
| 215 |
+
r'\b(?:warfarin|heparin|clopidogrel|aspirin)\b',
|
| 216 |
+
r'\b(?:furosemide|hydrochlorothiazide|spironolactone|triamterene)\b'
|
| 217 |
+
]
|
| 218 |
+
|
| 219 |
+
medications = set()
|
| 220 |
+
for pattern in medication_patterns:
|
| 221 |
+
matches = re.findall(pattern, text, re.IGNORECASE)
|
| 222 |
+
medications.update(matches)
|
| 223 |
+
|
| 224 |
+
return list(medications)
|
| 225 |
+
|
| 226 |
+
def extract_vital_signs_from_text(self, text: str) -> Dict[str, str]:
|
| 227 |
+
"""Extract vital signs from text using pattern matching."""
|
| 228 |
+
vital_signs = {}
|
| 229 |
+
|
| 230 |
+
# Blood pressure patterns
|
| 231 |
+
bp_pattern = r'(?:blood pressure|bp|pressure)\s*:?\s*(\d{2,3}/\d{2,3})'
|
| 232 |
+
bp_match = re.search(bp_pattern, text, re.IGNORECASE)
|
| 233 |
+
if bp_match:
|
| 234 |
+
vital_signs['blood_pressure'] = bp_match.group(1)
|
| 235 |
+
|
| 236 |
+
# Heart rate patterns
|
| 237 |
+
hr_pattern = r'(?:heart rate|hr|pulse)\s*:?\s*(\d{2,3})\s*(?:bpm|beats per minute)?'
|
| 238 |
+
hr_match = re.search(hr_pattern, text, re.IGNORECASE)
|
| 239 |
+
if hr_match:
|
| 240 |
+
vital_signs['heart_rate'] = hr_match.group(1)
|
| 241 |
+
|
| 242 |
+
# Temperature patterns
|
| 243 |
+
temp_pattern = r'(?:temperature|temp|fever)\s*:?\s*(\d{2,3}(?:\.\d)?)\s*(?:°?[fc])?'
|
| 244 |
+
temp_match = re.search(temp_pattern, text, re.IGNORECASE)
|
| 245 |
+
if temp_match:
|
| 246 |
+
vital_signs['temperature'] = temp_match.group(1)
|
| 247 |
+
|
| 248 |
+
# Respiratory rate patterns
|
| 249 |
+
rr_pattern = r'(?:respiratory rate|rr|breathing rate)\s*:?\s*(\d{1,2})\s*(?:breaths per minute|bpm)?'
|
| 250 |
+
rr_match = re.search(rr_pattern, text, re.IGNORECASE)
|
| 251 |
+
if rr_match:
|
| 252 |
+
vital_signs['respiratory_rate'] = rr_match.group(1)
|
| 253 |
+
|
| 254 |
+
# Oxygen saturation patterns
|
| 255 |
+
o2_pattern = r'(?:oxygen saturation|o2 sat|spo2)\s*:?\s*(\d{2,3})\s*%?'
|
| 256 |
+
o2_match = re.search(o2_pattern, text, re.IGNORECASE)
|
| 257 |
+
if o2_match:
|
| 258 |
+
vital_signs['oxygen_saturation'] = o2_match.group(1)
|
| 259 |
+
|
| 260 |
+
return vital_signs
|
src/emr/services/service.py
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# emr/services/service.py
|
| 2 |
+
|
| 3 |
+
from typing import Any, Dict, List, Optional
|
| 4 |
+
|
| 5 |
+
from src.emr.models.emr import EMRCreateRequest, EMRResponse, ExtractedData
|
| 6 |
+
from src.emr.repositories.emr import (
|
| 7 |
+
create_emr_entry,
|
| 8 |
+
get_patient_emr_entries,
|
| 9 |
+
get_emr_by_id,
|
| 10 |
+
search_emr_by_semantic_similarity,
|
| 11 |
+
update_emr_entry,
|
| 12 |
+
delete_emr_entry,
|
| 13 |
+
get_emr_statistics
|
| 14 |
+
)
|
| 15 |
+
from src.emr.services.extractor import EMRExtractor
|
| 16 |
+
from src.utils.rotator import APIKeyRotator
|
| 17 |
+
from src.utils.embeddings import EmbeddingClient
|
| 18 |
+
from src.utils.logger import logger
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class EMRService:
|
| 22 |
+
"""Main service for EMR operations including extraction, storage, and retrieval."""
|
| 23 |
+
|
| 24 |
+
def __init__(self, gemini_rotator: APIKeyRotator, embedding_client: EmbeddingClient):
|
| 25 |
+
self.gemini_rotator = gemini_rotator
|
| 26 |
+
self.embedding_client = embedding_client
|
| 27 |
+
self.extractor = EMRExtractor(gemini_rotator)
|
| 28 |
+
|
| 29 |
+
async def extract_and_store_emr(
|
| 30 |
+
self,
|
| 31 |
+
patient_id: str,
|
| 32 |
+
doctor_id: str,
|
| 33 |
+
message_id: str,
|
| 34 |
+
session_id: str,
|
| 35 |
+
message: str,
|
| 36 |
+
patient_context: Optional[Dict[str, Any]] = None
|
| 37 |
+
) -> str:
|
| 38 |
+
"""
|
| 39 |
+
Extract medical data from a message and store it as an EMR entry.
|
| 40 |
+
|
| 41 |
+
Args:
|
| 42 |
+
patient_id: ID of the patient
|
| 43 |
+
doctor_id: ID of the doctor
|
| 44 |
+
message_id: ID of the message being analyzed
|
| 45 |
+
session_id: ID of the chat session
|
| 46 |
+
message: The message content to analyze
|
| 47 |
+
patient_context: Optional patient context information
|
| 48 |
+
|
| 49 |
+
Returns:
|
| 50 |
+
ID of the created EMR entry
|
| 51 |
+
"""
|
| 52 |
+
try:
|
| 53 |
+
logger().info(f"Starting EMR extraction for patient {patient_id}, message {message_id}")
|
| 54 |
+
|
| 55 |
+
# Extract medical data using Gemini AI
|
| 56 |
+
extracted_data, confidence = await self.extractor.extract_medical_data(
|
| 57 |
+
message, patient_context
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
# Generate embeddings for the extracted data
|
| 61 |
+
embeddings = await self._generate_embeddings(extracted_data, message)
|
| 62 |
+
|
| 63 |
+
# Create EMR entry
|
| 64 |
+
emr_request = EMRCreateRequest(
|
| 65 |
+
patient_id=patient_id,
|
| 66 |
+
doctor_id=doctor_id,
|
| 67 |
+
message_id=message_id,
|
| 68 |
+
session_id=session_id,
|
| 69 |
+
original_message=message,
|
| 70 |
+
extracted_data=extracted_data,
|
| 71 |
+
confidence_score=confidence
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
# Store in database
|
| 75 |
+
emr_id = create_emr_entry(emr_request, embeddings)
|
| 76 |
+
|
| 77 |
+
logger().info(f"Successfully created EMR entry {emr_id} with confidence {confidence:.2f}")
|
| 78 |
+
return emr_id
|
| 79 |
+
|
| 80 |
+
except Exception as e:
|
| 81 |
+
logger().error(f"Error in extract_and_store_emr: {e}")
|
| 82 |
+
raise
|
| 83 |
+
|
| 84 |
+
async def _generate_embeddings(self, extracted_data: ExtractedData, original_message: str) -> List[float]:
|
| 85 |
+
"""Generate embeddings for the extracted medical data."""
|
| 86 |
+
try:
|
| 87 |
+
# Combine all extracted data into a single text for embedding
|
| 88 |
+
text_parts = []
|
| 89 |
+
|
| 90 |
+
if extracted_data.diagnosis:
|
| 91 |
+
text_parts.append(f"Diagnoses: {', '.join(extracted_data.diagnosis)}")
|
| 92 |
+
|
| 93 |
+
if extracted_data.symptoms:
|
| 94 |
+
text_parts.append(f"Symptoms: {', '.join(extracted_data.symptoms)}")
|
| 95 |
+
|
| 96 |
+
if extracted_data.medications:
|
| 97 |
+
med_text = []
|
| 98 |
+
for med in extracted_data.medications:
|
| 99 |
+
med_str = med.name
|
| 100 |
+
if med.dosage:
|
| 101 |
+
med_str += f" {med.dosage}"
|
| 102 |
+
if med.frequency:
|
| 103 |
+
med_str += f" {med.frequency}"
|
| 104 |
+
med_text.append(med_str)
|
| 105 |
+
text_parts.append(f"Medications: {', '.join(med_text)}")
|
| 106 |
+
|
| 107 |
+
if extracted_data.vital_signs:
|
| 108 |
+
vitals = []
|
| 109 |
+
if extracted_data.vital_signs.blood_pressure:
|
| 110 |
+
vitals.append(f"BP: {extracted_data.vital_signs.blood_pressure}")
|
| 111 |
+
if extracted_data.vital_signs.heart_rate:
|
| 112 |
+
vitals.append(f"HR: {extracted_data.vital_signs.heart_rate}")
|
| 113 |
+
if extracted_data.vital_signs.temperature:
|
| 114 |
+
vitals.append(f"Temp: {extracted_data.vital_signs.temperature}")
|
| 115 |
+
if vitals:
|
| 116 |
+
text_parts.append(f"Vital Signs: {', '.join(vitals)}")
|
| 117 |
+
|
| 118 |
+
if extracted_data.lab_results:
|
| 119 |
+
lab_text = []
|
| 120 |
+
for lab in extracted_data.lab_results:
|
| 121 |
+
lab_str = f"{lab.test_name}: {lab.value}"
|
| 122 |
+
if lab.unit:
|
| 123 |
+
lab_str += f" {lab.unit}"
|
| 124 |
+
lab_text.append(lab_str)
|
| 125 |
+
text_parts.append(f"Lab Results: {', '.join(lab_text)}")
|
| 126 |
+
|
| 127 |
+
if extracted_data.procedures:
|
| 128 |
+
text_parts.append(f"Procedures: {', '.join(extracted_data.procedures)}")
|
| 129 |
+
|
| 130 |
+
if extracted_data.notes:
|
| 131 |
+
text_parts.append(f"Notes: {extracted_data.notes}")
|
| 132 |
+
|
| 133 |
+
# Add original message for context
|
| 134 |
+
text_parts.append(f"Original: {original_message}")
|
| 135 |
+
|
| 136 |
+
# Generate embeddings
|
| 137 |
+
combined_text = " | ".join(text_parts)
|
| 138 |
+
embeddings = await self.embedding_client.generate_embeddings(combined_text)
|
| 139 |
+
|
| 140 |
+
return embeddings
|
| 141 |
+
|
| 142 |
+
except Exception as e:
|
| 143 |
+
logger().error(f"Error generating embeddings: {e}")
|
| 144 |
+
# Return empty embeddings if generation fails
|
| 145 |
+
return []
|
| 146 |
+
|
| 147 |
+
async def get_patient_emr(
|
| 148 |
+
self,
|
| 149 |
+
patient_id: str,
|
| 150 |
+
limit: int = 20,
|
| 151 |
+
offset: int = 0
|
| 152 |
+
) -> List[EMRResponse]:
|
| 153 |
+
"""Get EMR entries for a specific patient."""
|
| 154 |
+
try:
|
| 155 |
+
entries = get_patient_emr_entries(patient_id, limit, offset)
|
| 156 |
+
|
| 157 |
+
emr_responses = []
|
| 158 |
+
for entry in entries:
|
| 159 |
+
emr_response = EMRResponse(
|
| 160 |
+
emr_id=str(entry["_id"]),
|
| 161 |
+
patient_id=entry["patient_id"],
|
| 162 |
+
doctor_id=entry["doctor_id"],
|
| 163 |
+
message_id=entry["message_id"],
|
| 164 |
+
session_id=entry["session_id"],
|
| 165 |
+
original_message=entry["original_message"],
|
| 166 |
+
extracted_data=ExtractedData(**entry["extracted_data"]),
|
| 167 |
+
confidence_score=entry["confidence_score"],
|
| 168 |
+
created_at=entry["created_at"],
|
| 169 |
+
updated_at=entry["updated_at"]
|
| 170 |
+
)
|
| 171 |
+
emr_responses.append(emr_response)
|
| 172 |
+
|
| 173 |
+
return emr_responses
|
| 174 |
+
|
| 175 |
+
except Exception as e:
|
| 176 |
+
logger().error(f"Error getting patient EMR: {e}")
|
| 177 |
+
return []
|
| 178 |
+
|
| 179 |
+
async def search_emr_semantic(
|
| 180 |
+
self,
|
| 181 |
+
patient_id: str,
|
| 182 |
+
query: str,
|
| 183 |
+
limit: int = 10
|
| 184 |
+
) -> List[EMRResponse]:
|
| 185 |
+
"""Search EMR entries using semantic similarity."""
|
| 186 |
+
try:
|
| 187 |
+
# Generate embeddings for the search query
|
| 188 |
+
query_embeddings = await self.embedding_client.generate_embeddings(query)
|
| 189 |
+
|
| 190 |
+
# Search using semantic similarity
|
| 191 |
+
entries = search_emr_by_semantic_similarity(
|
| 192 |
+
patient_id, query_embeddings, limit
|
| 193 |
+
)
|
| 194 |
+
|
| 195 |
+
emr_responses = []
|
| 196 |
+
for entry in entries:
|
| 197 |
+
emr_response = EMRResponse(
|
| 198 |
+
emr_id=str(entry["_id"]),
|
| 199 |
+
patient_id=entry["patient_id"],
|
| 200 |
+
doctor_id=entry["doctor_id"],
|
| 201 |
+
message_id=entry["message_id"],
|
| 202 |
+
session_id=entry["session_id"],
|
| 203 |
+
original_message=entry["original_message"],
|
| 204 |
+
extracted_data=ExtractedData(**entry["extracted_data"]),
|
| 205 |
+
confidence_score=entry["confidence_score"],
|
| 206 |
+
created_at=entry["created_at"],
|
| 207 |
+
updated_at=entry["updated_at"]
|
| 208 |
+
)
|
| 209 |
+
emr_responses.append(emr_response)
|
| 210 |
+
|
| 211 |
+
return emr_responses
|
| 212 |
+
|
| 213 |
+
except Exception as e:
|
| 214 |
+
logger().error(f"Error searching EMR semantically: {e}")
|
| 215 |
+
return []
|
| 216 |
+
|
| 217 |
+
async def get_emr_by_id(self, emr_id: str) -> Optional[EMRResponse]:
|
| 218 |
+
"""Get a specific EMR entry by ID."""
|
| 219 |
+
try:
|
| 220 |
+
entry = get_emr_by_id(emr_id)
|
| 221 |
+
if not entry:
|
| 222 |
+
return None
|
| 223 |
+
|
| 224 |
+
return EMRResponse(
|
| 225 |
+
emr_id=str(entry["_id"]),
|
| 226 |
+
patient_id=entry["patient_id"],
|
| 227 |
+
doctor_id=entry["doctor_id"],
|
| 228 |
+
message_id=entry["message_id"],
|
| 229 |
+
session_id=entry["session_id"],
|
| 230 |
+
original_message=entry["original_message"],
|
| 231 |
+
extracted_data=ExtractedData(**entry["extracted_data"]),
|
| 232 |
+
confidence_score=entry["confidence_score"],
|
| 233 |
+
created_at=entry["created_at"],
|
| 234 |
+
updated_at=entry["updated_at"]
|
| 235 |
+
)
|
| 236 |
+
|
| 237 |
+
except Exception as e:
|
| 238 |
+
logger().error(f"Error getting EMR by ID: {e}")
|
| 239 |
+
return None
|
| 240 |
+
|
| 241 |
+
async def update_emr(self, emr_id: str, updates: Dict[str, Any]) -> bool:
|
| 242 |
+
"""Update an EMR entry."""
|
| 243 |
+
try:
|
| 244 |
+
return update_emr_entry(emr_id, updates)
|
| 245 |
+
except Exception as e:
|
| 246 |
+
logger().error(f"Error updating EMR: {e}")
|
| 247 |
+
return False
|
| 248 |
+
|
| 249 |
+
async def delete_emr(self, emr_id: str) -> bool:
|
| 250 |
+
"""Delete an EMR entry."""
|
| 251 |
+
try:
|
| 252 |
+
return delete_emr_entry(emr_id)
|
| 253 |
+
except Exception as e:
|
| 254 |
+
logger().error(f"Error deleting EMR: {e}")
|
| 255 |
+
return False
|
| 256 |
+
|
| 257 |
+
async def get_emr_statistics(self, patient_id: str) -> Dict[str, Any]:
|
| 258 |
+
"""Get EMR statistics for a patient."""
|
| 259 |
+
try:
|
| 260 |
+
return get_emr_statistics(patient_id)
|
| 261 |
+
except Exception as e:
|
| 262 |
+
logger().error(f"Error getting EMR statistics: {e}")
|
| 263 |
+
return {}
|
src/main.py
CHANGED
|
@@ -31,11 +31,13 @@ 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
|
| 33 |
from src.api.routes import system as system_route
|
|
|
|
| 34 |
from src.core.state import MedicalState, get_state
|
| 35 |
from src.data.repositories import account as account_repo
|
| 36 |
from src.data.repositories import medical as medical_repo
|
| 37 |
from src.data.repositories import patient as patient_repo
|
| 38 |
from src.data.repositories import session as session_repo
|
|
|
|
| 39 |
|
| 40 |
|
| 41 |
def startup_event(state: MedicalState):
|
|
@@ -82,6 +84,7 @@ def startup_event(state: MedicalState):
|
|
| 82 |
patient_repo.init()
|
| 83 |
session_repo.init()
|
| 84 |
#medical_repo.init()
|
|
|
|
| 85 |
|
| 86 |
def shutdown_event():
|
| 87 |
"""Cleanup on shutdown"""
|
|
@@ -127,6 +130,7 @@ app.include_router(account_route.router)
|
|
| 127 |
app.include_router(system_route.router)
|
| 128 |
app.include_router(static_route.router)
|
| 129 |
app.include_router(audio_route.router)
|
|
|
|
| 130 |
|
| 131 |
@app.get("/api/info")
|
| 132 |
async def get_api_info():
|
|
|
|
| 31 |
from src.api.routes import session as session_route
|
| 32 |
from src.api.routes import static as static_route
|
| 33 |
from src.api.routes import system as system_route
|
| 34 |
+
from src.emr.routes import emr as emr_route
|
| 35 |
from src.core.state import MedicalState, get_state
|
| 36 |
from src.data.repositories import account as account_repo
|
| 37 |
from src.data.repositories import medical as medical_repo
|
| 38 |
from src.data.repositories import patient as patient_repo
|
| 39 |
from src.data.repositories import session as session_repo
|
| 40 |
+
from src.emr.repositories import emr as emr_repo
|
| 41 |
|
| 42 |
|
| 43 |
def startup_event(state: MedicalState):
|
|
|
|
| 84 |
patient_repo.init()
|
| 85 |
session_repo.init()
|
| 86 |
#medical_repo.init()
|
| 87 |
+
emr_repo.create()
|
| 88 |
|
| 89 |
def shutdown_event():
|
| 90 |
"""Cleanup on shutdown"""
|
|
|
|
| 130 |
app.include_router(system_route.router)
|
| 131 |
app.include_router(static_route.router)
|
| 132 |
app.include_router(audio_route.router)
|
| 133 |
+
app.include_router(emr_route.router)
|
| 134 |
|
| 135 |
@app.get("/api/info")
|
| 136 |
async def get_api_info():
|
static/css/emr.css
CHANGED
|
@@ -1,14 +1,15 @@
|
|
| 1 |
-
/* EMR Page Styles */
|
|
|
|
| 2 |
.emr-container {
|
| 3 |
min-height: 100vh;
|
| 4 |
-
background-color: var(--bg-
|
| 5 |
-
color: var(--text-primary);
|
| 6 |
}
|
| 7 |
|
|
|
|
| 8 |
.emr-header {
|
| 9 |
-
background-color: var(--bg-
|
| 10 |
border-bottom: 1px solid var(--border-color);
|
| 11 |
-
padding:
|
| 12 |
position: sticky;
|
| 13 |
top: 0;
|
| 14 |
z-index: 100;
|
|
@@ -17,339 +18,517 @@
|
|
| 17 |
.emr-header-content {
|
| 18 |
max-width: 1200px;
|
| 19 |
margin: 0 auto;
|
| 20 |
-
padding: 0 1rem;
|
| 21 |
display: flex;
|
| 22 |
-
align-items: center;
|
| 23 |
justify-content: space-between;
|
| 24 |
-
|
| 25 |
-
gap: 1rem;
|
| 26 |
}
|
| 27 |
|
| 28 |
-
.
|
| 29 |
-
display:
|
| 30 |
align-items: center;
|
| 31 |
-
gap:
|
| 32 |
-
color: var(--primary-color);
|
| 33 |
-
text-decoration: none;
|
| 34 |
-
padding: 0.5rem 1rem;
|
| 35 |
-
border-radius: 6px;
|
| 36 |
-
transition: background-color var(--transition-fast);
|
| 37 |
}
|
| 38 |
|
| 39 |
-
.
|
| 40 |
-
|
|
|
|
| 41 |
}
|
| 42 |
|
| 43 |
-
.emr-
|
| 44 |
margin: 0;
|
|
|
|
| 45 |
font-size: 1.5rem;
|
| 46 |
font-weight: 600;
|
| 47 |
-
color: var(--text-primary);
|
| 48 |
}
|
| 49 |
|
| 50 |
-
.
|
| 51 |
display: flex;
|
| 52 |
-
|
| 53 |
-
align-items:
|
| 54 |
-
text-align: right;
|
| 55 |
-
}
|
| 56 |
-
|
| 57 |
-
.patient-name {
|
| 58 |
-
font-size: 1.1rem;
|
| 59 |
-
font-weight: 600;
|
| 60 |
-
color: var(--text-primary);
|
| 61 |
-
}
|
| 62 |
-
|
| 63 |
-
.patient-id {
|
| 64 |
-
font-size: 0.9rem;
|
| 65 |
-
color: var(--text-secondary);
|
| 66 |
-
font-family: monospace;
|
| 67 |
}
|
| 68 |
|
| 69 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
max-width: 1200px;
|
| 71 |
margin: 0 auto;
|
| 72 |
-
padding: 2rem 1rem;
|
| 73 |
}
|
| 74 |
|
| 75 |
-
.
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
margin-bottom: 2rem;
|
| 81 |
}
|
| 82 |
|
| 83 |
-
.
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
color: var(--
|
|
|
|
| 88 |
display: flex;
|
| 89 |
align-items: center;
|
| 90 |
-
|
|
|
|
| 91 |
}
|
| 92 |
|
| 93 |
-
.
|
| 94 |
-
|
|
|
|
|
|
|
| 95 |
}
|
| 96 |
|
| 97 |
-
.
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
}
|
| 102 |
|
| 103 |
-
.
|
| 104 |
display: grid;
|
| 105 |
-
|
|
|
|
| 106 |
}
|
| 107 |
|
| 108 |
-
.
|
| 109 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
}
|
| 111 |
|
| 112 |
-
.
|
| 113 |
-
|
|
|
|
|
|
|
|
|
|
| 114 |
}
|
| 115 |
|
| 116 |
-
.
|
| 117 |
-
|
| 118 |
-
|
|
|
|
|
|
|
| 119 |
}
|
| 120 |
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
color: var(--
|
| 124 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
}
|
| 126 |
|
| 127 |
-
.
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
padding: 0.75rem;
|
| 131 |
border: 1px solid var(--border-color);
|
| 132 |
-
border-radius:
|
| 133 |
-
|
| 134 |
-
color: var(--text-primary);
|
| 135 |
-
font-size: 0.9rem;
|
| 136 |
transition: border-color var(--transition-fast);
|
| 137 |
}
|
| 138 |
|
| 139 |
-
.
|
| 140 |
-
.emr-field select:focus,
|
| 141 |
-
.emr-field textarea:focus {
|
| 142 |
outline: none;
|
| 143 |
border-color: var(--primary-color);
|
| 144 |
-
box-shadow: 0 0 0
|
| 145 |
}
|
| 146 |
|
| 147 |
-
.
|
| 148 |
-
|
| 149 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
}
|
| 151 |
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
display: flex;
|
| 155 |
-
flex-direction: column;
|
| 156 |
-
gap: 1rem;
|
| 157 |
}
|
| 158 |
|
| 159 |
-
.
|
| 160 |
display: flex;
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
|
|
|
| 165 |
border: 1px solid var(--border-color);
|
| 166 |
border-radius: 6px;
|
| 167 |
background-color: var(--bg-primary);
|
|
|
|
|
|
|
| 168 |
}
|
| 169 |
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
font-weight: 500;
|
|
|
|
|
|
|
| 180 |
}
|
| 181 |
|
| 182 |
-
.
|
| 183 |
-
background:
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
transition: opacity var(--transition-fast);
|
| 191 |
}
|
| 192 |
|
| 193 |
-
.
|
| 194 |
-
|
|
|
|
| 195 |
}
|
| 196 |
|
| 197 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 198 |
display: flex;
|
| 199 |
-
gap: 0.5rem;
|
| 200 |
align-items: center;
|
|
|
|
| 201 |
}
|
| 202 |
|
| 203 |
-
.
|
| 204 |
-
|
| 205 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
}
|
| 207 |
|
| 208 |
-
.
|
| 209 |
-
|
| 210 |
}
|
| 211 |
|
| 212 |
-
|
| 213 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 214 |
display: flex;
|
| 215 |
-
|
| 216 |
-
gap: 1rem;
|
| 217 |
}
|
| 218 |
|
| 219 |
-
.
|
| 220 |
-
background
|
| 221 |
-
border:
|
| 222 |
-
|
| 223 |
-
padding: 1rem;
|
| 224 |
cursor: pointer;
|
|
|
|
|
|
|
| 225 |
transition: all var(--transition-fast);
|
|
|
|
| 226 |
}
|
| 227 |
|
| 228 |
-
.
|
| 229 |
-
border-color: var(--primary-color);
|
| 230 |
background-color: var(--bg-tertiary);
|
|
|
|
| 231 |
}
|
| 232 |
|
| 233 |
-
.
|
| 234 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 235 |
color: var(--text-primary);
|
| 236 |
-
margin-bottom: 0.5rem;
|
| 237 |
}
|
| 238 |
|
| 239 |
-
.
|
| 240 |
-
|
| 241 |
-
justify-content: space-between;
|
| 242 |
-
align-items: center;
|
| 243 |
-
font-size: 0.8rem;
|
| 244 |
color: var(--text-secondary);
|
| 245 |
}
|
| 246 |
|
| 247 |
-
|
| 248 |
-
|
|
|
|
|
|
|
|
|
|
| 249 |
}
|
| 250 |
|
| 251 |
-
.
|
| 252 |
-
|
| 253 |
}
|
| 254 |
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
|
|
|
| 260 |
}
|
| 261 |
|
| 262 |
-
.
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
gap: 0.5rem;
|
| 267 |
-
padding: 0.75rem 1.5rem;
|
| 268 |
-
border: none;
|
| 269 |
-
border-radius: 6px;
|
| 270 |
-
font-size: 0.9rem;
|
| 271 |
-
font-weight: 500;
|
| 272 |
-
cursor: pointer;
|
| 273 |
-
transition: all var(--transition-fast);
|
| 274 |
-
text-decoration: none;
|
| 275 |
}
|
| 276 |
|
| 277 |
-
.
|
| 278 |
-
|
| 279 |
-
|
| 280 |
}
|
| 281 |
|
| 282 |
-
.
|
| 283 |
-
|
| 284 |
-
transform: translateY(-1px);
|
| 285 |
}
|
| 286 |
|
| 287 |
-
.
|
| 288 |
-
background-color: var(--bg-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 289 |
color: var(--text-primary);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 290 |
border: 1px solid var(--border-color);
|
| 291 |
}
|
| 292 |
|
| 293 |
-
.
|
| 294 |
-
|
| 295 |
-
|
|
|
|
|
|
|
|
|
|
| 296 |
}
|
| 297 |
|
| 298 |
-
|
| 299 |
-
.
|
| 300 |
-
|
| 301 |
-
|
| 302 |
}
|
| 303 |
|
| 304 |
/* Responsive Design */
|
| 305 |
@media (max-width: 768px) {
|
| 306 |
.emr-header-content {
|
| 307 |
flex-direction: column;
|
|
|
|
| 308 |
align-items: stretch;
|
| 309 |
-
text-align: center;
|
| 310 |
}
|
| 311 |
|
| 312 |
-
.
|
| 313 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 314 |
text-align: center;
|
| 315 |
}
|
| 316 |
|
| 317 |
-
.
|
| 318 |
-
|
| 319 |
}
|
| 320 |
|
| 321 |
-
.
|
| 322 |
-
|
| 323 |
}
|
| 324 |
|
| 325 |
-
.
|
| 326 |
flex-direction: column;
|
| 327 |
}
|
| 328 |
|
| 329 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 330 |
flex-direction: column;
|
| 331 |
-
align-items: stretch;
|
| 332 |
}
|
| 333 |
}
|
| 334 |
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
.emr-
|
| 338 |
-
|
| 339 |
}
|
| 340 |
-
}
|
| 341 |
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
background-color: var(--bg-secondary);
|
| 347 |
-
}
|
| 348 |
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
}
|
| 352 |
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* EMR Page Specific Styles */
|
| 2 |
+
|
| 3 |
.emr-container {
|
| 4 |
min-height: 100vh;
|
| 5 |
+
background-color: var(--bg-secondary);
|
|
|
|
| 6 |
}
|
| 7 |
|
| 8 |
+
/* Header */
|
| 9 |
.emr-header {
|
| 10 |
+
background-color: var(--bg-primary);
|
| 11 |
border-bottom: 1px solid var(--border-color);
|
| 12 |
+
padding: var(--spacing-lg);
|
| 13 |
position: sticky;
|
| 14 |
top: 0;
|
| 15 |
z-index: 100;
|
|
|
|
| 18 |
.emr-header-content {
|
| 19 |
max-width: 1200px;
|
| 20 |
margin: 0 auto;
|
|
|
|
| 21 |
display: flex;
|
|
|
|
| 22 |
justify-content: space-between;
|
| 23 |
+
align-items: center;
|
|
|
|
| 24 |
}
|
| 25 |
|
| 26 |
+
.emr-title {
|
| 27 |
+
display: flex;
|
| 28 |
align-items: center;
|
| 29 |
+
gap: var(--spacing-md);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
}
|
| 31 |
|
| 32 |
+
.emr-title i {
|
| 33 |
+
font-size: 1.5rem;
|
| 34 |
+
color: var(--primary-color);
|
| 35 |
}
|
| 36 |
|
| 37 |
+
.emr-title h1 {
|
| 38 |
margin: 0;
|
| 39 |
+
color: var(--text-primary);
|
| 40 |
font-size: 1.5rem;
|
| 41 |
font-weight: 600;
|
|
|
|
| 42 |
}
|
| 43 |
|
| 44 |
+
.emr-actions {
|
| 45 |
display: flex;
|
| 46 |
+
gap: var(--spacing-md);
|
| 47 |
+
align-items: center;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
}
|
| 49 |
|
| 50 |
+
/* Patient Info Bar */
|
| 51 |
+
.patient-info-bar {
|
| 52 |
+
background-color: var(--bg-primary);
|
| 53 |
+
border-bottom: 1px solid var(--border-color);
|
| 54 |
+
padding: var(--spacing-lg);
|
| 55 |
max-width: 1200px;
|
| 56 |
margin: 0 auto;
|
|
|
|
| 57 |
}
|
| 58 |
|
| 59 |
+
.patient-info {
|
| 60 |
+
display: flex;
|
| 61 |
+
align-items: center;
|
| 62 |
+
gap: var(--spacing-md);
|
| 63 |
+
margin-bottom: var(--spacing-md);
|
|
|
|
| 64 |
}
|
| 65 |
|
| 66 |
+
.patient-avatar {
|
| 67 |
+
width: 48px;
|
| 68 |
+
height: 48px;
|
| 69 |
+
border-radius: 50%;
|
| 70 |
+
background-color: var(--primary-color);
|
| 71 |
+
color: white;
|
| 72 |
display: flex;
|
| 73 |
align-items: center;
|
| 74 |
+
justify-content: center;
|
| 75 |
+
font-size: 1.25rem;
|
| 76 |
}
|
| 77 |
|
| 78 |
+
.patient-details h3 {
|
| 79 |
+
margin: 0;
|
| 80 |
+
color: var(--text-primary);
|
| 81 |
+
font-size: 1.125rem;
|
| 82 |
}
|
| 83 |
|
| 84 |
+
.patient-details p {
|
| 85 |
+
margin: 0;
|
| 86 |
+
color: var(--text-secondary);
|
| 87 |
+
font-size: 0.875rem;
|
| 88 |
}
|
| 89 |
|
| 90 |
+
.patient-stats {
|
| 91 |
display: grid;
|
| 92 |
+
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
| 93 |
+
gap: var(--spacing-md);
|
| 94 |
}
|
| 95 |
|
| 96 |
+
.stat-item {
|
| 97 |
+
background-color: var(--bg-secondary);
|
| 98 |
+
padding: var(--spacing-md);
|
| 99 |
+
border-radius: 8px;
|
| 100 |
+
text-align: center;
|
| 101 |
+
border: 1px solid var(--border-color);
|
| 102 |
}
|
| 103 |
|
| 104 |
+
.stat-value {
|
| 105 |
+
font-size: 1.5rem;
|
| 106 |
+
font-weight: 600;
|
| 107 |
+
color: var(--primary-color);
|
| 108 |
+
margin-bottom: var(--spacing-xs);
|
| 109 |
}
|
| 110 |
|
| 111 |
+
.stat-label {
|
| 112 |
+
font-size: 0.75rem;
|
| 113 |
+
color: var(--text-secondary);
|
| 114 |
+
text-transform: uppercase;
|
| 115 |
+
letter-spacing: 0.05em;
|
| 116 |
}
|
| 117 |
|
| 118 |
+
/* Controls */
|
| 119 |
+
.emr-controls {
|
| 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 |
+
.search-container {
|
| 128 |
+
display: flex;
|
| 129 |
+
gap: var(--spacing-md);
|
| 130 |
+
margin-bottom: var(--spacing-md);
|
| 131 |
}
|
| 132 |
|
| 133 |
+
.search-input {
|
| 134 |
+
flex: 1;
|
| 135 |
+
padding: var(--spacing-md);
|
|
|
|
| 136 |
border: 1px solid var(--border-color);
|
| 137 |
+
border-radius: 8px;
|
| 138 |
+
font-size: 0.875rem;
|
|
|
|
|
|
|
| 139 |
transition: border-color var(--transition-fast);
|
| 140 |
}
|
| 141 |
|
| 142 |
+
.search-input:focus {
|
|
|
|
|
|
|
| 143 |
outline: none;
|
| 144 |
border-color: var(--primary-color);
|
| 145 |
+
box-shadow: 0 0 0 3px rgb(37 99 235 / 0.1);
|
| 146 |
}
|
| 147 |
|
| 148 |
+
.search-btn {
|
| 149 |
+
padding: var(--spacing-md) var(--spacing-lg);
|
| 150 |
+
background-color: var(--primary-color);
|
| 151 |
+
color: white;
|
| 152 |
+
border: none;
|
| 153 |
+
border-radius: 8px;
|
| 154 |
+
cursor: pointer;
|
| 155 |
+
transition: background-color var(--transition-fast);
|
| 156 |
}
|
| 157 |
|
| 158 |
+
.search-btn:hover {
|
| 159 |
+
background-color: var(--primary-hover);
|
|
|
|
|
|
|
|
|
|
| 160 |
}
|
| 161 |
|
| 162 |
+
.filter-container {
|
| 163 |
display: flex;
|
| 164 |
+
gap: var(--spacing-md);
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
.filter-select {
|
| 168 |
+
padding: var(--spacing-sm) var(--spacing-md);
|
| 169 |
border: 1px solid var(--border-color);
|
| 170 |
border-radius: 6px;
|
| 171 |
background-color: var(--bg-primary);
|
| 172 |
+
font-size: 0.875rem;
|
| 173 |
+
cursor: pointer;
|
| 174 |
}
|
| 175 |
|
| 176 |
+
/* Content */
|
| 177 |
+
.emr-content {
|
| 178 |
+
max-width: 1200px;
|
| 179 |
+
margin: 0 auto;
|
| 180 |
+
padding: var(--spacing-lg);
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
/* Table */
|
| 184 |
+
.emr-table-container {
|
| 185 |
+
background-color: var(--bg-primary);
|
| 186 |
+
border-radius: 12px;
|
| 187 |
+
border: 1px solid var(--border-color);
|
| 188 |
+
overflow: hidden;
|
| 189 |
+
box-shadow: var(--shadow-sm);
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
.emr-table {
|
| 193 |
+
width: 100%;
|
| 194 |
+
border-collapse: collapse;
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
.emr-table th {
|
| 198 |
+
background-color: var(--bg-tertiary);
|
| 199 |
+
padding: var(--spacing-md);
|
| 200 |
+
text-align: left;
|
| 201 |
+
font-weight: 600;
|
| 202 |
+
color: var(--text-primary);
|
| 203 |
+
border-bottom: 1px solid var(--border-color);
|
| 204 |
+
font-size: 0.875rem;
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
.emr-table td {
|
| 208 |
+
padding: var(--spacing-md);
|
| 209 |
+
border-bottom: 1px solid var(--border-color);
|
| 210 |
+
vertical-align: top;
|
| 211 |
+
font-size: 0.875rem;
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
.emr-table tbody tr:hover {
|
| 215 |
+
background-color: var(--bg-secondary);
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
.emr-table tbody tr:last-child td {
|
| 219 |
+
border-bottom: none;
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
/* EMR Entry Types */
|
| 223 |
+
.emr-type {
|
| 224 |
+
display: inline-block;
|
| 225 |
+
padding: var(--spacing-xs) var(--spacing-sm);
|
| 226 |
+
border-radius: 4px;
|
| 227 |
+
font-size: 0.75rem;
|
| 228 |
font-weight: 500;
|
| 229 |
+
text-transform: uppercase;
|
| 230 |
+
letter-spacing: 0.05em;
|
| 231 |
}
|
| 232 |
|
| 233 |
+
.emr-type-diagnosis {
|
| 234 |
+
background-color: #fef3c7;
|
| 235 |
+
color: #92400e;
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
.emr-type-medication {
|
| 239 |
+
background-color: #dbeafe;
|
| 240 |
+
color: #1e40af;
|
|
|
|
| 241 |
}
|
| 242 |
|
| 243 |
+
.emr-type-vitals {
|
| 244 |
+
background-color: #f3e8ff;
|
| 245 |
+
color: #7c3aed;
|
| 246 |
}
|
| 247 |
|
| 248 |
+
.emr-type-lab {
|
| 249 |
+
background-color: #ecfdf5;
|
| 250 |
+
color: #065f46;
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
.emr-type-general {
|
| 254 |
+
background-color: var(--bg-tertiary);
|
| 255 |
+
color: var(--text-secondary);
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
/* Confidence Score */
|
| 259 |
+
.confidence-score {
|
| 260 |
display: flex;
|
|
|
|
| 261 |
align-items: center;
|
| 262 |
+
gap: var(--spacing-xs);
|
| 263 |
}
|
| 264 |
|
| 265 |
+
.confidence-bar {
|
| 266 |
+
width: 60px;
|
| 267 |
+
height: 4px;
|
| 268 |
+
background-color: var(--bg-tertiary);
|
| 269 |
+
border-radius: 2px;
|
| 270 |
+
overflow: hidden;
|
| 271 |
+
}
|
| 272 |
+
|
| 273 |
+
.confidence-fill {
|
| 274 |
+
height: 100%;
|
| 275 |
+
transition: width var(--transition-normal);
|
| 276 |
}
|
| 277 |
|
| 278 |
+
.confidence-fill.high {
|
| 279 |
+
background-color: var(--success-color);
|
| 280 |
}
|
| 281 |
|
| 282 |
+
.confidence-fill.medium {
|
| 283 |
+
background-color: var(--warning-color);
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
.confidence-fill.low {
|
| 287 |
+
background-color: var(--accent-color);
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
.confidence-text {
|
| 291 |
+
font-size: 0.75rem;
|
| 292 |
+
color: var(--text-secondary);
|
| 293 |
+
font-weight: 500;
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
/* Action Buttons */
|
| 297 |
+
.action-buttons {
|
| 298 |
display: flex;
|
| 299 |
+
gap: var(--spacing-xs);
|
|
|
|
| 300 |
}
|
| 301 |
|
| 302 |
+
.action-btn {
|
| 303 |
+
background: none;
|
| 304 |
+
border: none;
|
| 305 |
+
color: var(--text-muted);
|
|
|
|
| 306 |
cursor: pointer;
|
| 307 |
+
padding: var(--spacing-xs);
|
| 308 |
+
border-radius: 4px;
|
| 309 |
transition: all var(--transition-fast);
|
| 310 |
+
font-size: 0.875rem;
|
| 311 |
}
|
| 312 |
|
| 313 |
+
.action-btn:hover {
|
|
|
|
| 314 |
background-color: var(--bg-tertiary);
|
| 315 |
+
color: var(--primary-color);
|
| 316 |
}
|
| 317 |
|
| 318 |
+
.action-btn.danger:hover {
|
| 319 |
+
color: var(--accent-color);
|
| 320 |
+
}
|
| 321 |
+
|
| 322 |
+
/* Loading and Empty States */
|
| 323 |
+
.loading-state, .empty-state {
|
| 324 |
+
text-align: center;
|
| 325 |
+
padding: var(--spacing-2xl);
|
| 326 |
+
background-color: var(--bg-primary);
|
| 327 |
+
border-radius: 12px;
|
| 328 |
+
border: 1px solid var(--border-color);
|
| 329 |
+
}
|
| 330 |
+
|
| 331 |
+
.loading-spinner {
|
| 332 |
+
font-size: 2rem;
|
| 333 |
+
color: var(--primary-color);
|
| 334 |
+
margin-bottom: var(--spacing-md);
|
| 335 |
+
}
|
| 336 |
+
|
| 337 |
+
.empty-icon {
|
| 338 |
+
font-size: 3rem;
|
| 339 |
+
color: var(--text-muted);
|
| 340 |
+
margin-bottom: var(--spacing-lg);
|
| 341 |
+
}
|
| 342 |
+
|
| 343 |
+
.empty-state h3 {
|
| 344 |
+
margin: 0 0 var(--spacing-md) 0;
|
| 345 |
color: var(--text-primary);
|
|
|
|
| 346 |
}
|
| 347 |
|
| 348 |
+
.empty-state p {
|
| 349 |
+
margin: 0 0 var(--spacing-lg) 0;
|
|
|
|
|
|
|
|
|
|
| 350 |
color: var(--text-secondary);
|
| 351 |
}
|
| 352 |
|
| 353 |
+
/* EMR Detail Modal */
|
| 354 |
+
.emr-detail-modal {
|
| 355 |
+
max-width: 800px;
|
| 356 |
+
max-height: 80vh;
|
| 357 |
+
overflow-y: auto;
|
| 358 |
}
|
| 359 |
|
| 360 |
+
.emr-detail-section {
|
| 361 |
+
margin-bottom: var(--spacing-lg);
|
| 362 |
}
|
| 363 |
|
| 364 |
+
.emr-detail-section h4 {
|
| 365 |
+
margin: 0 0 var(--spacing-md) 0;
|
| 366 |
+
color: var(--text-primary);
|
| 367 |
+
font-size: 1rem;
|
| 368 |
+
border-bottom: 1px solid var(--border-color);
|
| 369 |
+
padding-bottom: var(--spacing-sm);
|
| 370 |
}
|
| 371 |
|
| 372 |
+
.emr-detail-list {
|
| 373 |
+
list-style: none;
|
| 374 |
+
padding: 0;
|
| 375 |
+
margin: 0;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 376 |
}
|
| 377 |
|
| 378 |
+
.emr-detail-list li {
|
| 379 |
+
padding: var(--spacing-sm) 0;
|
| 380 |
+
border-bottom: 1px solid var(--bg-tertiary);
|
| 381 |
}
|
| 382 |
|
| 383 |
+
.emr-detail-list li:last-child {
|
| 384 |
+
border-bottom: none;
|
|
|
|
| 385 |
}
|
| 386 |
|
| 387 |
+
.medication-item {
|
| 388 |
+
background-color: var(--bg-secondary);
|
| 389 |
+
padding: var(--spacing-md);
|
| 390 |
+
border-radius: 8px;
|
| 391 |
+
margin-bottom: var(--spacing-sm);
|
| 392 |
+
border: 1px solid var(--border-color);
|
| 393 |
+
}
|
| 394 |
+
|
| 395 |
+
.medication-name {
|
| 396 |
+
font-weight: 600;
|
| 397 |
color: var(--text-primary);
|
| 398 |
+
margin-bottom: var(--spacing-xs);
|
| 399 |
+
}
|
| 400 |
+
|
| 401 |
+
.medication-details {
|
| 402 |
+
font-size: 0.875rem;
|
| 403 |
+
color: var(--text-secondary);
|
| 404 |
+
}
|
| 405 |
+
|
| 406 |
+
.vital-signs-grid {
|
| 407 |
+
display: grid;
|
| 408 |
+
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
| 409 |
+
gap: var(--spacing-md);
|
| 410 |
+
}
|
| 411 |
+
|
| 412 |
+
.vital-sign-item {
|
| 413 |
+
background-color: var(--bg-secondary);
|
| 414 |
+
padding: var(--spacing-md);
|
| 415 |
+
border-radius: 8px;
|
| 416 |
+
text-align: center;
|
| 417 |
border: 1px solid var(--border-color);
|
| 418 |
}
|
| 419 |
|
| 420 |
+
.vital-sign-label {
|
| 421 |
+
font-size: 0.75rem;
|
| 422 |
+
color: var(--text-secondary);
|
| 423 |
+
text-transform: uppercase;
|
| 424 |
+
letter-spacing: 0.05em;
|
| 425 |
+
margin-bottom: var(--spacing-xs);
|
| 426 |
}
|
| 427 |
|
| 428 |
+
.vital-sign-value {
|
| 429 |
+
font-size: 1.125rem;
|
| 430 |
+
font-weight: 600;
|
| 431 |
+
color: var(--text-primary);
|
| 432 |
}
|
| 433 |
|
| 434 |
/* Responsive Design */
|
| 435 |
@media (max-width: 768px) {
|
| 436 |
.emr-header-content {
|
| 437 |
flex-direction: column;
|
| 438 |
+
gap: var(--spacing-md);
|
| 439 |
align-items: stretch;
|
|
|
|
| 440 |
}
|
| 441 |
|
| 442 |
+
.emr-actions {
|
| 443 |
+
justify-content: center;
|
| 444 |
+
}
|
| 445 |
+
|
| 446 |
+
.patient-info {
|
| 447 |
+
flex-direction: column;
|
| 448 |
text-align: center;
|
| 449 |
}
|
| 450 |
|
| 451 |
+
.patient-stats {
|
| 452 |
+
grid-template-columns: repeat(2, 1fr);
|
| 453 |
}
|
| 454 |
|
| 455 |
+
.search-container {
|
| 456 |
+
flex-direction: column;
|
| 457 |
}
|
| 458 |
|
| 459 |
+
.filter-container {
|
| 460 |
flex-direction: column;
|
| 461 |
}
|
| 462 |
|
| 463 |
+
.emr-table-container {
|
| 464 |
+
overflow-x: auto;
|
| 465 |
+
}
|
| 466 |
+
|
| 467 |
+
.emr-table {
|
| 468 |
+
min-width: 600px;
|
| 469 |
+
}
|
| 470 |
+
|
| 471 |
+
.action-buttons {
|
| 472 |
flex-direction: column;
|
|
|
|
| 473 |
}
|
| 474 |
}
|
| 475 |
|
| 476 |
+
/* Dark Theme Support */
|
| 477 |
+
@media (prefers-color-scheme: dark) {
|
| 478 |
+
.emr-container {
|
| 479 |
+
background-color: var(--dark-bg-secondary);
|
| 480 |
}
|
|
|
|
| 481 |
|
| 482 |
+
.emr-header {
|
| 483 |
+
background-color: var(--dark-bg-primary);
|
| 484 |
+
border-bottom-color: var(--dark-border-color);
|
| 485 |
+
}
|
|
|
|
|
|
|
| 486 |
|
| 487 |
+
.emr-title h1 {
|
| 488 |
+
color: var(--dark-text-primary);
|
| 489 |
+
}
|
| 490 |
|
| 491 |
+
.patient-info-bar {
|
| 492 |
+
background-color: var(--dark-bg-primary);
|
| 493 |
+
border-bottom-color: var(--dark-border-color);
|
| 494 |
+
}
|
| 495 |
+
|
| 496 |
+
.patient-details h3 {
|
| 497 |
+
color: var(--dark-text-primary);
|
| 498 |
+
}
|
| 499 |
+
|
| 500 |
+
.patient-details p {
|
| 501 |
+
color: var(--dark-text-secondary);
|
| 502 |
+
}
|
| 503 |
+
|
| 504 |
+
.emr-controls {
|
| 505 |
+
background-color: var(--dark-bg-primary);
|
| 506 |
+
border-bottom-color: var(--dark-border-color);
|
| 507 |
+
}
|
| 508 |
+
|
| 509 |
+
.search-input, .filter-select {
|
| 510 |
+
background-color: var(--dark-bg-secondary);
|
| 511 |
+
border-color: var(--dark-border-color);
|
| 512 |
+
color: var(--dark-text-primary);
|
| 513 |
+
}
|
| 514 |
+
|
| 515 |
+
.emr-table-container {
|
| 516 |
+
background-color: var(--dark-bg-primary);
|
| 517 |
+
border-color: var(--dark-border-color);
|
| 518 |
+
}
|
| 519 |
+
|
| 520 |
+
.emr-table th {
|
| 521 |
+
background-color: var(--dark-bg-tertiary);
|
| 522 |
+
color: var(--dark-text-primary);
|
| 523 |
+
border-bottom-color: var(--dark-border-color);
|
| 524 |
+
}
|
| 525 |
+
|
| 526 |
+
.emr-table td {
|
| 527 |
+
border-bottom-color: var(--dark-border-color);
|
| 528 |
+
color: var(--dark-text-primary);
|
| 529 |
+
}
|
| 530 |
+
|
| 531 |
+
.emr-table tbody tr:hover {
|
| 532 |
+
background-color: var(--dark-bg-secondary);
|
| 533 |
+
}
|
| 534 |
+
}
|
static/css/styles.css
CHANGED
|
@@ -481,6 +481,96 @@ body {
|
|
| 481 |
text-align: right;
|
| 482 |
}
|
| 483 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 484 |
/* Chat Input */
|
| 485 |
.chat-input-container {
|
| 486 |
padding: var(--spacing-lg);
|
|
|
|
| 481 |
text-align: right;
|
| 482 |
}
|
| 483 |
|
| 484 |
+
/* EMR Button Styles */
|
| 485 |
+
.message-actions {
|
| 486 |
+
display: flex;
|
| 487 |
+
align-items: center;
|
| 488 |
+
margin-left: var(--spacing-sm);
|
| 489 |
+
opacity: 0;
|
| 490 |
+
transition: opacity var(--transition-fast);
|
| 491 |
+
}
|
| 492 |
+
|
| 493 |
+
.message:hover .message-actions {
|
| 494 |
+
opacity: 1;
|
| 495 |
+
}
|
| 496 |
+
|
| 497 |
+
.emr-extract-btn {
|
| 498 |
+
background: none;
|
| 499 |
+
border: none;
|
| 500 |
+
color: var(--text-muted);
|
| 501 |
+
cursor: pointer;
|
| 502 |
+
padding: var(--spacing-sm);
|
| 503 |
+
border-radius: 6px;
|
| 504 |
+
transition: all var(--transition-fast);
|
| 505 |
+
font-size: 0.875rem;
|
| 506 |
+
}
|
| 507 |
+
|
| 508 |
+
.emr-extract-btn:hover {
|
| 509 |
+
background-color: var(--bg-tertiary);
|
| 510 |
+
color: var(--primary-color);
|
| 511 |
+
transform: scale(1.1);
|
| 512 |
+
}
|
| 513 |
+
|
| 514 |
+
.emr-extract-btn:disabled {
|
| 515 |
+
opacity: 0.6;
|
| 516 |
+
cursor: not-allowed;
|
| 517 |
+
transform: none;
|
| 518 |
+
}
|
| 519 |
+
|
| 520 |
+
/* Notification Styles */
|
| 521 |
+
.notification {
|
| 522 |
+
position: fixed;
|
| 523 |
+
top: var(--spacing-lg);
|
| 524 |
+
right: var(--spacing-lg);
|
| 525 |
+
background-color: var(--bg-primary);
|
| 526 |
+
border: 1px solid var(--border-color);
|
| 527 |
+
border-radius: 8px;
|
| 528 |
+
box-shadow: var(--shadow-lg);
|
| 529 |
+
padding: var(--spacing-md);
|
| 530 |
+
z-index: 1000;
|
| 531 |
+
transform: translateX(100%);
|
| 532 |
+
transition: transform var(--transition-normal);
|
| 533 |
+
max-width: 300px;
|
| 534 |
+
}
|
| 535 |
+
|
| 536 |
+
.notification.show {
|
| 537 |
+
transform: translateX(0);
|
| 538 |
+
}
|
| 539 |
+
|
| 540 |
+
.notification-content {
|
| 541 |
+
display: flex;
|
| 542 |
+
align-items: center;
|
| 543 |
+
gap: var(--spacing-sm);
|
| 544 |
+
}
|
| 545 |
+
|
| 546 |
+
.notification-success {
|
| 547 |
+
border-left: 4px solid var(--success-color);
|
| 548 |
+
}
|
| 549 |
+
|
| 550 |
+
.notification-error {
|
| 551 |
+
border-left: 4px solid var(--accent-color);
|
| 552 |
+
}
|
| 553 |
+
|
| 554 |
+
.notification-info {
|
| 555 |
+
border-left: 4px solid var(--primary-color);
|
| 556 |
+
}
|
| 557 |
+
|
| 558 |
+
.notification-content i {
|
| 559 |
+
font-size: 1.125rem;
|
| 560 |
+
}
|
| 561 |
+
|
| 562 |
+
.notification-success .notification-content i {
|
| 563 |
+
color: var(--success-color);
|
| 564 |
+
}
|
| 565 |
+
|
| 566 |
+
.notification-error .notification-content i {
|
| 567 |
+
color: var(--accent-color);
|
| 568 |
+
}
|
| 569 |
+
|
| 570 |
+
.notification-info .notification-content i {
|
| 571 |
+
color: var(--primary-color);
|
| 572 |
+
}
|
| 573 |
+
|
| 574 |
/* Chat Input */
|
| 575 |
.chat-input-container {
|
| 576 |
padding: var(--spacing-lg);
|
static/emr.html
CHANGED
|
@@ -3,139 +3,164 @@
|
|
| 3 |
<head>
|
| 4 |
<meta charset="UTF-8">
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
-
<title>
|
|
|
|
| 7 |
<link rel="stylesheet" href="/static/css/styles.css">
|
| 8 |
<link rel="stylesheet" href="/static/css/emr.css">
|
| 9 |
-
<link
|
| 10 |
</head>
|
| 11 |
<body>
|
| 12 |
<div class="emr-container">
|
| 13 |
<!-- Header -->
|
| 14 |
-
<
|
| 15 |
<div class="emr-header-content">
|
| 16 |
-
<
|
| 17 |
-
<i class="fas fa-
|
| 18 |
-
|
| 19 |
-
</
|
| 20 |
-
<
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
</div>
|
| 25 |
</div>
|
| 26 |
-
</
|
| 27 |
|
| 28 |
-
<!--
|
| 29 |
-
<
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
<
|
| 36 |
-
|
| 37 |
-
<label>Full Name</label>
|
| 38 |
-
<input type="text" id="patientNameInput" placeholder="Enter patient name">
|
| 39 |
-
</div>
|
| 40 |
-
<div class="emr-field">
|
| 41 |
-
<label>Age</label>
|
| 42 |
-
<input type="number" id="patientAgeInput" placeholder="Enter age" min="0" max="150">
|
| 43 |
-
</div>
|
| 44 |
-
</div>
|
| 45 |
-
<!-- Row 2: Sex and Ethnicity -->
|
| 46 |
-
<div class="row">
|
| 47 |
-
<div class="emr-field">
|
| 48 |
-
<label>Sex</label>
|
| 49 |
-
<select id="patientSexInput">
|
| 50 |
-
<option value="Male">Male</option>
|
| 51 |
-
<option value="Female">Female</option>
|
| 52 |
-
<option value="Intersex">Intersex</option>
|
| 53 |
-
</select>
|
| 54 |
-
</div>
|
| 55 |
-
<div class="emr-field">
|
| 56 |
-
<label>Ethnicity</label>
|
| 57 |
-
<input type="ethnicity" id="patientEthnicityInput" placeholder="Enter ethnicity">
|
| 58 |
-
</div>
|
| 59 |
-
</div>
|
| 60 |
-
<!-- Row 3: Contact Information -->
|
| 61 |
-
<div class="row contact-info">
|
| 62 |
-
<div class="emr-field">
|
| 63 |
-
<label>Phone</label>
|
| 64 |
-
<input type="tel" id="patientPhoneInput" placeholder="Enter phone number">
|
| 65 |
-
</div>
|
| 66 |
-
<div class="emr-field">
|
| 67 |
-
<label>Email</label>
|
| 68 |
-
<input type="email" id="patientEmailInput" placeholder="Enter email address">
|
| 69 |
-
</div>
|
| 70 |
-
<div class="emr-field">
|
| 71 |
-
<label>Address</label>
|
| 72 |
-
<input type="text" id="patientAddressInput" placeholder="Enter full address">
|
| 73 |
-
</div>
|
| 74 |
-
</div>
|
| 75 |
</div>
|
| 76 |
-
</
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
<
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
</div>
|
| 103 |
-
|
|
|
|
| 104 |
|
| 105 |
-
<!--
|
| 106 |
-
<
|
| 107 |
-
<
|
| 108 |
-
|
| 109 |
-
<!-- Sessions will be loaded here -->
|
| 110 |
</div>
|
| 111 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
<div class="
|
| 117 |
-
<
|
| 118 |
-
|
| 119 |
-
</button>
|
| 120 |
-
<button id="refreshPatientBtn" class="btn-secondary">
|
| 121 |
-
<i class="fas fa-refresh"></i> Refresh Data
|
| 122 |
-
</button>
|
| 123 |
-
<button id="exportPatientBtn" class="btn-secondary">
|
| 124 |
-
<i class="fas fa-download"></i> Export EMR
|
| 125 |
-
</button>
|
| 126 |
</div>
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
</div>
|
| 137 |
</div>
|
| 138 |
|
| 139 |
<script src="/static/js/emr.js"></script>
|
| 140 |
</body>
|
| 141 |
-
</html>
|
|
|
|
| 3 |
<head>
|
| 4 |
<meta charset="UTF-8">
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>EMR - Medical AI Assistant</title>
|
| 7 |
+
<link rel="icon" type="image/svg+xml" href="/static/icon.svg">
|
| 8 |
<link rel="stylesheet" href="/static/css/styles.css">
|
| 9 |
<link rel="stylesheet" href="/static/css/emr.css">
|
| 10 |
+
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
| 11 |
</head>
|
| 12 |
<body>
|
| 13 |
<div class="emr-container">
|
| 14 |
<!-- Header -->
|
| 15 |
+
<div class="emr-header">
|
| 16 |
<div class="emr-header-content">
|
| 17 |
+
<div class="emr-title">
|
| 18 |
+
<i class="fas fa-file-medical"></i>
|
| 19 |
+
<h1>Electronic Medical Records</h1>
|
| 20 |
+
</div>
|
| 21 |
+
<div class="emr-actions">
|
| 22 |
+
<button class="btn btn-secondary" id="refreshBtn">
|
| 23 |
+
<i class="fas fa-sync-alt"></i>
|
| 24 |
+
Refresh
|
| 25 |
+
</button>
|
| 26 |
+
<button class="btn btn-primary" id="searchBtn">
|
| 27 |
+
<i class="fas fa-search"></i>
|
| 28 |
+
Search
|
| 29 |
+
</button>
|
| 30 |
+
<a href="/static/index.html" class="btn btn-outline">
|
| 31 |
+
<i class="fas fa-arrow-left"></i>
|
| 32 |
+
Back to Chat
|
| 33 |
+
</a>
|
| 34 |
</div>
|
| 35 |
</div>
|
| 36 |
+
</div>
|
| 37 |
|
| 38 |
+
<!-- Patient Info Bar -->
|
| 39 |
+
<div class="patient-info-bar" id="patientInfoBar" style="display: none;">
|
| 40 |
+
<div class="patient-info">
|
| 41 |
+
<div class="patient-avatar">
|
| 42 |
+
<i class="fas fa-user"></i>
|
| 43 |
+
</div>
|
| 44 |
+
<div class="patient-details">
|
| 45 |
+
<h3 id="patientName">Patient Name</h3>
|
| 46 |
+
<p id="patientDetails">Age: -- | Sex: -- | ID: --</p>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
</div>
|
| 48 |
+
</div>
|
| 49 |
+
<div class="patient-stats" id="patientStats">
|
| 50 |
+
<!-- Stats will be populated here -->
|
| 51 |
+
</div>
|
| 52 |
+
</div>
|
| 53 |
|
| 54 |
+
<!-- Search and Filters -->
|
| 55 |
+
<div class="emr-controls">
|
| 56 |
+
<div class="search-container">
|
| 57 |
+
<input type="text" id="searchInput" placeholder="Search EMR entries..." class="search-input">
|
| 58 |
+
<button class="search-btn" id="searchButton">
|
| 59 |
+
<i class="fas fa-search"></i>
|
| 60 |
+
</button>
|
| 61 |
+
</div>
|
| 62 |
+
<div class="filter-container">
|
| 63 |
+
<select id="dateFilter" class="filter-select">
|
| 64 |
+
<option value="all">All Time</option>
|
| 65 |
+
<option value="today">Today</option>
|
| 66 |
+
<option value="week">This Week</option>
|
| 67 |
+
<option value="month">This Month</option>
|
| 68 |
+
</select>
|
| 69 |
+
<select id="typeFilter" class="filter-select">
|
| 70 |
+
<option value="all">All Types</option>
|
| 71 |
+
<option value="diagnosis">Diagnosis</option>
|
| 72 |
+
<option value="medication">Medication</option>
|
| 73 |
+
<option value="vitals">Vital Signs</option>
|
| 74 |
+
<option value="lab">Lab Results</option>
|
| 75 |
+
</select>
|
| 76 |
+
</div>
|
| 77 |
+
</div>
|
| 78 |
+
|
| 79 |
+
<!-- EMR Entries Table -->
|
| 80 |
+
<div class="emr-content">
|
| 81 |
+
<div class="emr-table-container">
|
| 82 |
+
<table class="emr-table" id="emrTable">
|
| 83 |
+
<thead>
|
| 84 |
+
<tr>
|
| 85 |
+
<th>Date & Time</th>
|
| 86 |
+
<th>Type</th>
|
| 87 |
+
<th>Diagnosis</th>
|
| 88 |
+
<th>Medications</th>
|
| 89 |
+
<th>Vital Signs</th>
|
| 90 |
+
<th>Confidence</th>
|
| 91 |
+
<th>Actions</th>
|
| 92 |
+
</tr>
|
| 93 |
+
</thead>
|
| 94 |
+
<tbody id="emrTableBody">
|
| 95 |
+
<!-- EMR entries will be populated here -->
|
| 96 |
+
</tbody>
|
| 97 |
+
</table>
|
| 98 |
+
</div>
|
| 99 |
+
|
| 100 |
+
<!-- Loading State -->
|
| 101 |
+
<div class="loading-state" id="loadingState" style="display: none;">
|
| 102 |
+
<div class="loading-spinner">
|
| 103 |
+
<i class="fas fa-spinner fa-spin"></i>
|
| 104 |
</div>
|
| 105 |
+
<p>Loading EMR data...</p>
|
| 106 |
+
</div>
|
| 107 |
|
| 108 |
+
<!-- Empty State -->
|
| 109 |
+
<div class="empty-state" id="emptyState" style="display: none;">
|
| 110 |
+
<div class="empty-icon">
|
| 111 |
+
<i class="fas fa-file-medical"></i>
|
|
|
|
| 112 |
</div>
|
| 113 |
+
<h3>No EMR Data Found</h3>
|
| 114 |
+
<p>No medical records found for this patient. EMR data will appear here when you extract information from chat messages.</p>
|
| 115 |
+
<a href="/static/index.html" class="btn btn-primary">
|
| 116 |
+
<i class="fas fa-comments"></i>
|
| 117 |
+
Go to Chat
|
| 118 |
+
</a>
|
| 119 |
+
</div>
|
| 120 |
+
</div>
|
| 121 |
|
| 122 |
+
<!-- EMR Detail Modal -->
|
| 123 |
+
<div class="modal" id="emrDetailModal">
|
| 124 |
+
<div class="modal-content emr-detail-modal">
|
| 125 |
+
<div class="modal-header">
|
| 126 |
+
<h3>EMR Entry Details</h3>
|
| 127 |
+
<button class="modal-close" id="emrDetailModalClose">×</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
</div>
|
| 129 |
+
<div class="modal-body" id="emrDetailContent">
|
| 130 |
+
<!-- EMR details will be populated here -->
|
| 131 |
+
</div>
|
| 132 |
+
<div class="modal-footer">
|
| 133 |
+
<button class="btn btn-secondary" id="emrDetailModalCancel">Close</button>
|
| 134 |
+
<button class="btn btn-danger" id="deleteEmrBtn" style="display: none;">Delete Entry</button>
|
| 135 |
+
</div>
|
| 136 |
+
</div>
|
| 137 |
+
</div>
|
| 138 |
|
| 139 |
+
<!-- Search Modal -->
|
| 140 |
+
<div class="modal" id="searchModal">
|
| 141 |
+
<div class="modal-content">
|
| 142 |
+
<div class="modal-header">
|
| 143 |
+
<h3>Advanced Search</h3>
|
| 144 |
+
<button class="modal-close" id="searchModalClose">×</button>
|
| 145 |
+
</div>
|
| 146 |
+
<div class="modal-body">
|
| 147 |
+
<div class="form-group">
|
| 148 |
+
<label for="semanticSearchInput">Search by meaning (semantic search):</label>
|
| 149 |
+
<input type="text" id="semanticSearchInput" placeholder="e.g., 'chest pain treatment' or 'diabetes medication'">
|
| 150 |
+
</div>
|
| 151 |
+
<div class="form-group">
|
| 152 |
+
<label for="exactSearchInput">Search by exact text:</label>
|
| 153 |
+
<input type="text" id="exactSearchInput" placeholder="e.g., 'hypertension' or 'metformin'">
|
| 154 |
+
</div>
|
| 155 |
+
</div>
|
| 156 |
+
<div class="modal-footer">
|
| 157 |
+
<button class="btn btn-secondary" id="searchModalCancel">Cancel</button>
|
| 158 |
+
<button class="btn btn-primary" id="performSearchBtn">Search</button>
|
| 159 |
+
</div>
|
| 160 |
+
</div>
|
| 161 |
</div>
|
| 162 |
</div>
|
| 163 |
|
| 164 |
<script src="/static/js/emr.js"></script>
|
| 165 |
</body>
|
| 166 |
+
</html>
|
static/index.html
CHANGED
|
@@ -50,7 +50,7 @@
|
|
| 50 |
</div>
|
| 51 |
<div class="patient-status" id="patientStatus">No patient selected</div>
|
| 52 |
<div class="patient-actions" id="patientActions" style="display: none;">
|
| 53 |
-
<a href="
|
| 54 |
<i class="fas fa-file-medical"></i> EMR
|
| 55 |
</a>
|
| 56 |
</div>
|
|
|
|
| 50 |
</div>
|
| 51 |
<div class="patient-status" id="patientStatus">No patient selected</div>
|
| 52 |
<div class="patient-actions" id="patientActions" style="display: none;">
|
| 53 |
+
<a href="/static/emr.html" id="emrLink" class="emr-link">
|
| 54 |
<i class="fas fa-file-medical"></i> EMR
|
| 55 |
</a>
|
| 56 |
</div>
|
static/js/app.js
CHANGED
|
@@ -1830,15 +1830,30 @@ How can I assist you today?`;
|
|
| 1830 |
messageElement.id = `message-${message.id}`;
|
| 1831 |
const avatar = message.role === 'user' ? '<i class="fas fa-user"></i>' : '<i class="fas fa-robot"></i>';
|
| 1832 |
const time = this.formatTime(message.timestamp);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1833 |
messageElement.innerHTML = `
|
| 1834 |
<div class="message-avatar">${avatar}</div>
|
| 1835 |
<div class="message-content">
|
| 1836 |
<div class="message-text">${this.formatMessageContent(message.content)}</div>
|
| 1837 |
<div class="message-time">${time}</div>
|
| 1838 |
-
</div
|
|
|
|
| 1839 |
chatMessages.appendChild(messageElement);
|
| 1840 |
chatMessages.scrollTop = chatMessages.scrollHeight;
|
| 1841 |
if (this.currentSession) this.currentSession.lastActivity = new Date().toISOString();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1842 |
}
|
| 1843 |
|
| 1844 |
formatMessageContent(content) {
|
|
@@ -1871,6 +1886,129 @@ How can I assist you today?`;
|
|
| 1871 |
if (diff < 86400000) { const hours = Math.floor(diff / 3600000); return `${hours} hour${hours > 1 ? 's' : ''} ago`; }
|
| 1872 |
return date.toLocaleDateString();
|
| 1873 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1874 |
}
|
| 1875 |
// ----------------------------------------------------------
|
| 1876 |
// Additional UI setup END
|
|
|
|
| 1830 |
messageElement.id = `message-${message.id}`;
|
| 1831 |
const avatar = message.role === 'user' ? '<i class="fas fa-user"></i>' : '<i class="fas fa-robot"></i>';
|
| 1832 |
const time = this.formatTime(message.timestamp);
|
| 1833 |
+
|
| 1834 |
+
// Add EMR icon for assistant messages (system-generated)
|
| 1835 |
+
const emrIcon = message.role === 'assistant' ?
|
| 1836 |
+
`<div class="message-actions">
|
| 1837 |
+
<button class="emr-extract-btn" onclick="app.extractEMR('${message.id}')" title="Extract to EMR" data-message-id="${message.id}">
|
| 1838 |
+
<i class="fas fa-file-medical"></i>
|
| 1839 |
+
</button>
|
| 1840 |
+
</div>` : '';
|
| 1841 |
+
|
| 1842 |
messageElement.innerHTML = `
|
| 1843 |
<div class="message-avatar">${avatar}</div>
|
| 1844 |
<div class="message-content">
|
| 1845 |
<div class="message-text">${this.formatMessageContent(message.content)}</div>
|
| 1846 |
<div class="message-time">${time}</div>
|
| 1847 |
+
</div>
|
| 1848 |
+
${emrIcon}`;
|
| 1849 |
chatMessages.appendChild(messageElement);
|
| 1850 |
chatMessages.scrollTop = chatMessages.scrollHeight;
|
| 1851 |
if (this.currentSession) this.currentSession.lastActivity = new Date().toISOString();
|
| 1852 |
+
|
| 1853 |
+
// Check EMR status for assistant messages
|
| 1854 |
+
if (message.role === 'assistant' && this.currentPatientId) {
|
| 1855 |
+
this.checkEMRStatus(message.id);
|
| 1856 |
+
}
|
| 1857 |
}
|
| 1858 |
|
| 1859 |
formatMessageContent(content) {
|
|
|
|
| 1886 |
if (diff < 86400000) { const hours = Math.floor(diff / 3600000); return `${hours} hour${hours > 1 ? 's' : ''} ago`; }
|
| 1887 |
return date.toLocaleDateString();
|
| 1888 |
}
|
| 1889 |
+
|
| 1890 |
+
async extractEMR(messageId) {
|
| 1891 |
+
try {
|
| 1892 |
+
// Check if patient is selected
|
| 1893 |
+
if (!this.currentPatientId) {
|
| 1894 |
+
alert('Please select a patient before extracting EMR data.');
|
| 1895 |
+
return;
|
| 1896 |
+
}
|
| 1897 |
+
|
| 1898 |
+
// Check if doctor is logged in
|
| 1899 |
+
if (!this.currentUser) {
|
| 1900 |
+
alert('Please log in as a doctor before extracting EMR data.');
|
| 1901 |
+
return;
|
| 1902 |
+
}
|
| 1903 |
+
|
| 1904 |
+
// Find the message
|
| 1905 |
+
const message = this.currentSession?.messages?.find(m => m.id === messageId);
|
| 1906 |
+
if (!message) {
|
| 1907 |
+
console.error('Message not found:', messageId);
|
| 1908 |
+
return;
|
| 1909 |
+
}
|
| 1910 |
+
|
| 1911 |
+
// Show loading state
|
| 1912 |
+
const button = document.querySelector(`[onclick="app.extractEMR('${messageId}')"]`);
|
| 1913 |
+
if (button) {
|
| 1914 |
+
button.disabled = true;
|
| 1915 |
+
button.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
|
| 1916 |
+
}
|
| 1917 |
+
|
| 1918 |
+
// Call EMR extraction API
|
| 1919 |
+
const response = await fetch('/emr/extract', {
|
| 1920 |
+
method: 'POST',
|
| 1921 |
+
headers: {
|
| 1922 |
+
'Content-Type': 'application/json',
|
| 1923 |
+
},
|
| 1924 |
+
body: JSON.stringify({
|
| 1925 |
+
patient_id: this.currentPatientId,
|
| 1926 |
+
doctor_id: this.currentUser.id || 'default-doctor',
|
| 1927 |
+
message_id: messageId,
|
| 1928 |
+
session_id: this.currentSession?.id || 'default-session',
|
| 1929 |
+
message: message.content
|
| 1930 |
+
})
|
| 1931 |
+
});
|
| 1932 |
+
|
| 1933 |
+
if (response.ok) {
|
| 1934 |
+
const result = await response.json();
|
| 1935 |
+
console.log('EMR extraction successful:', result);
|
| 1936 |
+
|
| 1937 |
+
// Show success message
|
| 1938 |
+
if (button) {
|
| 1939 |
+
button.innerHTML = '<i class="fas fa-check"></i>';
|
| 1940 |
+
button.style.color = 'var(--success-color)';
|
| 1941 |
+
setTimeout(() => {
|
| 1942 |
+
button.innerHTML = '<i class="fas fa-file-medical"></i>';
|
| 1943 |
+
button.style.color = '';
|
| 1944 |
+
button.disabled = false;
|
| 1945 |
+
}, 2000);
|
| 1946 |
+
}
|
| 1947 |
+
|
| 1948 |
+
// Show notification
|
| 1949 |
+
this.showNotification('EMR data extracted successfully!', 'success');
|
| 1950 |
+
} else {
|
| 1951 |
+
const errorData = await response.json().catch(() => ({ detail: 'Unknown error' }));
|
| 1952 |
+
const errorMessage = errorData.detail || `HTTP ${response.status}: ${response.statusText}`;
|
| 1953 |
+
throw new Error(errorMessage);
|
| 1954 |
+
}
|
| 1955 |
+
|
| 1956 |
+
} catch (error) {
|
| 1957 |
+
console.error('Error extracting EMR:', error);
|
| 1958 |
+
|
| 1959 |
+
// Reset button state
|
| 1960 |
+
const button = document.querySelector(`[onclick="app.extractEMR('${messageId}')"]`);
|
| 1961 |
+
if (button) {
|
| 1962 |
+
button.innerHTML = '<i class="fas fa-file-medical"></i>';
|
| 1963 |
+
button.disabled = false;
|
| 1964 |
+
}
|
| 1965 |
+
|
| 1966 |
+
// Show error message
|
| 1967 |
+
this.showNotification('Failed to extract EMR data. Please try again.', 'error');
|
| 1968 |
+
}
|
| 1969 |
+
}
|
| 1970 |
+
|
| 1971 |
+
async checkEMRStatus(messageId) {
|
| 1972 |
+
try {
|
| 1973 |
+
const response = await fetch(`/emr/check/${messageId}`);
|
| 1974 |
+
if (response.ok) {
|
| 1975 |
+
const result = await response.json();
|
| 1976 |
+
const button = document.querySelector(`[data-message-id="${messageId}"]`);
|
| 1977 |
+
if (button && result.emr_exists) {
|
| 1978 |
+
button.innerHTML = '<i class="fas fa-check"></i>';
|
| 1979 |
+
button.style.color = 'var(--success-color)';
|
| 1980 |
+
button.title = 'EMR data already extracted';
|
| 1981 |
+
button.disabled = true;
|
| 1982 |
+
}
|
| 1983 |
+
}
|
| 1984 |
+
} catch (error) {
|
| 1985 |
+
console.warn('Could not check EMR status:', error);
|
| 1986 |
+
}
|
| 1987 |
+
}
|
| 1988 |
+
|
| 1989 |
+
showNotification(message, type = 'info') {
|
| 1990 |
+
// Create notification element
|
| 1991 |
+
const notification = document.createElement('div');
|
| 1992 |
+
notification.className = `notification notification-${type}`;
|
| 1993 |
+
notification.innerHTML = `
|
| 1994 |
+
<div class="notification-content">
|
| 1995 |
+
<i class="fas fa-${type === 'success' ? 'check-circle' : type === 'error' ? 'exclamation-circle' : 'info-circle'}"></i>
|
| 1996 |
+
<span>${message}</span>
|
| 1997 |
+
</div>
|
| 1998 |
+
`;
|
| 1999 |
+
|
| 2000 |
+
// Add to page
|
| 2001 |
+
document.body.appendChild(notification);
|
| 2002 |
+
|
| 2003 |
+
// Show notification
|
| 2004 |
+
setTimeout(() => notification.classList.add('show'), 100);
|
| 2005 |
+
|
| 2006 |
+
// Remove after 3 seconds
|
| 2007 |
+
setTimeout(() => {
|
| 2008 |
+
notification.classList.remove('show');
|
| 2009 |
+
setTimeout(() => notification.remove(), 300);
|
| 2010 |
+
}, 3000);
|
| 2011 |
+
}
|
| 2012 |
}
|
| 2013 |
// ----------------------------------------------------------
|
| 2014 |
// Additional UI setup END
|
static/js/emr.js
CHANGED
|
@@ -1,291 +1,564 @@
|
|
| 1 |
// EMR Page JavaScript
|
| 2 |
-
|
|
|
|
|
|
|
| 3 |
constructor() {
|
| 4 |
-
this.
|
| 5 |
-
this.
|
| 6 |
-
this.
|
| 7 |
-
this.
|
|
|
|
|
|
|
| 8 |
|
| 9 |
this.init();
|
| 10 |
}
|
| 11 |
|
| 12 |
async init() {
|
| 13 |
-
// Get patient ID from URL or localStorage
|
| 14 |
-
this.patientId = this.getPatientIdFromURL() || localStorage.getItem('medicalChatbotPatientId');
|
| 15 |
-
|
| 16 |
-
if (!this.patientId) {
|
| 17 |
-
this.showError('No patient selected. Please go back to the main page and select a patient.');
|
| 18 |
-
return;
|
| 19 |
-
}
|
| 20 |
-
|
| 21 |
this.setupEventListeners();
|
| 22 |
-
await this.
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
|
|
|
| 28 |
}
|
| 29 |
|
| 30 |
setupEventListeners() {
|
| 31 |
-
//
|
| 32 |
-
document.getElementById('
|
| 33 |
-
this.
|
| 34 |
});
|
| 35 |
|
| 36 |
-
//
|
| 37 |
-
document.getElementById('
|
| 38 |
-
this.
|
| 39 |
});
|
| 40 |
|
| 41 |
-
//
|
| 42 |
-
document.getElementById('
|
| 43 |
-
this.
|
| 44 |
});
|
| 45 |
|
| 46 |
-
//
|
| 47 |
-
document.getElementById('
|
| 48 |
-
this.
|
| 49 |
});
|
| 50 |
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
if (e.key === 'Enter') {
|
| 54 |
-
this.addMedication();
|
| 55 |
-
}
|
| 56 |
});
|
|
|
|
|
|
|
|
|
|
| 57 |
}
|
| 58 |
|
| 59 |
-
|
| 60 |
-
|
|
|
|
|
|
|
|
|
|
| 61 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
try {
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
|
|
|
|
|
|
|
|
|
| 67 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
|
| 69 |
-
|
| 70 |
-
|
| 71 |
|
| 72 |
-
|
| 73 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
} catch (error) {
|
| 76 |
-
console.error('Error loading patient
|
| 77 |
-
this.showError('Failed to load patient data. Please try again.');
|
| 78 |
-
} finally {
|
| 79 |
-
this.showLoading(false);
|
| 80 |
}
|
| 81 |
}
|
| 82 |
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
}
|
| 104 |
|
| 105 |
-
|
| 106 |
-
const
|
| 107 |
-
container.innerHTML = '';
|
| 108 |
|
| 109 |
-
if (this.
|
| 110 |
-
|
| 111 |
return;
|
| 112 |
}
|
| 113 |
|
| 114 |
-
this.
|
| 115 |
-
const
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
</button>
|
|
|
|
|
|
|
|
|
|
| 122 |
`;
|
| 123 |
-
|
| 124 |
-
|
| 125 |
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
});
|
| 133 |
}
|
| 134 |
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
const medication = input.value.trim();
|
| 138 |
|
| 139 |
-
|
|
|
|
|
|
|
|
|
|
| 140 |
|
| 141 |
-
|
| 142 |
-
this.renderMedications();
|
| 143 |
-
input.value = '';
|
| 144 |
}
|
| 145 |
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
}
|
| 150 |
|
| 151 |
-
async
|
| 152 |
try {
|
| 153 |
-
const
|
| 154 |
-
if (
|
| 155 |
-
const
|
| 156 |
-
this.
|
| 157 |
-
|
|
|
|
| 158 |
}
|
| 159 |
} catch (error) {
|
| 160 |
-
console.error('Error loading
|
|
|
|
| 161 |
}
|
| 162 |
}
|
| 163 |
|
| 164 |
-
|
| 165 |
-
const
|
|
|
|
|
|
|
|
|
|
| 166 |
|
| 167 |
-
|
| 168 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 169 |
return;
|
| 170 |
}
|
| 171 |
|
| 172 |
-
|
|
|
|
|
|
|
|
|
|
| 173 |
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
|
|
|
| 184 |
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 188 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 189 |
|
| 190 |
-
|
| 191 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
}
|
| 193 |
|
| 194 |
-
async
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 195 |
this.showLoading(true);
|
| 196 |
|
| 197 |
try {
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
medications: this.medications,
|
| 207 |
-
past_assessment_summary: document.getElementById('pastAssessmentInput').value.trim() || null
|
| 208 |
-
};
|
| 209 |
-
|
| 210 |
-
const resp = await fetch(`/patient/${this.patientId}`, {
|
| 211 |
-
method: 'PATCH',
|
| 212 |
-
headers: {
|
| 213 |
-
'Content-Type': 'application/json'
|
| 214 |
-
},
|
| 215 |
-
body: JSON.stringify(updateData)
|
| 216 |
-
});
|
| 217 |
|
| 218 |
-
if (
|
| 219 |
-
this.
|
| 220 |
-
|
| 221 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 222 |
} else {
|
| 223 |
-
|
|
|
|
| 224 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 225 |
} catch (error) {
|
| 226 |
-
console.error('Error
|
| 227 |
-
|
| 228 |
} finally {
|
| 229 |
this.showLoading(false);
|
| 230 |
}
|
| 231 |
}
|
| 232 |
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 237 |
}
|
| 238 |
-
|
| 239 |
-
const exportData = {
|
| 240 |
-
_id: this.patientData._id,
|
| 241 |
-
name: this.patientData.name,
|
| 242 |
-
age: this.patientData.age,
|
| 243 |
-
sex: this.patientData.sex,
|
| 244 |
-
ethnicity: this.patientData.ethnicity,
|
| 245 |
-
phone: this.patientData.phone,
|
| 246 |
-
email: this.patientData.email,
|
| 247 |
-
address: this.patientData.address,
|
| 248 |
-
medications: this.medications,
|
| 249 |
-
past_assessment_summary: this.patientData.past_assessment_summary,
|
| 250 |
-
created_at: this.patientData.created_at,
|
| 251 |
-
updated_at: this.patientData.updated_at,
|
| 252 |
-
sessions: this.sessions
|
| 253 |
-
};
|
| 254 |
-
|
| 255 |
-
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
|
| 256 |
-
const url = URL.createObjectURL(blob);
|
| 257 |
-
const a = document.createElement('a');
|
| 258 |
-
a.href = url;
|
| 259 |
-
a.download = `patient-${this.patientData._id}-emr.json`;
|
| 260 |
-
document.body.appendChild(a);
|
| 261 |
-
a.click();
|
| 262 |
-
document.body.removeChild(a);
|
| 263 |
-
URL.revokeObjectURL(url);
|
| 264 |
}
|
| 265 |
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
const
|
| 269 |
-
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString();
|
| 270 |
-
}
|
| 271 |
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
if (overlay) {
|
| 275 |
-
overlay.style.display = show ? 'flex' : 'none';
|
| 276 |
-
}
|
| 277 |
}
|
| 278 |
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 282 |
|
| 283 |
-
|
| 284 |
-
|
| 285 |
}
|
| 286 |
}
|
| 287 |
|
| 288 |
-
// Initialize EMR when DOM is loaded
|
| 289 |
document.addEventListener('DOMContentLoaded', () => {
|
| 290 |
-
new
|
| 291 |
});
|
|
|
|
| 1 |
// EMR Page JavaScript
|
| 2 |
+
// static/js/emr.js
|
| 3 |
+
|
| 4 |
+
class EMRPage {
|
| 5 |
constructor() {
|
| 6 |
+
this.currentPatientId = null;
|
| 7 |
+
this.currentPatient = null;
|
| 8 |
+
this.emrEntries = [];
|
| 9 |
+
this.filteredEntries = [];
|
| 10 |
+
this.currentPage = 1;
|
| 11 |
+
this.entriesPerPage = 20;
|
| 12 |
|
| 13 |
this.init();
|
| 14 |
}
|
| 15 |
|
| 16 |
async init() {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
this.setupEventListeners();
|
| 18 |
+
await this.loadPatientFromURL();
|
| 19 |
+
if (this.currentPatientId) {
|
| 20 |
+
await this.loadEMRData();
|
| 21 |
+
await this.loadPatientStats();
|
| 22 |
+
} else {
|
| 23 |
+
this.showEmptyState();
|
| 24 |
+
}
|
| 25 |
}
|
| 26 |
|
| 27 |
setupEventListeners() {
|
| 28 |
+
// Refresh button
|
| 29 |
+
document.getElementById('refreshBtn').addEventListener('click', () => {
|
| 30 |
+
this.loadEMRData();
|
| 31 |
});
|
| 32 |
|
| 33 |
+
// Search button
|
| 34 |
+
document.getElementById('searchBtn').addEventListener('click', () => {
|
| 35 |
+
this.openSearchModal();
|
| 36 |
});
|
| 37 |
|
| 38 |
+
// Search input
|
| 39 |
+
document.getElementById('searchInput').addEventListener('input', (e) => {
|
| 40 |
+
this.filterEntries(e.target.value);
|
| 41 |
});
|
| 42 |
|
| 43 |
+
// Filter selects
|
| 44 |
+
document.getElementById('dateFilter').addEventListener('change', () => {
|
| 45 |
+
this.applyFilters();
|
| 46 |
});
|
| 47 |
|
| 48 |
+
document.getElementById('typeFilter').addEventListener('change', () => {
|
| 49 |
+
this.applyFilters();
|
|
|
|
|
|
|
|
|
|
| 50 |
});
|
| 51 |
+
|
| 52 |
+
// Modal handlers
|
| 53 |
+
this.setupModalHandlers();
|
| 54 |
}
|
| 55 |
|
| 56 |
+
setupModalHandlers() {
|
| 57 |
+
// EMR Detail Modal
|
| 58 |
+
const emrDetailModal = document.getElementById('emrDetailModal');
|
| 59 |
+
const emrDetailModalClose = document.getElementById('emrDetailModalClose');
|
| 60 |
+
const emrDetailModalCancel = document.getElementById('emrDetailModalCancel');
|
| 61 |
|
| 62 |
+
if (emrDetailModalClose) {
|
| 63 |
+
emrDetailModalClose.addEventListener('click', () => {
|
| 64 |
+
emrDetailModal.classList.remove('show');
|
| 65 |
+
});
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
if (emrDetailModalCancel) {
|
| 69 |
+
emrDetailModalCancel.addEventListener('click', () => {
|
| 70 |
+
emrDetailModal.classList.remove('show');
|
| 71 |
+
});
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
// Search Modal
|
| 75 |
+
const searchModal = document.getElementById('searchModal');
|
| 76 |
+
const searchModalClose = document.getElementById('searchModalClose');
|
| 77 |
+
const searchModalCancel = document.getElementById('searchModalCancel');
|
| 78 |
+
const performSearchBtn = document.getElementById('performSearchBtn');
|
| 79 |
+
|
| 80 |
+
if (searchModalClose) {
|
| 81 |
+
searchModalClose.addEventListener('click', () => {
|
| 82 |
+
searchModal.classList.remove('show');
|
| 83 |
+
});
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
if (searchModalCancel) {
|
| 87 |
+
searchModalCancel.addEventListener('click', () => {
|
| 88 |
+
searchModal.classList.remove('show');
|
| 89 |
+
});
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
if (performSearchBtn) {
|
| 93 |
+
performSearchBtn.addEventListener('click', () => {
|
| 94 |
+
this.performAdvancedSearch();
|
| 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 (patientId) {
|
| 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 |
+
}
|
| 116 |
+
|
| 117 |
+
async loadPatientInfo() {
|
| 118 |
try {
|
| 119 |
+
const response = await fetch(`/patient/${this.currentPatientId}`);
|
| 120 |
+
if (response.ok) {
|
| 121 |
+
this.currentPatient = await response.json();
|
| 122 |
+
this.updatePatientInfoBar();
|
| 123 |
+
} else {
|
| 124 |
+
console.error('Failed to load patient info');
|
| 125 |
+
this.showEmptyState();
|
| 126 |
}
|
| 127 |
+
} catch (error) {
|
| 128 |
+
console.error('Error loading patient info:', error);
|
| 129 |
+
this.showEmptyState();
|
| 130 |
+
}
|
| 131 |
+
}
|
| 132 |
|
| 133 |
+
updatePatientInfoBar() {
|
| 134 |
+
if (!this.currentPatient) return;
|
| 135 |
|
| 136 |
+
const patientInfoBar = document.getElementById('patientInfoBar');
|
| 137 |
+
const patientName = document.getElementById('patientName');
|
| 138 |
+
const patientDetails = document.getElementById('patientDetails');
|
| 139 |
+
|
| 140 |
+
patientName.textContent = this.currentPatient.name;
|
| 141 |
+
patientDetails.textContent = `Age: ${this.currentPatient.age} | Sex: ${this.currentPatient.sex} | ID: ${this.currentPatient._id}`;
|
| 142 |
+
|
| 143 |
+
patientInfoBar.style.display = 'block';
|
| 144 |
+
}
|
| 145 |
|
| 146 |
+
async loadPatientStats() {
|
| 147 |
+
try {
|
| 148 |
+
const response = await fetch(`/emr/statistics/${this.currentPatientId}`);
|
| 149 |
+
if (response.ok) {
|
| 150 |
+
const stats = await response.json();
|
| 151 |
+
this.updatePatientStats(stats);
|
| 152 |
+
}
|
| 153 |
} catch (error) {
|
| 154 |
+
console.error('Error loading patient stats:', error);
|
|
|
|
|
|
|
|
|
|
| 155 |
}
|
| 156 |
}
|
| 157 |
|
| 158 |
+
updatePatientStats(stats) {
|
| 159 |
+
const patientStats = document.getElementById('patientStats');
|
| 160 |
+
|
| 161 |
+
patientStats.innerHTML = `
|
| 162 |
+
<div class="stat-item">
|
| 163 |
+
<div class="stat-value">${stats.total_entries || 0}</div>
|
| 164 |
+
<div class="stat-label">Total Entries</div>
|
| 165 |
+
</div>
|
| 166 |
+
<div class="stat-item">
|
| 167 |
+
<div class="stat-value">${Math.round((stats.avg_confidence || 0) * 100)}%</div>
|
| 168 |
+
<div class="stat-label">Avg Confidence</div>
|
| 169 |
+
</div>
|
| 170 |
+
<div class="stat-item">
|
| 171 |
+
<div class="stat-value">${stats.diagnosis_count || 0}</div>
|
| 172 |
+
<div class="stat-label">Diagnoses</div>
|
| 173 |
+
</div>
|
| 174 |
+
<div class="stat-item">
|
| 175 |
+
<div class="stat-value">${stats.medication_count || 0}</div>
|
| 176 |
+
<div class="stat-label">Medications</div>
|
| 177 |
+
</div>
|
| 178 |
+
`;
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
async loadEMRData() {
|
| 182 |
+
if (!this.currentPatientId) return;
|
| 183 |
+
|
| 184 |
+
this.showLoading(true);
|
| 185 |
+
|
| 186 |
+
try {
|
| 187 |
+
const response = await fetch(`/emr/patient/${this.currentPatientId}?limit=100`);
|
| 188 |
+
if (response.ok) {
|
| 189 |
+
this.emrEntries = await response.json();
|
| 190 |
+
this.filteredEntries = [...this.emrEntries];
|
| 191 |
+
this.renderEMRTable();
|
| 192 |
+
} else {
|
| 193 |
+
console.error('Failed to load EMR data');
|
| 194 |
+
this.showEmptyState();
|
| 195 |
+
}
|
| 196 |
+
} catch (error) {
|
| 197 |
+
console.error('Error loading EMR data:', error);
|
| 198 |
+
this.showErrorState('Failed to load EMR data. Please try again.');
|
| 199 |
+
} finally {
|
| 200 |
+
this.showLoading(false);
|
| 201 |
+
}
|
| 202 |
}
|
| 203 |
|
| 204 |
+
renderEMRTable() {
|
| 205 |
+
const tableBody = document.getElementById('emrTableBody');
|
|
|
|
| 206 |
|
| 207 |
+
if (this.filteredEntries.length === 0) {
|
| 208 |
+
this.showEmptyState();
|
| 209 |
return;
|
| 210 |
}
|
| 211 |
|
| 212 |
+
tableBody.innerHTML = this.filteredEntries.map(entry => {
|
| 213 |
+
const date = new Date(entry.created_at).toLocaleString();
|
| 214 |
+
const type = this.getEMRType(entry.extracted_data);
|
| 215 |
+
const diagnosis = entry.extracted_data.diagnosis?.slice(0, 2).join(', ') || '-';
|
| 216 |
+
const medications = entry.extracted_data.medications?.slice(0, 2).map(m => m.name).join(', ') || '-';
|
| 217 |
+
const vitals = this.formatVitalSigns(entry.extracted_data.vital_signs);
|
| 218 |
+
const confidence = this.formatConfidence(entry.confidence_score);
|
| 219 |
+
|
| 220 |
+
return `
|
| 221 |
+
<tr>
|
| 222 |
+
<td>${date}</td>
|
| 223 |
+
<td><span class="emr-type emr-type-${type}">${type}</span></td>
|
| 224 |
+
<td>${diagnosis}</td>
|
| 225 |
+
<td>${medications}</td>
|
| 226 |
+
<td>${vitals}</td>
|
| 227 |
+
<td>${confidence}</td>
|
| 228 |
+
<td>
|
| 229 |
+
<div class="action-buttons">
|
| 230 |
+
<button class="action-btn" onclick="emrPage.viewEMRDetail('${entry.emr_id}')" title="View Details">
|
| 231 |
+
<i class="fas fa-eye"></i>
|
| 232 |
+
</button>
|
| 233 |
+
<button class="action-btn danger" onclick="emrPage.deleteEMREntry('${entry.emr_id}')" title="Delete">
|
| 234 |
+
<i class="fas fa-trash"></i>
|
| 235 |
</button>
|
| 236 |
+
</div>
|
| 237 |
+
</td>
|
| 238 |
+
</tr>
|
| 239 |
`;
|
| 240 |
+
}).join('');
|
| 241 |
+
}
|
| 242 |
|
| 243 |
+
getEMRType(extractedData) {
|
| 244 |
+
if (extractedData.diagnosis?.length > 0) return 'diagnosis';
|
| 245 |
+
if (extractedData.medications?.length > 0) return 'medication';
|
| 246 |
+
if (extractedData.vital_signs && Object.values(extractedData.vital_signs).some(v => v)) return 'vitals';
|
| 247 |
+
if (extractedData.lab_results?.length > 0) return 'lab';
|
| 248 |
+
return 'general';
|
|
|
|
| 249 |
}
|
| 250 |
|
| 251 |
+
formatVitalSigns(vitalSigns) {
|
| 252 |
+
if (!vitalSigns) return '-';
|
|
|
|
| 253 |
|
| 254 |
+
const vitals = [];
|
| 255 |
+
if (vitalSigns.blood_pressure) vitals.push(`BP: ${vitalSigns.blood_pressure}`);
|
| 256 |
+
if (vitalSigns.heart_rate) vitals.push(`HR: ${vitalSigns.heart_rate}`);
|
| 257 |
+
if (vitalSigns.temperature) vitals.push(`Temp: ${vitalSigns.temperature}`);
|
| 258 |
|
| 259 |
+
return vitals.length > 0 ? vitals.join(', ') : '-';
|
|
|
|
|
|
|
| 260 |
}
|
| 261 |
|
| 262 |
+
formatConfidence(score) {
|
| 263 |
+
const percentage = Math.round(score * 100);
|
| 264 |
+
const level = score >= 0.8 ? 'high' : score >= 0.6 ? 'medium' : 'low';
|
| 265 |
+
|
| 266 |
+
return `
|
| 267 |
+
<div class="confidence-score">
|
| 268 |
+
<div class="confidence-bar">
|
| 269 |
+
<div class="confidence-fill ${level}" style="width: ${percentage}%"></div>
|
| 270 |
+
</div>
|
| 271 |
+
<span class="confidence-text">${percentage}%</span>
|
| 272 |
+
</div>
|
| 273 |
+
`;
|
| 274 |
}
|
| 275 |
|
| 276 |
+
async viewEMRDetail(emrId) {
|
| 277 |
try {
|
| 278 |
+
const response = await fetch(`/emr/${emrId}`);
|
| 279 |
+
if (response.ok) {
|
| 280 |
+
const entry = await response.json();
|
| 281 |
+
this.showEMRDetailModal(entry);
|
| 282 |
+
} else {
|
| 283 |
+
alert('Failed to load EMR details');
|
| 284 |
}
|
| 285 |
} catch (error) {
|
| 286 |
+
console.error('Error loading EMR detail:', error);
|
| 287 |
+
alert('Error loading EMR details');
|
| 288 |
}
|
| 289 |
}
|
| 290 |
|
| 291 |
+
showEMRDetailModal(entry) {
|
| 292 |
+
const modal = document.getElementById('emrDetailModal');
|
| 293 |
+
const content = document.getElementById('emrDetailContent');
|
| 294 |
+
|
| 295 |
+
const date = new Date(entry.created_at).toLocaleString();
|
| 296 |
|
| 297 |
+
content.innerHTML = `
|
| 298 |
+
<div class="emr-detail-section">
|
| 299 |
+
<h4>Basic Information</h4>
|
| 300 |
+
<p><strong>Date:</strong> ${date}</p>
|
| 301 |
+
<p><strong>Confidence:</strong> ${Math.round(entry.confidence_score * 100)}%</p>
|
| 302 |
+
<p><strong>Original Message:</strong></p>
|
| 303 |
+
<div style="background-color: var(--bg-secondary); padding: var(--spacing-md); border-radius: 8px; margin-top: var(--spacing-sm);">
|
| 304 |
+
${entry.original_message}
|
| 305 |
+
</div>
|
| 306 |
+
</div>
|
| 307 |
+
|
| 308 |
+
${entry.extracted_data.diagnosis?.length > 0 ? `
|
| 309 |
+
<div class="emr-detail-section">
|
| 310 |
+
<h4>Diagnoses</h4>
|
| 311 |
+
<ul class="emr-detail-list">
|
| 312 |
+
${entry.extracted_data.diagnosis.map(d => `<li>${d}</li>`).join('')}
|
| 313 |
+
</ul>
|
| 314 |
+
</div>
|
| 315 |
+
` : ''}
|
| 316 |
+
|
| 317 |
+
${entry.extracted_data.symptoms?.length > 0 ? `
|
| 318 |
+
<div class="emr-detail-section">
|
| 319 |
+
<h4>Symptoms</h4>
|
| 320 |
+
<ul class="emr-detail-list">
|
| 321 |
+
${entry.extracted_data.symptoms.map(s => `<li>${s}</li>`).join('')}
|
| 322 |
+
</ul>
|
| 323 |
+
</div>
|
| 324 |
+
` : ''}
|
| 325 |
+
|
| 326 |
+
${entry.extracted_data.medications?.length > 0 ? `
|
| 327 |
+
<div class="emr-detail-section">
|
| 328 |
+
<h4>Medications</h4>
|
| 329 |
+
${entry.extracted_data.medications.map(med => `
|
| 330 |
+
<div class="medication-item">
|
| 331 |
+
<div class="medication-name">${med.name}</div>
|
| 332 |
+
<div class="medication-details">
|
| 333 |
+
${med.dosage ? `Dosage: ${med.dosage}` : ''}
|
| 334 |
+
${med.frequency ? ` | Frequency: ${med.frequency}` : ''}
|
| 335 |
+
${med.duration ? ` | Duration: ${med.duration}` : ''}
|
| 336 |
+
</div>
|
| 337 |
+
</div>
|
| 338 |
+
`).join('')}
|
| 339 |
+
</div>
|
| 340 |
+
` : ''}
|
| 341 |
+
|
| 342 |
+
${entry.extracted_data.vital_signs && Object.values(entry.extracted_data.vital_signs).some(v => v) ? `
|
| 343 |
+
<div class="emr-detail-section">
|
| 344 |
+
<h4>Vital Signs</h4>
|
| 345 |
+
<div class="vital-signs-grid">
|
| 346 |
+
${Object.entries(entry.extracted_data.vital_signs).map(([key, value]) =>
|
| 347 |
+
value ? `
|
| 348 |
+
<div class="vital-sign-item">
|
| 349 |
+
<div class="vital-sign-label">${key.replace('_', ' ').toUpperCase()}</div>
|
| 350 |
+
<div class="vital-sign-value">${value}</div>
|
| 351 |
+
</div>
|
| 352 |
+
` : ''
|
| 353 |
+
).join('')}
|
| 354 |
+
</div>
|
| 355 |
+
</div>
|
| 356 |
+
` : ''}
|
| 357 |
+
|
| 358 |
+
${entry.extracted_data.lab_results?.length > 0 ? `
|
| 359 |
+
<div class="emr-detail-section">
|
| 360 |
+
<h4>Lab Results</h4>
|
| 361 |
+
<ul class="emr-detail-list">
|
| 362 |
+
${entry.extracted_data.lab_results.map(lab => `
|
| 363 |
+
<li>
|
| 364 |
+
<strong>${lab.test_name}:</strong> ${lab.value} ${lab.unit || ''}
|
| 365 |
+
${lab.reference_range ? ` (Normal: ${lab.reference_range})` : ''}
|
| 366 |
+
</li>
|
| 367 |
+
`).join('')}
|
| 368 |
+
</ul>
|
| 369 |
+
</div>
|
| 370 |
+
` : ''}
|
| 371 |
+
|
| 372 |
+
${entry.extracted_data.procedures?.length > 0 ? `
|
| 373 |
+
<div class="emr-detail-section">
|
| 374 |
+
<h4>Procedures</h4>
|
| 375 |
+
<ul class="emr-detail-list">
|
| 376 |
+
${entry.extracted_data.procedures.map(p => `<li>${p}</li>`).join('')}
|
| 377 |
+
</ul>
|
| 378 |
+
</div>
|
| 379 |
+
` : ''}
|
| 380 |
+
|
| 381 |
+
${entry.extracted_data.notes ? `
|
| 382 |
+
<div class="emr-detail-section">
|
| 383 |
+
<h4>Notes</h4>
|
| 384 |
+
<p>${entry.extracted_data.notes}</p>
|
| 385 |
+
</div>
|
| 386 |
+
` : ''}
|
| 387 |
+
`;
|
| 388 |
+
|
| 389 |
+
modal.classList.add('show');
|
| 390 |
+
}
|
| 391 |
+
|
| 392 |
+
async deleteEMREntry(emrId) {
|
| 393 |
+
if (!confirm('Are you sure you want to delete this EMR entry?')) {
|
| 394 |
return;
|
| 395 |
}
|
| 396 |
|
| 397 |
+
try {
|
| 398 |
+
const response = await fetch(`/emr/${emrId}`, {
|
| 399 |
+
method: 'DELETE'
|
| 400 |
+
});
|
| 401 |
|
| 402 |
+
if (response.ok) {
|
| 403 |
+
this.loadEMRData(); // Refresh the data
|
| 404 |
+
this.loadPatientStats(); // Refresh stats
|
| 405 |
+
} else {
|
| 406 |
+
alert('Failed to delete EMR entry');
|
| 407 |
+
}
|
| 408 |
+
} catch (error) {
|
| 409 |
+
console.error('Error deleting EMR entry:', error);
|
| 410 |
+
alert('Error deleting EMR entry');
|
| 411 |
+
}
|
| 412 |
+
}
|
| 413 |
|
| 414 |
+
filterEntries(query) {
|
| 415 |
+
if (!query.trim()) {
|
| 416 |
+
this.filteredEntries = [...this.emrEntries];
|
| 417 |
+
} else {
|
| 418 |
+
this.filteredEntries = this.emrEntries.filter(entry => {
|
| 419 |
+
const searchText = query.toLowerCase();
|
| 420 |
+
return (
|
| 421 |
+
entry.original_message.toLowerCase().includes(searchText) ||
|
| 422 |
+
entry.extracted_data.diagnosis?.some(d => d.toLowerCase().includes(searchText)) ||
|
| 423 |
+
entry.extracted_data.symptoms?.some(s => s.toLowerCase().includes(searchText)) ||
|
| 424 |
+
entry.extracted_data.medications?.some(m => m.name.toLowerCase().includes(searchText)) ||
|
| 425 |
+
entry.extracted_data.notes?.toLowerCase().includes(searchText)
|
| 426 |
+
);
|
| 427 |
});
|
| 428 |
+
}
|
| 429 |
+
this.renderEMRTable();
|
| 430 |
+
}
|
| 431 |
+
|
| 432 |
+
applyFilters() {
|
| 433 |
+
const dateFilter = document.getElementById('dateFilter').value;
|
| 434 |
+
const typeFilter = document.getElementById('typeFilter').value;
|
| 435 |
+
|
| 436 |
+
this.filteredEntries = this.emrEntries.filter(entry => {
|
| 437 |
+
// Date filter
|
| 438 |
+
if (dateFilter !== 'all') {
|
| 439 |
+
const entryDate = new Date(entry.created_at);
|
| 440 |
+
const now = new Date();
|
| 441 |
+
|
| 442 |
+
switch (dateFilter) {
|
| 443 |
+
case 'today':
|
| 444 |
+
if (entryDate.toDateString() !== now.toDateString()) return false;
|
| 445 |
+
break;
|
| 446 |
+
case 'week':
|
| 447 |
+
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
| 448 |
+
if (entryDate < weekAgo) return false;
|
| 449 |
+
break;
|
| 450 |
+
case 'month':
|
| 451 |
+
const monthAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
| 452 |
+
if (entryDate < monthAgo) return false;
|
| 453 |
+
break;
|
| 454 |
+
}
|
| 455 |
+
}
|
| 456 |
+
|
| 457 |
+
// Type filter
|
| 458 |
+
if (typeFilter !== 'all') {
|
| 459 |
+
const entryType = this.getEMRType(entry.extracted_data);
|
| 460 |
+
if (entryType !== typeFilter) return false;
|
| 461 |
+
}
|
| 462 |
|
| 463 |
+
return true;
|
| 464 |
});
|
| 465 |
+
|
| 466 |
+
this.renderEMRTable();
|
| 467 |
+
}
|
| 468 |
+
|
| 469 |
+
openSearchModal() {
|
| 470 |
+
document.getElementById('searchModal').classList.add('show');
|
| 471 |
}
|
| 472 |
|
| 473 |
+
async performAdvancedSearch() {
|
| 474 |
+
const semanticQuery = document.getElementById('semanticSearchInput').value.trim();
|
| 475 |
+
const exactQuery = document.getElementById('exactSearchInput').value.trim();
|
| 476 |
+
|
| 477 |
+
if (!semanticQuery && !exactQuery) {
|
| 478 |
+
alert('Please enter a search query');
|
| 479 |
+
return;
|
| 480 |
+
}
|
| 481 |
+
|
| 482 |
this.showLoading(true);
|
| 483 |
|
| 484 |
try {
|
| 485 |
+
let searchResults = [];
|
| 486 |
+
|
| 487 |
+
if (semanticQuery) {
|
| 488 |
+
const response = await fetch(`/emr/search/${this.currentPatientId}?query=${encodeURIComponent(semanticQuery)}&limit=50`);
|
| 489 |
+
if (response.ok) {
|
| 490 |
+
searchResults = await response.json();
|
| 491 |
+
}
|
| 492 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 493 |
|
| 494 |
+
if (exactQuery) {
|
| 495 |
+
const exactResults = this.emrEntries.filter(entry => {
|
| 496 |
+
const searchText = exactQuery.toLowerCase();
|
| 497 |
+
return (
|
| 498 |
+
entry.original_message.toLowerCase().includes(searchText) ||
|
| 499 |
+
entry.extracted_data.diagnosis?.some(d => d.toLowerCase().includes(searchText)) ||
|
| 500 |
+
entry.extracted_data.symptoms?.some(s => s.toLowerCase().includes(searchText)) ||
|
| 501 |
+
entry.extracted_data.medications?.some(m => m.name.toLowerCase().includes(searchText)) ||
|
| 502 |
+
entry.extracted_data.notes?.toLowerCase().includes(searchText)
|
| 503 |
+
);
|
| 504 |
+
});
|
| 505 |
+
|
| 506 |
+
// Merge results if both searches were performed
|
| 507 |
+
if (semanticQuery) {
|
| 508 |
+
const exactIds = new Set(exactResults.map(r => r.emr_id));
|
| 509 |
+
searchResults = searchResults.concat(exactResults.filter(r => !exactIds.has(r.emr_id)));
|
| 510 |
} else {
|
| 511 |
+
searchResults = exactResults;
|
| 512 |
+
}
|
| 513 |
}
|
| 514 |
+
|
| 515 |
+
this.filteredEntries = searchResults;
|
| 516 |
+
this.renderEMRTable();
|
| 517 |
+
|
| 518 |
} catch (error) {
|
| 519 |
+
console.error('Error performing search:', error);
|
| 520 |
+
alert('Error performing search');
|
| 521 |
} finally {
|
| 522 |
this.showLoading(false);
|
| 523 |
}
|
| 524 |
}
|
| 525 |
|
| 526 |
+
showLoading(show) {
|
| 527 |
+
const loadingState = document.getElementById('loadingState');
|
| 528 |
+
const tableContainer = document.querySelector('.emr-table-container');
|
| 529 |
+
|
| 530 |
+
if (show) {
|
| 531 |
+
loadingState.style.display = 'block';
|
| 532 |
+
tableContainer.style.display = 'none';
|
| 533 |
+
} else {
|
| 534 |
+
loadingState.style.display = 'none';
|
| 535 |
+
tableContainer.style.display = 'block';
|
| 536 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 537 |
}
|
| 538 |
|
| 539 |
+
showEmptyState() {
|
| 540 |
+
const emptyState = document.getElementById('emptyState');
|
| 541 |
+
const tableContainer = document.querySelector('.emr-table-container');
|
|
|
|
|
|
|
| 542 |
|
| 543 |
+
emptyState.style.display = 'block';
|
| 544 |
+
tableContainer.style.display = 'none';
|
|
|
|
|
|
|
|
|
|
| 545 |
}
|
| 546 |
|
| 547 |
+
showErrorState(message) {
|
| 548 |
+
const emptyState = document.getElementById('emptyState');
|
| 549 |
+
const tableContainer = document.querySelector('.emr-table-container');
|
| 550 |
+
|
| 551 |
+
// Update the empty state to show error message
|
| 552 |
+
emptyState.querySelector('h3').textContent = 'Error Loading EMR Data';
|
| 553 |
+
emptyState.querySelector('p').textContent = message;
|
| 554 |
+
emptyState.querySelector('.btn').style.display = 'none';
|
| 555 |
|
| 556 |
+
emptyState.style.display = 'block';
|
| 557 |
+
tableContainer.style.display = 'none';
|
| 558 |
}
|
| 559 |
}
|
| 560 |
|
| 561 |
+
// Initialize the EMR page when DOM is loaded
|
| 562 |
document.addEventListener('DOMContentLoaded', () => {
|
| 563 |
+
window.emrPage = new EMRPage();
|
| 564 |
});
|