Spaces:
Sleeping
Sleeping
Merge branch 'hf_main' into hf_mongo
Browse files- README.md +137 -275
- TODO.md +20 -0
- UI_SETUP.md +147 -0
- src/api/routes/chat.py +22 -10
- src/api/routes/session.py +49 -4
- src/api/routes/user.py +134 -2
- src/core/memory/history.py +322 -0
- src/core/memory/memory.py +210 -0
- src/core/state.py +2 -0
- src/data/__init__.py +55 -0
- src/data/connection.py +57 -0
- src/data/medical/__init__.py +20 -0
- src/data/medical/operations.py +133 -0
- src/data/medical_kb.py +2 -1
- src/data/message/__init__.py +18 -0
- src/data/message/operations.py +124 -0
- src/data/mongodb.py.backup +638 -0
- src/data/patient/__init__.py +18 -0
- src/data/patient/operations.py +101 -0
- src/data/session/__init__.py +24 -0
- src/data/session/operations.py +126 -0
- src/data/user/__init__.py +24 -0
- src/data/user/operations.py +146 -0
- src/data/utils.py +48 -0
- src/models/chat.py +4 -0
- src/models/user.py +31 -2
- start.py +19 -3
- static/css/emr.css +340 -0
- static/css/patient.css +22 -0
- static/css/styles.css +146 -1
- static/emr.html +127 -0
- static/index.html +64 -12
- static/js/app.js +0 -0
- static/js/chat/messaging.js +162 -0
- static/js/chat/sessions.js +164 -0
- static/js/emr.js +288 -0
- static/js/patient.js +131 -0
- static/js/ui/doctor.js +124 -0
- static/js/ui/handlers.js +68 -0
- static/js/ui/patient.js +246 -0
- static/js/ui/settings.js +74 -0
- static/patient.html +59 -0
README.md
CHANGED
|
@@ -13,310 +13,172 @@ app_port: 7860
|
|
| 13 |
|
| 14 |
# Medical AI Assistant
|
| 15 |
|
| 16 |
-
|
| 17 |
|
| 18 |
-
## 🚀 Features
|
| 19 |
|
| 20 |
-
|
| 21 |
-
-
|
| 22 |
-
-
|
| 23 |
-
-
|
| 24 |
-
-
|
| 25 |
-
-
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
-
|
| 28 |
-
- **ChatGPT-like Design**: Familiar, intuitive interface optimized for medical professionals
|
| 29 |
-
- **Responsive Design**: Works seamlessly on desktop, tablet, and mobile devices
|
| 30 |
-
- **Dark/Light Theme**: Automatic theme switching with system preference detection
|
| 31 |
-
- **Real-time Chat**: Smooth, responsive chat experience with typing indicators
|
| 32 |
-
- **Session Management**: Easy navigation between different chat sessions
|
| 33 |
|
| 34 |
### Medical Features
|
| 35 |
-
- **Medical Knowledge Base**: Built-in medical information for common symptoms,
|
| 36 |
-
|
|
|
|
|
|
|
| 37 |
- **Role-Based Responses**: Tailored responses based on user's medical role and specialty
|
| 38 |
- **Medical Disclaimers**: Appropriate warnings and disclaimers for medical information
|
| 39 |
-
- **Export Functionality**: Export chat sessions for medical records or educational
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
### Memory System
|
| 61 |
-
- **User Profiles**: Persistent user data with preferences and roles
|
| 62 |
-
- **Chat Sessions**: Individual conversation threads with message history
|
| 63 |
-
- **Medical Context**: QA summaries stored in LRU cache for quick retrieval
|
| 64 |
-
- **Semantic Search**: Embedding-based similarity search for relevant medical information
|
| 65 |
-
|
| 66 |
-
### API Integration
|
| 67 |
-
- **Gemini API**: Google's advanced language model for medical responses
|
| 68 |
-
- **Key Rotation**: Automatic rotation on rate limits or errors
|
| 69 |
-
- **Fallback Support**: Graceful degradation when external APIs are unavailable
|
| 70 |
-
|
| 71 |
-
## 🛠️ Installation
|
| 72 |
|
| 73 |
### Prerequisites
|
| 74 |
-
- Python 3.
|
| 75 |
-
- pip
|
| 76 |
-
- Modern web browser
|
| 77 |
|
| 78 |
### Setup
|
| 79 |
-
1.
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
2.
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
```
|
| 102 |
-
|
| 103 |
-
5. **Access the UI**
|
| 104 |
-
Open your browser and navigate to `http://localhost:8000`
|
| 105 |
-
|
| 106 |
-
## 🔧 Configuration
|
| 107 |
-
|
| 108 |
-
### Environment Variables
|
| 109 |
-
- `GEMINI_API_1` through `GEMINI_API_5`: Gemini API keys for rotation
|
| 110 |
-
- `LOG_LEVEL`: Logging level (DEBUG, INFO, WARNING, ERROR)
|
| 111 |
-
- `PORT`: Server port (default: 8000)
|
| 112 |
-
|
| 113 |
-
### Memory Settings
|
| 114 |
-
- **LRU Capacity**: Default 50 QA summaries per user
|
| 115 |
-
- **Max Sessions**: Default 20 sessions per user
|
| 116 |
-
- **Session Timeout**: Configurable session expiration
|
| 117 |
|
| 118 |
-
|
| 119 |
-
-
|
| 120 |
-
-
|
| 121 |
-
-
|
|
|
|
| 122 |
|
| 123 |
## 📱 Usage
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
-
|
| 134 |
-
-
|
| 135 |
-
-
|
| 136 |
-
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
-
|
| 141 |
-
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
- **Responsive Design**: Works on all device sizes
|
| 145 |
-
|
| 146 |
-
## 🔒 Security & Privacy
|
| 147 |
-
|
| 148 |
-
### Data Protection
|
| 149 |
-
- **Local Storage**: User data stored locally in browser (no server persistence)
|
| 150 |
-
- **Session Isolation**: Users can only access their own data
|
| 151 |
-
- **No PII Storage**: Personal information not logged or stored
|
| 152 |
-
- **Medical Disclaimers**: Clear warnings about information limitations
|
| 153 |
-
|
| 154 |
-
### API Security
|
| 155 |
-
- **Key Rotation**: Automatic API key rotation for security
|
| 156 |
-
- **Rate Limiting**: Built-in protection against API abuse
|
| 157 |
-
- **Error Handling**: Graceful degradation on API failures
|
| 158 |
-
|
| 159 |
-
## 🧪 Development
|
| 160 |
-
|
| 161 |
-
### Local Development
|
| 162 |
```bash
|
| 163 |
-
# Install development dependencies
|
| 164 |
pip install -r requirements.txt
|
| 165 |
-
|
| 166 |
-
#
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
# Run tests
|
| 170 |
-
pytest
|
| 171 |
-
|
| 172 |
-
# Format code
|
| 173 |
-
black .
|
| 174 |
-
|
| 175 |
-
# Lint code
|
| 176 |
-
flake8
|
| 177 |
```
|
| 178 |
|
| 179 |
### Project Structure
|
| 180 |
```
|
| 181 |
-
|
| 182 |
-
├── scripts/
|
| 183 |
-
│ └── download_model.py # Model downloading script
|
| 184 |
├── src/
|
| 185 |
│ ├── api/
|
| 186 |
-
│ │ └── routes/
|
| 187 |
-
│ │ ├── chat.py
|
| 188 |
-
│ │ ├── session.py
|
| 189 |
-
│ │ ├──
|
| 190 |
-
│ │ ├── system.py
|
| 191 |
-
│ │ └──
|
| 192 |
│ ├── core/
|
| 193 |
-
│ │ ├── memory/
|
| 194 |
-
│ │ │ ├── memory.py
|
| 195 |
-
│ │ │ └── history.py
|
| 196 |
-
│ │ └── state.py #
|
| 197 |
-
│ ├──
|
| 198 |
-
│ │
|
| 199 |
-
│ │
|
| 200 |
-
│ ├──
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 201 |
│ │ ├── chat.py
|
| 202 |
│ │ └── user.py
|
| 203 |
-
│ ├── services/
|
| 204 |
-
│ │ ├── medical_response.py
|
| 205 |
-
│ │ └── summariser.py
|
| 206 |
-
│ ├── utils/
|
| 207 |
-
│ │ ├── __init__.py
|
| 208 |
│ │ ├── embeddings.py
|
| 209 |
│ │ ├── logger.py
|
| 210 |
-
│ │ ├── naming.py
|
| 211 |
│ │ └── rotator.py
|
| 212 |
-
│
|
| 213 |
-
├── static/
|
| 214 |
-
│ ├──
|
| 215 |
-
│ │ ├── app.js
|
| 216 |
-
│ │ └── health.js
|
| 217 |
│ ├── css/
|
| 218 |
-
│ │
|
| 219 |
-
│
|
| 220 |
-
│ ├──
|
| 221 |
-
│
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
├── .
|
| 225 |
-
├──
|
| 226 |
├── Dockerfile
|
| 227 |
-
├──
|
| 228 |
-
├──
|
| 229 |
-
|
| 230 |
-
├── requirements-dev.txt # Development dependencies
|
| 231 |
-
├── SETUP_GUIDE.md # Detailed setup instructions
|
| 232 |
-
└── start.py # Application launcher
|
| 233 |
-
```
|
| 234 |
-
|
| 235 |
-
### Adding New Features
|
| 236 |
-
1. **Backend**: Add new endpoints in `app.py`
|
| 237 |
-
2. **Memory**: Extend memory system in `memo/memory.py`
|
| 238 |
-
3. **Frontend**: Update UI components in `static/` files
|
| 239 |
-
4. **Testing**: Add tests for new functionality
|
| 240 |
-
|
| 241 |
-
## 🚀 Deployment
|
| 242 |
-
|
| 243 |
-
### Production Considerations
|
| 244 |
-
- **Environment Variables**: Secure API key management
|
| 245 |
-
- **HTTPS**: Enable SSL/TLS for production
|
| 246 |
-
- **Rate Limiting**: Implement request rate limiting
|
| 247 |
-
- **Monitoring**: Add health checks and logging
|
| 248 |
-
- **Database**: Consider persistent storage for production
|
| 249 |
-
|
| 250 |
-
### Docker Deployment
|
| 251 |
-
```dockerfile
|
| 252 |
-
FROM python:3.9-slim
|
| 253 |
-
WORKDIR /app
|
| 254 |
-
COPY requirements.txt .
|
| 255 |
-
RUN pip install -r requirements.txt
|
| 256 |
-
COPY . .
|
| 257 |
-
EXPOSE 8000
|
| 258 |
-
CMD ["python", "app.py"]
|
| 259 |
```
|
| 260 |
|
| 261 |
-
|
| 262 |
-
-
|
| 263 |
-
-
|
| 264 |
-
-
|
| 265 |
-
- **Heroku**: Simple deployment with Procfile
|
| 266 |
-
|
| 267 |
-
## 📊 Performance
|
| 268 |
-
|
| 269 |
-
### Optimization Features
|
| 270 |
-
- **Lazy Loading**: Embedding models loaded on demand
|
| 271 |
-
- **LRU Caching**: Efficient memory management
|
| 272 |
-
- **API Rotation**: Load balancing across multiple API keys
|
| 273 |
-
- **Fallback Modes**: Graceful degradation on failures
|
| 274 |
-
|
| 275 |
-
### Monitoring
|
| 276 |
-
- **Health Checks**: `/health` endpoint for system status
|
| 277 |
-
- **Resource Usage**: CPU and memory monitoring
|
| 278 |
-
- **API Metrics**: Response times and success rates
|
| 279 |
-
- **Error Tracking**: Comprehensive error logging
|
| 280 |
-
|
| 281 |
-
## 🤝 Contributing
|
| 282 |
-
|
| 283 |
-
### Development Guidelines
|
| 284 |
-
1. **Code Style**: Follow PEP 8 and use Black formatter
|
| 285 |
-
2. **Testing**: Add tests for new features
|
| 286 |
-
3. **Documentation**: Update README and docstrings
|
| 287 |
-
4. **Security**: Follow security best practices
|
| 288 |
-
5. **Performance**: Consider performance implications
|
| 289 |
-
|
| 290 |
-
### Pull Request Process
|
| 291 |
-
1. Fork the repository
|
| 292 |
-
2. Create a feature branch
|
| 293 |
-
3. Make your changes
|
| 294 |
-
4. Add tests and documentation
|
| 295 |
-
5. Submit a pull request
|
| 296 |
-
|
| 297 |
-
## 📄 License
|
| 298 |
-
|
| 299 |
-
This project is licensed under the MIT License - see the LICENSE file for details.
|
| 300 |
-
|
| 301 |
-
## ⚠️ Disclaimer
|
| 302 |
-
|
| 303 |
-
**Medical Information Disclaimer**: This application provides educational medical information only. It is not a substitute for professional medical advice, diagnosis, or treatment. Always consult with qualified healthcare professionals for medical decisions.
|
| 304 |
-
|
| 305 |
-
**AI Limitations**: While this system uses advanced AI technology, it has limitations and should not be relied upon for critical medical decisions.
|
| 306 |
-
|
| 307 |
-
## 🆘 Support
|
| 308 |
-
|
| 309 |
-
### Getting Help
|
| 310 |
-
- **Issues**: Report bugs via GitHub Issues
|
| 311 |
-
- **Documentation**: Check this README and code comments
|
| 312 |
-
- **Community**: Join discussions in GitHub Discussions
|
| 313 |
-
|
| 314 |
-
### Common Issues
|
| 315 |
-
- **API Keys**: Ensure Gemini API keys are properly set
|
| 316 |
-
- **Dependencies**: Verify all requirements are installed
|
| 317 |
-
- **Port Conflicts**: Check if port 8000 is available
|
| 318 |
-
- **Memory Issues**: Monitor system resources
|
| 319 |
-
|
| 320 |
-
---
|
| 321 |
-
|
| 322 |
-
**Built with ❤️ for the medical community**
|
|
|
|
| 13 |
|
| 14 |
# Medical AI Assistant
|
| 15 |
|
| 16 |
+
AI-powered medical chatbot with patient-centric memory, MongoDB persistence, and a fast, modern UI.
|
| 17 |
|
| 18 |
+
## 🚀 Key Features
|
| 19 |
|
| 20 |
+
- Patient-centric RAG memory
|
| 21 |
+
- Short-term: last 3 QA summaries in in-memory LRU (fast context)
|
| 22 |
+
- Long-term: last 20 QA summaries per patient persisted in MongoDB (continuity)
|
| 23 |
+
- Chat history persistence per session in MongoDB
|
| 24 |
+
- Patient/Doctor context saved on all messages and summaries
|
| 25 |
+
- Patient search typeahead (name or ID) with instant session hydration
|
| 26 |
+
- Doctor dropdown with built‑in "Create doctor user..." flow
|
| 27 |
+
- Modern UI: sidebar sessions, modals (doctor, settings, patient profile), dark/light mode, mobile-friendly
|
| 28 |
+
- Model integration: Gemini responses, NVIDIA summariser fallback via key rotators
|
| 29 |
|
| 30 |
+
## 🏗️ Architecture (high-level)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
|
| 32 |
### Medical Features
|
| 33 |
+
- **Medical Knowledge Base**: Built-in medical information for common symptoms,
|
| 34 |
+
conditions, and medications
|
| 35 |
+
- **Context Awareness**: Remembers previous conversations and provides relevant medical
|
| 36 |
+
context
|
| 37 |
- **Role-Based Responses**: Tailored responses based on user's medical role and specialty
|
| 38 |
- **Medical Disclaimers**: Appropriate warnings and disclaimers for medical information
|
| 39 |
+
- **Export Functionality**: Export chat sessions for medical records or educational
|
| 40 |
+
purposes
|
| 41 |
+
|
| 42 |
+
Backend (FastAPI):
|
| 43 |
+
- `src/core/memory/memory.py`: LRU short‑term memory + sessions
|
| 44 |
+
- `src/core/memory/history.py`: builds context; writes memory/messages to Mongo
|
| 45 |
+
- `src/data/`: Mongo helpers (modularized)
|
| 46 |
+
- `connection.py`: Mongo connection + collection names
|
| 47 |
+
- `session/operations.py`: chat sessions (ensure/list/delete/etc.)
|
| 48 |
+
- `message/operations.py`: chat messages (save/list)
|
| 49 |
+
- `patient/operations.py`: patients (get/create/update/search)
|
| 50 |
+
- `user/operations.py`: accounts/doctors (create/search/list)
|
| 51 |
+
- `medical/operations.py`: medical records + memory summaries
|
| 52 |
+
- `utils.py`: generic helpers (indexing, backups)
|
| 53 |
+
- `src/api/routes/`: `chat`, `session`, `user` (patients), `system`, `static`
|
| 54 |
+
|
| 55 |
+
Frontend (static):
|
| 56 |
+
- `static/index.html`, `static/css/styles.css`
|
| 57 |
+
- `static/js/app.js` (or modularized under `static/js/ui/*` and `static/js/chat/*` — see `UI_SETUP.md`)
|
| 58 |
+
|
| 59 |
+
## 🛠️ Quick Start
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
|
| 61 |
### Prerequisites
|
| 62 |
+
- Python 3.11+
|
| 63 |
+
- pip
|
|
|
|
| 64 |
|
| 65 |
### Setup
|
| 66 |
+
1. Clone and install
|
| 67 |
+
```bash
|
| 68 |
+
git clone https://huggingface.co/spaces/MedAI-COS30018/MedicalDiagnosisSystem
|
| 69 |
+
cd MedAI
|
| 70 |
+
pip install -r requirements.txt
|
| 71 |
+
```
|
| 72 |
+
2. Configure environment
|
| 73 |
+
```bash
|
| 74 |
+
# Create .env
|
| 75 |
+
echo "GEMINI_API_1=your_gemini_api_key_1" > .env
|
| 76 |
+
echo "NVIDIA_API_1=your_nvidia_api_key_1" >> .env
|
| 77 |
+
# MongoDB (required)
|
| 78 |
+
echo "MONGO_USER=your_mongodb_connection_string" >> .env
|
| 79 |
+
# Optional DB name (default: medicaldiagnosissystem)
|
| 80 |
+
# Optional DB name (default: medicaldiagnosissystem). Env var key: USER_DB
|
| 81 |
+
echo "USER_DB=medicaldiagnosissystem" >> .env
|
| 82 |
+
```
|
| 83 |
+
3. Run
|
| 84 |
+
```bash
|
| 85 |
+
python -m src.main
|
| 86 |
+
```
|
| 87 |
+
Helpful: [UI SETUP](https://huggingface.co/spaces/MedAI-COS30018/MedicalDiagnosisSystem/blob/main/UI_SETUP.md) | [SETUP GUIDE](https://huggingface.co/spaces/MedAI-COS30018/MedicalDiagnosisSystem/blob/main/SETUP_GUIDE.md)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
|
| 89 |
+
## 🔧 Config
|
| 90 |
+
- `GEMINI_API_1..5`, `NVIDIA_API_1..5`
|
| 91 |
+
- `MONGO_USER`, `MONGO_DB`
|
| 92 |
+
- `LOG_LEVEL`, `PORT`
|
| 93 |
+
- Memory: 3 short‑term, 20 long‑term
|
| 94 |
|
| 95 |
## 📱 Usage
|
| 96 |
+
1. Select/create a doctor; set role/specialty.
|
| 97 |
+
2. Search patient by name/ID; select a result.
|
| 98 |
+
3. Start a new chat; ask your question.
|
| 99 |
+
4. Manage sessions in the sidebar (rename/delete from menu).
|
| 100 |
+
5. View patient profile and create/edit via modals/pages.
|
| 101 |
+
|
| 102 |
+
## 🔌 Endpoints (selected)
|
| 103 |
+
- `POST /chat` → `{ response, session_id, timestamp, medical_context? }`
|
| 104 |
+
- `POST /sessions` → `{ session_id }`
|
| 105 |
+
- `GET /patients/{patient_id}/sessions`
|
| 106 |
+
- `GET /sessions/{session_id}/messages`
|
| 107 |
+
- `DELETE /sessions/{session_id}` → deletes session (cache + Mongo) and its messages
|
| 108 |
+
- `GET /patients/search?q=term&limit=8`
|
| 109 |
+
|
| 110 |
+
## 🔒 Data & Privacy
|
| 111 |
+
- MongoDB persistence keyed by `patient_id` with `doctor_id` attribution
|
| 112 |
+
- UI localStorage for UX (doctor list, preferences, selected patient)
|
| 113 |
+
- Avoid logging PHI; secure Mongo credentials
|
| 114 |
+
|
| 115 |
+
## 🧪 Dev
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
```bash
|
|
|
|
| 117 |
pip install -r requirements.txt
|
| 118 |
+
python -m src.main # run
|
| 119 |
+
pytest # tests
|
| 120 |
+
black . && flake8 # format + lint
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
```
|
| 122 |
|
| 123 |
### Project Structure
|
| 124 |
```
|
| 125 |
+
MedAI/
|
|
|
|
|
|
|
| 126 |
├── src/
|
| 127 |
│ ├── api/
|
| 128 |
+
│ │ └── routes/
|
| 129 |
+
│ │ ├── chat.py # Chat endpoint
|
| 130 |
+
│ │ ├── session.py # Session endpoints
|
| 131 |
+
│ │ ├── user.py # Patient APIs (get/create/update/search)
|
| 132 |
+
│ │ ├── system.py # Health/info
|
| 133 |
+
│ │ └── static.py # Serve index
|
| 134 |
│ ├── core/
|
| 135 |
+
│ │ ├── memory/
|
| 136 |
+
│ │ │ ├── memory.py # LRU short‑term memory + sessions
|
| 137 |
+
│ │ │ └── history.py # Context builder, persistence hooks
|
| 138 |
+
│ │ └── state.py # App state (rotators, embeddings, memory)
|
| 139 |
+
│ ├── data/
|
| 140 |
+
│ │ ├── __init__.py # Barrel exports for data layer
|
| 141 |
+
│ │ ├── connection.py # Mongo connection + collection names
|
| 142 |
+
│ │ ├── utils.py # Indexing, backups
|
| 143 |
+
│ │ ├── session/
|
| 144 |
+
│ │ │ └── operations.py # Sessions: ensure/list/delete
|
| 145 |
+
│ │ ├── message/
|
| 146 |
+
│ │ │ └── operations.py # Messages: save/list
|
| 147 |
+
│ │ ├── patient/
|
| 148 |
+
│ │ │ └── operations.py # Patients: get/create/update/search
|
| 149 |
+
│ │ ├── user/
|
| 150 |
+
│ │ │ └── operations.py # Accounts/Doctors
|
| 151 |
+
│ │ └── medical/
|
| 152 |
+
│ │ └── operations.py # Medical records + memory summaries
|
| 153 |
+
│ ├── models/
|
| 154 |
│ │ ├── chat.py
|
| 155 |
│ │ └── user.py
|
| 156 |
+
│ ├── services/
|
| 157 |
+
│ │ ├── medical_response.py # Calls model(s)
|
| 158 |
+
│ │ └── summariser.py # Title/QA summarisation
|
| 159 |
+
│ ├── utils/
|
|
|
|
| 160 |
│ │ ├── embeddings.py
|
| 161 |
│ │ ├── logger.py
|
|
|
|
| 162 |
│ │ └── rotator.py
|
| 163 |
+
│ └── main.py # FastAPI entrypoint
|
| 164 |
+
├── static/
|
| 165 |
+
│ ├── index.html
|
|
|
|
|
|
|
| 166 |
│ ├── css/
|
| 167 |
+
│ │ ├── styles.css
|
| 168 |
+
│ │ └── patient.css
|
| 169 |
+
│ ├── js/
|
| 170 |
+
│ │ ├── app.js # Submodules under /ui/* and /chat/*
|
| 171 |
+
│ │ └── patient.js
|
| 172 |
+
│ └── patient.html
|
| 173 |
+
├── requirements.txt
|
| 174 |
+
├── requirements-dev.txt
|
| 175 |
├── Dockerfile
|
| 176 |
+
├── docker-compose.yml
|
| 177 |
+
├── LICENSE
|
| 178 |
+
└── README.md
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
```
|
| 180 |
|
| 181 |
+
## 🧾 License & Disclaimer
|
| 182 |
+
- MIT License (see [LICENSE](https://huggingface.co/spaces/MedAI-COS30018/MedicalDiagnosisSystem/blob/main/LICENSE))
|
| 183 |
+
- Educational information only; not a substitute for professional medical advice
|
| 184 |
+
- Team D1 - COS30018, Swinburne University of Technology
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
TODO.md
CHANGED
|
@@ -6,6 +6,7 @@
|
|
| 6 |
- [ ] Accounts
|
| 7 |
- [ ] Saving chats
|
| 8 |
- [ ] MongoDB hosting
|
|
|
|
| 9 |
## Suggestions
|
| 10 |
|
| 11 |
1. Dependency Injection: — Investigated
|
|
@@ -47,3 +48,22 @@
|
|
| 47 |
10. State Management:
|
| 48 |
- Global state in MedicalState could be improved
|
| 49 |
- Session management could be more robust
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
- [ ] Accounts
|
| 7 |
- [ ] Saving chats
|
| 8 |
- [ ] MongoDB hosting
|
| 9 |
+
|
| 10 |
## Suggestions
|
| 11 |
|
| 12 |
1. Dependency Injection: — Investigated
|
|
|
|
| 48 |
10. State Management:
|
| 49 |
- Global state in MedicalState could be improved
|
| 50 |
- Session management could be more robust
|
| 51 |
+
|
| 52 |
+
## PHASE 2:
|
| 53 |
+
- [ ] EMR enhancement
|
| 54 |
+
- [ ] Naive RAG integration
|
| 55 |
+
- [ ] Chat history consistency
|
| 56 |
+
- [ ] Summary history to EMR
|
| 57 |
+
|
| 58 |
+
## PHASE 3:
|
| 59 |
+
- [ ] Multimodal integration
|
| 60 |
+
- [ ] Reasoning orchestrator
|
| 61 |
+
- [ ] Dynamic/Tree/Graph RAG
|
| 62 |
+
- [ ] Integrate specialist model
|
| 63 |
+
|
| 64 |
+
## PHASE 4:
|
| 65 |
+
- [ ] Multimodal validation
|
| 66 |
+
- [ ] Orchestrator validation
|
| 67 |
+
- [ ] RAG validation
|
| 68 |
+
- [ ] UI Enhancement
|
| 69 |
+
- [ ] Future suggestions
|
UI_SETUP.md
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
## UI setup and structure
|
| 2 |
+
|
| 3 |
+
This document explains the browser UI code structure, responsibilities of each module, how the app boots, what localStorage keys are used, and how the UI communicates with the Python backend.
|
| 4 |
+
|
| 5 |
+
### 1) Directory layout (frontend)
|
| 6 |
+
|
| 7 |
+
```
|
| 8 |
+
static/
|
| 9 |
+
index.html # Main UI page
|
| 10 |
+
css/
|
| 11 |
+
styles.css # Global theme + layout
|
| 12 |
+
patient.css # Patient registration page
|
| 13 |
+
js/
|
| 14 |
+
app.js # Legacy monolith boot (kept for compatibility)
|
| 15 |
+
core.js # (optional) Core app bootstrap if using modules
|
| 16 |
+
boot.js # (optional) Bootstrapping helpers for modules
|
| 17 |
+
ui/ # UI-layer modules (DOM + events)
|
| 18 |
+
sidebar.js # Sidebar open/close, overlay, sessions list
|
| 19 |
+
modals.js # User/doctor modal, settings modal, edit-title modal
|
| 20 |
+
patient.js # Patient section: typeahead, status, select
|
| 21 |
+
theme.js # Theme/font-size handling
|
| 22 |
+
voice.js # Web Speech API setup (optional)
|
| 23 |
+
utils.js # Small DOM utilities (qs, on, etc.)
|
| 24 |
+
chat/ # Chat logic modules
|
| 25 |
+
messages.js # Render and manage messages in chat pane
|
| 26 |
+
sessions.js # Client-side session CRUD (local + fetch from backend)
|
| 27 |
+
api.js # Fetch helpers to talk to backend endpoints
|
| 28 |
+
state.js # Ephemeral UI state (current user/patient/session)
|
| 29 |
+
|
| 30 |
+
patient.html # Patient registration page
|
| 31 |
+
```
|
| 32 |
+
|
| 33 |
+
Notes:
|
| 34 |
+
- If `core.js` and `boot.js` are present and loaded as type="module", they should import from `js/ui/*` and `js/chat/*`. If you continue using `app.js`, it should delegate to these modules (or keep the current inline logic).
|
| 35 |
+
|
| 36 |
+
### 2) Boot sequence
|
| 37 |
+
|
| 38 |
+
Minimal boot (non-module):
|
| 39 |
+
- `index.html` loads `app.js` (non-module). `app.js` wires events, restores local state, applies theme, renders sidebar sessions, and binds modals.
|
| 40 |
+
|
| 41 |
+
Module-based boot (recommended):
|
| 42 |
+
- `index.html` includes:
|
| 43 |
+
- `<script type="module" src="/static/js/core.js"></script>`
|
| 44 |
+
- `core.js` imports from `ui/*` and `chat/*`, builds the app, and calls `boot.init()`.
|
| 45 |
+
- `boot.js` provides `init()` that wires all UI modules in a deterministic order.
|
| 46 |
+
|
| 47 |
+
Expected init order:
|
| 48 |
+
1. Load preferences (theme, font size) and apply to `document.documentElement`.
|
| 49 |
+
2. Restore user (doctor) profile from localStorage.
|
| 50 |
+
3. Restore selected patient id from localStorage and, if present, preload sessions from backend.
|
| 51 |
+
4. Wire global UI events: sidebar toggle, outside-click overlay, send, clear, export.
|
| 52 |
+
5. Wire modals: user/doctor modal, settings modal, edit-session-title modal.
|
| 53 |
+
6. Wire patient section: typeahead search + selection + status.
|
| 54 |
+
7. Render current session messages.
|
| 55 |
+
|
| 56 |
+
### 3) State model (in-memory)
|
| 57 |
+
|
| 58 |
+
- `state.user`: current doctor `{ id, name, role, specialty, createdAt }`.
|
| 59 |
+
- `state.patientId`: 8-digit patient id string kept in localStorage under `medicalChatbotPatientId`.
|
| 60 |
+
- `state.currentSession`: `{ id, title, messages[], createdAt, lastActivity, source }`.
|
| 61 |
+
- `state.sessions`: local session cache (for non-backend sessions).
|
| 62 |
+
|
| 63 |
+
LocalStorage keys:
|
| 64 |
+
- `medicalChatbotUser`: current doctor object.
|
| 65 |
+
- `medicalChatbotDoctors`: array of `{ name }` (unique, used for the dropdown).
|
| 66 |
+
- `medicalChatbotPatientId`: selected patient id.
|
| 67 |
+
- `medicalChatbotPreferences`: `{ theme, fontSize, autoSave, notifications }`.
|
| 68 |
+
|
| 69 |
+
### 4) Components and responsibilities
|
| 70 |
+
|
| 71 |
+
UI modules (ui/*):
|
| 72 |
+
- `sidebar.js`: opens/closes sidebar, manages `#sidebarOverlay`, renders session cards, handles outside-click close.
|
| 73 |
+
- `modals.js`: shows/hides user/doctor modal, settings modal, edit-title modal. Populates doctor dropdown with a first item "Create doctor user..." and injects current doctor name if missing.
|
| 74 |
+
- `patient.js`: typeahead over `/patients/search?q=...`, renders suggestions, sets `state.patientId` and persists to localStorage, triggers sessions preload.
|
| 75 |
+
- `theme.js`: reads/writes `medicalChatbotPreferences`, applies `data-theme` on `<html>`, sets root font-size.
|
| 76 |
+
- `voice.js`: optional Web Speech API wiring to fill `#chatInput`.
|
| 77 |
+
|
| 78 |
+
Chat modules (chat/*):
|
| 79 |
+
- `messages.js`: adds user/assistant messages, formats content, timestamps, scrolls to bottom.
|
| 80 |
+
- `sessions.js`: saves/loads local sessions, hydrates backend sessions (`GET /sessions/{id}/messages`), deletes/renames sessions.
|
| 81 |
+
- `api.js`: wrappers around backend endpoints (`/chat`, `/patients/*`, `/sessions/*`). Adds `Accept: application/json` and logs responses.
|
| 82 |
+
- `state.js`: exports a singleton state object used by UI and chat modules.
|
| 83 |
+
|
| 84 |
+
### 5) Backend endpoints used by the UI
|
| 85 |
+
|
| 86 |
+
- `POST /chat` — main inference call.
|
| 87 |
+
- `GET /patients/search?q=...&limit=...` — typeahead. Returns `{ results: [{ name, patient_id, ...}, ...] }`.
|
| 88 |
+
- `GET /patients/{patient_id}` — patient profile (used by patient modal).
|
| 89 |
+
- `POST /patients` — create patient (used by patient.html). Returns `{ patient_id, name, ... }`.
|
| 90 |
+
- `PATCH /patients/{patient_id}` — update patient fields.
|
| 91 |
+
- `GET /patients/{patient_id}/sessions` — list sessions.
|
| 92 |
+
- `GET /sessions/{session_id}/messages?limit=...` — hydrate messages.
|
| 93 |
+
|
| 94 |
+
### 6) Theming
|
| 95 |
+
|
| 96 |
+
- CSS variables are declared under `:root` and overridden under `[data-theme="dark"]`.
|
| 97 |
+
- On boot, the app reads `medicalChatbotPreferences.theme` and applies:
|
| 98 |
+
- `auto` => matches `prefers-color-scheme`.
|
| 99 |
+
- `light`/`dark` => sets `document.documentElement.dataset.theme` accordingly.
|
| 100 |
+
|
| 101 |
+
### 7) Patient typeahead contract
|
| 102 |
+
|
| 103 |
+
- Input: `#patientIdInput`.
|
| 104 |
+
- Suggestions container: `#patientSuggestions` (absolute, below input).
|
| 105 |
+
- Debounce: ~200 ms. Request: `GET /patients/search?q=<term>&limit=8`.
|
| 106 |
+
- On selection: set `state.patientId`, persist to localStorage, update `#patientStatus`, call sessions preload, close suggestions.
|
| 107 |
+
- On Enter:
|
| 108 |
+
- If exact 8 digits, call `loadPatient()`.
|
| 109 |
+
- Otherwise, search and pick the first match if any.
|
| 110 |
+
|
| 111 |
+
### 8) Sidebar behavior
|
| 112 |
+
|
| 113 |
+
- Open: click `#sidebarToggle`.
|
| 114 |
+
- Close: clicking outside (main area) or on `#sidebarOverlay` hides the sidebar on all viewports.
|
| 115 |
+
|
| 116 |
+
### 9) Doctor dropdown rules
|
| 117 |
+
|
| 118 |
+
- First option is always "Create doctor user..." (value: `__create__`).
|
| 119 |
+
- If the current doctor name is not in `medicalChatbotDoctors`, it is inserted and saved.
|
| 120 |
+
- Choosing the create option reveals an inline mini-form to add a new doctor; Confirm inserts and selects it.
|
| 121 |
+
|
| 122 |
+
### 10) Voice input (optional)
|
| 123 |
+
|
| 124 |
+
- If using `voice.js`: checks `window.SpeechRecognition || window.webkitSpeechRecognition`.
|
| 125 |
+
- Streams interim results into `#chatInput`, toggled by `#microphoneBtn`.
|
| 126 |
+
|
| 127 |
+
### 11) Patient registration flow
|
| 128 |
+
|
| 129 |
+
- `patient.html` posts to `POST /patients` with name/age/sex/etc.
|
| 130 |
+
- On success, shows a modal with the new Patient ID and two actions: Return to main page, Edit patient profile.
|
| 131 |
+
- Stores `medicalChatbotPatientId` and redirects when appropriate.
|
| 132 |
+
|
| 133 |
+
### 12) Troubleshooting
|
| 134 |
+
|
| 135 |
+
- Sidebar won’t close: ensure `#sidebarOverlay` exists and that `sidebar.js`/`app.js` wires outside-click and overlay click listeners.
|
| 136 |
+
- Doctor dropdown empty: confirm `medicalChatbotDoctors` exists or `populateDoctorSelect()` runs on opening the modal.
|
| 137 |
+
- Typeahead doesn’t show results: open network tab and hit `/patients/search?q=test`; ensure 200 and JSON. Logs are printed by FastAPI (see server console).
|
| 138 |
+
- Theme not changing: ensure `theme.js` sets `data-theme` on `<html>` and `styles.css` uses `[data-theme="dark"]` overrides.
|
| 139 |
+
|
| 140 |
+
### 13) Migration from app.js to modules
|
| 141 |
+
|
| 142 |
+
If you refactored into `ui/*` and `chat/*`:
|
| 143 |
+
1. Ensure `index.html` loads `core.js` as a module.
|
| 144 |
+
2. In `core.js`, import and initialize modules in the order described in Boot sequence.
|
| 145 |
+
3. Keep `app.js` only if you need compatibility; progressively move code into the relevant module files.
|
| 146 |
+
|
| 147 |
+
|
src/api/routes/chat.py
CHANGED
|
@@ -11,6 +11,8 @@ from src.services.medical_response import generate_medical_response
|
|
| 11 |
from src.services.summariser import summarise_title_with_nvidia
|
| 12 |
from src.utils.logger import logger
|
| 13 |
|
|
|
|
|
|
|
| 14 |
router = APIRouter()
|
| 15 |
|
| 16 |
@router.post("/chat", response_model=ChatResponse)
|
|
@@ -18,14 +20,16 @@ async def chat_endpoint(
|
|
| 18 |
request: ChatRequest,
|
| 19 |
state: MedicalState = Depends(get_state)
|
| 20 |
):
|
| 21 |
-
"""
|
|
|
|
|
|
|
| 22 |
start_time = time.time()
|
| 23 |
|
| 24 |
try:
|
| 25 |
-
logger().info(f"
|
| 26 |
logger().info(f"Message: {request.message[:100]}...") # Log first 100 chars of message
|
| 27 |
|
| 28 |
-
# Get or create user profile
|
| 29 |
user_profile = state.memory_system.get_user(request.user_id)
|
| 30 |
if not user_profile:
|
| 31 |
state.memory_system.create_user(request.user_id, request.user_role or "Anonymous")
|
|
@@ -35,7 +39,7 @@ async def chat_endpoint(
|
|
| 35 |
{"specialty": request.user_specialty}
|
| 36 |
)
|
| 37 |
|
| 38 |
-
# Get or create session
|
| 39 |
session = state.memory_system.get_session(request.session_id)
|
| 40 |
if not session:
|
| 41 |
session_id = state.memory_system.create_session(request.user_id, request.title or "New Chat")
|
|
@@ -43,11 +47,16 @@ async def chat_endpoint(
|
|
| 43 |
session = state.memory_system.get_session(session_id)
|
| 44 |
logger().info(f"Created new session: {session_id}")
|
| 45 |
|
| 46 |
-
#
|
| 47 |
-
|
|
|
|
|
|
|
|
|
|
| 48 |
request.user_id,
|
| 49 |
-
|
| 50 |
-
|
|
|
|
|
|
|
| 51 |
)
|
| 52 |
|
| 53 |
# Generate response using Gemini AI
|
|
@@ -68,7 +77,10 @@ async def chat_endpoint(
|
|
| 68 |
request.message,
|
| 69 |
response,
|
| 70 |
state.gemini_rotator,
|
| 71 |
-
state.nvidia_rotator
|
|
|
|
|
|
|
|
|
|
| 72 |
)
|
| 73 |
except Exception as e:
|
| 74 |
logger().warning(f"Failed to process medical exchange: {e}")
|
|
@@ -83,7 +95,7 @@ async def chat_endpoint(
|
|
| 83 |
return ChatResponse(
|
| 84 |
response=response,
|
| 85 |
session_id=request.session_id,
|
| 86 |
-
timestamp=datetime.now(timezone.utc).isoformat(),
|
| 87 |
medical_context=medical_context if medical_context else None
|
| 88 |
)
|
| 89 |
|
|
|
|
| 11 |
from src.services.summariser import summarise_title_with_nvidia
|
| 12 |
from src.utils.logger import logger
|
| 13 |
|
| 14 |
+
from src.data import ensure_session
|
| 15 |
+
|
| 16 |
router = APIRouter()
|
| 17 |
|
| 18 |
@router.post("/chat", response_model=ChatResponse)
|
|
|
|
| 20 |
request: ChatRequest,
|
| 21 |
state: MedicalState = Depends(get_state)
|
| 22 |
):
|
| 23 |
+
"""
|
| 24 |
+
Process a chat message, generate response, and persist short-term cache + long-term Mongo.
|
| 25 |
+
"""
|
| 26 |
start_time = time.time()
|
| 27 |
|
| 28 |
try:
|
| 29 |
+
logger().info(f"POST /chat user={request.user_id} session={request.session_id} patient={request.patient_id} doctor={request.doctor_id}")
|
| 30 |
logger().info(f"Message: {request.message[:100]}...") # Log first 100 chars of message
|
| 31 |
|
| 32 |
+
# Get or create user profile (doctor as current user profile)
|
| 33 |
user_profile = state.memory_system.get_user(request.user_id)
|
| 34 |
if not user_profile:
|
| 35 |
state.memory_system.create_user(request.user_id, request.user_role or "Anonymous")
|
|
|
|
| 39 |
{"specialty": request.user_specialty}
|
| 40 |
)
|
| 41 |
|
| 42 |
+
# Get or create session (cache)
|
| 43 |
session = state.memory_system.get_session(request.session_id)
|
| 44 |
if not session:
|
| 45 |
session_id = state.memory_system.create_session(request.user_id, request.title or "New Chat")
|
|
|
|
| 47 |
session = state.memory_system.get_session(session_id)
|
| 48 |
logger().info(f"Created new session: {session_id}")
|
| 49 |
|
| 50 |
+
# Ensure session exists in Mongo with patient/doctor context
|
| 51 |
+
ensure_session(session_id=request.session_id, patient_id=request.patient_id, doctor_id=request.doctor_id, title=request.title or "New Chat", last_activity=datetime.now(timezone.utc))
|
| 52 |
+
|
| 53 |
+
# Get enhanced medical context with STM + LTM semantic search + NVIDIA reasoning
|
| 54 |
+
medical_context = await state.history_manager.get_enhanced_conversation_context(
|
| 55 |
request.user_id,
|
| 56 |
+
request.session_id,
|
| 57 |
+
request.message,
|
| 58 |
+
state.nvidia_rotator,
|
| 59 |
+
patient_id=request.patient_id
|
| 60 |
)
|
| 61 |
|
| 62 |
# Generate response using Gemini AI
|
|
|
|
| 77 |
request.message,
|
| 78 |
response,
|
| 79 |
state.gemini_rotator,
|
| 80 |
+
state.nvidia_rotator,
|
| 81 |
+
patient_id=request.patient_id,
|
| 82 |
+
doctor_id=request.doctor_id,
|
| 83 |
+
session_title=request.title or "New Chat"
|
| 84 |
)
|
| 85 |
except Exception as e:
|
| 86 |
logger().warning(f"Failed to process medical exchange: {e}")
|
|
|
|
| 95 |
return ChatResponse(
|
| 96 |
response=response,
|
| 97 |
session_id=request.session_id,
|
| 98 |
+
timestamp=datetime.now(timezone.utc).isoformat(),
|
| 99 |
medical_context=medical_context if medical_context else None
|
| 100 |
)
|
| 101 |
|
src/api/routes/session.py
CHANGED
|
@@ -3,10 +3,12 @@
|
|
| 3 |
from datetime import datetime
|
| 4 |
|
| 5 |
from fastapi import APIRouter, Depends, HTTPException
|
|
|
|
| 6 |
|
| 7 |
from src.core.state import MedicalState, get_state
|
| 8 |
from src.models.chat import SessionRequest
|
| 9 |
from src.utils.logger import logger
|
|
|
|
| 10 |
|
| 11 |
router = APIRouter()
|
| 12 |
|
|
@@ -15,9 +17,12 @@ async def create_chat_session(
|
|
| 15 |
request: SessionRequest,
|
| 16 |
state: MedicalState = Depends(get_state)
|
| 17 |
):
|
| 18 |
-
"""Create a new chat session"""
|
| 19 |
try:
|
|
|
|
| 20 |
session_id = state.memory_system.create_session(request.user_id, request.title or "New Chat")
|
|
|
|
|
|
|
| 21 |
return {"session_id": session_id, "message": "Session created successfully"}
|
| 22 |
except Exception as e:
|
| 23 |
logger().error(f"Error creating session: {e}")
|
|
@@ -28,7 +33,7 @@ async def get_chat_session(
|
|
| 28 |
session_id: str,
|
| 29 |
state: MedicalState = Depends(get_state)
|
| 30 |
):
|
| 31 |
-
"""Get
|
| 32 |
try:
|
| 33 |
session = state.memory_system.get_session(session_id)
|
| 34 |
if not session:
|
|
@@ -52,15 +57,55 @@ async def get_chat_session(
|
|
| 52 |
logger().error(f"Error getting session: {e}")
|
| 53 |
raise HTTPException(status_code=500, detail=str(e))
|
| 54 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
@router.delete("/sessions/{session_id}")
|
| 56 |
async def delete_chat_session(
|
| 57 |
session_id: str,
|
| 58 |
state: MedicalState = Depends(get_state)
|
| 59 |
):
|
| 60 |
-
"""Delete a chat session"""
|
| 61 |
try:
|
|
|
|
|
|
|
|
|
|
| 62 |
state.memory_system.delete_session(session_id)
|
| 63 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
except Exception as e:
|
| 65 |
logger().error(f"Error deleting session: {e}")
|
| 66 |
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
| 3 |
from datetime import datetime
|
| 4 |
|
| 5 |
from fastapi import APIRouter, Depends, HTTPException
|
| 6 |
+
from datetime import datetime
|
| 7 |
|
| 8 |
from src.core.state import MedicalState, get_state
|
| 9 |
from src.models.chat import SessionRequest
|
| 10 |
from src.utils.logger import logger
|
| 11 |
+
from src.data import list_patient_sessions, list_session_messages, ensure_session, delete_session, delete_session_messages
|
| 12 |
|
| 13 |
router = APIRouter()
|
| 14 |
|
|
|
|
| 17 |
request: SessionRequest,
|
| 18 |
state: MedicalState = Depends(get_state)
|
| 19 |
):
|
| 20 |
+
"""Create a new chat session (cache + Mongo)"""
|
| 21 |
try:
|
| 22 |
+
logger().info(f"POST /sessions user_id={request.user_id} patient_id={request.patient_id} doctor_id={request.doctor_id}")
|
| 23 |
session_id = state.memory_system.create_session(request.user_id, request.title or "New Chat")
|
| 24 |
+
# Also ensure in Mongo with patient/doctor
|
| 25 |
+
ensure_session(session_id=session_id, patient_id=request.patient_id, doctor_id=request.doctor_id, title=request.title or "New Chat")
|
| 26 |
return {"session_id": session_id, "message": "Session created successfully"}
|
| 27 |
except Exception as e:
|
| 28 |
logger().error(f"Error creating session: {e}")
|
|
|
|
| 33 |
session_id: str,
|
| 34 |
state: MedicalState = Depends(get_state)
|
| 35 |
):
|
| 36 |
+
"""Get session from cache (for quick preview)"""
|
| 37 |
try:
|
| 38 |
session = state.memory_system.get_session(session_id)
|
| 39 |
if not session:
|
|
|
|
| 57 |
logger().error(f"Error getting session: {e}")
|
| 58 |
raise HTTPException(status_code=500, detail=str(e))
|
| 59 |
|
| 60 |
+
@router.get("/patients/{patient_id}/sessions")
|
| 61 |
+
async def list_sessions_for_patient(patient_id: str):
|
| 62 |
+
"""List sessions for a patient from Mongo"""
|
| 63 |
+
try:
|
| 64 |
+
logger().info(f"GET /patients/{patient_id}/sessions")
|
| 65 |
+
return {"sessions": list_patient_sessions(patient_id)}
|
| 66 |
+
except Exception as e:
|
| 67 |
+
logger().error(f"Error listing sessions: {e}")
|
| 68 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 69 |
+
|
| 70 |
+
@router.get("/sessions/{session_id}/messages")
|
| 71 |
+
async def list_messages_for_session(session_id: str, patient_id: str, limit: int | None = None):
|
| 72 |
+
"""List messages for a session from Mongo, verified to belong to the patient"""
|
| 73 |
+
try:
|
| 74 |
+
logger().info(f"GET /sessions/{session_id}/messages patient_id={patient_id} limit={limit}")
|
| 75 |
+
msgs = list_session_messages(session_id, patient_id=patient_id, limit=limit)
|
| 76 |
+
# ensure JSON-friendly timestamps
|
| 77 |
+
for m in msgs:
|
| 78 |
+
if isinstance(m.get("timestamp"), datetime):
|
| 79 |
+
m["timestamp"] = m["timestamp"].isoformat()
|
| 80 |
+
m["_id"] = str(m["_id"]) if "_id" in m else None
|
| 81 |
+
return {"messages": msgs}
|
| 82 |
+
except Exception as e:
|
| 83 |
+
logger().error(f"Error listing messages: {e}")
|
| 84 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 85 |
+
|
| 86 |
@router.delete("/sessions/{session_id}")
|
| 87 |
async def delete_chat_session(
|
| 88 |
session_id: str,
|
| 89 |
state: MedicalState = Depends(get_state)
|
| 90 |
):
|
| 91 |
+
"""Delete a chat session from both memory system and MongoDB"""
|
| 92 |
try:
|
| 93 |
+
logger().info(f"DELETE /sessions/{session_id}")
|
| 94 |
+
|
| 95 |
+
# Delete from memory system
|
| 96 |
state.memory_system.delete_session(session_id)
|
| 97 |
+
|
| 98 |
+
# Delete from MongoDB
|
| 99 |
+
session_deleted = delete_session(session_id)
|
| 100 |
+
messages_deleted = delete_session_messages(session_id)
|
| 101 |
+
|
| 102 |
+
logger().info(f"Deleted session {session_id}: session={session_deleted}, messages={messages_deleted}")
|
| 103 |
+
|
| 104 |
+
return {
|
| 105 |
+
"message": "Session deleted successfully",
|
| 106 |
+
"session_deleted": session_deleted,
|
| 107 |
+
"messages_deleted": messages_deleted
|
| 108 |
+
}
|
| 109 |
except Exception as e:
|
| 110 |
logger().error(f"Error deleting session: {e}")
|
| 111 |
raise HTTPException(status_code=500, detail=str(e))
|
src/api/routes/user.py
CHANGED
|
@@ -3,8 +3,9 @@
|
|
| 3 |
from fastapi import APIRouter, Depends, HTTPException
|
| 4 |
|
| 5 |
from src.core.state import MedicalState, get_state
|
| 6 |
-
from src.models.user import UserProfileRequest
|
| 7 |
from src.utils.logger import logger
|
|
|
|
|
|
|
| 8 |
|
| 9 |
router = APIRouter()
|
| 10 |
|
|
@@ -15,12 +16,25 @@ async def create_user_profile(
|
|
| 15 |
):
|
| 16 |
"""Create or update user profile"""
|
| 17 |
try:
|
|
|
|
| 18 |
user = state.memory_system.create_user(request.user_id, request.name)
|
| 19 |
user.set_preference("role", request.role)
|
| 20 |
if request.specialty:
|
| 21 |
user.set_preference("specialty", request.specialty)
|
|
|
|
|
|
|
| 22 |
|
| 23 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
except Exception as e:
|
| 25 |
logger().error(f"Error creating user profile: {e}")
|
| 26 |
raise HTTPException(status_code=500, detail=str(e))
|
|
@@ -44,6 +58,7 @@ async def get_user_profile(
|
|
| 44 |
"name": user.name,
|
| 45 |
"role": user.preferences.get("role", "Unknown"),
|
| 46 |
"specialty": user.preferences.get("specialty", ""),
|
|
|
|
| 47 |
"created_at": user.created_at,
|
| 48 |
"last_seen": user.last_seen
|
| 49 |
},
|
|
@@ -63,3 +78,120 @@ async def get_user_profile(
|
|
| 63 |
except Exception as e:
|
| 64 |
logger().error(f"Error getting user profile: {e}")
|
| 65 |
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
from fastapi import APIRouter, Depends, HTTPException
|
| 4 |
|
| 5 |
from src.core.state import MedicalState, get_state
|
|
|
|
| 6 |
from src.utils.logger import logger
|
| 7 |
+
from src.models.user import UserProfileRequest, PatientCreateRequest, PatientUpdateRequest, DoctorCreateRequest
|
| 8 |
+
from src.data import create_account, create_doctor, get_doctor_by_name, search_doctors, get_all_doctors
|
| 9 |
|
| 10 |
router = APIRouter()
|
| 11 |
|
|
|
|
| 16 |
):
|
| 17 |
"""Create or update user profile"""
|
| 18 |
try:
|
| 19 |
+
# Persist to in-memory profile (existing behavior)
|
| 20 |
user = state.memory_system.create_user(request.user_id, request.name)
|
| 21 |
user.set_preference("role", request.role)
|
| 22 |
if request.specialty:
|
| 23 |
user.set_preference("specialty", request.specialty)
|
| 24 |
+
if request.medical_roles:
|
| 25 |
+
user.set_preference("medical_roles", request.medical_roles)
|
| 26 |
|
| 27 |
+
# Persist to MongoDB accounts collection
|
| 28 |
+
account_doc = {
|
| 29 |
+
"user_id": request.user_id,
|
| 30 |
+
"name": request.name,
|
| 31 |
+
"role": request.role,
|
| 32 |
+
"medical_roles": request.medical_roles or [request.role] if request.role else [],
|
| 33 |
+
"specialty": request.specialty or None,
|
| 34 |
+
}
|
| 35 |
+
account_id = create_account(account_doc)
|
| 36 |
+
|
| 37 |
+
return {"message": "User profile created successfully", "user_id": request.user_id, "account_id": account_id}
|
| 38 |
except Exception as e:
|
| 39 |
logger().error(f"Error creating user profile: {e}")
|
| 40 |
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
| 58 |
"name": user.name,
|
| 59 |
"role": user.preferences.get("role", "Unknown"),
|
| 60 |
"specialty": user.preferences.get("specialty", ""),
|
| 61 |
+
"medical_roles": user.preferences.get("medical_roles", []),
|
| 62 |
"created_at": user.created_at,
|
| 63 |
"last_seen": user.last_seen
|
| 64 |
},
|
|
|
|
| 78 |
except Exception as e:
|
| 79 |
logger().error(f"Error getting user profile: {e}")
|
| 80 |
raise HTTPException(status_code=500, detail=str(e))
|
| 81 |
+
|
| 82 |
+
# -------------------- Patient APIs --------------------
|
| 83 |
+
from src.data import get_patient_by_id, create_patient, update_patient_profile, search_patients
|
| 84 |
+
|
| 85 |
+
@router.get("/patients/search")
|
| 86 |
+
async def search_patients_route(q: str, limit: int = 20):
|
| 87 |
+
try:
|
| 88 |
+
logger().info(f"GET /patients/search q='{q}' limit={limit}")
|
| 89 |
+
results = search_patients(q, limit=limit)
|
| 90 |
+
logger().info(f"Search returned {len(results)} results")
|
| 91 |
+
return {"results": results}
|
| 92 |
+
except Exception as e:
|
| 93 |
+
logger().error(f"Error searching patients: {e}")
|
| 94 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 95 |
+
|
| 96 |
+
@router.get("/patients/{patient_id}")
|
| 97 |
+
async def get_patient(patient_id: str):
|
| 98 |
+
try:
|
| 99 |
+
logger().info(f"GET /patients/{patient_id}")
|
| 100 |
+
patient = get_patient_by_id(patient_id)
|
| 101 |
+
if not patient:
|
| 102 |
+
raise HTTPException(status_code=404, detail="Patient not found")
|
| 103 |
+
patient["_id"] = str(patient.get("_id")) if patient.get("_id") else None
|
| 104 |
+
return patient
|
| 105 |
+
except HTTPException:
|
| 106 |
+
raise
|
| 107 |
+
except Exception as e:
|
| 108 |
+
logger().error(f"Error getting patient: {e}")
|
| 109 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 110 |
+
|
| 111 |
+
@router.post("/patients")
|
| 112 |
+
async def create_patient_profile(req: PatientCreateRequest):
|
| 113 |
+
try:
|
| 114 |
+
logger().info(f"POST /patients name={req.name}")
|
| 115 |
+
patient = create_patient(
|
| 116 |
+
name=req.name,
|
| 117 |
+
age=req.age,
|
| 118 |
+
sex=req.sex,
|
| 119 |
+
address=req.address,
|
| 120 |
+
phone=req.phone,
|
| 121 |
+
email=req.email,
|
| 122 |
+
medications=req.medications,
|
| 123 |
+
past_assessment_summary=req.past_assessment_summary,
|
| 124 |
+
assigned_doctor_id=req.assigned_doctor_id
|
| 125 |
+
)
|
| 126 |
+
patient["_id"] = str(patient.get("_id")) if patient.get("_id") else None
|
| 127 |
+
logger().info(f"Created patient {patient.get('name')} id={patient.get('patient_id')}")
|
| 128 |
+
return patient
|
| 129 |
+
except Exception as e:
|
| 130 |
+
logger().error(f"Error creating patient: {e}")
|
| 131 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 132 |
+
|
| 133 |
+
@router.patch("/patients/{patient_id}")
|
| 134 |
+
async def update_patient(patient_id: str, req: PatientUpdateRequest):
|
| 135 |
+
try:
|
| 136 |
+
payload = {k: v for k, v in req.model_dump().items() if v is not None}
|
| 137 |
+
logger().info(f"PATCH /patients/{patient_id} fields={list(payload.keys())}")
|
| 138 |
+
modified = update_patient_profile(patient_id, payload)
|
| 139 |
+
if modified == 0:
|
| 140 |
+
return {"message": "No changes"}
|
| 141 |
+
return {"message": "Updated"}
|
| 142 |
+
except Exception as e:
|
| 143 |
+
logger().error(f"Error updating patient: {e}")
|
| 144 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 145 |
+
|
| 146 |
+
# -------------------- Doctor APIs --------------------
|
| 147 |
+
@router.post("/doctors")
|
| 148 |
+
async def create_doctor_profile(req: DoctorCreateRequest):
|
| 149 |
+
try:
|
| 150 |
+
logger().info(f"POST /doctors name={req.name}")
|
| 151 |
+
doctor_id = create_doctor(
|
| 152 |
+
name=req.name,
|
| 153 |
+
role=req.role,
|
| 154 |
+
specialty=req.specialty,
|
| 155 |
+
medical_roles=req.medical_roles
|
| 156 |
+
)
|
| 157 |
+
logger().info(f"Created doctor {req.name} id={doctor_id}")
|
| 158 |
+
return {"doctor_id": doctor_id, "name": req.name}
|
| 159 |
+
except Exception as e:
|
| 160 |
+
logger().error(f"Error creating doctor: {e}")
|
| 161 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 162 |
+
|
| 163 |
+
@router.get("/doctors/{doctor_name}")
|
| 164 |
+
async def get_doctor(doctor_name: str):
|
| 165 |
+
try:
|
| 166 |
+
logger().info(f"GET /doctors/{doctor_name}")
|
| 167 |
+
doctor = get_doctor_by_name(doctor_name)
|
| 168 |
+
if not doctor:
|
| 169 |
+
raise HTTPException(status_code=404, detail="Doctor not found")
|
| 170 |
+
return doctor
|
| 171 |
+
except HTTPException:
|
| 172 |
+
raise
|
| 173 |
+
except Exception as e:
|
| 174 |
+
logger().error(f"Error getting doctor: {e}")
|
| 175 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 176 |
+
|
| 177 |
+
@router.get("/doctors/search")
|
| 178 |
+
async def search_doctors_route(q: str, limit: int = 10):
|
| 179 |
+
try:
|
| 180 |
+
logger().info(f"GET /doctors/search q='{q}' limit={limit}")
|
| 181 |
+
results = search_doctors(q, limit=limit)
|
| 182 |
+
logger().info(f"Doctor search returned {len(results)} results")
|
| 183 |
+
return {"results": results}
|
| 184 |
+
except Exception as e:
|
| 185 |
+
logger().error(f"Error searching doctors: {e}")
|
| 186 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 187 |
+
|
| 188 |
+
@router.get("/doctors")
|
| 189 |
+
async def get_all_doctors_route(limit: int = 50):
|
| 190 |
+
try:
|
| 191 |
+
logger().info(f"GET /doctors limit={limit}")
|
| 192 |
+
results = get_all_doctors(limit=limit)
|
| 193 |
+
logger().info(f"Retrieved {len(results)} doctors")
|
| 194 |
+
return {"results": results}
|
| 195 |
+
except Exception as e:
|
| 196 |
+
logger().error(f"Error getting all doctors: {e}")
|
| 197 |
+
raise HTTPException(status_code=500, detail=str(e))
|
src/core/memory/history.py
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# core/memory/history.py
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
from typing import Any
|
| 5 |
+
|
| 6 |
+
import numpy as np
|
| 7 |
+
from datetime import datetime, timezone
|
| 8 |
+
|
| 9 |
+
from src.services.nvidia import nvidia_chat
|
| 10 |
+
from src.services.summariser import (summarise_qa_with_gemini,
|
| 11 |
+
summarise_qa_with_nvidia)
|
| 12 |
+
from src.utils.embeddings import EmbeddingClient
|
| 13 |
+
from src.utils.logger import get_logger
|
| 14 |
+
from src.data import save_memory_summary, save_chat_message, ensure_session, get_recent_memory_summaries, search_memory_summaries_semantic
|
| 15 |
+
|
| 16 |
+
logger = get_logger("MED_HISTORY")
|
| 17 |
+
|
| 18 |
+
def _safe_json(s: str) -> Any:
|
| 19 |
+
try:
|
| 20 |
+
return json.loads(s)
|
| 21 |
+
except Exception:
|
| 22 |
+
# Try to extract a JSON object from text
|
| 23 |
+
start = s.find("{")
|
| 24 |
+
end = s.rfind("}")
|
| 25 |
+
if start != -1 and end != -1 and end > start:
|
| 26 |
+
try:
|
| 27 |
+
return json.loads(s[start:end+1])
|
| 28 |
+
except Exception:
|
| 29 |
+
return {}
|
| 30 |
+
return {}
|
| 31 |
+
|
| 32 |
+
async def files_relevance(question: str, file_summaries: list[dict[str, str]], rotator) -> dict[str, bool]:
|
| 33 |
+
"""
|
| 34 |
+
Ask NVIDIA model to mark each file as relevant (true) or not (false) for the question.
|
| 35 |
+
Returns {filename: bool}
|
| 36 |
+
"""
|
| 37 |
+
sys = "You classify file relevance. Return STRICT JSON only with shape {\"relevance\":[{\"filename\":\"...\",\"relevant\":true|false}]}."
|
| 38 |
+
items = [{"filename": f["filename"], "summary": f.get("summary","")} for f in file_summaries]
|
| 39 |
+
user = f"Question: {question}\n\nFiles:\n{json.dumps(items, ensure_ascii=False)}\n\nReturn JSON only."
|
| 40 |
+
out = await nvidia_chat(sys, user, rotator)
|
| 41 |
+
data = _safe_json(out) or {}
|
| 42 |
+
rels = {}
|
| 43 |
+
for row in data.get("relevance", []):
|
| 44 |
+
fn = row.get("filename")
|
| 45 |
+
rv = row.get("relevant")
|
| 46 |
+
if isinstance(fn, str) and isinstance(rv, bool):
|
| 47 |
+
rels[fn] = rv
|
| 48 |
+
# If parsing failed, default to considering all files possibly relevant
|
| 49 |
+
if not rels and file_summaries:
|
| 50 |
+
rels = {f["filename"]: True for f in file_summaries}
|
| 51 |
+
return rels
|
| 52 |
+
|
| 53 |
+
def _cosine(a: np.ndarray, b: np.ndarray) -> float:
|
| 54 |
+
denom = (np.linalg.norm(a) * np.linalg.norm(b)) or 1.0
|
| 55 |
+
return float(np.dot(a, b) / denom)
|
| 56 |
+
|
| 57 |
+
def _as_text(block: str) -> str:
|
| 58 |
+
return block.strip()
|
| 59 |
+
|
| 60 |
+
async def related_recent_and_semantic_context(user_id: str, question: str, memory, embedder: EmbeddingClient, topk_sem: int = 3) -> tuple[str, str]:
|
| 61 |
+
"""
|
| 62 |
+
Returns (recent_related_text, semantic_related_text).
|
| 63 |
+
- recent_related_text: NVIDIA checks the last 3 summaries for direct relatedness.
|
| 64 |
+
- semantic_related_text: cosine-sim search over the remaining 17 summaries (top-k).
|
| 65 |
+
"""
|
| 66 |
+
recent3 = memory.recent(user_id, 3)
|
| 67 |
+
rest17 = memory.rest(user_id, 3)
|
| 68 |
+
|
| 69 |
+
recent_text = ""
|
| 70 |
+
if recent3:
|
| 71 |
+
sys = "Pick only items that directly relate to the new question. Output the selected items verbatim, no commentary. If none, output nothing."
|
| 72 |
+
numbered = [{"id": i+1, "text": s} for i, s in enumerate(recent3)]
|
| 73 |
+
user = f"Question: {question}\nCandidates:\n{json.dumps(numbered, ensure_ascii=False)}\nSelect any related items and output ONLY their 'text' lines concatenated."
|
| 74 |
+
key = None # We'll let robust_post_json handle rotation via rotator param
|
| 75 |
+
# Semantic over rest17
|
| 76 |
+
sem_text = ""
|
| 77 |
+
if rest17:
|
| 78 |
+
qv = np.array(embedder.embed([question])[0], dtype="float32")
|
| 79 |
+
mats = embedder.embed([_as_text(s) for s in rest17])
|
| 80 |
+
sims = [(_cosine(qv, np.array(v, dtype="float32")), s) for v, s in zip(mats, rest17)]
|
| 81 |
+
sims.sort(key=lambda x: x[0], reverse=True)
|
| 82 |
+
top = [s for (sc, s) in sims[:topk_sem] if sc > 0.15] # small threshold
|
| 83 |
+
if top:
|
| 84 |
+
sem_text = "\n\n".join(top)
|
| 85 |
+
# Return recent empty (to be filled by caller using NVIDIA), and semantic text
|
| 86 |
+
return ("", sem_text)
|
| 87 |
+
|
| 88 |
+
class MedicalHistoryManager:
|
| 89 |
+
"""
|
| 90 |
+
Enhanced medical history manager that works with the new memory system
|
| 91 |
+
"""
|
| 92 |
+
def __init__(self, memory, embedder: EmbeddingClient | None = None):
|
| 93 |
+
self.memory = memory
|
| 94 |
+
self.embedder = embedder
|
| 95 |
+
|
| 96 |
+
async def process_medical_exchange(self, user_id: str, session_id: str, question: str, answer: str, gemini_rotator, nvidia_rotator=None, *, patient_id: str | None = None, doctor_id: str | None = None, session_title: str | None = None) -> str:
|
| 97 |
+
"""
|
| 98 |
+
Process a medical Q&A exchange and store it in memory and MongoDB
|
| 99 |
+
"""
|
| 100 |
+
try:
|
| 101 |
+
# Check if we have valid API keys
|
| 102 |
+
if not gemini_rotator or not gemini_rotator.get_key() or gemini_rotator.get_key() == "":
|
| 103 |
+
logger.info("No valid Gemini API keys available, using fallback summary")
|
| 104 |
+
summary = f"q: {question}\na: {answer}"
|
| 105 |
+
else:
|
| 106 |
+
# Try to create summary using Gemini (preferred) or NVIDIA as fallback
|
| 107 |
+
try:
|
| 108 |
+
# First try Gemini
|
| 109 |
+
summary = await summarise_qa_with_gemini(question, answer, gemini_rotator)
|
| 110 |
+
if not summary or summary.strip() == "":
|
| 111 |
+
# Fallback to NVIDIA if Gemini fails
|
| 112 |
+
if nvidia_rotator and nvidia_rotator.get_key():
|
| 113 |
+
summary = await summarise_qa_with_nvidia(question, answer, nvidia_rotator)
|
| 114 |
+
if not summary or summary.strip() == "":
|
| 115 |
+
summary = f"q: {question}\na: {answer}"
|
| 116 |
+
else:
|
| 117 |
+
summary = f"q: {question}\na: {answer}"
|
| 118 |
+
except Exception as e:
|
| 119 |
+
logger.warning(f"Failed to create AI summary: {e}")
|
| 120 |
+
summary = f"q: {question}\na: {answer}"
|
| 121 |
+
|
| 122 |
+
# Short-term cache under patient_id when available
|
| 123 |
+
cache_key = patient_id or user_id
|
| 124 |
+
self.memory.add(cache_key, summary)
|
| 125 |
+
|
| 126 |
+
# Add to session history in cache
|
| 127 |
+
self.memory.add_message_to_session(session_id, "user", question)
|
| 128 |
+
self.memory.add_message_to_session(session_id, "assistant", answer)
|
| 129 |
+
|
| 130 |
+
# Persist to MongoDB with patient/doctor context
|
| 131 |
+
if patient_id and doctor_id:
|
| 132 |
+
ensure_session(session_id=session_id, patient_id=patient_id, doctor_id=doctor_id, title=session_title or "New Chat", last_activity=datetime.now(timezone.utc))
|
| 133 |
+
save_chat_message(session_id=session_id, patient_id=patient_id, doctor_id=doctor_id, role="user", content=question)
|
| 134 |
+
save_chat_message(session_id=session_id, patient_id=patient_id, doctor_id=doctor_id, role="assistant", content=answer)
|
| 135 |
+
|
| 136 |
+
# Generate embedding for semantic search
|
| 137 |
+
embedding = None
|
| 138 |
+
if self.embedder:
|
| 139 |
+
try:
|
| 140 |
+
embedding = self.embedder.embed([summary])[0]
|
| 141 |
+
except Exception as e:
|
| 142 |
+
logger.warning(f"Failed to generate embedding for summary: {e}")
|
| 143 |
+
|
| 144 |
+
save_memory_summary(patient_id=patient_id, doctor_id=doctor_id, summary=summary, embedding=embedding)
|
| 145 |
+
|
| 146 |
+
# Update session title if it's the first message
|
| 147 |
+
session = self.memory.get_session(session_id)
|
| 148 |
+
if session and len(session.messages) == 2: # Just user + assistant
|
| 149 |
+
# Generate a title using NVIDIA API if available
|
| 150 |
+
try:
|
| 151 |
+
from src.services.summariser import summarise_title_with_nvidia
|
| 152 |
+
title = await summarise_title_with_nvidia(question, nvidia_rotator, max_words=5)
|
| 153 |
+
if not title or title.strip() == "":
|
| 154 |
+
title = question[:50] + ("..." if len(question) > 50 else "")
|
| 155 |
+
except Exception as e:
|
| 156 |
+
logger.warning(f"Failed to generate title with NVIDIA: {e}")
|
| 157 |
+
title = question[:50] + ("..." if len(question) > 50 else "")
|
| 158 |
+
|
| 159 |
+
self.memory.update_session_title(session_id, title)
|
| 160 |
+
|
| 161 |
+
# Also update the session in MongoDB
|
| 162 |
+
if patient_id and doctor_id:
|
| 163 |
+
ensure_session(session_id=session_id, patient_id=patient_id, doctor_id=doctor_id, title=title, last_activity=datetime.now(timezone.utc))
|
| 164 |
+
|
| 165 |
+
return summary
|
| 166 |
+
|
| 167 |
+
except Exception as e:
|
| 168 |
+
logger.error(f"Error processing medical exchange: {e}")
|
| 169 |
+
# Fallback: store without summary
|
| 170 |
+
summary = f"q: {question}\na: {answer}"
|
| 171 |
+
cache_key = patient_id or user_id
|
| 172 |
+
self.memory.add(cache_key, summary)
|
| 173 |
+
self.memory.add_message_to_session(session_id, "user", question)
|
| 174 |
+
self.memory.add_message_to_session(session_id, "assistant", answer)
|
| 175 |
+
return summary
|
| 176 |
+
|
| 177 |
+
def get_conversation_context(self, user_id: str, session_id: str, question: str, *, patient_id: str | None = None) -> str:
|
| 178 |
+
"""
|
| 179 |
+
Get relevant conversation context combining short-term cache (3) and long-term Mongo (20)
|
| 180 |
+
"""
|
| 181 |
+
# Short-term summaries
|
| 182 |
+
cache_key = patient_id or user_id
|
| 183 |
+
recent_qa = self.memory.recent(cache_key, 3)
|
| 184 |
+
|
| 185 |
+
# Long-term summaries from Mongo (exclude ones already likely in cache by time order)
|
| 186 |
+
long_term = []
|
| 187 |
+
if patient_id:
|
| 188 |
+
try:
|
| 189 |
+
long_term = get_recent_memory_summaries(patient_id, limit=20)
|
| 190 |
+
except Exception as e:
|
| 191 |
+
logger.warning(f"Failed to fetch long-term memory: {e}")
|
| 192 |
+
|
| 193 |
+
# Get current session messages for context
|
| 194 |
+
session = self.memory.get_session(session_id)
|
| 195 |
+
session_context = ""
|
| 196 |
+
if session:
|
| 197 |
+
recent_messages = session.get_messages(10)
|
| 198 |
+
session_context = "\n".join([f"{msg['role']}: {msg['content']}" for msg in recent_messages])
|
| 199 |
+
|
| 200 |
+
# Combine context
|
| 201 |
+
context_parts = []
|
| 202 |
+
combined = []
|
| 203 |
+
if long_term:
|
| 204 |
+
combined.extend(long_term[::-1]) # oldest to newest within limit
|
| 205 |
+
if recent_qa:
|
| 206 |
+
combined.extend(recent_qa[::-1])
|
| 207 |
+
if combined:
|
| 208 |
+
context_parts.append("Recent medical context:\n" + "\n".join(combined[-20:]))
|
| 209 |
+
if session_context:
|
| 210 |
+
context_parts.append("Current conversation:\n" + session_context)
|
| 211 |
+
|
| 212 |
+
return "\n\n".join(context_parts) if context_parts else ""
|
| 213 |
+
|
| 214 |
+
async def get_enhanced_conversation_context(self, user_id: str, session_id: str, question: str, nvidia_rotator, *, patient_id: str | None = None) -> str:
|
| 215 |
+
"""
|
| 216 |
+
Enhanced context retrieval combining STM (3) + LTM semantic search (2) with NVIDIA reasoning.
|
| 217 |
+
Returns context that NVIDIA model can use to decide between STM and LTM information.
|
| 218 |
+
"""
|
| 219 |
+
cache_key = patient_id or user_id
|
| 220 |
+
|
| 221 |
+
# Get STM summaries (recent 3)
|
| 222 |
+
recent_qa = self.memory.recent(cache_key, 3)
|
| 223 |
+
|
| 224 |
+
# Get LTM semantic matches (top 2 most similar)
|
| 225 |
+
ltm_semantic = []
|
| 226 |
+
if patient_id and self.embedder:
|
| 227 |
+
try:
|
| 228 |
+
query_embedding = self.embedder.embed([question])[0]
|
| 229 |
+
ltm_results = search_memory_summaries_semantic(
|
| 230 |
+
patient_id=patient_id,
|
| 231 |
+
query_embedding=query_embedding,
|
| 232 |
+
limit=2,
|
| 233 |
+
similarity_threshold=0.5 # >= 50% semantic similarity
|
| 234 |
+
)
|
| 235 |
+
ltm_semantic = [result["summary"] for result in ltm_results]
|
| 236 |
+
except Exception as e:
|
| 237 |
+
logger.warning(f"Failed to perform LTM semantic search: {e}")
|
| 238 |
+
|
| 239 |
+
# Get current session messages for context
|
| 240 |
+
session = self.memory.get_session(session_id)
|
| 241 |
+
session_context = ""
|
| 242 |
+
if session:
|
| 243 |
+
recent_messages = session.get_messages(10)
|
| 244 |
+
session_context = "\n".join([f"{msg['role']}: {msg['content']}" for msg in recent_messages])
|
| 245 |
+
|
| 246 |
+
# Use NVIDIA to reason about STM relevance
|
| 247 |
+
relevant_stm = []
|
| 248 |
+
if recent_qa and nvidia_rotator:
|
| 249 |
+
try:
|
| 250 |
+
sys = "You are a medical AI assistant. Select only the most relevant recent medical context that directly relates to the new question. Return the selected items verbatim, no commentary. If none are relevant, return nothing."
|
| 251 |
+
numbered = [{"id": i+1, "text": s} for i, s in enumerate(recent_qa)]
|
| 252 |
+
user = f"Question: {question}\n\nRecent medical context (last 3 exchanges):\n{json.dumps(numbered, ensure_ascii=False)}\n\nSelect any relevant items and output ONLY their 'text' lines concatenated."
|
| 253 |
+
relevant_stm_text = await nvidia_chat(sys, user, nvidia_rotator)
|
| 254 |
+
if relevant_stm_text and relevant_stm_text.strip():
|
| 255 |
+
relevant_stm = [relevant_stm_text.strip()]
|
| 256 |
+
except Exception as e:
|
| 257 |
+
logger.warning(f"Failed to get NVIDIA STM reasoning: {e}")
|
| 258 |
+
# Fallback to all recent QA if NVIDIA fails
|
| 259 |
+
relevant_stm = recent_qa
|
| 260 |
+
else:
|
| 261 |
+
relevant_stm = recent_qa
|
| 262 |
+
|
| 263 |
+
# Combine all relevant context
|
| 264 |
+
context_parts = []
|
| 265 |
+
|
| 266 |
+
# Add STM context
|
| 267 |
+
if relevant_stm:
|
| 268 |
+
context_parts.append("Recent relevant medical context (STM):\n" + "\n".join(relevant_stm))
|
| 269 |
+
|
| 270 |
+
# Add LTM semantic context
|
| 271 |
+
if ltm_semantic:
|
| 272 |
+
context_parts.append("Semantically relevant medical history (LTM):\n" + "\n".join(ltm_semantic))
|
| 273 |
+
|
| 274 |
+
# Add current session context
|
| 275 |
+
if session_context:
|
| 276 |
+
context_parts.append("Current conversation:\n" + session_context)
|
| 277 |
+
|
| 278 |
+
return "\n\n".join(context_parts) if context_parts else ""
|
| 279 |
+
|
| 280 |
+
def get_user_medical_history(self, user_id: str, limit: int = 20) -> list[str]:
|
| 281 |
+
"""
|
| 282 |
+
Get user's medical history (QA summaries)
|
| 283 |
+
"""
|
| 284 |
+
return self.memory.all(user_id)[-limit:]
|
| 285 |
+
|
| 286 |
+
def search_medical_context(self, user_id: str, query: str, top_k: int = 5) -> list[str]:
|
| 287 |
+
"""
|
| 288 |
+
Search through user's medical context for relevant information
|
| 289 |
+
"""
|
| 290 |
+
if not self.embedder:
|
| 291 |
+
# Fallback to simple text search
|
| 292 |
+
all_context = self.memory.all(user_id)
|
| 293 |
+
query_lower = query.lower()
|
| 294 |
+
relevant = [ctx for ctx in all_context if query_lower in ctx.lower()]
|
| 295 |
+
return relevant[:top_k]
|
| 296 |
+
|
| 297 |
+
try:
|
| 298 |
+
# Semantic search using embeddings
|
| 299 |
+
query_embedding = np.array(self.embedder.embed([query])[0], dtype="float32")
|
| 300 |
+
all_context = self.memory.all(user_id)
|
| 301 |
+
|
| 302 |
+
if not all_context:
|
| 303 |
+
return []
|
| 304 |
+
|
| 305 |
+
context_embeddings = self.embedder.embed(all_context)
|
| 306 |
+
similarities = []
|
| 307 |
+
|
| 308 |
+
for i, ctx_emb in enumerate(context_embeddings):
|
| 309 |
+
sim = _cosine(query_embedding, np.array(ctx_emb, dtype="float32"))
|
| 310 |
+
similarities.append((sim, all_context[i]))
|
| 311 |
+
|
| 312 |
+
# Sort by similarity and return top-k
|
| 313 |
+
similarities.sort(key=lambda x: x[0], reverse=True)
|
| 314 |
+
return [ctx for sim, ctx in similarities[:top_k] if sim > 0.1]
|
| 315 |
+
|
| 316 |
+
except Exception as e:
|
| 317 |
+
logger.error(f"Error in semantic search: {e}")
|
| 318 |
+
# Fallback to simple search
|
| 319 |
+
all_context = self.memory.all(user_id)
|
| 320 |
+
query_lower = query.lower()
|
| 321 |
+
relevant = [ctx for ctx in all_context if query_lower in ctx.lower()]
|
| 322 |
+
return relevant[:top_k]
|
src/core/memory/memory.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# core/memory/memory.py
|
| 2 |
+
|
| 3 |
+
import uuid
|
| 4 |
+
from collections import defaultdict, deque
|
| 5 |
+
from datetime import datetime, timezone
|
| 6 |
+
from typing import Any
|
| 7 |
+
|
| 8 |
+
from src.utils.logger import get_logger
|
| 9 |
+
|
| 10 |
+
logger = get_logger("MEMORY")
|
| 11 |
+
|
| 12 |
+
class ChatSession:
|
| 13 |
+
"""Represents a chat session with a user"""
|
| 14 |
+
def __init__(self, session_id: str, user_id: str, title: str = "New Chat"):
|
| 15 |
+
self.session_id = session_id
|
| 16 |
+
self.user_id = user_id
|
| 17 |
+
self.title = title
|
| 18 |
+
self.created_at = datetime.now(timezone.utc)
|
| 19 |
+
self.last_activity = datetime.now(timezone.utc)
|
| 20 |
+
self.messages: list[dict[str, Any]] = []
|
| 21 |
+
|
| 22 |
+
def add_message(self, role: str, content: str, metadata: dict | None = None):
|
| 23 |
+
"""Add a message to the session"""
|
| 24 |
+
message = {
|
| 25 |
+
"id": str(uuid.uuid4()),
|
| 26 |
+
"role": role, # "user" or "assistant"
|
| 27 |
+
"content": content,
|
| 28 |
+
"timestamp": datetime.now(timezone.utc),
|
| 29 |
+
"metadata": metadata or {}
|
| 30 |
+
}
|
| 31 |
+
self.messages.append(message)
|
| 32 |
+
self.last_activity = datetime.now(timezone.utc)
|
| 33 |
+
|
| 34 |
+
def get_messages(self, limit: int | None = None) -> list[dict[str, Any]]:
|
| 35 |
+
"""Get messages from the session, optionally limited"""
|
| 36 |
+
if limit is None:
|
| 37 |
+
return self.messages
|
| 38 |
+
return self.messages[-limit:]
|
| 39 |
+
|
| 40 |
+
def update_title(self, title: str):
|
| 41 |
+
"""Update the session title"""
|
| 42 |
+
self.title = title
|
| 43 |
+
self.last_activity = datetime.now(timezone.utc)
|
| 44 |
+
|
| 45 |
+
class UserProfile:
|
| 46 |
+
"""Represents a user profile with multiple chat sessions"""
|
| 47 |
+
def __init__(self, user_id: str, name: str = "Anonymous"):
|
| 48 |
+
self.user_id = user_id
|
| 49 |
+
self.name = name
|
| 50 |
+
self.created_at = datetime.now(timezone.utc)
|
| 51 |
+
self.last_seen = datetime.now(timezone.utc)
|
| 52 |
+
self.preferences: dict[str, Any] = {}
|
| 53 |
+
|
| 54 |
+
def update_activity(self):
|
| 55 |
+
"""Update last seen timestamp"""
|
| 56 |
+
self.last_seen = datetime.now(timezone.utc)
|
| 57 |
+
|
| 58 |
+
def set_preference(self, key: str, value: Any):
|
| 59 |
+
"""Set a user preference"""
|
| 60 |
+
self.preferences[key] = value
|
| 61 |
+
|
| 62 |
+
@property
|
| 63 |
+
def role(self) -> str:
|
| 64 |
+
"""Get user role from preferences"""
|
| 65 |
+
return self.preferences.get("role", "Unknown")
|
| 66 |
+
|
| 67 |
+
class MemoryLRU:
|
| 68 |
+
"""
|
| 69 |
+
Enhanced LRU-like memory system supporting:
|
| 70 |
+
- Multiple users with profiles
|
| 71 |
+
- Multiple chat sessions per user
|
| 72 |
+
- Chat history and continuity
|
| 73 |
+
- Medical context summaries
|
| 74 |
+
"""
|
| 75 |
+
def __init__(self, capacity: int = 20, max_sessions_per_user: int = 10):
|
| 76 |
+
self.capacity = capacity
|
| 77 |
+
self.max_sessions_per_user = max_sessions_per_user
|
| 78 |
+
|
| 79 |
+
# User profiles and sessions
|
| 80 |
+
self._users: dict[str, UserProfile] = {}
|
| 81 |
+
self._sessions: dict[str, ChatSession] = {}
|
| 82 |
+
self._user_sessions: dict[str, list[str]] = defaultdict(list)
|
| 83 |
+
|
| 84 |
+
# Medical context summaries (QA pairs)
|
| 85 |
+
self._qa_store: dict[str, deque] = defaultdict(lambda: deque(maxlen=self.capacity))
|
| 86 |
+
|
| 87 |
+
def create_user(self, user_id: str, name: str = "Anonymous") -> UserProfile:
|
| 88 |
+
"""Create a new user profile"""
|
| 89 |
+
if user_id not in self._users:
|
| 90 |
+
user = UserProfile(user_id, name)
|
| 91 |
+
self._users[user_id] = user
|
| 92 |
+
return self._users[user_id]
|
| 93 |
+
|
| 94 |
+
def get_user(self, user_id: str) -> UserProfile | None:
|
| 95 |
+
"""Get user profile by ID"""
|
| 96 |
+
user = self._users.get(user_id)
|
| 97 |
+
if user:
|
| 98 |
+
user.update_activity()
|
| 99 |
+
return user
|
| 100 |
+
|
| 101 |
+
def create_session(self, user_id: str, title: str = "New Chat") -> str:
|
| 102 |
+
"""Create a new chat session for a user"""
|
| 103 |
+
# Ensure user exists
|
| 104 |
+
if user_id not in self._users:
|
| 105 |
+
self.create_user(user_id)
|
| 106 |
+
|
| 107 |
+
# Create session
|
| 108 |
+
session_id = str(uuid.uuid4())
|
| 109 |
+
session = ChatSession(session_id, user_id, title)
|
| 110 |
+
self._sessions[session_id] = session
|
| 111 |
+
|
| 112 |
+
# Add to user's session list
|
| 113 |
+
user_sessions = self._user_sessions[user_id]
|
| 114 |
+
user_sessions.append(session_id)
|
| 115 |
+
|
| 116 |
+
# Enforce max sessions per user
|
| 117 |
+
if len(user_sessions) > self.max_sessions_per_user:
|
| 118 |
+
oldest_session = user_sessions.pop(0)
|
| 119 |
+
if oldest_session in self._sessions:
|
| 120 |
+
del self._sessions[oldest_session]
|
| 121 |
+
|
| 122 |
+
return session_id
|
| 123 |
+
|
| 124 |
+
def get_session(self, session_id: str) -> ChatSession | None:
|
| 125 |
+
"""Get a chat session by ID"""
|
| 126 |
+
return self._sessions.get(session_id)
|
| 127 |
+
|
| 128 |
+
def get_user_sessions(self, user_id: str) -> list[ChatSession]:
|
| 129 |
+
"""Get all sessions for a user"""
|
| 130 |
+
session_ids = self._user_sessions.get(user_id, [])
|
| 131 |
+
sessions = []
|
| 132 |
+
for sid in session_ids:
|
| 133 |
+
if sid in self._sessions:
|
| 134 |
+
sessions.append(self._sessions[sid])
|
| 135 |
+
# Sort by last activity (most recent first)
|
| 136 |
+
sessions.sort(key=lambda x: x.last_activity, reverse=True)
|
| 137 |
+
return sessions
|
| 138 |
+
|
| 139 |
+
def add_message_to_session(self, session_id: str, role: str, content: str, metadata: dict | None = None):
|
| 140 |
+
"""Add a message to a specific session"""
|
| 141 |
+
session = self._sessions.get(session_id)
|
| 142 |
+
if session:
|
| 143 |
+
session.add_message(role, content, metadata)
|
| 144 |
+
|
| 145 |
+
def update_session_title(self, session_id: str, title: str):
|
| 146 |
+
"""Update the title of a session"""
|
| 147 |
+
session = self._sessions.get(session_id)
|
| 148 |
+
if session:
|
| 149 |
+
session.update_title(title)
|
| 150 |
+
|
| 151 |
+
def delete_session(self, session_id: str):
|
| 152 |
+
"""Delete a chat session"""
|
| 153 |
+
if session_id in self._sessions:
|
| 154 |
+
session = self._sessions[session_id]
|
| 155 |
+
user_id = session.user_id
|
| 156 |
+
|
| 157 |
+
# Remove from user's session list
|
| 158 |
+
if user_id in self._user_sessions:
|
| 159 |
+
self._user_sessions[user_id] = [s for s in self._user_sessions[user_id] if s != session_id]
|
| 160 |
+
|
| 161 |
+
# Delete session
|
| 162 |
+
del self._sessions[session_id]
|
| 163 |
+
|
| 164 |
+
def add(self, user_id: str, qa_summary: str):
|
| 165 |
+
"""Add a QA summary to the medical context store"""
|
| 166 |
+
self._qa_store[user_id].append(qa_summary)
|
| 167 |
+
|
| 168 |
+
def recent(self, user_id: str, n: int = 3) -> list[str]:
|
| 169 |
+
"""Get recent QA summaries for medical context"""
|
| 170 |
+
d = self._qa_store[user_id]
|
| 171 |
+
if not d:
|
| 172 |
+
return []
|
| 173 |
+
return list(d)[-n:][::-1]
|
| 174 |
+
|
| 175 |
+
def rest(self, user_id: str, skip_n: int = 3) -> list[str]:
|
| 176 |
+
"""Get older QA summaries for medical context"""
|
| 177 |
+
d = self._qa_store[user_id]
|
| 178 |
+
if not d:
|
| 179 |
+
return []
|
| 180 |
+
return list(d)[:-skip_n] if len(d) > skip_n else []
|
| 181 |
+
|
| 182 |
+
def all(self, user_id: str) -> list[str]:
|
| 183 |
+
"""Get all QA summaries for medical context"""
|
| 184 |
+
return list(self._qa_store[user_id])
|
| 185 |
+
|
| 186 |
+
def clear(self, user_id: str) -> None:
|
| 187 |
+
"""Clear all cached summaries for the given user"""
|
| 188 |
+
if user_id in self._qa_store:
|
| 189 |
+
self._qa_store[user_id].clear()
|
| 190 |
+
|
| 191 |
+
def get_medical_context(self, user_id: str, session_id: str, question: str) -> str:
|
| 192 |
+
"""Get relevant medical context for a question"""
|
| 193 |
+
# Get recent QA summaries
|
| 194 |
+
recent_qa = self.recent(user_id, 5)
|
| 195 |
+
|
| 196 |
+
# Get current session messages for context
|
| 197 |
+
session = self.get_session(session_id)
|
| 198 |
+
session_context = ""
|
| 199 |
+
if session:
|
| 200 |
+
recent_messages = session.get_messages(10)
|
| 201 |
+
session_context = "\n".join([f"{msg['role']}: {msg['content']}" for msg in recent_messages])
|
| 202 |
+
|
| 203 |
+
# Combine context
|
| 204 |
+
context_parts = []
|
| 205 |
+
if recent_qa:
|
| 206 |
+
context_parts.append("Recent medical context:\n" + "\n".join(recent_qa))
|
| 207 |
+
if session_context:
|
| 208 |
+
context_parts.append("Current conversation:\n" + session_context)
|
| 209 |
+
|
| 210 |
+
return "\n\n".join(context_parts) if context_parts else ""
|
src/core/state.py
CHANGED
|
@@ -30,6 +30,8 @@ class MedicalState:
|
|
| 30 |
"""Initializes all core application components."""
|
| 31 |
self.memory_system = MemoryLRU(max_sessions_per_user=20)
|
| 32 |
self.embedding_client = EmbeddingClient(model_name="all-MiniLM-L6-v2", dimension=384)
|
|
|
|
|
|
|
| 33 |
self.history_manager = MedicalHistoryManager(self.memory_system, self.embedding_client)
|
| 34 |
self.gemini_rotator = APIKeyRotator("GEMINI_API_", max_slots=5)
|
| 35 |
self.nvidia_rotator = APIKeyRotator("NVIDIA_API_", max_slots=5)
|
|
|
|
| 30 |
"""Initializes all core application components."""
|
| 31 |
self.memory_system = MemoryLRU(max_sessions_per_user=20)
|
| 32 |
self.embedding_client = EmbeddingClient(model_name="all-MiniLM-L6-v2", dimension=384)
|
| 33 |
+
# Keep only 3 short-term summaries/messages in cache
|
| 34 |
+
#self.memory_system = MemoryLRU(capacity=3, max_sessions_per_user=20)
|
| 35 |
self.history_manager = MedicalHistoryManager(self.memory_system, self.embedding_client)
|
| 36 |
self.gemini_rotator = APIKeyRotator("GEMINI_API_", max_slots=5)
|
| 37 |
self.nvidia_rotator = APIKeyRotator("NVIDIA_API_", max_slots=5)
|
src/data/__init__.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# data/__init__.py
|
| 2 |
+
"""
|
| 3 |
+
Data layer for MongoDB operations.
|
| 4 |
+
Organized into specialized modules for different data types.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from .connection import get_database, get_collection, close_connection
|
| 8 |
+
from .session import *
|
| 9 |
+
from .user import *
|
| 10 |
+
from .message import *
|
| 11 |
+
from .patient import *
|
| 12 |
+
from .medical import *
|
| 13 |
+
from .utils import create_index, backup_collection
|
| 14 |
+
|
| 15 |
+
__all__ = [
|
| 16 |
+
# Connection
|
| 17 |
+
'get_database',
|
| 18 |
+
'get_collection',
|
| 19 |
+
'close_connection',
|
| 20 |
+
# Session functions
|
| 21 |
+
'create_chat_session',
|
| 22 |
+
'get_user_sessions',
|
| 23 |
+
'ensure_session',
|
| 24 |
+
'list_patient_sessions',
|
| 25 |
+
'delete_session',
|
| 26 |
+
'delete_session_messages',
|
| 27 |
+
'delete_old_sessions',
|
| 28 |
+
# User functions
|
| 29 |
+
'create_account',
|
| 30 |
+
'update_account',
|
| 31 |
+
'get_account_frame',
|
| 32 |
+
'create_doctor',
|
| 33 |
+
'get_doctor_by_name',
|
| 34 |
+
'search_doctors',
|
| 35 |
+
'get_all_doctors',
|
| 36 |
+
# Message functions
|
| 37 |
+
'add_message',
|
| 38 |
+
'get_session_messages',
|
| 39 |
+
'save_chat_message',
|
| 40 |
+
'list_session_messages',
|
| 41 |
+
# Patient functions
|
| 42 |
+
'get_patient_by_id',
|
| 43 |
+
'create_patient',
|
| 44 |
+
'update_patient_profile',
|
| 45 |
+
'search_patients',
|
| 46 |
+
# Medical functions
|
| 47 |
+
'create_medical_record',
|
| 48 |
+
'get_user_medical_records',
|
| 49 |
+
'save_memory_summary',
|
| 50 |
+
'get_recent_memory_summaries',
|
| 51 |
+
'search_memory_summaries_semantic',
|
| 52 |
+
# Utility functions
|
| 53 |
+
'create_index',
|
| 54 |
+
'backup_collection',
|
| 55 |
+
]
|
src/data/connection.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# data/connection.py
|
| 2 |
+
"""
|
| 3 |
+
MongoDB connection management and base database operations.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import os
|
| 7 |
+
from typing import Any
|
| 8 |
+
|
| 9 |
+
from pymongo import MongoClient
|
| 10 |
+
from pymongo.collection import Collection
|
| 11 |
+
from pymongo.database import Database
|
| 12 |
+
|
| 13 |
+
from src.utils.logger import get_logger
|
| 14 |
+
|
| 15 |
+
logger = get_logger("MONGO")
|
| 16 |
+
|
| 17 |
+
# Global client instance
|
| 18 |
+
_mongo_client: MongoClient | None = None
|
| 19 |
+
|
| 20 |
+
# Collection Names
|
| 21 |
+
ACCOUNTS_COLLECTION = "accounts"
|
| 22 |
+
CHAT_SESSIONS_COLLECTION = "chat_sessions"
|
| 23 |
+
CHAT_MESSAGES_COLLECTION = "chat_messages"
|
| 24 |
+
MEDICAL_RECORDS_COLLECTION = "medical_records"
|
| 25 |
+
MEDICAL_MEMORY_COLLECTION = "medical_memory"
|
| 26 |
+
PATIENTS_COLLECTION = "patients"
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def get_database() -> Database:
|
| 30 |
+
"""Get database instance with connection management"""
|
| 31 |
+
global _mongo_client
|
| 32 |
+
if _mongo_client is None:
|
| 33 |
+
CONNECTION_STRING = os.getenv("MONGO_USER", "mongodb://127.0.0.1:27017/") # fall back to local host if no user is provided
|
| 34 |
+
try:
|
| 35 |
+
logger.info("Initializing MongoDB connection")
|
| 36 |
+
_mongo_client = MongoClient(CONNECTION_STRING)
|
| 37 |
+
except Exception as e:
|
| 38 |
+
logger.error(f"Failed to connect to MongoDB: {str(e)}")
|
| 39 |
+
# Pass the error down, code that calls this function should handle it
|
| 40 |
+
raise e
|
| 41 |
+
db_name = os.getenv("USER_DB", "medicaldiagnosissystem")
|
| 42 |
+
return _mongo_client[db_name]
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def close_connection():
|
| 46 |
+
"""Close MongoDB connection"""
|
| 47 |
+
global _mongo_client
|
| 48 |
+
if _mongo_client is not None:
|
| 49 |
+
# Close the connection and reset the client
|
| 50 |
+
_mongo_client.close()
|
| 51 |
+
_mongo_client = None
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def get_collection(name: str, /) -> Collection:
|
| 55 |
+
"""Get a MongoDB collection by name"""
|
| 56 |
+
db = get_database()
|
| 57 |
+
return db.get_collection(name)
|
src/data/medical/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# data/medical/__init__.py
|
| 2 |
+
"""
|
| 3 |
+
Medical records and memory management operations for MongoDB.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from .operations import (
|
| 7 |
+
create_medical_record,
|
| 8 |
+
get_user_medical_records,
|
| 9 |
+
save_memory_summary,
|
| 10 |
+
get_recent_memory_summaries,
|
| 11 |
+
search_memory_summaries_semantic,
|
| 12 |
+
)
|
| 13 |
+
|
| 14 |
+
__all__ = [
|
| 15 |
+
'create_medical_record',
|
| 16 |
+
'get_user_medical_records',
|
| 17 |
+
'save_memory_summary',
|
| 18 |
+
'get_recent_memory_summaries',
|
| 19 |
+
'search_memory_summaries_semantic',
|
| 20 |
+
]
|
src/data/medical/operations.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# data/medical/operations.py
|
| 2 |
+
"""
|
| 3 |
+
Medical records and memory management operations for MongoDB.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from datetime import datetime, timezone
|
| 7 |
+
from typing import Any
|
| 8 |
+
|
| 9 |
+
from pymongo import ASCENDING, DESCENDING
|
| 10 |
+
|
| 11 |
+
from ..connection import get_collection, MEDICAL_RECORDS_COLLECTION, MEDICAL_MEMORY_COLLECTION
|
| 12 |
+
from src.utils.logger import get_logger
|
| 13 |
+
|
| 14 |
+
logger = get_logger("MEDICAL_OPS")
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def create_medical_record(
|
| 18 |
+
record_data: dict[str, Any],
|
| 19 |
+
/,
|
| 20 |
+
*,
|
| 21 |
+
collection_name: str = MEDICAL_RECORDS_COLLECTION
|
| 22 |
+
) -> str:
|
| 23 |
+
"""Create a new medical record"""
|
| 24 |
+
collection = get_collection(collection_name)
|
| 25 |
+
now = datetime.now(timezone.utc)
|
| 26 |
+
record_data["created_at"] = now
|
| 27 |
+
record_data["updated_at"] = now
|
| 28 |
+
result = collection.insert_one(record_data)
|
| 29 |
+
return str(result.inserted_id)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def get_user_medical_records(
|
| 33 |
+
user_id: str,
|
| 34 |
+
/,
|
| 35 |
+
*,
|
| 36 |
+
collection_name: str = MEDICAL_RECORDS_COLLECTION
|
| 37 |
+
) -> list[dict[str, Any]]:
|
| 38 |
+
"""Get medical records for a specific user"""
|
| 39 |
+
collection = get_collection(collection_name)
|
| 40 |
+
return list(collection.find({"user_id": user_id}).sort("created_at", ASCENDING))
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def save_memory_summary(
|
| 44 |
+
*,
|
| 45 |
+
patient_id: str,
|
| 46 |
+
doctor_id: str,
|
| 47 |
+
summary: str,
|
| 48 |
+
embedding: list[float] | None = None,
|
| 49 |
+
created_at: datetime | None = None,
|
| 50 |
+
collection_name: str = MEDICAL_MEMORY_COLLECTION
|
| 51 |
+
) -> str:
|
| 52 |
+
collection = get_collection(collection_name)
|
| 53 |
+
ts = created_at or datetime.now(timezone.utc)
|
| 54 |
+
doc = {
|
| 55 |
+
"patient_id": patient_id,
|
| 56 |
+
"doctor_id": doctor_id,
|
| 57 |
+
"summary": summary,
|
| 58 |
+
"created_at": ts
|
| 59 |
+
}
|
| 60 |
+
if embedding is not None:
|
| 61 |
+
doc["embedding"] = embedding
|
| 62 |
+
result = collection.insert_one(doc)
|
| 63 |
+
return str(result.inserted_id)
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def get_recent_memory_summaries(
|
| 67 |
+
patient_id: str,
|
| 68 |
+
/,
|
| 69 |
+
*,
|
| 70 |
+
limit: int = 20,
|
| 71 |
+
collection_name: str = MEDICAL_MEMORY_COLLECTION
|
| 72 |
+
) -> list[str]:
|
| 73 |
+
collection = get_collection(collection_name)
|
| 74 |
+
docs = list(collection.find({"patient_id": patient_id}).sort("created_at", DESCENDING).limit(limit))
|
| 75 |
+
return [d.get("summary", "") for d in docs]
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def search_memory_summaries_semantic(
|
| 79 |
+
patient_id: str,
|
| 80 |
+
query_embedding: list[float],
|
| 81 |
+
/,
|
| 82 |
+
*,
|
| 83 |
+
limit: int = 5,
|
| 84 |
+
similarity_threshold: float = 0.5, # >= 50% semantic similarity
|
| 85 |
+
collection_name: str = MEDICAL_MEMORY_COLLECTION
|
| 86 |
+
) -> list[dict[str, Any]]:
|
| 87 |
+
"""
|
| 88 |
+
Search memory summaries using semantic similarity with embeddings.
|
| 89 |
+
Returns list of {summary, similarity_score, created_at} sorted by similarity.
|
| 90 |
+
"""
|
| 91 |
+
collection = get_collection(collection_name)
|
| 92 |
+
|
| 93 |
+
# Get all summaries with embeddings for this patient
|
| 94 |
+
docs = list(collection.find({
|
| 95 |
+
"patient_id": patient_id,
|
| 96 |
+
"embedding": {"$exists": True}
|
| 97 |
+
}))
|
| 98 |
+
|
| 99 |
+
if not docs:
|
| 100 |
+
return []
|
| 101 |
+
|
| 102 |
+
# Calculate similarities
|
| 103 |
+
import numpy as np
|
| 104 |
+
query_vec = np.array(query_embedding, dtype="float32")
|
| 105 |
+
results = []
|
| 106 |
+
|
| 107 |
+
for doc in docs:
|
| 108 |
+
embedding = doc.get("embedding")
|
| 109 |
+
if not embedding:
|
| 110 |
+
continue
|
| 111 |
+
|
| 112 |
+
# Calculate cosine similarity
|
| 113 |
+
doc_vec = np.array(embedding, dtype="float32")
|
| 114 |
+
dot_product = np.dot(query_vec, doc_vec)
|
| 115 |
+
norm_query = np.linalg.norm(query_vec)
|
| 116 |
+
norm_doc = np.linalg.norm(doc_vec)
|
| 117 |
+
|
| 118 |
+
if norm_query == 0 or norm_doc == 0:
|
| 119 |
+
similarity = 0.0
|
| 120 |
+
else:
|
| 121 |
+
similarity = float(dot_product / (norm_query * norm_doc))
|
| 122 |
+
|
| 123 |
+
if similarity >= similarity_threshold:
|
| 124 |
+
results.append({
|
| 125 |
+
"summary": doc.get("summary", ""),
|
| 126 |
+
"similarity_score": similarity,
|
| 127 |
+
"created_at": doc.get("created_at"),
|
| 128 |
+
"session_id": doc.get("session_id") # if we add this field later
|
| 129 |
+
})
|
| 130 |
+
|
| 131 |
+
# Sort by similarity (highest first) and return top results
|
| 132 |
+
results.sort(key=lambda x: x["similarity_score"], reverse=True)
|
| 133 |
+
return results[:limit]
|
src/data/medical_kb.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
# data/medical_kb.py
|
| 2 |
-
# Medical Knowledge Base for the Medical AI Assistant
|
| 3 |
|
|
|
|
|
|
|
| 4 |
MEDICAL_KB = {
|
| 5 |
"symptoms": {
|
| 6 |
"fever": "Fever is a temporary increase in body temperature, often due to illness. Normal body temperature is around 98.6°F (37°C).",
|
|
|
|
| 1 |
# data/medical_kb.py
|
|
|
|
| 2 |
|
| 3 |
+
# TODO: This should be replaced with a more robust knowledge base system that can be updated by the user.
|
| 4 |
+
# Medical Knowledge Base for the Medical AI Assistant
|
| 5 |
MEDICAL_KB = {
|
| 6 |
"symptoms": {
|
| 7 |
"fever": "Fever is a temporary increase in body temperature, often due to illness. Normal body temperature is around 98.6°F (37°C).",
|
src/data/message/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# data/message/__init__.py
|
| 2 |
+
"""
|
| 3 |
+
Message management operations for MongoDB.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from .operations import (
|
| 7 |
+
add_message,
|
| 8 |
+
get_session_messages,
|
| 9 |
+
save_chat_message,
|
| 10 |
+
list_session_messages,
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
__all__ = [
|
| 14 |
+
'add_message',
|
| 15 |
+
'get_session_messages',
|
| 16 |
+
'save_chat_message',
|
| 17 |
+
'list_session_messages',
|
| 18 |
+
]
|
src/data/message/operations.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# data/message/operations.py
|
| 2 |
+
"""
|
| 3 |
+
Message management operations for MongoDB.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from datetime import datetime, timezone
|
| 7 |
+
from typing import Any
|
| 8 |
+
|
| 9 |
+
from bson import ObjectId
|
| 10 |
+
from pymongo import ASCENDING
|
| 11 |
+
|
| 12 |
+
from ..connection import get_collection, CHAT_SESSIONS_COLLECTION, CHAT_MESSAGES_COLLECTION
|
| 13 |
+
from src.utils.logger import get_logger
|
| 14 |
+
|
| 15 |
+
logger = get_logger("MESSAGE_OPS")
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def add_message(
|
| 19 |
+
session_id: str,
|
| 20 |
+
message_data: dict[str, Any],
|
| 21 |
+
/,
|
| 22 |
+
*,
|
| 23 |
+
collection_name: str = CHAT_SESSIONS_COLLECTION
|
| 24 |
+
) -> str | None:
|
| 25 |
+
"""Add a message to a chat session"""
|
| 26 |
+
collection = get_collection(collection_name)
|
| 27 |
+
|
| 28 |
+
# Verify session exists first
|
| 29 |
+
session = collection.find_one({
|
| 30 |
+
"$or": [
|
| 31 |
+
{"_id": session_id},
|
| 32 |
+
{"_id": ObjectId(session_id) if ObjectId.is_valid(session_id) else None}
|
| 33 |
+
]
|
| 34 |
+
})
|
| 35 |
+
if not session:
|
| 36 |
+
logger.error(f"Failed to add message - session not found: {session_id}")
|
| 37 |
+
raise ValueError(f"Chat session not found: {session_id}")
|
| 38 |
+
|
| 39 |
+
now = datetime.now(timezone.utc)
|
| 40 |
+
message_data["timestamp"] = now
|
| 41 |
+
result = collection.update_one(
|
| 42 |
+
{"_id": session["_id"]},
|
| 43 |
+
{
|
| 44 |
+
"$push": {"messages": message_data},
|
| 45 |
+
"$set": {"updated_at": now}
|
| 46 |
+
}
|
| 47 |
+
)
|
| 48 |
+
return str(session_id) if result.modified_count > 0 else None
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def get_session_messages(
|
| 52 |
+
session_id: str,
|
| 53 |
+
/,
|
| 54 |
+
limit: int | None = None,
|
| 55 |
+
*,
|
| 56 |
+
collection_name: str = CHAT_SESSIONS_COLLECTION
|
| 57 |
+
) -> list[dict[str, Any]]:
|
| 58 |
+
"""Get messages from a specific chat session"""
|
| 59 |
+
collection = get_collection(collection_name)
|
| 60 |
+
pipeline = [
|
| 61 |
+
{"$match": {"_id": session_id}},
|
| 62 |
+
{"$unwind": "$messages"},
|
| 63 |
+
{"$sort": {"messages.timestamp": -1}}
|
| 64 |
+
]
|
| 65 |
+
if limit:
|
| 66 |
+
pipeline.append({"$limit": limit})
|
| 67 |
+
return [doc["messages"] for doc in collection.aggregate(pipeline)]
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def save_chat_message(
|
| 71 |
+
*,
|
| 72 |
+
session_id: str,
|
| 73 |
+
patient_id: str,
|
| 74 |
+
doctor_id: str,
|
| 75 |
+
role: str,
|
| 76 |
+
content: str,
|
| 77 |
+
timestamp: datetime | None = None,
|
| 78 |
+
collection_name: str = CHAT_MESSAGES_COLLECTION
|
| 79 |
+
) -> ObjectId:
|
| 80 |
+
collection = get_collection(collection_name)
|
| 81 |
+
ts = timestamp or datetime.now(timezone.utc)
|
| 82 |
+
doc = {
|
| 83 |
+
"session_id": session_id,
|
| 84 |
+
"patient_id": patient_id,
|
| 85 |
+
"doctor_id": doctor_id,
|
| 86 |
+
"role": role,
|
| 87 |
+
"content": content,
|
| 88 |
+
"timestamp": ts,
|
| 89 |
+
"created_at": ts
|
| 90 |
+
}
|
| 91 |
+
result = collection.insert_one(doc)
|
| 92 |
+
return result.inserted_id
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def list_session_messages(
|
| 96 |
+
session_id: str,
|
| 97 |
+
/,
|
| 98 |
+
*,
|
| 99 |
+
patient_id: str | None = None,
|
| 100 |
+
limit: int | None = None,
|
| 101 |
+
collection_name: str = CHAT_MESSAGES_COLLECTION
|
| 102 |
+
) -> list[dict[str, Any]]:
|
| 103 |
+
collection = get_collection(collection_name)
|
| 104 |
+
|
| 105 |
+
# First verify the session belongs to the patient
|
| 106 |
+
if patient_id:
|
| 107 |
+
session_collection = get_collection(CHAT_SESSIONS_COLLECTION)
|
| 108 |
+
session = session_collection.find_one({
|
| 109 |
+
"session_id": session_id,
|
| 110 |
+
"patient_id": patient_id
|
| 111 |
+
})
|
| 112 |
+
if not session:
|
| 113 |
+
logger.warning(f"Session {session_id} not found for patient {patient_id}")
|
| 114 |
+
return []
|
| 115 |
+
|
| 116 |
+
# Query messages with patient_id filter if provided
|
| 117 |
+
query = {"session_id": session_id}
|
| 118 |
+
if patient_id:
|
| 119 |
+
query["patient_id"] = patient_id
|
| 120 |
+
|
| 121 |
+
cursor = collection.find(query).sort("timestamp", ASCENDING)
|
| 122 |
+
if limit is not None:
|
| 123 |
+
cursor = cursor.limit(limit)
|
| 124 |
+
return list(cursor)
|
src/data/mongodb.py.backup
ADDED
|
@@ -0,0 +1,638 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# data/mongodb.py
|
| 2 |
+
|
| 3 |
+
"""
|
| 4 |
+
Interface for mongodb using pymongo.
|
| 5 |
+
This file has been refactored.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from datetime import datetime, timedelta, timezone
|
| 9 |
+
from typing import Any
|
| 10 |
+
|
| 11 |
+
from bson import ObjectId
|
| 12 |
+
from pandas import DataFrame
|
| 13 |
+
from pymongo import ASCENDING, DESCENDING, MongoClient
|
| 14 |
+
from pymongo.collection import Collection
|
| 15 |
+
from pymongo.database import Database
|
| 16 |
+
from pymongo.errors import DuplicateKeyError
|
| 17 |
+
|
| 18 |
+
from src.utils.logger import get_logger
|
| 19 |
+
import os
|
| 20 |
+
|
| 21 |
+
logger = get_logger("MONGO")
|
| 22 |
+
|
| 23 |
+
# Global client instance
|
| 24 |
+
_mongo_client: MongoClient | None = None
|
| 25 |
+
|
| 26 |
+
# Collection Names
|
| 27 |
+
ACCOUNTS_COLLECTION = "accounts"
|
| 28 |
+
CHAT_SESSIONS_COLLECTION = "chat_sessions"
|
| 29 |
+
MEDICAL_RECORDS_COLLECTION = "medical_records"
|
| 30 |
+
# DOCTORS_COLLECTION = "doctors"
|
| 31 |
+
|
| 32 |
+
# Base Database Operations
|
| 33 |
+
def get_database() -> Database:
|
| 34 |
+
"""Get database instance with connection management"""
|
| 35 |
+
global _mongo_client
|
| 36 |
+
if _mongo_client is None:
|
| 37 |
+
CONNECTION_STRING = os.getenv("MONGO_USER", "mongodb://127.0.0.1:27017/") # fall back to local host if no user is provided
|
| 38 |
+
try:
|
| 39 |
+
logger.info("Initializing MongoDB connection")
|
| 40 |
+
_mongo_client = MongoClient(CONNECTION_STRING)
|
| 41 |
+
except Exception as e:
|
| 42 |
+
logger.error(f"Failed to connect to MongoDB: {str(e)}")
|
| 43 |
+
# Pass the error down, code that calls this function should handle it
|
| 44 |
+
raise e
|
| 45 |
+
db_name = os.getenv("USER_DB", "medicaldiagnosissystem")
|
| 46 |
+
return _mongo_client[db_name]
|
| 47 |
+
|
| 48 |
+
def close_connection():
|
| 49 |
+
"""Close MongoDB connection"""
|
| 50 |
+
global _mongo_client
|
| 51 |
+
if _mongo_client is not None:
|
| 52 |
+
# Close the connection and reset the client
|
| 53 |
+
_mongo_client.close()
|
| 54 |
+
_mongo_client = None
|
| 55 |
+
|
| 56 |
+
def get_collection(name: str, /) -> Collection:
|
| 57 |
+
"""Get a MongoDB collection by name"""
|
| 58 |
+
db = get_database()
|
| 59 |
+
return db.get_collection(name)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
# Account Management
|
| 63 |
+
def get_account_frame(
|
| 64 |
+
*,
|
| 65 |
+
collection_name: str = ACCOUNTS_COLLECTION
|
| 66 |
+
) -> DataFrame:
|
| 67 |
+
"""Get accounts as a pandas DataFrame"""
|
| 68 |
+
return DataFrame(get_collection(collection_name).find())
|
| 69 |
+
|
| 70 |
+
def create_account(
|
| 71 |
+
user_data: dict[str, Any],
|
| 72 |
+
/, *,
|
| 73 |
+
collection_name: str = ACCOUNTS_COLLECTION
|
| 74 |
+
) -> str:
|
| 75 |
+
"""Create a new user account"""
|
| 76 |
+
collection = get_collection(collection_name)
|
| 77 |
+
now = datetime.now(timezone.utc)
|
| 78 |
+
user_data["created_at"] = now
|
| 79 |
+
user_data["updated_at"] = now
|
| 80 |
+
try:
|
| 81 |
+
result = collection.insert_one(user_data)
|
| 82 |
+
logger.info(f"Created new account: {result.inserted_id}")
|
| 83 |
+
return str(result.inserted_id)
|
| 84 |
+
except DuplicateKeyError as e:
|
| 85 |
+
logger.error(f"Failed to create account - duplicate key: {str(e)}")
|
| 86 |
+
raise DuplicateKeyError(f"Account already exists: {e}") from e
|
| 87 |
+
|
| 88 |
+
def update_account(
|
| 89 |
+
user_id: str,
|
| 90 |
+
updates: dict[str, Any],
|
| 91 |
+
/, *,
|
| 92 |
+
collection_name: str = ACCOUNTS_COLLECTION
|
| 93 |
+
) -> bool:
|
| 94 |
+
"""Update an existing user account"""
|
| 95 |
+
collection = get_collection(collection_name)
|
| 96 |
+
updates["updated_at"] = datetime.now(timezone.utc)
|
| 97 |
+
result = collection.update_one(
|
| 98 |
+
{"_id": user_id},
|
| 99 |
+
{"$set": updates}
|
| 100 |
+
)
|
| 101 |
+
return result.modified_count > 0
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
# Chat Session Management
|
| 105 |
+
def create_chat_session(
|
| 106 |
+
session_data: dict[str, Any],
|
| 107 |
+
/, *,
|
| 108 |
+
collection_name: str = CHAT_SESSIONS_COLLECTION
|
| 109 |
+
) -> str:
|
| 110 |
+
"""Create a new chat session"""
|
| 111 |
+
collection = get_collection(collection_name)
|
| 112 |
+
now = datetime.now(timezone.utc)
|
| 113 |
+
session_data["created_at"] = now
|
| 114 |
+
session_data["updated_at"] = now
|
| 115 |
+
if "_id" not in session_data:
|
| 116 |
+
session_data["_id"] = str(ObjectId())
|
| 117 |
+
result = collection.insert_one(session_data)
|
| 118 |
+
return str(result.inserted_id)
|
| 119 |
+
|
| 120 |
+
def get_user_sessions(
|
| 121 |
+
user_id: str,
|
| 122 |
+
/,
|
| 123 |
+
limit: int = 20,
|
| 124 |
+
*,
|
| 125 |
+
collection_name: str = CHAT_SESSIONS_COLLECTION
|
| 126 |
+
) -> list[dict[str, Any]]:
|
| 127 |
+
"""Get chat sessions for a specific user"""
|
| 128 |
+
collection = get_collection(collection_name)
|
| 129 |
+
return list(collection.find(
|
| 130 |
+
{"user_id": user_id}
|
| 131 |
+
).sort("updated_at", DESCENDING).limit(limit))
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
# Message History
|
| 135 |
+
def add_message(
|
| 136 |
+
session_id: str,
|
| 137 |
+
message_data: dict[str, Any],
|
| 138 |
+
/, *,
|
| 139 |
+
collection_name: str = CHAT_SESSIONS_COLLECTION
|
| 140 |
+
) -> str | None:
|
| 141 |
+
"""Add a message to a chat session"""
|
| 142 |
+
collection = get_collection(collection_name)
|
| 143 |
+
|
| 144 |
+
# Verify session exists first
|
| 145 |
+
session = collection.find_one({
|
| 146 |
+
"$or": [
|
| 147 |
+
{"_id": session_id},
|
| 148 |
+
{"_id": ObjectId(session_id) if ObjectId.is_valid(session_id) else None}
|
| 149 |
+
]
|
| 150 |
+
})
|
| 151 |
+
if not session:
|
| 152 |
+
logger.error(f"Failed to add message - session not found: {session_id}")
|
| 153 |
+
raise ValueError(f"Chat session not found: {session_id}")
|
| 154 |
+
|
| 155 |
+
now = datetime.now(timezone.utc)
|
| 156 |
+
message_data["timestamp"] = now
|
| 157 |
+
result = collection.update_one(
|
| 158 |
+
{"_id": session["_id"]},
|
| 159 |
+
{
|
| 160 |
+
"$push": {"messages": message_data},
|
| 161 |
+
"$set": {"updated_at": now}
|
| 162 |
+
}
|
| 163 |
+
)
|
| 164 |
+
return str(session_id) if result.modified_count > 0 else None
|
| 165 |
+
|
| 166 |
+
def get_session_messages(
|
| 167 |
+
session_id: str,
|
| 168 |
+
/,
|
| 169 |
+
limit: int | None = None,
|
| 170 |
+
*,
|
| 171 |
+
collection_name: str = CHAT_SESSIONS_COLLECTION
|
| 172 |
+
) -> list[dict[str, Any]]:
|
| 173 |
+
"""Get messages from a specific chat session"""
|
| 174 |
+
collection = get_collection(collection_name)
|
| 175 |
+
pipeline = [
|
| 176 |
+
{"$match": {"_id": session_id}},
|
| 177 |
+
{"$unwind": "$messages"},
|
| 178 |
+
{"$sort": {"messages.timestamp": -1}}
|
| 179 |
+
]
|
| 180 |
+
if limit:
|
| 181 |
+
pipeline.append({"$limit": limit})
|
| 182 |
+
return [doc["messages"] for doc in collection.aggregate(pipeline)]
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
# Medical Records
|
| 186 |
+
def create_medical_record(
|
| 187 |
+
record_data: dict[str, Any],
|
| 188 |
+
/, *,
|
| 189 |
+
collection_name: str = MEDICAL_RECORDS_COLLECTION
|
| 190 |
+
) -> str:
|
| 191 |
+
"""Create a new medical record"""
|
| 192 |
+
collection = get_collection(collection_name)
|
| 193 |
+
now = datetime.now(timezone.utc)
|
| 194 |
+
record_data["created_at"] = now
|
| 195 |
+
record_data["updated_at"] = now
|
| 196 |
+
result = collection.insert_one(record_data)
|
| 197 |
+
return str(result.inserted_id)
|
| 198 |
+
|
| 199 |
+
def get_user_medical_records(
|
| 200 |
+
user_id: str,
|
| 201 |
+
/, *,
|
| 202 |
+
collection_name: str = MEDICAL_RECORDS_COLLECTION
|
| 203 |
+
) -> list[dict[str, Any]]:
|
| 204 |
+
"""Get medical records for a specific user"""
|
| 205 |
+
collection = get_collection(collection_name)
|
| 206 |
+
return list(collection.find({"user_id": user_id}).sort("created_at", ASCENDING))
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
# Utility Functions
|
| 210 |
+
def create_index(
|
| 211 |
+
collection_name: str,
|
| 212 |
+
field_name: str,
|
| 213 |
+
/,
|
| 214 |
+
unique: bool = False
|
| 215 |
+
) -> None:
|
| 216 |
+
"""Create an index on a collection"""
|
| 217 |
+
collection = get_collection(collection_name)
|
| 218 |
+
collection.create_index([(field_name, ASCENDING)], unique=unique)
|
| 219 |
+
|
| 220 |
+
def delete_old_sessions(
|
| 221 |
+
days: int = 30,
|
| 222 |
+
*,
|
| 223 |
+
collection_name: str = CHAT_SESSIONS_COLLECTION
|
| 224 |
+
) -> int:
|
| 225 |
+
"""Delete chat sessions older than specified days"""
|
| 226 |
+
collection = get_collection(collection_name)
|
| 227 |
+
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
|
| 228 |
+
result = collection.delete_many({
|
| 229 |
+
"updated_at": {"$lt": cutoff}
|
| 230 |
+
})
|
| 231 |
+
if result.deleted_count > 0:
|
| 232 |
+
logger.info(f"Deleted {result.deleted_count} old sessions (>{days} days)")
|
| 233 |
+
return result.deleted_count
|
| 234 |
+
|
| 235 |
+
def backup_collection(collection_name: str) -> str:
|
| 236 |
+
"""Create a backup of a collection"""
|
| 237 |
+
collection = get_collection(collection_name)
|
| 238 |
+
backup_name = f"{collection_name}_backup_{datetime.now(timezone.utc).strftime('%Y%m%d')}"
|
| 239 |
+
db = get_database()
|
| 240 |
+
|
| 241 |
+
# Drop existing backup if it exists
|
| 242 |
+
if backup_name in db.list_collection_names():
|
| 243 |
+
logger.info(f"Removing existing backup: {backup_name}")
|
| 244 |
+
db.drop_collection(backup_name)
|
| 245 |
+
|
| 246 |
+
db.create_collection(backup_name)
|
| 247 |
+
backup = db[backup_name]
|
| 248 |
+
|
| 249 |
+
doc_count = 0
|
| 250 |
+
for doc in collection.find():
|
| 251 |
+
backup.insert_one(doc)
|
| 252 |
+
doc_count += 1
|
| 253 |
+
|
| 254 |
+
logger.info(f"Created backup {backup_name} with {doc_count} documents")
|
| 255 |
+
return backup_name
|
| 256 |
+
|
| 257 |
+
# New: Chat and Medical Memory Persistence Helpers
|
| 258 |
+
|
| 259 |
+
CHAT_MESSAGES_COLLECTION = "chat_messages"
|
| 260 |
+
MEDICAL_MEMORY_COLLECTION = "medical_memory"
|
| 261 |
+
PATIENTS_COLLECTION = "patients"
|
| 262 |
+
|
| 263 |
+
|
| 264 |
+
def ensure_session(
|
| 265 |
+
*,
|
| 266 |
+
session_id: str,
|
| 267 |
+
patient_id: str,
|
| 268 |
+
doctor_id: str,
|
| 269 |
+
title: str,
|
| 270 |
+
last_activity: datetime | None = None,
|
| 271 |
+
collection_name: str = CHAT_SESSIONS_COLLECTION
|
| 272 |
+
) -> None:
|
| 273 |
+
collection = get_collection(collection_name)
|
| 274 |
+
now = datetime.now(timezone.utc)
|
| 275 |
+
collection.update_one(
|
| 276 |
+
{"session_id": session_id},
|
| 277 |
+
{"$set": {
|
| 278 |
+
"session_id": session_id,
|
| 279 |
+
"patient_id": patient_id,
|
| 280 |
+
"doctor_id": doctor_id,
|
| 281 |
+
"title": title,
|
| 282 |
+
"last_activity": (last_activity or now),
|
| 283 |
+
"updated_at": now
|
| 284 |
+
}, "$setOnInsert": {"created_at": now}},
|
| 285 |
+
upsert=True
|
| 286 |
+
)
|
| 287 |
+
|
| 288 |
+
|
| 289 |
+
def save_chat_message(
|
| 290 |
+
*,
|
| 291 |
+
session_id: str,
|
| 292 |
+
patient_id: str,
|
| 293 |
+
doctor_id: str,
|
| 294 |
+
role: str,
|
| 295 |
+
content: str,
|
| 296 |
+
timestamp: datetime | None = None,
|
| 297 |
+
collection_name: str = CHAT_MESSAGES_COLLECTION
|
| 298 |
+
) -> ObjectId:
|
| 299 |
+
collection = get_collection(collection_name)
|
| 300 |
+
ts = timestamp or datetime.now(timezone.utc)
|
| 301 |
+
doc = {
|
| 302 |
+
"session_id": session_id,
|
| 303 |
+
"patient_id": patient_id,
|
| 304 |
+
"doctor_id": doctor_id,
|
| 305 |
+
"role": role,
|
| 306 |
+
"content": content,
|
| 307 |
+
"timestamp": ts,
|
| 308 |
+
"created_at": ts
|
| 309 |
+
}
|
| 310 |
+
result = collection.insert_one(doc)
|
| 311 |
+
return result.inserted_id
|
| 312 |
+
|
| 313 |
+
|
| 314 |
+
def list_session_messages(
|
| 315 |
+
session_id: str,
|
| 316 |
+
/,
|
| 317 |
+
*,
|
| 318 |
+
patient_id: str | None = None,
|
| 319 |
+
limit: int | None = None,
|
| 320 |
+
collection_name: str = CHAT_MESSAGES_COLLECTION
|
| 321 |
+
) -> list[dict[str, Any]]:
|
| 322 |
+
collection = get_collection(collection_name)
|
| 323 |
+
|
| 324 |
+
# First verify the session belongs to the patient
|
| 325 |
+
if patient_id:
|
| 326 |
+
session_collection = get_collection(CHAT_SESSIONS_COLLECTION)
|
| 327 |
+
session = session_collection.find_one({
|
| 328 |
+
"session_id": session_id,
|
| 329 |
+
"patient_id": patient_id
|
| 330 |
+
})
|
| 331 |
+
if not session:
|
| 332 |
+
logger.warning(f"Session {session_id} not found for patient {patient_id}")
|
| 333 |
+
return []
|
| 334 |
+
|
| 335 |
+
# Query messages with patient_id filter if provided
|
| 336 |
+
query = {"session_id": session_id}
|
| 337 |
+
if patient_id:
|
| 338 |
+
query["patient_id"] = patient_id
|
| 339 |
+
|
| 340 |
+
cursor = collection.find(query).sort("timestamp", ASCENDING)
|
| 341 |
+
if limit is not None:
|
| 342 |
+
cursor = cursor.limit(limit)
|
| 343 |
+
return list(cursor)
|
| 344 |
+
|
| 345 |
+
|
| 346 |
+
def save_memory_summary(
|
| 347 |
+
*,
|
| 348 |
+
patient_id: str,
|
| 349 |
+
doctor_id: str,
|
| 350 |
+
summary: str,
|
| 351 |
+
embedding: list[float] | None = None,
|
| 352 |
+
created_at: datetime | None = None,
|
| 353 |
+
collection_name: str = MEDICAL_MEMORY_COLLECTION
|
| 354 |
+
) -> ObjectId:
|
| 355 |
+
collection = get_collection(collection_name)
|
| 356 |
+
ts = created_at or datetime.now(timezone.utc)
|
| 357 |
+
doc = {
|
| 358 |
+
"patient_id": patient_id,
|
| 359 |
+
"doctor_id": doctor_id,
|
| 360 |
+
"summary": summary,
|
| 361 |
+
"created_at": ts
|
| 362 |
+
}
|
| 363 |
+
if embedding is not None:
|
| 364 |
+
doc["embedding"] = embedding
|
| 365 |
+
result = collection.insert_one(doc)
|
| 366 |
+
return result.inserted_id
|
| 367 |
+
|
| 368 |
+
|
| 369 |
+
def get_recent_memory_summaries(
|
| 370 |
+
patient_id: str,
|
| 371 |
+
/,
|
| 372 |
+
*,
|
| 373 |
+
limit: int = 20,
|
| 374 |
+
collection_name: str = MEDICAL_MEMORY_COLLECTION
|
| 375 |
+
) -> list[str]:
|
| 376 |
+
collection = get_collection(collection_name)
|
| 377 |
+
docs = list(collection.find({"patient_id": patient_id}).sort("created_at", DESCENDING).limit(limit))
|
| 378 |
+
return [d.get("summary", "") for d in docs]
|
| 379 |
+
|
| 380 |
+
def search_memory_summaries_semantic(
|
| 381 |
+
patient_id: str,
|
| 382 |
+
query_embedding: list[float],
|
| 383 |
+
/,
|
| 384 |
+
*,
|
| 385 |
+
limit: int = 5,
|
| 386 |
+
similarity_threshold: float = 0.5, # >= 50% semantic similarity
|
| 387 |
+
collection_name: str = MEDICAL_MEMORY_COLLECTION
|
| 388 |
+
) -> list[dict[str, Any]]:
|
| 389 |
+
"""
|
| 390 |
+
Search memory summaries using semantic similarity with embeddings.
|
| 391 |
+
Returns list of {summary, similarity_score, created_at} sorted by similarity.
|
| 392 |
+
"""
|
| 393 |
+
collection = get_collection(collection_name)
|
| 394 |
+
|
| 395 |
+
# Get all summaries with embeddings for this patient
|
| 396 |
+
docs = list(collection.find({
|
| 397 |
+
"patient_id": patient_id,
|
| 398 |
+
"embedding": {"$exists": True}
|
| 399 |
+
}))
|
| 400 |
+
|
| 401 |
+
if not docs:
|
| 402 |
+
return []
|
| 403 |
+
|
| 404 |
+
# Calculate similarities
|
| 405 |
+
import numpy as np
|
| 406 |
+
query_vec = np.array(query_embedding, dtype="float32")
|
| 407 |
+
results = []
|
| 408 |
+
|
| 409 |
+
for doc in docs:
|
| 410 |
+
embedding = doc.get("embedding")
|
| 411 |
+
if not embedding:
|
| 412 |
+
continue
|
| 413 |
+
|
| 414 |
+
# Calculate cosine similarity
|
| 415 |
+
doc_vec = np.array(embedding, dtype="float32")
|
| 416 |
+
dot_product = np.dot(query_vec, doc_vec)
|
| 417 |
+
norm_query = np.linalg.norm(query_vec)
|
| 418 |
+
norm_doc = np.linalg.norm(doc_vec)
|
| 419 |
+
|
| 420 |
+
if norm_query == 0 or norm_doc == 0:
|
| 421 |
+
similarity = 0.0
|
| 422 |
+
else:
|
| 423 |
+
similarity = float(dot_product / (norm_query * norm_doc))
|
| 424 |
+
|
| 425 |
+
if similarity >= similarity_threshold:
|
| 426 |
+
results.append({
|
| 427 |
+
"summary": doc.get("summary", ""),
|
| 428 |
+
"similarity_score": similarity,
|
| 429 |
+
"created_at": doc.get("created_at"),
|
| 430 |
+
"session_id": doc.get("session_id") # if we add this field later
|
| 431 |
+
})
|
| 432 |
+
|
| 433 |
+
# Sort by similarity (highest first) and return top results
|
| 434 |
+
results.sort(key=lambda x: x["similarity_score"], reverse=True)
|
| 435 |
+
return results[:limit]
|
| 436 |
+
|
| 437 |
+
|
| 438 |
+
def list_patient_sessions(
|
| 439 |
+
patient_id: str,
|
| 440 |
+
/,
|
| 441 |
+
*,
|
| 442 |
+
collection_name: str = CHAT_SESSIONS_COLLECTION
|
| 443 |
+
) -> list[dict[str, Any]]:
|
| 444 |
+
collection = get_collection(collection_name)
|
| 445 |
+
sessions = list(collection.find({"patient_id": patient_id}).sort("last_activity", DESCENDING))
|
| 446 |
+
# Convert ObjectId to string for JSON serialization
|
| 447 |
+
for session in sessions:
|
| 448 |
+
if "_id" in session:
|
| 449 |
+
session["_id"] = str(session["_id"])
|
| 450 |
+
return sessions
|
| 451 |
+
|
| 452 |
+
def delete_session(
|
| 453 |
+
session_id: str,
|
| 454 |
+
/,
|
| 455 |
+
*,
|
| 456 |
+
collection_name: str = CHAT_SESSIONS_COLLECTION
|
| 457 |
+
) -> bool:
|
| 458 |
+
"""Delete a chat session from MongoDB"""
|
| 459 |
+
collection = get_collection(collection_name)
|
| 460 |
+
result = collection.delete_one({"session_id": session_id})
|
| 461 |
+
return result.deleted_count > 0
|
| 462 |
+
|
| 463 |
+
def delete_session_messages(
|
| 464 |
+
session_id: str,
|
| 465 |
+
/,
|
| 466 |
+
*,
|
| 467 |
+
collection_name: str = CHAT_MESSAGES_COLLECTION
|
| 468 |
+
) -> int:
|
| 469 |
+
"""Delete all messages for a session from MongoDB"""
|
| 470 |
+
collection = get_collection(collection_name)
|
| 471 |
+
result = collection.delete_many({"session_id": session_id})
|
| 472 |
+
return result.deleted_count
|
| 473 |
+
|
| 474 |
+
# Patients helpers
|
| 475 |
+
|
| 476 |
+
def _generate_patient_id() -> str:
|
| 477 |
+
# Generate zero-padded 8-digit ID
|
| 478 |
+
import random
|
| 479 |
+
return f"{random.randint(0, 99999999):08d}"
|
| 480 |
+
|
| 481 |
+
def get_patient_by_id(patient_id: str) -> dict[str, Any] | None:
|
| 482 |
+
collection = get_collection(PATIENTS_COLLECTION)
|
| 483 |
+
return collection.find_one({"patient_id": patient_id})
|
| 484 |
+
|
| 485 |
+
def create_patient(
|
| 486 |
+
*,
|
| 487 |
+
name: str,
|
| 488 |
+
age: int,
|
| 489 |
+
sex: str,
|
| 490 |
+
address: str | None = None,
|
| 491 |
+
phone: str | None = None,
|
| 492 |
+
email: str | None = None,
|
| 493 |
+
medications: list[str] | None = None,
|
| 494 |
+
past_assessment_summary: str | None = None,
|
| 495 |
+
assigned_doctor_id: str | None = None
|
| 496 |
+
) -> dict[str, Any]:
|
| 497 |
+
collection = get_collection(PATIENTS_COLLECTION)
|
| 498 |
+
now = datetime.now(timezone.utc)
|
| 499 |
+
# Ensure unique 8-digit id
|
| 500 |
+
for _ in range(10):
|
| 501 |
+
pid = _generate_patient_id()
|
| 502 |
+
if not collection.find_one({"patient_id": pid}):
|
| 503 |
+
break
|
| 504 |
+
else:
|
| 505 |
+
raise RuntimeError("Failed to generate unique patient ID")
|
| 506 |
+
doc = {
|
| 507 |
+
"patient_id": pid,
|
| 508 |
+
"name": name,
|
| 509 |
+
"age": age,
|
| 510 |
+
"sex": sex,
|
| 511 |
+
"address": address,
|
| 512 |
+
"phone": phone,
|
| 513 |
+
"email": email,
|
| 514 |
+
"medications": medications or [],
|
| 515 |
+
"past_assessment_summary": past_assessment_summary or "",
|
| 516 |
+
"assigned_doctor_id": assigned_doctor_id,
|
| 517 |
+
"created_at": now,
|
| 518 |
+
"updated_at": now
|
| 519 |
+
}
|
| 520 |
+
collection.insert_one(doc)
|
| 521 |
+
return doc
|
| 522 |
+
|
| 523 |
+
def update_patient_profile(patient_id: str, updates: dict[str, Any]) -> int:
|
| 524 |
+
collection = get_collection(PATIENTS_COLLECTION)
|
| 525 |
+
updates["updated_at"] = datetime.now(timezone.utc)
|
| 526 |
+
result = collection.update_one({"patient_id": patient_id}, {"$set": updates})
|
| 527 |
+
return result.modified_count
|
| 528 |
+
|
| 529 |
+
def search_patients(query: str, limit: int = 10) -> list[dict[str, Any]]:
|
| 530 |
+
"""Search patients by name (case-insensitive starts-with/contains) or partial patient_id."""
|
| 531 |
+
collection = get_collection(PATIENTS_COLLECTION)
|
| 532 |
+
if not query:
|
| 533 |
+
return []
|
| 534 |
+
|
| 535 |
+
logger.info(f"Searching patients with query: '{query}', limit: {limit}")
|
| 536 |
+
|
| 537 |
+
# Build a regex for name search and patient_id partial match
|
| 538 |
+
import re
|
| 539 |
+
pattern = re.compile(re.escape(query), re.IGNORECASE)
|
| 540 |
+
|
| 541 |
+
try:
|
| 542 |
+
cursor = collection.find({
|
| 543 |
+
"$or": [
|
| 544 |
+
{"name": {"$regex": pattern}},
|
| 545 |
+
{"patient_id": {"$regex": pattern}}
|
| 546 |
+
]
|
| 547 |
+
}).sort("name", ASCENDING).limit(limit)
|
| 548 |
+
results = []
|
| 549 |
+
for p in cursor:
|
| 550 |
+
p["_id"] = str(p.get("_id")) if p.get("_id") else None
|
| 551 |
+
results.append(p)
|
| 552 |
+
logger.info(f"Found {len(results)} patients matching query")
|
| 553 |
+
return results
|
| 554 |
+
except Exception as e:
|
| 555 |
+
logger.error(f"Error in search_patients: {e}")
|
| 556 |
+
return []
|
| 557 |
+
|
| 558 |
+
# Doctor Management
|
| 559 |
+
def create_doctor(
|
| 560 |
+
*,
|
| 561 |
+
name: str,
|
| 562 |
+
role: str | None = None,
|
| 563 |
+
specialty: str | None = None,
|
| 564 |
+
medical_roles: list[str] | None = None
|
| 565 |
+
) -> str:
|
| 566 |
+
"""Create a new doctor profile"""
|
| 567 |
+
collection = get_collection(ACCOUNTS_COLLECTION)
|
| 568 |
+
now = datetime.now(timezone.utc)
|
| 569 |
+
doctor_doc = {
|
| 570 |
+
"name": name,
|
| 571 |
+
"role": role,
|
| 572 |
+
"specialty": specialty,
|
| 573 |
+
"medical_roles": medical_roles or [],
|
| 574 |
+
"created_at": now,
|
| 575 |
+
"updated_at": now
|
| 576 |
+
}
|
| 577 |
+
try:
|
| 578 |
+
result = collection.insert_one(doctor_doc)
|
| 579 |
+
logger.info(f"Created new doctor: {name} with id {result.inserted_id}")
|
| 580 |
+
return str(result.inserted_id)
|
| 581 |
+
except Exception as e:
|
| 582 |
+
logger.error(f"Error creating doctor: {e}")
|
| 583 |
+
raise e
|
| 584 |
+
|
| 585 |
+
def get_doctor_by_name(name: str) -> dict[str, Any] | None:
|
| 586 |
+
"""Get doctor by name from accounts collection"""
|
| 587 |
+
collection = get_collection(ACCOUNTS_COLLECTION)
|
| 588 |
+
doctor = collection.find_one({
|
| 589 |
+
"name": name,
|
| 590 |
+
"role": {"$in": ["Doctor", "Healthcare Prof", "General Practitioner", "Cardiologist", "Pediatrician", "Neurologist", "Dermatologist"]}
|
| 591 |
+
})
|
| 592 |
+
if doctor:
|
| 593 |
+
doctor["_id"] = str(doctor.get("_id")) if doctor.get("_id") else None
|
| 594 |
+
return doctor
|
| 595 |
+
|
| 596 |
+
def search_doctors(query: str, limit: int = 10) -> list[dict[str, Any]]:
|
| 597 |
+
"""Search doctors by name (case-insensitive contains) from accounts collection"""
|
| 598 |
+
collection = get_collection(ACCOUNTS_COLLECTION)
|
| 599 |
+
if not query:
|
| 600 |
+
return []
|
| 601 |
+
|
| 602 |
+
logger.info(f"Searching doctors with query: '{query}', limit: {limit}")
|
| 603 |
+
|
| 604 |
+
# Build a regex for name search
|
| 605 |
+
import re
|
| 606 |
+
pattern = re.compile(re.escape(query), re.IGNORECASE)
|
| 607 |
+
|
| 608 |
+
try:
|
| 609 |
+
cursor = collection.find({
|
| 610 |
+
"name": {"$regex": pattern},
|
| 611 |
+
"role": {"$in": ["Doctor", "Healthcare Prof", "General Practitioner", "Cardiologist", "Pediatrician", "Neurologist", "Dermatologist"]}
|
| 612 |
+
}).sort("name", ASCENDING).limit(limit)
|
| 613 |
+
results = []
|
| 614 |
+
for d in cursor:
|
| 615 |
+
d["_id"] = str(d.get("_id")) if d.get("_id") else None
|
| 616 |
+
results.append(d)
|
| 617 |
+
logger.info(f"Found {len(results)} doctors matching query")
|
| 618 |
+
return results
|
| 619 |
+
except Exception as e:
|
| 620 |
+
logger.error(f"Error in search_doctors: {e}")
|
| 621 |
+
return []
|
| 622 |
+
|
| 623 |
+
def get_all_doctors(limit: int = 50) -> list[dict[str, Any]]:
|
| 624 |
+
"""Get all doctors with optional limit from accounts collection"""
|
| 625 |
+
collection = get_collection(ACCOUNTS_COLLECTION)
|
| 626 |
+
try:
|
| 627 |
+
cursor = collection.find({
|
| 628 |
+
"role": {"$in": ["Doctor", "Healthcare Prof", "General Practitioner", "Cardiologist", "Pediatrician", "Neurologist", "Dermatologist"]}
|
| 629 |
+
}).sort("name", ASCENDING).limit(limit)
|
| 630 |
+
results = []
|
| 631 |
+
for d in cursor:
|
| 632 |
+
d["_id"] = str(d.get("_id")) if d.get("_id") else None
|
| 633 |
+
results.append(d)
|
| 634 |
+
logger.info(f"Retrieved {len(results)} doctors")
|
| 635 |
+
return results
|
| 636 |
+
except Exception as e:
|
| 637 |
+
logger.error(f"Error getting all doctors: {e}")
|
| 638 |
+
return []
|
src/data/patient/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# data/patient/__init__.py
|
| 2 |
+
"""
|
| 3 |
+
Patient management operations for MongoDB.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from .operations import (
|
| 7 |
+
get_patient_by_id,
|
| 8 |
+
create_patient,
|
| 9 |
+
update_patient_profile,
|
| 10 |
+
search_patients,
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
__all__ = [
|
| 14 |
+
'get_patient_by_id',
|
| 15 |
+
'create_patient',
|
| 16 |
+
'update_patient_profile',
|
| 17 |
+
'search_patients',
|
| 18 |
+
]
|
src/data/patient/operations.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# data/patient/operations.py
|
| 2 |
+
"""
|
| 3 |
+
Patient management operations for MongoDB.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import re
|
| 7 |
+
from datetime import datetime, timezone
|
| 8 |
+
from typing import Any
|
| 9 |
+
|
| 10 |
+
from pymongo import ASCENDING
|
| 11 |
+
|
| 12 |
+
from ..connection import get_collection, PATIENTS_COLLECTION
|
| 13 |
+
from src.utils.logger import get_logger
|
| 14 |
+
|
| 15 |
+
logger = get_logger("PATIENT_OPS")
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def _generate_patient_id() -> str:
|
| 19 |
+
"""Generate zero-padded 8-digit ID"""
|
| 20 |
+
import random
|
| 21 |
+
return f"{random.randint(0, 99999999):08d}"
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def get_patient_by_id(patient_id: str) -> dict[str, Any] | None:
|
| 25 |
+
collection = get_collection(PATIENTS_COLLECTION)
|
| 26 |
+
return collection.find_one({"patient_id": patient_id})
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def create_patient(
|
| 30 |
+
*,
|
| 31 |
+
name: str,
|
| 32 |
+
age: int,
|
| 33 |
+
sex: str,
|
| 34 |
+
address: str | None = None,
|
| 35 |
+
phone: str | None = None,
|
| 36 |
+
email: str | None = None,
|
| 37 |
+
medications: list[str] | None = None,
|
| 38 |
+
past_assessment_summary: str | None = None,
|
| 39 |
+
assigned_doctor_id: str | None = None
|
| 40 |
+
) -> dict[str, Any]:
|
| 41 |
+
collection = get_collection(PATIENTS_COLLECTION)
|
| 42 |
+
now = datetime.now(timezone.utc)
|
| 43 |
+
# Ensure unique 8-digit id
|
| 44 |
+
for _ in range(10):
|
| 45 |
+
pid = _generate_patient_id()
|
| 46 |
+
if not collection.find_one({"patient_id": pid}):
|
| 47 |
+
break
|
| 48 |
+
else:
|
| 49 |
+
raise RuntimeError("Failed to generate unique patient ID")
|
| 50 |
+
doc = {
|
| 51 |
+
"patient_id": pid,
|
| 52 |
+
"name": name,
|
| 53 |
+
"age": age,
|
| 54 |
+
"sex": sex,
|
| 55 |
+
"address": address,
|
| 56 |
+
"phone": phone,
|
| 57 |
+
"email": email,
|
| 58 |
+
"medications": medications or [],
|
| 59 |
+
"past_assessment_summary": past_assessment_summary or "",
|
| 60 |
+
"assigned_doctor_id": assigned_doctor_id,
|
| 61 |
+
"created_at": now,
|
| 62 |
+
"updated_at": now
|
| 63 |
+
}
|
| 64 |
+
collection.insert_one(doc)
|
| 65 |
+
return doc
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def update_patient_profile(patient_id: str, updates: dict[str, Any]) -> int:
|
| 69 |
+
collection = get_collection(PATIENTS_COLLECTION)
|
| 70 |
+
updates["updated_at"] = datetime.now(timezone.utc)
|
| 71 |
+
result = collection.update_one({"patient_id": patient_id}, {"$set": updates})
|
| 72 |
+
return result.modified_count
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def search_patients(query: str, limit: int = 10) -> list[dict[str, Any]]:
|
| 76 |
+
"""Search patients by name (case-insensitive starts-with/contains) or partial patient_id."""
|
| 77 |
+
collection = get_collection(PATIENTS_COLLECTION)
|
| 78 |
+
if not query:
|
| 79 |
+
return []
|
| 80 |
+
|
| 81 |
+
logger.info(f"Searching patients with query: '{query}', limit: {limit}")
|
| 82 |
+
|
| 83 |
+
# Build a regex for name search and patient_id partial match
|
| 84 |
+
pattern = re.compile(re.escape(query), re.IGNORECASE)
|
| 85 |
+
|
| 86 |
+
try:
|
| 87 |
+
cursor = collection.find({
|
| 88 |
+
"$or": [
|
| 89 |
+
{"name": {"$regex": pattern}},
|
| 90 |
+
{"patient_id": {"$regex": pattern}}
|
| 91 |
+
]
|
| 92 |
+
}).sort("name", ASCENDING).limit(limit)
|
| 93 |
+
results = []
|
| 94 |
+
for p in cursor:
|
| 95 |
+
p["_id"] = str(p.get("_id")) if p.get("_id") else None
|
| 96 |
+
results.append(p)
|
| 97 |
+
logger.info(f"Found {len(results)} patients matching query")
|
| 98 |
+
return results
|
| 99 |
+
except Exception as e:
|
| 100 |
+
logger.error(f"Error in search_patients: {e}")
|
| 101 |
+
return []
|
src/data/session/__init__.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# data/session/__init__.py
|
| 2 |
+
"""
|
| 3 |
+
Session management operations for MongoDB.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from .operations import (
|
| 7 |
+
create_chat_session,
|
| 8 |
+
get_user_sessions,
|
| 9 |
+
ensure_session,
|
| 10 |
+
list_patient_sessions,
|
| 11 |
+
delete_session,
|
| 12 |
+
delete_session_messages,
|
| 13 |
+
delete_old_sessions,
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
__all__ = [
|
| 17 |
+
'create_chat_session',
|
| 18 |
+
'get_user_sessions',
|
| 19 |
+
'ensure_session',
|
| 20 |
+
'list_patient_sessions',
|
| 21 |
+
'delete_session',
|
| 22 |
+
'delete_session_messages',
|
| 23 |
+
'delete_old_sessions',
|
| 24 |
+
]
|
src/data/session/operations.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# data/session/operations.py
|
| 2 |
+
"""
|
| 3 |
+
Session management operations for MongoDB.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from datetime import datetime, timedelta, timezone
|
| 7 |
+
from typing import Any
|
| 8 |
+
|
| 9 |
+
from bson import ObjectId
|
| 10 |
+
from pymongo import ASCENDING, DESCENDING
|
| 11 |
+
|
| 12 |
+
from ..connection import get_collection, CHAT_SESSIONS_COLLECTION, CHAT_MESSAGES_COLLECTION
|
| 13 |
+
from src.utils.logger import get_logger
|
| 14 |
+
|
| 15 |
+
logger = get_logger("SESSION_OPS")
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def create_chat_session(
|
| 19 |
+
session_data: dict[str, Any],
|
| 20 |
+
/,
|
| 21 |
+
*,
|
| 22 |
+
collection_name: str = CHAT_SESSIONS_COLLECTION
|
| 23 |
+
) -> str:
|
| 24 |
+
"""Create a new chat session"""
|
| 25 |
+
collection = get_collection(collection_name)
|
| 26 |
+
now = datetime.now(timezone.utc)
|
| 27 |
+
session_data["created_at"] = now
|
| 28 |
+
session_data["updated_at"] = now
|
| 29 |
+
if "_id" not in session_data:
|
| 30 |
+
session_data["_id"] = str(ObjectId())
|
| 31 |
+
result = collection.insert_one(session_data)
|
| 32 |
+
return str(result.inserted_id)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def get_user_sessions(
|
| 36 |
+
user_id: str,
|
| 37 |
+
/,
|
| 38 |
+
limit: int = 20,
|
| 39 |
+
*,
|
| 40 |
+
collection_name: str = CHAT_SESSIONS_COLLECTION
|
| 41 |
+
) -> list[dict[str, Any]]:
|
| 42 |
+
"""Get chat sessions for a specific user"""
|
| 43 |
+
collection = get_collection(collection_name)
|
| 44 |
+
return list(collection.find(
|
| 45 |
+
{"user_id": user_id}
|
| 46 |
+
).sort("updated_at", DESCENDING).limit(limit))
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def ensure_session(
|
| 50 |
+
*,
|
| 51 |
+
session_id: str,
|
| 52 |
+
patient_id: str,
|
| 53 |
+
doctor_id: str,
|
| 54 |
+
title: str,
|
| 55 |
+
last_activity: datetime | None = None,
|
| 56 |
+
collection_name: str = CHAT_SESSIONS_COLLECTION
|
| 57 |
+
) -> None:
|
| 58 |
+
collection = get_collection(collection_name)
|
| 59 |
+
now = datetime.now(timezone.utc)
|
| 60 |
+
collection.update_one(
|
| 61 |
+
{"session_id": session_id},
|
| 62 |
+
{"$set": {
|
| 63 |
+
"session_id": session_id,
|
| 64 |
+
"patient_id": patient_id,
|
| 65 |
+
"doctor_id": doctor_id,
|
| 66 |
+
"title": title,
|
| 67 |
+
"last_activity": (last_activity or now),
|
| 68 |
+
"updated_at": now
|
| 69 |
+
}, "$setOnInsert": {"created_at": now}},
|
| 70 |
+
upsert=True
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def list_patient_sessions(
|
| 75 |
+
patient_id: str,
|
| 76 |
+
/,
|
| 77 |
+
*,
|
| 78 |
+
collection_name: str = CHAT_SESSIONS_COLLECTION
|
| 79 |
+
) -> list[dict[str, Any]]:
|
| 80 |
+
collection = get_collection(collection_name)
|
| 81 |
+
sessions = list(collection.find({"patient_id": patient_id}).sort("last_activity", DESCENDING))
|
| 82 |
+
# Convert ObjectId to string for JSON serialization
|
| 83 |
+
for session in sessions:
|
| 84 |
+
if "_id" in session:
|
| 85 |
+
session["_id"] = str(session["_id"])
|
| 86 |
+
return sessions
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def delete_session(
|
| 90 |
+
session_id: str,
|
| 91 |
+
/,
|
| 92 |
+
*,
|
| 93 |
+
collection_name: str = CHAT_SESSIONS_COLLECTION
|
| 94 |
+
) -> bool:
|
| 95 |
+
"""Delete a chat session from MongoDB"""
|
| 96 |
+
collection = get_collection(collection_name)
|
| 97 |
+
result = collection.delete_one({"session_id": session_id})
|
| 98 |
+
return result.deleted_count > 0
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def delete_session_messages(
|
| 102 |
+
session_id: str,
|
| 103 |
+
/,
|
| 104 |
+
*,
|
| 105 |
+
collection_name: str = CHAT_MESSAGES_COLLECTION
|
| 106 |
+
) -> int:
|
| 107 |
+
"""Delete all messages for a session from MongoDB"""
|
| 108 |
+
collection = get_collection(collection_name)
|
| 109 |
+
result = collection.delete_many({"session_id": session_id})
|
| 110 |
+
return result.deleted_count
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def delete_old_sessions(
|
| 114 |
+
days: int = 30,
|
| 115 |
+
*,
|
| 116 |
+
collection_name: str = CHAT_SESSIONS_COLLECTION
|
| 117 |
+
) -> int:
|
| 118 |
+
"""Delete chat sessions older than specified days"""
|
| 119 |
+
collection = get_collection(collection_name)
|
| 120 |
+
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
|
| 121 |
+
result = collection.delete_many({
|
| 122 |
+
"updated_at": {"$lt": cutoff}
|
| 123 |
+
})
|
| 124 |
+
if result.deleted_count > 0:
|
| 125 |
+
logger.info(f"Deleted {result.deleted_count} old sessions (>{days} days)")
|
| 126 |
+
return result.deleted_count
|
src/data/user/__init__.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# data/user/__init__.py
|
| 2 |
+
"""
|
| 3 |
+
User management operations for MongoDB.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from .operations import (
|
| 7 |
+
create_account,
|
| 8 |
+
update_account,
|
| 9 |
+
get_account_frame,
|
| 10 |
+
create_doctor,
|
| 11 |
+
get_doctor_by_name,
|
| 12 |
+
search_doctors,
|
| 13 |
+
get_all_doctors,
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
__all__ = [
|
| 17 |
+
'create_account',
|
| 18 |
+
'update_account',
|
| 19 |
+
'get_account_frame',
|
| 20 |
+
'create_doctor',
|
| 21 |
+
'get_doctor_by_name',
|
| 22 |
+
'search_doctors',
|
| 23 |
+
'get_all_doctors',
|
| 24 |
+
]
|
src/data/user/operations.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# data/user/operations.py
|
| 2 |
+
"""
|
| 3 |
+
User management operations for MongoDB.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from datetime import datetime, timezone
|
| 7 |
+
from typing import Any
|
| 8 |
+
|
| 9 |
+
import re
|
| 10 |
+
from pandas import DataFrame
|
| 11 |
+
from pymongo import ASCENDING
|
| 12 |
+
from pymongo.errors import DuplicateKeyError
|
| 13 |
+
|
| 14 |
+
from ..connection import get_collection, ACCOUNTS_COLLECTION
|
| 15 |
+
from src.utils.logger import get_logger
|
| 16 |
+
|
| 17 |
+
logger = get_logger("USER_OPS")
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def get_account_frame(
|
| 21 |
+
*,
|
| 22 |
+
collection_name: str = ACCOUNTS_COLLECTION
|
| 23 |
+
) -> DataFrame:
|
| 24 |
+
"""Get accounts as a pandas DataFrame"""
|
| 25 |
+
return DataFrame(get_collection(collection_name).find())
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def create_account(
|
| 29 |
+
user_data: dict[str, Any],
|
| 30 |
+
/,
|
| 31 |
+
*,
|
| 32 |
+
collection_name: str = ACCOUNTS_COLLECTION
|
| 33 |
+
) -> str:
|
| 34 |
+
"""Create a new user account"""
|
| 35 |
+
collection = get_collection(collection_name)
|
| 36 |
+
now = datetime.now(timezone.utc)
|
| 37 |
+
user_data["created_at"] = now
|
| 38 |
+
user_data["updated_at"] = now
|
| 39 |
+
try:
|
| 40 |
+
result = collection.insert_one(user_data)
|
| 41 |
+
logger.info(f"Created new account: {result.inserted_id}")
|
| 42 |
+
return str(result.inserted_id)
|
| 43 |
+
except DuplicateKeyError as e:
|
| 44 |
+
logger.error(f"Failed to create account - duplicate key: {str(e)}")
|
| 45 |
+
raise DuplicateKeyError(f"Account already exists: {e}") from e
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def update_account(
|
| 49 |
+
user_id: str,
|
| 50 |
+
updates: dict[str, Any],
|
| 51 |
+
/,
|
| 52 |
+
*,
|
| 53 |
+
collection_name: str = ACCOUNTS_COLLECTION
|
| 54 |
+
) -> bool:
|
| 55 |
+
"""Update an existing user account"""
|
| 56 |
+
collection = get_collection(collection_name)
|
| 57 |
+
updates["updated_at"] = datetime.now(timezone.utc)
|
| 58 |
+
result = collection.update_one(
|
| 59 |
+
{"_id": user_id},
|
| 60 |
+
{"$set": updates}
|
| 61 |
+
)
|
| 62 |
+
return result.modified_count > 0
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def create_doctor(
|
| 66 |
+
*,
|
| 67 |
+
name: str,
|
| 68 |
+
role: str | None = None,
|
| 69 |
+
specialty: str | None = None,
|
| 70 |
+
medical_roles: list[str] | None = None
|
| 71 |
+
) -> str:
|
| 72 |
+
"""Create a new doctor profile"""
|
| 73 |
+
collection = get_collection(ACCOUNTS_COLLECTION)
|
| 74 |
+
now = datetime.now(timezone.utc)
|
| 75 |
+
doctor_doc = {
|
| 76 |
+
"name": name,
|
| 77 |
+
"role": role,
|
| 78 |
+
"specialty": specialty,
|
| 79 |
+
"medical_roles": medical_roles or [],
|
| 80 |
+
"created_at": now,
|
| 81 |
+
"updated_at": now
|
| 82 |
+
}
|
| 83 |
+
try:
|
| 84 |
+
result = collection.insert_one(doctor_doc)
|
| 85 |
+
logger.info(f"Created new doctor: {name} with id {result.inserted_id}")
|
| 86 |
+
return str(result.inserted_id)
|
| 87 |
+
except Exception as e:
|
| 88 |
+
logger.error(f"Error creating doctor: {e}")
|
| 89 |
+
raise e
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def get_doctor_by_name(name: str) -> dict[str, Any] | None:
|
| 93 |
+
"""Get doctor by name from accounts collection"""
|
| 94 |
+
collection = get_collection(ACCOUNTS_COLLECTION)
|
| 95 |
+
doctor = collection.find_one({
|
| 96 |
+
"name": name,
|
| 97 |
+
"role": {"$in": ["Doctor", "Healthcare Prof", "General Practitioner", "Cardiologist", "Pediatrician", "Neurologist", "Dermatologist"]}
|
| 98 |
+
})
|
| 99 |
+
if doctor:
|
| 100 |
+
doctor["_id"] = str(doctor.get("_id")) if doctor.get("_id") else None
|
| 101 |
+
return doctor
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def search_doctors(query: str, limit: int = 10) -> list[dict[str, Any]]:
|
| 105 |
+
"""Search doctors by name (case-insensitive contains) from accounts collection"""
|
| 106 |
+
collection = get_collection(ACCOUNTS_COLLECTION)
|
| 107 |
+
if not query:
|
| 108 |
+
return []
|
| 109 |
+
|
| 110 |
+
logger.info(f"Searching doctors with query: '{query}', limit: {limit}")
|
| 111 |
+
|
| 112 |
+
# Build a regex for name search
|
| 113 |
+
pattern = re.compile(re.escape(query), re.IGNORECASE)
|
| 114 |
+
|
| 115 |
+
try:
|
| 116 |
+
cursor = collection.find({
|
| 117 |
+
"name": {"$regex": pattern},
|
| 118 |
+
"role": {"$in": ["Doctor", "Healthcare Prof", "General Practitioner", "Cardiologist", "Pediatrician", "Neurologist", "Dermatologist"]}
|
| 119 |
+
}).sort("name", ASCENDING).limit(limit)
|
| 120 |
+
results = []
|
| 121 |
+
for d in cursor:
|
| 122 |
+
d["_id"] = str(d.get("_id")) if d.get("_id") else None
|
| 123 |
+
results.append(d)
|
| 124 |
+
logger.info(f"Found {len(results)} doctors matching query")
|
| 125 |
+
return results
|
| 126 |
+
except Exception as e:
|
| 127 |
+
logger.error(f"Error in search_doctors: {e}")
|
| 128 |
+
return []
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def get_all_doctors(limit: int = 50) -> list[dict[str, Any]]:
|
| 132 |
+
"""Get all doctors with optional limit from accounts collection"""
|
| 133 |
+
collection = get_collection(ACCOUNTS_COLLECTION)
|
| 134 |
+
try:
|
| 135 |
+
cursor = collection.find({
|
| 136 |
+
"role": {"$in": ["Doctor", "Healthcare Prof", "General Practitioner", "Cardiologist", "Pediatrician", "Neurologist", "Dermatologist"]}
|
| 137 |
+
}).sort("name", ASCENDING).limit(limit)
|
| 138 |
+
results = []
|
| 139 |
+
for d in cursor:
|
| 140 |
+
d["_id"] = str(d.get("_id")) if d.get("_id") else None
|
| 141 |
+
results.append(d)
|
| 142 |
+
logger.info(f"Retrieved {len(results)} doctors")
|
| 143 |
+
return results
|
| 144 |
+
except Exception as e:
|
| 145 |
+
logger.error(f"Error getting all doctors: {e}")
|
| 146 |
+
return []
|
src/data/utils.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# data/utils.py
|
| 2 |
+
"""
|
| 3 |
+
Utility functions for MongoDB operations.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from datetime import datetime, timezone
|
| 7 |
+
from typing import Any
|
| 8 |
+
|
| 9 |
+
from pymongo import ASCENDING
|
| 10 |
+
|
| 11 |
+
from .connection import get_collection, get_database
|
| 12 |
+
from src.utils.logger import get_logger
|
| 13 |
+
|
| 14 |
+
logger = get_logger("MONGO_UTILS")
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def create_index(
|
| 18 |
+
collection_name: str,
|
| 19 |
+
field_name: str,
|
| 20 |
+
/,
|
| 21 |
+
unique: bool = False
|
| 22 |
+
) -> None:
|
| 23 |
+
"""Create an index on a collection"""
|
| 24 |
+
collection = get_collection(collection_name)
|
| 25 |
+
collection.create_index([(field_name, ASCENDING)], unique=unique)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def backup_collection(collection_name: str) -> str:
|
| 29 |
+
"""Create a backup of a collection"""
|
| 30 |
+
collection = get_collection(collection_name)
|
| 31 |
+
backup_name = f"{collection_name}_backup_{datetime.now(timezone.utc).strftime('%Y%m%d')}"
|
| 32 |
+
db = get_database()
|
| 33 |
+
|
| 34 |
+
# Drop existing backup if it exists
|
| 35 |
+
if backup_name in db.list_collection_names():
|
| 36 |
+
logger.info(f"Removing existing backup: {backup_name}")
|
| 37 |
+
db.drop_collection(backup_name)
|
| 38 |
+
|
| 39 |
+
db.create_collection(backup_name)
|
| 40 |
+
backup = db[backup_name]
|
| 41 |
+
|
| 42 |
+
doc_count = 0
|
| 43 |
+
for doc in collection.find():
|
| 44 |
+
backup.insert_one(doc)
|
| 45 |
+
doc_count += 1
|
| 46 |
+
|
| 47 |
+
logger.info(f"Created backup {backup_name} with {doc_count} documents")
|
| 48 |
+
return backup_name
|
src/models/chat.py
CHANGED
|
@@ -4,6 +4,8 @@ from pydantic import BaseModel
|
|
| 4 |
|
| 5 |
class ChatRequest(BaseModel):
|
| 6 |
user_id: str
|
|
|
|
|
|
|
| 7 |
session_id: str
|
| 8 |
message: str
|
| 9 |
user_role: str | None = "Medical Professional"
|
|
@@ -18,6 +20,8 @@ class ChatResponse(BaseModel):
|
|
| 18 |
|
| 19 |
class SessionRequest(BaseModel):
|
| 20 |
user_id: str
|
|
|
|
|
|
|
| 21 |
title: str | None = "New Chat"
|
| 22 |
|
| 23 |
class SummariseRequest(BaseModel):
|
|
|
|
| 4 |
|
| 5 |
class ChatRequest(BaseModel):
|
| 6 |
user_id: str
|
| 7 |
+
patient_id: str
|
| 8 |
+
doctor_id: str
|
| 9 |
session_id: str
|
| 10 |
message: str
|
| 11 |
user_role: str | None = "Medical Professional"
|
|
|
|
| 20 |
|
| 21 |
class SessionRequest(BaseModel):
|
| 22 |
user_id: str
|
| 23 |
+
patient_id: str
|
| 24 |
+
doctor_id: str
|
| 25 |
title: str | None = "New Chat"
|
| 26 |
|
| 27 |
class SummariseRequest(BaseModel):
|
src/models/user.py
CHANGED
|
@@ -1,9 +1,38 @@
|
|
| 1 |
# model/user.py
|
| 2 |
-
|
| 3 |
from pydantic import BaseModel
|
|
|
|
| 4 |
|
| 5 |
class UserProfileRequest(BaseModel):
|
| 6 |
user_id: str
|
| 7 |
name: str
|
| 8 |
role: str
|
| 9 |
-
specialty: str
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
# model/user.py
|
|
|
|
| 2 |
from pydantic import BaseModel
|
| 3 |
+
from typing import Optional, List
|
| 4 |
|
| 5 |
class UserProfileRequest(BaseModel):
|
| 6 |
user_id: str
|
| 7 |
name: str
|
| 8 |
role: str
|
| 9 |
+
specialty: Optional[str] = None
|
| 10 |
+
medical_roles: Optional[List[str]] = None
|
| 11 |
+
|
| 12 |
+
class PatientCreateRequest(BaseModel):
|
| 13 |
+
name: str
|
| 14 |
+
age: int
|
| 15 |
+
sex: str
|
| 16 |
+
address: Optional[str] = None
|
| 17 |
+
phone: Optional[str] = None
|
| 18 |
+
email: Optional[str] = None
|
| 19 |
+
medications: Optional[List[str]] = None
|
| 20 |
+
past_assessment_summary: Optional[str] = None
|
| 21 |
+
assigned_doctor_id: Optional[str] = None
|
| 22 |
+
|
| 23 |
+
class PatientUpdateRequest(BaseModel):
|
| 24 |
+
name: Optional[str] = None
|
| 25 |
+
age: Optional[int] = None
|
| 26 |
+
sex: Optional[str] = None
|
| 27 |
+
address: Optional[str] = None
|
| 28 |
+
phone: Optional[str] = None
|
| 29 |
+
email: Optional[str] = None
|
| 30 |
+
medications: Optional[List[str]] = None
|
| 31 |
+
past_assessment_summary: Optional[str] = None
|
| 32 |
+
assigned_doctor_id: Optional[str] = None
|
| 33 |
+
|
| 34 |
+
class DoctorCreateRequest(BaseModel):
|
| 35 |
+
name: str
|
| 36 |
+
role: Optional[str] = None
|
| 37 |
+
specialty: Optional[str] = None
|
| 38 |
+
medical_roles: Optional[List[str]] = None
|
start.py
CHANGED
|
@@ -36,10 +36,26 @@ def main():
|
|
| 36 |
else:
|
| 37 |
print(f"✅ Found {len(gemini_keys)} Gemini API keys")
|
| 38 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
print("\n📱 Starting Medical AI Assistant...")
|
| 40 |
-
print("🌐 Web UI will be available at:
|
| 41 |
-
print("📚 API documentation at:
|
| 42 |
-
print("🔍 Health check at:
|
| 43 |
print("\nPress Ctrl+C to stop the server")
|
| 44 |
print("=" * 50)
|
| 45 |
|
|
|
|
| 36 |
else:
|
| 37 |
print(f"✅ Found {len(gemini_keys)} Gemini API keys")
|
| 38 |
|
| 39 |
+
# Check for MongoDB environment variables
|
| 40 |
+
mongo_user = os.getenv("MONGO_USER")
|
| 41 |
+
user_db = os.getenv("USER_DB")
|
| 42 |
+
|
| 43 |
+
if not mongo_user:
|
| 44 |
+
print("❌ Error: MONGO_USER environment variable not found!")
|
| 45 |
+
print("Set MONGO_USER environment variable for database connectivity.")
|
| 46 |
+
sys.exit(1)
|
| 47 |
+
|
| 48 |
+
if not user_db:
|
| 49 |
+
print("❌ Error: USER_DB environment variable not found!")
|
| 50 |
+
print("Set USER_DB environment variable for database connectivity.")
|
| 51 |
+
sys.exit(1)
|
| 52 |
+
|
| 53 |
+
print("✅ MongoDB environment variables found")
|
| 54 |
+
|
| 55 |
print("\n📱 Starting Medical AI Assistant...")
|
| 56 |
+
print("🌐 Web UI will be available at: https://medai-cos30018-medicaldiagnosissystem.hf.space")
|
| 57 |
+
print("📚 API documentation at: https://medai-cos30018-medicaldiagnosissystem.hf.space/docs")
|
| 58 |
+
print("🔍 Health check at: https://medai-cos30018-medicaldiagnosissystem.hf.space/health")
|
| 59 |
print("\nPress Ctrl+C to stop the server")
|
| 60 |
print("=" * 50)
|
| 61 |
|
static/css/emr.css
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* EMR Page Styles */
|
| 2 |
+
.emr-container {
|
| 3 |
+
min-height: 100vh;
|
| 4 |
+
background-color: var(--bg-primary);
|
| 5 |
+
color: var(--text-primary);
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
.emr-header {
|
| 9 |
+
background-color: var(--bg-secondary);
|
| 10 |
+
border-bottom: 1px solid var(--border-color);
|
| 11 |
+
padding: 1rem 0;
|
| 12 |
+
position: sticky;
|
| 13 |
+
top: 0;
|
| 14 |
+
z-index: 100;
|
| 15 |
+
}
|
| 16 |
+
|
| 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 |
+
flex-wrap: wrap;
|
| 25 |
+
gap: 1rem;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
.back-link {
|
| 29 |
+
display: inline-flex;
|
| 30 |
+
align-items: center;
|
| 31 |
+
gap: 0.5rem;
|
| 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 |
+
.back-link:hover {
|
| 40 |
+
background-color: var(--bg-tertiary);
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
.emr-header h1 {
|
| 44 |
+
margin: 0;
|
| 45 |
+
font-size: 1.5rem;
|
| 46 |
+
font-weight: 600;
|
| 47 |
+
color: var(--text-primary);
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
.patient-info-header {
|
| 51 |
+
display: flex;
|
| 52 |
+
flex-direction: column;
|
| 53 |
+
align-items: flex-end;
|
| 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 |
+
.emr-main {
|
| 70 |
+
max-width: 1200px;
|
| 71 |
+
margin: 0 auto;
|
| 72 |
+
padding: 2rem 1rem;
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
.emr-section {
|
| 76 |
+
background-color: var(--bg-secondary);
|
| 77 |
+
border: 1px solid var(--border-color);
|
| 78 |
+
border-radius: 8px;
|
| 79 |
+
padding: 1.5rem;
|
| 80 |
+
margin-bottom: 2rem;
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
.emr-section h2 {
|
| 84 |
+
margin: 0 0 1.5rem 0;
|
| 85 |
+
font-size: 1.25rem;
|
| 86 |
+
font-weight: 600;
|
| 87 |
+
color: var(--text-primary);
|
| 88 |
+
display: flex;
|
| 89 |
+
align-items: center;
|
| 90 |
+
gap: 0.5rem;
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
.emr-section h2 i {
|
| 94 |
+
color: var(--primary-color);
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
.emr-grid {
|
| 98 |
+
display: grid;
|
| 99 |
+
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
| 100 |
+
gap: 1.5rem;
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
.emr-field {
|
| 104 |
+
display: flex;
|
| 105 |
+
flex-direction: column;
|
| 106 |
+
gap: 0.5rem;
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
.emr-field.full-width {
|
| 110 |
+
grid-column: 1 / -1;
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
.emr-field label {
|
| 114 |
+
font-weight: 500;
|
| 115 |
+
color: var(--text-primary);
|
| 116 |
+
font-size: 0.9rem;
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
.emr-field input,
|
| 120 |
+
.emr-field select,
|
| 121 |
+
.emr-field textarea {
|
| 122 |
+
padding: 0.75rem;
|
| 123 |
+
border: 1px solid var(--border-color);
|
| 124 |
+
border-radius: 6px;
|
| 125 |
+
background-color: var(--bg-primary);
|
| 126 |
+
color: var(--text-primary);
|
| 127 |
+
font-size: 0.9rem;
|
| 128 |
+
transition: border-color var(--transition-fast);
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
.emr-field input:focus,
|
| 132 |
+
.emr-field select:focus,
|
| 133 |
+
.emr-field textarea:focus {
|
| 134 |
+
outline: none;
|
| 135 |
+
border-color: var(--primary-color);
|
| 136 |
+
box-shadow: 0 0 0 2px rgba(var(--primary-color-rgb), 0.1);
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
.emr-field textarea {
|
| 140 |
+
resize: vertical;
|
| 141 |
+
min-height: 80px;
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
/* Medications */
|
| 145 |
+
.medications-container {
|
| 146 |
+
display: flex;
|
| 147 |
+
flex-direction: column;
|
| 148 |
+
gap: 1rem;
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
.medications-list {
|
| 152 |
+
display: flex;
|
| 153 |
+
flex-wrap: wrap;
|
| 154 |
+
gap: 0.5rem;
|
| 155 |
+
min-height: 40px;
|
| 156 |
+
padding: 0.5rem;
|
| 157 |
+
border: 1px solid var(--border-color);
|
| 158 |
+
border-radius: 6px;
|
| 159 |
+
background-color: var(--bg-primary);
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
.medication-tag {
|
| 163 |
+
display: inline-flex;
|
| 164 |
+
align-items: center;
|
| 165 |
+
gap: 0.5rem;
|
| 166 |
+
background-color: var(--primary-color);
|
| 167 |
+
color: white;
|
| 168 |
+
padding: 0.25rem 0.75rem;
|
| 169 |
+
border-radius: 20px;
|
| 170 |
+
font-size: 0.8rem;
|
| 171 |
+
font-weight: 500;
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
.medication-tag .remove-medication {
|
| 175 |
+
background: none;
|
| 176 |
+
border: none;
|
| 177 |
+
color: white;
|
| 178 |
+
cursor: pointer;
|
| 179 |
+
padding: 0;
|
| 180 |
+
margin-left: 0.25rem;
|
| 181 |
+
opacity: 0.7;
|
| 182 |
+
transition: opacity var(--transition-fast);
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
.medication-tag .remove-medication:hover {
|
| 186 |
+
opacity: 1;
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
.add-medication {
|
| 190 |
+
display: flex;
|
| 191 |
+
gap: 0.5rem;
|
| 192 |
+
align-items: center;
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
.add-medication input {
|
| 196 |
+
flex: 1;
|
| 197 |
+
margin: 0;
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
.add-medication button {
|
| 201 |
+
white-space: nowrap;
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
/* Sessions */
|
| 205 |
+
.sessions-container {
|
| 206 |
+
display: flex;
|
| 207 |
+
flex-direction: column;
|
| 208 |
+
gap: 1rem;
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
.session-item {
|
| 212 |
+
background-color: var(--bg-primary);
|
| 213 |
+
border: 1px solid var(--border-color);
|
| 214 |
+
border-radius: 6px;
|
| 215 |
+
padding: 1rem;
|
| 216 |
+
cursor: pointer;
|
| 217 |
+
transition: all var(--transition-fast);
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
.session-item:hover {
|
| 221 |
+
border-color: var(--primary-color);
|
| 222 |
+
background-color: var(--bg-tertiary);
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
.session-title {
|
| 226 |
+
font-weight: 600;
|
| 227 |
+
color: var(--text-primary);
|
| 228 |
+
margin-bottom: 0.5rem;
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
.session-meta {
|
| 232 |
+
display: flex;
|
| 233 |
+
justify-content: space-between;
|
| 234 |
+
align-items: center;
|
| 235 |
+
font-size: 0.8rem;
|
| 236 |
+
color: var(--text-secondary);
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
.session-date {
|
| 240 |
+
font-family: monospace;
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
.session-messages {
|
| 244 |
+
color: var(--text-secondary);
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
/* Actions */
|
| 248 |
+
.emr-actions {
|
| 249 |
+
display: flex;
|
| 250 |
+
gap: 1rem;
|
| 251 |
+
flex-wrap: wrap;
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
.btn-primary,
|
| 255 |
+
.btn-secondary {
|
| 256 |
+
display: inline-flex;
|
| 257 |
+
align-items: center;
|
| 258 |
+
gap: 0.5rem;
|
| 259 |
+
padding: 0.75rem 1.5rem;
|
| 260 |
+
border: none;
|
| 261 |
+
border-radius: 6px;
|
| 262 |
+
font-size: 0.9rem;
|
| 263 |
+
font-weight: 500;
|
| 264 |
+
cursor: pointer;
|
| 265 |
+
transition: all var(--transition-fast);
|
| 266 |
+
text-decoration: none;
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
.btn-primary {
|
| 270 |
+
background-color: var(--primary-color);
|
| 271 |
+
color: white;
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
.btn-primary:hover {
|
| 275 |
+
background-color: var(--primary-color-dark);
|
| 276 |
+
transform: translateY(-1px);
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
.btn-secondary {
|
| 280 |
+
background-color: var(--bg-tertiary);
|
| 281 |
+
color: var(--text-primary);
|
| 282 |
+
border: 1px solid var(--border-color);
|
| 283 |
+
}
|
| 284 |
+
|
| 285 |
+
.btn-secondary:hover {
|
| 286 |
+
background-color: var(--bg-primary);
|
| 287 |
+
border-color: var(--primary-color);
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
/* Loading States */
|
| 291 |
+
.loading {
|
| 292 |
+
opacity: 0.6;
|
| 293 |
+
pointer-events: none;
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
/* Responsive Design */
|
| 297 |
+
@media (max-width: 768px) {
|
| 298 |
+
.emr-header-content {
|
| 299 |
+
flex-direction: column;
|
| 300 |
+
align-items: stretch;
|
| 301 |
+
text-align: center;
|
| 302 |
+
}
|
| 303 |
+
|
| 304 |
+
.patient-info-header {
|
| 305 |
+
align-items: center;
|
| 306 |
+
text-align: center;
|
| 307 |
+
}
|
| 308 |
+
|
| 309 |
+
.emr-main {
|
| 310 |
+
padding: 1rem;
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
.emr-grid {
|
| 314 |
+
grid-template-columns: 1fr;
|
| 315 |
+
}
|
| 316 |
+
|
| 317 |
+
.emr-actions {
|
| 318 |
+
flex-direction: column;
|
| 319 |
+
}
|
| 320 |
+
|
| 321 |
+
.add-medication {
|
| 322 |
+
flex-direction: column;
|
| 323 |
+
align-items: stretch;
|
| 324 |
+
}
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
/* Dark Theme Adjustments */
|
| 328 |
+
[data-theme="dark"] .emr-field input,
|
| 329 |
+
[data-theme="dark"] .emr-field select,
|
| 330 |
+
[data-theme="dark"] .emr-field textarea {
|
| 331 |
+
background-color: var(--bg-secondary);
|
| 332 |
+
}
|
| 333 |
+
|
| 334 |
+
[data-theme="dark"] .medications-list {
|
| 335 |
+
background-color: var(--bg-secondary);
|
| 336 |
+
}
|
| 337 |
+
|
| 338 |
+
[data-theme="dark"] .session-item {
|
| 339 |
+
background-color: var(--bg-secondary);
|
| 340 |
+
}
|
static/css/patient.css
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; background: #0f172a; color: #e2e8f0; }
|
| 2 |
+
.container { max-width: 900px; margin: 40px auto; padding: 24px; background: #111827; border-radius: 12px; box-shadow: 0 10px 30px rgba(0,0,0,0.4); }
|
| 3 |
+
.back-link { display:inline-flex; align-items:center; gap:8px; color:#93c5fd; text-decoration:none; margin-bottom:12px; }
|
| 4 |
+
.back-link:hover { text-decoration:underline; }
|
| 5 |
+
h1 { margin-bottom: 16px; font-size: 1.6rem; }
|
| 6 |
+
.grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
|
| 7 |
+
label { display: grid; gap: 6px; font-size: 0.9rem; }
|
| 8 |
+
input, select, textarea { padding: 10px; border-radius: 8px; border: 1px solid #334155; background: #0b1220; color: #e2e8f0; }
|
| 9 |
+
textarea { min-height: 100px; }
|
| 10 |
+
.actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 18px; }
|
| 11 |
+
.primary { background: #2563eb; color: #fff; border: none; padding: 10px 14px; border-radius: 8px; cursor: pointer; }
|
| 12 |
+
.secondary { background: transparent; color: #e2e8f0; border: 1px solid #334155; padding: 10px 14px; border-radius: 8px; cursor: pointer; }
|
| 13 |
+
.result { margin-top: 12px; color: #93c5fd; }
|
| 14 |
+
@media (max-width: 720px) { .grid { grid-template-columns: 1fr; } }
|
| 15 |
+
.modal { position: fixed; inset:0; display:none; align-items:center; justify-content:center; background: rgba(0,0,0,0.45); z-index: 3000; }
|
| 16 |
+
.modal.show { display:flex; }
|
| 17 |
+
.modal-content { background:#111827; color:#e2e8f0; width: 520px; max-width: 92vw; border-radius: 12px; overflow:hidden; box-shadow: 0 10px 30px rgba(0,0,0,0.4); }
|
| 18 |
+
.modal-header, .modal-footer { padding: 16px; border-bottom: 1px solid #334155; display:flex; align-items:center; justify-content: space-between; }
|
| 19 |
+
.modal-footer { border-bottom: none; border-top: 1px solid #334155; justify-content: flex-end; gap: 8px; }
|
| 20 |
+
.modal-body { padding: 16px; }
|
| 21 |
+
.modal-close { background: transparent; border: none; color: #94a3b8; font-size: 20px; cursor: pointer; }
|
| 22 |
+
.big-id { font-size: 28px; letter-spacing: 2px; font-weight: 700; color: #93c5fd; margin: 8px 0 4px; }
|
static/css/styles.css
CHANGED
|
@@ -80,6 +80,8 @@ body {
|
|
| 80 |
height: 100vh;
|
| 81 |
overflow: hidden;
|
| 82 |
}
|
|
|
|
|
|
|
| 83 |
|
| 84 |
/* Sidebar */
|
| 85 |
.sidebar {
|
|
@@ -89,7 +91,7 @@ body {
|
|
| 89 |
display: flex;
|
| 90 |
flex-direction: column;
|
| 91 |
transition: transform var(--transition-normal);
|
| 92 |
-
z-index:
|
| 93 |
}
|
| 94 |
|
| 95 |
.sidebar-header {
|
|
@@ -358,6 +360,19 @@ body {
|
|
| 358 |
background-color: var(--bg-tertiary);
|
| 359 |
}
|
| 360 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 361 |
.chat-title {
|
| 362 |
flex: 1;
|
| 363 |
font-size: 1.25rem;
|
|
@@ -742,11 +757,15 @@ body {
|
|
| 742 |
left: 0;
|
| 743 |
top: 0;
|
| 744 |
height: 100vh;
|
|
|
|
| 745 |
transform: translateX(-100%);
|
|
|
|
|
|
|
| 746 |
}
|
| 747 |
|
| 748 |
.sidebar.show {
|
| 749 |
transform: translateX(0);
|
|
|
|
| 750 |
}
|
| 751 |
|
| 752 |
.sidebar-toggle {
|
|
@@ -757,6 +776,24 @@ body {
|
|
| 757 |
font-size: 1rem;
|
| 758 |
}
|
| 759 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 760 |
.modal-content {
|
| 761 |
width: 95%;
|
| 762 |
margin: var(--spacing-md);
|
|
@@ -858,3 +895,111 @@ body {
|
|
| 858 |
color: var(--primary-color);
|
| 859 |
margin-right: var(--spacing-sm);
|
| 860 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
height: 100vh;
|
| 81 |
overflow: hidden;
|
| 82 |
}
|
| 83 |
+
.app-overlay { position: fixed; inset:0; background: rgba(0,0,0,0.2); display:none; z-index: 1000; }
|
| 84 |
+
.app-overlay.show { display:block; }
|
| 85 |
|
| 86 |
/* Sidebar */
|
| 87 |
.sidebar {
|
|
|
|
| 91 |
display: flex;
|
| 92 |
flex-direction: column;
|
| 93 |
transition: transform var(--transition-normal);
|
| 94 |
+
z-index: 1001;
|
| 95 |
}
|
| 96 |
|
| 97 |
.sidebar-header {
|
|
|
|
| 360 |
background-color: var(--bg-tertiary);
|
| 361 |
}
|
| 362 |
|
| 363 |
+
.sidebar-close {
|
| 364 |
+
background: none;
|
| 365 |
+
border: none;
|
| 366 |
+
color: var(--text-secondary);
|
| 367 |
+
font-size: 1.2rem;
|
| 368 |
+
cursor: pointer;
|
| 369 |
+
padding: var(--spacing-sm);
|
| 370 |
+
border-radius: 6px;
|
| 371 |
+
transition: background-color var(--transition-fast);
|
| 372 |
+
margin-left: auto;
|
| 373 |
+
}
|
| 374 |
+
.sidebar-close:hover { background-color: var(--bg-tertiary); }
|
| 375 |
+
|
| 376 |
.chat-title {
|
| 377 |
flex: 1;
|
| 378 |
font-size: 1.25rem;
|
|
|
|
| 757 |
left: 0;
|
| 758 |
top: 0;
|
| 759 |
height: 100vh;
|
| 760 |
+
width: 300px;
|
| 761 |
transform: translateX(-100%);
|
| 762 |
+
transition: transform 0.3s ease;
|
| 763 |
+
z-index: 1001;
|
| 764 |
}
|
| 765 |
|
| 766 |
.sidebar.show {
|
| 767 |
transform: translateX(0);
|
| 768 |
+
z-index: 1002;
|
| 769 |
}
|
| 770 |
|
| 771 |
.sidebar-toggle {
|
|
|
|
| 776 |
font-size: 1rem;
|
| 777 |
}
|
| 778 |
|
| 779 |
+
.app-overlay {
|
| 780 |
+
position: fixed;
|
| 781 |
+
top: 0;
|
| 782 |
+
left: 0;
|
| 783 |
+
width: 100%;
|
| 784 |
+
height: 100%;
|
| 785 |
+
background: rgba(0, 0, 0, 0.5);
|
| 786 |
+
z-index: 999;
|
| 787 |
+
display: none;
|
| 788 |
+
opacity: 0;
|
| 789 |
+
transition: opacity 0.3s ease;
|
| 790 |
+
}
|
| 791 |
+
|
| 792 |
+
.app-overlay.show {
|
| 793 |
+
display: block;
|
| 794 |
+
opacity: 1;
|
| 795 |
+
}
|
| 796 |
+
|
| 797 |
.modal-content {
|
| 798 |
width: 95%;
|
| 799 |
margin: var(--spacing-md);
|
|
|
|
| 895 |
color: var(--primary-color);
|
| 896 |
margin-right: var(--spacing-sm);
|
| 897 |
}
|
| 898 |
+
|
| 899 |
+
.patient-section {
|
| 900 |
+
padding: var(--spacing-lg);
|
| 901 |
+
border-bottom: 1px solid var(--border-color);
|
| 902 |
+
}
|
| 903 |
+
|
| 904 |
+
.patient-header {
|
| 905 |
+
font-weight: 600;
|
| 906 |
+
margin-bottom: var(--spacing-sm);
|
| 907 |
+
color: var(--text-primary);
|
| 908 |
+
}
|
| 909 |
+
|
| 910 |
+
.patient-input-group {
|
| 911 |
+
display: flex;
|
| 912 |
+
gap: var(--spacing-sm);
|
| 913 |
+
align-items: center;
|
| 914 |
+
}
|
| 915 |
+
.patient-typeahead { position: relative; }
|
| 916 |
+
.patient-suggestions { position:absolute; top: 100%; left:0; right:0; background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 6px; box-shadow: var(--shadow-lg); z-index: 3500; max-height: 220px; overflow-y: auto; }
|
| 917 |
+
.patient-suggestion { padding: 8px 10px; cursor:pointer; color: var(--text-primary); }
|
| 918 |
+
.patient-suggestion:hover { background: var(--bg-tertiary); }
|
| 919 |
+
|
| 920 |
+
.patient-input {
|
| 921 |
+
flex: 1;
|
| 922 |
+
padding: 8px 10px;
|
| 923 |
+
border: 1px solid var(--border-color);
|
| 924 |
+
border-radius: 6px;
|
| 925 |
+
background: var(--bg-primary);
|
| 926 |
+
color: var(--text-primary);
|
| 927 |
+
}
|
| 928 |
+
|
| 929 |
+
.patient-load-btn {
|
| 930 |
+
padding: 8px 10px;
|
| 931 |
+
background: var(--primary-color);
|
| 932 |
+
color: #fff;
|
| 933 |
+
border: none;
|
| 934 |
+
border-radius: 6px;
|
| 935 |
+
cursor: pointer;
|
| 936 |
+
}
|
| 937 |
+
.patient-load-btn:hover {
|
| 938 |
+
background: var(--primary-hover);
|
| 939 |
+
}
|
| 940 |
+
.patient-create-link {
|
| 941 |
+
display:inline-flex; align-items:center;
|
| 942 |
+
justify-content:center; padding:8px 10px;
|
| 943 |
+
border: 1px solid var(--border-color);
|
| 944 |
+
border-radius:6px; color: var(--text-secondary);
|
| 945 |
+
text-decoration:none;
|
| 946 |
+
}
|
| 947 |
+
.patient-create-link:hover { background: var(--bg-tertiary); }
|
| 948 |
+
|
| 949 |
+
.patient-status {
|
| 950 |
+
margin-top: var(--spacing-sm);
|
| 951 |
+
font-size: 0.8rem;
|
| 952 |
+
color: var(--text-secondary);
|
| 953 |
+
}
|
| 954 |
+
|
| 955 |
+
.patient-actions {
|
| 956 |
+
margin-top: var(--spacing-sm);
|
| 957 |
+
text-align: center;
|
| 958 |
+
}
|
| 959 |
+
|
| 960 |
+
.emr-link {
|
| 961 |
+
display: inline-flex;
|
| 962 |
+
align-items: center;
|
| 963 |
+
gap: 0.5rem;
|
| 964 |
+
padding: 0.5rem 1rem;
|
| 965 |
+
background-color: var(--primary-color);
|
| 966 |
+
color: white;
|
| 967 |
+
text-decoration: none;
|
| 968 |
+
border-radius: 6px;
|
| 969 |
+
font-size: 0.8rem;
|
| 970 |
+
font-weight: 500;
|
| 971 |
+
transition: all var(--transition-fast);
|
| 972 |
+
}
|
| 973 |
+
|
| 974 |
+
.emr-link:hover {
|
| 975 |
+
background-color: var(--primary-color-dark);
|
| 976 |
+
transform: translateY(-1px);
|
| 977 |
+
color: white;
|
| 978 |
+
}
|
| 979 |
+
|
| 980 |
+
/* Sidebar mobile behavior */
|
| 981 |
+
@media (max-width: 1024px) {
|
| 982 |
+
.sidebar {
|
| 983 |
+
position: fixed;
|
| 984 |
+
top: 0;
|
| 985 |
+
bottom: 0;
|
| 986 |
+
left: 0;
|
| 987 |
+
transform: translateX(-100%);
|
| 988 |
+
width: 85vw;
|
| 989 |
+
}
|
| 990 |
+
.sidebar.show {
|
| 991 |
+
transform: translateX(0);
|
| 992 |
+
}
|
| 993 |
+
.main-content {
|
| 994 |
+
position: relative;
|
| 995 |
+
z-index: 1;
|
| 996 |
+
}
|
| 997 |
+
.sidebar-toggle {
|
| 998 |
+
display: inline-flex;
|
| 999 |
+
}
|
| 1000 |
+
}
|
| 1001 |
+
|
| 1002 |
+
/* Loading overlay default hidden */
|
| 1003 |
+
.loading-overlay { display: none; align-items: center; justify-content: center; position: fixed; inset: 0; background: rgba(0,0,0,0.35); z-index: 3000; pointer-events: none; }
|
| 1004 |
+
.loading-overlay.show { display: flex; pointer-events: all; }
|
| 1005 |
+
.loading-spinner { display: grid; gap: 8px; color: #fff; text-align: center; }
|
static/emr.html
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>Patient EMR - Medical AI Assistant</title>
|
| 7 |
+
<link rel="stylesheet" href="/static/css/styles.css">
|
| 8 |
+
<link rel="stylesheet" href="/static/css/emr.css">
|
| 9 |
+
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
| 10 |
+
</head>
|
| 11 |
+
<body>
|
| 12 |
+
<div class="emr-container">
|
| 13 |
+
<!-- Header -->
|
| 14 |
+
<header class="emr-header">
|
| 15 |
+
<div class="emr-header-content">
|
| 16 |
+
<a href="/" class="back-link">
|
| 17 |
+
<i class="fas fa-arrow-left"></i>
|
| 18 |
+
Back to Assistant
|
| 19 |
+
</a>
|
| 20 |
+
<h1>Patient EMR</h1>
|
| 21 |
+
<div class="patient-info-header" id="patientInfoHeader">
|
| 22 |
+
<span class="patient-name" id="patientName">Loading...</span>
|
| 23 |
+
<span class="patient-id" id="patientId">Loading...</span>
|
| 24 |
+
</div>
|
| 25 |
+
</div>
|
| 26 |
+
</header>
|
| 27 |
+
|
| 28 |
+
<!-- Main Content -->
|
| 29 |
+
<main class="emr-main">
|
| 30 |
+
<!-- Patient Overview -->
|
| 31 |
+
<section class="emr-section">
|
| 32 |
+
<h2><i class="fas fa-user"></i> Patient Overview</h2>
|
| 33 |
+
<div class="emr-grid">
|
| 34 |
+
<div class="emr-field">
|
| 35 |
+
<label>Full Name</label>
|
| 36 |
+
<input type="text" id="patientNameInput" placeholder="Enter patient name">
|
| 37 |
+
</div>
|
| 38 |
+
<div class="emr-field">
|
| 39 |
+
<label>Age</label>
|
| 40 |
+
<input type="number" id="patientAgeInput" placeholder="Enter age" min="0" max="150">
|
| 41 |
+
</div>
|
| 42 |
+
<div class="emr-field">
|
| 43 |
+
<label>Sex</label>
|
| 44 |
+
<select id="patientSexInput">
|
| 45 |
+
<option value="">Select sex</option>
|
| 46 |
+
<option value="Male">Male</option>
|
| 47 |
+
<option value="Female">Female</option>
|
| 48 |
+
<option value="Other">Other</option>
|
| 49 |
+
</select>
|
| 50 |
+
</div>
|
| 51 |
+
<div class="emr-field">
|
| 52 |
+
<label>Phone</label>
|
| 53 |
+
<input type="tel" id="patientPhoneInput" placeholder="Enter phone number">
|
| 54 |
+
</div>
|
| 55 |
+
<div class="emr-field">
|
| 56 |
+
<label>Email</label>
|
| 57 |
+
<input type="email" id="patientEmailInput" placeholder="Enter email address">
|
| 58 |
+
</div>
|
| 59 |
+
<div class="emr-field full-width">
|
| 60 |
+
<label>Address</label>
|
| 61 |
+
<textarea id="patientAddressInput" placeholder="Enter full address" rows="3"></textarea>
|
| 62 |
+
</div>
|
| 63 |
+
</div>
|
| 64 |
+
</section>
|
| 65 |
+
|
| 66 |
+
<!-- Medical Information -->
|
| 67 |
+
<section class="emr-section">
|
| 68 |
+
<h2><i class="fas fa-pills"></i> Medical Information</h2>
|
| 69 |
+
<div class="emr-grid">
|
| 70 |
+
<div class="emr-field full-width">
|
| 71 |
+
<label>Current Medications</label>
|
| 72 |
+
<div class="medications-container">
|
| 73 |
+
<div class="medications-list" id="medicationsList">
|
| 74 |
+
<!-- Medications will be added here dynamically -->
|
| 75 |
+
</div>
|
| 76 |
+
<div class="add-medication">
|
| 77 |
+
<input type="text" id="newMedicationInput" placeholder="Add new medication">
|
| 78 |
+
<button id="addMedicationBtn" class="btn-secondary">
|
| 79 |
+
<i class="fas fa-plus"></i> Add
|
| 80 |
+
</button>
|
| 81 |
+
</div>
|
| 82 |
+
</div>
|
| 83 |
+
</div>
|
| 84 |
+
<div class="emr-field full-width">
|
| 85 |
+
<label>Past Assessment Summary</label>
|
| 86 |
+
<textarea id="pastAssessmentInput" placeholder="Enter past assessment summary" rows="4"></textarea>
|
| 87 |
+
</div>
|
| 88 |
+
</div>
|
| 89 |
+
</section>
|
| 90 |
+
|
| 91 |
+
<!-- Recent Sessions -->
|
| 92 |
+
<section class="emr-section">
|
| 93 |
+
<h2><i class="fas fa-comments"></i> Recent Chat Sessions</h2>
|
| 94 |
+
<div class="sessions-container" id="sessionsContainer">
|
| 95 |
+
<!-- Sessions will be loaded here -->
|
| 96 |
+
</div>
|
| 97 |
+
</section>
|
| 98 |
+
|
| 99 |
+
<!-- Actions -->
|
| 100 |
+
<section class="emr-section">
|
| 101 |
+
<h2><i class="fas fa-cog"></i> Actions</h2>
|
| 102 |
+
<div class="emr-actions">
|
| 103 |
+
<button id="savePatientBtn" class="btn-primary">
|
| 104 |
+
<i class="fas fa-save"></i> Save Changes
|
| 105 |
+
</button>
|
| 106 |
+
<button id="refreshPatientBtn" class="btn-secondary">
|
| 107 |
+
<i class="fas fa-refresh"></i> Refresh Data
|
| 108 |
+
</button>
|
| 109 |
+
<button id="exportPatientBtn" class="btn-secondary">
|
| 110 |
+
<i class="fas fa-download"></i> Export EMR
|
| 111 |
+
</button>
|
| 112 |
+
</div>
|
| 113 |
+
</section>
|
| 114 |
+
</main>
|
| 115 |
+
</div>
|
| 116 |
+
|
| 117 |
+
<!-- Loading Overlay -->
|
| 118 |
+
<div class="loading-overlay" id="loadingOverlay">
|
| 119 |
+
<div class="loading-spinner">
|
| 120 |
+
<i class="fas fa-spinner fa-spin"></i>
|
| 121 |
+
<div>Loading...</div>
|
| 122 |
+
</div>
|
| 123 |
+
</div>
|
| 124 |
+
|
| 125 |
+
<script src="/static/js/emr.js"></script>
|
| 126 |
+
</body>
|
| 127 |
+
</html>
|
static/index.html
CHANGED
|
@@ -10,6 +10,7 @@
|
|
| 10 |
</head>
|
| 11 |
<body>
|
| 12 |
<div class="app-container">
|
|
|
|
| 13 |
<!-- Sidebar -->
|
| 14 |
<div class="sidebar" id="sidebar">
|
| 15 |
<div class="sidebar-header">
|
|
@@ -26,7 +27,7 @@
|
|
| 26 |
</div>
|
| 27 |
<div class="user-info">
|
| 28 |
<div class="user-name" id="userName">Anonymous</div>
|
| 29 |
-
<div class="user-status">Medical Professional</div>
|
| 30 |
</div>
|
| 31 |
<button class="user-menu-btn" id="userMenuBtn">
|
| 32 |
<i class="fas fa-ellipsis-v"></i>
|
|
@@ -34,6 +35,27 @@
|
|
| 34 |
</div>
|
| 35 |
</div>
|
| 36 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
<div class="chat-sessions" id="chatSessions">
|
| 38 |
<!-- Chat sessions will be populated here -->
|
| 39 |
</div>
|
|
@@ -122,22 +144,31 @@
|
|
| 122 |
<div class="modal" id="userModal">
|
| 123 |
<div class="modal-content">
|
| 124 |
<div class="modal-header">
|
| 125 |
-
<h3>
|
| 126 |
<button class="modal-close" id="userModalClose">×</button>
|
| 127 |
</div>
|
| 128 |
<div class="modal-body">
|
| 129 |
<div class="form-group">
|
| 130 |
-
<label for="
|
| 131 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
</div>
|
| 133 |
<div class="form-group">
|
| 134 |
<label for="profileRole">Medical Role:</label>
|
| 135 |
<select id="profileRole">
|
| 136 |
-
<option value="
|
|
|
|
| 137 |
<option value="Nurse">Nurse</option>
|
|
|
|
|
|
|
| 138 |
<option value="Medical Student">Medical Student</option>
|
| 139 |
-
<option value="Healthcare Professional">Healthcare Professional</option>
|
| 140 |
-
<option value="Patient">Patient</option>
|
| 141 |
<option value="Other">Other</option>
|
| 142 |
</select>
|
| 143 |
</div>
|
|
@@ -187,10 +218,7 @@
|
|
| 187 |
</label>
|
| 188 |
</div>
|
| 189 |
</div>
|
| 190 |
-
<div class="modal-footer">
|
| 191 |
-
<button class="btn btn-secondary" id="settingsModalCancel">Cancel</button>
|
| 192 |
-
<button class="btn btn-primary" id="settingsModalSave">Save</button>
|
| 193 |
-
</div>
|
| 194 |
</div>
|
| 195 |
</div>
|
| 196 |
|
|
@@ -214,6 +242,27 @@
|
|
| 214 |
</div>
|
| 215 |
</div>
|
| 216 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 217 |
<!-- Loading overlay -->
|
| 218 |
<div class="loading-overlay" id="loadingOverlay">
|
| 219 |
<div class="loading-spinner">
|
|
@@ -222,6 +271,9 @@
|
|
| 222 |
</div>
|
| 223 |
</div>
|
| 224 |
|
| 225 |
-
|
|
|
|
|
|
|
|
|
|
| 226 |
</body>
|
| 227 |
</html>
|
|
|
|
| 10 |
</head>
|
| 11 |
<body>
|
| 12 |
<div class="app-container">
|
| 13 |
+
<div class="app-overlay" id="appOverlay"></div>
|
| 14 |
<!-- Sidebar -->
|
| 15 |
<div class="sidebar" id="sidebar">
|
| 16 |
<div class="sidebar-header">
|
|
|
|
| 27 |
</div>
|
| 28 |
<div class="user-info">
|
| 29 |
<div class="user-name" id="userName">Anonymous</div>
|
| 30 |
+
<div class="user-status" id="userStatus">Medical Professional</div>
|
| 31 |
</div>
|
| 32 |
<button class="user-menu-btn" id="userMenuBtn">
|
| 33 |
<i class="fas fa-ellipsis-v"></i>
|
|
|
|
| 35 |
</div>
|
| 36 |
</div>
|
| 37 |
|
| 38 |
+
<!-- Patient Login Section -->
|
| 39 |
+
<div class="patient-section">
|
| 40 |
+
<div class="patient-header">Patient</div>
|
| 41 |
+
<div class="patient-input-group patient-typeahead">
|
| 42 |
+
<input type="text" id="patientIdInput" class="patient-input" placeholder="Search patient by name or ID">
|
| 43 |
+
<div id="patientSuggestions" class="patient-suggestions" style="display:none;"></div>
|
| 44 |
+
<button class="patient-load-btn" id="loadPatientBtn" title="Load Patient">
|
| 45 |
+
<i class="fas fa-arrow-right"></i>
|
| 46 |
+
</button>
|
| 47 |
+
<a class="patient-create-link" id="createPatientLink" href="/static/patient.html" title="Create new patient">
|
| 48 |
+
<i class="fas fa-user-plus"></i>
|
| 49 |
+
</a>
|
| 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="#" id="emrLink" class="emr-link">
|
| 54 |
+
<i class="fas fa-file-medical"></i> EMR
|
| 55 |
+
</a>
|
| 56 |
+
</div>
|
| 57 |
+
</div>
|
| 58 |
+
|
| 59 |
<div class="chat-sessions" id="chatSessions">
|
| 60 |
<!-- Chat sessions will be populated here -->
|
| 61 |
</div>
|
|
|
|
| 144 |
<div class="modal" id="userModal">
|
| 145 |
<div class="modal-content">
|
| 146 |
<div class="modal-header">
|
| 147 |
+
<h3>Doctor Profile</h3>
|
| 148 |
<button class="modal-close" id="userModalClose">×</button>
|
| 149 |
</div>
|
| 150 |
<div class="modal-body">
|
| 151 |
<div class="form-group">
|
| 152 |
+
<label for="profileNameSelect">Name:</label>
|
| 153 |
+
<select id="profileNameSelect"></select>
|
| 154 |
+
</div>
|
| 155 |
+
<div class="form-group" id="newDoctorSection" style="display:none;">
|
| 156 |
+
<label for="newDoctorName">Doctor name</label>
|
| 157 |
+
<input type="text" id="newDoctorName" placeholder="Enter doctor name">
|
| 158 |
+
<div style="display:flex; gap:8px; margin-top:8px;">
|
| 159 |
+
<button type="button" class="btn btn-secondary" id="cancelNewDoctor">Cancel</button>
|
| 160 |
+
<button type="button" class="btn btn-primary" id="confirmNewDoctor">Confirm</button>
|
| 161 |
+
</div>
|
| 162 |
</div>
|
| 163 |
<div class="form-group">
|
| 164 |
<label for="profileRole">Medical Role:</label>
|
| 165 |
<select id="profileRole">
|
| 166 |
+
<option value="Doctor">Doctor</option>
|
| 167 |
+
<option value="Healthcare Prof">Healthcare Prof</option>
|
| 168 |
<option value="Nurse">Nurse</option>
|
| 169 |
+
<option value="Caregiver">Caregiver</option>
|
| 170 |
+
<option value="Physician">Physician</option>
|
| 171 |
<option value="Medical Student">Medical Student</option>
|
|
|
|
|
|
|
| 172 |
<option value="Other">Other</option>
|
| 173 |
</select>
|
| 174 |
</div>
|
|
|
|
| 218 |
</label>
|
| 219 |
</div>
|
| 220 |
</div>
|
| 221 |
+
<div class="modal-footer"></div>
|
|
|
|
|
|
|
|
|
|
| 222 |
</div>
|
| 223 |
</div>
|
| 224 |
|
|
|
|
| 242 |
</div>
|
| 243 |
</div>
|
| 244 |
|
| 245 |
+
<!-- Patient Modal -->
|
| 246 |
+
<div class="modal" id="patientModal">
|
| 247 |
+
<div class="modal-content">
|
| 248 |
+
<div class="modal-header">
|
| 249 |
+
<h3>Patient Profile</h3>
|
| 250 |
+
<button class="modal-close" id="patientModalClose">×</button>
|
| 251 |
+
</div>
|
| 252 |
+
<div class="modal-body">
|
| 253 |
+
<div class="patient-summary" id="patientSummary"></div>
|
| 254 |
+
<div class="patient-details">
|
| 255 |
+
<div><strong>Medications:</strong> <span id="patientMedications">-</span></div>
|
| 256 |
+
<div><strong>Past Assessment:</strong> <span id="patientAssessment">-</span></div>
|
| 257 |
+
</div>
|
| 258 |
+
</div>
|
| 259 |
+
<div class="modal-footer">
|
| 260 |
+
<button id="patientLogoutBtn" class="btn-danger">Log out patient</button>
|
| 261 |
+
<a id="patientCreateBtn" class="btn-primary" href="/static/patient.html">Create new patient</a>
|
| 262 |
+
</div>
|
| 263 |
+
</div>
|
| 264 |
+
</div>
|
| 265 |
+
|
| 266 |
<!-- Loading overlay -->
|
| 267 |
<div class="loading-overlay" id="loadingOverlay">
|
| 268 |
<div class="loading-spinner">
|
|
|
|
| 271 |
</div>
|
| 272 |
</div>
|
| 273 |
|
| 274 |
+
<!-- Sidebar overlay for outside-click close -->
|
| 275 |
+
<div id="sidebarOverlay" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.35);z-index:900;"></div>
|
| 276 |
+
|
| 277 |
+
<script type="module" src="/static/js/app.js"></script>
|
| 278 |
</body>
|
| 279 |
</html>
|
static/js/app.js
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
static/js/chat/messaging.js
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// chat/messaging.js
|
| 2 |
+
// Send, API call, add/display message, summariseTitle, format content/time
|
| 3 |
+
|
| 4 |
+
export function attachMessagingUI(app) {
|
| 5 |
+
app.sendMessage = async function () {
|
| 6 |
+
const input = document.getElementById('chatInput');
|
| 7 |
+
const message = input.value.trim();
|
| 8 |
+
if (!message || app.isLoading) return;
|
| 9 |
+
if (!app.currentPatientId) {
|
| 10 |
+
const status = document.getElementById('patientStatus');
|
| 11 |
+
if (status) { status.textContent = 'Select a patient before chatting.'; status.style.color = 'var(--warning-color)'; }
|
| 12 |
+
return;
|
| 13 |
+
}
|
| 14 |
+
input.value = '';
|
| 15 |
+
app.autoResizeTextarea(input);
|
| 16 |
+
app.addMessage('user', message);
|
| 17 |
+
app.showLoading(true);
|
| 18 |
+
try {
|
| 19 |
+
const response = await app.callMedicalAPI(message);
|
| 20 |
+
app.addMessage('assistant', response);
|
| 21 |
+
app.updateCurrentSession();
|
| 22 |
+
} catch (error) {
|
| 23 |
+
console.error('Error sending message:', error);
|
| 24 |
+
let errorMessage = 'I apologize, but I encountered an error processing your request.';
|
| 25 |
+
if (error.message.includes('500')) errorMessage = 'The server encountered an internal error. Please try again in a moment.';
|
| 26 |
+
else if (error.message.includes('404')) errorMessage = 'The requested service was not found. Please check your connection.';
|
| 27 |
+
else if (error.message.includes('fetch')) errorMessage = 'Unable to connect to the server. Please check your internet connection.';
|
| 28 |
+
app.addMessage('assistant', errorMessage);
|
| 29 |
+
} finally {
|
| 30 |
+
app.showLoading(false);
|
| 31 |
+
}
|
| 32 |
+
};
|
| 33 |
+
|
| 34 |
+
app.callMedicalAPI = async function (message) {
|
| 35 |
+
try {
|
| 36 |
+
const response = await fetch('/chat', {
|
| 37 |
+
method: 'POST',
|
| 38 |
+
headers: { 'Content-Type': 'application/json' },
|
| 39 |
+
body: JSON.stringify({
|
| 40 |
+
user_id: app.currentUser.id,
|
| 41 |
+
patient_id: app.currentPatientId,
|
| 42 |
+
doctor_id: app.currentUser.id,
|
| 43 |
+
session_id: app.currentSession?.id || 'default',
|
| 44 |
+
message: message,
|
| 45 |
+
user_role: app.currentUser.role,
|
| 46 |
+
user_specialty: app.currentUser.specialty,
|
| 47 |
+
title: app.currentSession?.title || 'New Chat'
|
| 48 |
+
})
|
| 49 |
+
});
|
| 50 |
+
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
|
| 51 |
+
const data = await response.json();
|
| 52 |
+
return data.response || 'I apologize, but I received an empty response. Please try again.';
|
| 53 |
+
} catch (error) {
|
| 54 |
+
console.error('API call failed:', error);
|
| 55 |
+
console.error('Error details:', {
|
| 56 |
+
message: error.message,
|
| 57 |
+
stack: error.stack,
|
| 58 |
+
user: app.currentUser,
|
| 59 |
+
session: app.currentSession,
|
| 60 |
+
patientId: app.currentPatientId
|
| 61 |
+
});
|
| 62 |
+
if (error.name === 'TypeError' && error.message.includes('fetch')) return app.generateMockResponse(message);
|
| 63 |
+
throw error;
|
| 64 |
+
}
|
| 65 |
+
};
|
| 66 |
+
|
| 67 |
+
app.generateMockResponse = function (message) {
|
| 68 |
+
const responses = [
|
| 69 |
+
"Based on your question about medical topics, I can provide general information. However, please remember that this is for educational purposes only and should not replace professional medical advice.",
|
| 70 |
+
"That's an interesting medical question. While I can offer some general insights, it's important to consult with healthcare professionals for personalized medical advice.",
|
| 71 |
+
"I understand your medical inquiry. For accurate diagnosis and treatment recommendations, please consult with qualified healthcare providers who can assess your specific situation.",
|
| 72 |
+
"Thank you for your medical question. I can provide educational information, but medical decisions should always be made in consultation with healthcare professionals.",
|
| 73 |
+
"I appreciate your interest in medical topics. Remember that medical information found online should be discussed with healthcare providers for proper evaluation."
|
| 74 |
+
];
|
| 75 |
+
return responses[Math.floor(Math.random() * responses.length)];
|
| 76 |
+
};
|
| 77 |
+
|
| 78 |
+
app.addMessage = function (role, content) {
|
| 79 |
+
if (!app.currentSession) app.startNewChat();
|
| 80 |
+
const message = { id: app.generateId(), role, content, timestamp: new Date().toISOString() };
|
| 81 |
+
app.currentSession.messages.push(message);
|
| 82 |
+
app.displayMessage(message);
|
| 83 |
+
if (role === 'user' && app.currentSession.messages.length === 2) app.summariseAndSetTitle(content);
|
| 84 |
+
};
|
| 85 |
+
|
| 86 |
+
app.summariseAndSetTitle = async function (text) {
|
| 87 |
+
try {
|
| 88 |
+
const resp = await fetch('/summarise', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text, max_words: 5 }) });
|
| 89 |
+
if (resp.ok) {
|
| 90 |
+
const data = await resp.json();
|
| 91 |
+
const title = (data.title || 'New Chat').trim();
|
| 92 |
+
app.currentSession.title = title;
|
| 93 |
+
app.updateCurrentSession();
|
| 94 |
+
app.updateChatTitle();
|
| 95 |
+
app.loadChatSessions();
|
| 96 |
+
} else {
|
| 97 |
+
const fallback = text.length > 50 ? text.substring(0, 50) + '...' : text;
|
| 98 |
+
app.currentSession.title = fallback;
|
| 99 |
+
app.updateCurrentSession();
|
| 100 |
+
app.updateChatTitle();
|
| 101 |
+
app.loadChatSessions();
|
| 102 |
+
}
|
| 103 |
+
} catch (e) {
|
| 104 |
+
const fallback = text.length > 50 ? text.substring(0, 50) + '...' : text;
|
| 105 |
+
app.currentSession.title = fallback;
|
| 106 |
+
app.updateCurrentSession();
|
| 107 |
+
app.updateChatTitle();
|
| 108 |
+
app.loadChatSessions();
|
| 109 |
+
}
|
| 110 |
+
};
|
| 111 |
+
|
| 112 |
+
app.displayMessage = function (message) {
|
| 113 |
+
const chatMessages = document.getElementById('chatMessages');
|
| 114 |
+
const messageElement = document.createElement('div');
|
| 115 |
+
messageElement.className = `message ${message.role}-message fade-in`;
|
| 116 |
+
messageElement.id = `message-${message.id}`;
|
| 117 |
+
const avatar = message.role === 'user' ? '<i class="fas fa-user"></i>' : '<i class="fas fa-robot"></i>';
|
| 118 |
+
const time = app.formatTime(message.timestamp);
|
| 119 |
+
messageElement.innerHTML = `
|
| 120 |
+
<div class="message-avatar">${avatar}</div>
|
| 121 |
+
<div class="message-content">
|
| 122 |
+
<div class="message-text">${app.formatMessageContent(message.content)}</div>
|
| 123 |
+
<div class="message-time">${time}</div>
|
| 124 |
+
</div>`;
|
| 125 |
+
chatMessages.appendChild(messageElement);
|
| 126 |
+
chatMessages.scrollTop = chatMessages.scrollHeight;
|
| 127 |
+
if (app.currentSession) app.currentSession.lastActivity = new Date().toISOString();
|
| 128 |
+
};
|
| 129 |
+
|
| 130 |
+
app.formatMessageContent = function (content) {
|
| 131 |
+
return content
|
| 132 |
+
// Handle headers (1-6 # symbols)
|
| 133 |
+
.replace(/^#{1,6}\s+(.+)$/gm, (match, text, offset, string) => {
|
| 134 |
+
const level = match.match(/^#+/)[0].length;
|
| 135 |
+
return `<h${level}>${text}</h${level}>`;
|
| 136 |
+
})
|
| 137 |
+
// Handle bold text
|
| 138 |
+
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
|
| 139 |
+
// Handle italic text
|
| 140 |
+
.replace(/\*(.*?)\*/g, '<em>$1</em>')
|
| 141 |
+
// Handle line breaks
|
| 142 |
+
.replace(/\n/g, '<br>')
|
| 143 |
+
// Handle emojis with colors
|
| 144 |
+
.replace(/🔍/g, '<span style="color: var(--primary-color);">🔍</span>')
|
| 145 |
+
.replace(/📋/g, '<span style="color: var(--secondary-color);">📋</span>')
|
| 146 |
+
.replace(/💊/g, '<span style="color: var(--accent-color);">💊</span>')
|
| 147 |
+
.replace(/📚/g, '<span style="color: var(--success-color);">📚</span>')
|
| 148 |
+
.replace(/⚠️/g, '<span style="color: var(--warning-color);">⚠️</span>');
|
| 149 |
+
};
|
| 150 |
+
|
| 151 |
+
app.formatTime = function (timestamp) {
|
| 152 |
+
const date = new Date(timestamp);
|
| 153 |
+
const now = new Date();
|
| 154 |
+
const diff = now - date;
|
| 155 |
+
if (diff < 60000) return 'Just now';
|
| 156 |
+
if (diff < 3600000) { const minutes = Math.floor(diff / 60000); return `${minutes} minute${minutes > 1 ? 's' : ''} ago`; }
|
| 157 |
+
if (diff < 86400000) { const hours = Math.floor(diff / 3600000); return `${hours} hour${hours > 1 ? 's' : ''} ago`; }
|
| 158 |
+
return date.toLocaleDateString();
|
| 159 |
+
};
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
|
static/js/chat/sessions.js
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// chat/sessions.js
|
| 2 |
+
// Session sidebar rendering, context menu, rename/delete, local storage helpers
|
| 3 |
+
|
| 4 |
+
export function attachSessionsUI(app) {
|
| 5 |
+
app.getChatSessions = function () {
|
| 6 |
+
const sessions = localStorage.getItem(`chatSessions_${app.currentUser.id}`);
|
| 7 |
+
return sessions ? JSON.parse(sessions) : [];
|
| 8 |
+
};
|
| 9 |
+
|
| 10 |
+
app.saveCurrentSession = function () {
|
| 11 |
+
if (!app.currentSession) return;
|
| 12 |
+
if (app.currentSession.source === 'backend') return; // do not persist backend sessions locally here
|
| 13 |
+
const sessions = app.getChatSessions();
|
| 14 |
+
const existingIndex = sessions.findIndex(s => s.id === app.currentSession.id);
|
| 15 |
+
if (existingIndex >= 0) sessions[existingIndex] = { ...app.currentSession };
|
| 16 |
+
else sessions.unshift(app.currentSession);
|
| 17 |
+
localStorage.setItem(`chatSessions_${app.currentUser.id}`, JSON.stringify(sessions));
|
| 18 |
+
};
|
| 19 |
+
|
| 20 |
+
app.updateCurrentSession = function () {
|
| 21 |
+
if (app.currentSession) {
|
| 22 |
+
app.currentSession.lastActivity = new Date().toISOString();
|
| 23 |
+
app.saveCurrentSession();
|
| 24 |
+
}
|
| 25 |
+
};
|
| 26 |
+
|
| 27 |
+
app.updateChatTitle = function () {
|
| 28 |
+
const titleElement = document.getElementById('chatTitle');
|
| 29 |
+
if (app.currentSession) titleElement.textContent = app.currentSession.title; else titleElement.textContent = 'Medical AI Assistant';
|
| 30 |
+
};
|
| 31 |
+
|
| 32 |
+
app.loadChatSession = function (sessionId) {
|
| 33 |
+
const sessions = app.getChatSessions();
|
| 34 |
+
const session = sessions.find(s => s.id === sessionId);
|
| 35 |
+
if (!session) return;
|
| 36 |
+
app.currentSession = session;
|
| 37 |
+
app.clearChatMessages();
|
| 38 |
+
session.messages.forEach(message => app.displayMessage(message));
|
| 39 |
+
app.updateChatTitle();
|
| 40 |
+
app.loadChatSessions();
|
| 41 |
+
};
|
| 42 |
+
|
| 43 |
+
app.deleteChatSession = function (sessionId) {
|
| 44 |
+
const sessions = app.getChatSessions();
|
| 45 |
+
const index = sessions.findIndex(s => s.id === sessionId);
|
| 46 |
+
if (index === -1) return;
|
| 47 |
+
const confirmDelete = confirm('Delete this chat session? This cannot be undone.');
|
| 48 |
+
if (!confirmDelete) return;
|
| 49 |
+
sessions.splice(index, 1);
|
| 50 |
+
localStorage.setItem(`chatSessions_${app.currentUser.id}`, JSON.stringify(sessions));
|
| 51 |
+
if (app.currentSession && app.currentSession.id === sessionId) {
|
| 52 |
+
if (sessions.length > 0) {
|
| 53 |
+
app.currentSession = sessions[0];
|
| 54 |
+
app.clearChatMessages();
|
| 55 |
+
app.currentSession.messages.forEach(m => app.displayMessage(m));
|
| 56 |
+
app.updateChatTitle();
|
| 57 |
+
} else {
|
| 58 |
+
app.currentSession = null;
|
| 59 |
+
app.clearChatMessages();
|
| 60 |
+
app.updateChatTitle();
|
| 61 |
+
}
|
| 62 |
+
}
|
| 63 |
+
app.loadChatSessions();
|
| 64 |
+
};
|
| 65 |
+
|
| 66 |
+
app.renameChatSession = function (sessionId, newTitle) {
|
| 67 |
+
const sessions = app.getChatSessions();
|
| 68 |
+
const idx = sessions.findIndex(s => s.id === sessionId);
|
| 69 |
+
if (idx === -1) return;
|
| 70 |
+
sessions[idx] = { ...sessions[idx], title: newTitle };
|
| 71 |
+
localStorage.setItem(`chatSessions_${app.currentUser.id}`, JSON.stringify(sessions));
|
| 72 |
+
if (app.currentSession && app.currentSession.id === sessionId) {
|
| 73 |
+
app.currentSession.title = newTitle;
|
| 74 |
+
app.updateChatTitle();
|
| 75 |
+
}
|
| 76 |
+
app.loadChatSessions();
|
| 77 |
+
};
|
| 78 |
+
|
| 79 |
+
app.showSessionMenu = function (anchorEl, sessionId) {
|
| 80 |
+
// Remove existing popover
|
| 81 |
+
document.querySelectorAll('.chat-session-menu-popover').forEach(p => p.remove());
|
| 82 |
+
const rect = anchorEl.getBoundingClientRect();
|
| 83 |
+
const pop = document.createElement('div');
|
| 84 |
+
pop.className = 'chat-session-menu-popover show';
|
| 85 |
+
pop.innerHTML = `
|
| 86 |
+
<div class="chat-session-menu-item" data-action="edit" data-session-id="${sessionId}"><i class="fas fa-pen"></i> Edit Name</div>
|
| 87 |
+
<div class="chat-session-menu-item" data-action="delete" data-session-id="${sessionId}"><i class="fas fa-trash"></i> Delete</div>
|
| 88 |
+
`;
|
| 89 |
+
document.body.appendChild(pop);
|
| 90 |
+
pop.style.top = `${rect.bottom + window.scrollY + 6}px`;
|
| 91 |
+
pop.style.left = `${rect.right + window.scrollX - pop.offsetWidth}px`;
|
| 92 |
+
const onDocClick = (ev) => {
|
| 93 |
+
if (!pop.contains(ev.target) && ev.target !== anchorEl) {
|
| 94 |
+
pop.remove();
|
| 95 |
+
document.removeEventListener('click', onDocClick);
|
| 96 |
+
}
|
| 97 |
+
};
|
| 98 |
+
setTimeout(() => document.addEventListener('click', onDocClick), 0);
|
| 99 |
+
pop.querySelectorAll('.chat-session-menu-item').forEach(item => {
|
| 100 |
+
item.addEventListener('click', (e) => {
|
| 101 |
+
const action = item.getAttribute('data-action');
|
| 102 |
+
const id = item.getAttribute('data-session-id');
|
| 103 |
+
if (action === 'delete') app.deleteChatSession(id);
|
| 104 |
+
else if (action === 'edit') {
|
| 105 |
+
app._pendingEditSessionId = id;
|
| 106 |
+
const sessions = app.getChatSessions();
|
| 107 |
+
const s = sessions.find(x => x.id === id);
|
| 108 |
+
const input = document.getElementById('editSessionTitleInput');
|
| 109 |
+
if (input) input.value = s ? s.title : '';
|
| 110 |
+
app.showModal('editTitleModal');
|
| 111 |
+
}
|
| 112 |
+
pop.remove();
|
| 113 |
+
});
|
| 114 |
+
});
|
| 115 |
+
};
|
| 116 |
+
|
| 117 |
+
app.loadChatSessions = function () {
|
| 118 |
+
const sessionsContainer = document.getElementById('chatSessions');
|
| 119 |
+
sessionsContainer.innerHTML = '';
|
| 120 |
+
const sessions = (app.backendSessions && app.backendSessions.length > 0) ? app.backendSessions : app.getChatSessions();
|
| 121 |
+
if (sessions.length === 0) {
|
| 122 |
+
sessionsContainer.innerHTML = '<div class="no-sessions">No chat sessions yet</div>';
|
| 123 |
+
return;
|
| 124 |
+
}
|
| 125 |
+
sessions.forEach(session => {
|
| 126 |
+
const sessionElement = document.createElement('div');
|
| 127 |
+
sessionElement.className = `chat-session ${session.id === app.currentSession?.id ? 'active' : ''}`;
|
| 128 |
+
sessionElement.addEventListener('click', async () => {
|
| 129 |
+
if (session.source === 'backend') {
|
| 130 |
+
app.currentSession = { ...session };
|
| 131 |
+
await app.hydrateMessagesForSession(session.id);
|
| 132 |
+
} else {
|
| 133 |
+
app.loadChatSession(session.id);
|
| 134 |
+
}
|
| 135 |
+
});
|
| 136 |
+
const time = app.formatTime(session.lastActivity);
|
| 137 |
+
sessionElement.innerHTML = `
|
| 138 |
+
<div class="chat-session-row">
|
| 139 |
+
<div class="chat-session-meta">
|
| 140 |
+
<div class="chat-session-title">${session.title}</div>
|
| 141 |
+
<div class="chat-session-time">${time}</div>
|
| 142 |
+
</div>
|
| 143 |
+
<div class="chat-session-actions">
|
| 144 |
+
<button class="chat-session-menu" title="Options" aria-label="Options" data-session-id="${session.id}">
|
| 145 |
+
<i class="fas fa-ellipsis-vertical"></i>
|
| 146 |
+
</button>
|
| 147 |
+
</div>
|
| 148 |
+
</div>
|
| 149 |
+
`;
|
| 150 |
+
sessionsContainer.appendChild(sessionElement);
|
| 151 |
+
const menuBtn = sessionElement.querySelector('.chat-session-menu');
|
| 152 |
+
if (session.source !== 'backend') {
|
| 153 |
+
menuBtn.addEventListener('click', (e) => {
|
| 154 |
+
e.stopPropagation();
|
| 155 |
+
app.showSessionMenu(e.currentTarget, session.id);
|
| 156 |
+
});
|
| 157 |
+
} else {
|
| 158 |
+
menuBtn.disabled = true;
|
| 159 |
+
menuBtn.style.opacity = 0.5;
|
| 160 |
+
menuBtn.title = 'Options available for local sessions only';
|
| 161 |
+
}
|
| 162 |
+
});
|
| 163 |
+
};
|
| 164 |
+
}
|
static/js/emr.js
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// EMR Page JavaScript
|
| 2 |
+
class PatientEMR {
|
| 3 |
+
constructor() {
|
| 4 |
+
this.patientId = null;
|
| 5 |
+
this.patientData = null;
|
| 6 |
+
this.medications = [];
|
| 7 |
+
this.sessions = [];
|
| 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.loadPatientData();
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
getPatientIdFromURL() {
|
| 26 |
+
const urlParams = new URLSearchParams(window.location.search);
|
| 27 |
+
return urlParams.get('patient_id');
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
setupEventListeners() {
|
| 31 |
+
// Save button
|
| 32 |
+
document.getElementById('savePatientBtn').addEventListener('click', () => {
|
| 33 |
+
this.savePatientData();
|
| 34 |
+
});
|
| 35 |
+
|
| 36 |
+
// Refresh button
|
| 37 |
+
document.getElementById('refreshPatientBtn').addEventListener('click', () => {
|
| 38 |
+
this.loadPatientData();
|
| 39 |
+
});
|
| 40 |
+
|
| 41 |
+
// Export button
|
| 42 |
+
document.getElementById('exportPatientBtn').addEventListener('click', () => {
|
| 43 |
+
this.exportPatientData();
|
| 44 |
+
});
|
| 45 |
+
|
| 46 |
+
// Add medication button
|
| 47 |
+
document.getElementById('addMedicationBtn').addEventListener('click', () => {
|
| 48 |
+
this.addMedication();
|
| 49 |
+
});
|
| 50 |
+
|
| 51 |
+
// Add medication on Enter key
|
| 52 |
+
document.getElementById('newMedicationInput').addEventListener('keydown', (e) => {
|
| 53 |
+
if (e.key === 'Enter') {
|
| 54 |
+
this.addMedication();
|
| 55 |
+
}
|
| 56 |
+
});
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
async loadPatientData() {
|
| 60 |
+
this.showLoading(true);
|
| 61 |
+
|
| 62 |
+
try {
|
| 63 |
+
// Load patient data
|
| 64 |
+
const patientResp = await fetch(`/patients/${this.patientId}`);
|
| 65 |
+
if (!patientResp.ok) {
|
| 66 |
+
throw new Error('Failed to load patient data');
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
this.patientData = await patientResp.json();
|
| 70 |
+
this.populatePatientForm();
|
| 71 |
+
|
| 72 |
+
// Load patient sessions
|
| 73 |
+
await this.loadPatientSessions();
|
| 74 |
+
|
| 75 |
+
} catch (error) {
|
| 76 |
+
console.error('Error loading patient data:', error);
|
| 77 |
+
this.showError('Failed to load patient data. Please try again.');
|
| 78 |
+
} finally {
|
| 79 |
+
this.showLoading(false);
|
| 80 |
+
}
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
populatePatientForm() {
|
| 84 |
+
if (!this.patientData) return;
|
| 85 |
+
|
| 86 |
+
// Update header
|
| 87 |
+
document.getElementById('patientName').textContent = this.patientData.name || 'Unknown';
|
| 88 |
+
document.getElementById('patientId').textContent = `ID: ${this.patientData.patient_id}`;
|
| 89 |
+
|
| 90 |
+
// Populate form fields
|
| 91 |
+
document.getElementById('patientNameInput').value = this.patientData.name || '';
|
| 92 |
+
document.getElementById('patientAgeInput').value = this.patientData.age || '';
|
| 93 |
+
document.getElementById('patientSexInput').value = this.patientData.sex || '';
|
| 94 |
+
document.getElementById('patientPhoneInput').value = this.patientData.phone || '';
|
| 95 |
+
document.getElementById('patientEmailInput').value = this.patientData.email || '';
|
| 96 |
+
document.getElementById('patientAddressInput').value = this.patientData.address || '';
|
| 97 |
+
document.getElementById('pastAssessmentInput').value = this.patientData.past_assessment_summary || '';
|
| 98 |
+
|
| 99 |
+
// Populate medications
|
| 100 |
+
this.medications = this.patientData.medications || [];
|
| 101 |
+
this.renderMedications();
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
renderMedications() {
|
| 105 |
+
const container = document.getElementById('medicationsList');
|
| 106 |
+
container.innerHTML = '';
|
| 107 |
+
|
| 108 |
+
if (this.medications.length === 0) {
|
| 109 |
+
container.innerHTML = '<div style="color: var(--text-secondary); font-style: italic;">No medications listed</div>';
|
| 110 |
+
return;
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
this.medications.forEach((medication, index) => {
|
| 114 |
+
const tag = document.createElement('div');
|
| 115 |
+
tag.className = 'medication-tag';
|
| 116 |
+
tag.innerHTML = `
|
| 117 |
+
${medication}
|
| 118 |
+
<button class="remove-medication" data-index="${index}">
|
| 119 |
+
<i class="fas fa-times"></i>
|
| 120 |
+
</button>
|
| 121 |
+
`;
|
| 122 |
+
container.appendChild(tag);
|
| 123 |
+
});
|
| 124 |
+
|
| 125 |
+
// Add event listeners for remove buttons
|
| 126 |
+
container.querySelectorAll('.remove-medication').forEach(btn => {
|
| 127 |
+
btn.addEventListener('click', (e) => {
|
| 128 |
+
const index = parseInt(e.target.closest('.remove-medication').dataset.index);
|
| 129 |
+
this.removeMedication(index);
|
| 130 |
+
});
|
| 131 |
+
});
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
addMedication() {
|
| 135 |
+
const input = document.getElementById('newMedicationInput');
|
| 136 |
+
const medication = input.value.trim();
|
| 137 |
+
|
| 138 |
+
if (!medication) return;
|
| 139 |
+
|
| 140 |
+
this.medications.push(medication);
|
| 141 |
+
this.renderMedications();
|
| 142 |
+
input.value = '';
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
removeMedication(index) {
|
| 146 |
+
this.medications.splice(index, 1);
|
| 147 |
+
this.renderMedications();
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
async loadPatientSessions() {
|
| 151 |
+
try {
|
| 152 |
+
const resp = await fetch(`/patients/${this.patientId}/sessions`);
|
| 153 |
+
if (resp.ok) {
|
| 154 |
+
const data = await resp.json();
|
| 155 |
+
this.sessions = data.sessions || [];
|
| 156 |
+
this.renderSessions();
|
| 157 |
+
}
|
| 158 |
+
} catch (error) {
|
| 159 |
+
console.error('Error loading sessions:', error);
|
| 160 |
+
}
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
renderSessions() {
|
| 164 |
+
const container = document.getElementById('sessionsContainer');
|
| 165 |
+
|
| 166 |
+
if (this.sessions.length === 0) {
|
| 167 |
+
container.innerHTML = '<div style="color: var(--text-secondary); font-style: italic;">No chat sessions found</div>';
|
| 168 |
+
return;
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
container.innerHTML = '';
|
| 172 |
+
|
| 173 |
+
this.sessions.forEach(session => {
|
| 174 |
+
const sessionEl = document.createElement('div');
|
| 175 |
+
sessionEl.className = 'session-item';
|
| 176 |
+
sessionEl.innerHTML = `
|
| 177 |
+
<div class="session-title">${session.title || 'Untitled Session'}</div>
|
| 178 |
+
<div class="session-meta">
|
| 179 |
+
<span class="session-date">${this.formatDate(session.created_at)}</span>
|
| 180 |
+
<span class="session-messages">${session.message_count || 0} messages</span>
|
| 181 |
+
</div>
|
| 182 |
+
`;
|
| 183 |
+
|
| 184 |
+
sessionEl.addEventListener('click', () => {
|
| 185 |
+
// Could open session details or redirect to main page with session
|
| 186 |
+
window.location.href = `/?session_id=${session.session_id}`;
|
| 187 |
+
});
|
| 188 |
+
|
| 189 |
+
container.appendChild(sessionEl);
|
| 190 |
+
});
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
async savePatientData() {
|
| 194 |
+
this.showLoading(true);
|
| 195 |
+
|
| 196 |
+
try {
|
| 197 |
+
const updateData = {
|
| 198 |
+
name: document.getElementById('patientNameInput').value.trim(),
|
| 199 |
+
age: parseInt(document.getElementById('patientAgeInput').value) || null,
|
| 200 |
+
sex: document.getElementById('patientSexInput').value || null,
|
| 201 |
+
phone: document.getElementById('patientPhoneInput').value.trim() || null,
|
| 202 |
+
email: document.getElementById('patientEmailInput').value.trim() || null,
|
| 203 |
+
address: document.getElementById('patientAddressInput').value.trim() || null,
|
| 204 |
+
medications: this.medications,
|
| 205 |
+
past_assessment_summary: document.getElementById('pastAssessmentInput').value.trim() || null
|
| 206 |
+
};
|
| 207 |
+
|
| 208 |
+
const resp = await fetch(`/patients/${this.patientId}`, {
|
| 209 |
+
method: 'PATCH',
|
| 210 |
+
headers: {
|
| 211 |
+
'Content-Type': 'application/json'
|
| 212 |
+
},
|
| 213 |
+
body: JSON.stringify(updateData)
|
| 214 |
+
});
|
| 215 |
+
|
| 216 |
+
if (resp.ok) {
|
| 217 |
+
this.showSuccess('Patient data saved successfully!');
|
| 218 |
+
// Update the header with new name
|
| 219 |
+
document.getElementById('patientName').textContent = updateData.name || 'Unknown';
|
| 220 |
+
} else {
|
| 221 |
+
throw new Error('Failed to save patient data');
|
| 222 |
+
}
|
| 223 |
+
} catch (error) {
|
| 224 |
+
console.error('Error saving patient data:', error);
|
| 225 |
+
this.showError('Failed to save patient data. Please try again.');
|
| 226 |
+
} finally {
|
| 227 |
+
this.showLoading(false);
|
| 228 |
+
}
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
exportPatientData() {
|
| 232 |
+
if (!this.patientData) {
|
| 233 |
+
this.showError('No patient data to export');
|
| 234 |
+
return;
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
const exportData = {
|
| 238 |
+
patient_id: this.patientData.patient_id,
|
| 239 |
+
name: this.patientData.name,
|
| 240 |
+
age: this.patientData.age,
|
| 241 |
+
sex: this.patientData.sex,
|
| 242 |
+
phone: this.patientData.phone,
|
| 243 |
+
email: this.patientData.email,
|
| 244 |
+
address: this.patientData.address,
|
| 245 |
+
medications: this.medications,
|
| 246 |
+
past_assessment_summary: this.patientData.past_assessment_summary,
|
| 247 |
+
created_at: this.patientData.created_at,
|
| 248 |
+
updated_at: new Date().toISOString(),
|
| 249 |
+
sessions: this.sessions
|
| 250 |
+
};
|
| 251 |
+
|
| 252 |
+
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
|
| 253 |
+
const url = URL.createObjectURL(blob);
|
| 254 |
+
const a = document.createElement('a');
|
| 255 |
+
a.href = url;
|
| 256 |
+
a.download = `patient-${this.patientData.patient_id}-emr.json`;
|
| 257 |
+
document.body.appendChild(a);
|
| 258 |
+
a.click();
|
| 259 |
+
document.body.removeChild(a);
|
| 260 |
+
URL.revokeObjectURL(url);
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
formatDate(dateString) {
|
| 264 |
+
if (!dateString) return 'Unknown date';
|
| 265 |
+
const date = new Date(dateString);
|
| 266 |
+
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString();
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
showLoading(show) {
|
| 270 |
+
const overlay = document.getElementById('loadingOverlay');
|
| 271 |
+
if (overlay) {
|
| 272 |
+
overlay.style.display = show ? 'flex' : 'none';
|
| 273 |
+
}
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
showError(message) {
|
| 277 |
+
alert('Error: ' + message);
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
showSuccess(message) {
|
| 281 |
+
alert('Success: ' + message);
|
| 282 |
+
}
|
| 283 |
+
}
|
| 284 |
+
|
| 285 |
+
// Initialize EMR when DOM is loaded
|
| 286 |
+
document.addEventListener('DOMContentLoaded', () => {
|
| 287 |
+
new PatientEMR();
|
| 288 |
+
});
|
static/js/patient.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
document.addEventListener('DOMContentLoaded', () => {
|
| 2 |
+
const form = document.getElementById('patientForm');
|
| 3 |
+
const result = document.getElementById('result');
|
| 4 |
+
const cancelBtn = document.getElementById('cancelBtn');
|
| 5 |
+
const successModal = document.getElementById('patientSuccessModal');
|
| 6 |
+
const successClose = document.getElementById('patientSuccessClose');
|
| 7 |
+
const successReturn = document.getElementById('patientSuccessReturn');
|
| 8 |
+
const successEdit = document.getElementById('patientSuccessEdit');
|
| 9 |
+
const createdIdEl = document.getElementById('createdPatientId');
|
| 10 |
+
const submitBtn = form?.querySelector('button[type="submit"]');
|
| 11 |
+
const titleEl = document.querySelector('h1');
|
| 12 |
+
|
| 13 |
+
let isEditMode = false;
|
| 14 |
+
let currentPatientId = null;
|
| 15 |
+
|
| 16 |
+
function getPatientIdFromUrl() {
|
| 17 |
+
const urlParams = new URLSearchParams(window.location.search);
|
| 18 |
+
const pidFromUrl = urlParams.get('patient_id');
|
| 19 |
+
if (pidFromUrl && /^\d{8}$/.test(pidFromUrl)) return pidFromUrl;
|
| 20 |
+
return null;
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
async function loadPatientIntoForm(patientId) {
|
| 24 |
+
try {
|
| 25 |
+
const resp = await fetch(`/patients/${patientId}`);
|
| 26 |
+
if (!resp.ok) return;
|
| 27 |
+
const data = await resp.json();
|
| 28 |
+
document.getElementById('name').value = data.name || '';
|
| 29 |
+
document.getElementById('age').value = data.age ?? '';
|
| 30 |
+
document.getElementById('sex').value = data.sex || 'Other';
|
| 31 |
+
document.getElementById('address').value = data.address || '';
|
| 32 |
+
document.getElementById('phone').value = data.phone || '';
|
| 33 |
+
document.getElementById('email').value = data.email || '';
|
| 34 |
+
document.getElementById('medications').value = Array.isArray(data.medications) ? data.medications.join('\n') : '';
|
| 35 |
+
document.getElementById('summary').value = data.past_assessment_summary || '';
|
| 36 |
+
} catch (e) {
|
| 37 |
+
console.warn('Failed to load patient profile for editing', e);
|
| 38 |
+
}
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
function enableEditMode(patientId) {
|
| 42 |
+
isEditMode = true;
|
| 43 |
+
currentPatientId = patientId;
|
| 44 |
+
if (submitBtn) submitBtn.textContent = 'Update';
|
| 45 |
+
if (titleEl) titleEl.textContent = 'Edit Patient';
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
// Initialize: only enter edit mode if patient_id is explicitly in URL
|
| 49 |
+
const pidFromUrl = getPatientIdFromUrl();
|
| 50 |
+
if (pidFromUrl) {
|
| 51 |
+
enableEditMode(pidFromUrl);
|
| 52 |
+
loadPatientIntoForm(pidFromUrl);
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
cancelBtn.addEventListener('click', () => {
|
| 56 |
+
window.location.href = '/';
|
| 57 |
+
});
|
| 58 |
+
|
| 59 |
+
form.addEventListener('submit', async (e) => {
|
| 60 |
+
e.preventDefault();
|
| 61 |
+
result.textContent = '';
|
| 62 |
+
result.style.color = '';
|
| 63 |
+
const payload = {
|
| 64 |
+
name: document.getElementById('name').value.trim(),
|
| 65 |
+
age: parseInt(document.getElementById('age').value, 10),
|
| 66 |
+
sex: document.getElementById('sex').value,
|
| 67 |
+
address: document.getElementById('address').value.trim() || null,
|
| 68 |
+
phone: document.getElementById('phone').value.trim() || null,
|
| 69 |
+
email: document.getElementById('email').value.trim() || null,
|
| 70 |
+
medications: document.getElementById('medications').value.split('\n').map(s => s.trim()).filter(Boolean),
|
| 71 |
+
past_assessment_summary: document.getElementById('summary').value.trim() || null
|
| 72 |
+
};
|
| 73 |
+
try {
|
| 74 |
+
if (isEditMode && currentPatientId) {
|
| 75 |
+
const resp = await fetch(`/patients/${currentPatientId}`, {
|
| 76 |
+
method: 'PATCH',
|
| 77 |
+
headers: { 'Content-Type': 'application/json' },
|
| 78 |
+
body: JSON.stringify(payload)
|
| 79 |
+
});
|
| 80 |
+
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
| 81 |
+
result.textContent = 'Patient updated successfully.';
|
| 82 |
+
result.style.color = 'green';
|
| 83 |
+
} else {
|
| 84 |
+
const resp = await fetch('/patients', {
|
| 85 |
+
method: 'POST',
|
| 86 |
+
headers: { 'Content-Type': 'application/json' },
|
| 87 |
+
body: JSON.stringify(payload)
|
| 88 |
+
});
|
| 89 |
+
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
| 90 |
+
const data = await resp.json();
|
| 91 |
+
const pid = data.patient_id;
|
| 92 |
+
localStorage.setItem('medicalChatbotPatientId', pid);
|
| 93 |
+
|
| 94 |
+
// Add to localStorage for future suggestions
|
| 95 |
+
const existingPatients = JSON.parse(localStorage.getItem('medicalChatbotPatients') || '[]');
|
| 96 |
+
const newPatient = {
|
| 97 |
+
patient_id: pid,
|
| 98 |
+
name: payload.name,
|
| 99 |
+
age: payload.age,
|
| 100 |
+
sex: payload.sex
|
| 101 |
+
};
|
| 102 |
+
// Check if patient already exists to avoid duplicates
|
| 103 |
+
const exists = existingPatients.some(p => p.patient_id === pid);
|
| 104 |
+
if (!exists) {
|
| 105 |
+
existingPatients.push(newPatient);
|
| 106 |
+
localStorage.setItem('medicalChatbotPatients', JSON.stringify(existingPatients));
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
// Show success modal (stay in create view until user opts to edit)
|
| 110 |
+
if (createdIdEl) createdIdEl.textContent = pid;
|
| 111 |
+
successModal.classList.add('show');
|
| 112 |
+
}
|
| 113 |
+
} catch (err) {
|
| 114 |
+
console.error(err);
|
| 115 |
+
result.textContent = isEditMode ? 'Failed to update patient. Please try again.' : 'Failed to create patient. Please try again.';
|
| 116 |
+
result.style.color = 'crimson';
|
| 117 |
+
}
|
| 118 |
+
});
|
| 119 |
+
|
| 120 |
+
// Success modal wiring
|
| 121 |
+
if (successClose) successClose.addEventListener('click', () => successModal.classList.remove('show'));
|
| 122 |
+
if (successReturn) successReturn.addEventListener('click', () => { window.location.href = '/'; });
|
| 123 |
+
if (successEdit) successEdit.addEventListener('click', () => {
|
| 124 |
+
successModal.classList.remove('show');
|
| 125 |
+
const pid = createdIdEl?.textContent?.trim() || localStorage.getItem('medicalChatbotPatientId');
|
| 126 |
+
if (pid && /^\d{8}$/.test(pid)) {
|
| 127 |
+
enableEditMode(pid);
|
| 128 |
+
loadPatientIntoForm(pid);
|
| 129 |
+
}
|
| 130 |
+
});
|
| 131 |
+
});
|
static/js/ui/doctor.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// ui/doctor.js
|
| 2 |
+
// Doctor list load/save, dropdown populate, create-flow, show/save profile
|
| 3 |
+
|
| 4 |
+
export function attachDoctorUI(app) {
|
| 5 |
+
// Model: list of doctors persisted in localStorage
|
| 6 |
+
app.loadDoctors = function () {
|
| 7 |
+
try {
|
| 8 |
+
const raw = localStorage.getItem('medicalChatbotDoctors');
|
| 9 |
+
const arr = raw ? JSON.parse(raw) : [];
|
| 10 |
+
const seen = new Set();
|
| 11 |
+
return arr.filter(x => x && x.name && !seen.has(x.name) && seen.add(x.name));
|
| 12 |
+
} catch { return []; }
|
| 13 |
+
};
|
| 14 |
+
|
| 15 |
+
app.saveDoctors = function () {
|
| 16 |
+
localStorage.setItem('medicalChatbotDoctors', JSON.stringify(app.doctors));
|
| 17 |
+
};
|
| 18 |
+
|
| 19 |
+
app.populateDoctorSelect = function () {
|
| 20 |
+
const sel = document.getElementById('profileNameSelect');
|
| 21 |
+
const newSec = document.getElementById('newDoctorSection');
|
| 22 |
+
if (!sel) return;
|
| 23 |
+
sel.innerHTML = '';
|
| 24 |
+
const createOpt = document.createElement('option');
|
| 25 |
+
createOpt.value = '__create__';
|
| 26 |
+
createOpt.textContent = 'Create doctor user...';
|
| 27 |
+
sel.appendChild(createOpt);
|
| 28 |
+
// Ensure no duplicates, include current doctor
|
| 29 |
+
const names = new Set(app.doctors.map(d => d.name));
|
| 30 |
+
if (app.currentUser?.name && !names.has(app.currentUser.name)) {
|
| 31 |
+
app.doctors.unshift({ name: app.currentUser.name });
|
| 32 |
+
names.add(app.currentUser.name);
|
| 33 |
+
app.saveDoctors();
|
| 34 |
+
}
|
| 35 |
+
app.doctors.forEach(d => {
|
| 36 |
+
const opt = document.createElement('option');
|
| 37 |
+
opt.value = d.name;
|
| 38 |
+
opt.textContent = d.name;
|
| 39 |
+
if (app.currentUser?.name === d.name) opt.selected = true;
|
| 40 |
+
sel.appendChild(opt);
|
| 41 |
+
});
|
| 42 |
+
sel.addEventListener('change', () => {
|
| 43 |
+
if (sel.value === '__create__') {
|
| 44 |
+
newSec.style.display = '';
|
| 45 |
+
const input = document.getElementById('newDoctorName');
|
| 46 |
+
if (input) input.value = '';
|
| 47 |
+
} else {
|
| 48 |
+
newSec.style.display = 'none';
|
| 49 |
+
}
|
| 50 |
+
});
|
| 51 |
+
const cancelBtn = document.getElementById('cancelNewDoctor');
|
| 52 |
+
const confirmBtn = document.getElementById('confirmNewDoctor');
|
| 53 |
+
if (cancelBtn) cancelBtn.onclick = () => { newSec.style.display = 'none'; sel.value = app.currentUser?.name || ''; };
|
| 54 |
+
if (confirmBtn) confirmBtn.onclick = () => {
|
| 55 |
+
const name = (document.getElementById('newDoctorName').value || '').trim();
|
| 56 |
+
if (!name) return;
|
| 57 |
+
if (!app.doctors.find(d => d.name === name)) {
|
| 58 |
+
app.doctors.unshift({ name });
|
| 59 |
+
app.saveDoctors();
|
| 60 |
+
}
|
| 61 |
+
app.populateDoctorSelect();
|
| 62 |
+
sel.value = name;
|
| 63 |
+
newSec.style.display = 'none';
|
| 64 |
+
};
|
| 65 |
+
};
|
| 66 |
+
|
| 67 |
+
app.showUserModal = function () {
|
| 68 |
+
app.populateDoctorSelect();
|
| 69 |
+
const sel = document.getElementById('profileNameSelect');
|
| 70 |
+
if (sel && sel.options.length === 0) {
|
| 71 |
+
const createOpt = document.createElement('option');
|
| 72 |
+
createOpt.value = '__create__';
|
| 73 |
+
createOpt.textContent = 'Create doctor user...';
|
| 74 |
+
sel.appendChild(createOpt);
|
| 75 |
+
}
|
| 76 |
+
if (sel && !sel.value) sel.value = app.currentUser?.name || '__create__';
|
| 77 |
+
document.getElementById('profileRole').value = app.currentUser.role;
|
| 78 |
+
document.getElementById('profileSpecialty').value = app.currentUser.specialty || '';
|
| 79 |
+
app.showModal('userModal');
|
| 80 |
+
};
|
| 81 |
+
|
| 82 |
+
app.saveUserProfile = function () {
|
| 83 |
+
const nameSel = document.getElementById('profileNameSelect');
|
| 84 |
+
const name = nameSel ? nameSel.value : '';
|
| 85 |
+
const role = document.getElementById('profileRole').value;
|
| 86 |
+
const specialty = document.getElementById('profileSpecialty').value.trim();
|
| 87 |
+
|
| 88 |
+
if (!name || name === '__create__') {
|
| 89 |
+
alert('Please select or create a doctor name.');
|
| 90 |
+
return;
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
if (!app.doctors.find(d => d.name === name)) {
|
| 94 |
+
app.doctors.unshift({ name });
|
| 95 |
+
app.saveDoctors();
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
app.currentUser.name = name;
|
| 99 |
+
app.currentUser.role = role;
|
| 100 |
+
app.currentUser.specialty = specialty;
|
| 101 |
+
|
| 102 |
+
app.saveUser();
|
| 103 |
+
app.updateUserDisplay();
|
| 104 |
+
app.hideModal('userModal');
|
| 105 |
+
};
|
| 106 |
+
|
| 107 |
+
// Doctor modal open/close wiring
|
| 108 |
+
document.addEventListener('DOMContentLoaded', () => {
|
| 109 |
+
const doctorCard = document.getElementById('userProfile');
|
| 110 |
+
const userModal = document.getElementById('userModal');
|
| 111 |
+
const closeBtn = document.getElementById('userModalClose');
|
| 112 |
+
const cancelBtn = document.getElementById('userModalCancel');
|
| 113 |
+
if (doctorCard && userModal) {
|
| 114 |
+
doctorCard.addEventListener('click', () => userModal.classList.add('show'));
|
| 115 |
+
}
|
| 116 |
+
if (closeBtn) closeBtn.addEventListener('click', () => userModal.classList.remove('show'));
|
| 117 |
+
if (cancelBtn) cancelBtn.addEventListener('click', () => userModal.classList.remove('show'));
|
| 118 |
+
if (userModal) {
|
| 119 |
+
userModal.addEventListener('click', (e) => { if (e.target === userModal) userModal.classList.remove('show'); });
|
| 120 |
+
}
|
| 121 |
+
});
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
|
static/js/ui/handlers.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// ui/handlers.js
|
| 2 |
+
// DOM wiring helpers: sidebar open/close, modal wiring, textarea autosize, export/clear
|
| 3 |
+
|
| 4 |
+
export function attachUIHandlers(app) {
|
| 5 |
+
// Sidebar toggle implementation
|
| 6 |
+
app.toggleSidebar = function () {
|
| 7 |
+
const sidebar = document.getElementById('sidebar');
|
| 8 |
+
console.log('[DEBUG] toggleSidebar called');
|
| 9 |
+
if (sidebar) {
|
| 10 |
+
const wasOpen = sidebar.classList.contains('show');
|
| 11 |
+
sidebar.classList.toggle('show');
|
| 12 |
+
const isNowOpen = sidebar.classList.contains('show');
|
| 13 |
+
console.log('[DEBUG] Sidebar toggled - was open:', wasOpen, 'now open:', isNowOpen);
|
| 14 |
+
} else {
|
| 15 |
+
console.error('[DEBUG] Sidebar element not found');
|
| 16 |
+
}
|
| 17 |
+
};
|
| 18 |
+
|
| 19 |
+
// Textarea autosize
|
| 20 |
+
app.autoResizeTextarea = function (textarea) {
|
| 21 |
+
if (!textarea) return;
|
| 22 |
+
textarea.style.height = 'auto';
|
| 23 |
+
textarea.style.height = Math.min(textarea.scrollHeight, 120) + 'px';
|
| 24 |
+
};
|
| 25 |
+
|
| 26 |
+
// Export current chat as JSON
|
| 27 |
+
app.exportChat = function () {
|
| 28 |
+
if (!app.currentSession || app.currentSession.messages.length === 0) {
|
| 29 |
+
alert('No chat to export.');
|
| 30 |
+
return;
|
| 31 |
+
}
|
| 32 |
+
const chatData = {
|
| 33 |
+
user: app.currentUser?.name || 'Unknown',
|
| 34 |
+
session: app.currentSession.title,
|
| 35 |
+
date: new Date().toISOString(),
|
| 36 |
+
messages: app.currentSession.messages
|
| 37 |
+
};
|
| 38 |
+
const blob = new Blob([JSON.stringify(chatData, null, 2)], { type: 'application/json' });
|
| 39 |
+
const url = URL.createObjectURL(blob);
|
| 40 |
+
const a = document.createElement('a');
|
| 41 |
+
a.href = url;
|
| 42 |
+
a.download = `medical-chat-${app.currentSession.title.replace(/[^a-z0-9]/gi, '-')}.json`;
|
| 43 |
+
document.body.appendChild(a);
|
| 44 |
+
a.click();
|
| 45 |
+
document.body.removeChild(a);
|
| 46 |
+
URL.revokeObjectURL(url);
|
| 47 |
+
};
|
| 48 |
+
|
| 49 |
+
// Clear current chat
|
| 50 |
+
app.clearChat = function () {
|
| 51 |
+
if (confirm('Are you sure you want to clear this chat? This action cannot be undone.')) {
|
| 52 |
+
app.clearChatMessages();
|
| 53 |
+
if (app.currentSession) {
|
| 54 |
+
app.currentSession.messages = [];
|
| 55 |
+
app.currentSession.title = 'New Chat';
|
| 56 |
+
app.updateChatTitle();
|
| 57 |
+
}
|
| 58 |
+
}
|
| 59 |
+
};
|
| 60 |
+
|
| 61 |
+
// Generic modal helpers
|
| 62 |
+
app.showModal = function (modalId) {
|
| 63 |
+
document.getElementById(modalId)?.classList.add('show');
|
| 64 |
+
};
|
| 65 |
+
app.hideModal = function (modalId) {
|
| 66 |
+
document.getElementById(modalId)?.classList.remove('show');
|
| 67 |
+
};
|
| 68 |
+
}
|
static/js/ui/patient.js
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// ui/patient.js
|
| 2 |
+
// Patient selection, typeahead search, load/hydrate, patient modal wiring
|
| 3 |
+
|
| 4 |
+
export function attachPatientUI(app) {
|
| 5 |
+
// State helpers
|
| 6 |
+
app.loadSavedPatientId = function () {
|
| 7 |
+
const pid = localStorage.getItem('medicalChatbotPatientId');
|
| 8 |
+
if (pid && /^\d{8}$/.test(pid)) {
|
| 9 |
+
app.currentPatientId = pid;
|
| 10 |
+
const status = document.getElementById('patientStatus');
|
| 11 |
+
if (status) {
|
| 12 |
+
status.textContent = `Patient: ${pid}`;
|
| 13 |
+
status.style.color = 'var(--text-secondary)';
|
| 14 |
+
}
|
| 15 |
+
const input = document.getElementById('patientIdInput');
|
| 16 |
+
if (input) input.value = pid;
|
| 17 |
+
}
|
| 18 |
+
};
|
| 19 |
+
|
| 20 |
+
app.savePatientId = function () {
|
| 21 |
+
if (app.currentPatientId) localStorage.setItem('medicalChatbotPatientId', app.currentPatientId);
|
| 22 |
+
else localStorage.removeItem('medicalChatbotPatientId');
|
| 23 |
+
};
|
| 24 |
+
|
| 25 |
+
app.loadPatient = async function () {
|
| 26 |
+
console.log('[DEBUG] loadPatient called');
|
| 27 |
+
const input = document.getElementById('patientIdInput');
|
| 28 |
+
const status = document.getElementById('patientStatus');
|
| 29 |
+
const id = (input?.value || '').trim();
|
| 30 |
+
console.log('[DEBUG] Patient ID from input:', id);
|
| 31 |
+
if (!/^\d{8}$/.test(id)) {
|
| 32 |
+
console.log('[DEBUG] Invalid patient ID format');
|
| 33 |
+
if (status) { status.textContent = 'Invalid patient ID. Use 8 digits.'; status.style.color = 'var(--warning-color)'; }
|
| 34 |
+
return;
|
| 35 |
+
}
|
| 36 |
+
console.log('[DEBUG] Setting current patient ID:', id);
|
| 37 |
+
app.currentPatientId = id;
|
| 38 |
+
app.savePatientId();
|
| 39 |
+
if (status) { status.textContent = `Patient: ${id}`; status.style.color = 'var(--text-secondary)'; }
|
| 40 |
+
await app.fetchAndRenderPatientSessions();
|
| 41 |
+
};
|
| 42 |
+
|
| 43 |
+
app.fetchAndRenderPatientSessions = async function () {
|
| 44 |
+
if (!app.currentPatientId) return;
|
| 45 |
+
try {
|
| 46 |
+
const resp = await fetch(`/patients/${app.currentPatientId}/sessions`);
|
| 47 |
+
if (resp.ok) {
|
| 48 |
+
const data = await resp.json();
|
| 49 |
+
const sessions = Array.isArray(data.sessions) ? data.sessions : [];
|
| 50 |
+
app.backendSessions = sessions.map(s => ({
|
| 51 |
+
id: s.session_id,
|
| 52 |
+
title: s.title || 'New Chat',
|
| 53 |
+
messages: [],
|
| 54 |
+
createdAt: s.created_at || new Date().toISOString(),
|
| 55 |
+
lastActivity: s.last_activity || new Date().toISOString(),
|
| 56 |
+
source: 'backend'
|
| 57 |
+
}));
|
| 58 |
+
if (app.backendSessions.length > 0) {
|
| 59 |
+
app.currentSession = app.backendSessions[0];
|
| 60 |
+
await app.hydrateMessagesForSession(app.currentSession.id);
|
| 61 |
+
}
|
| 62 |
+
} else {
|
| 63 |
+
console.warn('Failed to fetch patient sessions', resp.status);
|
| 64 |
+
app.backendSessions = [];
|
| 65 |
+
}
|
| 66 |
+
} catch (e) {
|
| 67 |
+
console.error('Failed to load patient sessions', e);
|
| 68 |
+
app.backendSessions = [];
|
| 69 |
+
}
|
| 70 |
+
app.loadChatSessions();
|
| 71 |
+
};
|
| 72 |
+
|
| 73 |
+
app.hydrateMessagesForSession = async function (sessionId) {
|
| 74 |
+
try {
|
| 75 |
+
const resp = await fetch(`/sessions/${sessionId}/messages?patient_id=${app.currentPatientId}&limit=1000`);
|
| 76 |
+
if (!resp.ok) return;
|
| 77 |
+
const data = await resp.json();
|
| 78 |
+
const msgs = Array.isArray(data.messages) ? data.messages : [];
|
| 79 |
+
const normalized = msgs.map(m => ({
|
| 80 |
+
id: m._id || app.generateId(),
|
| 81 |
+
role: m.role,
|
| 82 |
+
content: m.content,
|
| 83 |
+
timestamp: m.timestamp
|
| 84 |
+
}));
|
| 85 |
+
if (app.currentSession && app.currentSession.id === sessionId) {
|
| 86 |
+
app.currentSession.messages = normalized;
|
| 87 |
+
app.clearChatMessages();
|
| 88 |
+
app.currentSession.messages.forEach(m => app.displayMessage(m));
|
| 89 |
+
app.updateChatTitle();
|
| 90 |
+
}
|
| 91 |
+
} catch (e) {
|
| 92 |
+
console.error('Failed to hydrate session messages', e);
|
| 93 |
+
}
|
| 94 |
+
};
|
| 95 |
+
|
| 96 |
+
// Bind patient input + typeahead + load button
|
| 97 |
+
app.bindPatientHandlers = function () {
|
| 98 |
+
console.log('[DEBUG] bindPatientHandlers called');
|
| 99 |
+
const loadBtn = document.getElementById('loadPatientBtn');
|
| 100 |
+
console.log('[DEBUG] Load button found:', !!loadBtn);
|
| 101 |
+
if (loadBtn) loadBtn.addEventListener('click', () => app.loadPatient());
|
| 102 |
+
const patientInput = document.getElementById('patientIdInput');
|
| 103 |
+
const suggestionsEl = document.getElementById('patientSuggestions');
|
| 104 |
+
console.log('[DEBUG] Patient input found:', !!patientInput);
|
| 105 |
+
console.log('[DEBUG] Suggestions element found:', !!suggestionsEl);
|
| 106 |
+
if (!patientInput) return;
|
| 107 |
+
let debounceTimer;
|
| 108 |
+
const hideSuggestions = () => { if (suggestionsEl) suggestionsEl.style.display = 'none'; };
|
| 109 |
+
const renderSuggestions = (items) => {
|
| 110 |
+
if (!suggestionsEl) return;
|
| 111 |
+
if (!items || items.length === 0) { hideSuggestions(); return; }
|
| 112 |
+
suggestionsEl.innerHTML = '';
|
| 113 |
+
items.forEach(p => {
|
| 114 |
+
const div = document.createElement('div');
|
| 115 |
+
div.className = 'patient-suggestion';
|
| 116 |
+
div.textContent = `${p.name || 'Unknown'} (${p.patient_id})`;
|
| 117 |
+
div.addEventListener('click', async () => {
|
| 118 |
+
app.currentPatientId = p.patient_id;
|
| 119 |
+
app.savePatientId();
|
| 120 |
+
patientInput.value = p.patient_id;
|
| 121 |
+
hideSuggestions();
|
| 122 |
+
const status = document.getElementById('patientStatus');
|
| 123 |
+
if (status) { status.textContent = `Patient: ${p.patient_id}`; status.style.color = 'var(--text-secondary)'; }
|
| 124 |
+
await app.fetchAndRenderPatientSessions();
|
| 125 |
+
});
|
| 126 |
+
suggestionsEl.appendChild(div);
|
| 127 |
+
});
|
| 128 |
+
suggestionsEl.style.display = 'block';
|
| 129 |
+
};
|
| 130 |
+
patientInput.addEventListener('input', () => {
|
| 131 |
+
const q = patientInput.value.trim();
|
| 132 |
+
console.log('[DEBUG] Patient input changed:', q);
|
| 133 |
+
clearTimeout(debounceTimer);
|
| 134 |
+
if (!q) { hideSuggestions(); return; }
|
| 135 |
+
debounceTimer = setTimeout(async () => {
|
| 136 |
+
try {
|
| 137 |
+
console.log('[DEBUG] Searching patients with query:', q);
|
| 138 |
+
const resp = await fetch(`/patients/search?q=${encodeURIComponent(q)}&limit=8`, { headers: { 'Accept': 'application/json' } });
|
| 139 |
+
console.log('[DEBUG] Search response status:', resp.status);
|
| 140 |
+
if (resp.ok) {
|
| 141 |
+
const data = await resp.json();
|
| 142 |
+
console.log('[DEBUG] Search results:', data);
|
| 143 |
+
renderSuggestions(data.results || []);
|
| 144 |
+
} else {
|
| 145 |
+
console.warn('Search request failed', resp.status);
|
| 146 |
+
}
|
| 147 |
+
} catch (e) {
|
| 148 |
+
console.error('[DEBUG] Search error:', e);
|
| 149 |
+
}
|
| 150 |
+
}, 200);
|
| 151 |
+
});
|
| 152 |
+
patientInput.addEventListener('keydown', async (e) => {
|
| 153 |
+
if (e.key === 'Enter') {
|
| 154 |
+
const value = patientInput.value.trim();
|
| 155 |
+
console.log('[DEBUG] Patient input Enter pressed with value:', value);
|
| 156 |
+
if (/^\d{8}$/.test(value)) {
|
| 157 |
+
console.log('[DEBUG] Loading patient with 8-digit ID');
|
| 158 |
+
await app.loadPatient();
|
| 159 |
+
hideSuggestions();
|
| 160 |
+
} else {
|
| 161 |
+
console.log('[DEBUG] Searching for patient by name/partial ID');
|
| 162 |
+
try {
|
| 163 |
+
const resp = await fetch(`/patients/search?q=${encodeURIComponent(value)}&limit=1`);
|
| 164 |
+
console.log('[DEBUG] Search response status:', resp.status);
|
| 165 |
+
if (resp.ok) {
|
| 166 |
+
const data = await resp.json();
|
| 167 |
+
console.log('[DEBUG] Search results for Enter:', data);
|
| 168 |
+
const first = (data.results || [])[0];
|
| 169 |
+
if (first) {
|
| 170 |
+
console.log('[DEBUG] Found patient, setting as current:', first);
|
| 171 |
+
app.currentPatientId = first.patient_id;
|
| 172 |
+
app.savePatientId();
|
| 173 |
+
patientInput.value = first.patient_id;
|
| 174 |
+
hideSuggestions();
|
| 175 |
+
const status = document.getElementById('patientStatus');
|
| 176 |
+
if (status) { status.textContent = `Patient: ${first.patient_id}`; status.style.color = 'var(--text-secondary)'; }
|
| 177 |
+
await app.fetchAndRenderPatientSessions();
|
| 178 |
+
return;
|
| 179 |
+
}
|
| 180 |
+
}
|
| 181 |
+
} catch (e) {
|
| 182 |
+
console.error('[DEBUG] Search error on Enter:', e);
|
| 183 |
+
}
|
| 184 |
+
const status = document.getElementById('patientStatus');
|
| 185 |
+
if (status) { status.textContent = 'No matching patient found'; status.style.color = 'var(--warning-color)'; }
|
| 186 |
+
}
|
| 187 |
+
}
|
| 188 |
+
});
|
| 189 |
+
document.addEventListener('click', (ev) => {
|
| 190 |
+
if (!suggestionsEl) return;
|
| 191 |
+
if (!suggestionsEl.contains(ev.target) && ev.target !== patientInput) hideSuggestions();
|
| 192 |
+
});
|
| 193 |
+
};
|
| 194 |
+
|
| 195 |
+
// Patient modal wiring
|
| 196 |
+
document.addEventListener('DOMContentLoaded', () => {
|
| 197 |
+
const profileBtn = document.getElementById('patientMenuBtn');
|
| 198 |
+
const modal = document.getElementById('patientModal');
|
| 199 |
+
const closeBtn = document.getElementById('patientModalClose');
|
| 200 |
+
const logoutBtn = document.getElementById('patientLogoutBtn');
|
| 201 |
+
const createBtn = document.getElementById('patientCreateBtn');
|
| 202 |
+
if (profileBtn && modal) {
|
| 203 |
+
profileBtn.addEventListener('click', async () => {
|
| 204 |
+
const pid = app?.currentPatientId;
|
| 205 |
+
if (pid) {
|
| 206 |
+
try {
|
| 207 |
+
const resp = await fetch(`/patients/${pid}`);
|
| 208 |
+
if (resp.ok) {
|
| 209 |
+
const p = await resp.json();
|
| 210 |
+
const name = p.name || 'Unknown';
|
| 211 |
+
const age = typeof p.age === 'number' ? p.age : '-';
|
| 212 |
+
const sex = p.sex || '-';
|
| 213 |
+
const meds = Array.isArray(p.medications) && p.medications.length > 0 ? p.medications.join(', ') : '-';
|
| 214 |
+
document.getElementById('patientSummary').textContent = `${name} — ${sex}, ${age}`;
|
| 215 |
+
document.getElementById('patientMedications').textContent = meds;
|
| 216 |
+
document.getElementById('patientAssessment').textContent = p.past_assessment_summary || '-';
|
| 217 |
+
}
|
| 218 |
+
} catch (e) {
|
| 219 |
+
console.error('Failed to load patient profile', e);
|
| 220 |
+
}
|
| 221 |
+
}
|
| 222 |
+
modal.classList.add('show');
|
| 223 |
+
});
|
| 224 |
+
}
|
| 225 |
+
if (closeBtn && modal) {
|
| 226 |
+
closeBtn.addEventListener('click', () => modal.classList.remove('show'));
|
| 227 |
+
modal.addEventListener('click', (e) => { if (e.target === modal) modal.classList.remove('show'); });
|
| 228 |
+
}
|
| 229 |
+
if (logoutBtn) {
|
| 230 |
+
logoutBtn.addEventListener('click', () => {
|
| 231 |
+
if (confirm('Log out current patient?')) {
|
| 232 |
+
app.currentPatientId = null;
|
| 233 |
+
localStorage.removeItem('medicalChatbotPatientId');
|
| 234 |
+
const status = document.getElementById('patientStatus');
|
| 235 |
+
if (status) { status.textContent = 'No patient selected'; status.style.color = 'var(--text-secondary)'; }
|
| 236 |
+
const input = document.getElementById('patientIdInput');
|
| 237 |
+
if (input) input.value = '';
|
| 238 |
+
modal.classList.remove('show');
|
| 239 |
+
}
|
| 240 |
+
});
|
| 241 |
+
}
|
| 242 |
+
if (createBtn) createBtn.addEventListener('click', () => modal.classList.remove('show'));
|
| 243 |
+
});
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
|
static/js/ui/settings.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// ui/settings.js
|
| 2 |
+
// Settings UI: theme/font preferences, showLoading overlay, settings modal wiring
|
| 3 |
+
|
| 4 |
+
export function attachSettingsUI(app) {
|
| 5 |
+
app.loadUserPreferences = function () {
|
| 6 |
+
const preferences = localStorage.getItem('medicalChatbotPreferences');
|
| 7 |
+
if (preferences) {
|
| 8 |
+
const prefs = JSON.parse(preferences);
|
| 9 |
+
app.setTheme(prefs.theme || 'auto');
|
| 10 |
+
app.setFontSize(prefs.fontSize || 'medium');
|
| 11 |
+
}
|
| 12 |
+
};
|
| 13 |
+
|
| 14 |
+
app.setupTheme = function () {
|
| 15 |
+
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
| 16 |
+
app.setTheme('auto');
|
| 17 |
+
}
|
| 18 |
+
};
|
| 19 |
+
|
| 20 |
+
app.setTheme = function (theme) {
|
| 21 |
+
const root = document.documentElement;
|
| 22 |
+
if (theme === 'auto') {
|
| 23 |
+
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
| 24 |
+
root.setAttribute('data-theme', isDark ? 'dark' : 'light');
|
| 25 |
+
} else {
|
| 26 |
+
root.setAttribute('data-theme', theme);
|
| 27 |
+
}
|
| 28 |
+
const sel = document.getElementById('themeSelect');
|
| 29 |
+
if (sel) sel.value = theme;
|
| 30 |
+
app.savePreferences();
|
| 31 |
+
};
|
| 32 |
+
|
| 33 |
+
app.setFontSize = function (size) {
|
| 34 |
+
const root = document.documentElement;
|
| 35 |
+
root.style.fontSize = size === 'small' ? '14px' : size === 'large' ? '18px' : '16px';
|
| 36 |
+
app.savePreferences();
|
| 37 |
+
};
|
| 38 |
+
|
| 39 |
+
app.savePreferences = function () {
|
| 40 |
+
const preferences = {
|
| 41 |
+
theme: document.getElementById('themeSelect')?.value,
|
| 42 |
+
fontSize: document.getElementById('fontSize')?.value,
|
| 43 |
+
autoSave: document.getElementById('autoSave')?.checked,
|
| 44 |
+
notifications: document.getElementById('notifications')?.checked
|
| 45 |
+
};
|
| 46 |
+
localStorage.setItem('medicalChatbotPreferences', JSON.stringify(preferences));
|
| 47 |
+
};
|
| 48 |
+
|
| 49 |
+
app.showLoading = function (show) {
|
| 50 |
+
app.isLoading = show;
|
| 51 |
+
const overlay = document.getElementById('loadingOverlay');
|
| 52 |
+
const sendBtn = document.getElementById('sendBtn');
|
| 53 |
+
if (!overlay || !sendBtn) return;
|
| 54 |
+
if (show) {
|
| 55 |
+
overlay.classList.add('show');
|
| 56 |
+
sendBtn.disabled = true;
|
| 57 |
+
} else {
|
| 58 |
+
overlay.classList.remove('show');
|
| 59 |
+
sendBtn.disabled = false;
|
| 60 |
+
}
|
| 61 |
+
};
|
| 62 |
+
|
| 63 |
+
// Settings modal open/close wiring
|
| 64 |
+
document.addEventListener('DOMContentLoaded', () => {
|
| 65 |
+
const settingsBtn = document.getElementById('settingsBtn');
|
| 66 |
+
const modal = document.getElementById('settingsModal');
|
| 67 |
+
const closeBtn = document.getElementById('settingsModalClose');
|
| 68 |
+
const cancelBtn = document.getElementById('settingsModalCancel');
|
| 69 |
+
if (settingsBtn && modal) settingsBtn.addEventListener('click', () => modal.classList.add('show'));
|
| 70 |
+
if (closeBtn) closeBtn.addEventListener('click', () => modal.classList.remove('show'));
|
| 71 |
+
if (cancelBtn) cancelBtn.addEventListener('click', () => modal.classList.remove('show'));
|
| 72 |
+
if (modal) modal.addEventListener('click', (e) => { if (e.target === modal) modal.classList.remove('show'); });
|
| 73 |
+
});
|
| 74 |
+
}
|
static/patient.html
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>Create Patient</title>
|
| 7 |
+
<link rel="stylesheet" href="/static/css/patient.css">
|
| 8 |
+
<link rel="icon" type="image/svg+xml" href="/static/icon.svg">
|
| 9 |
+
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
| 10 |
+
</head>
|
| 11 |
+
<body>
|
| 12 |
+
<div class="container">
|
| 13 |
+
<a href="/" class="back-link"><i class="fas fa-arrow-left"></i> Back to Assistant</a>
|
| 14 |
+
<h1>Create Patient</h1>
|
| 15 |
+
<form id="patientForm">
|
| 16 |
+
<div class="grid">
|
| 17 |
+
<label>Name<input type="text" id="name" required></label>
|
| 18 |
+
<label>Age<input type="number" id="age" min="0" max="130" required></label>
|
| 19 |
+
<label>Sex
|
| 20 |
+
<select id="sex" required>
|
| 21 |
+
<option value="Male">Male</option>
|
| 22 |
+
<option value="Female">Female</option>
|
| 23 |
+
<option value="Other">Other</option>
|
| 24 |
+
</select>
|
| 25 |
+
</label>
|
| 26 |
+
<label>Phone<input type="tel" id="phone"></label>
|
| 27 |
+
<label>Email<input type="email" id="email"></label>
|
| 28 |
+
<label>Address<input type="text" id="address"></label>
|
| 29 |
+
<label>Active Medications<textarea id="medications" placeholder="One per line"></textarea></label>
|
| 30 |
+
<label>Past Assessment Summary<textarea id="summary"></textarea></label>
|
| 31 |
+
</div>
|
| 32 |
+
<div class="actions">
|
| 33 |
+
<button type="button" id="cancelBtn" class="secondary">Cancel</button>
|
| 34 |
+
<button type="submit" class="primary">Create</button>
|
| 35 |
+
</div>
|
| 36 |
+
</form>
|
| 37 |
+
<div id="result" class="result"></div>
|
| 38 |
+
</div>
|
| 39 |
+
<!-- Success Modal -->
|
| 40 |
+
<div class="modal" id="patientSuccessModal">
|
| 41 |
+
<div class="modal-content">
|
| 42 |
+
<div class="modal-header">
|
| 43 |
+
<h3>Patient Created</h3>
|
| 44 |
+
<button class="modal-close" id="patientSuccessClose">×</button>
|
| 45 |
+
</div>
|
| 46 |
+
<div class="modal-body">
|
| 47 |
+
<p>Your new Patient ID is:</p>
|
| 48 |
+
<div id="createdPatientId" class="big-id"></div>
|
| 49 |
+
</div>
|
| 50 |
+
<div class="modal-footer">
|
| 51 |
+
<button class="secondary" id="patientSuccessReturn">Return to main page</button>
|
| 52 |
+
<button class="primary" id="patientSuccessEdit">Edit patient profile</button>
|
| 53 |
+
</div>
|
| 54 |
+
</div>
|
| 55 |
+
</div>
|
| 56 |
+
|
| 57 |
+
<script src="/static/js/patient.js"></script>
|
| 58 |
+
</body>
|
| 59 |
+
</html>
|