zunayed02 commited on
Commit
5feba25
Β·
1 Parent(s): 31515be

Deploy Medical Diagnosis AI - Full Stack Application

Browse files

Features:
- React frontend with Vite
- FastAPI backend with smart predictions
- ML model (Gradient Boosting) for disease diagnosis
- 16-feature medical assessment
- Professional UI with glassmorphism design
- Patient Data Report with all metrics
- Docker container with non-root user (UID 1000)
- Serves both API and static frontend on port 7860

Note: node_modules will be built during Docker build via npm install
This keeps the repository size manageable (~20MB vs 500MB+)

.dockerignore ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Git
2
+ .git
3
+ .gitignore
4
+ .gitattributes
5
+
6
+ # Documentation
7
+ *.md
8
+ README.md
9
+ LICENSE
10
+
11
+ # Development
12
+ .env
13
+ .env.local
14
+ .env.*.local
15
+
16
+ # Python
17
+ __pycache__
18
+ *.pyc
19
+ *.pyo
20
+ *.pyd
21
+ .Python
22
+ env/
23
+ venv/
24
+ .venv
25
+ pip-log.txt
26
+ pip-delete-this-directory.txt
27
+ .tox/
28
+ .coverage
29
+ .coverage.*
30
+ .cache
31
+ nosetests.xml
32
+ coverage.xml
33
+ *.cover
34
+ .hypothesis/
35
+ .pytest_cache/
36
+ *.egg-info/
37
+ dist/
38
+ build/
39
+
40
+ # Node
41
+ node_modules/
42
+ npm-debug.log*
43
+ yarn-debug.log*
44
+ yarn-error.log*
45
+ .npm
46
+ .eslintcache
47
+
48
+ # IDE
49
+ .vscode
50
+ .idea
51
+ *.swp
52
+ *.swo
53
+ *~
54
+ .DS_Store
55
+
56
+ # Frontend build artifacts (will be rebuilt)
57
+ frontend/dist
58
+ frontend/.vite
59
+
60
+ # Tests
61
+ test_*.py
62
+ tests/
63
+ *.test.js
64
+
65
+ # Sessions and cache
66
+ /tmp
67
+ sessions/
68
+ *.json
69
+ .claude*
70
+
71
+ # Docker
72
+ .dockerignore
73
+ Dockerfile
74
+
75
+ # CI/CD
76
+ .github
77
+ .gitlab-ci.yml
78
+
79
+ # Misc
80
+ *.log
81
+ .next
.gitignore ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Node
2
+ node_modules/
3
+ npm-debug.log*
4
+ yarn-debug.log*
5
+ yarn-error.log*
6
+ .npm
7
+ .eslintcache
8
+
9
+ # Python
10
+ __pycache__/
11
+ *.pyc
12
+ *.pyo
13
+ *.pyd
14
+ .Python
15
+ env/
16
+ venv/
17
+ .venv
18
+ pip-log.txt
19
+ .tox/
20
+ .coverage
21
+ .pytest_cache/
22
+ *.egg-info/
23
+
24
+ # Build artifacts
25
+ frontend/dist/
26
+ frontend/.vite/
27
+
28
+ # IDE
29
+ .vscode
30
+ .idea
31
+ *.swp
32
+ *.swo
33
+
34
+ # Environment
35
+ .env
36
+ .env.local
37
+
38
+ # Sessions
39
+ /tmp
40
+ sessions/
41
+
42
+ # OS
43
+ .DS_Store
Dockerfile ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Multi-stage build for Medical Diagnosis AI
2
+ # Stage 1: Build React frontend with Node.js
3
+ # Stage 2: Python backend serving both API and static files
4
+
5
+ # ============================================================
6
+ # Stage 1: Build React Frontend
7
+ # ============================================================
8
+ FROM node:18-slim AS frontend-builder
9
+
10
+ WORKDIR /build/frontend
11
+
12
+ # Copy frontend source code
13
+ COPY frontend/package*.json ./
14
+ RUN npm install --frozen-lockfile
15
+
16
+ # Copy frontend source
17
+ COPY frontend/ .
18
+
19
+ # Build the frontend (creates dist/ folder)
20
+ RUN npm run build
21
+
22
+ # ============================================================
23
+ # Stage 2: Python Backend with Static File Serving
24
+ # ============================================================
25
+ FROM python:3.10-slim
26
+
27
+ # Set metadata
28
+ LABEL maintainer="Medical Diagnosis AI Team"
29
+ LABEL description="Medical Diagnosis AI - Full Stack Application"
30
+
31
+ # Set environment variables
32
+ ENV PYTHONUNBUFFERED=1 \
33
+ PYTHONDONTWRITEBYTECODE=1 \
34
+ PIP_NO_CACHE_DIR=1 \
35
+ PIP_DISABLE_PIP_VERSION_CHECK=1
36
+
37
+ # Create non-root user with UID 1000 (Hugging Face Spaces requirement)
38
+ RUN groupadd -r user && useradd -r -u 1000 -g user user
39
+
40
+ # Set working directory
41
+ WORKDIR /home/user/app
42
+
43
+ # Copy requirements and install Python dependencies
44
+ COPY requirements.txt .
45
+ RUN pip install --upgrade pip && \
46
+ pip install -r requirements.txt
47
+
48
+ # Install curl for health checks
49
+ RUN apt-get update && \
50
+ apt-get install -y --no-install-recommends curl && \
51
+ rm -rf /var/lib/apt/lists/*
52
+
53
+ # Copy backend application code
54
+ COPY app/ ./app/
55
+ COPY server.py .
56
+
57
+ # Copy the ML model (CRITICAL - needed for disease predictions)
58
+ COPY models/ ./models/
59
+
60
+ # Create frontend dist directory and copy built frontend from Stage 1
61
+ RUN mkdir -p frontend
62
+ COPY --from=frontend-builder /build/frontend/dist ./frontend/dist
63
+
64
+ # Change ownership of all files to the non-root user
65
+ RUN chown -R user:user /home/user/app
66
+
67
+ # Switch to non-root user
68
+ USER user
69
+
70
+ # Expose port 7860 (Hugging Face Spaces standard)
71
+ EXPOSE 7860
72
+
73
+ # Health check
74
+ HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
75
+ CMD curl -f http://localhost:7860/health || exit 1
76
+
77
+ # Run the server
78
+ CMD ["python", "server.py"]
PUSH_TO_HF_SPACES.md ADDED
@@ -0,0 +1,178 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Push to Hugging Face Spaces - Final Step
2
+
3
+ You're almost done! Your files are committed and ready to push. Follow these steps:
4
+
5
+ ---
6
+
7
+ ## πŸ” Step 1: Authenticate with Hugging Face
8
+
9
+ ### Option A: Using HF Token (Recommended)
10
+
11
+ **Get your token:**
12
+ 1. Go to: https://huggingface.co/settings/tokens
13
+ 2. Create a new token if you don't have one
14
+ 3. Copy the token
15
+
16
+ **Set it up in your terminal:**
17
+
18
+ ```bash
19
+ # Option 1: Set as environment variable (temporary)
20
+ export HF_TOKEN=your_actual_token_here
21
+ git push
22
+
23
+ # Option 2: Use git credential helper (persistent)
24
+ git config --global credential.helper store
25
+ # When prompted, enter:
26
+ # Username: your_huggingface_username
27
+ # Password: your_HF_TOKEN
28
+ git push
29
+ ```
30
+
31
+ ### Option B: Using SSH (Alternative)
32
+
33
+ If you have SSH keys set up on HF:
34
+
35
+ ```bash
36
+ git remote set-url origin git@huggingface.co:spaces/zunayed02/MediHelp.git
37
+ git push
38
+ ```
39
+
40
+ ---
41
+
42
+ ## πŸš€ Step 2: Push to Your Space
43
+
44
+ Once authenticated:
45
+
46
+ ```bash
47
+ cd /media/zunayed/HDD_code/chatbot\ with\ llm/MediHelp
48
+ git push
49
+ ```
50
+
51
+ **Expected output:**
52
+ ```
53
+ Counting objects: ...
54
+ Writing objects: ...
55
+ Total ... (delta ...)
56
+ remote: Scanning for {content}...
57
+ To https://huggingface.co/spaces/zunayed02/MediHelp
58
+ 31515be..0917c28 main -> main
59
+ ```
60
+
61
+ ---
62
+
63
+ ## ⏳ Step 3: Wait for Build
64
+
65
+ After pushing:
66
+
67
+ 1. **Go to your Space:** https://huggingface.co/spaces/zunayed02/MediHelp
68
+ 2. **Click "Build" tab** to watch the build progress
69
+ 3. **Build takes:** 5-15 minutes
70
+ 4. **Watch for:**
71
+ - βœ… Stage 1: Frontend build
72
+ - βœ… Stage 2: Backend setup
73
+ - βœ… Health check: Pass
74
+
75
+ ---
76
+
77
+ ## βš™οΈ Step 4: Configure GROQ_API_KEY (CRITICAL!)
78
+
79
+ **After build completes:**
80
+
81
+ 1. Go to your Space: https://huggingface.co/spaces/zunayed02/MediHelp
82
+ 2. Click **Settings** (gear icon)
83
+ 3. Click **Secrets**
84
+ 4. Add new secret:
85
+ - **Key:** `GROQ_API_KEY`
86
+ - **Value:** [Your actual Groq API key](https://console.groq.com)
87
+ 5. Save
88
+
89
+ **Without this, the app won't work!**
90
+
91
+ ---
92
+
93
+ ## βœ… Step 5: Test Your Live App
94
+
95
+ Once build is complete and secrets are set:
96
+
97
+ 1. **Reload the Space:** F5 or refresh
98
+ 2. **Test the app:**
99
+ - βœ… Frontend loads
100
+ - βœ… Can enter health data
101
+ - βœ… Gets diagnosis results
102
+ - βœ… Shows Patient Data Report
103
+
104
+ ---
105
+
106
+ ## πŸ“Š Current Status
107
+
108
+ | Item | Status |
109
+ |------|--------|
110
+ | **Files committed** | βœ… Done |
111
+ | **Need to push** | ⏳ Next step |
112
+ | **Get HF token** | ⏳ Next step |
113
+ | **Push to Space** | ⏳ Next step |
114
+ | **Wait for build** | ⏳ Then |
115
+ | **Add GROQ_API_KEY** | ⏳ Then |
116
+ | **Test app** | ⏳ Final |
117
+
118
+ ---
119
+
120
+ ## 🎯 Quick Commands
121
+
122
+ ```bash
123
+ # Navigate to MediHelp
124
+ cd /media/zunayed/HDD_code/chatbot\ with\ llm/MediHelp
125
+
126
+ # Check what's committed
127
+ git log --oneline -3
128
+
129
+ # Set HF token (temporary)
130
+ export HF_TOKEN=your_token_here
131
+
132
+ # Push
133
+ git push
134
+
135
+ # Verify
136
+ git status
137
+ # Should show: "Your branch is up to date with 'origin/main'"
138
+ ```
139
+
140
+ ---
141
+
142
+ ## ⚠️ Troubleshooting
143
+
144
+ ### "No such device or address"
145
+ - No internet connection
146
+ - Network issue
147
+ - Token not set
148
+
149
+ **Solution:** Set token and try again
150
+
151
+ ### "Permission denied"
152
+ - Wrong token
153
+ - Wrong username
154
+ - Invalid credentials
155
+
156
+ **Solution:** Verify token at https://huggingface.co/settings/tokens
157
+
158
+ ### Build fails in HF Spaces
159
+ - Check the build logs
160
+ - Most likely causes:
161
+ - Docker issue (shouldn't happen - we tested)
162
+ - Missing environment variable (GROQ_API_KEY)
163
+
164
+ ---
165
+
166
+ ## πŸ“ž Next Steps After Push
167
+
168
+ 1. **Watch build logs** (should succeed)
169
+ 2. **Add GROQ_API_KEY** to Space secrets
170
+ 3. **Test the app** at your Space URL
171
+ 4. **Share with friends!**
172
+
173
+ ---
174
+
175
+ **Questions?** Check the deployment guides in the medical-predictor-chatbot directory:
176
+ - `DEPLOYMENT_GO_NO_GO.md` - Overview
177
+ - `HUGGING_FACE_DEPLOYMENT.md` - Full guide
178
+ - `DEPLOYMENT_READINESS_AUDIT.md` - Technical details
app/__init__.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ """Medical Predictor Chatbot Application"""
2
+
3
+ __version__ = "1.0.0"
4
+ __author__ = "Medical AI Team"
app/api.py ADDED
@@ -0,0 +1,266 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FastAPI REST API layer wrapping the existing medical chatbot logic.
3
+ Converts the Gradio interface to a REST API for React frontend integration.
4
+ """
5
+
6
+ import hashlib
7
+ import logging
8
+ from fastapi import FastAPI, HTTPException
9
+ from fastapi.middleware.cors import CORSMiddleware
10
+ from pydantic import BaseModel
11
+ from typing import Optional, Dict, Any
12
+ import json
13
+
14
+ from app.main import (
15
+ chat_fn,
16
+ _load_state,
17
+ _save_state,
18
+ _parse_response,
19
+ _get_question_hint,
20
+ )
21
+ from app.services.llm_extractor import extract_features_from_text
22
+ from app.services.feature_builder import count_collected_features, is_ready_for_prediction, prepare_feature_vector
23
+ from app.services.predictor import get_predictor
24
+ from app.memory import initialize_state, update_state, get_missing_features
25
+ from app.config import DEFAULT_MODEL_FEATURES, MIN_FEATURES_FOR_PREDICTION, CLASS_NAMES
26
+ from app.utils.helpers import generate_question, prioritize_features
27
+ from app.services.session_manager import get_session_manager
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+ # Initialize FastAPI
32
+ app = FastAPI(title="Medical Diagnosis AI", description="REST API for medical health prediction")
33
+
34
+ # CORS middleware
35
+ app.add_middleware(
36
+ CORSMiddleware,
37
+ allow_origins=["*"],
38
+ allow_credentials=True,
39
+ allow_methods=["*"],
40
+ allow_headers=["*"],
41
+ )
42
+
43
+ # Session manager
44
+ session_manager = get_session_manager()
45
+
46
+
47
+ # ===== Request/Response Models =====
48
+
49
+ class ChatRequest(BaseModel):
50
+ """Chat message request"""
51
+ session_id: Optional[str] = None
52
+ message: str
53
+ history: list = []
54
+
55
+
56
+ class ChatResponse(BaseModel):
57
+ """Chat response with features and state"""
58
+ session_id: str
59
+ message: str
60
+ features: Dict[str, Any]
61
+ collected_count: int
62
+ total_features: int = 16
63
+ is_complete: bool
64
+ prediction: Optional[Dict[str, Any]] = None
65
+ hint: str = ""
66
+
67
+
68
+ class ResetRequest(BaseModel):
69
+ """Reset session request"""
70
+ session_id: str
71
+
72
+
73
+ class SessionStateResponse(BaseModel):
74
+ """Session state response"""
75
+ session_id: str
76
+ features: Dict[str, Any]
77
+ collected_count: int
78
+ total_features: int = 16
79
+
80
+
81
+ # ===== Helper Functions =====
82
+
83
+ def _build_acknowledgment(extracted: dict) -> str:
84
+ """Build acknowledgment message from extracted features"""
85
+ extracted_items = []
86
+ for feature, value in extracted.items():
87
+ if value is not None and feature in DEFAULT_MODEL_FEATURES:
88
+ extracted_items.append(f"{feature}: {value}")
89
+
90
+ if extracted_items:
91
+ return f"βœ“ Got your {', '.join(extracted_items[:2])}"
92
+ return ""
93
+
94
+
95
+ # ===== API Endpoints =====
96
+
97
+ @app.get("/health")
98
+ def health_check():
99
+ """Health check endpoint"""
100
+ return {"status": "ok", "service": "Medical Diagnosis AI"}
101
+
102
+
103
+ @app.post("/api/chat", response_model=ChatResponse)
104
+ def chat_endpoint(req: ChatRequest):
105
+ """
106
+ Send a message and get AI response with updated features.
107
+
108
+ Handles:
109
+ - Session ID generation if not provided
110
+ - Feature extraction from user message
111
+ - State persistence
112
+ - Prediction when all 16 features collected
113
+ """
114
+ try:
115
+ # Generate or use session ID
116
+ if req.session_id:
117
+ session_id = req.session_id
118
+ else:
119
+ # Generate from first message hash
120
+ session_id = "sess_" + hashlib.md5(req.message.encode()).hexdigest()[:8]
121
+ logger.info(f"πŸ” Created new session: {session_id}")
122
+
123
+ # Load persisted state
124
+ state = _load_state(session_id)
125
+
126
+ # Extract features from user message
127
+ extracted = extract_features_from_text(req.message)
128
+
129
+ # Update state with extracted features
130
+ state = update_state(state, extracted)
131
+
132
+ # Count collected features
133
+ collected = count_collected_features(state)
134
+ missing = get_missing_features(state)
135
+
136
+ # Check if ready for prediction
137
+ if is_ready_for_prediction(state, MIN_FEATURES_FOR_PREDICTION):
138
+ # All 16 features collected - make prediction
139
+ feature_vector = prepare_feature_vector(state)
140
+ predictor = get_predictor()
141
+ pred_result = predictor.predict(feature_vector)
142
+
143
+ pred_data = {
144
+ "prediction_class": int(pred_result.prediction),
145
+ "prediction_name": CLASS_NAMES[int(pred_result.prediction)],
146
+ "confidence": float(pred_result.probability),
147
+ "risk_level": pred_result.risk_level,
148
+ "explanation": pred_result.explanation,
149
+ "features": state
150
+ }
151
+
152
+ # Simple completion message - full details now in DiagnosisCard component
153
+ response_msg = "βœ… Assessment Complete! Your diagnosis is ready below."
154
+
155
+ _save_state(session_id, state)
156
+
157
+ return ChatResponse(
158
+ session_id=session_id,
159
+ message=response_msg,
160
+ features=state,
161
+ collected_count=collected,
162
+ is_complete=True,
163
+ prediction=pred_data,
164
+ hint=""
165
+ )
166
+
167
+ # Not complete yet - ask for next missing feature
168
+ prioritized_missing = prioritize_features(missing)
169
+ next_question = generate_question(prioritized_missing[:1])
170
+ ack = _build_acknowledgment(extracted)
171
+ remaining = 16 - collected
172
+
173
+ response_msg = f"""{ack}
174
+
175
+ {next_question}
176
+
177
+ **{remaining} more pieces of information needed.**""" if ack else f"""{next_question}
178
+
179
+ **{remaining} more pieces of information needed.**"""
180
+
181
+ # Get hint for the next question
182
+ hint = _get_question_hint(next_question)
183
+
184
+ _save_state(session_id, state)
185
+
186
+ return ChatResponse(
187
+ session_id=session_id,
188
+ message=response_msg,
189
+ features=state,
190
+ collected_count=collected,
191
+ is_complete=False,
192
+ hint=hint
193
+ )
194
+
195
+ except Exception as e:
196
+ logger.error(f"❌ Error in chat endpoint: {e}", exc_info=True)
197
+ raise HTTPException(status_code=500, detail=str(e))
198
+
199
+
200
+ @app.post("/api/reset")
201
+ def reset_endpoint(req: ResetRequest):
202
+ """Reset a session - clear all features and start fresh"""
203
+ try:
204
+ session_manager = get_session_manager()
205
+ success = session_manager.reset_session(req.session_id)
206
+
207
+ if success:
208
+ logger.info(f"βœ… Reset session {req.session_id}")
209
+ return {
210
+ "success": True,
211
+ "message": "Session reset successfully",
212
+ "session_id": req.session_id
213
+ }
214
+ else:
215
+ raise HTTPException(status_code=404, detail="Session not found")
216
+
217
+ except Exception as e:
218
+ logger.error(f"❌ Error resetting session: {e}")
219
+ raise HTTPException(status_code=500, detail=str(e))
220
+
221
+
222
+ @app.get("/api/session/{session_id}", response_model=SessionStateResponse)
223
+ def get_session_endpoint(session_id: str):
224
+ """Get current session state"""
225
+ try:
226
+ state = _load_state(session_id)
227
+ collected = count_collected_features(state)
228
+
229
+ return SessionStateResponse(
230
+ session_id=session_id,
231
+ features=state,
232
+ collected_count=collected
233
+ )
234
+
235
+ except Exception as e:
236
+ logger.error(f"❌ Error getting session: {e}")
237
+ raise HTTPException(status_code=500, detail=str(e))
238
+
239
+
240
+ @app.get("/api/features")
241
+ def get_features_list():
242
+ """Get list of all 16 features with their metadata"""
243
+ from app.config import FEATURE_RANGES
244
+
245
+ features_info = {}
246
+ for feature in DEFAULT_MODEL_FEATURES:
247
+ if feature in FEATURE_RANGES:
248
+ min_val, max_val, _ = FEATURE_RANGES[feature]
249
+ features_info[feature] = {
250
+ "min": min_val,
251
+ "max": max_val,
252
+ "type": "numeric" if feature not in ["Smoking", "Alcohol", "Family History"] else "binary"
253
+ }
254
+ else:
255
+ features_info[feature] = {"min": None, "max": None, "type": "unknown"}
256
+
257
+ return {
258
+ "total": len(DEFAULT_MODEL_FEATURES),
259
+ "features": DEFAULT_MODEL_FEATURES,
260
+ "metadata": features_info
261
+ }
262
+
263
+
264
+ if __name__ == "__main__":
265
+ import uvicorn
266
+ uvicorn.run(app, host="0.0.0.0", port=8000, reload=True)
app/config.py ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from pathlib import Path
3
+ from dotenv import load_dotenv
4
+
5
+ # Load environment
6
+ load_dotenv()
7
+
8
+ # Paths - robust for both local and HF Spaces deployment
9
+ BASE_DIR = Path(__file__).parent.parent
10
+
11
+ # Try multiple possible model locations
12
+ _possible_paths = [
13
+ BASE_DIR / "models" / "GradientBoosting_model.pkl", # Local development
14
+ BASE_DIR / "model" / "GradientBoosting_model.pkl", # If named 'model' instead
15
+ Path("/app/models/GradientBoosting_model.pkl"), # HF Spaces absolute path
16
+ Path.cwd() / "models" / "GradientBoosting_model.pkl", # Current working directory
17
+ ]
18
+
19
+ MODEL_PATH = None
20
+ for path in _possible_paths:
21
+ if path.exists():
22
+ MODEL_PATH = path
23
+ break
24
+
25
+ # Default to first path if none found (will error gracefully)
26
+ if MODEL_PATH is None:
27
+ MODEL_PATH = _possible_paths[0]
28
+
29
+ # Groq API configuration
30
+ GROQ_API_KEY = os.getenv("GROQ_API_KEY")
31
+ GROQ_MODEL = "llama-3.1-70b-versatile"
32
+ GROQ_TEMPERATURE = 0
33
+ GROQ_TIMEOUT = 30
34
+
35
+ # Medical features (all 16)
36
+ DEFAULT_MODEL_FEATURES = [
37
+ "LengthOfStay",
38
+ "Smoking",
39
+ "Family History",
40
+ "HbA1c",
41
+ "Glucose",
42
+ "Age",
43
+ "Diet Score",
44
+ "Alcohol",
45
+ "Physical Activity",
46
+ "Blood Pressure",
47
+ "BMI",
48
+ "Cholesterol",
49
+ "Sleep Hours",
50
+ "Stress Level",
51
+ "Triglycerides",
52
+ "Oxygen Saturation"
53
+ ]
54
+
55
+ # Feature validation ranges (min, max, expected type)
56
+ FEATURE_RANGES = {
57
+ "Age": (0, 150, float),
58
+ "Glucose": (70, 400, float),
59
+ "HbA1c": (3, 15, float),
60
+ "BMI": (10, 60, float),
61
+ "Cholesterol": (100, 400, float),
62
+ "Triglycerides": (20, 500, float),
63
+ "Blood Pressure": (60, 200, float),
64
+ "Physical Activity": (0, 24, float),
65
+ "Sleep Hours": (0, 24, float),
66
+ "Stress Level": (1, 10, float),
67
+ "Diet Score": (1, 10, float),
68
+ "Smoking": (0, 1, int),
69
+ "Alcohol": (0, 1, int),
70
+ "Family History": (0, 1, int),
71
+ "LengthOfStay": (0, 365, int),
72
+ "Oxygen Saturation": (80, 100, float),
73
+ }
74
+
75
+ # Model configuration
76
+ MIN_FEATURES_FOR_PREDICTION = 16 # Ask for all 16 features
77
+ MAX_RETRIES = 3
78
+
79
+ # Prediction classes
80
+ CLASS_NAMES = [
81
+ "Arthritis",
82
+ "Asthma",
83
+ "Cancer",
84
+ "Diabetes",
85
+ "Healthy",
86
+ "Hypertension",
87
+ "Obesity",
88
+ "Other/Unknown",
89
+ ]
90
+
91
+ # App configuration
92
+ DEBUG_MODE = True
93
+ CONVERSATION_MAX_TURNS = 20
app/main.py ADDED
@@ -0,0 +1,923 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import gradio as gr
2
+ import logging
3
+ import json
4
+ import re
5
+ import hashlib
6
+ from pathlib import Path
7
+ from typing import List, Tuple
8
+
9
+ from .services.llm_extractor import extract_features_from_text
10
+ from .services.feature_builder import (
11
+ prepare_feature_vector,
12
+ count_collected_features,
13
+ is_ready_for_prediction
14
+ )
15
+ from .services.predictor import get_predictor
16
+ from .services.session_manager import get_session_manager
17
+ from .memory import initialize_state, update_state, get_missing_features
18
+ from .utils.helpers import generate_question, prioritize_features
19
+ from .config import DEFAULT_MODEL_FEATURES, MIN_FEATURES_FOR_PREDICTION, CLASS_NAMES
20
+
21
+ # Setup logging
22
+ logging.basicConfig(
23
+ level=logging.INFO,
24
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
25
+ )
26
+ logger = logging.getLogger(__name__)
27
+
28
+ # Session state storage
29
+ SESSION_DIR = Path("/tmp/deepsense_sessions")
30
+ SESSION_DIR.mkdir(exist_ok=True)
31
+
32
+ # Session manager
33
+ session_manager = get_session_manager()
34
+
35
+ def _get_session_id(history: List) -> str:
36
+ """Generate session ID consistently across all turns
37
+
38
+ Key insight: In Gradio ChatInterface:
39
+ - Turn 1: history is empty
40
+ - Turn 2+: history contains all prior messages, starting with first user message
41
+
42
+ Strategy: When history is empty, use a temporary default and let it be overridden
43
+ on the next turn when we have the actual first message.
44
+ """
45
+ try:
46
+ first_msg = None
47
+
48
+ # If we have history, always use the first message (most reliable)
49
+ if history:
50
+ # Gradio 6 format: list of dicts with 'role' and 'content'
51
+ if isinstance(history[0], dict):
52
+ first_msg = history[0].get("content", "")
53
+ # Fallback for tuple format (Gradio 4 or mixed formats)
54
+ elif isinstance(history[0], (tuple, list)):
55
+ first_msg = history[0][0] if len(history[0]) > 0 else ""
56
+ else:
57
+ first_msg = str(history[0])
58
+
59
+ # Handle case where first_msg is a list (Gradio 6 content can be list)
60
+ if first_msg and isinstance(first_msg, list):
61
+ first_msg = str(first_msg[0]) if first_msg else ""
62
+
63
+ first_msg = str(first_msg) if first_msg else "default"
64
+
65
+ # Generate session ID from first message hash
66
+ session_id = "sess_" + hashlib.md5(first_msg.encode()).hexdigest()[:8]
67
+ logger.debug(f"πŸ” Session ID: {session_id} (from message: {first_msg[:50]}...)")
68
+ return session_id
69
+
70
+ except Exception as e:
71
+ logger.warning(f"⚠️ Error generating session ID: {e}")
72
+ return "sess_error"
73
+
74
+ def _load_state(session_id: str) -> dict:
75
+ """Load state from file with detailed logging and error handling"""
76
+ try:
77
+ path = SESSION_DIR / f"{session_id}.json"
78
+ if path.exists():
79
+ with open(path, 'r') as f:
80
+ state_data = json.load(f)
81
+ collected = sum(1 for v in state_data.values() if v is not None)
82
+ logger.info(f"πŸ“‚ Loaded session {session_id}: {collected}/16 features")
83
+ return state_data
84
+ else:
85
+ logger.info(f"πŸ“‚ New session {session_id} (no prior file)")
86
+ return initialize_state()
87
+ except Exception as e:
88
+ logger.error(f"❌ Error loading state: {e} - creating fresh state")
89
+ return initialize_state()
90
+
91
+ def _save_state(session_id: str, state: dict):
92
+ """Save state to file with detailed logging and proper error handling"""
93
+ try:
94
+ path = SESSION_DIR / f"{session_id}.json"
95
+ with open(path, 'w') as f:
96
+ json.dump(state, f)
97
+ collected = sum(1 for v in state.values() if v is not None)
98
+ logger.info(f"πŸ’Ύ Saved session {session_id}: {collected}/16 features to {path}")
99
+ except Exception as e:
100
+ logger.error(f"❌ Error saving state to {path}: {e}")
101
+
102
+
103
+ def _handle_simple_response(message: str, feature: str) -> dict:
104
+ """
105
+ Handle simple yes/no responses for binary features and numeric responses.
106
+ Maps responses like 'yes', 'no', '45', '10 days', etc. to feature values.
107
+
108
+ Args:
109
+ message: User's message
110
+ feature: Feature name being asked about
111
+
112
+ Returns:
113
+ Dictionary with feature mapped to value, or empty dict if not extractable
114
+ """
115
+ import re
116
+
117
+ msg_lower = message.lower().strip()
118
+
119
+ # Binary features: Smoking, Alcohol, Family History
120
+ if feature in ["Smoking", "Alcohol", "Family History"]:
121
+ if any(word in msg_lower for word in ["yes", "yep", "yeah", "yea", "true", "i have", "i do", "positive"]):
122
+ return {feature: 1}
123
+ elif any(word in msg_lower for word in ["no", "nope", "nah", "false", "i don't", "i do not", "negative", "neither"]):
124
+ return {feature: 0}
125
+
126
+ # Numeric features - extract first number from message
127
+ numeric_features = [
128
+ "Age", "Glucose", "HbA1c", "BMI", "Cholesterol", "Triglycerides",
129
+ "Blood Pressure", "Physical Activity", "Sleep Hours", "Stress Level",
130
+ "Diet Score", "LengthOfStay", "Oxygen Saturation"
131
+ ]
132
+
133
+ if feature in numeric_features:
134
+ # Try to extract a number from the message
135
+ numbers = re.findall(r"[-+]?\d*\.?\d+", message)
136
+ if numbers:
137
+ try:
138
+ value = float(numbers[0]) if "." in numbers[0] else int(numbers[0])
139
+ logger.debug(f"βœ… Extracted {feature} = {value} from simple response")
140
+ return {feature: value}
141
+ except (ValueError, IndexError):
142
+ pass
143
+
144
+ return {}
145
+
146
+
147
+ def _extract_previously_asked_features(history: List[Tuple[str, str]]) -> set:
148
+ """
149
+ Extract what features have already been asked about in the conversation.
150
+ This prevents repetition.
151
+
152
+ Args:
153
+ history: Conversation history (list of tuples)
154
+
155
+ Returns:
156
+ Set of features that have been asked about
157
+ """
158
+ asked_features = set()
159
+ if not history:
160
+ return asked_features
161
+
162
+ question_keywords = {
163
+ "Age": ["age", "years old"],
164
+ "BloodPressure": ["blood pressure", "bp"],
165
+ "Glucose": ["glucose", "blood sugar"],
166
+ "BMI": ["bmi", "body mass"],
167
+ "Cholesterol": ["cholesterol"],
168
+ "HbA1c": ["hba1c", "hemoglobin"],
169
+ "Triglycerides": ["triglycerides"],
170
+ "Smoking": ["smoke", "smoking"],
171
+ "Alcohol": ["drink", "alcohol"],
172
+ "PhysicalActivity": ["exercise", "activity", "workout"],
173
+ "SleepHours": ["sleep", "hours of sleep"],
174
+ "StressLevel": ["stress"],
175
+ "DietScore": ["diet", "nutrition"],
176
+ "FamilyHistory": ["family history", "disease history"],
177
+ "LengthOfStay": ["hospital", "stay", "days"],
178
+ "OxygenSaturation": ["oxygen", "saturation"],
179
+ }
180
+
181
+ try:
182
+ # Check bot's previous questions (Gradio 6 format)
183
+ for item in history:
184
+ try:
185
+ # Gradio 6: dict with 'role' and 'content'
186
+ if isinstance(item, dict):
187
+ if item.get("role") == "assistant":
188
+ bot_response = item.get("content", "")
189
+ else:
190
+ continue
191
+ # Fallback for tuple format
192
+ elif isinstance(item, (list, tuple)) and len(item) >= 2:
193
+ _, bot_response = item[0], item[1]
194
+ else:
195
+ continue
196
+
197
+ if bot_response:
198
+ response_lower = str(bot_response).lower()
199
+ for feature, keywords in question_keywords.items():
200
+ for keyword in keywords:
201
+ if keyword in response_lower:
202
+ asked_features.add(feature)
203
+ break
204
+ except (ValueError, IndexError, TypeError):
205
+ # Skip malformed history items
206
+ continue
207
+
208
+ return asked_features
209
+ except Exception as e:
210
+ logger.warning(f"⚠️ Error extracting asked features: {e}")
211
+ return set()
212
+
213
+
214
+ def chat_fn(message: str, history: List[Tuple[str, str]]) -> str:
215
+ """
216
+ Main chat function for Gradio ChatInterface.
217
+
218
+ Orchestrates the complete conversation flow:
219
+ 1. Extract features from user input (Groq LLM)
220
+ 2. For simple yes/no to binary features, use pattern matching
221
+ 3. Update memory with extracted features
222
+ 4. Check for missing features
223
+ 5. If all features ready, make prediction
224
+ 6. Otherwise, ask for next missing feature
225
+
226
+ Args:
227
+ message: User's current message
228
+ history: Conversation history (not used but required by Gradio)
229
+
230
+ Returns:
231
+ Bot response (question or prediction)
232
+ """
233
+ try:
234
+ # Get/create session and load persisted state
235
+ # If history is empty (Turn 1), use current message for consistent session ID
236
+ if not history:
237
+ # Generate session_id from first user message to ensure consistency across turns
238
+ session_id = "sess_" + hashlib.md5(message.encode()).hexdigest()[:8]
239
+ logger.debug(f"πŸ” Turn 1 Session ID: {session_id} (from current message)")
240
+ else:
241
+ # Turn 2+: Use first message from history
242
+ session_id = _get_session_id(history)
243
+
244
+ state = _load_state(session_id)
245
+
246
+ # Normalize message
247
+ msg_lower = message.lower().strip()
248
+
249
+ # Check if conversation has started (by checking if state has any data)
250
+ collected = count_collected_features(state)
251
+ is_first_message = collected == 0 # True only if no features collected yet
252
+ conversation_active = collected > 0
253
+ previously_asked = _extract_previously_asked_features(history) if history else set()
254
+
255
+ # Track last asked feature for simple response matching
256
+ last_asked_feature = None
257
+
258
+ # First message greeting (ONLY if truly first message AND looks like greeting)
259
+ if is_first_message and len(msg_lower.split()) <= 6: # Short messages are likely greetings
260
+ logger.info("🎯 Starting new conversation")
261
+
262
+ # Check if it's a simple greeting
263
+ greeting_words = ["hello", "hi", "hey", "good morning", "good afternoon", "good evening", "name is", "i am", "i'm"]
264
+ if any(word in msg_lower for word in greeting_words):
265
+ # Extract name if mentioned (letters only, not numbers)
266
+ name_match = re.search(r"(?:i'm|i am|my name is|name's)\s+([a-z]+)", msg_lower)
267
+ user_name = name_match.group(1).capitalize() if name_match else "there"
268
+
269
+ greeting_response = f"Hi {user_name}! Nice to meet you. 😊\n\n"
270
+ greeting_response += """I'm here to help build your health profile. We'll gather 16 key health metrics in a natural, conversational way.
271
+
272
+ **To get started: What is your age?**"""
273
+ _save_state(session_id, state)
274
+ return _format_response(state, greeting_response)
275
+
276
+ # Handle mid-conversation greetings (don't reset, reference last topic)
277
+ if conversation_active:
278
+ greeting_words = ["hello", "hi", "hey", "good morning", "good afternoon", "good evening"]
279
+ if any(word in msg_lower for word in greeting_words) and len(msg_lower.split()) <= 3:
280
+ # It's a simple greeting mid-conversation, don't reset
281
+ last_bot_message = history[-1][1] if history else ""
282
+ context = "your health profile"
283
+ if "age" in last_bot_message.lower():
284
+ context = "your age"
285
+ elif "blood pressure" in last_bot_message.lower():
286
+ context = "your blood pressure"
287
+ elif "glucose" in last_bot_message.lower():
288
+ context = "your glucose level"
289
+
290
+ continuation = f"Hey! We were just talking about {context}. Do you have those numbers for me?"
291
+ _save_state(session_id, state)
292
+ return _format_response(state, continuation)
293
+
294
+ # Handle reset command
295
+ if msg_lower in ["reset", "clear", "new", "start over", "/reset"]:
296
+ state = initialize_state() # Fresh state
297
+ _save_state(session_id, state)
298
+ reset_msg = """Let's start fresh! 🌟
299
+
300
+ I'm ready to help build your health profile again. Just share your health information naturally, like:
301
+ - "I'm 45 years old and my glucose is 150"
302
+ - "I smoke and my stress level is 8"
303
+ - "My BMI is 28 and I exercise 5 hours per week"
304
+
305
+ What would you like to share first?"""
306
+ return _format_response(state, reset_msg)
307
+
308
+ # Step 1: Extract features (with safety)
309
+ logger.info(f"πŸ‘€ User: {message}")
310
+ try:
311
+ extracted = extract_features_from_text(message)
312
+ if not extracted:
313
+ extracted = {f: None for f in DEFAULT_MODEL_FEATURES}
314
+ except Exception as e:
315
+ logger.warning(f"Extraction failed: {e}, using empty")
316
+ extracted = {f: None for f in DEFAULT_MODEL_FEATURES}
317
+
318
+ logger.info(f"πŸ“Š Extracted: {[k for k,v in extracted.items() if v]}")
319
+
320
+ # Step 1.5: Check if any features were extracted
321
+ features_extracted = sum(1 for v in extracted.values() if v is not None)
322
+ if features_extracted == 0:
323
+ logger.debug(f"⚠️ No features extracted from: {message}")
324
+
325
+ # Step 1b: If extraction didn't work and we asked about a feature, use simple pattern matching
326
+ if last_asked_feature and all(v is None for v in extracted.values()):
327
+ simple_response = _handle_simple_response(message, last_asked_feature)
328
+ if simple_response:
329
+ logger.info(f"βœ… Matched simple response for {last_asked_feature}")
330
+ extracted.update(simple_response)
331
+
332
+ # Step 2: Update memory with extracted features (safe)
333
+ try:
334
+ state = update_state(state, extracted)
335
+ collected = count_collected_features(state)
336
+ logger.info(f"πŸ’Ύ Collected: {collected}/16")
337
+
338
+ # Verify state integrity
339
+ if state is None:
340
+ logger.error("❌ State became None after update!")
341
+ state = initialize_state()
342
+ if not isinstance(state, dict):
343
+ logger.error(f"❌ State is not a dict: {type(state)}")
344
+ state = initialize_state()
345
+
346
+ except Exception as e:
347
+ logger.error(f"State update failed: {e}")
348
+ state = initialize_state()
349
+ collected = 0
350
+
351
+ # Step 3: Check for missing features
352
+ missing = get_missing_features(state)
353
+
354
+ # Step 3.5: Smart handling - if nothing extracted and no features collected yet
355
+ if features_extracted == 0 and collected == 0:
356
+ logger.info("⚠️ User input is non-medical. Prompting for medical information with positive frame.")
357
+ non_medical_msg = """I'm here to help build your health profile. To get started, could you tell me your age or any health details you'd like to share?
358
+
359
+ For example: "I'm 45 years old" or "My glucose is 150" β€” anything you're comfortable sharing helps!"""
360
+ return _format_response(state, non_medical_msg)
361
+
362
+ # Step 4: Decision - Can we make a prediction?
363
+ if len(missing) == 0 or is_ready_for_prediction(state, MIN_FEATURES_FOR_PREDICTION):
364
+ logger.info("🎯 All features collected! Making prediction...")
365
+ last_asked_feature = None
366
+ return _make_prediction(state, session_id)
367
+
368
+ # Step 5: Not ready yet - ask for next missing features (1-2 at a time)
369
+ else:
370
+ if missing:
371
+ last_asked_feature = missing[0]
372
+ question_text = _ask_next_question(missing, len(missing), extracted)
373
+ _save_state(session_id, state) # Save state before returning
374
+ return _format_response(state, "", question_text)
375
+
376
+ except Exception as e:
377
+ logger.error(f"❌ Error in chat: {e}", exc_info=True)
378
+ logger.error(f" History type: {type(history)}, length: {len(history) if history else 0}")
379
+ logger.error(f" State type: {type(state)}")
380
+ logger.error(f" Message: {message}")
381
+
382
+ # Return user-friendly error message
383
+ error_msg = "I encountered an issue processing your message. Could you try again or rephrase?"
384
+ return _format_response(state or initialize_state(), error_msg)
385
+
386
+
387
+ def _make_prediction(state: dict, session_id: str = None) -> str:
388
+ """
389
+ Make ML prediction once all features are collected.
390
+
391
+ Args:
392
+ state: Dictionary with all medical features
393
+ session_id: Session ID for saving final state
394
+
395
+ Returns:
396
+ Formatted prediction result message
397
+ """
398
+ try:
399
+ # Prepare feature vector for ML model
400
+ feature_vector = prepare_feature_vector(state)
401
+ logger.info(f"πŸ”’ Feature vector prepared: {feature_vector}")
402
+
403
+ # Get predictor and make prediction
404
+ predictor = get_predictor()
405
+ result = predictor.predict(feature_vector)
406
+
407
+ # Format response
408
+ class_name = CLASS_NAMES[result.prediction] if result.prediction < len(CLASS_NAMES) else "Unknown"
409
+
410
+ response = f"""
411
+ βœ… **All Information Collected!**
412
+
413
+ ---
414
+
415
+ ### πŸ₯ Prediction Results
416
+
417
+ **Predicted Condition:** {class_name}
418
+ **Confidence Level:** {result.probability*100:.1f}%
419
+ **Risk Assessment:** **{result.risk_level}**
420
+
421
+ **Details:** {result.explanation}
422
+
423
+ ---
424
+
425
+ ### πŸ“‹ Features Used for Prediction:
426
+ """
427
+ # Add feature summary
428
+ for feature in DEFAULT_MODEL_FEATURES:
429
+ value = state.get(feature)
430
+ response += f"\nβ€’ **{feature}:** {value if value is not None else 'Not provided'}"
431
+
432
+ response += """
433
+
434
+ ---
435
+
436
+ ### ⚠️ Important Disclaimer
437
+ **This is a demonstration tool only.** The prediction should never be used as a substitute for professional medical advice. Please consult with a qualified healthcare professional to discuss these results and your health concerns.
438
+
439
+ ---
440
+
441
+ **Thank you for providing this information. Your health matters!**
442
+ """
443
+
444
+ # Add JSON output for backend processing
445
+ json_output = {
446
+ "FINAL_DATA": {
447
+ "prediction_class": result.prediction,
448
+ "prediction_name": class_name,
449
+ "confidence": round(result.probability * 100, 1),
450
+ "risk_level": result.risk_level,
451
+ "features": state.copy()
452
+ }
453
+ }
454
+
455
+ # Add JSON to response (for backend to capture)
456
+ response += f"\n\n```json\n{json.dumps(json_output, indent=2)}\n```"
457
+
458
+ logger.info("βœ… Prediction completed successfully")
459
+ if session_id:
460
+ _save_state(session_id, state) # Save final state
461
+ return _format_response(state, response)
462
+
463
+ except Exception as e:
464
+ logger.error(f"❌ Prediction error: {e}", exc_info=True)
465
+ return f"❌ Error making prediction: {str(e)}. Please try again."
466
+
467
+
468
+ def _format_response(current_state: dict, acknowledgment: str = "", question: str = "") -> str:
469
+ """
470
+ Format response: ALWAYS show DATA_STATE JSON + RESPONSE text
471
+ """
472
+ # Build JSON state
473
+ data_state = {}
474
+ for feature in DEFAULT_MODEL_FEATURES:
475
+ value = current_state.get(feature)
476
+ data_state[feature] = value
477
+
478
+ json_state = json.dumps(data_state)
479
+
480
+ # Build response text
481
+ response_text = acknowledgment
482
+ if question:
483
+ if acknowledgment:
484
+ response_text += f"\n\n{question}"
485
+ else:
486
+ response_text = question
487
+
488
+ # ALWAYS show both DATA_STATE and RESPONSE
489
+ formatted = f"""DATA_STATE: {json_state}
490
+
491
+ RESPONSE: {response_text}"""
492
+
493
+ return formatted
494
+
495
+
496
+ def _ask_next_question(missing_features: list, count_missing: int, last_extracted: dict = None) -> str:
497
+ """
498
+ Generate professional response asking for next missing features (1-2 at a time).
499
+ Follows the Medical Data Analyst protocol with structured output.
500
+
501
+ Args:
502
+ missing_features: List of missing feature names
503
+ count_missing: Count of missing features
504
+ last_extracted: Dictionary of features just extracted
505
+
506
+ Returns:
507
+ Formatted response with internal state and assistant message
508
+ """
509
+ try:
510
+ # Build acknowledgment of what was received
511
+ acknowledgment = ""
512
+ if last_extracted and any(v is not None for v in last_extracted.values()):
513
+ extracted_items = []
514
+ for k, v in last_extracted.items():
515
+ if v is not None:
516
+ # Format value nicely
517
+ if v in [0, 1]:
518
+ display_val = "Yes" if v == 1 else "No"
519
+ else:
520
+ display_val = f"{v}"
521
+ extracted_items.append(f"{k} of {display_val}")
522
+
523
+ if extracted_items:
524
+ acknowledgment = f"βœ“ I've noted your {' and '.join(extracted_items)}."
525
+
526
+ # Prioritize next questions
527
+ prioritized_missing = prioritize_features(missing_features)
528
+ next_questions = generate_question(prioritized_missing[:2])
529
+
530
+ progress = f"**{count_missing} more pieces of information needed to complete your profile.**"
531
+
532
+ full_question = f"{next_questions}\n\n{progress}"
533
+
534
+ logger.info(f"❓ Asking for: {prioritized_missing[0] if prioritized_missing else 'unknown'}")
535
+ return full_question
536
+
537
+ except Exception as e:
538
+ logger.error(f"❌ Error generating question: {e}", exc_info=True)
539
+ return "❌ Error generating question. Please try again."
540
+
541
+
542
+ def reset_session(history: List) -> List:
543
+ """
544
+ Reset current session: clear all data and start fresh
545
+
546
+ Args:
547
+ history: Chat history (used to get session ID)
548
+
549
+ Returns:
550
+ New history with reset confirmation message
551
+ """
552
+ try:
553
+ session_id = _get_session_id(history)
554
+
555
+ # Reset session in manager
556
+ success = session_manager.reset_session(session_id)
557
+
558
+ if success:
559
+ reset_msg = """πŸ”„ **Session Reset Successfully!**
560
+
561
+ Your health profile has been cleared. Let's start fresh with a new assessment.
562
+
563
+ Just introduce yourself and tell me about your health:
564
+ - "Hi, I'm John, 45 years old, my glucose is 150"
565
+ - "I'm Sarah, smoke occasionally, stress level is 8"
566
+ - Or any other health information you'd like to share"""
567
+
568
+ logger.info(f"βœ… Session {session_id} reset by user")
569
+
570
+ # Return new history with reset message (Gradio 6 format)
571
+ return [{"role": "assistant", "content": reset_msg}]
572
+ else:
573
+ return [{"role": "assistant", "content": "❌ Error resetting session. Please try again."}]
574
+
575
+ except Exception as e:
576
+ logger.error(f"❌ Error in reset_session: {e}")
577
+ return [{"role": "assistant", "content": f"❌ Error: {str(e)}"}]
578
+
579
+
580
+ def _parse_response(raw: str) -> tuple:
581
+ """
582
+ Parse the raw chat_fn output to extract clean response and data state.
583
+
584
+ Input format: "DATA_STATE: {...}\n\nRESPONSE: <text>"
585
+ Returns: (clean_response_text, data_state_dict)
586
+ """
587
+ data_state = {}
588
+ response = raw
589
+
590
+ if "DATA_STATE:" in raw and "RESPONSE:" in raw:
591
+ try:
592
+ parts = raw.split("RESPONSE:", 1)
593
+ data_part = parts[0].replace("DATA_STATE:", "").strip()
594
+ response = parts[1].strip()
595
+ data_state = json.loads(data_part)
596
+ except (json.JSONDecodeError, IndexError):
597
+ logger.warning(f"Could not parse DATA_STATE from response")
598
+ data_state = {f: None for f in DEFAULT_MODEL_FEATURES}
599
+
600
+ return response, data_state
601
+
602
+
603
+ def _get_question_hint(response: str) -> str:
604
+ """
605
+ Extract hint text based on which feature is being asked in the bot's response.
606
+ Matches feature names in the response text.
607
+ """
608
+ hints = {
609
+ "Blood Pressure": "Systolic pressure (e.g., 120). Normal: 90-120 mmHg.",
610
+ "Glucose": "Blood glucose in mg/dL (e.g., 100). Normal: 70-140.",
611
+ "HbA1c": "HbA1c percentage (e.g., 5.5%). Normal: 4-6%.",
612
+ "BMI": "Body Mass Index (e.g., 25). Normal: 18.5-24.9.",
613
+ "Age": "Age in years (e.g., 34). Range: 18-100.",
614
+ "Cholesterol": "Total cholesterol in mg/dL (e.g., 180). Normal: <200.",
615
+ "Triglycerides": "Triglycerides in mg/dL (e.g., 150). Normal: <150.",
616
+ "Oxygen Saturation": "SpO2 percentage (example: 98). Normal: 95-100%.",
617
+ "Sleep Hours": "Hours of sleep per night (e.g., 7). Normal: 6-9.",
618
+ "Stress Level": "Stress level 1-10 (e.g., 5). 1=very low, 10=very high.",
619
+ "Physical Activity": "Exercise hours per week (e.g., 5).",
620
+ "Diet Score": "Diet quality 1-10 (e.g., 7). 1=poor, 10=excellent.",
621
+ "Smoking": "Do you smoke? Answer yes or no.",
622
+ "Alcohol": "Do you drink alcohol? Answer yes or no.",
623
+ "Family History": "Family history of disease? Answer yes or no.",
624
+ "LengthOfStay": "Hospital stay in days (e.g., 3). Enter 0 if not hospitalized.",
625
+ }
626
+
627
+ response_lower = response.lower()
628
+ for feature, hint in hints.items():
629
+ if feature.lower() in response_lower:
630
+ return hint
631
+
632
+ return "Type your response naturally or just enter a number."
633
+
634
+
635
+ # Create Gradio UI with modern medical intake form design
636
+ css_styling = """
637
+ /* Base container */
638
+ .gradio-container {
639
+ background: linear-gradient(135deg, #F5F3FF 0%, #FAF9FF 100%) !important;
640
+ max-width: 900px !important;
641
+ margin: auto !important;
642
+ padding: 1rem !important;
643
+ }
644
+
645
+ /* Header card */
646
+ .header-card {
647
+ background: white;
648
+ border-radius: 16px;
649
+ box-shadow: 0 2px 12px rgba(124, 58, 237, 0.08);
650
+ padding: 2rem;
651
+ margin-bottom: 1.5rem;
652
+ text-align: center;
653
+ }
654
+
655
+ .app-title {
656
+ color: #7C3AED;
657
+ font-size: 2.2rem;
658
+ font-weight: 700;
659
+ margin: 0;
660
+ margin-bottom: 0.5rem;
661
+ }
662
+
663
+ .app-subtitle {
664
+ color: #999;
665
+ font-size: 0.95rem;
666
+ margin: 0;
667
+ }
668
+
669
+ /* Main form card */
670
+ .form-card {
671
+ background: white;
672
+ border-radius: 16px;
673
+ box-shadow: 0 4px 24px rgba(124, 58, 237, 0.1);
674
+ padding: 2rem;
675
+ }
676
+
677
+ /* Question section header */
678
+ .question-header {
679
+ display: flex;
680
+ justify-content: space-between;
681
+ align-items: center;
682
+ margin-bottom: 1rem;
683
+ padding-bottom: 1rem;
684
+ border-bottom: 2px solid #F0E7FF;
685
+ }
686
+
687
+ .header-label {
688
+ color: #7C3AED;
689
+ font-weight: 600;
690
+ font-size: 0.95rem;
691
+ }
692
+
693
+ .progress-label {
694
+ color: #7C3AED;
695
+ font-weight: 600;
696
+ font-size: 0.95rem;
697
+ text-align: right;
698
+ }
699
+
700
+ /* Progress bar */
701
+ .progress-bar-container {
702
+ background: #EDE9FE;
703
+ border-radius: 4px;
704
+ height: 6px;
705
+ overflow: hidden;
706
+ margin-bottom: 1.5rem;
707
+ }
708
+
709
+ .progress-bar-fill {
710
+ height: 100%;
711
+ background: #7C3AED;
712
+ transition: width 0.3s ease;
713
+ }
714
+
715
+ /* Question text */
716
+ .question-text {
717
+ font-size: 1.1rem;
718
+ color: #1F2937;
719
+ margin-bottom: 0.8rem;
720
+ line-height: 1.6;
721
+ }
722
+
723
+ /* Hint text */
724
+ .hint-text {
725
+ color: #888;
726
+ font-size: 0.9rem;
727
+ margin-bottom: 1.5rem;
728
+ padding: 0.75rem 1rem;
729
+ background: #F9F7FF;
730
+ border-left: 3px solid #DDD6FE;
731
+ border-radius: 4px;
732
+ }
733
+
734
+ /* Input and buttons */
735
+ .input-group {
736
+ display: flex;
737
+ gap: 0.75rem;
738
+ margin-top: 1.5rem;
739
+ }
740
+
741
+ .input-box {
742
+ flex: 1;
743
+ }
744
+
745
+ .btn-new {
746
+ background: white !important;
747
+ border: 1px solid #DDD !important;
748
+ border-radius: 8px !important;
749
+ color: #555 !important;
750
+ font-weight: 500 !important;
751
+ cursor: pointer;
752
+ transition: all 0.2s;
753
+ }
754
+
755
+ .btn-new:hover {
756
+ border-color: #7C3AED !important;
757
+ color: #7C3AED !important;
758
+ }
759
+
760
+ .btn-submit {
761
+ background: #7C3AED !important;
762
+ border: 0 !important;
763
+ border-radius: 8px !important;
764
+ color: white !important;
765
+ font-weight: 600 !important;
766
+ cursor: pointer;
767
+ transition: all 0.2s;
768
+ }
769
+
770
+ .btn-submit:hover {
771
+ background: #6D28D9 !important;
772
+ box-shadow: 0 4px 12px rgba(124, 58, 237, 0.3);
773
+ }
774
+
775
+ /* Disclaimer */
776
+ .disclaimer {
777
+ text-align: center;
778
+ color: #999;
779
+ font-size: 0.85rem;
780
+ margin-top: 1rem;
781
+ }
782
+
783
+ /* Gradio textbox customization */
784
+ .textbox-input input {
785
+ border-radius: 8px !important;
786
+ border: 1px solid #DDD !important;
787
+ padding: 0.75rem 1rem !important;
788
+ font-size: 0.95rem !important;
789
+ }
790
+
791
+ .textbox-input input:focus {
792
+ border-color: #7C3AED !important;
793
+ box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.1) !important;
794
+ }
795
+ """
796
+
797
+ with gr.Blocks() as demo:
798
+ # State management
799
+ chat_history = gr.State([])
800
+
801
+ # Header section
802
+ gr.HTML("""
803
+ <div class="header-card">
804
+ <h1 class="app-title">Medical Diagnosis AI</h1>
805
+ <p class="app-subtitle">Clinical intake assessment powered by machine learning</p>
806
+ </div>
807
+ """)
808
+
809
+ # Main form card
810
+ with gr.Column(elem_classes="form-card"):
811
+ # Question header with progress
812
+ with gr.Row(elem_classes="question-header"):
813
+ header_label = gr.HTML('<div class="header-label">Medical Intake Assistant<br>Question 1 of 16</div>')
814
+ progress_label = gr.HTML('<div class="progress-label">0 / 16 questions</div>')
815
+
816
+ # Progress bar
817
+ gr.HTML('<div class="progress-bar-container"><div class="progress-bar-fill" id="progress-fill" style="width:0%"></div></div>')
818
+
819
+ # Question display
820
+ question_display = gr.Markdown(
821
+ "**Hi! I'm your Medical Intake Assistant. Let's begin your health assessment.**\n\n**What is your age?**",
822
+ elem_classes="question-text"
823
+ )
824
+
825
+ # Hint text
826
+ hint_display = gr.Markdown(
827
+ "Age in years (example: 34). Normal range: 18-100.",
828
+ elem_classes="hint-text"
829
+ )
830
+
831
+ # Input row
832
+ with gr.Row():
833
+ msg_input = gr.Textbox(
834
+ placeholder="Enter your response...",
835
+ show_label=False,
836
+ lines=1,
837
+ elem_classes="textbox-input",
838
+ scale=8
839
+ )
840
+ new_btn = gr.Button("New", scale=1, elem_classes="btn-new")
841
+ submit_btn = gr.Button("β–Ά", scale=1, elem_classes="btn-submit")
842
+
843
+ # Hidden chatbot for state management
844
+ chatbot = gr.Chatbot(visible=False)
845
+
846
+ # Disclaimer
847
+ gr.HTML('<div class="disclaimer">⚠️ This is a demonstration tool. Always consult with healthcare professionals for medical decisions.</div>')
848
+
849
+ # Event handler for submission
850
+ def handle_submission(message, history):
851
+ """Process user input and return updated UI elements"""
852
+ if not message or not message.strip():
853
+ return (
854
+ history, # Updated history
855
+ "Please enter a response.", # Question display
856
+ "Try entering your answer.", # Hint
857
+ "", # Clear input
858
+ "0 / 16" # Progress
859
+ )
860
+
861
+ # Call the existing chat function
862
+ raw_response = chat_fn(message, history)
863
+ clean_response, data_state = _parse_response(raw_response)
864
+
865
+ # Update history
866
+ new_history = history + [
867
+ {"role": "user", "content": message},
868
+ {"role": "assistant", "content": clean_response}
869
+ ]
870
+
871
+ # Calculate progress
872
+ collected = sum(1 for v in data_state.values() if v is not None)
873
+ progress_text = f"{collected} / 16"
874
+
875
+ # Get hint for next question
876
+ hint_text = _get_question_hint(clean_response)
877
+
878
+ logger.info(f"πŸ“Š Progress: {collected}/16")
879
+
880
+ return (
881
+ new_history,
882
+ clean_response,
883
+ hint_text,
884
+ "", # Clear input for next message
885
+ progress_text
886
+ )
887
+
888
+ # Handle Enter key and submit button
889
+ submit_btn.click(
890
+ fn=handle_submission,
891
+ inputs=[msg_input, chatbot],
892
+ outputs=[chatbot, question_display, hint_display, msg_input, progress_label],
893
+ queue=True
894
+ )
895
+
896
+ msg_input.submit(
897
+ fn=handle_submission,
898
+ inputs=[msg_input, chatbot],
899
+ outputs=[chatbot, question_display, hint_display, msg_input, progress_label],
900
+ queue=True
901
+ )
902
+
903
+ # Reset button handler
904
+ def reset_conversation():
905
+ """Reset the conversation"""
906
+ reset_history = reset_session([])
907
+ return (
908
+ reset_history, # Reset history
909
+ "**Hi! I'm your Medical Intake Assistant. Let's begin your health assessment.**\n\n**What is your age?**",
910
+ "Age in years (example: 34). Normal range: 18-100.",
911
+ "",
912
+ "0 / 16"
913
+ )
914
+
915
+ new_btn.click(
916
+ fn=reset_conversation,
917
+ outputs=[chatbot, question_display, hint_display, msg_input, progress_label],
918
+ queue=False
919
+ )
920
+
921
+ if __name__ == "__main__":
922
+ logger.info("πŸš€ Starting Medical Predictor Chatbot...")
923
+ demo.launch()
app/memory.py ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ from typing import Dict, Any
3
+
4
+ from app.config import DEFAULT_MODEL_FEATURES, FEATURE_RANGES
5
+
6
+ logger = logging.getLogger(__name__)
7
+
8
+
9
+ def initialize_state() -> Dict[str, Any]:
10
+ """
11
+ Initialize conversation state with all 16 features set to None.
12
+
13
+ Returns:
14
+ Dictionary with all features initialized to None
15
+ """
16
+ state = {feature: None for feature in DEFAULT_MODEL_FEATURES}
17
+ logger.debug(f"βœ… State initialized with {len(state)} features")
18
+ return state
19
+
20
+
21
+ def update_state(state: dict, new_data: dict) -> dict:
22
+ """
23
+ Merge newly extracted values into memory.
24
+ Only updates non-null values (preserves existing data).
25
+ Validates values are within acceptable ranges.
26
+
27
+ Args:
28
+ state: Current state dictionary
29
+ new_data: Dictionary with newly extracted features
30
+
31
+ Returns:
32
+ Updated state dictionary
33
+ """
34
+ updated_count = 0
35
+ for key, value in new_data.items():
36
+ if value is not None:
37
+ # Validate value is in acceptable range
38
+ if key in FEATURE_RANGES:
39
+ min_val, max_val, expected_type = FEATURE_RANGES[key]
40
+ try:
41
+ converted = expected_type(value)
42
+ # Check range
43
+ if not (min_val <= converted <= max_val):
44
+ logger.debug(f" SKIPPED {key}={value} (out of range [{min_val}, {max_val}])")
45
+ continue
46
+ value = converted
47
+ except (ValueError, TypeError):
48
+ logger.debug(f" SKIPPED {key}={value} (invalid type)")
49
+ continue
50
+
51
+ old_value = state.get(key)
52
+ state[key] = value
53
+ if old_value != value:
54
+ logger.debug(f" Updated {key}: {old_value} β†’ {value}")
55
+ updated_count += 1
56
+
57
+ if updated_count > 0:
58
+ logger.debug(f"βœ… State updated: {updated_count} features changed")
59
+ return state
60
+
61
+
62
+ def get_missing_features(state: dict) -> list:
63
+ """
64
+ Return list of features that are still missing (None).
65
+
66
+ Args:
67
+ state: Current state dictionary
68
+
69
+ Returns:
70
+ List of feature names with None values
71
+ """
72
+ missing = [k for k, v in state.items() if v is None]
73
+ logger.debug(f"❓ Missing features: {len(missing)}/16 - {missing[:3]}{'...' if len(missing) > 3 else ''}")
74
+ return missing
75
+
76
+
77
+ def get_state_summary(state: dict) -> Dict[str, Any]:
78
+ """
79
+ Get a summary of current state.
80
+
81
+ Args:
82
+ state: Current state dictionary
83
+
84
+ Returns:
85
+ Summary with counts and status
86
+ """
87
+ total = len(state)
88
+ collected = sum(1 for v in state.values() if v is not None)
89
+ missing = total - collected
90
+
91
+ return {
92
+ "total_features": total,
93
+ "collected": collected,
94
+ "missing": missing,
95
+ "percentage": (collected / total * 100) if total > 0 else 0,
96
+ "state": state.copy()
97
+ }
app/schemas.py ADDED
@@ -0,0 +1,146 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Optional
2
+ from pydantic import BaseModel, field_validator
3
+
4
+
5
+ class MedicalFeatures(BaseModel):
6
+ """All 16 required medical features with validation"""
7
+
8
+ Age: Optional[float] = None
9
+ Glucose: Optional[float] = None
10
+ HbA1c: Optional[float] = None
11
+ BMI: Optional[float] = None
12
+ Cholesterol: Optional[float] = None
13
+ Triglycerides: Optional[float] = None
14
+ Blood_Pressure: Optional[float] = None
15
+ Physical_Activity: Optional[float] = None
16
+ Sleep_Hours: Optional[float] = None
17
+ Stress_Level: Optional[float] = None
18
+ Diet_Score: Optional[float] = None
19
+ Smoking: Optional[int] = None
20
+ Alcohol: Optional[int] = None
21
+ Family_History: Optional[int] = None
22
+ LengthOfStay: Optional[int] = None
23
+ Oxygen_Saturation: Optional[float] = None
24
+
25
+ @field_validator("Age")
26
+ @classmethod
27
+ def validate_age(cls, v):
28
+ if v is not None and not (0 <= v <= 150):
29
+ raise ValueError("Age must be between 0 and 150")
30
+ return v
31
+
32
+ @field_validator("Glucose")
33
+ @classmethod
34
+ def validate_glucose(cls, v):
35
+ if v is not None and not (70 <= v <= 400):
36
+ raise ValueError("Glucose must be between 70 and 400")
37
+ return v
38
+
39
+ @field_validator("HbA1c")
40
+ @classmethod
41
+ def validate_hba1c(cls, v):
42
+ if v is not None and not (3 <= v <= 15):
43
+ raise ValueError("HbA1c must be between 3 and 15")
44
+ return v
45
+
46
+ @field_validator("BMI")
47
+ @classmethod
48
+ def validate_bmi(cls, v):
49
+ if v is not None and not (10 <= v <= 60):
50
+ raise ValueError("BMI must be between 10 and 60")
51
+ return v
52
+
53
+ @field_validator("Cholesterol")
54
+ @classmethod
55
+ def validate_cholesterol(cls, v):
56
+ if v is not None and not (100 <= v <= 400):
57
+ raise ValueError("Cholesterol must be between 100 and 400")
58
+ return v
59
+
60
+ @field_validator("Triglycerides")
61
+ @classmethod
62
+ def validate_triglycerides(cls, v):
63
+ if v is not None and not (20 <= v <= 500):
64
+ raise ValueError("Triglycerides must be between 20 and 500")
65
+ return v
66
+
67
+ @field_validator("Blood_Pressure")
68
+ @classmethod
69
+ def validate_blood_pressure(cls, v):
70
+ if v is not None and not (60 <= v <= 200):
71
+ raise ValueError("Blood Pressure must be between 60 and 200")
72
+ return v
73
+
74
+ @field_validator("Physical_Activity")
75
+ @classmethod
76
+ def validate_physical_activity(cls, v):
77
+ if v is not None and not (0 <= v <= 24):
78
+ raise ValueError("Physical Activity must be between 0 and 24 hours/week")
79
+ return v
80
+
81
+ @field_validator("Sleep_Hours")
82
+ @classmethod
83
+ def validate_sleep_hours(cls, v):
84
+ if v is not None and not (0 <= v <= 24):
85
+ raise ValueError("Sleep Hours must be between 0 and 24")
86
+ return v
87
+
88
+ @field_validator("Stress_Level")
89
+ @classmethod
90
+ def validate_stress_level(cls, v):
91
+ if v is not None and not (1 <= v <= 10):
92
+ raise ValueError("Stress Level must be between 1 and 10")
93
+ return v
94
+
95
+ @field_validator("Diet_Score")
96
+ @classmethod
97
+ def validate_diet_score(cls, v):
98
+ if v is not None and not (1 <= v <= 10):
99
+ raise ValueError("Diet Score must be between 1 and 10")
100
+ return v
101
+
102
+ @field_validator("Smoking", "Alcohol", "Family_History")
103
+ @classmethod
104
+ def validate_binary(cls, v):
105
+ if v is not None and v not in (0, 1):
106
+ raise ValueError("Binary values must be 0 or 1")
107
+ return v
108
+
109
+ @field_validator("LengthOfStay")
110
+ @classmethod
111
+ def validate_length_of_stay(cls, v):
112
+ if v is not None and not (0 <= v <= 365):
113
+ raise ValueError("Length of Stay must be between 0 and 365 days")
114
+ return v
115
+
116
+ @field_validator("Oxygen_Saturation")
117
+ @classmethod
118
+ def validate_oxygen_saturation(cls, v):
119
+ if v is not None and not (80 <= v <= 100):
120
+ raise ValueError("Oxygen Saturation must be between 80 and 100%")
121
+ return v
122
+
123
+ class Config:
124
+ use_enum_values = True
125
+
126
+
127
+ class PredictionRequest(BaseModel):
128
+ """Request for prediction"""
129
+
130
+ features: MedicalFeatures
131
+
132
+
133
+ class PredictionResponse(BaseModel):
134
+ """Prediction response with confidence"""
135
+
136
+ prediction: int # 0 or 1 (disease class)
137
+ probability: float # 0.0 to 1.0
138
+ risk_level: str # "Low", "Medium", "High"
139
+ explanation: str
140
+
141
+
142
+ class ExtractionResponse(BaseModel):
143
+ """LLM extraction response"""
144
+
145
+ extracted_features: MedicalFeatures
146
+ confidence: float
app/services/__init__.py ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Services module for Medical Predictor Chatbot"""
2
+
3
+ from app.services.llm_extractor import extract_features_from_text
4
+ from app.services.feature_builder import prepare_feature_vector, is_ready_for_prediction
5
+ from app.services.predictor import get_predictor
6
+
7
+ __all__ = [
8
+ "extract_features_from_text",
9
+ "prepare_feature_vector",
10
+ "is_ready_for_prediction",
11
+ "get_predictor",
12
+ ]
app/services/feature_builder.py ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ from typing import List, Dict, Any
3
+ from app.config import FEATURE_RANGES, DEFAULT_MODEL_FEATURES
4
+
5
+ logger = logging.getLogger(__name__)
6
+
7
+
8
+ def validate_feature(name: str, value: Any) -> Any:
9
+ """
10
+ Validate a single feature value against its constraints.
11
+
12
+ Args:
13
+ name: Feature name
14
+ value: Value to validate
15
+
16
+ Returns:
17
+ Validated value or None if invalid
18
+ """
19
+ if value is None:
20
+ return None
21
+
22
+ if name not in FEATURE_RANGES:
23
+ return None
24
+
25
+ min_val, max_val, expected_type = FEATURE_RANGES[name]
26
+
27
+ # Convert to expected type
28
+ try:
29
+ converted = expected_type(value)
30
+ except (ValueError, TypeError):
31
+ logger.warning(f"Could not convert {name}={value} to {expected_type}")
32
+ return None
33
+
34
+ # Check range
35
+ if not (min_val <= converted <= max_val):
36
+ logger.warning(f"{name}={converted} is outside valid range [{min_val}, {max_val}]")
37
+ return None
38
+
39
+ return converted
40
+
41
+
42
+ def prepare_features_dict(state: Dict[str, Any]) -> Dict[str, Any]:
43
+ """
44
+ Validate and prepare features dictionary.
45
+
46
+ Args:
47
+ state: Dictionary of features from memory
48
+
49
+ Returns:
50
+ Dictionary with validated features
51
+ """
52
+ validated = {}
53
+
54
+ for feature in DEFAULT_MODEL_FEATURES:
55
+ value = state.get(feature)
56
+ validated[feature] = validate_feature(feature, value)
57
+
58
+ return validated
59
+
60
+
61
+ def prepare_feature_vector(state: Dict[str, Any]) -> List[float]:
62
+ """
63
+ Convert state dict to feature vector for ML model.
64
+
65
+ The vector must have features in the exact order expected by the model.
66
+ Features are ordered as in DEFAULT_MODEL_FEATURES.
67
+ Missing values are filled with 0.0 (neutral value).
68
+
69
+ Args:
70
+ state: Dictionary with feature values from memory
71
+
72
+ Returns:
73
+ List of 16 floats ready for ML model prediction
74
+ """
75
+ # Define feature order (MUST match training data order)
76
+ feature_order = DEFAULT_MODEL_FEATURES
77
+
78
+ vector = []
79
+
80
+ for feature_name in feature_order:
81
+ value = state.get(feature_name)
82
+
83
+ if value is not None:
84
+ try:
85
+ vector.append(float(value))
86
+ except (ValueError, TypeError):
87
+ logger.warning(f"Could not convert {feature_name}={value} to float, using 0.0")
88
+ vector.append(0.0)
89
+ else:
90
+ # Use 0.0 for missing values (neutral/default)
91
+ vector.append(0.0)
92
+
93
+ assert len(vector) == 16, f"Expected 16 features, got {len(vector)}"
94
+ return vector
95
+
96
+
97
+ def count_collected_features(state: Dict[str, Any]) -> int:
98
+ """
99
+ Count how many features have been collected (non-null).
100
+
101
+ Args:
102
+ state: Dictionary with feature values
103
+
104
+ Returns:
105
+ Count of non-null features
106
+ """
107
+ return sum(1 for v in state.values() if v is not None)
108
+
109
+
110
+ def is_ready_for_prediction(state: Dict[str, Any], min_features: int = 14) -> bool:
111
+ """
112
+ Check if enough features collected for reliable prediction.
113
+
114
+ Args:
115
+ state: Dictionary with feature values
116
+ min_features: Minimum features needed (default 14/16)
117
+
118
+ Returns:
119
+ True if ready for prediction
120
+ """
121
+ collected = count_collected_features(state)
122
+ return collected >= min_features
app/services/llm_extractor.py ADDED
@@ -0,0 +1,508 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import json
3
+ import logging
4
+ import re
5
+ from groq import Groq
6
+ from dotenv import load_dotenv
7
+
8
+ from app.config import GROQ_API_KEY, GROQ_MODEL, GROQ_TEMPERATURE, DEFAULT_MODEL_FEATURES
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+ # Load environment variables
13
+ load_dotenv()
14
+
15
+ # Initialize client (GLOBAL)
16
+ try:
17
+ client = Groq(api_key=GROQ_API_KEY)
18
+ logger.info("βœ… Groq client initialized")
19
+ except Exception as e:
20
+ logger.error(f"❌ Failed to initialize Groq client: {e}")
21
+ client = None
22
+
23
+
24
+ def _extract_with_regex(user_input: str, aggressive: bool = True) -> dict:
25
+ """
26
+ SMART regex-based extraction with flexible patterns.
27
+ Handles: verb tenses (is/was/being), connecting words (a/up to/hit),
28
+ corrections (put 0/actually/not X), and multiple features in one sentence.
29
+
30
+ Args:
31
+ user_input: User's text input
32
+
33
+ Returns:
34
+ Dictionary with extracted features
35
+ """
36
+ result = {feature: None for feature in DEFAULT_MODEL_FEATURES}
37
+ text = user_input.lower()
38
+
39
+ # Helper function to extract a number safely with ultra-flexible matching
40
+ def extract_number(text, keyword_pattern, value_type=int, min_val=None, max_val=None, allow_float=False):
41
+ """Extract number with MAXIMUM flexibility for medical terminology.
42
+ Handles: all verb tenses, multiple connectors, adverbs, typos, and word order variations."""
43
+
44
+ # Comprehensive verb tenses and action words
45
+ verbs = r'(?:is|was|were|being|am|are|equals|measure|measured|measures|rate|rated|rates|hit|reached|have|has|say|saying|said|indicate|indicates)'
46
+
47
+ # Connector words for maximum flexibility
48
+ connectors = r'(?:up\s+to|actually|exactly|around|approximately|about|of|in|as|like|nearly|roughly)\s*'
49
+
50
+ # Optional article before number
51
+ article = r'(?:a|an|the)?\s*'
52
+
53
+ # Modifier words that can appear between keyword and verb
54
+ modifiers = r'(?:\s+(?:level|score|quality|quantity|range|reading|measurement|value))?'
55
+
56
+ # All possible patterns, ordered by specificity
57
+ patterns = [
58
+ # Pattern 0: keyword + [level/score/etc] + verb + connectors + article + number (MOST FLEXIBLE!)
59
+ rf'{keyword_pattern}{modifiers}\s+(?:{verbs})\s+(?:{connectors})?{article}(\d{{1,3}}(?:\.\d{{1,2}})?)',
60
+
61
+ # Pattern 1: keyword + verb + connectors + article + number
62
+ rf'{keyword_pattern}\s+(?:{verbs})\s+(?:{connectors})?{article}(\d{{1,3}}(?:\.\d{{1,2}})?)',
63
+
64
+ # Pattern 2: keyword + connector words (without verb) + article + number
65
+ rf'{keyword_pattern}\s+(?:{connectors}){article}(\d{{1,3}}(?:\.\d{{1,2}})?)',
66
+
67
+ # Pattern 3: prefix + keyword + [level] + verb + connectors + article + number
68
+ rf'(?:my|his|her|the|your)\s+{keyword_pattern}{modifiers}\s+(?:{verbs})\s+(?:{connectors})?{article}(\d{{1,3}}(?:\.\d{{1,2}})?)',
69
+
70
+ # Pattern 4: keyword + direct number
71
+ rf'{keyword_pattern}\s+(\d{{1,3}}(?:\.\d{{1,2}})?)',
72
+
73
+ # Pattern 5: prefix + keyword + number
74
+ rf'(?:my|his|her|the|your)\s+{keyword_pattern}\s+{article}(\d{{1,3}}(?:\.\d{{1,2}})?)',
75
+
76
+ # Pattern 6: "let's say number" or "say number" pattern
77
+ rf'(?:let\'s\s+)?say\s+(?:an?\s+)?(\d{{1,3}}(?:\.\d{{1,2}})?)\s+(?:out\s+of\s+10|for\s+{keyword_pattern})?',
78
+ ]
79
+
80
+ for pattern in patterns:
81
+ try:
82
+ match = re.search(pattern, text)
83
+ if match:
84
+ val_str = match.group(1)
85
+ val = float(val_str) if (allow_float or '.' in val_str) else int(val_str)
86
+ if min_val is not None and max_val is not None:
87
+ if min_val <= val <= max_val:
88
+ return val
89
+ elif min_val is not None and val >= min_val:
90
+ return val
91
+ elif max_val is not None and val <= max_val:
92
+ return val
93
+ else:
94
+ return val if not (min_val or max_val) else None
95
+ except (ValueError, IndexError, AttributeError, TypeError):
96
+ continue
97
+ return None
98
+
99
+ try:
100
+ # ===== AGE =====
101
+ # Handle multiple formats: "i'm 45", "age 45", "45 years old", "45-year-old", "45 year old"
102
+ age_patterns = [
103
+ # "45 years old" or "45 year old"
104
+ r'(\d{1,3})\s+years?\s+old',
105
+ # "45-year-old" or "45year old"
106
+ r'(\d{1,3})\s*-?\s*years?\s*-?\s*old',
107
+ # Using the flexible extractor for other formats
108
+ r'(?:age|i\s+(?:am|\'m))\s+(\d{1,3})',
109
+ # "I'm 45" or "I am 45"
110
+ r'(?:i\'m|i\s+am)\s+(\d{1,3})',
111
+ ]
112
+ age_val = None
113
+ for age_pat in age_patterns:
114
+ try:
115
+ age_match = re.search(age_pat, text)
116
+ if age_match:
117
+ age_candidate = int(age_match.group(1))
118
+ if 18 <= age_candidate <= 100:
119
+ age_val = float(age_candidate)
120
+ break
121
+ except (IndexError, AttributeError, ValueError):
122
+ continue
123
+ result["Age"] = age_val
124
+
125
+ # ===== GLUCOSE =====
126
+ glucose_val = extract_number(text, r'(?:glucose|blood\s+sugar)', min_val=70, max_val=400)
127
+ result["Glucose"] = float(glucose_val) if glucose_val else None
128
+
129
+ # ===== HBA1C =====
130
+ # Handles: "HbA1c was exactly 5.1%", "HbA1c up to 6.5", "my HbA1c is 5%", "my hba1c level is 10%"
131
+ hba1c_patterns = [
132
+ # "my hba1c level is 10%" or "hba1c level is 6%"
133
+ r'(?:my\s+)?hba1c\s+(?:level)?\s+(?:is|was|being)?\s+(?:exactly|around|approximately)?\s+(?:up\s+to\s+)?(\d{1,2}(?:\.\d{1,2})?)',
134
+ # "HbA1c was exactly 5.1%"
135
+ r'hba1c\s+(?:is|was|being)?\s+(?:exactly|around|approximately)?\s+(?:up\s+to\s+)?(\d{1,2}(?:\.\d{1,2})?)',
136
+ # "my HbA1c is 5.1"
137
+ r'my\s+hba1c\s+(?:is|was)?\s+(?:exactly\s+)?(\d{1,2}(?:\.\d{1,2})?)',
138
+ # Direct "HbA1c 5.1"
139
+ r'hba1c\s+(\d{1,2}(?:\.\d{1,2})?)',
140
+ # Just "5.1%" or "6%" after context (when HbA1c is implied)
141
+ r'(?:hba1c\s+)?(?:level\s+)?(?:of\s+)?(\d{1,2}(?:\.\d{1,2})?)%',
142
+ ]
143
+ for hba_pat in hba1c_patterns:
144
+ try:
145
+ hba_match = re.search(hba_pat, text)
146
+ if hba_match:
147
+ hba = float(hba_match.group(1))
148
+ if 3 <= hba <= 15:
149
+ result["HbA1c"] = float(hba)
150
+ break
151
+ except (IndexError, AttributeError):
152
+ continue
153
+
154
+ # ===== BMI =====
155
+ bmi_patterns = [
156
+ r'bmi\s+(?:is\s+)?(\d{1,2})',
157
+ r'my\s+bmi\s+(?:is\s+)?(\d{1,2})',
158
+ r'body\s+mass\s+index\s+(?:is\s+)?(\d{1,2})',
159
+ ]
160
+ for bmi_pat in bmi_patterns:
161
+ bmi_match = re.search(bmi_pat, text)
162
+ if bmi_match:
163
+ bmi = int(bmi_match.group(1))
164
+ if 10 <= bmi <= 60:
165
+ result["BMI"] = float(bmi)
166
+ break
167
+
168
+ # ===== CHOLESTEROL =====
169
+ chol_val = extract_number(text, r'cholesterol', min_val=100, max_val=400)
170
+ result["Cholesterol"] = float(chol_val) if chol_val else None
171
+
172
+ # ===== TRIGLYCERIDES =====
173
+ trig_val = extract_number(text, r'triglycerides', min_val=20, max_val=500)
174
+ result["Triglycerides"] = float(trig_val) if trig_val else None
175
+
176
+ # ===== BLOOD PRESSURE =====
177
+ # Special handling for BP: "120/80" or just systolic "160"
178
+ bp_patterns = [
179
+ # "blood pressure usually measures around 115"
180
+ r'(?:blood\s+pressure|bp)\s+(?:usually\s+)?(?:measures|measured|is|was|being)?\s+(?:around\s+|about\s+|approximately\s+)?(\d{2,3})',
181
+ # "blood pressure is/was 140"
182
+ r'(?:blood\s+pressure|bp)\s+(?:is|was|hit|reached|being)?\s+(?:around\s+|up\s+to\s+)?(\d{2,3})',
183
+ # "my blood pressure 115"
184
+ r'my\s+(?:blood\s+pressure|bp)\s+(?:is|was)?\s+(?:around\s+)?(\d{2,3})',
185
+ # "BP 130"
186
+ r'bp\s+(\d{2,3})',
187
+ # Systolic/diastolic format
188
+ r'(?:blood\s+pressure|bp)\s+(?:is|was)?\s+(?:around\s+|about\s+)?(\d{2,3})\s*/\s*(\d{2,3})',
189
+ ]
190
+ for bp_pat in bp_patterns:
191
+ try:
192
+ bp_match = re.search(bp_pat, text)
193
+ if bp_match:
194
+ bp_val = int(bp_match.group(1))
195
+ if 60 <= bp_val <= 200:
196
+ result["Blood Pressure"] = float(bp_val)
197
+ break
198
+ except (IndexError, AttributeError):
199
+ continue
200
+
201
+ # ===== PHYSICAL ACTIVITY =====
202
+ # Handles: "exercise 5 hours", "play football for 4 hours", "walk to work 15 hours", "activity is 15", "walking 3 hours"
203
+ activity_patterns = [
204
+ r'(?:physical\s+)?activity\s+(?:is|was)?\s+(?:for\s+)?(?:about\s+)?(\d{1,2})\s*(?:hours?)?',
205
+ r'(?:exercise|activity|workout|sport|play|football|basketball|swimming|running|walk|biking)\s+(?:for\s+)?(?:about\s+|around\s+)?(\d{1,2})\s*(?:hours?)?',
206
+ r'(?:walk|exercise|activity|play)ing\s+(?:for\s+)?(?:about\s+)?(\d{1,2})\s*(?:hours?)?',
207
+ r'(\d{1,2})\s*(?:hours?)\s+(?:of\s+)?(?:exercise|activity|walking|playing|workout)',
208
+ ]
209
+ for act_pat in activity_patterns:
210
+ activity_match = re.search(act_pat, text)
211
+ if activity_match:
212
+ activity = int(activity_match.group(1))
213
+ if 0 <= activity <= 24:
214
+ result["Physical Activity"] = float(activity)
215
+ break
216
+
217
+ # ===== SLEEP HOURS =====
218
+ # Handles: "sleep 8 hours", "sleeping 7 hours", "sleep 7", "get 6 hours of sleep", "sleep for 5 hours"
219
+ sleep_patterns = [
220
+ # "sleep for 5 hours" or "sleep 5 hours"
221
+ r'(?:sleep|sleeping|get|getting)\s+(?:for\s+)?(?:about\s+|around\s+)?(\d{1,2})\s*(?:hours?)?',
222
+ # "5 hours of sleep" or "5 hours sleep"
223
+ r'(\d{1,2})\s*(?:hours?)\s+(?:of\s+)?sleep',
224
+ ]
225
+ for sleep_pat in sleep_patterns:
226
+ sleep_match = re.search(sleep_pat, text)
227
+ if sleep_match:
228
+ sleep = int(sleep_match.group(1))
229
+ if 0 <= sleep <= 24:
230
+ result["Sleep Hours"] = float(sleep)
231
+ break
232
+
233
+ # ===== STRESS LEVEL =====
234
+ # Handles: "stress 8", "stress level is 5", "I'd rate my stress level as a 3"
235
+ stress_patterns = [
236
+ # "I'd rate my stress level as a 3 out of 10"
237
+ r'(?:rate|rated)\s+(?:my\s+)?stress\s+(?:level)?\s+(?:as|like)\s+(?:a|an)?\s*(\d{1,2})',
238
+ # "stress level is/as a 5"
239
+ r'stress\s+(?:level)?\s+(?:is|was|as)?\s+(?:a|an)?\s*(\d{1,2})',
240
+ # "my stress level in 4" (typo handling - "in" for "is")
241
+ r'(?:my\s+)?stress\s+(?:level)?\s+(?:in|is)\s+(?:a|an)?\s*(\d{1,2})',
242
+ # Direct pattern
243
+ r'(?:my\s+)?stress\s+(?:level)?\s+(?:a|an)?\s*(\d{1,2})',
244
+ ]
245
+ for stress_pat in stress_patterns:
246
+ try:
247
+ stress_match = re.search(stress_pat, text)
248
+ if stress_match:
249
+ stress = int(stress_match.group(1))
250
+ if 1 <= stress <= 10:
251
+ result["Stress Level"] = float(stress)
252
+ break
253
+ except (IndexError, AttributeError):
254
+ continue
255
+
256
+ # ===== OXYGEN SATURATION =====
257
+ # Handles: "oxygen saturation 90%", "oxygen saturation level is 95%", "oxygen was 96%", "o2 96"
258
+ oxygen_patterns = [
259
+ # "oxygen saturation level is 95%" or "oxygen saturation is 95%"
260
+ r'(?:oxygen|o2|oβ‚‚)\s+(?:saturation\s+)?(?:level\s+)?(?:is|was)?\s+(?:around\s+)?(\d{1,3})(?:%)?',
261
+ # "oxygen saturation 90%"
262
+ r'(?:oxygen|o2|oβ‚‚)\s+saturation\s+(?:level\s+)?(\d{1,3})(?:%)?',
263
+ # "oxygen was 96%" or "o2 is 95"
264
+ r'(?:oxygen|o2|oβ‚‚)\s+(?:was|is|being|level)?\s+(\d{1,3})(?:%)?',
265
+ # "my oxygen was 90%"
266
+ r'my\s+oxygen\s+(?:was|is)?\s+(\d{1,3})(?:%)?',
267
+ # "SpO2 95" or "spo2 is 92%"
268
+ r'(?:spo2|spo\s*2)\s+(?:is\s+)?(\d{1,3})(?:%)?',
269
+ # Direct "o2 97" or "oxygen 95" (needs to be number at end)
270
+ r'(?:oxygen|o2|oβ‚‚)\s+(\d{1,3})$',
271
+ ]
272
+ for o2_pat in oxygen_patterns:
273
+ o2_match = re.search(o2_pat, text)
274
+ if o2_match:
275
+ o2 = int(o2_match.group(1))
276
+ if 80 <= o2 <= 100:
277
+ result["Oxygen Saturation"] = float(o2)
278
+ break
279
+
280
+ # ===== LENGTH OF STAY =====
281
+ # Handles: "hospitalized for 2 days", "2 day hospital stay", "stay 4 days", "length of stay is 10", "i was in hospital for 5 days"
282
+ stay_patterns = [
283
+ # "length of stay is 10"
284
+ r'length\s+of\s+stay\s+(?:is\s+)?(\d{1,3})',
285
+ # "my length of stay is 10"
286
+ r'my\s+length\s+of\s+stay\s+(?:is\s+)?(\d{1,3})',
287
+ # "hospitalized for 4 days" or "hospitali... for 2 days"
288
+ r'(?:hospitali[z]?e?d?|in\s+hospital)\s+(?:for\s+)?(\d{1,3})\s*(?:days?)?',
289
+ # "stay for 5 days" or "hospital stay 4 days"
290
+ r'(?:hospital\s+)?stay\s+(?:for\s+)?(\d{1,3})\s*(?:days?)?',
291
+ # "for/in 2 days hospital/stay"
292
+ r'(?:for|in)\s+(\d{1,3})\s*(?:days?)\s+(?:hospital|stay)',
293
+ # "2 day hospital stay" or "5 days in hospital"
294
+ r'(\d{1,3})\s*(?:days?)\s+(?:hospital|stay|in\s+hospital)',
295
+ ]
296
+ if 'hospital' in text or 'stay' in text or 'length' in text:
297
+ for stay_pat in stay_patterns:
298
+ try:
299
+ stay_match = re.search(stay_pat, text)
300
+ if stay_match:
301
+ stay = int(stay_match.group(1))
302
+ if 0 <= stay <= 365:
303
+ result["LengthOfStay"] = float(stay)
304
+ break
305
+ except (AttributeError, IndexError):
306
+ continue
307
+
308
+ # ===== SMOKING =====
309
+ # Explicit corrections: "put 0 for smoking", "i stopped smoking", "put 0 for that"
310
+ if re.search(r'(?:put|mark|set)\s+0\s+(?:for\s+)?(?:smoking|smok)', text):
311
+ result["Smoking"] = 0
312
+ elif re.search(r'(?:stopped|quit|don\'t)\s+(?:smok|smoke)', text):
313
+ result["Smoking"] = 0
314
+ elif re.search(r'non[- ]smok|nonsmoker|don\'t\s+smok|no\s+smok', text):
315
+ result["Smoking"] = 0
316
+ elif re.search(r'(?:smok|smoke|smoking|smoker)(?!\s+before)', text):
317
+ result["Smoking"] = 1
318
+
319
+ # ===== ALCOHOL =====
320
+ # Explicit: "put 0 for alcohol", "don't drink", "wine daily" (means drinking)
321
+ if re.search(r'(?:put|mark|set)\s+0\s+(?:for\s+)?(?:alcohol|drink)', text):
322
+ result["Alcohol"] = 0
323
+ elif re.search(r'(?:don\'t|no|don\'?t)\s+(?:drink|alcohol)', text):
324
+ result["Alcohol"] = 0
325
+ elif re.search(r'(?:wine|beer|alcohol|drink|drinking|glass|daily)', text):
326
+ result["Alcohol"] = 1
327
+
328
+ # ===== FAMILY HISTORY =====
329
+ if re.search(r'(?:no|don\'t|don\'?t)\s+(?:family\s+)?(?:history|disease|condition)', text):
330
+ result["Family History"] = 0
331
+ elif re.search(r'(?:family\s+)?(?:history|disease|condition|yes)', text):
332
+ result["Family History"] = 1
333
+
334
+ # ===== DIET SCORE =====
335
+ # Handles: "diet score is 8", "diet quality is 5", "eat healthy so say 8"
336
+ diet_patterns = [
337
+ # "diet score is/quality is 8"
338
+ r'diet\s+(?:score|quality|nutritional?\s+value)\s+(?:is|was)?\s+(?:an?\s+)?(\d{1,2})',
339
+ # "nutrition score 8"
340
+ r'nutrition\s+(?:score|quality)?\s+(?:is|was)?\s+(?:an?\s+)?(\d{1,2})',
341
+ # "say an 8" pattern for diet (contextual)
342
+ r'(?:diet|nutrition|healthy)\s+[^.]*\bsay\s+(?:an?\s+)?(\d{1,2})(?:\s+out\s+of\s+10)?',
343
+ # "let's say an 8"
344
+ r'let\'s\s+say\s+(?:an?\s+)?(\d{1,2})\s+(?:for\s+diet|for\s+nutrition|out\s+of\s+10)',
345
+ # Direct "diet 8"
346
+ r'diet\s+(\d{1,2})',
347
+ ]
348
+ for diet_pat in diet_patterns:
349
+ try:
350
+ diet_match = re.search(diet_pat, text)
351
+ if diet_match:
352
+ diet = int(diet_match.group(1))
353
+ if 1 <= diet <= 10:
354
+ result["Diet Score"] = float(diet)
355
+ break
356
+ except (IndexError, AttributeError):
357
+ continue
358
+
359
+ extracted_keys = {k: v for k, v in result.items() if v is not None}
360
+ logger.debug(f"βœ… Regex extraction found: {list(extracted_keys.keys())}")
361
+ logger.info(f"πŸ” Regex result: {extracted_keys if extracted_keys else 'EMPTY'}")
362
+ return result
363
+
364
+ except Exception as e:
365
+ logger.error(f"Regex extraction error: {e}")
366
+ return {feature: None for feature in DEFAULT_MODEL_FEATURES}
367
+
368
+
369
+ def extract_features_from_text(user_input: str) -> dict:
370
+ """
371
+ Uses SMART extraction: Regex first (fast), then LLM (comprehensive), with smart fallbacks.
372
+ Returns dictionary with all required keys (values are null if not extracted).
373
+
374
+ Args:
375
+ user_input: User's free-form text input
376
+
377
+ Returns:
378
+ Dictionary with all 16 features (values null if not found)
379
+ """
380
+
381
+ if not user_input or not user_input.strip():
382
+ logger.warning("Empty user input received")
383
+ return {feature: None for feature in DEFAULT_MODEL_FEATURES}
384
+
385
+ # STEP 1: TRY REGEX FIRST (fast, reliable for common patterns)
386
+ regex_result = _extract_with_regex(user_input, aggressive=True)
387
+ extracted_keys = {k: v for k, v in regex_result.items() if v is not None}
388
+ logger.info(f"πŸ” Regex result: {extracted_keys if extracted_keys else 'EMPTY'}")
389
+
390
+ if any(v is not None for v in regex_result.values()):
391
+ logger.info(f"βœ… Regex extraction succeeded: {list(extracted_keys.keys())}")
392
+ return regex_result
393
+
394
+ # STEP 2: If regex didn't work, try LLM (only if client available)
395
+ if client is None:
396
+ logger.warning("⚠️ Groq client not initialized - returning regex results (empty)")
397
+ return regex_result
398
+
399
+ prompt = f"""
400
+ You are a medical information extraction system. Extract health data from ANY mention in the text.
401
+
402
+ Features with VALID RANGES:
403
+ - Age (18-100): "I am 30", "30 years old", "age 45" β†’ extract the number
404
+ - Glucose (70-400): any number mentioned with glucose/blood sugar
405
+ - HbA1c (3-15): any number with HbA1c/hemoglobin
406
+ - BMI (10-60): any number with BMI/body mass
407
+ - Cholesterol (100-400): any number with cholesterol
408
+ - Triglycerides (20-500): any number with triglycerides
409
+ - Blood Pressure (60-200): any number with BP/blood pressure
410
+ - Physical Activity (0-24): any number with exercise/activity/workout hours
411
+ - Sleep Hours (0-24): any number with sleep/hours slept
412
+ - Stress Level (1-10): any number (1-10) with stress
413
+ - Diet Score (1-10): any number (1-10) with diet/nutrition
414
+ - Smoking (0 or 1): "smoke/smoking/smoker" β†’ 1, "don't/no smoking/non-smoker" β†’ 0, "sometimes/occasionally" β†’ 1
415
+ - Alcohol (0 or 1): "drink/alcohol/drinking" β†’ 1, "don't drink/no alcohol" β†’ 0, "sometimes" β†’ 1
416
+ - Family History (0 or 1): "family history/disease history" β†’ 1, "no family history" β†’ 0
417
+ - LengthOfStay (0-365): any number with hospital/stay/days
418
+ - Oxygen Saturation (80-100): any number with oxygen/O2/saturation
419
+
420
+ EXTRACTION RULES:
421
+ 1. Return ONLY valid JSON. No markdown, no text, no explanation
422
+ 2. Use ALL 16 keys exactly as shown
423
+ 3. Extract ANY number from text (age, measurements, etc)
424
+ 4. For binary features (Smoking/Alcohol/Family History): ALWAYS return 0 or 1, never null if mentioned
425
+ 5. For "sometimes/occasionally/rarely" with Smoking/Alcohol β†’ use 1 (yes, they do it)
426
+ 6. If value outside range, use null
427
+ 7. If feature not mentioned at all, use null
428
+
429
+ Examples:
430
+ "I'm 30 years old and I smoke sometimes" β†’ {{"Age": 30, "Smoking": 1, ...other null...}}
431
+ "my name is John, I don't drink" β†’ {{"Alcohol": 0, ...other null...}}
432
+ "45 with diabetes" β†’ {{"Age": 45, ...}}
433
+ "I exercise 5 hours" β†’ {{"Physical Activity": 5, ...}}
434
+
435
+ User Input: "{user_input}"
436
+
437
+ Return ONLY JSON with 16 keys:"""
438
+
439
+ try:
440
+ # STEP 2A: Call Groq API with timeout and error handling
441
+ response = client.chat.completions.create(
442
+ model=GROQ_MODEL,
443
+ messages=[
444
+ {"role": "system", "content": "You are a strict JSON generator. Always return valid JSON."},
445
+ {"role": "user", "content": prompt}
446
+ ],
447
+ temperature=GROQ_TEMPERATURE,
448
+ max_tokens=1000,
449
+ timeout=30
450
+ )
451
+
452
+ content = response.choices[0].message.content.strip()
453
+
454
+ # Try to parse JSON
455
+ try:
456
+ data = json.loads(content)
457
+ logger.debug(f"βœ… Successfully extracted via LLM: {[k for k,v in data.items() if v]}")
458
+ except json.JSONDecodeError:
459
+ # Try removing markdown code blocks
460
+ logger.debug(f"First parse failed, trying fallback parsing")
461
+ if "```" in content:
462
+ content = content.split("```")[1]
463
+ if content.startswith("json"):
464
+ content = content[4:].strip()
465
+
466
+ try:
467
+ data = json.loads(content)
468
+ logger.debug(f"βœ… Fallback JSON parse succeeded")
469
+ except json.JSONDecodeError as e:
470
+ logger.error(f"❌ Could not parse JSON: {e}")
471
+ logger.error(f" Content was: {content[:300]}")
472
+ logger.warning(f"⚠️ LLM JSON parsing failed, falling back to regex results")
473
+ data = {feature: None for feature in DEFAULT_MODEL_FEATURES}
474
+
475
+ # Ensure all features are present
476
+ result = {feature: None for feature in DEFAULT_MODEL_FEATURES}
477
+ result.update(data)
478
+
479
+ # STEP 2B: Use regex as enhancement to LLM (fill gaps)
480
+ regex_data = _extract_with_regex(user_input, aggressive=True)
481
+
482
+ # Merge: LLM data takes priority, regex fills gaps
483
+ llm_extracted = sum(1 for v in result.values() if v is not None)
484
+ for feature, value in regex_data.items():
485
+ if value is not None and result[feature] is None:
486
+ result[feature] = value
487
+
488
+ regex_filled = sum(1 for v in result.values() if v is not None) - llm_extracted
489
+ if regex_filled > 0:
490
+ logger.info(f"πŸ“ LLM + Regex merge: LLM found {llm_extracted}, Regex filled {regex_filled} gaps")
491
+
492
+ return result
493
+
494
+ except Exception as e:
495
+ error_msg = str(e)
496
+ if "organization_restricted" in error_msg.lower():
497
+ logger.error(f"❌ Groq API BLOCKED: Organization restricted. Check your API key and account status.")
498
+ elif "invalid_api_key" in error_msg.lower():
499
+ logger.error(f"❌ Groq API ERROR: Invalid or expired API key")
500
+ elif "rate_limit" in error_msg.lower():
501
+ logger.warning(f"⚠️ Groq API RATE LIMITED: Too many requests, using regex only")
502
+ else:
503
+ logger.error(f"❌ Groq API ERROR: {type(e).__name__}: {error_msg[:200]}")
504
+
505
+ # Fallback: Return regex results if LLM fails (don't return empty)
506
+ logger.info("⚠️ Falling back to regex-only extraction")
507
+ regex_fallback = _extract_with_regex(user_input, aggressive=True)
508
+ return regex_fallback
app/services/predictor.py ADDED
@@ -0,0 +1,146 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import joblib
2
+ import logging
3
+ from pathlib import Path
4
+ from typing import List
5
+
6
+ from app.config import MODEL_PATH, CLASS_NAMES, FEATURE_RANGES
7
+ from app.schemas import PredictionResponse
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ class Predictor:
13
+ """Load and use ML model for predictions"""
14
+
15
+ def __init__(self, model_path: str = None):
16
+ """
17
+ Initialize predictor with model path.
18
+
19
+ Args:
20
+ model_path: Path to model file (default: config.MODEL_PATH)
21
+ """
22
+ if model_path is None:
23
+ model_path = MODEL_PATH
24
+
25
+ self.model_path = Path(model_path)
26
+ self.model = None
27
+ self._load_model()
28
+
29
+ def _load_model(self):
30
+ """Load model from disk using joblib"""
31
+ if not self.model_path.exists():
32
+ raise FileNotFoundError(f"Model not found at {self.model_path}")
33
+
34
+ try:
35
+ self.model = joblib.load(self.model_path)
36
+ logger.info(f"βœ… Model loaded from {self.model_path}")
37
+ logger.info(f" Model type: {type(self.model).__name__}")
38
+ logger.info(f" Expected features: {self.model.n_features_in_}")
39
+ except Exception as e:
40
+ logger.error(f"❌ Failed to load model: {e}")
41
+ raise
42
+
43
+ def predict(self, feature_vector: List[float]) -> PredictionResponse:
44
+ """
45
+ Make prediction on feature vector.
46
+
47
+ Args:
48
+ feature_vector: List of 16 floats in correct order
49
+
50
+ Returns:
51
+ PredictionResponse with prediction, probability, risk level
52
+
53
+ Raises:
54
+ RuntimeError: If model not loaded
55
+ ValueError: If feature vector wrong size
56
+ """
57
+ if self.model is None:
58
+ raise RuntimeError("Model not loaded")
59
+
60
+ if len(feature_vector) != 16:
61
+ raise ValueError(f"Expected 16 features, got {len(feature_vector)}")
62
+
63
+ try:
64
+ # Make prediction on single sample
65
+ # Note: sklearn expects 2D array [n_samples, n_features]
66
+ prediction_class = int(self.model.predict([feature_vector])[0])
67
+
68
+ # Get probability/confidence array for all classes
69
+ proba = self.model.predict_proba([feature_vector])[0]
70
+ probability = float(max(proba))
71
+
72
+ # SMART LOGIC: If top prediction is "Other/Unknown" (class 7), use second-best
73
+ OTHER_UNKNOWN_CLASS = 7
74
+ if prediction_class == OTHER_UNKNOWN_CLASS:
75
+ # Find second-highest confidence
76
+ top_two_indices = (-proba).argsort()[:2] # Get top 2 class indices
77
+ prediction_class = int(top_two_indices[1]) # Use second-best class
78
+ probability = float(proba[prediction_class]) # Get its confidence
79
+ logger.info(f"⚠️ Top prediction was Other/Unknown, using second-best: {CLASS_NAMES[prediction_class]}")
80
+
81
+ # Map class number to class name
82
+ class_name = CLASS_NAMES[prediction_class] if prediction_class < len(CLASS_NAMES) else "Unknown"
83
+
84
+ # Determine risk level based on probability
85
+ if probability >= 0.8:
86
+ risk_level = "High"
87
+ elif probability >= 0.6:
88
+ risk_level = "Medium"
89
+ else:
90
+ risk_level = "Low"
91
+
92
+ # Create explanation based on the predicted condition
93
+ if class_name == "Healthy":
94
+ explanation = f"Model predicts HEALTHY status with {probability*100:.1f}% confidence. Keep up healthy lifestyle!"
95
+ elif class_name == "Other/Unknown":
96
+ explanation = f"Model unable to classify clearly with {probability*100:.1f}% confidence. Consult healthcare provider."
97
+ else:
98
+ explanation = f"Model predicts {class_name} with {probability*100:.1f}% confidence. Please consult a healthcare professional."
99
+
100
+ result = PredictionResponse(
101
+ prediction=prediction_class,
102
+ probability=probability,
103
+ risk_level=risk_level,
104
+ explanation=explanation
105
+ )
106
+
107
+ logger.info(f"βœ… Prediction: {class_name} (class {prediction_class}, confidence: {probability*100:.1f}%)")
108
+ return result
109
+
110
+ except Exception as e:
111
+ logger.error(f"❌ Prediction failed: {e}")
112
+ raise
113
+
114
+
115
+ # Global predictor instance (loaded once)
116
+ _predictor_instance = None
117
+
118
+
119
+ def get_predictor() -> Predictor:
120
+ """
121
+ Get or create global predictor instance (lazy loading).
122
+
123
+ Returns:
124
+ Predictor instance
125
+
126
+ Raises:
127
+ FileNotFoundError: If model file not found
128
+ """
129
+ global _predictor_instance
130
+
131
+ if _predictor_instance is None:
132
+ _predictor_instance = Predictor()
133
+
134
+ return _predictor_instance
135
+
136
+
137
+ def reload_predictor() -> Predictor:
138
+ """
139
+ Force reload of predictor (useful for testing).
140
+
141
+ Returns:
142
+ New Predictor instance
143
+ """
144
+ global _predictor_instance
145
+ _predictor_instance = Predictor()
146
+ return _predictor_instance
app/services/session_manager.py ADDED
@@ -0,0 +1,178 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Session Management for Medical Predictor Chatbot
3
+ Handles: session creation, persistence, cleanup, metadata tracking
4
+ """
5
+
6
+ import json
7
+ import logging
8
+ from pathlib import Path
9
+ from datetime import datetime
10
+ from typing import Dict, Optional, Any
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ # Session storage locations
15
+ SESSION_DIR = Path("/tmp/deepsense_sessions")
16
+ METADATA_FILE = SESSION_DIR / "session_metadata.json"
17
+
18
+ # Ensure directories exist
19
+ SESSION_DIR.mkdir(exist_ok=True)
20
+
21
+
22
+ class SessionManager:
23
+ """Manages session lifecycle, persistence, and metadata tracking"""
24
+
25
+ def __init__(self, session_dir: Path = SESSION_DIR):
26
+ self.session_dir = session_dir
27
+ self.metadata_file = METADATA_FILE
28
+ self._load_all_metadata()
29
+
30
+ def _load_all_metadata(self):
31
+ """Load all session metadata from file"""
32
+ try:
33
+ if self.metadata_file.exists():
34
+ with open(self.metadata_file, 'r') as f:
35
+ self.all_metadata = json.load(f)
36
+ else:
37
+ self.all_metadata = {}
38
+ except Exception as e:
39
+ logger.warning(f"⚠️ Could not load session metadata: {e}")
40
+ self.all_metadata = {}
41
+
42
+ def _save_all_metadata(self):
43
+ """Persist all session metadata to file"""
44
+ try:
45
+ with open(self.metadata_file, 'w') as f:
46
+ json.dump(self.all_metadata, f, indent=2)
47
+ except Exception as e:
48
+ logger.error(f"❌ Error saving session metadata: {e}")
49
+
50
+ def create_session(self, session_id: str, user_name: str = "User") -> Dict[str, Any]:
51
+ """
52
+ Create a new session with metadata
53
+
54
+ Args:
55
+ session_id: Unique session identifier
56
+ user_name: User name (extracted from greeting)
57
+
58
+ Returns:
59
+ Session metadata dict
60
+ """
61
+ metadata = {
62
+ "session_id": session_id,
63
+ "user_name": user_name,
64
+ "created_at": datetime.now().isoformat(),
65
+ "last_accessed": datetime.now().isoformat(),
66
+ "features_collected": 0,
67
+ "status": "active",
68
+ "reset_count": 0
69
+ }
70
+
71
+ self.all_metadata[session_id] = metadata
72
+ self._save_all_metadata()
73
+
74
+ logger.info(f"βœ… Created session: {session_id} for {user_name}")
75
+ return metadata
76
+
77
+ def get_session_metadata(self, session_id: str) -> Optional[Dict[str, Any]]:
78
+ """Get metadata for a specific session"""
79
+ return self.all_metadata.get(session_id)
80
+
81
+ def update_session(self, session_id: str, features_collected: int):
82
+ """Update session metadata with current stats"""
83
+ if session_id in self.all_metadata:
84
+ self.all_metadata[session_id]["last_accessed"] = datetime.now().isoformat()
85
+ self.all_metadata[session_id]["features_collected"] = features_collected
86
+ self._save_all_metadata()
87
+
88
+ def reset_session(self, session_id: str) -> bool:
89
+ """
90
+ Reset a session: delete session file and update metadata
91
+
92
+ Args:
93
+ session_id: Session to reset
94
+
95
+ Returns:
96
+ True if successful, False otherwise
97
+ """
98
+ try:
99
+ # Delete session state file
100
+ session_file = self.session_dir / f"{session_id}.json"
101
+ if session_file.exists():
102
+ session_file.unlink()
103
+ logger.info(f"πŸ—‘οΈ Deleted session file: {session_file}")
104
+
105
+ # Update metadata
106
+ if session_id in self.all_metadata:
107
+ self.all_metadata[session_id]["last_accessed"] = datetime.now().isoformat()
108
+ self.all_metadata[session_id]["features_collected"] = 0
109
+ self.all_metadata[session_id]["reset_count"] += 1
110
+ self.all_metadata[session_id]["status"] = "reset"
111
+ self._save_all_metadata()
112
+
113
+ reset_count = self.all_metadata[session_id]["reset_count"]
114
+ logger.info(f"πŸ”„ Session {session_id} reset (reset count: {reset_count})")
115
+
116
+ return True
117
+ except Exception as e:
118
+ logger.error(f"❌ Error resetting session {session_id}: {e}")
119
+ return False
120
+
121
+ def cleanup_old_sessions(self, max_age_hours: int = 24):
122
+ """
123
+ Clean up session files older than max_age_hours
124
+
125
+ Args:
126
+ max_age_hours: Sessions older than this are deleted
127
+ """
128
+ try:
129
+ import time
130
+ current_time = time.time()
131
+ deleted_count = 0
132
+
133
+ for session_id, metadata in list(self.all_metadata.items()):
134
+ session_file = self.session_dir / f"{session_id}.json"
135
+ if session_file.exists():
136
+ file_age_hours = (current_time - session_file.stat().st_mtime) / 3600
137
+
138
+ if file_age_hours > max_age_hours:
139
+ session_file.unlink()
140
+ logger.info(f"πŸ—‘οΈ Cleaned up old session: {session_id}")
141
+ deleted_count += 1
142
+
143
+ if deleted_count > 0:
144
+ logger.info(f"🧹 Cleaned up {deleted_count} old session(s)")
145
+
146
+ except Exception as e:
147
+ logger.warning(f"⚠️ Error during session cleanup: {e}")
148
+
149
+ def get_all_active_sessions(self) -> Dict[str, Dict[str, Any]]:
150
+ """Get all active sessions"""
151
+ return {sid: meta for sid, meta in self.all_metadata.items()
152
+ if meta.get("status") == "active"}
153
+
154
+ def list_sessions(self) -> str:
155
+ """Get formatted list of all sessions"""
156
+ if not self.all_metadata:
157
+ return "No active sessions"
158
+
159
+ lines = ["Active Sessions:"]
160
+ for sid, meta in self.all_metadata.items():
161
+ lines.append(
162
+ f" β€’ {meta['session_id']}: {meta['user_name']} "
163
+ f"({meta['features_collected']}/16 features, "
164
+ f"reset {meta['reset_count']} times)"
165
+ )
166
+ return "\n".join(lines)
167
+
168
+
169
+ # Global session manager instance
170
+ _session_manager: Optional[SessionManager] = None
171
+
172
+
173
+ def get_session_manager() -> SessionManager:
174
+ """Get or create the global session manager"""
175
+ global _session_manager
176
+ if _session_manager is None:
177
+ _session_manager = SessionManager()
178
+ return _session_manager
app/utils/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ """Utilities module for Medical Predictor Chatbot"""
2
+
3
+ from app.utils.helpers import generate_question
4
+
5
+ __all__ = ["generate_question"]
app/utils/helpers.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ def prioritize_features(missing_features: list) -> list:
2
+ """
3
+ Prioritize missing features for asking in natural order.
4
+
5
+ Priority: Critical Basics β†’ Key Metrics β†’ Lifestyle β†’ Wellness β†’ Medical
6
+ """
7
+ priority_order = [
8
+ "Age", "Blood Pressure", "Glucose", # Critical basics
9
+ "BMI", "Cholesterol", "HbA1c", # Key metrics
10
+ "Smoking", "Alcohol", "Physical Activity", # Lifestyle
11
+ "Sleep Hours", "Stress Level", "Diet Score", # Wellness
12
+ "Triglycerides", "Family History", "Oxygen Saturation", "LengthOfStay" # Medical
13
+ ]
14
+
15
+ # Sort missing features by priority
16
+ sorted_features = sorted(missing_features,
17
+ key=lambda x: priority_order.index(x) if x in priority_order else 999)
18
+ return sorted_features
19
+
20
+
21
+ def generate_question(missing_features: list) -> str:
22
+ """
23
+ Generate warm, empathetic questions for ONE missing feature at a time.
24
+ This keeps the user focused on one health metric at a time, improving UX.
25
+
26
+ Args:
27
+ missing_features: List of missing feature names (typically just 1)
28
+
29
+ Returns:
30
+ Formatted question string asking for the first missing item
31
+ """
32
+ questions = {
33
+ "Age": "Could you tell me your age?",
34
+ "Glucose": "What's your typical glucose level (in mg/dL)?",
35
+ "Smoking": "Do you smoke, or have you smoked in the past? (yes/no)",
36
+ "Family History": "Is there a family history of disease or health conditions? (yes/no)",
37
+ "HbA1c": "What's your HbA1c level (%)? (This measures average blood sugar over 3 months)",
38
+ "Diet Score": "How would you rate your overall diet quality on a scale of 1-10?",
39
+ "Alcohol": "Do you consume alcohol regularly? (yes/no)",
40
+ "Physical Activity": "How many hours per week do you typically exercise or stay physically active?",
41
+ "Blood Pressure": "What's your blood pressure reading? (e.g., 120/80)",
42
+ "BMI": "What's your BMI (Body Mass Index)?",
43
+ "Cholesterol": "What's your cholesterol level (in mg/dL)?",
44
+ "Sleep Hours": "How many hours of sleep do you typically get per night?",
45
+ "Stress Level": "How would you rate your current stress level on a scale of 1-10?",
46
+ "Triglycerides": "What's your triglycerides level (in mg/dL)?",
47
+ "Oxygen Saturation": "What's your oxygen saturation level (%)? (Normal is 95-100%)",
48
+ "LengthOfStay": "How many days were you hospitalized, if applicable?"
49
+ }
50
+
51
+ if not missing_features:
52
+ return ""
53
+
54
+ # Ask for only 1 item at a time to keep user focused
55
+ feature = missing_features[0]
56
+ return f"**{feature}:** {questions.get(feature, f'Please provide {feature}')}"
frontend/index.html ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/svg+xml" href="/vite.svg" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>Medical Diagnosis AI</title>
8
+ </head>
9
+ <body>
10
+ <div id="root"></div>
11
+ <script type="module" src="/src/main.jsx"></script>
12
+ </body>
13
+ </html>
frontend/package-lock.json ADDED
@@ -0,0 +1,2720 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "medical-predictor-frontend",
3
+ "version": "0.0.1",
4
+ "lockfileVersion": 3,
5
+ "requires": true,
6
+ "packages": {
7
+ "": {
8
+ "name": "medical-predictor-frontend",
9
+ "version": "0.0.1",
10
+ "dependencies": {
11
+ "axios": "^1.6.0",
12
+ "lucide-react": "^0.294.0",
13
+ "react": "^18.2.0",
14
+ "react-dom": "^18.2.0"
15
+ },
16
+ "devDependencies": {
17
+ "@types/react": "^18.2.0",
18
+ "@types/react-dom": "^18.2.0",
19
+ "@vitejs/plugin-react": "^4.2.1",
20
+ "autoprefixer": "^10.4.16",
21
+ "postcss": "^8.4.31",
22
+ "tailwindcss": "^3.3.0",
23
+ "vite": "^5.0.0"
24
+ }
25
+ },
26
+ "node_modules/@alloc/quick-lru": {
27
+ "version": "5.2.0",
28
+ "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
29
+ "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
30
+ "dev": true,
31
+ "engines": {
32
+ "node": ">=10"
33
+ },
34
+ "funding": {
35
+ "url": "https://github.com/sponsors/sindresorhus"
36
+ }
37
+ },
38
+ "node_modules/@babel/code-frame": {
39
+ "version": "7.29.0",
40
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
41
+ "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
42
+ "dev": true,
43
+ "dependencies": {
44
+ "@babel/helper-validator-identifier": "^7.28.5",
45
+ "js-tokens": "^4.0.0",
46
+ "picocolors": "^1.1.1"
47
+ },
48
+ "engines": {
49
+ "node": ">=6.9.0"
50
+ }
51
+ },
52
+ "node_modules/@babel/compat-data": {
53
+ "version": "7.29.0",
54
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz",
55
+ "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==",
56
+ "dev": true,
57
+ "engines": {
58
+ "node": ">=6.9.0"
59
+ }
60
+ },
61
+ "node_modules/@babel/core": {
62
+ "version": "7.29.0",
63
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
64
+ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
65
+ "dev": true,
66
+ "dependencies": {
67
+ "@babel/code-frame": "^7.29.0",
68
+ "@babel/generator": "^7.29.0",
69
+ "@babel/helper-compilation-targets": "^7.28.6",
70
+ "@babel/helper-module-transforms": "^7.28.6",
71
+ "@babel/helpers": "^7.28.6",
72
+ "@babel/parser": "^7.29.0",
73
+ "@babel/template": "^7.28.6",
74
+ "@babel/traverse": "^7.29.0",
75
+ "@babel/types": "^7.29.0",
76
+ "@jridgewell/remapping": "^2.3.5",
77
+ "convert-source-map": "^2.0.0",
78
+ "debug": "^4.1.0",
79
+ "gensync": "^1.0.0-beta.2",
80
+ "json5": "^2.2.3",
81
+ "semver": "^6.3.1"
82
+ },
83
+ "engines": {
84
+ "node": ">=6.9.0"
85
+ },
86
+ "funding": {
87
+ "type": "opencollective",
88
+ "url": "https://opencollective.com/babel"
89
+ }
90
+ },
91
+ "node_modules/@babel/generator": {
92
+ "version": "7.29.1",
93
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
94
+ "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
95
+ "dev": true,
96
+ "dependencies": {
97
+ "@babel/parser": "^7.29.0",
98
+ "@babel/types": "^7.29.0",
99
+ "@jridgewell/gen-mapping": "^0.3.12",
100
+ "@jridgewell/trace-mapping": "^0.3.28",
101
+ "jsesc": "^3.0.2"
102
+ },
103
+ "engines": {
104
+ "node": ">=6.9.0"
105
+ }
106
+ },
107
+ "node_modules/@babel/helper-compilation-targets": {
108
+ "version": "7.28.6",
109
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
110
+ "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
111
+ "dev": true,
112
+ "dependencies": {
113
+ "@babel/compat-data": "^7.28.6",
114
+ "@babel/helper-validator-option": "^7.27.1",
115
+ "browserslist": "^4.24.0",
116
+ "lru-cache": "^5.1.1",
117
+ "semver": "^6.3.1"
118
+ },
119
+ "engines": {
120
+ "node": ">=6.9.0"
121
+ }
122
+ },
123
+ "node_modules/@babel/helper-globals": {
124
+ "version": "7.28.0",
125
+ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
126
+ "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
127
+ "dev": true,
128
+ "engines": {
129
+ "node": ">=6.9.0"
130
+ }
131
+ },
132
+ "node_modules/@babel/helper-module-imports": {
133
+ "version": "7.28.6",
134
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
135
+ "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
136
+ "dev": true,
137
+ "dependencies": {
138
+ "@babel/traverse": "^7.28.6",
139
+ "@babel/types": "^7.28.6"
140
+ },
141
+ "engines": {
142
+ "node": ">=6.9.0"
143
+ }
144
+ },
145
+ "node_modules/@babel/helper-module-transforms": {
146
+ "version": "7.28.6",
147
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
148
+ "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
149
+ "dev": true,
150
+ "dependencies": {
151
+ "@babel/helper-module-imports": "^7.28.6",
152
+ "@babel/helper-validator-identifier": "^7.28.5",
153
+ "@babel/traverse": "^7.28.6"
154
+ },
155
+ "engines": {
156
+ "node": ">=6.9.0"
157
+ },
158
+ "peerDependencies": {
159
+ "@babel/core": "^7.0.0"
160
+ }
161
+ },
162
+ "node_modules/@babel/helper-plugin-utils": {
163
+ "version": "7.28.6",
164
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz",
165
+ "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==",
166
+ "dev": true,
167
+ "engines": {
168
+ "node": ">=6.9.0"
169
+ }
170
+ },
171
+ "node_modules/@babel/helper-string-parser": {
172
+ "version": "7.27.1",
173
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
174
+ "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
175
+ "dev": true,
176
+ "engines": {
177
+ "node": ">=6.9.0"
178
+ }
179
+ },
180
+ "node_modules/@babel/helper-validator-identifier": {
181
+ "version": "7.28.5",
182
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
183
+ "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
184
+ "dev": true,
185
+ "engines": {
186
+ "node": ">=6.9.0"
187
+ }
188
+ },
189
+ "node_modules/@babel/helper-validator-option": {
190
+ "version": "7.27.1",
191
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
192
+ "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
193
+ "dev": true,
194
+ "engines": {
195
+ "node": ">=6.9.0"
196
+ }
197
+ },
198
+ "node_modules/@babel/helpers": {
199
+ "version": "7.29.2",
200
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz",
201
+ "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==",
202
+ "dev": true,
203
+ "dependencies": {
204
+ "@babel/template": "^7.28.6",
205
+ "@babel/types": "^7.29.0"
206
+ },
207
+ "engines": {
208
+ "node": ">=6.9.0"
209
+ }
210
+ },
211
+ "node_modules/@babel/parser": {
212
+ "version": "7.29.2",
213
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz",
214
+ "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==",
215
+ "dev": true,
216
+ "dependencies": {
217
+ "@babel/types": "^7.29.0"
218
+ },
219
+ "bin": {
220
+ "parser": "bin/babel-parser.js"
221
+ },
222
+ "engines": {
223
+ "node": ">=6.0.0"
224
+ }
225
+ },
226
+ "node_modules/@babel/plugin-transform-react-jsx-self": {
227
+ "version": "7.27.1",
228
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz",
229
+ "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==",
230
+ "dev": true,
231
+ "dependencies": {
232
+ "@babel/helper-plugin-utils": "^7.27.1"
233
+ },
234
+ "engines": {
235
+ "node": ">=6.9.0"
236
+ },
237
+ "peerDependencies": {
238
+ "@babel/core": "^7.0.0-0"
239
+ }
240
+ },
241
+ "node_modules/@babel/plugin-transform-react-jsx-source": {
242
+ "version": "7.27.1",
243
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz",
244
+ "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==",
245
+ "dev": true,
246
+ "dependencies": {
247
+ "@babel/helper-plugin-utils": "^7.27.1"
248
+ },
249
+ "engines": {
250
+ "node": ">=6.9.0"
251
+ },
252
+ "peerDependencies": {
253
+ "@babel/core": "^7.0.0-0"
254
+ }
255
+ },
256
+ "node_modules/@babel/template": {
257
+ "version": "7.28.6",
258
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
259
+ "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
260
+ "dev": true,
261
+ "dependencies": {
262
+ "@babel/code-frame": "^7.28.6",
263
+ "@babel/parser": "^7.28.6",
264
+ "@babel/types": "^7.28.6"
265
+ },
266
+ "engines": {
267
+ "node": ">=6.9.0"
268
+ }
269
+ },
270
+ "node_modules/@babel/traverse": {
271
+ "version": "7.29.0",
272
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
273
+ "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
274
+ "dev": true,
275
+ "dependencies": {
276
+ "@babel/code-frame": "^7.29.0",
277
+ "@babel/generator": "^7.29.0",
278
+ "@babel/helper-globals": "^7.28.0",
279
+ "@babel/parser": "^7.29.0",
280
+ "@babel/template": "^7.28.6",
281
+ "@babel/types": "^7.29.0",
282
+ "debug": "^4.3.1"
283
+ },
284
+ "engines": {
285
+ "node": ">=6.9.0"
286
+ }
287
+ },
288
+ "node_modules/@babel/types": {
289
+ "version": "7.29.0",
290
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
291
+ "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
292
+ "dev": true,
293
+ "dependencies": {
294
+ "@babel/helper-string-parser": "^7.27.1",
295
+ "@babel/helper-validator-identifier": "^7.28.5"
296
+ },
297
+ "engines": {
298
+ "node": ">=6.9.0"
299
+ }
300
+ },
301
+ "node_modules/@esbuild/aix-ppc64": {
302
+ "version": "0.21.5",
303
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
304
+ "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
305
+ "cpu": [
306
+ "ppc64"
307
+ ],
308
+ "dev": true,
309
+ "optional": true,
310
+ "os": [
311
+ "aix"
312
+ ],
313
+ "engines": {
314
+ "node": ">=12"
315
+ }
316
+ },
317
+ "node_modules/@esbuild/android-arm": {
318
+ "version": "0.21.5",
319
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
320
+ "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
321
+ "cpu": [
322
+ "arm"
323
+ ],
324
+ "dev": true,
325
+ "optional": true,
326
+ "os": [
327
+ "android"
328
+ ],
329
+ "engines": {
330
+ "node": ">=12"
331
+ }
332
+ },
333
+ "node_modules/@esbuild/android-arm64": {
334
+ "version": "0.21.5",
335
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
336
+ "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
337
+ "cpu": [
338
+ "arm64"
339
+ ],
340
+ "dev": true,
341
+ "optional": true,
342
+ "os": [
343
+ "android"
344
+ ],
345
+ "engines": {
346
+ "node": ">=12"
347
+ }
348
+ },
349
+ "node_modules/@esbuild/android-x64": {
350
+ "version": "0.21.5",
351
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
352
+ "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
353
+ "cpu": [
354
+ "x64"
355
+ ],
356
+ "dev": true,
357
+ "optional": true,
358
+ "os": [
359
+ "android"
360
+ ],
361
+ "engines": {
362
+ "node": ">=12"
363
+ }
364
+ },
365
+ "node_modules/@esbuild/darwin-arm64": {
366
+ "version": "0.21.5",
367
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
368
+ "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
369
+ "cpu": [
370
+ "arm64"
371
+ ],
372
+ "dev": true,
373
+ "optional": true,
374
+ "os": [
375
+ "darwin"
376
+ ],
377
+ "engines": {
378
+ "node": ">=12"
379
+ }
380
+ },
381
+ "node_modules/@esbuild/darwin-x64": {
382
+ "version": "0.21.5",
383
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
384
+ "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
385
+ "cpu": [
386
+ "x64"
387
+ ],
388
+ "dev": true,
389
+ "optional": true,
390
+ "os": [
391
+ "darwin"
392
+ ],
393
+ "engines": {
394
+ "node": ">=12"
395
+ }
396
+ },
397
+ "node_modules/@esbuild/freebsd-arm64": {
398
+ "version": "0.21.5",
399
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
400
+ "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
401
+ "cpu": [
402
+ "arm64"
403
+ ],
404
+ "dev": true,
405
+ "optional": true,
406
+ "os": [
407
+ "freebsd"
408
+ ],
409
+ "engines": {
410
+ "node": ">=12"
411
+ }
412
+ },
413
+ "node_modules/@esbuild/freebsd-x64": {
414
+ "version": "0.21.5",
415
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
416
+ "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
417
+ "cpu": [
418
+ "x64"
419
+ ],
420
+ "dev": true,
421
+ "optional": true,
422
+ "os": [
423
+ "freebsd"
424
+ ],
425
+ "engines": {
426
+ "node": ">=12"
427
+ }
428
+ },
429
+ "node_modules/@esbuild/linux-arm": {
430
+ "version": "0.21.5",
431
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
432
+ "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
433
+ "cpu": [
434
+ "arm"
435
+ ],
436
+ "dev": true,
437
+ "optional": true,
438
+ "os": [
439
+ "linux"
440
+ ],
441
+ "engines": {
442
+ "node": ">=12"
443
+ }
444
+ },
445
+ "node_modules/@esbuild/linux-arm64": {
446
+ "version": "0.21.5",
447
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
448
+ "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
449
+ "cpu": [
450
+ "arm64"
451
+ ],
452
+ "dev": true,
453
+ "optional": true,
454
+ "os": [
455
+ "linux"
456
+ ],
457
+ "engines": {
458
+ "node": ">=12"
459
+ }
460
+ },
461
+ "node_modules/@esbuild/linux-ia32": {
462
+ "version": "0.21.5",
463
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
464
+ "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
465
+ "cpu": [
466
+ "ia32"
467
+ ],
468
+ "dev": true,
469
+ "optional": true,
470
+ "os": [
471
+ "linux"
472
+ ],
473
+ "engines": {
474
+ "node": ">=12"
475
+ }
476
+ },
477
+ "node_modules/@esbuild/linux-loong64": {
478
+ "version": "0.21.5",
479
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
480
+ "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
481
+ "cpu": [
482
+ "loong64"
483
+ ],
484
+ "dev": true,
485
+ "optional": true,
486
+ "os": [
487
+ "linux"
488
+ ],
489
+ "engines": {
490
+ "node": ">=12"
491
+ }
492
+ },
493
+ "node_modules/@esbuild/linux-mips64el": {
494
+ "version": "0.21.5",
495
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
496
+ "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
497
+ "cpu": [
498
+ "mips64el"
499
+ ],
500
+ "dev": true,
501
+ "optional": true,
502
+ "os": [
503
+ "linux"
504
+ ],
505
+ "engines": {
506
+ "node": ">=12"
507
+ }
508
+ },
509
+ "node_modules/@esbuild/linux-ppc64": {
510
+ "version": "0.21.5",
511
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
512
+ "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
513
+ "cpu": [
514
+ "ppc64"
515
+ ],
516
+ "dev": true,
517
+ "optional": true,
518
+ "os": [
519
+ "linux"
520
+ ],
521
+ "engines": {
522
+ "node": ">=12"
523
+ }
524
+ },
525
+ "node_modules/@esbuild/linux-riscv64": {
526
+ "version": "0.21.5",
527
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
528
+ "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
529
+ "cpu": [
530
+ "riscv64"
531
+ ],
532
+ "dev": true,
533
+ "optional": true,
534
+ "os": [
535
+ "linux"
536
+ ],
537
+ "engines": {
538
+ "node": ">=12"
539
+ }
540
+ },
541
+ "node_modules/@esbuild/linux-s390x": {
542
+ "version": "0.21.5",
543
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
544
+ "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
545
+ "cpu": [
546
+ "s390x"
547
+ ],
548
+ "dev": true,
549
+ "optional": true,
550
+ "os": [
551
+ "linux"
552
+ ],
553
+ "engines": {
554
+ "node": ">=12"
555
+ }
556
+ },
557
+ "node_modules/@esbuild/linux-x64": {
558
+ "version": "0.21.5",
559
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
560
+ "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
561
+ "cpu": [
562
+ "x64"
563
+ ],
564
+ "dev": true,
565
+ "optional": true,
566
+ "os": [
567
+ "linux"
568
+ ],
569
+ "engines": {
570
+ "node": ">=12"
571
+ }
572
+ },
573
+ "node_modules/@esbuild/netbsd-x64": {
574
+ "version": "0.21.5",
575
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
576
+ "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
577
+ "cpu": [
578
+ "x64"
579
+ ],
580
+ "dev": true,
581
+ "optional": true,
582
+ "os": [
583
+ "netbsd"
584
+ ],
585
+ "engines": {
586
+ "node": ">=12"
587
+ }
588
+ },
589
+ "node_modules/@esbuild/openbsd-x64": {
590
+ "version": "0.21.5",
591
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
592
+ "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
593
+ "cpu": [
594
+ "x64"
595
+ ],
596
+ "dev": true,
597
+ "optional": true,
598
+ "os": [
599
+ "openbsd"
600
+ ],
601
+ "engines": {
602
+ "node": ">=12"
603
+ }
604
+ },
605
+ "node_modules/@esbuild/sunos-x64": {
606
+ "version": "0.21.5",
607
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
608
+ "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
609
+ "cpu": [
610
+ "x64"
611
+ ],
612
+ "dev": true,
613
+ "optional": true,
614
+ "os": [
615
+ "sunos"
616
+ ],
617
+ "engines": {
618
+ "node": ">=12"
619
+ }
620
+ },
621
+ "node_modules/@esbuild/win32-arm64": {
622
+ "version": "0.21.5",
623
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
624
+ "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
625
+ "cpu": [
626
+ "arm64"
627
+ ],
628
+ "dev": true,
629
+ "optional": true,
630
+ "os": [
631
+ "win32"
632
+ ],
633
+ "engines": {
634
+ "node": ">=12"
635
+ }
636
+ },
637
+ "node_modules/@esbuild/win32-ia32": {
638
+ "version": "0.21.5",
639
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
640
+ "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
641
+ "cpu": [
642
+ "ia32"
643
+ ],
644
+ "dev": true,
645
+ "optional": true,
646
+ "os": [
647
+ "win32"
648
+ ],
649
+ "engines": {
650
+ "node": ">=12"
651
+ }
652
+ },
653
+ "node_modules/@esbuild/win32-x64": {
654
+ "version": "0.21.5",
655
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
656
+ "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
657
+ "cpu": [
658
+ "x64"
659
+ ],
660
+ "dev": true,
661
+ "optional": true,
662
+ "os": [
663
+ "win32"
664
+ ],
665
+ "engines": {
666
+ "node": ">=12"
667
+ }
668
+ },
669
+ "node_modules/@jridgewell/gen-mapping": {
670
+ "version": "0.3.13",
671
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
672
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
673
+ "dev": true,
674
+ "dependencies": {
675
+ "@jridgewell/sourcemap-codec": "^1.5.0",
676
+ "@jridgewell/trace-mapping": "^0.3.24"
677
+ }
678
+ },
679
+ "node_modules/@jridgewell/remapping": {
680
+ "version": "2.3.5",
681
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
682
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
683
+ "dev": true,
684
+ "dependencies": {
685
+ "@jridgewell/gen-mapping": "^0.3.5",
686
+ "@jridgewell/trace-mapping": "^0.3.24"
687
+ }
688
+ },
689
+ "node_modules/@jridgewell/resolve-uri": {
690
+ "version": "3.1.2",
691
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
692
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
693
+ "dev": true,
694
+ "engines": {
695
+ "node": ">=6.0.0"
696
+ }
697
+ },
698
+ "node_modules/@jridgewell/sourcemap-codec": {
699
+ "version": "1.5.5",
700
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
701
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
702
+ "dev": true
703
+ },
704
+ "node_modules/@jridgewell/trace-mapping": {
705
+ "version": "0.3.31",
706
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
707
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
708
+ "dev": true,
709
+ "dependencies": {
710
+ "@jridgewell/resolve-uri": "^3.1.0",
711
+ "@jridgewell/sourcemap-codec": "^1.4.14"
712
+ }
713
+ },
714
+ "node_modules/@nodelib/fs.scandir": {
715
+ "version": "2.1.5",
716
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
717
+ "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
718
+ "dev": true,
719
+ "dependencies": {
720
+ "@nodelib/fs.stat": "2.0.5",
721
+ "run-parallel": "^1.1.9"
722
+ },
723
+ "engines": {
724
+ "node": ">= 8"
725
+ }
726
+ },
727
+ "node_modules/@nodelib/fs.stat": {
728
+ "version": "2.0.5",
729
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
730
+ "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
731
+ "dev": true,
732
+ "engines": {
733
+ "node": ">= 8"
734
+ }
735
+ },
736
+ "node_modules/@nodelib/fs.walk": {
737
+ "version": "1.2.8",
738
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
739
+ "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
740
+ "dev": true,
741
+ "dependencies": {
742
+ "@nodelib/fs.scandir": "2.1.5",
743
+ "fastq": "^1.6.0"
744
+ },
745
+ "engines": {
746
+ "node": ">= 8"
747
+ }
748
+ },
749
+ "node_modules/@rolldown/pluginutils": {
750
+ "version": "1.0.0-beta.27",
751
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
752
+ "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
753
+ "dev": true
754
+ },
755
+ "node_modules/@rollup/rollup-android-arm-eabi": {
756
+ "version": "4.60.1",
757
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz",
758
+ "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==",
759
+ "cpu": [
760
+ "arm"
761
+ ],
762
+ "dev": true,
763
+ "optional": true,
764
+ "os": [
765
+ "android"
766
+ ]
767
+ },
768
+ "node_modules/@rollup/rollup-android-arm64": {
769
+ "version": "4.60.1",
770
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz",
771
+ "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==",
772
+ "cpu": [
773
+ "arm64"
774
+ ],
775
+ "dev": true,
776
+ "optional": true,
777
+ "os": [
778
+ "android"
779
+ ]
780
+ },
781
+ "node_modules/@rollup/rollup-darwin-arm64": {
782
+ "version": "4.60.1",
783
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz",
784
+ "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==",
785
+ "cpu": [
786
+ "arm64"
787
+ ],
788
+ "dev": true,
789
+ "optional": true,
790
+ "os": [
791
+ "darwin"
792
+ ]
793
+ },
794
+ "node_modules/@rollup/rollup-darwin-x64": {
795
+ "version": "4.60.1",
796
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz",
797
+ "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==",
798
+ "cpu": [
799
+ "x64"
800
+ ],
801
+ "dev": true,
802
+ "optional": true,
803
+ "os": [
804
+ "darwin"
805
+ ]
806
+ },
807
+ "node_modules/@rollup/rollup-freebsd-arm64": {
808
+ "version": "4.60.1",
809
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz",
810
+ "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==",
811
+ "cpu": [
812
+ "arm64"
813
+ ],
814
+ "dev": true,
815
+ "optional": true,
816
+ "os": [
817
+ "freebsd"
818
+ ]
819
+ },
820
+ "node_modules/@rollup/rollup-freebsd-x64": {
821
+ "version": "4.60.1",
822
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz",
823
+ "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==",
824
+ "cpu": [
825
+ "x64"
826
+ ],
827
+ "dev": true,
828
+ "optional": true,
829
+ "os": [
830
+ "freebsd"
831
+ ]
832
+ },
833
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
834
+ "version": "4.60.1",
835
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz",
836
+ "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==",
837
+ "cpu": [
838
+ "arm"
839
+ ],
840
+ "dev": true,
841
+ "optional": true,
842
+ "os": [
843
+ "linux"
844
+ ]
845
+ },
846
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
847
+ "version": "4.60.1",
848
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz",
849
+ "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==",
850
+ "cpu": [
851
+ "arm"
852
+ ],
853
+ "dev": true,
854
+ "optional": true,
855
+ "os": [
856
+ "linux"
857
+ ]
858
+ },
859
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
860
+ "version": "4.60.1",
861
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz",
862
+ "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==",
863
+ "cpu": [
864
+ "arm64"
865
+ ],
866
+ "dev": true,
867
+ "optional": true,
868
+ "os": [
869
+ "linux"
870
+ ]
871
+ },
872
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
873
+ "version": "4.60.1",
874
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz",
875
+ "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==",
876
+ "cpu": [
877
+ "arm64"
878
+ ],
879
+ "dev": true,
880
+ "optional": true,
881
+ "os": [
882
+ "linux"
883
+ ]
884
+ },
885
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
886
+ "version": "4.60.1",
887
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz",
888
+ "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==",
889
+ "cpu": [
890
+ "loong64"
891
+ ],
892
+ "dev": true,
893
+ "optional": true,
894
+ "os": [
895
+ "linux"
896
+ ]
897
+ },
898
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
899
+ "version": "4.60.1",
900
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz",
901
+ "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==",
902
+ "cpu": [
903
+ "loong64"
904
+ ],
905
+ "dev": true,
906
+ "optional": true,
907
+ "os": [
908
+ "linux"
909
+ ]
910
+ },
911
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
912
+ "version": "4.60.1",
913
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz",
914
+ "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==",
915
+ "cpu": [
916
+ "ppc64"
917
+ ],
918
+ "dev": true,
919
+ "optional": true,
920
+ "os": [
921
+ "linux"
922
+ ]
923
+ },
924
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
925
+ "version": "4.60.1",
926
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz",
927
+ "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==",
928
+ "cpu": [
929
+ "ppc64"
930
+ ],
931
+ "dev": true,
932
+ "optional": true,
933
+ "os": [
934
+ "linux"
935
+ ]
936
+ },
937
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
938
+ "version": "4.60.1",
939
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz",
940
+ "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==",
941
+ "cpu": [
942
+ "riscv64"
943
+ ],
944
+ "dev": true,
945
+ "optional": true,
946
+ "os": [
947
+ "linux"
948
+ ]
949
+ },
950
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
951
+ "version": "4.60.1",
952
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz",
953
+ "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==",
954
+ "cpu": [
955
+ "riscv64"
956
+ ],
957
+ "dev": true,
958
+ "optional": true,
959
+ "os": [
960
+ "linux"
961
+ ]
962
+ },
963
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
964
+ "version": "4.60.1",
965
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz",
966
+ "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==",
967
+ "cpu": [
968
+ "s390x"
969
+ ],
970
+ "dev": true,
971
+ "optional": true,
972
+ "os": [
973
+ "linux"
974
+ ]
975
+ },
976
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
977
+ "version": "4.60.1",
978
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz",
979
+ "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==",
980
+ "cpu": [
981
+ "x64"
982
+ ],
983
+ "dev": true,
984
+ "optional": true,
985
+ "os": [
986
+ "linux"
987
+ ]
988
+ },
989
+ "node_modules/@rollup/rollup-linux-x64-musl": {
990
+ "version": "4.60.1",
991
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz",
992
+ "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==",
993
+ "cpu": [
994
+ "x64"
995
+ ],
996
+ "dev": true,
997
+ "optional": true,
998
+ "os": [
999
+ "linux"
1000
+ ]
1001
+ },
1002
+ "node_modules/@rollup/rollup-openbsd-x64": {
1003
+ "version": "4.60.1",
1004
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz",
1005
+ "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==",
1006
+ "cpu": [
1007
+ "x64"
1008
+ ],
1009
+ "dev": true,
1010
+ "optional": true,
1011
+ "os": [
1012
+ "openbsd"
1013
+ ]
1014
+ },
1015
+ "node_modules/@rollup/rollup-openharmony-arm64": {
1016
+ "version": "4.60.1",
1017
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz",
1018
+ "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==",
1019
+ "cpu": [
1020
+ "arm64"
1021
+ ],
1022
+ "dev": true,
1023
+ "optional": true,
1024
+ "os": [
1025
+ "openharmony"
1026
+ ]
1027
+ },
1028
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
1029
+ "version": "4.60.1",
1030
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz",
1031
+ "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==",
1032
+ "cpu": [
1033
+ "arm64"
1034
+ ],
1035
+ "dev": true,
1036
+ "optional": true,
1037
+ "os": [
1038
+ "win32"
1039
+ ]
1040
+ },
1041
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
1042
+ "version": "4.60.1",
1043
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz",
1044
+ "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==",
1045
+ "cpu": [
1046
+ "ia32"
1047
+ ],
1048
+ "dev": true,
1049
+ "optional": true,
1050
+ "os": [
1051
+ "win32"
1052
+ ]
1053
+ },
1054
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
1055
+ "version": "4.60.1",
1056
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz",
1057
+ "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==",
1058
+ "cpu": [
1059
+ "x64"
1060
+ ],
1061
+ "dev": true,
1062
+ "optional": true,
1063
+ "os": [
1064
+ "win32"
1065
+ ]
1066
+ },
1067
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
1068
+ "version": "4.60.1",
1069
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz",
1070
+ "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==",
1071
+ "cpu": [
1072
+ "x64"
1073
+ ],
1074
+ "dev": true,
1075
+ "optional": true,
1076
+ "os": [
1077
+ "win32"
1078
+ ]
1079
+ },
1080
+ "node_modules/@types/babel__core": {
1081
+ "version": "7.20.5",
1082
+ "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
1083
+ "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
1084
+ "dev": true,
1085
+ "dependencies": {
1086
+ "@babel/parser": "^7.20.7",
1087
+ "@babel/types": "^7.20.7",
1088
+ "@types/babel__generator": "*",
1089
+ "@types/babel__template": "*",
1090
+ "@types/babel__traverse": "*"
1091
+ }
1092
+ },
1093
+ "node_modules/@types/babel__generator": {
1094
+ "version": "7.27.0",
1095
+ "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
1096
+ "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
1097
+ "dev": true,
1098
+ "dependencies": {
1099
+ "@babel/types": "^7.0.0"
1100
+ }
1101
+ },
1102
+ "node_modules/@types/babel__template": {
1103
+ "version": "7.4.4",
1104
+ "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
1105
+ "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
1106
+ "dev": true,
1107
+ "dependencies": {
1108
+ "@babel/parser": "^7.1.0",
1109
+ "@babel/types": "^7.0.0"
1110
+ }
1111
+ },
1112
+ "node_modules/@types/babel__traverse": {
1113
+ "version": "7.28.0",
1114
+ "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
1115
+ "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
1116
+ "dev": true,
1117
+ "dependencies": {
1118
+ "@babel/types": "^7.28.2"
1119
+ }
1120
+ },
1121
+ "node_modules/@types/estree": {
1122
+ "version": "1.0.8",
1123
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
1124
+ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
1125
+ "dev": true
1126
+ },
1127
+ "node_modules/@types/prop-types": {
1128
+ "version": "15.7.15",
1129
+ "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
1130
+ "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
1131
+ "dev": true
1132
+ },
1133
+ "node_modules/@types/react": {
1134
+ "version": "18.3.28",
1135
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz",
1136
+ "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
1137
+ "dev": true,
1138
+ "dependencies": {
1139
+ "@types/prop-types": "*",
1140
+ "csstype": "^3.2.2"
1141
+ }
1142
+ },
1143
+ "node_modules/@types/react-dom": {
1144
+ "version": "18.3.7",
1145
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
1146
+ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
1147
+ "dev": true,
1148
+ "peerDependencies": {
1149
+ "@types/react": "^18.0.0"
1150
+ }
1151
+ },
1152
+ "node_modules/@vitejs/plugin-react": {
1153
+ "version": "4.7.0",
1154
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
1155
+ "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
1156
+ "dev": true,
1157
+ "dependencies": {
1158
+ "@babel/core": "^7.28.0",
1159
+ "@babel/plugin-transform-react-jsx-self": "^7.27.1",
1160
+ "@babel/plugin-transform-react-jsx-source": "^7.27.1",
1161
+ "@rolldown/pluginutils": "1.0.0-beta.27",
1162
+ "@types/babel__core": "^7.20.5",
1163
+ "react-refresh": "^0.17.0"
1164
+ },
1165
+ "engines": {
1166
+ "node": "^14.18.0 || >=16.0.0"
1167
+ },
1168
+ "peerDependencies": {
1169
+ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
1170
+ }
1171
+ },
1172
+ "node_modules/any-promise": {
1173
+ "version": "1.3.0",
1174
+ "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
1175
+ "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==",
1176
+ "dev": true
1177
+ },
1178
+ "node_modules/anymatch": {
1179
+ "version": "3.1.3",
1180
+ "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
1181
+ "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
1182
+ "dev": true,
1183
+ "dependencies": {
1184
+ "normalize-path": "^3.0.0",
1185
+ "picomatch": "^2.0.4"
1186
+ },
1187
+ "engines": {
1188
+ "node": ">= 8"
1189
+ }
1190
+ },
1191
+ "node_modules/arg": {
1192
+ "version": "5.0.2",
1193
+ "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
1194
+ "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
1195
+ "dev": true
1196
+ },
1197
+ "node_modules/asynckit": {
1198
+ "version": "0.4.0",
1199
+ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
1200
+ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
1201
+ },
1202
+ "node_modules/autoprefixer": {
1203
+ "version": "10.4.27",
1204
+ "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz",
1205
+ "integrity": "sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==",
1206
+ "dev": true,
1207
+ "funding": [
1208
+ {
1209
+ "type": "opencollective",
1210
+ "url": "https://opencollective.com/postcss/"
1211
+ },
1212
+ {
1213
+ "type": "tidelift",
1214
+ "url": "https://tidelift.com/funding/github/npm/autoprefixer"
1215
+ },
1216
+ {
1217
+ "type": "github",
1218
+ "url": "https://github.com/sponsors/ai"
1219
+ }
1220
+ ],
1221
+ "dependencies": {
1222
+ "browserslist": "^4.28.1",
1223
+ "caniuse-lite": "^1.0.30001774",
1224
+ "fraction.js": "^5.3.4",
1225
+ "picocolors": "^1.1.1",
1226
+ "postcss-value-parser": "^4.2.0"
1227
+ },
1228
+ "bin": {
1229
+ "autoprefixer": "bin/autoprefixer"
1230
+ },
1231
+ "engines": {
1232
+ "node": "^10 || ^12 || >=14"
1233
+ },
1234
+ "peerDependencies": {
1235
+ "postcss": "^8.1.0"
1236
+ }
1237
+ },
1238
+ "node_modules/axios": {
1239
+ "version": "1.14.0",
1240
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz",
1241
+ "integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==",
1242
+ "dependencies": {
1243
+ "follow-redirects": "^1.15.11",
1244
+ "form-data": "^4.0.5",
1245
+ "proxy-from-env": "^2.1.0"
1246
+ }
1247
+ },
1248
+ "node_modules/baseline-browser-mapping": {
1249
+ "version": "2.10.16",
1250
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.16.tgz",
1251
+ "integrity": "sha512-Lyf3aK28zpsD1yQMiiHD4RvVb6UdMoo8xzG2XzFIfR9luPzOpcBlAsT/qfB1XWS1bxWT+UtE4WmQgsp297FYOA==",
1252
+ "dev": true,
1253
+ "bin": {
1254
+ "baseline-browser-mapping": "dist/cli.cjs"
1255
+ },
1256
+ "engines": {
1257
+ "node": ">=6.0.0"
1258
+ }
1259
+ },
1260
+ "node_modules/binary-extensions": {
1261
+ "version": "2.3.0",
1262
+ "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
1263
+ "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
1264
+ "dev": true,
1265
+ "engines": {
1266
+ "node": ">=8"
1267
+ },
1268
+ "funding": {
1269
+ "url": "https://github.com/sponsors/sindresorhus"
1270
+ }
1271
+ },
1272
+ "node_modules/braces": {
1273
+ "version": "3.0.3",
1274
+ "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
1275
+ "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
1276
+ "dev": true,
1277
+ "dependencies": {
1278
+ "fill-range": "^7.1.1"
1279
+ },
1280
+ "engines": {
1281
+ "node": ">=8"
1282
+ }
1283
+ },
1284
+ "node_modules/browserslist": {
1285
+ "version": "4.28.2",
1286
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
1287
+ "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==",
1288
+ "dev": true,
1289
+ "funding": [
1290
+ {
1291
+ "type": "opencollective",
1292
+ "url": "https://opencollective.com/browserslist"
1293
+ },
1294
+ {
1295
+ "type": "tidelift",
1296
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
1297
+ },
1298
+ {
1299
+ "type": "github",
1300
+ "url": "https://github.com/sponsors/ai"
1301
+ }
1302
+ ],
1303
+ "dependencies": {
1304
+ "baseline-browser-mapping": "^2.10.12",
1305
+ "caniuse-lite": "^1.0.30001782",
1306
+ "electron-to-chromium": "^1.5.328",
1307
+ "node-releases": "^2.0.36",
1308
+ "update-browserslist-db": "^1.2.3"
1309
+ },
1310
+ "bin": {
1311
+ "browserslist": "cli.js"
1312
+ },
1313
+ "engines": {
1314
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
1315
+ }
1316
+ },
1317
+ "node_modules/call-bind-apply-helpers": {
1318
+ "version": "1.0.2",
1319
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
1320
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
1321
+ "dependencies": {
1322
+ "es-errors": "^1.3.0",
1323
+ "function-bind": "^1.1.2"
1324
+ },
1325
+ "engines": {
1326
+ "node": ">= 0.4"
1327
+ }
1328
+ },
1329
+ "node_modules/camelcase-css": {
1330
+ "version": "2.0.1",
1331
+ "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
1332
+ "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==",
1333
+ "dev": true,
1334
+ "engines": {
1335
+ "node": ">= 6"
1336
+ }
1337
+ },
1338
+ "node_modules/caniuse-lite": {
1339
+ "version": "1.0.30001786",
1340
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001786.tgz",
1341
+ "integrity": "sha512-4oxTZEvqmLLrERwxO76yfKM7acZo310U+v4kqexI2TL1DkkUEMT8UijrxxcnVdxR3qkVf5awGRX+4Z6aPHVKrA==",
1342
+ "dev": true,
1343
+ "funding": [
1344
+ {
1345
+ "type": "opencollective",
1346
+ "url": "https://opencollective.com/browserslist"
1347
+ },
1348
+ {
1349
+ "type": "tidelift",
1350
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
1351
+ },
1352
+ {
1353
+ "type": "github",
1354
+ "url": "https://github.com/sponsors/ai"
1355
+ }
1356
+ ]
1357
+ },
1358
+ "node_modules/chokidar": {
1359
+ "version": "3.6.0",
1360
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
1361
+ "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
1362
+ "dev": true,
1363
+ "dependencies": {
1364
+ "anymatch": "~3.1.2",
1365
+ "braces": "~3.0.2",
1366
+ "glob-parent": "~5.1.2",
1367
+ "is-binary-path": "~2.1.0",
1368
+ "is-glob": "~4.0.1",
1369
+ "normalize-path": "~3.0.0",
1370
+ "readdirp": "~3.6.0"
1371
+ },
1372
+ "engines": {
1373
+ "node": ">= 8.10.0"
1374
+ },
1375
+ "funding": {
1376
+ "url": "https://paulmillr.com/funding/"
1377
+ },
1378
+ "optionalDependencies": {
1379
+ "fsevents": "~2.3.2"
1380
+ }
1381
+ },
1382
+ "node_modules/chokidar/node_modules/glob-parent": {
1383
+ "version": "5.1.2",
1384
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
1385
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
1386
+ "dev": true,
1387
+ "dependencies": {
1388
+ "is-glob": "^4.0.1"
1389
+ },
1390
+ "engines": {
1391
+ "node": ">= 6"
1392
+ }
1393
+ },
1394
+ "node_modules/combined-stream": {
1395
+ "version": "1.0.8",
1396
+ "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
1397
+ "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
1398
+ "dependencies": {
1399
+ "delayed-stream": "~1.0.0"
1400
+ },
1401
+ "engines": {
1402
+ "node": ">= 0.8"
1403
+ }
1404
+ },
1405
+ "node_modules/commander": {
1406
+ "version": "4.1.1",
1407
+ "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
1408
+ "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
1409
+ "dev": true,
1410
+ "engines": {
1411
+ "node": ">= 6"
1412
+ }
1413
+ },
1414
+ "node_modules/convert-source-map": {
1415
+ "version": "2.0.0",
1416
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
1417
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
1418
+ "dev": true
1419
+ },
1420
+ "node_modules/cssesc": {
1421
+ "version": "3.0.0",
1422
+ "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
1423
+ "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
1424
+ "dev": true,
1425
+ "bin": {
1426
+ "cssesc": "bin/cssesc"
1427
+ },
1428
+ "engines": {
1429
+ "node": ">=4"
1430
+ }
1431
+ },
1432
+ "node_modules/csstype": {
1433
+ "version": "3.2.3",
1434
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
1435
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
1436
+ "dev": true
1437
+ },
1438
+ "node_modules/debug": {
1439
+ "version": "4.4.3",
1440
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
1441
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
1442
+ "dev": true,
1443
+ "dependencies": {
1444
+ "ms": "^2.1.3"
1445
+ },
1446
+ "engines": {
1447
+ "node": ">=6.0"
1448
+ },
1449
+ "peerDependenciesMeta": {
1450
+ "supports-color": {
1451
+ "optional": true
1452
+ }
1453
+ }
1454
+ },
1455
+ "node_modules/delayed-stream": {
1456
+ "version": "1.0.0",
1457
+ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
1458
+ "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
1459
+ "engines": {
1460
+ "node": ">=0.4.0"
1461
+ }
1462
+ },
1463
+ "node_modules/didyoumean": {
1464
+ "version": "1.2.2",
1465
+ "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
1466
+ "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
1467
+ "dev": true
1468
+ },
1469
+ "node_modules/dlv": {
1470
+ "version": "1.1.3",
1471
+ "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
1472
+ "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
1473
+ "dev": true
1474
+ },
1475
+ "node_modules/dunder-proto": {
1476
+ "version": "1.0.1",
1477
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
1478
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
1479
+ "dependencies": {
1480
+ "call-bind-apply-helpers": "^1.0.1",
1481
+ "es-errors": "^1.3.0",
1482
+ "gopd": "^1.2.0"
1483
+ },
1484
+ "engines": {
1485
+ "node": ">= 0.4"
1486
+ }
1487
+ },
1488
+ "node_modules/electron-to-chromium": {
1489
+ "version": "1.5.331",
1490
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.331.tgz",
1491
+ "integrity": "sha512-IbxXrsTlD3hRodkLnbxAPP4OuJYdWCeM3IOdT+CpcMoIwIoDfCmRpEtSPfwBXxVkg9xmBeY7Lz2Eo2TDn/HC3Q==",
1492
+ "dev": true
1493
+ },
1494
+ "node_modules/es-define-property": {
1495
+ "version": "1.0.1",
1496
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
1497
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
1498
+ "engines": {
1499
+ "node": ">= 0.4"
1500
+ }
1501
+ },
1502
+ "node_modules/es-errors": {
1503
+ "version": "1.3.0",
1504
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
1505
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
1506
+ "engines": {
1507
+ "node": ">= 0.4"
1508
+ }
1509
+ },
1510
+ "node_modules/es-object-atoms": {
1511
+ "version": "1.1.1",
1512
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
1513
+ "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
1514
+ "dependencies": {
1515
+ "es-errors": "^1.3.0"
1516
+ },
1517
+ "engines": {
1518
+ "node": ">= 0.4"
1519
+ }
1520
+ },
1521
+ "node_modules/es-set-tostringtag": {
1522
+ "version": "2.1.0",
1523
+ "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
1524
+ "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
1525
+ "dependencies": {
1526
+ "es-errors": "^1.3.0",
1527
+ "get-intrinsic": "^1.2.6",
1528
+ "has-tostringtag": "^1.0.2",
1529
+ "hasown": "^2.0.2"
1530
+ },
1531
+ "engines": {
1532
+ "node": ">= 0.4"
1533
+ }
1534
+ },
1535
+ "node_modules/esbuild": {
1536
+ "version": "0.21.5",
1537
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
1538
+ "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
1539
+ "dev": true,
1540
+ "hasInstallScript": true,
1541
+ "bin": {
1542
+ "esbuild": "bin/esbuild"
1543
+ },
1544
+ "engines": {
1545
+ "node": ">=12"
1546
+ },
1547
+ "optionalDependencies": {
1548
+ "@esbuild/aix-ppc64": "0.21.5",
1549
+ "@esbuild/android-arm": "0.21.5",
1550
+ "@esbuild/android-arm64": "0.21.5",
1551
+ "@esbuild/android-x64": "0.21.5",
1552
+ "@esbuild/darwin-arm64": "0.21.5",
1553
+ "@esbuild/darwin-x64": "0.21.5",
1554
+ "@esbuild/freebsd-arm64": "0.21.5",
1555
+ "@esbuild/freebsd-x64": "0.21.5",
1556
+ "@esbuild/linux-arm": "0.21.5",
1557
+ "@esbuild/linux-arm64": "0.21.5",
1558
+ "@esbuild/linux-ia32": "0.21.5",
1559
+ "@esbuild/linux-loong64": "0.21.5",
1560
+ "@esbuild/linux-mips64el": "0.21.5",
1561
+ "@esbuild/linux-ppc64": "0.21.5",
1562
+ "@esbuild/linux-riscv64": "0.21.5",
1563
+ "@esbuild/linux-s390x": "0.21.5",
1564
+ "@esbuild/linux-x64": "0.21.5",
1565
+ "@esbuild/netbsd-x64": "0.21.5",
1566
+ "@esbuild/openbsd-x64": "0.21.5",
1567
+ "@esbuild/sunos-x64": "0.21.5",
1568
+ "@esbuild/win32-arm64": "0.21.5",
1569
+ "@esbuild/win32-ia32": "0.21.5",
1570
+ "@esbuild/win32-x64": "0.21.5"
1571
+ }
1572
+ },
1573
+ "node_modules/escalade": {
1574
+ "version": "3.2.0",
1575
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
1576
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
1577
+ "dev": true,
1578
+ "engines": {
1579
+ "node": ">=6"
1580
+ }
1581
+ },
1582
+ "node_modules/fast-glob": {
1583
+ "version": "3.3.3",
1584
+ "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
1585
+ "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
1586
+ "dev": true,
1587
+ "dependencies": {
1588
+ "@nodelib/fs.stat": "^2.0.2",
1589
+ "@nodelib/fs.walk": "^1.2.3",
1590
+ "glob-parent": "^5.1.2",
1591
+ "merge2": "^1.3.0",
1592
+ "micromatch": "^4.0.8"
1593
+ },
1594
+ "engines": {
1595
+ "node": ">=8.6.0"
1596
+ }
1597
+ },
1598
+ "node_modules/fast-glob/node_modules/glob-parent": {
1599
+ "version": "5.1.2",
1600
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
1601
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
1602
+ "dev": true,
1603
+ "dependencies": {
1604
+ "is-glob": "^4.0.1"
1605
+ },
1606
+ "engines": {
1607
+ "node": ">= 6"
1608
+ }
1609
+ },
1610
+ "node_modules/fastq": {
1611
+ "version": "1.20.1",
1612
+ "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
1613
+ "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
1614
+ "dev": true,
1615
+ "dependencies": {
1616
+ "reusify": "^1.0.4"
1617
+ }
1618
+ },
1619
+ "node_modules/fill-range": {
1620
+ "version": "7.1.1",
1621
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
1622
+ "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
1623
+ "dev": true,
1624
+ "dependencies": {
1625
+ "to-regex-range": "^5.0.1"
1626
+ },
1627
+ "engines": {
1628
+ "node": ">=8"
1629
+ }
1630
+ },
1631
+ "node_modules/follow-redirects": {
1632
+ "version": "1.15.11",
1633
+ "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
1634
+ "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
1635
+ "funding": [
1636
+ {
1637
+ "type": "individual",
1638
+ "url": "https://github.com/sponsors/RubenVerborgh"
1639
+ }
1640
+ ],
1641
+ "engines": {
1642
+ "node": ">=4.0"
1643
+ },
1644
+ "peerDependenciesMeta": {
1645
+ "debug": {
1646
+ "optional": true
1647
+ }
1648
+ }
1649
+ },
1650
+ "node_modules/form-data": {
1651
+ "version": "4.0.5",
1652
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
1653
+ "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
1654
+ "dependencies": {
1655
+ "asynckit": "^0.4.0",
1656
+ "combined-stream": "^1.0.8",
1657
+ "es-set-tostringtag": "^2.1.0",
1658
+ "hasown": "^2.0.2",
1659
+ "mime-types": "^2.1.12"
1660
+ },
1661
+ "engines": {
1662
+ "node": ">= 6"
1663
+ }
1664
+ },
1665
+ "node_modules/fraction.js": {
1666
+ "version": "5.3.4",
1667
+ "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
1668
+ "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==",
1669
+ "dev": true,
1670
+ "engines": {
1671
+ "node": "*"
1672
+ },
1673
+ "funding": {
1674
+ "type": "github",
1675
+ "url": "https://github.com/sponsors/rawify"
1676
+ }
1677
+ },
1678
+ "node_modules/fsevents": {
1679
+ "version": "2.3.3",
1680
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
1681
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
1682
+ "dev": true,
1683
+ "hasInstallScript": true,
1684
+ "optional": true,
1685
+ "os": [
1686
+ "darwin"
1687
+ ],
1688
+ "engines": {
1689
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
1690
+ }
1691
+ },
1692
+ "node_modules/function-bind": {
1693
+ "version": "1.1.2",
1694
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
1695
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
1696
+ "funding": {
1697
+ "url": "https://github.com/sponsors/ljharb"
1698
+ }
1699
+ },
1700
+ "node_modules/gensync": {
1701
+ "version": "1.0.0-beta.2",
1702
+ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
1703
+ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
1704
+ "dev": true,
1705
+ "engines": {
1706
+ "node": ">=6.9.0"
1707
+ }
1708
+ },
1709
+ "node_modules/get-intrinsic": {
1710
+ "version": "1.3.0",
1711
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
1712
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
1713
+ "dependencies": {
1714
+ "call-bind-apply-helpers": "^1.0.2",
1715
+ "es-define-property": "^1.0.1",
1716
+ "es-errors": "^1.3.0",
1717
+ "es-object-atoms": "^1.1.1",
1718
+ "function-bind": "^1.1.2",
1719
+ "get-proto": "^1.0.1",
1720
+ "gopd": "^1.2.0",
1721
+ "has-symbols": "^1.1.0",
1722
+ "hasown": "^2.0.2",
1723
+ "math-intrinsics": "^1.1.0"
1724
+ },
1725
+ "engines": {
1726
+ "node": ">= 0.4"
1727
+ },
1728
+ "funding": {
1729
+ "url": "https://github.com/sponsors/ljharb"
1730
+ }
1731
+ },
1732
+ "node_modules/get-proto": {
1733
+ "version": "1.0.1",
1734
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
1735
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
1736
+ "dependencies": {
1737
+ "dunder-proto": "^1.0.1",
1738
+ "es-object-atoms": "^1.0.0"
1739
+ },
1740
+ "engines": {
1741
+ "node": ">= 0.4"
1742
+ }
1743
+ },
1744
+ "node_modules/glob-parent": {
1745
+ "version": "6.0.2",
1746
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
1747
+ "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
1748
+ "dev": true,
1749
+ "dependencies": {
1750
+ "is-glob": "^4.0.3"
1751
+ },
1752
+ "engines": {
1753
+ "node": ">=10.13.0"
1754
+ }
1755
+ },
1756
+ "node_modules/gopd": {
1757
+ "version": "1.2.0",
1758
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
1759
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
1760
+ "engines": {
1761
+ "node": ">= 0.4"
1762
+ },
1763
+ "funding": {
1764
+ "url": "https://github.com/sponsors/ljharb"
1765
+ }
1766
+ },
1767
+ "node_modules/has-symbols": {
1768
+ "version": "1.1.0",
1769
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
1770
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
1771
+ "engines": {
1772
+ "node": ">= 0.4"
1773
+ },
1774
+ "funding": {
1775
+ "url": "https://github.com/sponsors/ljharb"
1776
+ }
1777
+ },
1778
+ "node_modules/has-tostringtag": {
1779
+ "version": "1.0.2",
1780
+ "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
1781
+ "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
1782
+ "dependencies": {
1783
+ "has-symbols": "^1.0.3"
1784
+ },
1785
+ "engines": {
1786
+ "node": ">= 0.4"
1787
+ },
1788
+ "funding": {
1789
+ "url": "https://github.com/sponsors/ljharb"
1790
+ }
1791
+ },
1792
+ "node_modules/hasown": {
1793
+ "version": "2.0.2",
1794
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
1795
+ "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
1796
+ "dependencies": {
1797
+ "function-bind": "^1.1.2"
1798
+ },
1799
+ "engines": {
1800
+ "node": ">= 0.4"
1801
+ }
1802
+ },
1803
+ "node_modules/is-binary-path": {
1804
+ "version": "2.1.0",
1805
+ "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
1806
+ "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
1807
+ "dev": true,
1808
+ "dependencies": {
1809
+ "binary-extensions": "^2.0.0"
1810
+ },
1811
+ "engines": {
1812
+ "node": ">=8"
1813
+ }
1814
+ },
1815
+ "node_modules/is-core-module": {
1816
+ "version": "2.16.1",
1817
+ "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
1818
+ "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
1819
+ "dev": true,
1820
+ "dependencies": {
1821
+ "hasown": "^2.0.2"
1822
+ },
1823
+ "engines": {
1824
+ "node": ">= 0.4"
1825
+ },
1826
+ "funding": {
1827
+ "url": "https://github.com/sponsors/ljharb"
1828
+ }
1829
+ },
1830
+ "node_modules/is-extglob": {
1831
+ "version": "2.1.1",
1832
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
1833
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
1834
+ "dev": true,
1835
+ "engines": {
1836
+ "node": ">=0.10.0"
1837
+ }
1838
+ },
1839
+ "node_modules/is-glob": {
1840
+ "version": "4.0.3",
1841
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
1842
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
1843
+ "dev": true,
1844
+ "dependencies": {
1845
+ "is-extglob": "^2.1.1"
1846
+ },
1847
+ "engines": {
1848
+ "node": ">=0.10.0"
1849
+ }
1850
+ },
1851
+ "node_modules/is-number": {
1852
+ "version": "7.0.0",
1853
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
1854
+ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
1855
+ "dev": true,
1856
+ "engines": {
1857
+ "node": ">=0.12.0"
1858
+ }
1859
+ },
1860
+ "node_modules/jiti": {
1861
+ "version": "1.21.7",
1862
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
1863
+ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
1864
+ "dev": true,
1865
+ "bin": {
1866
+ "jiti": "bin/jiti.js"
1867
+ }
1868
+ },
1869
+ "node_modules/js-tokens": {
1870
+ "version": "4.0.0",
1871
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
1872
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
1873
+ },
1874
+ "node_modules/jsesc": {
1875
+ "version": "3.1.0",
1876
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
1877
+ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
1878
+ "dev": true,
1879
+ "bin": {
1880
+ "jsesc": "bin/jsesc"
1881
+ },
1882
+ "engines": {
1883
+ "node": ">=6"
1884
+ }
1885
+ },
1886
+ "node_modules/json5": {
1887
+ "version": "2.2.3",
1888
+ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
1889
+ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
1890
+ "dev": true,
1891
+ "bin": {
1892
+ "json5": "lib/cli.js"
1893
+ },
1894
+ "engines": {
1895
+ "node": ">=6"
1896
+ }
1897
+ },
1898
+ "node_modules/lilconfig": {
1899
+ "version": "3.1.3",
1900
+ "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
1901
+ "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==",
1902
+ "dev": true,
1903
+ "engines": {
1904
+ "node": ">=14"
1905
+ },
1906
+ "funding": {
1907
+ "url": "https://github.com/sponsors/antonk52"
1908
+ }
1909
+ },
1910
+ "node_modules/lines-and-columns": {
1911
+ "version": "1.2.4",
1912
+ "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
1913
+ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
1914
+ "dev": true
1915
+ },
1916
+ "node_modules/loose-envify": {
1917
+ "version": "1.4.0",
1918
+ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
1919
+ "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
1920
+ "dependencies": {
1921
+ "js-tokens": "^3.0.0 || ^4.0.0"
1922
+ },
1923
+ "bin": {
1924
+ "loose-envify": "cli.js"
1925
+ }
1926
+ },
1927
+ "node_modules/lru-cache": {
1928
+ "version": "5.1.1",
1929
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
1930
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
1931
+ "dev": true,
1932
+ "dependencies": {
1933
+ "yallist": "^3.0.2"
1934
+ }
1935
+ },
1936
+ "node_modules/lucide-react": {
1937
+ "version": "0.294.0",
1938
+ "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.294.0.tgz",
1939
+ "integrity": "sha512-V7o0/VECSGbLHn3/1O67FUgBwWB+hmzshrgDVRJQhMh8uj5D3HBuIvhuAmQTtlupILSplwIZg5FTc4tTKMA2SA==",
1940
+ "peerDependencies": {
1941
+ "react": "^16.5.1 || ^17.0.0 || ^18.0.0"
1942
+ }
1943
+ },
1944
+ "node_modules/math-intrinsics": {
1945
+ "version": "1.1.0",
1946
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
1947
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
1948
+ "engines": {
1949
+ "node": ">= 0.4"
1950
+ }
1951
+ },
1952
+ "node_modules/merge2": {
1953
+ "version": "1.4.1",
1954
+ "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
1955
+ "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
1956
+ "dev": true,
1957
+ "engines": {
1958
+ "node": ">= 8"
1959
+ }
1960
+ },
1961
+ "node_modules/micromatch": {
1962
+ "version": "4.0.8",
1963
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
1964
+ "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
1965
+ "dev": true,
1966
+ "dependencies": {
1967
+ "braces": "^3.0.3",
1968
+ "picomatch": "^2.3.1"
1969
+ },
1970
+ "engines": {
1971
+ "node": ">=8.6"
1972
+ }
1973
+ },
1974
+ "node_modules/mime-db": {
1975
+ "version": "1.52.0",
1976
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
1977
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
1978
+ "engines": {
1979
+ "node": ">= 0.6"
1980
+ }
1981
+ },
1982
+ "node_modules/mime-types": {
1983
+ "version": "2.1.35",
1984
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
1985
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
1986
+ "dependencies": {
1987
+ "mime-db": "1.52.0"
1988
+ },
1989
+ "engines": {
1990
+ "node": ">= 0.6"
1991
+ }
1992
+ },
1993
+ "node_modules/ms": {
1994
+ "version": "2.1.3",
1995
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
1996
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
1997
+ "dev": true
1998
+ },
1999
+ "node_modules/mz": {
2000
+ "version": "2.7.0",
2001
+ "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
2002
+ "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==",
2003
+ "dev": true,
2004
+ "dependencies": {
2005
+ "any-promise": "^1.0.0",
2006
+ "object-assign": "^4.0.1",
2007
+ "thenify-all": "^1.0.0"
2008
+ }
2009
+ },
2010
+ "node_modules/nanoid": {
2011
+ "version": "3.3.11",
2012
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
2013
+ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
2014
+ "dev": true,
2015
+ "funding": [
2016
+ {
2017
+ "type": "github",
2018
+ "url": "https://github.com/sponsors/ai"
2019
+ }
2020
+ ],
2021
+ "bin": {
2022
+ "nanoid": "bin/nanoid.cjs"
2023
+ },
2024
+ "engines": {
2025
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
2026
+ }
2027
+ },
2028
+ "node_modules/node-releases": {
2029
+ "version": "2.0.37",
2030
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz",
2031
+ "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==",
2032
+ "dev": true
2033
+ },
2034
+ "node_modules/normalize-path": {
2035
+ "version": "3.0.0",
2036
+ "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
2037
+ "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
2038
+ "dev": true,
2039
+ "engines": {
2040
+ "node": ">=0.10.0"
2041
+ }
2042
+ },
2043
+ "node_modules/object-assign": {
2044
+ "version": "4.1.1",
2045
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
2046
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
2047
+ "dev": true,
2048
+ "engines": {
2049
+ "node": ">=0.10.0"
2050
+ }
2051
+ },
2052
+ "node_modules/object-hash": {
2053
+ "version": "3.0.0",
2054
+ "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
2055
+ "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
2056
+ "dev": true,
2057
+ "engines": {
2058
+ "node": ">= 6"
2059
+ }
2060
+ },
2061
+ "node_modules/path-parse": {
2062
+ "version": "1.0.7",
2063
+ "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
2064
+ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
2065
+ "dev": true
2066
+ },
2067
+ "node_modules/picocolors": {
2068
+ "version": "1.1.1",
2069
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
2070
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
2071
+ "dev": true
2072
+ },
2073
+ "node_modules/picomatch": {
2074
+ "version": "2.3.2",
2075
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
2076
+ "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
2077
+ "dev": true,
2078
+ "engines": {
2079
+ "node": ">=8.6"
2080
+ },
2081
+ "funding": {
2082
+ "url": "https://github.com/sponsors/jonschlinkert"
2083
+ }
2084
+ },
2085
+ "node_modules/pify": {
2086
+ "version": "2.3.0",
2087
+ "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
2088
+ "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==",
2089
+ "dev": true,
2090
+ "engines": {
2091
+ "node": ">=0.10.0"
2092
+ }
2093
+ },
2094
+ "node_modules/pirates": {
2095
+ "version": "4.0.7",
2096
+ "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
2097
+ "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==",
2098
+ "dev": true,
2099
+ "engines": {
2100
+ "node": ">= 6"
2101
+ }
2102
+ },
2103
+ "node_modules/postcss": {
2104
+ "version": "8.5.8",
2105
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
2106
+ "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
2107
+ "dev": true,
2108
+ "funding": [
2109
+ {
2110
+ "type": "opencollective",
2111
+ "url": "https://opencollective.com/postcss/"
2112
+ },
2113
+ {
2114
+ "type": "tidelift",
2115
+ "url": "https://tidelift.com/funding/github/npm/postcss"
2116
+ },
2117
+ {
2118
+ "type": "github",
2119
+ "url": "https://github.com/sponsors/ai"
2120
+ }
2121
+ ],
2122
+ "dependencies": {
2123
+ "nanoid": "^3.3.11",
2124
+ "picocolors": "^1.1.1",
2125
+ "source-map-js": "^1.2.1"
2126
+ },
2127
+ "engines": {
2128
+ "node": "^10 || ^12 || >=14"
2129
+ }
2130
+ },
2131
+ "node_modules/postcss-import": {
2132
+ "version": "15.1.0",
2133
+ "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz",
2134
+ "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==",
2135
+ "dev": true,
2136
+ "dependencies": {
2137
+ "postcss-value-parser": "^4.0.0",
2138
+ "read-cache": "^1.0.0",
2139
+ "resolve": "^1.1.7"
2140
+ },
2141
+ "engines": {
2142
+ "node": ">=14.0.0"
2143
+ },
2144
+ "peerDependencies": {
2145
+ "postcss": "^8.0.0"
2146
+ }
2147
+ },
2148
+ "node_modules/postcss-js": {
2149
+ "version": "4.1.0",
2150
+ "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz",
2151
+ "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==",
2152
+ "dev": true,
2153
+ "funding": [
2154
+ {
2155
+ "type": "opencollective",
2156
+ "url": "https://opencollective.com/postcss/"
2157
+ },
2158
+ {
2159
+ "type": "github",
2160
+ "url": "https://github.com/sponsors/ai"
2161
+ }
2162
+ ],
2163
+ "dependencies": {
2164
+ "camelcase-css": "^2.0.1"
2165
+ },
2166
+ "engines": {
2167
+ "node": "^12 || ^14 || >= 16"
2168
+ },
2169
+ "peerDependencies": {
2170
+ "postcss": "^8.4.21"
2171
+ }
2172
+ },
2173
+ "node_modules/postcss-load-config": {
2174
+ "version": "6.0.1",
2175
+ "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz",
2176
+ "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==",
2177
+ "dev": true,
2178
+ "funding": [
2179
+ {
2180
+ "type": "opencollective",
2181
+ "url": "https://opencollective.com/postcss/"
2182
+ },
2183
+ {
2184
+ "type": "github",
2185
+ "url": "https://github.com/sponsors/ai"
2186
+ }
2187
+ ],
2188
+ "dependencies": {
2189
+ "lilconfig": "^3.1.1"
2190
+ },
2191
+ "engines": {
2192
+ "node": ">= 18"
2193
+ },
2194
+ "peerDependencies": {
2195
+ "jiti": ">=1.21.0",
2196
+ "postcss": ">=8.0.9",
2197
+ "tsx": "^4.8.1",
2198
+ "yaml": "^2.4.2"
2199
+ },
2200
+ "peerDependenciesMeta": {
2201
+ "jiti": {
2202
+ "optional": true
2203
+ },
2204
+ "postcss": {
2205
+ "optional": true
2206
+ },
2207
+ "tsx": {
2208
+ "optional": true
2209
+ },
2210
+ "yaml": {
2211
+ "optional": true
2212
+ }
2213
+ }
2214
+ },
2215
+ "node_modules/postcss-nested": {
2216
+ "version": "6.2.0",
2217
+ "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz",
2218
+ "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==",
2219
+ "dev": true,
2220
+ "funding": [
2221
+ {
2222
+ "type": "opencollective",
2223
+ "url": "https://opencollective.com/postcss/"
2224
+ },
2225
+ {
2226
+ "type": "github",
2227
+ "url": "https://github.com/sponsors/ai"
2228
+ }
2229
+ ],
2230
+ "dependencies": {
2231
+ "postcss-selector-parser": "^6.1.1"
2232
+ },
2233
+ "engines": {
2234
+ "node": ">=12.0"
2235
+ },
2236
+ "peerDependencies": {
2237
+ "postcss": "^8.2.14"
2238
+ }
2239
+ },
2240
+ "node_modules/postcss-selector-parser": {
2241
+ "version": "6.1.2",
2242
+ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
2243
+ "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
2244
+ "dev": true,
2245
+ "dependencies": {
2246
+ "cssesc": "^3.0.0",
2247
+ "util-deprecate": "^1.0.2"
2248
+ },
2249
+ "engines": {
2250
+ "node": ">=4"
2251
+ }
2252
+ },
2253
+ "node_modules/postcss-value-parser": {
2254
+ "version": "4.2.0",
2255
+ "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
2256
+ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
2257
+ "dev": true
2258
+ },
2259
+ "node_modules/proxy-from-env": {
2260
+ "version": "2.1.0",
2261
+ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
2262
+ "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
2263
+ "engines": {
2264
+ "node": ">=10"
2265
+ }
2266
+ },
2267
+ "node_modules/queue-microtask": {
2268
+ "version": "1.2.3",
2269
+ "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
2270
+ "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
2271
+ "dev": true,
2272
+ "funding": [
2273
+ {
2274
+ "type": "github",
2275
+ "url": "https://github.com/sponsors/feross"
2276
+ },
2277
+ {
2278
+ "type": "patreon",
2279
+ "url": "https://www.patreon.com/feross"
2280
+ },
2281
+ {
2282
+ "type": "consulting",
2283
+ "url": "https://feross.org/support"
2284
+ }
2285
+ ]
2286
+ },
2287
+ "node_modules/react": {
2288
+ "version": "18.3.1",
2289
+ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
2290
+ "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
2291
+ "dependencies": {
2292
+ "loose-envify": "^1.1.0"
2293
+ },
2294
+ "engines": {
2295
+ "node": ">=0.10.0"
2296
+ }
2297
+ },
2298
+ "node_modules/react-dom": {
2299
+ "version": "18.3.1",
2300
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
2301
+ "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
2302
+ "dependencies": {
2303
+ "loose-envify": "^1.1.0",
2304
+ "scheduler": "^0.23.2"
2305
+ },
2306
+ "peerDependencies": {
2307
+ "react": "^18.3.1"
2308
+ }
2309
+ },
2310
+ "node_modules/react-refresh": {
2311
+ "version": "0.17.0",
2312
+ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
2313
+ "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
2314
+ "dev": true,
2315
+ "engines": {
2316
+ "node": ">=0.10.0"
2317
+ }
2318
+ },
2319
+ "node_modules/read-cache": {
2320
+ "version": "1.0.0",
2321
+ "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
2322
+ "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==",
2323
+ "dev": true,
2324
+ "dependencies": {
2325
+ "pify": "^2.3.0"
2326
+ }
2327
+ },
2328
+ "node_modules/readdirp": {
2329
+ "version": "3.6.0",
2330
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
2331
+ "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
2332
+ "dev": true,
2333
+ "dependencies": {
2334
+ "picomatch": "^2.2.1"
2335
+ },
2336
+ "engines": {
2337
+ "node": ">=8.10.0"
2338
+ }
2339
+ },
2340
+ "node_modules/resolve": {
2341
+ "version": "1.22.11",
2342
+ "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
2343
+ "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==",
2344
+ "dev": true,
2345
+ "dependencies": {
2346
+ "is-core-module": "^2.16.1",
2347
+ "path-parse": "^1.0.7",
2348
+ "supports-preserve-symlinks-flag": "^1.0.0"
2349
+ },
2350
+ "bin": {
2351
+ "resolve": "bin/resolve"
2352
+ },
2353
+ "engines": {
2354
+ "node": ">= 0.4"
2355
+ },
2356
+ "funding": {
2357
+ "url": "https://github.com/sponsors/ljharb"
2358
+ }
2359
+ },
2360
+ "node_modules/reusify": {
2361
+ "version": "1.1.0",
2362
+ "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
2363
+ "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
2364
+ "dev": true,
2365
+ "engines": {
2366
+ "iojs": ">=1.0.0",
2367
+ "node": ">=0.10.0"
2368
+ }
2369
+ },
2370
+ "node_modules/rollup": {
2371
+ "version": "4.60.1",
2372
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz",
2373
+ "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==",
2374
+ "dev": true,
2375
+ "dependencies": {
2376
+ "@types/estree": "1.0.8"
2377
+ },
2378
+ "bin": {
2379
+ "rollup": "dist/bin/rollup"
2380
+ },
2381
+ "engines": {
2382
+ "node": ">=18.0.0",
2383
+ "npm": ">=8.0.0"
2384
+ },
2385
+ "optionalDependencies": {
2386
+ "@rollup/rollup-android-arm-eabi": "4.60.1",
2387
+ "@rollup/rollup-android-arm64": "4.60.1",
2388
+ "@rollup/rollup-darwin-arm64": "4.60.1",
2389
+ "@rollup/rollup-darwin-x64": "4.60.1",
2390
+ "@rollup/rollup-freebsd-arm64": "4.60.1",
2391
+ "@rollup/rollup-freebsd-x64": "4.60.1",
2392
+ "@rollup/rollup-linux-arm-gnueabihf": "4.60.1",
2393
+ "@rollup/rollup-linux-arm-musleabihf": "4.60.1",
2394
+ "@rollup/rollup-linux-arm64-gnu": "4.60.1",
2395
+ "@rollup/rollup-linux-arm64-musl": "4.60.1",
2396
+ "@rollup/rollup-linux-loong64-gnu": "4.60.1",
2397
+ "@rollup/rollup-linux-loong64-musl": "4.60.1",
2398
+ "@rollup/rollup-linux-ppc64-gnu": "4.60.1",
2399
+ "@rollup/rollup-linux-ppc64-musl": "4.60.1",
2400
+ "@rollup/rollup-linux-riscv64-gnu": "4.60.1",
2401
+ "@rollup/rollup-linux-riscv64-musl": "4.60.1",
2402
+ "@rollup/rollup-linux-s390x-gnu": "4.60.1",
2403
+ "@rollup/rollup-linux-x64-gnu": "4.60.1",
2404
+ "@rollup/rollup-linux-x64-musl": "4.60.1",
2405
+ "@rollup/rollup-openbsd-x64": "4.60.1",
2406
+ "@rollup/rollup-openharmony-arm64": "4.60.1",
2407
+ "@rollup/rollup-win32-arm64-msvc": "4.60.1",
2408
+ "@rollup/rollup-win32-ia32-msvc": "4.60.1",
2409
+ "@rollup/rollup-win32-x64-gnu": "4.60.1",
2410
+ "@rollup/rollup-win32-x64-msvc": "4.60.1",
2411
+ "fsevents": "~2.3.2"
2412
+ }
2413
+ },
2414
+ "node_modules/run-parallel": {
2415
+ "version": "1.2.0",
2416
+ "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
2417
+ "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
2418
+ "dev": true,
2419
+ "funding": [
2420
+ {
2421
+ "type": "github",
2422
+ "url": "https://github.com/sponsors/feross"
2423
+ },
2424
+ {
2425
+ "type": "patreon",
2426
+ "url": "https://www.patreon.com/feross"
2427
+ },
2428
+ {
2429
+ "type": "consulting",
2430
+ "url": "https://feross.org/support"
2431
+ }
2432
+ ],
2433
+ "dependencies": {
2434
+ "queue-microtask": "^1.2.2"
2435
+ }
2436
+ },
2437
+ "node_modules/scheduler": {
2438
+ "version": "0.23.2",
2439
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
2440
+ "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
2441
+ "dependencies": {
2442
+ "loose-envify": "^1.1.0"
2443
+ }
2444
+ },
2445
+ "node_modules/semver": {
2446
+ "version": "6.3.1",
2447
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
2448
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
2449
+ "dev": true,
2450
+ "bin": {
2451
+ "semver": "bin/semver.js"
2452
+ }
2453
+ },
2454
+ "node_modules/source-map-js": {
2455
+ "version": "1.2.1",
2456
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
2457
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
2458
+ "dev": true,
2459
+ "engines": {
2460
+ "node": ">=0.10.0"
2461
+ }
2462
+ },
2463
+ "node_modules/sucrase": {
2464
+ "version": "3.35.1",
2465
+ "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
2466
+ "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==",
2467
+ "dev": true,
2468
+ "dependencies": {
2469
+ "@jridgewell/gen-mapping": "^0.3.2",
2470
+ "commander": "^4.0.0",
2471
+ "lines-and-columns": "^1.1.6",
2472
+ "mz": "^2.7.0",
2473
+ "pirates": "^4.0.1",
2474
+ "tinyglobby": "^0.2.11",
2475
+ "ts-interface-checker": "^0.1.9"
2476
+ },
2477
+ "bin": {
2478
+ "sucrase": "bin/sucrase",
2479
+ "sucrase-node": "bin/sucrase-node"
2480
+ },
2481
+ "engines": {
2482
+ "node": ">=16 || 14 >=14.17"
2483
+ }
2484
+ },
2485
+ "node_modules/supports-preserve-symlinks-flag": {
2486
+ "version": "1.0.0",
2487
+ "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
2488
+ "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
2489
+ "dev": true,
2490
+ "engines": {
2491
+ "node": ">= 0.4"
2492
+ },
2493
+ "funding": {
2494
+ "url": "https://github.com/sponsors/ljharb"
2495
+ }
2496
+ },
2497
+ "node_modules/tailwindcss": {
2498
+ "version": "3.4.19",
2499
+ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
2500
+ "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
2501
+ "dev": true,
2502
+ "dependencies": {
2503
+ "@alloc/quick-lru": "^5.2.0",
2504
+ "arg": "^5.0.2",
2505
+ "chokidar": "^3.6.0",
2506
+ "didyoumean": "^1.2.2",
2507
+ "dlv": "^1.1.3",
2508
+ "fast-glob": "^3.3.2",
2509
+ "glob-parent": "^6.0.2",
2510
+ "is-glob": "^4.0.3",
2511
+ "jiti": "^1.21.7",
2512
+ "lilconfig": "^3.1.3",
2513
+ "micromatch": "^4.0.8",
2514
+ "normalize-path": "^3.0.0",
2515
+ "object-hash": "^3.0.0",
2516
+ "picocolors": "^1.1.1",
2517
+ "postcss": "^8.4.47",
2518
+ "postcss-import": "^15.1.0",
2519
+ "postcss-js": "^4.0.1",
2520
+ "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0",
2521
+ "postcss-nested": "^6.2.0",
2522
+ "postcss-selector-parser": "^6.1.2",
2523
+ "resolve": "^1.22.8",
2524
+ "sucrase": "^3.35.0"
2525
+ },
2526
+ "bin": {
2527
+ "tailwind": "lib/cli.js",
2528
+ "tailwindcss": "lib/cli.js"
2529
+ },
2530
+ "engines": {
2531
+ "node": ">=14.0.0"
2532
+ }
2533
+ },
2534
+ "node_modules/thenify": {
2535
+ "version": "3.3.1",
2536
+ "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
2537
+ "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==",
2538
+ "dev": true,
2539
+ "dependencies": {
2540
+ "any-promise": "^1.0.0"
2541
+ }
2542
+ },
2543
+ "node_modules/thenify-all": {
2544
+ "version": "1.6.0",
2545
+ "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz",
2546
+ "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==",
2547
+ "dev": true,
2548
+ "dependencies": {
2549
+ "thenify": ">= 3.1.0 < 4"
2550
+ },
2551
+ "engines": {
2552
+ "node": ">=0.8"
2553
+ }
2554
+ },
2555
+ "node_modules/tinyglobby": {
2556
+ "version": "0.2.15",
2557
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
2558
+ "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
2559
+ "dev": true,
2560
+ "dependencies": {
2561
+ "fdir": "^6.5.0",
2562
+ "picomatch": "^4.0.3"
2563
+ },
2564
+ "engines": {
2565
+ "node": ">=12.0.0"
2566
+ },
2567
+ "funding": {
2568
+ "url": "https://github.com/sponsors/SuperchupuDev"
2569
+ }
2570
+ },
2571
+ "node_modules/tinyglobby/node_modules/fdir": {
2572
+ "version": "6.5.0",
2573
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
2574
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
2575
+ "dev": true,
2576
+ "engines": {
2577
+ "node": ">=12.0.0"
2578
+ },
2579
+ "peerDependencies": {
2580
+ "picomatch": "^3 || ^4"
2581
+ },
2582
+ "peerDependenciesMeta": {
2583
+ "picomatch": {
2584
+ "optional": true
2585
+ }
2586
+ }
2587
+ },
2588
+ "node_modules/tinyglobby/node_modules/picomatch": {
2589
+ "version": "4.0.4",
2590
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
2591
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
2592
+ "dev": true,
2593
+ "engines": {
2594
+ "node": ">=12"
2595
+ },
2596
+ "funding": {
2597
+ "url": "https://github.com/sponsors/jonschlinkert"
2598
+ }
2599
+ },
2600
+ "node_modules/to-regex-range": {
2601
+ "version": "5.0.1",
2602
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
2603
+ "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
2604
+ "dev": true,
2605
+ "dependencies": {
2606
+ "is-number": "^7.0.0"
2607
+ },
2608
+ "engines": {
2609
+ "node": ">=8.0"
2610
+ }
2611
+ },
2612
+ "node_modules/ts-interface-checker": {
2613
+ "version": "0.1.13",
2614
+ "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
2615
+ "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
2616
+ "dev": true
2617
+ },
2618
+ "node_modules/update-browserslist-db": {
2619
+ "version": "1.2.3",
2620
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
2621
+ "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
2622
+ "dev": true,
2623
+ "funding": [
2624
+ {
2625
+ "type": "opencollective",
2626
+ "url": "https://opencollective.com/browserslist"
2627
+ },
2628
+ {
2629
+ "type": "tidelift",
2630
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
2631
+ },
2632
+ {
2633
+ "type": "github",
2634
+ "url": "https://github.com/sponsors/ai"
2635
+ }
2636
+ ],
2637
+ "dependencies": {
2638
+ "escalade": "^3.2.0",
2639
+ "picocolors": "^1.1.1"
2640
+ },
2641
+ "bin": {
2642
+ "update-browserslist-db": "cli.js"
2643
+ },
2644
+ "peerDependencies": {
2645
+ "browserslist": ">= 4.21.0"
2646
+ }
2647
+ },
2648
+ "node_modules/util-deprecate": {
2649
+ "version": "1.0.2",
2650
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
2651
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
2652
+ "dev": true
2653
+ },
2654
+ "node_modules/vite": {
2655
+ "version": "5.4.21",
2656
+ "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
2657
+ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
2658
+ "dev": true,
2659
+ "dependencies": {
2660
+ "esbuild": "^0.21.3",
2661
+ "postcss": "^8.4.43",
2662
+ "rollup": "^4.20.0"
2663
+ },
2664
+ "bin": {
2665
+ "vite": "bin/vite.js"
2666
+ },
2667
+ "engines": {
2668
+ "node": "^18.0.0 || >=20.0.0"
2669
+ },
2670
+ "funding": {
2671
+ "url": "https://github.com/vitejs/vite?sponsor=1"
2672
+ },
2673
+ "optionalDependencies": {
2674
+ "fsevents": "~2.3.3"
2675
+ },
2676
+ "peerDependencies": {
2677
+ "@types/node": "^18.0.0 || >=20.0.0",
2678
+ "less": "*",
2679
+ "lightningcss": "^1.21.0",
2680
+ "sass": "*",
2681
+ "sass-embedded": "*",
2682
+ "stylus": "*",
2683
+ "sugarss": "*",
2684
+ "terser": "^5.4.0"
2685
+ },
2686
+ "peerDependenciesMeta": {
2687
+ "@types/node": {
2688
+ "optional": true
2689
+ },
2690
+ "less": {
2691
+ "optional": true
2692
+ },
2693
+ "lightningcss": {
2694
+ "optional": true
2695
+ },
2696
+ "sass": {
2697
+ "optional": true
2698
+ },
2699
+ "sass-embedded": {
2700
+ "optional": true
2701
+ },
2702
+ "stylus": {
2703
+ "optional": true
2704
+ },
2705
+ "sugarss": {
2706
+ "optional": true
2707
+ },
2708
+ "terser": {
2709
+ "optional": true
2710
+ }
2711
+ }
2712
+ },
2713
+ "node_modules/yallist": {
2714
+ "version": "3.1.1",
2715
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
2716
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
2717
+ "dev": true
2718
+ }
2719
+ }
2720
+ }
frontend/package.json ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "medical-predictor-frontend",
3
+ "private": true,
4
+ "version": "0.0.1",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "vite build",
9
+ "preview": "vite preview"
10
+ },
11
+ "dependencies": {
12
+ "react": "^18.2.0",
13
+ "react-dom": "^18.2.0",
14
+ "axios": "^1.6.0",
15
+ "lucide-react": "^0.294.0"
16
+ },
17
+ "devDependencies": {
18
+ "@types/react": "^18.2.0",
19
+ "@types/react-dom": "^18.2.0",
20
+ "@vitejs/plugin-react": "^4.2.1",
21
+ "vite": "^5.0.0",
22
+ "tailwindcss": "^3.3.0",
23
+ "postcss": "^8.4.31",
24
+ "autoprefixer": "^10.4.16"
25
+ }
26
+ }
frontend/postcss.config.js ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ export default {
2
+ plugins: {
3
+ tailwindcss: {},
4
+ autoprefixer: {},
5
+ },
6
+ }
frontend/src/App.jsx ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from 'react'
2
+ import useChat from './hooks/useChat'
3
+ import FeatureSidebar from './components/FeatureSidebar'
4
+ import ChatWindow from './components/ChatWindow'
5
+ import InputBar from './components/InputBar'
6
+
7
+ function App() {
8
+ const { messages, features, loading, prediction, sendMessage, resetChat } = useChat()
9
+ const [showConfirm, setShowConfirm] = useState(false)
10
+
11
+ const handleReset = async () => {
12
+ setShowConfirm(true)
13
+ }
14
+
15
+ const confirmReset = async () => {
16
+ setShowConfirm(false)
17
+ await resetChat()
18
+ }
19
+
20
+ return (
21
+ <div className="flex h-screen bg-white overflow-hidden">
22
+ {/* Sidebar with feature tracking */}
23
+ <FeatureSidebar features={features} />
24
+
25
+ {/* Main chat area */}
26
+ <div className="flex-1 flex flex-col">
27
+ {/* Chat messages */}
28
+ <ChatWindow messages={messages} prediction={prediction} />
29
+
30
+ {/* Input bar */}
31
+ <InputBar
32
+ onSend={sendMessage}
33
+ onReset={handleReset}
34
+ disabled={loading}
35
+ />
36
+ </div>
37
+
38
+ {/* Reset Confirmation Modal */}
39
+ {showConfirm && (
40
+ <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
41
+ <div className="bg-white rounded-lg shadow-xl p-6 max-w-sm mx-4">
42
+ <h3 className="text-lg font-semibold text-gray-900 mb-2">Start New Assessment?</h3>
43
+ <p className="text-gray-600 mb-6">
44
+ This will clear all your current health data and start a fresh assessment.
45
+ </p>
46
+ <div className="flex gap-3 justify-end">
47
+ <button
48
+ onClick={() => setShowConfirm(false)}
49
+ className="px-4 py-2 rounded-lg border border-gray-300 text-gray-700 hover:bg-gray-50 font-medium transition-colors"
50
+ >
51
+ Cancel
52
+ </button>
53
+ <button
54
+ onClick={confirmReset}
55
+ className="px-4 py-2 rounded-lg bg-purple-600 hover:bg-purple-700 text-white font-medium transition-colors"
56
+ >
57
+ Start New
58
+ </button>
59
+ </div>
60
+ </div>
61
+ </div>
62
+ )}
63
+ </div>
64
+ )
65
+ }
66
+
67
+ export default App
frontend/src/components/ChatWindow.jsx ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useRef } from 'react'
2
+ import MessageBubble from './MessageBubble'
3
+ import DiagnosisCard from './DiagnosisCard'
4
+
5
+ const ChatWindow = ({ messages, prediction }) => {
6
+ const messagesEndRef = useRef(null)
7
+
8
+ useEffect(() => {
9
+ // Auto-scroll to bottom on new message
10
+ messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
11
+ }, [messages, prediction])
12
+
13
+ return (
14
+ <div className="flex-1 overflow-y-auto bg-white px-6 py-6">
15
+ <div className="max-w-2xl mx-auto">
16
+ {messages.length === 0 && (
17
+ <div className="flex items-center justify-center h-full text-center">
18
+ <div>
19
+ <h2 className="text-3xl font-bold text-gray-800 mb-3">Medical Diagnosis AI</h2>
20
+ <p className="text-gray-500 mb-2">Your personalized health assessment</p>
21
+ <p className="text-xs text-gray-400 max-w-md mx-auto">
22
+ Answer health questions to receive a personalized diagnosis assessment.
23
+ All information is secure and used only for analysis.
24
+ </p>
25
+ </div>
26
+ </div>
27
+ )}
28
+
29
+ {messages.map((msg, idx) => (
30
+ <MessageBubble key={idx} role={msg.role} content={msg.content} />
31
+ ))}
32
+
33
+ {/* Show diagnosis card when prediction is complete */}
34
+ {prediction && (
35
+ <div className="mt-6 mb-4">
36
+ <DiagnosisCard diagnosis={prediction} />
37
+ </div>
38
+ )}
39
+
40
+ <div ref={messagesEndRef} />
41
+ </div>
42
+ </div>
43
+ )
44
+ }
45
+
46
+ export default ChatWindow
frontend/src/components/DiagnosisCard.jsx ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * DiagnosisCard Component
3
+ *
4
+ * Professional diagnosis result card with:
5
+ * - Light background with professional styling
6
+ * - Confidence gauge with progress bar
7
+ * - Comprehensive Patient Data Report grid
8
+ * - Clean, focused layout
9
+ */
10
+ const DiagnosisCard = ({ diagnosis }) => {
11
+ if (!diagnosis) return null
12
+
13
+ const {
14
+ prediction_name,
15
+ confidence,
16
+ explanation,
17
+ features = {},
18
+ } = diagnosis
19
+
20
+ // Calculate confidence percentage
21
+ const confidencePercent = (confidence * 100).toFixed(1)
22
+
23
+ // Feature display labels for the Patient Data Report
24
+ const featureLabels = {
25
+ Age: 'Age',
26
+ Glucose: 'Blood Glucose',
27
+ HbA1c: 'HbA1c',
28
+ BMI: 'BMI',
29
+ Cholesterol: 'Cholesterol',
30
+ Triglycerides: 'Triglycerides',
31
+ BloodPressure: 'Blood Pressure',
32
+ PhysicalActivity: 'Physical Activity',
33
+ SleepHours: 'Sleep Hours',
34
+ StressLevel: 'Stress Level',
35
+ DietScore: 'Diet Score',
36
+ Smoking: 'Smoking',
37
+ Alcohol: 'Alcohol',
38
+ FamilyHistory: 'Family History',
39
+ LengthOfStay: 'Length of Stay',
40
+ OxygenSaturation: 'Oxygen Saturation',
41
+ }
42
+
43
+ // Get progress bar color based on confidence
44
+ const getProgressBarColor = () => {
45
+ if (confidencePercent >= 80) return 'bg-green-500'
46
+ if (confidencePercent >= 60) return 'bg-amber-500'
47
+ return 'bg-red-500'
48
+ }
49
+
50
+ return (
51
+ <div className="rounded-2xl p-6 border-2 bg-slate-50 border-slate-200">
52
+ {/* Header */}
53
+ <h2 className="text-xl font-bold text-slate-900 mb-4">
54
+ Health Assessment Summary
55
+ </h2>
56
+
57
+ {/* Diagnosis Display */}
58
+ <div className="mb-6">
59
+ <p className="text-sm font-medium text-slate-600 mb-2">Diagnosis</p>
60
+ <h3 className="text-3xl font-bold text-slate-900">
61
+ {prediction_name}
62
+ </h3>
63
+ </div>
64
+
65
+ {/* Confidence Gauge */}
66
+ <div className="mb-6">
67
+ <div className="flex items-center justify-between mb-2">
68
+ <p className="text-sm font-medium text-slate-700">Confidence Level</p>
69
+ <p className="text-sm font-bold text-slate-900">{confidencePercent}%</p>
70
+ </div>
71
+
72
+ {/* Progress Bar */}
73
+ <div className="w-full h-2 bg-slate-200 rounded-full overflow-hidden">
74
+ <div
75
+ className={`h-full ${getProgressBarColor()} transition-all duration-500`}
76
+ style={{ width: `${confidencePercent}%` }}
77
+ />
78
+ </div>
79
+
80
+ {/* Confidence Description */}
81
+ <p className="text-xs text-slate-500 mt-2">
82
+ {confidencePercent >= 80
83
+ ? 'High confidence prediction'
84
+ : confidencePercent >= 60
85
+ ? 'Moderate confidence prediction'
86
+ : 'Lower confidence prediction - consult healthcare provider'}
87
+ </p>
88
+ </div>
89
+
90
+ {/* Explanation */}
91
+ <div className="bg-white/50 rounded-lg p-4 border border-slate-200 mb-6">
92
+ <p className="text-sm text-slate-700 leading-relaxed">
93
+ {explanation}
94
+ </p>
95
+ </div>
96
+
97
+ {/* Patient Data Report */}
98
+ <div className="border-t border-slate-200 mt-4 pt-4">
99
+ <h3 className="text-lg font-bold text-slate-900 mb-4">Patient Data Report</h3>
100
+
101
+ <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
102
+ {Object.entries(features).map(([key, value]) => {
103
+ // Get the display label for this feature
104
+ const displayLabel = featureLabels[key] || key
105
+
106
+ return (
107
+ <div key={key} className="space-y-1">
108
+ <p className="text-xs font-medium text-slate-500 uppercase tracking-wider">
109
+ {displayLabel}
110
+ </p>
111
+ <p className="text-sm font-semibold text-slate-900">
112
+ {value !== null && value !== undefined ? String(value) : 'β€”'}
113
+ </p>
114
+ </div>
115
+ )
116
+ })}
117
+ </div>
118
+ </div>
119
+ </div>
120
+ )
121
+ }
122
+
123
+ export default DiagnosisCard
frontend/src/components/FeatureSidebar.jsx ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import MetricItem from './MetricItem'
2
+
3
+ const FEATURE_LABELS = {
4
+ 'Age': 'Age',
5
+ 'Glucose': 'Blood Glucose',
6
+ 'HbA1c': 'HbA1c',
7
+ 'BMI': 'BMI',
8
+ 'Cholesterol': 'Cholesterol',
9
+ 'Triglycerides': 'Triglycerides',
10
+ 'Blood Pressure': 'Blood Pressure',
11
+ 'Physical Activity': 'Physical Activity',
12
+ 'Sleep Hours': 'Sleep Hours',
13
+ 'Stress Level': 'Stress Level',
14
+ 'Diet Score': 'Diet Score',
15
+ 'Smoking': 'Smoking',
16
+ 'Alcohol': 'Alcohol',
17
+ 'Family History': 'Family History',
18
+ 'LengthOfStay': 'Length of Stay',
19
+ 'Oxygen Saturation': 'Oxygen Saturation',
20
+ }
21
+
22
+ const FEATURE_DEFINITIONS = {
23
+ 'Age': 'Your current age in years.',
24
+ 'Glucose': 'Current blood sugar level (mg/dL).',
25
+ 'HbA1c': 'Average blood sugar over the last 3 months (%).',
26
+ 'BMI': 'Body Mass Index; a measure of body fat based on height and weight.',
27
+ 'Cholesterol': 'Total amount of cholesterol in your blood (mg/dL).',
28
+ 'Triglycerides': 'A type of fat found in your blood (mg/dL).',
29
+ 'Blood Pressure': 'The force of your blood against artery walls (e.g., 120).',
30
+ 'Physical Activity': 'Total hours of exercise or active movement per week.',
31
+ 'Sleep Hours': 'Average hours of sleep you get per 24-hour period.',
32
+ 'Stress Level': '1-3 (Low): Calm, in control. 4-6 (Moderate): Busy, pressured. 7-8 (High): Anxious, overwhelmed. 9-10 (Very High): Exhausted, unable to cope.',
33
+ 'Diet Score': 'Self-rating of how healthy your meals are (1-10).',
34
+ 'Smoking': 'Whether you currently smoke or have a history of smoking.',
35
+ 'Alcohol': 'Your frequency of alcohol consumption.',
36
+ 'Family History': 'Whether close relatives have had chronic conditions like heart disease or diabetes.',
37
+ 'LengthOfStay': 'Total days spent in a hospital during your last visit.',
38
+ 'Oxygen Saturation': 'The percentage of oxygen in your blood (SpO2).',
39
+ }
40
+
41
+ const DEFAULT_FEATURES = [
42
+ 'Age', 'Glucose', 'HbA1c', 'BMI',
43
+ 'Cholesterol', 'Triglycerides', 'Blood Pressure', 'Physical Activity',
44
+ 'Sleep Hours', 'Stress Level', 'Diet Score', 'Smoking',
45
+ 'Alcohol', 'Family History', 'LengthOfStay', 'Oxygen Saturation'
46
+ ]
47
+
48
+ const FeatureSidebar = ({ features }) => {
49
+ const collectedCount = DEFAULT_FEATURES.filter(
50
+ feature => features[feature] !== null && features[feature] !== undefined
51
+ ).length
52
+
53
+ const progressPercent = (collectedCount / 16) * 100
54
+
55
+ return (
56
+ <div className="w-72 bg-gray-50 border-r border-gray-200 flex flex-col h-full">
57
+ {/* Header */}
58
+ <div className="p-4 border-b border-gray-200">
59
+ <h2 className="text-sm font-semibold text-gray-900 mb-3">Health Metrics</h2>
60
+
61
+ {/* Progress bar */}
62
+ <div className="mb-2">
63
+ <div className="flex justify-between mb-1">
64
+ <span className="text-xs text-gray-600">{collectedCount}/16</span>
65
+ <span className="text-xs text-gray-600">{Math.round(progressPercent)}%</span>
66
+ </div>
67
+ <div className="w-full h-2 bg-gray-200 rounded-full overflow-hidden">
68
+ <div
69
+ className="h-full bg-purple-600 transition-all duration-300"
70
+ style={{ width: `${progressPercent}%` }}
71
+ />
72
+ </div>
73
+ </div>
74
+ </div>
75
+
76
+ {/* Features list */}
77
+ <div className="flex-1 overflow-y-auto px-4 py-3">
78
+ <div className="space-y-2">
79
+ {DEFAULT_FEATURES.map(feature => {
80
+ const isCollected = features[feature] !== null && features[feature] !== undefined
81
+ const displayLabel = FEATURE_LABELS[feature] || feature
82
+ const definition = FEATURE_DEFINITIONS[feature] || 'No description available.'
83
+
84
+ return (
85
+ <MetricItem
86
+ key={feature}
87
+ feature={feature}
88
+ displayLabel={displayLabel}
89
+ definition={definition}
90
+ isCollected={isCollected}
91
+ />
92
+ )
93
+ })}
94
+ </div>
95
+
96
+ {/* Help text at bottom */}
97
+ <div className="mt-4 pt-3 border-t border-gray-200 text-xs text-gray-500">
98
+ <p>πŸ’‘ <strong>Tip:</strong> Hover over or click the <span className="text-gray-600">β“˜</span> icon next to each metric to learn what it measures.</p>
99
+ </div>
100
+ </div>
101
+ </div>
102
+ )
103
+ }
104
+
105
+ export default FeatureSidebar
frontend/src/components/FeatureTooltip.jsx ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useRef, useEffect } from 'react'
2
+ import { Info, X } from 'lucide-react'
3
+
4
+ const FeatureTooltip = ({ feature, definition }) => {
5
+ const [isOpen, setIsOpen] = useState(false)
6
+ const [position, setPosition] = useState({ top: 0, left: 0 })
7
+ const iconRef = useRef(null)
8
+ const tooltipRef = useRef(null)
9
+
10
+ // Handle tooltip positioning to stay visible
11
+ useEffect(() => {
12
+ if (!isOpen || !iconRef.current || !tooltipRef.current) return
13
+
14
+ const calculatePosition = () => {
15
+ const iconRect = iconRef.current.getBoundingClientRect()
16
+ const tooltipRect = tooltipRef.current.getBoundingClientRect()
17
+
18
+ let top = iconRect.top - 8
19
+ let left = iconRect.right + 12
20
+
21
+ // Adjust if tooltip goes off-screen to the right
22
+ if (left + tooltipRect.width > window.innerWidth - 10) {
23
+ left = iconRect.left - tooltipRect.width - 12
24
+ }
25
+
26
+ // Adjust if tooltip goes off-screen at the bottom
27
+ if (top + tooltipRect.height > window.innerHeight - 10) {
28
+ top = window.innerHeight - tooltipRect.height - 10
29
+ }
30
+
31
+ // Adjust if tooltip goes off-screen at the top
32
+ if (top < 10) {
33
+ top = 10
34
+ }
35
+
36
+ setPosition({ top, left })
37
+ }
38
+
39
+ calculatePosition()
40
+
41
+ // Recalculate on window resize
42
+ window.addEventListener('resize', calculatePosition)
43
+ return () => window.removeEventListener('resize', calculatePosition)
44
+ }, [isOpen])
45
+
46
+ // Close tooltip when clicking outside
47
+ useEffect(() => {
48
+ if (!isOpen) return
49
+
50
+ const handleClickOutside = (e) => {
51
+ if (
52
+ tooltipRef.current &&
53
+ !tooltipRef.current.contains(e.target) &&
54
+ iconRef.current &&
55
+ !iconRef.current.contains(e.target)
56
+ ) {
57
+ setIsOpen(false)
58
+ }
59
+ }
60
+
61
+ document.addEventListener('mousedown', handleClickOutside)
62
+ return () => document.removeEventListener('mousedown', handleClickOutside)
63
+ }, [isOpen])
64
+
65
+ const handleIconClick = (e) => {
66
+ e.stopPropagation()
67
+ setIsOpen(!isOpen)
68
+ }
69
+
70
+ const handleIconHover = (open) => {
71
+ // Only auto-close on hover leave for desktop, not on click
72
+ if (open) {
73
+ setIsOpen(true)
74
+ }
75
+ }
76
+
77
+ return (
78
+ <div className="relative inline-block">
79
+ {/* Info Icon */}
80
+ <button
81
+ ref={iconRef}
82
+ onClick={handleIconClick}
83
+ onMouseEnter={() => handleIconHover(true)}
84
+ onMouseLeave={() => {
85
+ // Auto-close on hover only if opened via hover (not click)
86
+ if (!isOpen) return
87
+ // Check if it was opened by click or hover
88
+ const isClickOpened = isOpen && true
89
+ // Keep it open if clicked, close if just hovered
90
+ }}
91
+ className="p-1 rounded-full text-gray-400 hover:text-gray-600 hover:bg-gray-100 transition-all duration-150 cursor-help"
92
+ title="Click or hover for definition"
93
+ aria-label={`Information about ${feature}`}
94
+ >
95
+ <Info size={14} className="flex-shrink-0" />
96
+ </button>
97
+
98
+ {/* Tooltip */}
99
+ {isOpen && (
100
+ <div
101
+ ref={tooltipRef}
102
+ className="fixed max-w-xs bg-slate-800 text-white rounded-lg shadow-xl p-3 text-xs leading-relaxed z-50 border border-slate-700 animate-in fade-in duration-200"
103
+ style={{
104
+ top: `${position.top}px`,
105
+ left: `${position.left}px`,
106
+ }}
107
+ >
108
+ {/* Header with feature name and close button */}
109
+ <div className="flex items-start justify-between gap-2 mb-2">
110
+ <span className="font-semibold text-slate-200">{feature}</span>
111
+ <button
112
+ onClick={() => setIsOpen(false)}
113
+ className="flex-shrink-0 text-slate-400 hover:text-slate-200 transition-colors"
114
+ aria-label="Close tooltip"
115
+ >
116
+ <X size={14} />
117
+ </button>
118
+ </div>
119
+
120
+ {/* Definition */}
121
+ <p className="text-slate-100 leading-relaxed">{definition}</p>
122
+
123
+ {/* Arrow pointer (subtle) */}
124
+ <div className="absolute w-2 h-2 bg-slate-800 border-r border-t border-slate-700 transform -translate-x-1 -top-1 left-4 rotate-45" />
125
+ </div>
126
+ )}
127
+
128
+ {/* Hint text - visible on hover/click for mobile users */}
129
+ {!isOpen && (
130
+ <div className="absolute bottom-full right-0 mb-1 text-xs text-gray-400 whitespace-nowrap pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity">
131
+ <span className="text-gray-400 text-[10px]">Click for info</span>
132
+ </div>
133
+ )}
134
+ </div>
135
+ )
136
+ }
137
+
138
+ export default FeatureTooltip
frontend/src/components/InputBar.jsx ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useRef, useEffect } from 'react'
2
+ import { Send } from 'lucide-react'
3
+
4
+ const InputBar = ({ onSend, onReset, disabled }) => {
5
+ const [input, setInput] = useState('')
6
+ const textareaRef = useRef(null)
7
+
8
+ // Auto-resize textarea
9
+ useEffect(() => {
10
+ if (textareaRef.current) {
11
+ textareaRef.current.style.height = 'auto'
12
+ textareaRef.current.style.height = Math.min(textareaRef.current.scrollHeight, 120) + 'px'
13
+ }
14
+ }, [input])
15
+
16
+ const handleSend = async () => {
17
+ if (input.trim() && !disabled) {
18
+ await onSend(input)
19
+ setInput('')
20
+ if (textareaRef.current) {
21
+ textareaRef.current.style.height = 'auto'
22
+ }
23
+ }
24
+ }
25
+
26
+ const handleKeyDown = (e) => {
27
+ if (e.key === 'Enter' && !e.shiftKey) {
28
+ e.preventDefault()
29
+ handleSend()
30
+ }
31
+ }
32
+
33
+ return (
34
+ <div className="border-t border-gray-200 bg-white px-6 py-4 shadow-lg">
35
+ <div className="max-w-2xl mx-auto">
36
+ <div className="flex gap-3 items-end">
37
+ {/* New button */}
38
+ <button
39
+ onClick={onReset}
40
+ disabled={disabled}
41
+ className="px-4 py-2 rounded-lg border border-gray-300 text-gray-600 hover:bg-gray-50 hover:border-gray-400 text-sm font-medium transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
42
+ title="Start a new assessment"
43
+ >
44
+ New
45
+ </button>
46
+
47
+ {/* Input box */}
48
+ <div className="flex-1 border border-gray-300 rounded-xl px-4 py-2 bg-white shadow-sm hover:shadow-md focus-within:shadow-md focus-within:border-purple-400 transition-all duration-200">
49
+ <textarea
50
+ ref={textareaRef}
51
+ value={input}
52
+ onChange={(e) => setInput(e.target.value)}
53
+ onKeyDown={handleKeyDown}
54
+ placeholder="Tell me about your health..."
55
+ disabled={disabled}
56
+ className="w-full resize-none outline-none text-sm border-none p-0 bg-transparent text-gray-900 placeholder-gray-400 disabled:bg-gray-50 disabled:cursor-not-allowed"
57
+ rows="1"
58
+ />
59
+ </div>
60
+
61
+ {/* Send button */}
62
+ <button
63
+ onClick={handleSend}
64
+ disabled={disabled || !input.trim()}
65
+ className="px-4 py-2 rounded-lg bg-purple-600 hover:bg-purple-700 text-white text-sm font-medium transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 shadow-sm hover:shadow-md"
66
+ title="Send message (Enter)"
67
+ >
68
+ <Send size={16} />
69
+ </button>
70
+ </div>
71
+ <p className="text-xs text-gray-400 mt-2">
72
+ πŸ’‘ Tip: Press Shift+Enter for new line
73
+ </p>
74
+ </div>
75
+ </div>
76
+ )
77
+ }
78
+
79
+ export default InputBar
frontend/src/components/MessageBubble.jsx ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const MessageBubble = ({ role, content }) => {
2
+ if (role === 'user') {
3
+ return (
4
+ <div className="flex justify-end mb-4">
5
+ <div className="bg-white border border-gray-200 rounded-xl shadow-sm px-4 py-3 max-w-prose hover:shadow-md transition-shadow">
6
+ <p className="text-gray-900 text-sm leading-relaxed whitespace-pre-wrap break-words">
7
+ {content}
8
+ </p>
9
+ </div>
10
+ </div>
11
+ )
12
+ }
13
+
14
+ return (
15
+ <div className="flex justify-start mb-4">
16
+ <div className="bg-[#FFD1DC] rounded-xl px-4 py-3 max-w-prose shadow-sm">
17
+ <p className="text-gray-900 text-sm leading-relaxed whitespace-pre-wrap break-words">
18
+ {content}
19
+ </p>
20
+ </div>
21
+ </div>
22
+ )
23
+ }
24
+
25
+ export default MessageBubble
frontend/src/components/MetricItem.jsx ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useRef, useEffect } from 'react'
2
+ import { createPortal } from 'react-dom'
3
+ import { CheckCircle, Circle, Info, X } from 'lucide-react'
4
+
5
+ /**
6
+ * MetricItem Component
7
+ *
8
+ * Handles individual health metric with isolated hover state
9
+ * Uses React Portal to render popover outside the scrollable sidebar
10
+ * Ensures popover floats over the chat area without being clipped
11
+ */
12
+ const MetricItem = ({ feature, displayLabel, definition, isCollected }) => {
13
+ const [showHint, setShowHint] = useState(false)
14
+ const [popoverPosition, setPopoverPosition] = useState({ top: 0, left: 0 })
15
+ const iconRef = useRef(null)
16
+
17
+ // Calculate popover position based on icon location
18
+ useEffect(() => {
19
+ if (!showHint || !iconRef.current) return
20
+
21
+ const iconRect = iconRef.current.getBoundingClientRect()
22
+
23
+ // Position popover to the right of the icon, aligned with top
24
+ // Add some spacing (16px) to the right
25
+ const left = iconRect.right + 16
26
+ const top = iconRect.top - 8 // Slight vertical centering
27
+
28
+ setPopoverPosition({ top, left })
29
+ }, [showHint])
30
+
31
+ // Handle mouse leaving the metric item
32
+ const handleMouseLeave = () => {
33
+ setShowHint(false)
34
+ }
35
+
36
+ // Handle mouse entering the metric item
37
+ const handleMouseEnter = () => {
38
+ setShowHint(true)
39
+ }
40
+
41
+ // Close hint when clicking X button
42
+ const handleCloseHint = (e) => {
43
+ e.stopPropagation()
44
+ setShowHint(false)
45
+ }
46
+
47
+ return (
48
+ <>
49
+ {/* Metric Item Row */}
50
+ <div
51
+ className={`flex items-center justify-between gap-2 text-xs p-2 rounded transition-all duration-150 ${
52
+ isCollected
53
+ ? 'text-green-700 bg-green-50 hover:bg-green-100'
54
+ : 'text-gray-400 hover:bg-gray-100'
55
+ }`}
56
+ onMouseEnter={handleMouseEnter}
57
+ onMouseLeave={handleMouseLeave}
58
+ >
59
+ {/* Status icon + Label */}
60
+ <div className="flex items-center gap-2 flex-1 min-w-0">
61
+ {isCollected ? (
62
+ <CheckCircle size={16} className="flex-shrink-0" />
63
+ ) : (
64
+ <Circle size={16} className="flex-shrink-0" />
65
+ )}
66
+ <span className="truncate">{displayLabel}</span>
67
+ </div>
68
+
69
+ {/* Info Icon Button */}
70
+ <button
71
+ ref={iconRef}
72
+ onClick={() => setShowHint(!showHint)}
73
+ className="p-1 rounded-full text-gray-400 hover:text-gray-600 hover:bg-gray-200 transition-all duration-150 cursor-help flex-shrink-0"
74
+ title="Click for definition"
75
+ aria-label={`Information about ${displayLabel}`}
76
+ >
77
+ <Info size={14} />
78
+ </button>
79
+ </div>
80
+
81
+ {/* Popover - Rendered via Portal (outside sidebar) with Glassmorphism */}
82
+ {showHint &&
83
+ createPortal(
84
+ <div
85
+ className="fixed z-9999 w-80 rounded-2xl shadow-lg p-4 text-xs leading-relaxed animate-in fade-in duration-200 pointer-events-auto backdrop-blur-md"
86
+ style={{
87
+ top: `${popoverPosition.top}px`,
88
+ left: `${popoverPosition.left}px`,
89
+ backgroundColor: 'rgba(220, 252, 231, 0.75)',
90
+ borderColor: 'rgba(134, 239, 172, 0.5)',
91
+ borderWidth: '1px',
92
+ WebkitBackdropFilter: 'blur(12px)',
93
+ }}
94
+ onMouseEnter={() => setShowHint(true)}
95
+ onMouseLeave={() => setShowHint(false)}
96
+ >
97
+ {/* Header with feature name and close button */}
98
+ <div className="flex items-start justify-between gap-3 mb-3">
99
+ <span className="font-semibold text-green-900 text-sm">{displayLabel}</span>
100
+ <button
101
+ onClick={handleCloseHint}
102
+ className="flex-shrink-0 text-green-700 hover:text-green-900 transition-colors p-0.5 hover:bg-green-200/40 rounded-lg"
103
+ aria-label="Close popover"
104
+ >
105
+ <X size={16} />
106
+ </button>
107
+ </div>
108
+
109
+ {/* Definition text */}
110
+ <p className="text-green-900 leading-relaxed mb-2 font-medium">{definition}</p>
111
+
112
+ {/* Arrow pointer - points back to info icon (matches popover styling) */}
113
+ <div
114
+ className="absolute w-3 h-3 transform rotate-45"
115
+ style={{
116
+ right: '-6px',
117
+ top: `${8}px`,
118
+ backgroundColor: 'rgba(220, 252, 231, 0.85)',
119
+ borderTop: '1px solid rgba(134, 239, 172, 0.4)',
120
+ borderLeft: '1px solid rgba(134, 239, 172, 0.4)',
121
+ }}
122
+ />
123
+ </div>,
124
+ document.body
125
+ )}
126
+ </>
127
+ )
128
+ }
129
+
130
+ export default MetricItem
frontend/src/hooks/useChat.js ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from 'react'
2
+ import axios from 'axios'
3
+
4
+ const useChat = () => {
5
+ const [messages, setMessages] = useState([])
6
+ const [features, setFeatures] = useState({})
7
+ const [sessionId, setSessionId] = useState(null)
8
+ const [loading, setLoading] = useState(false)
9
+ const [prediction, setPrediction] = useState(null)
10
+
11
+ const sendMessage = async (text) => {
12
+ if (!text.trim()) return
13
+
14
+ // Add user message immediately
15
+ setMessages(prev => [...prev, { role: 'user', content: text }])
16
+ setLoading(true)
17
+
18
+ try {
19
+ // Call POST /api/chat
20
+ const res = await axios.post('/api/chat', {
21
+ session_id: sessionId,
22
+ message: text,
23
+ history: messages,
24
+ })
25
+
26
+ // Update state from response
27
+ setSessionId(res.data.session_id)
28
+ setFeatures(res.data.features)
29
+ setMessages(prev => [...prev, { role: 'assistant', content: res.data.message }])
30
+
31
+ // Store prediction if complete
32
+ if (res.data.is_complete && res.data.prediction) {
33
+ setPrediction(res.data.prediction)
34
+ }
35
+
36
+ return res.data
37
+ } catch (error) {
38
+ console.error('Error sending message:', error)
39
+ setMessages(prev => [...prev, {
40
+ role: 'assistant',
41
+ content: '❌ Error: Could not process your message. Please try again.'
42
+ }])
43
+ } finally {
44
+ setLoading(false)
45
+ }
46
+ }
47
+
48
+ const resetChat = async () => {
49
+ if (!sessionId) {
50
+ // Reset UI without API call if no session yet
51
+ setMessages([])
52
+ setFeatures({})
53
+ setPrediction(null)
54
+ return
55
+ }
56
+
57
+ try {
58
+ await axios.post('/api/reset', { session_id: sessionId })
59
+ setMessages([])
60
+ setFeatures({})
61
+ setSessionId(null)
62
+ setPrediction(null)
63
+ } catch (error) {
64
+ console.error('Error resetting session:', error)
65
+ // Still reset UI even if API call fails
66
+ setMessages([])
67
+ setFeatures({})
68
+ setSessionId(null)
69
+ setPrediction(null)
70
+ }
71
+ }
72
+
73
+ return {
74
+ messages,
75
+ features,
76
+ loading,
77
+ sessionId,
78
+ prediction,
79
+ sendMessage,
80
+ resetChat,
81
+ }
82
+ }
83
+
84
+ export default useChat
frontend/src/index.css ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
4
+
5
+ * {
6
+ margin: 0;
7
+ padding: 0;
8
+ box-sizing: border-box;
9
+ }
10
+
11
+ body {
12
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
13
+ background: #ffffff;
14
+ }
15
+
16
+ html, body, #root {
17
+ height: 100%;
18
+ }
19
+
20
+ /* Scrollbar styling */
21
+ ::-webkit-scrollbar {
22
+ width: 8px;
23
+ }
24
+
25
+ ::-webkit-scrollbar-track {
26
+ background: #f1f1f1;
27
+ }
28
+
29
+ ::-webkit-scrollbar-thumb {
30
+ background: #cbd5e1;
31
+ border-radius: 4px;
32
+ }
33
+
34
+ ::-webkit-scrollbar-thumb:hover {
35
+ background: #94a3b8;
36
+ }
37
+
38
+ /* Custom z-index for popovers - ensures they float above everything */
39
+ .z-9999 {
40
+ z-index: 9999;
41
+ }
frontend/src/main.jsx ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react'
2
+ import ReactDOM from 'react-dom/client'
3
+ import App from './App.jsx'
4
+ import './index.css'
5
+
6
+ ReactDOM.createRoot(document.getElementById('root')).render(
7
+ <React.StrictMode>
8
+ <App />
9
+ </React.StrictMode>,
10
+ )
frontend/tailwind.config.js ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** @type {import('tailwindcss').Config} */
2
+ export default {
3
+ content: [
4
+ "./index.html",
5
+ "./src/**/*.{js,jsx}",
6
+ ],
7
+ theme: {
8
+ extend: {
9
+ colors: {
10
+ purple: {
11
+ 600: '#9333ea',
12
+ 700: '#7e22ce',
13
+ },
14
+ },
15
+ },
16
+ },
17
+ plugins: [],
18
+ }
frontend/vite.config.js ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig } from 'vite'
2
+ import react from '@vitejs/plugin-react'
3
+
4
+ export default defineConfig({
5
+ plugins: [react()],
6
+ server: {
7
+ proxy: {
8
+ '/api': {
9
+ target: 'http://localhost:8000',
10
+ changeOrigin: true,
11
+ },
12
+ },
13
+ },
14
+ })
models/GradientBoosting_model.pkl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:394bcda0ead5e154434e56d53bc1f8536585c9d5a69db4a93345e2413daf99b6
3
+ size 2062313
requirements.txt ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ gradio>=4.44.1
2
+ groq
3
+ python-dotenv
4
+ pydantic
5
+ scikit-learn
6
+ joblib
7
+ numpy
8
+ huggingface_hub>=0.23.4
9
+ fastapi>=0.100.0
10
+ uvicorn[standard]>=0.23.0
11
+ python-multipart>=0.0.6
server.py ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ FastAPI server entry point for Medical Diagnosis AI
4
+
5
+ Local Development:
6
+ python server.py
7
+ Server will start on http://localhost:8000
8
+ API docs: http://localhost:8000/docs
9
+
10
+ Hugging Face Spaces / Production:
11
+ python server.py
12
+ Server will start on http://0.0.0.0:7860
13
+ Serves both API and static React frontend
14
+ """
15
+
16
+ import uvicorn
17
+ import logging
18
+ import os
19
+ from pathlib import Path
20
+ from fastapi.staticfiles import StaticFiles
21
+
22
+ # Setup logging
23
+ logging.basicConfig(
24
+ level=logging.INFO,
25
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
26
+ )
27
+ logger = logging.getLogger(__name__)
28
+
29
+
30
+ def get_server_config():
31
+ """Determine server configuration based on environment"""
32
+ # Check if running in Hugging Face Spaces
33
+ in_space = os.getenv("SPACE_ID") is not None
34
+
35
+ host = "0.0.0.0"
36
+ port = 7860 if in_space else 8000
37
+ reload = not in_space # Disable reload in production (HF Spaces)
38
+
39
+ return host, port, reload, in_space
40
+
41
+
42
+ if __name__ == "__main__":
43
+ host, port, reload, in_space = get_server_config()
44
+
45
+ # Import FastAPI app AFTER configuration
46
+ from app.api import app
47
+
48
+ # Configure static file serving for the React frontend
49
+ frontend_dist = Path(__file__).parent / "frontend" / "dist"
50
+
51
+ if frontend_dist.exists():
52
+ logger.info(f"πŸ“ Mounting static files from: {frontend_dist}")
53
+ # Mount static files at root, but BEFORE API routes are checked
54
+ # This way /api/* routes are handled by FastAPI, everything else by static files
55
+ app.mount("/", StaticFiles(directory=frontend_dist, html=True), name="static")
56
+ else:
57
+ logger.warning(f"⚠️ Frontend dist directory not found: {frontend_dist}")
58
+ logger.warning(" Run 'cd frontend && npm run build' to build the frontend")
59
+
60
+ # Log startup info
61
+ environment = "πŸš€ Hugging Face Spaces" if in_space else "πŸ’» Local Development"
62
+ logger.info(f"Starting Medical Diagnosis AI Server ({environment})")
63
+ logger.info(f"πŸ“ Server: http://{host}:{port}")
64
+ logger.info(f"🌐 Frontend: http://{host}:{port}")
65
+ logger.info(f"πŸ“Š API Base: http://{host}:{port}/api")
66
+ logger.info(f"πŸ“– API Docs: http://{host}:{port}/docs")
67
+
68
+ uvicorn.run(
69
+ "app.api:app",
70
+ host=host,
71
+ port=port,
72
+ reload=reload,
73
+ log_level="info"
74
+ )