Spaces:
Sleeping
Sleeping
Commit Β·
27158b3
0
Parent(s):
Initial lightweight hackathon submission
Browse files- .dockerignore +13 -0
- .gitignore +9 -0
- Dockerfile +45 -0
- README.md +232 -0
- app.py +104 -0
- environment.py +237 -0
- graders.py +162 -0
- inference.py +326 -0
- lifeline-ai/.gitignore +41 -0
- lifeline-ai/AGENTS.md +5 -0
- lifeline-ai/CLAUDE.md +1 -0
- lifeline-ai/README.md +129 -0
- lifeline-ai/backend/app/ai.py +347 -0
- lifeline-ai/backend/app/hf_torch.py +67 -0
- lifeline-ai/backend/app/main.py +142 -0
- lifeline-ai/backend/app/models.py +89 -0
- lifeline-ai/backend/app/store.py +110 -0
- lifeline-ai/backend/data/hospitals.json +68 -0
- lifeline-ai/backend/requirements.txt +8 -0
- lifeline-ai/backend/run.sh +5 -0
- lifeline-ai/eslint.config.mjs +18 -0
- lifeline-ai/next.config.ts +7 -0
- lifeline-ai/package-lock.json +0 -0
- lifeline-ai/package.json +26 -0
- lifeline-ai/postcss.config.mjs +7 -0
- lifeline-ai/public/file.svg +1 -0
- lifeline-ai/public/globe.svg +1 -0
- lifeline-ai/public/next.svg +1 -0
- lifeline-ai/public/vercel.svg +1 -0
- lifeline-ai/public/window.svg +1 -0
- lifeline-ai/src/app/book/page.tsx +267 -0
- lifeline-ai/src/app/favicon.ico +0 -0
- lifeline-ai/src/app/globals.css +88 -0
- lifeline-ai/src/app/hospitals/page.tsx +220 -0
- lifeline-ai/src/app/layout.tsx +35 -0
- lifeline-ai/src/app/not-found.tsx +25 -0
- lifeline-ai/src/app/page.tsx +213 -0
- lifeline-ai/src/app/results/page.tsx +206 -0
- lifeline-ai/src/components/SOSButton.tsx +142 -0
- lifeline-ai/src/components/ui.tsx +79 -0
- lifeline-ai/src/lib/api.ts +127 -0
- lifeline-ai/src/lib/demo.ts +34 -0
- lifeline-ai/tsconfig.json +34 -0
- medical_data.json +359 -0
- models.py +113 -0
- openenv.yaml +107 -0
- requirements.txt +2 -0
- tasks.py +140 -0
- test.py +42 -0
.dockerignore
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.git
|
| 2 |
+
.venv
|
| 3 |
+
__pycache__/
|
| 4 |
+
*.pyc
|
| 5 |
+
*.pyo
|
| 6 |
+
*.pyd
|
| 7 |
+
.pytest_cache/
|
| 8 |
+
.mypy_cache/
|
| 9 |
+
.ruff_cache/
|
| 10 |
+
.DS_Store
|
| 11 |
+
.idea/
|
| 12 |
+
.vscode/
|
| 13 |
+
*.log
|
.gitignore
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
node_modules/
|
| 2 |
+
.next/
|
| 3 |
+
backend/.venv/
|
| 4 |
+
venv/
|
| 5 |
+
__pycache__/
|
| 6 |
+
*.pyc
|
| 7 |
+
*.log
|
| 8 |
+
.env
|
| 9 |
+
.DS_Store
|
Dockerfile
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ββ Base image (lightweight, CPU-only) ββββββββββββββββββββββββββββββββββββββββ
|
| 2 |
+
FROM python:3.11-slim
|
| 3 |
+
|
| 4 |
+
# ββ Metadata ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 5 |
+
LABEL maintainer="MediRoute Team" \
|
| 6 |
+
env_id="mediroute-openenv-v1" \
|
| 7 |
+
version="1.0.0" \
|
| 8 |
+
description="Medical Triage and Hospital Routing OpenEnv Environment" \
|
| 9 |
+
org.opencontainers.image.title="MediRoute OpenEnv" \
|
| 10 |
+
org.opencontainers.image.licenses="MIT"
|
| 11 |
+
|
| 12 |
+
# ββ System dependencies βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 13 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 14 |
+
curl \
|
| 15 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 16 |
+
|
| 17 |
+
# ββ Python runtime defaults βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 18 |
+
ENV PYTHONDONTWRITEBYTECODE=1 \
|
| 19 |
+
PYTHONUNBUFFERED=1
|
| 20 |
+
|
| 21 |
+
# ββ Working directory βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 22 |
+
WORKDIR /app
|
| 23 |
+
|
| 24 |
+
# ββ Install Python dependencies first (cache-friendly layer) ββββββββββββββββββ
|
| 25 |
+
COPY requirements.txt .
|
| 26 |
+
RUN pip install --no-cache-dir --upgrade pip \
|
| 27 |
+
&& pip install --no-cache-dir -r requirements.txt
|
| 28 |
+
|
| 29 |
+
# ββ Copy application source safely ββββββββββββββββββββββββββββββββββββββββββββ
|
| 30 |
+
# Copying the full project avoids file-not-found build breaks and is HF-Spaces-friendly.
|
| 31 |
+
COPY . .
|
| 32 |
+
|
| 33 |
+
# ββ Non-root user for security ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 34 |
+
RUN adduser --disabled-password --gecos "" appuser
|
| 35 |
+
USER appuser
|
| 36 |
+
|
| 37 |
+
# ββ Environment variable defaults (override at runtime) βββββββββββββββββββββββ
|
| 38 |
+
ENV OPENAI_API_KEY="EMPTY" \
|
| 39 |
+
API_BASE_URL="https://api.openai.com/v1" \
|
| 40 |
+
MODEL_NAME="gpt-4o-mini" \
|
| 41 |
+
HF_TOKEN=""
|
| 42 |
+
|
| 43 |
+
# ββ Default command: run baseline inference across all tasks ββββββββββββββββββ
|
| 44 |
+
# LLM agent can be enabled by passing: --agent llm and setting API env vars.
|
| 45 |
+
CMD ["python", "-u", "inference.py", "--difficulty", "all", "--agent", "rules"]
|
README.md
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# MediRoute OpenEnv
|
| 2 |
+
|
| 3 |
+
**MediRoute OpenEnv** is a deterministic **healthcare triage + hospital routing** simulation environment designed for evaluating agent decision-making under realistic clinical constraints.
|
| 4 |
+
|
| 5 |
+
It models the end-to-end flow a real triage system must handle:
|
| 6 |
+
- interpret symptoms + vitals/labs
|
| 7 |
+
- assign severity (non-emergency β critical)
|
| 8 |
+
- route to the right specialist
|
| 9 |
+
- pick an appropriate nearby facility
|
| 10 |
+
- decide between **appointment vs ambulance escalation**
|
| 11 |
+
|
| 12 |
+
This environment is intentionally small, fully deterministic, and strongly typed so it can be used in hackathon evaluation pipelines and reproduced exactly.
|
| 13 |
+
|
| 14 |
+
---
|
| 15 |
+
|
| 16 |
+
## Why this matters (motivation + utility)
|
| 17 |
+
|
| 18 |
+
Healthcare triage is a high-stakes planning problem with:
|
| 19 |
+
- **multi-step reasoning** (severity β specialist β facility β action)
|
| 20 |
+
- **safety-critical escalation** (ambulance dispatch vs harmful delays)
|
| 21 |
+
- **real-world constraints** (limited specialists, nearby hospitals, and incomplete info)
|
| 22 |
+
|
| 23 |
+
MediRoute is useful for agent evaluation because it tests:
|
| 24 |
+
- **trajectory quality** (progressive reward shaping across steps)
|
| 25 |
+
- **loop avoidance** (duplicate actions and stalling are penalized)
|
| 26 |
+
- **robustness** (invalid actions are handled safely and deterministically)
|
| 27 |
+
- **policy compliance** (terminal actions and episode boundaries are enforced)
|
| 28 |
+
|
| 29 |
+
---
|
| 30 |
+
|
| 31 |
+
## Environment overview
|
| 32 |
+
|
| 33 |
+
- **Environment class**: `MediRouteEnv` in `environment.py`
|
| 34 |
+
- **Spec**: `openenv.yaml`
|
| 35 |
+
- **Typed interface**: `models.py` (Pydantic `Observation`, `Action`, `StepResult`)
|
| 36 |
+
- **Tasks**: `tasks.py` (`easy`, `medium`, `hard`)
|
| 37 |
+
- **Deterministic graders**: `graders.py` (`grade_step`, `grade_episode`)
|
| 38 |
+
|
| 39 |
+
OpenEnv interface methods:
|
| 40 |
+
- `reset(difficulty: str) -> Observation`
|
| 41 |
+
- `step(action: Action) -> StepResult` where `StepResult` contains:
|
| 42 |
+
- `observation` (updated `Observation`)
|
| 43 |
+
- `reward` (incremental step reward)
|
| 44 |
+
- `done` (episode termination flag)
|
| 45 |
+
- `info` (diagnostics incl. totals and termination reason)
|
| 46 |
+
- `state() -> Observation` (read-only snapshot)
|
| 47 |
+
|
| 48 |
+
---
|
| 49 |
+
|
| 50 |
+
## Tasks (real-world healthcare cases)
|
| 51 |
+
|
| 52 |
+
The tasks represent increasing clinical risk and decision complexity.
|
| 53 |
+
|
| 54 |
+
### Easy β mild illness (primary care)
|
| 55 |
+
- **Scenario**: fever + sore throat with positive strep test
|
| 56 |
+
- **Goal**: classify **low** severity, route to **General Physician**, choose an appropriate clinic, then close with appointment/guidance
|
| 57 |
+
- **Clinical realism**: routine outpatient triage with lab confirmation
|
| 58 |
+
|
| 59 |
+
### Medium β suspected acute coronary syndrome
|
| 60 |
+
- **Scenario**: crushing chest pain, hypertension, ECG ST-elevation, elevated troponin
|
| 61 |
+
- **Goal**: classify **high** severity, route to **Cardiologist**, select a cardiac-capable hospital, then close appropriately
|
| 62 |
+
- **Clinical realism**: time-sensitive cardiology routing
|
| 63 |
+
|
| 64 |
+
### Hard β critical collapse (life-threatening)
|
| 65 |
+
- **Scenario**: unresponsive patient with cyanosis and SpOβ crash
|
| 66 |
+
- **Goal**: classify **critical** severity and **dispatch ambulance** (terminal action), avoiding unsafe appointment flows
|
| 67 |
+
- **Clinical realism**: emergency escalation with irreversible harm from delay
|
| 68 |
+
|
| 69 |
+
---
|
| 70 |
+
|
| 71 |
+
## Action space
|
| 72 |
+
|
| 73 |
+
Defined in `models.py` (`VALID_ACTION_TYPES`) and mirrored in `openenv.yaml`:
|
| 74 |
+
|
| 75 |
+
- `analyze_symptoms` β classify severity (target: `low|moderate|high|critical`)
|
| 76 |
+
- `request_more_info` β ask for missing details (target optional)
|
| 77 |
+
- `recommend_specialist` β choose specialist (target: a specialist name)
|
| 78 |
+
- `select_hospital` β choose facility (target: a hospital name)
|
| 79 |
+
- `book_appointment` β close non-emergencies (target optional)
|
| 80 |
+
- `call_ambulance` β escalate emergencies (target optional)
|
| 81 |
+
- `provide_temp_guidance` β short-term guidance (target optional)
|
| 82 |
+
|
| 83 |
+
---
|
| 84 |
+
|
| 85 |
+
## Observation space
|
| 86 |
+
|
| 87 |
+
`Observation` fields (see `models.py` and `openenv.yaml`):
|
| 88 |
+
- `symptoms: str`
|
| 89 |
+
- `lab_report_summary: dict`
|
| 90 |
+
- `severity_score: float` in `[0.0, 1.0]` (updated when severity is analyzed)
|
| 91 |
+
- `location: str`
|
| 92 |
+
- `nearby_hospitals: list[str]`
|
| 93 |
+
- `available_specialists: list[str]`
|
| 94 |
+
- `previous_actions: list[str]` (canonical `"<action_type>:<target>"`)
|
| 95 |
+
|
| 96 |
+
---
|
| 97 |
+
|
| 98 |
+
## Reward shaping (non-binary, trajectory-based)
|
| 99 |
+
|
| 100 |
+
Reward is **shaped across the trajectory** (not a single binary outcome):
|
| 101 |
+
- partial credit for intermediate correct decisions (severity, specialist, hospital)
|
| 102 |
+
- penalties for unsafe or unproductive behavior (wrong routing, duplicates, stalling)
|
| 103 |
+
- episode total is clamped to `[0.0, 1.0]` for consistent scoring
|
| 104 |
+
|
| 105 |
+
Implementation:
|
| 106 |
+
- per-step reward: `graders.grade_step(task, action, previous_actions)`
|
| 107 |
+
- episode summary: `graders.grade_episode(...)`
|
| 108 |
+
- total reward clamped + tracked in `environment.py`
|
| 109 |
+
|
| 110 |
+
---
|
| 111 |
+
|
| 112 |
+
## Setup
|
| 113 |
+
|
| 114 |
+
### Local (Python)
|
| 115 |
+
|
| 116 |
+
```bash
|
| 117 |
+
cd meta
|
| 118 |
+
python -m venv .venv
|
| 119 |
+
source .venv/bin/activate
|
| 120 |
+
pip install -r requirements.txt
|
| 121 |
+
```
|
| 122 |
+
|
| 123 |
+
---
|
| 124 |
+
|
| 125 |
+
## Run the environment
|
| 126 |
+
|
| 127 |
+
### Interactive REPL (manual testing)
|
| 128 |
+
|
| 129 |
+
```bash
|
| 130 |
+
cd meta
|
| 131 |
+
python app.py --difficulty easy
|
| 132 |
+
```
|
| 133 |
+
|
| 134 |
+
### Baseline inference (LLM agent)
|
| 135 |
+
|
| 136 |
+
Environment variables:
|
| 137 |
+
- `OPENAI_API_KEY` (or `HF_TOKEN` for gated HF models)
|
| 138 |
+
- `API_BASE_URL` (defaults to OpenAI; can be any OpenAI-compatible server)
|
| 139 |
+
- `MODEL_NAME` (defaults to `gpt-4o-mini`)
|
| 140 |
+
|
| 141 |
+
```bash
|
| 142 |
+
cd meta
|
| 143 |
+
export OPENAI_API_KEY="..."
|
| 144 |
+
export API_BASE_URL="https://api.openai.com/v1"
|
| 145 |
+
export MODEL_NAME="gpt-4o-mini"
|
| 146 |
+
python inference.py --difficulty all --agent llm
|
| 147 |
+
```
|
| 148 |
+
|
| 149 |
+
### Baseline inference (deterministic rules agent)
|
| 150 |
+
|
| 151 |
+
This baseline runs **without any network calls** and is fully reproducible.
|
| 152 |
+
|
| 153 |
+
```bash
|
| 154 |
+
cd meta
|
| 155 |
+
python inference.py --difficulty all --agent rules
|
| 156 |
+
```
|
| 157 |
+
|
| 158 |
+
---
|
| 159 |
+
|
| 160 |
+
## Expected baseline scores
|
| 161 |
+
|
| 162 |
+
Because the environment and grader are deterministic:
|
| 163 |
+
- **Rules baseline** (`--agent rules`) is expected to score **1.0000** on `easy`, `medium`, and `hard`.
|
| 164 |
+
- **LLM baseline** (`--agent llm`) depends on the chosen model/endpoint, but should typically pass all tasks with a capable instruction-following model.
|
| 165 |
+
|
| 166 |
+
---
|
| 167 |
+
|
| 168 |
+
## Docker (build + run)
|
| 169 |
+
|
| 170 |
+
### Build
|
| 171 |
+
|
| 172 |
+
```bash
|
| 173 |
+
cd meta
|
| 174 |
+
docker build -t mediroute-openenv:latest .
|
| 175 |
+
```
|
| 176 |
+
|
| 177 |
+
### Run (rules baseline, no API required)
|
| 178 |
+
|
| 179 |
+
```bash
|
| 180 |
+
docker run --rm mediroute-openenv:latest python -u inference.py --difficulty all --agent rules
|
| 181 |
+
```
|
| 182 |
+
|
| 183 |
+
### Run (LLM baseline)
|
| 184 |
+
|
| 185 |
+
```bash
|
| 186 |
+
docker run --rm \
|
| 187 |
+
-e OPENAI_API_KEY="..." \
|
| 188 |
+
-e API_BASE_URL="https://api.openai.com/v1" \
|
| 189 |
+
-e MODEL_NAME="gpt-4o-mini" \
|
| 190 |
+
mediroute-openenv:latest python -u inference.py --difficulty all --agent llm
|
| 191 |
+
```
|
| 192 |
+
|
| 193 |
+
---
|
| 194 |
+
|
| 195 |
+
## Hugging Face Spaces (CPU) deployment notes
|
| 196 |
+
|
| 197 |
+
MediRoute is HF-Spaces-friendly because it is **CPU-only** and can run fully offline using the rules baseline.
|
| 198 |
+
|
| 199 |
+
Recommended Space setup:
|
| 200 |
+
- **SDK**: Docker (or Python, but Docker is easiest)
|
| 201 |
+
- **Hardware**: CPU basic
|
| 202 |
+
- **Entrypoint**: keep the default `CMD` (runs all tasks), or override to rules mode
|
| 203 |
+
|
| 204 |
+
If using Docker Spaces:
|
| 205 |
+
- add secrets as needed (`OPENAI_API_KEY` / `HF_TOKEN`)
|
| 206 |
+
- optionally set `MODEL_NAME` and `API_BASE_URL` for your endpoint
|
| 207 |
+
|
| 208 |
+
To default the Space to offline evaluation:
|
| 209 |
+
- configure it to run: `python -u inference.py --difficulty all --agent rules`
|
| 210 |
+
|
| 211 |
+
---
|
| 212 |
+
|
| 213 |
+
## Novelty (why this is different)
|
| 214 |
+
|
| 215 |
+
Compared to common OpenEnv tasks (email triage, scheduling, simple classification), MediRoute is novel because it combines:
|
| 216 |
+
- **safety-critical escalation** (ambulance dispatch logic, harmful appointment decisions)
|
| 217 |
+
- **severity inference β downstream routing** (specialist + hospital choice depends on severity)
|
| 218 |
+
- **trajectory shaping** that rewards incremental clinical reasoning and penalizes loops
|
| 219 |
+
- **healthcare-specific realism** (vitals/labs, STEMI-like signals, SpOβ collapse)
|
| 220 |
+
|
| 221 |
+
---
|
| 222 |
+
|
| 223 |
+
## Repo map
|
| 224 |
+
|
| 225 |
+
- `environment.py` β OpenEnv environment implementation (`reset/step/state`)
|
| 226 |
+
- `models.py` β Pydantic models (`Observation`, `Action`, `StepResult`)
|
| 227 |
+
- `tasks.py` β deterministic tasks (`easy|medium|hard`)
|
| 228 |
+
- `graders.py` β deterministic reward shaping and episode grading
|
| 229 |
+
- `inference.py` β baseline inference runner (`--agent llm|rules`)
|
| 230 |
+
- `app.py` β manual interactive REPL
|
| 231 |
+
- `openenv.yaml` β OpenEnv specification
|
| 232 |
+
|
app.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
app.py β Interactive entrypoint for MediRoute OpenEnv.
|
| 3 |
+
|
| 4 |
+
Run this script for a quick interactive session with the environment:
|
| 5 |
+
python app.py --difficulty easy
|
| 6 |
+
python app.py --difficulty medium
|
| 7 |
+
python app.py --difficulty hard
|
| 8 |
+
|
| 9 |
+
The script drives a simple REPL loop so you can manually test the environment
|
| 10 |
+
without running the full AI inference pipeline.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import argparse
|
| 16 |
+
import json
|
| 17 |
+
import sys
|
| 18 |
+
|
| 19 |
+
from environment import MediRouteEnv
|
| 20 |
+
from models import Action
|
| 21 |
+
from tasks import list_tasks
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def print_obs(obs) -> None:
|
| 25 |
+
sep = "β" * 60
|
| 26 |
+
print(sep)
|
| 27 |
+
print(f" π Location : {obs.location}")
|
| 28 |
+
print(f" π€ Symptoms : {obs.symptoms}")
|
| 29 |
+
print(f" π¬ Labs : {json.dumps(obs.lab_report_summary, indent=4)}")
|
| 30 |
+
print(f" β‘ Severity score : {obs.severity_score:.2f}")
|
| 31 |
+
print(f" π₯ Hospitals : {obs.nearby_hospitals}")
|
| 32 |
+
print(f" π¨ββοΈ Specialists : {obs.available_specialists}")
|
| 33 |
+
print(f" π Past actions : {obs.previous_actions or '(none)'}")
|
| 34 |
+
print(sep)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def repl(difficulty: str) -> None:
|
| 38 |
+
env = MediRouteEnv()
|
| 39 |
+
obs = env.reset(difficulty=difficulty)
|
| 40 |
+
|
| 41 |
+
print(f"\nπ₯ MediRoute OpenEnv β difficulty: {difficulty.upper()}")
|
| 42 |
+
print_obs(obs)
|
| 43 |
+
|
| 44 |
+
valid_types = [
|
| 45 |
+
"analyze_symptoms",
|
| 46 |
+
"request_more_info",
|
| 47 |
+
"recommend_specialist",
|
| 48 |
+
"select_hospital",
|
| 49 |
+
"book_appointment",
|
| 50 |
+
"call_ambulance",
|
| 51 |
+
"provide_temp_guidance",
|
| 52 |
+
]
|
| 53 |
+
print("Valid action types:", ", ".join(valid_types))
|
| 54 |
+
print("Type 'quit' to exit.\n")
|
| 55 |
+
|
| 56 |
+
while True:
|
| 57 |
+
raw = input("action_type [target]: ").strip()
|
| 58 |
+
if raw.lower() in {"quit", "exit", "q"}:
|
| 59 |
+
break
|
| 60 |
+
|
| 61 |
+
parts = raw.split(maxsplit=1)
|
| 62 |
+
action_type = parts[0]
|
| 63 |
+
target = parts[1] if len(parts) > 1 else None
|
| 64 |
+
|
| 65 |
+
action = Action(action_type=action_type, target=target)
|
| 66 |
+
result = env.step(action)
|
| 67 |
+
|
| 68 |
+
reward_sign = "+" if result.reward >= 0 else ""
|
| 69 |
+
print(f"\n Reward : {reward_sign}{result.reward:.2f}")
|
| 70 |
+
print(f" Done : {result.done}")
|
| 71 |
+
print(f" Total : {result.info.get('total_reward', 0):.2f}")
|
| 72 |
+
|
| 73 |
+
if result.done:
|
| 74 |
+
summary = result.info.get("episode_summary", {})
|
| 75 |
+
print("\nπ― Episode complete!")
|
| 76 |
+
print(json.dumps(summary, indent=4))
|
| 77 |
+
break
|
| 78 |
+
else:
|
| 79 |
+
print_obs(result.observation)
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def main() -> None:
|
| 83 |
+
parser = argparse.ArgumentParser(description="MediRoute OpenEnv interactive REPL")
|
| 84 |
+
parser.add_argument(
|
| 85 |
+
"--difficulty",
|
| 86 |
+
choices=["easy", "medium", "hard"],
|
| 87 |
+
default="easy",
|
| 88 |
+
help="Task difficulty level (default: easy)",
|
| 89 |
+
)
|
| 90 |
+
parser.add_argument("--list-tasks", action="store_true", help="List available tasks and exit")
|
| 91 |
+
|
| 92 |
+
args = parser.parse_args()
|
| 93 |
+
|
| 94 |
+
if args.list_tasks:
|
| 95 |
+
print("\nAvailable Tasks:\n")
|
| 96 |
+
for diff, desc in list_tasks().items():
|
| 97 |
+
print(f" [{diff.upper():6}] {desc}")
|
| 98 |
+
sys.exit(0)
|
| 99 |
+
|
| 100 |
+
repl(args.difficulty)
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
if __name__ == "__main__":
|
| 104 |
+
main()
|
environment.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
environment.py β Core MediRoute OpenEnv environment.
|
| 3 |
+
|
| 4 |
+
This module implements the standard OpenEnv interface:
|
| 5 |
+
env.reset(difficulty) β Observation
|
| 6 |
+
env.step(action) β StepResult
|
| 7 |
+
env.state() β Observation
|
| 8 |
+
|
| 9 |
+
The environment is fully deterministic given the same task; no randomness.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
from dataclasses import dataclass
|
| 15 |
+
from typing import Any, Dict, Optional, Tuple
|
| 16 |
+
|
| 17 |
+
from graders import grade_episode, grade_step
|
| 18 |
+
from models import Action, Observation, StepResult
|
| 19 |
+
from tasks import get_task
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@dataclass(frozen=True)
|
| 23 |
+
class DoneReason:
|
| 24 |
+
code: str
|
| 25 |
+
message: str
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class MediRouteEnv:
|
| 29 |
+
"""
|
| 30 |
+
Medical Triage and Hospital Routing simulation environment.
|
| 31 |
+
|
| 32 |
+
Follows the OpenEnv specification:
|
| 33 |
+
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 34 |
+
β reset(difficulty) β Observation β
|
| 35 |
+
β step(action) β StepResult(obs, reward, done, info)β
|
| 36 |
+
β state() β Observation (read-only snapshot) β
|
| 37 |
+
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 38 |
+
"""
|
| 39 |
+
|
| 40 |
+
# Class-level metadata (used by openenv.yaml / registry)
|
| 41 |
+
ENV_ID: str = "mediroute-openenv-v1"
|
| 42 |
+
VERSION: str = "1.0.0"
|
| 43 |
+
|
| 44 |
+
def __init__(self) -> None:
|
| 45 |
+
self._task: Dict[str, Any] = {}
|
| 46 |
+
self._obs: Observation | None = None
|
| 47 |
+
self._total_reward: float = 0.0
|
| 48 |
+
self._done: bool = False
|
| 49 |
+
self._step_count: int = 0
|
| 50 |
+
self._done_reason: Optional[DoneReason] = None
|
| 51 |
+
|
| 52 |
+
# βββββββββββββββββββββββββββββββββββββββββββββ
|
| 53 |
+
# OpenEnv Interface
|
| 54 |
+
# βββββββββββββββββββββββββββββββββββββββββββββ
|
| 55 |
+
|
| 56 |
+
def reset(self, difficulty: str = "easy") -> Observation:
|
| 57 |
+
"""
|
| 58 |
+
Initialise (or re-initialise) the environment for a new episode.
|
| 59 |
+
|
| 60 |
+
Args:
|
| 61 |
+
difficulty: One of 'easy', 'medium', 'hard'.
|
| 62 |
+
|
| 63 |
+
Returns:
|
| 64 |
+
The initial Observation the agent should act upon.
|
| 65 |
+
"""
|
| 66 |
+
self._task = get_task(difficulty)
|
| 67 |
+
self._total_reward = 0.0
|
| 68 |
+
self._done = False
|
| 69 |
+
self._done_reason = None
|
| 70 |
+
self._step_count = 0
|
| 71 |
+
|
| 72 |
+
self._obs = Observation(
|
| 73 |
+
symptoms=self._task["symptoms"],
|
| 74 |
+
lab_report_summary=self._task["lab_report_summary"],
|
| 75 |
+
severity_score=self._task["severity_score"],
|
| 76 |
+
location=self._task["location"],
|
| 77 |
+
nearby_hospitals=self._task["nearby_hospitals"],
|
| 78 |
+
available_specialists=self._task["available_specialists"],
|
| 79 |
+
previous_actions=[],
|
| 80 |
+
)
|
| 81 |
+
return self._obs
|
| 82 |
+
|
| 83 |
+
def step(self, action: Action) -> StepResult:
|
| 84 |
+
"""
|
| 85 |
+
Advance the environment by one action.
|
| 86 |
+
|
| 87 |
+
Args:
|
| 88 |
+
action: A typed Action submitted by the agent.
|
| 89 |
+
|
| 90 |
+
Returns:
|
| 91 |
+
StepResult with updated observation, step reward, done flag, and info.
|
| 92 |
+
"""
|
| 93 |
+
if self._obs is None:
|
| 94 |
+
raise RuntimeError("Environment not initialised. Call reset() first.")
|
| 95 |
+
|
| 96 |
+
if self._done:
|
| 97 |
+
return StepResult(
|
| 98 |
+
observation=self._obs,
|
| 99 |
+
reward=0.0,
|
| 100 |
+
done=True,
|
| 101 |
+
info={
|
| 102 |
+
"warning": "Episode is already done; no further steps are accepted.",
|
| 103 |
+
"total_reward": self._total_reward,
|
| 104 |
+
"done_reason": (self._done_reason.code if self._done_reason else "done"),
|
| 105 |
+
},
|
| 106 |
+
)
|
| 107 |
+
|
| 108 |
+
# ββ Validate action type βββββββββββββββββββββββββββββββββββββββββββββββ
|
| 109 |
+
if not action.validate_action_type():
|
| 110 |
+
return StepResult(
|
| 111 |
+
observation=self._obs,
|
| 112 |
+
reward=-0.10,
|
| 113 |
+
done=False,
|
| 114 |
+
info={
|
| 115 |
+
"error": f"Unknown action_type '{action.action_type}'.",
|
| 116 |
+
"total_reward": self._total_reward,
|
| 117 |
+
},
|
| 118 |
+
)
|
| 119 |
+
|
| 120 |
+
# ββ Basic action schema validation (deterministic, non-throwing) βββββββ
|
| 121 |
+
invalid_reason, target_norm = self._validate_action_semantics(action)
|
| 122 |
+
if invalid_reason:
|
| 123 |
+
# Do not mutate state for invalid semantic actions; keep episode running.
|
| 124 |
+
return StepResult(
|
| 125 |
+
observation=self._obs,
|
| 126 |
+
reward=-0.10,
|
| 127 |
+
done=False,
|
| 128 |
+
info={
|
| 129 |
+
"error": invalid_reason,
|
| 130 |
+
"total_reward": self._total_reward,
|
| 131 |
+
},
|
| 132 |
+
)
|
| 133 |
+
|
| 134 |
+
# ββ Compute incremental reward ββββββββββββββββββββββββββββββββββββββββ
|
| 135 |
+
raw_reward = grade_step(
|
| 136 |
+
task=self._task,
|
| 137 |
+
action=action,
|
| 138 |
+
previous_actions=self._obs.previous_actions,
|
| 139 |
+
)
|
| 140 |
+
|
| 141 |
+
# ββ Accumulate and clamp total reward to [0, 1] βββββββββββββββββββββββ
|
| 142 |
+
new_total = max(0.0, min(1.0, self._total_reward + raw_reward))
|
| 143 |
+
incremental_reward = new_total - self._total_reward
|
| 144 |
+
self._total_reward = new_total
|
| 145 |
+
|
| 146 |
+
# ββ Update observation: record action, update severity_score ββββββββββ
|
| 147 |
+
self._obs.previous_actions.append(action.as_key())
|
| 148 |
+
self._step_count += 1
|
| 149 |
+
|
| 150 |
+
# Reflect severity classification if agent analysed symptoms
|
| 151 |
+
if action.action_type == "analyze_symptoms" and target_norm:
|
| 152 |
+
severity_map = {"low": 0.2, "moderate": 0.5, "high": 0.75, "critical": 0.95}
|
| 153 |
+
# If an unknown target somehow slips through, do not overwrite severity.
|
| 154 |
+
if target_norm in severity_map:
|
| 155 |
+
self._obs.severity_score = severity_map[target_norm]
|
| 156 |
+
|
| 157 |
+
# ββ Determine if episode terminates βββββββββββββββββββββββββββββββββββ
|
| 158 |
+
terminal_actions = self._task.get("terminal_actions", {"book_appointment", "call_ambulance"})
|
| 159 |
+
max_steps = self._task.get("max_steps", 8)
|
| 160 |
+
|
| 161 |
+
if action.action_type in terminal_actions:
|
| 162 |
+
self._done = True
|
| 163 |
+
self._done_reason = DoneReason(
|
| 164 |
+
code="terminal_action",
|
| 165 |
+
message=f"Episode ended by terminal action: {action.action_type}.",
|
| 166 |
+
)
|
| 167 |
+
elif self._step_count >= max_steps:
|
| 168 |
+
self._done = True
|
| 169 |
+
self._done_reason = DoneReason(
|
| 170 |
+
code="max_steps",
|
| 171 |
+
message=f"Episode ended after reaching max_steps={max_steps}.",
|
| 172 |
+
)
|
| 173 |
+
|
| 174 |
+
# ββ Build info payload ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 175 |
+
info: Dict[str, Any] = {
|
| 176 |
+
"step": self._step_count,
|
| 177 |
+
"raw_step_reward": raw_reward,
|
| 178 |
+
"total_reward": self._total_reward,
|
| 179 |
+
"done": self._done,
|
| 180 |
+
"done_reason": (self._done_reason.code if self._done_reason else None),
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
if self._done:
|
| 184 |
+
info["episode_summary"] = grade_episode(
|
| 185 |
+
task=self._task,
|
| 186 |
+
all_actions=self._obs.previous_actions,
|
| 187 |
+
final_total_reward=self._total_reward,
|
| 188 |
+
)
|
| 189 |
+
|
| 190 |
+
return StepResult(
|
| 191 |
+
observation=self._obs,
|
| 192 |
+
reward=incremental_reward,
|
| 193 |
+
done=self._done,
|
| 194 |
+
info=info,
|
| 195 |
+
)
|
| 196 |
+
|
| 197 |
+
def state(self) -> Observation:
|
| 198 |
+
"""Return the current observation without advancing the environment."""
|
| 199 |
+
if self._obs is None:
|
| 200 |
+
raise RuntimeError("Environment not initialised. Call reset() first.")
|
| 201 |
+
return self._obs
|
| 202 |
+
|
| 203 |
+
# βββββββββββββββββββββββββββββββββββββββββββββ
|
| 204 |
+
# Validation helpers
|
| 205 |
+
# βββββββββββββββββββββββββββββββββββββββββββββ
|
| 206 |
+
|
| 207 |
+
def _validate_action_semantics(self, action: Action) -> Tuple[Optional[str], Optional[str]]:
|
| 208 |
+
"""
|
| 209 |
+
Validate action semantics in a deterministic, non-throwing way.
|
| 210 |
+
|
| 211 |
+
Returns:
|
| 212 |
+
(error_message_or_none, normalized_target_or_none)
|
| 213 |
+
"""
|
| 214 |
+
action_type = action.action_type
|
| 215 |
+
target = (action.target or "").strip()
|
| 216 |
+
target_norm = target.lower() if target else None
|
| 217 |
+
|
| 218 |
+
# Target requirements
|
| 219 |
+
if action_type == "analyze_symptoms":
|
| 220 |
+
if not target_norm:
|
| 221 |
+
return "analyze_symptoms requires a target severity: low|moderate|high|critical.", None
|
| 222 |
+
if target_norm not in {"low", "moderate", "high", "critical"}:
|
| 223 |
+
return "Invalid severity target for analyze_symptoms (use low|moderate|high|critical).", None
|
| 224 |
+
return None, target_norm
|
| 225 |
+
|
| 226 |
+
if action_type in {"recommend_specialist", "select_hospital"} and not target:
|
| 227 |
+
return f"{action_type} requires a non-empty target.", None
|
| 228 |
+
|
| 229 |
+
# Loop prevention / stalling guardrails (lightweight, deterministic)
|
| 230 |
+
# Excessive 'request_more_info' stalls the episode without progress.
|
| 231 |
+
if action_type == "request_more_info":
|
| 232 |
+
recent = self._obs.previous_actions[-3:] if self._obs else []
|
| 233 |
+
if sum(1 for a in recent if a.startswith("request_more_info:")) >= 2:
|
| 234 |
+
# Not invalid, but strongly discouraged: let grader penalize via duplicates/negative.
|
| 235 |
+
return None, target_norm
|
| 236 |
+
|
| 237 |
+
return None, target_norm
|
graders.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
graders.py β Reward shaping logic for MediRoute OpenEnv.
|
| 3 |
+
|
| 4 |
+
Each action is evaluated against the ground-truth task expectations.
|
| 5 |
+
Rewards are incremental per-step values; the environment accumulates and
|
| 6 |
+
clamps the episode total to [0.0, 1.0].
|
| 7 |
+
|
| 8 |
+
Reward table
|
| 9 |
+
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 10 |
+
Correct severity classification (analyze_symptoms) +0.30
|
| 11 |
+
Correct specialist recommendation +0.30
|
| 12 |
+
Correct hospital selection +0.20
|
| 13 |
+
Successful appointment booking (non-emergency) +0.20
|
| 14 |
+
Correct emergency escalation (call_ambulance) +0.50
|
| 15 |
+
Wrong department / specialist -0.20
|
| 16 |
+
Unnecessary loop / duplicate action -0.30
|
| 17 |
+
Calling ambulance on non-emergency -0.30
|
| 18 |
+
Booking appointment in emergency case -0.30
|
| 19 |
+
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
from __future__ import annotations
|
| 23 |
+
|
| 24 |
+
from typing import Any, Dict, List
|
| 25 |
+
|
| 26 |
+
from models import Action
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
# βββββββββββββββββββββββββββββββββββββββββββββ
|
| 30 |
+
# Internal helpers
|
| 31 |
+
# βββββββββββββββββββββββββββββββββββββββββββββ
|
| 32 |
+
|
| 33 |
+
def _is_duplicate(action: Action, previous_actions: List[str]) -> bool:
|
| 34 |
+
return action.as_key() in previous_actions
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
# βββββββββββββββββββββββββββββββββββββββββββββ
|
| 38 |
+
# Public API
|
| 39 |
+
# βββββββββββββββββββββββββββββββββββββββββββββ
|
| 40 |
+
|
| 41 |
+
def grade_step(
|
| 42 |
+
task: Dict[str, Any],
|
| 43 |
+
action: Action,
|
| 44 |
+
previous_actions: List[str],
|
| 45 |
+
) -> float:
|
| 46 |
+
"""
|
| 47 |
+
Compute the incremental reward for a single action taken in *task*.
|
| 48 |
+
|
| 49 |
+
Args:
|
| 50 |
+
task: The full task dict as returned by tasks.get_task().
|
| 51 |
+
action: The Action the agent wants to execute.
|
| 52 |
+
previous_actions: Actions already taken this episode (as 'type:target' strings).
|
| 53 |
+
|
| 54 |
+
Returns:
|
| 55 |
+
A float reward value (can be negative; clamping is done in the environment).
|
| 56 |
+
"""
|
| 57 |
+
|
| 58 |
+
# ββ Duplicate penalty ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 59 |
+
if _is_duplicate(action, previous_actions):
|
| 60 |
+
return -0.30
|
| 61 |
+
|
| 62 |
+
action_type = action.action_type
|
| 63 |
+
target = (action.target or "").strip()
|
| 64 |
+
|
| 65 |
+
# ββ analyze_symptoms βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 66 |
+
if action_type == "analyze_symptoms":
|
| 67 |
+
if target.lower() == task["expected_severity"].lower():
|
| 68 |
+
return 0.30
|
| 69 |
+
else:
|
| 70 |
+
return -0.10 # Incorrect severity assessment
|
| 71 |
+
|
| 72 |
+
# ββ request_more_info ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 73 |
+
elif action_type == "request_more_info":
|
| 74 |
+
# Neutral in most cases; mild reward only if no prior analysis done
|
| 75 |
+
analyzed = any(a.startswith("analyze_symptoms") for a in previous_actions)
|
| 76 |
+
return 0.05 if not analyzed else -0.05
|
| 77 |
+
|
| 78 |
+
# ββ recommend_specialist βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 79 |
+
elif action_type == "recommend_specialist":
|
| 80 |
+
if target == task["expected_specialist"]:
|
| 81 |
+
return 0.30
|
| 82 |
+
else:
|
| 83 |
+
return -0.20 # Wrong department
|
| 84 |
+
|
| 85 |
+
# ββ select_hospital ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 86 |
+
elif action_type == "select_hospital":
|
| 87 |
+
if target == task["expected_hospital"]:
|
| 88 |
+
return 0.20
|
| 89 |
+
elif target in task["nearby_hospitals"]:
|
| 90 |
+
return 0.05 # Nearby but not optimal
|
| 91 |
+
else:
|
| 92 |
+
return -0.10 # Unknown / unreachable hospital
|
| 93 |
+
|
| 94 |
+
# ββ book_appointment βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 95 |
+
elif action_type == "book_appointment":
|
| 96 |
+
if task["requires_ambulance"]:
|
| 97 |
+
# Trying to book appointment in a life-threatening emergency is wrong
|
| 98 |
+
return -0.30
|
| 99 |
+
return 0.20
|
| 100 |
+
|
| 101 |
+
# ββ call_ambulance ββοΏ½οΏ½οΏ½ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 102 |
+
elif action_type == "call_ambulance":
|
| 103 |
+
if task["requires_ambulance"]:
|
| 104 |
+
return 0.50 # Correct emergency escalation
|
| 105 |
+
else:
|
| 106 |
+
return -0.30 # Unnecessary ambulance dispatch
|
| 107 |
+
|
| 108 |
+
# ββ provide_temp_guidance βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 109 |
+
elif action_type == "provide_temp_guidance":
|
| 110 |
+
# Acceptable as a closing action for non-emergencies
|
| 111 |
+
if not task["requires_ambulance"]:
|
| 112 |
+
return 0.10
|
| 113 |
+
else:
|
| 114 |
+
return -0.10 # Not enough for a critical patient
|
| 115 |
+
|
| 116 |
+
# ββ Unknown action ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 117 |
+
return -0.10
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def grade_episode(
|
| 121 |
+
task: Dict[str, Any],
|
| 122 |
+
all_actions: List[str],
|
| 123 |
+
final_total_reward: float,
|
| 124 |
+
) -> Dict[str, Any]:
|
| 125 |
+
"""
|
| 126 |
+
Produce a final episode summary / score report.
|
| 127 |
+
|
| 128 |
+
Args:
|
| 129 |
+
task: Task dict.
|
| 130 |
+
all_actions: Full list of action keys taken during the episode.
|
| 131 |
+
final_total_reward: Accumulated clamped reward from the environment.
|
| 132 |
+
|
| 133 |
+
Returns:
|
| 134 |
+
A dict with score, pass/fail, and diagnostic breakdown.
|
| 135 |
+
"""
|
| 136 |
+
score = round(final_total_reward, 4)
|
| 137 |
+
passed = score >= 0.5
|
| 138 |
+
|
| 139 |
+
breakdown = {
|
| 140 |
+
"severity_classified": any(
|
| 141 |
+
a.startswith(f"analyze_symptoms:{task['expected_severity']}")
|
| 142 |
+
for a in all_actions
|
| 143 |
+
),
|
| 144 |
+
"correct_specialist": any(
|
| 145 |
+
a.startswith(f"recommend_specialist:{task['expected_specialist']}")
|
| 146 |
+
for a in all_actions
|
| 147 |
+
),
|
| 148 |
+
"correct_hospital": any(
|
| 149 |
+
a.startswith(f"select_hospital:{task['expected_hospital']}")
|
| 150 |
+
for a in all_actions
|
| 151 |
+
),
|
| 152 |
+
"ambulance_called": any(a.startswith("call_ambulance") for a in all_actions),
|
| 153 |
+
"appointment_booked": any(a.startswith("book_appointment") for a in all_actions),
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
return {
|
| 157 |
+
"score": score,
|
| 158 |
+
"passed": passed,
|
| 159 |
+
"difficulty": task["difficulty"],
|
| 160 |
+
"total_steps": len(all_actions),
|
| 161 |
+
"breakdown": breakdown,
|
| 162 |
+
}
|
inference.py
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
inference.py β Baseline AI agent for MediRoute OpenEnv.
|
| 3 |
+
|
| 4 |
+
Connects to any OpenAI-compatible endpoint (including Hugging Face TGI,
|
| 5 |
+
vLLM, or the official OpenAI API) and runs the agent across all three
|
| 6 |
+
difficulty tasks, printing structured logs.
|
| 7 |
+
|
| 8 |
+
Environment variables (set before running):
|
| 9 |
+
OPENAI_API_KEY β API key (use 'EMPTY' for local / HF endpoints)
|
| 10 |
+
API_BASE_URL β Base URL, e.g. https://api-inference.huggingface.co/v1
|
| 11 |
+
MODEL_NAME β Model identifier, e.g. mistralai/Mistral-7B-Instruct-v0.3
|
| 12 |
+
HF_TOKEN β (Optional) Hugging Face token for gated models
|
| 13 |
+
|
| 14 |
+
Usage:
|
| 15 |
+
python inference.py
|
| 16 |
+
python inference.py --difficulty easy
|
| 17 |
+
python inference.py --difficulty all
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
import argparse
|
| 23 |
+
import json
|
| 24 |
+
import os
|
| 25 |
+
import re
|
| 26 |
+
import sys
|
| 27 |
+
import time
|
| 28 |
+
from typing import Any, Dict, List, Optional, TYPE_CHECKING
|
| 29 |
+
|
| 30 |
+
# OpenAI SDK is only required for the LLM agent mode.
|
| 31 |
+
if TYPE_CHECKING:
|
| 32 |
+
from openai import OpenAI # pragma: no cover
|
| 33 |
+
else:
|
| 34 |
+
OpenAI = Any # type: ignore[misc,assignment]
|
| 35 |
+
|
| 36 |
+
from environment import MediRouteEnv
|
| 37 |
+
from models import Action, VALID_ACTION_TYPES
|
| 38 |
+
|
| 39 |
+
# βββββββββββββββββββββββββββββββββββββββββββββ
|
| 40 |
+
# Configuration from environment variables
|
| 41 |
+
# βββββββββββββββββββββββββββββββββββββββββββββ
|
| 42 |
+
|
| 43 |
+
API_KEY: str = os.getenv("OPENAI_API_KEY", "EMPTY")
|
| 44 |
+
API_BASE_URL: str = os.getenv("API_BASE_URL", "https://api.openai.com/v1")
|
| 45 |
+
MODEL_NAME: str = os.getenv("MODEL_NAME", "gpt-4o-mini")
|
| 46 |
+
HF_TOKEN: str = os.getenv("HF_TOKEN", "")
|
| 47 |
+
|
| 48 |
+
# If HF_TOKEN is set, prefer it as the API key for HF endpoints
|
| 49 |
+
if HF_TOKEN and API_KEY == "EMPTY":
|
| 50 |
+
API_KEY = HF_TOKEN
|
| 51 |
+
|
| 52 |
+
MAX_STEPS_PER_EPISODE: int = 8
|
| 53 |
+
ALL_DIFFICULTIES: List[str] = ["easy", "medium", "hard"]
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
# βββββββββββββββββββββββββββββββββββββββββββββ
|
| 57 |
+
# Prompt engineering
|
| 58 |
+
# βββββββββββββββββββββββββββββββββββββββββββββ
|
| 59 |
+
|
| 60 |
+
SYSTEM_PROMPT = """You are MediRoute, an AI medical triage and routing agent.
|
| 61 |
+
Your goal is to help patients by:
|
| 62 |
+
1. Analysing their symptoms and lab reports to determine severity.
|
| 63 |
+
2. Recommending the correct medical specialist.
|
| 64 |
+
3. Selecting the best nearby hospital.
|
| 65 |
+
4. Booking appointments or dispatching ambulances as appropriate.
|
| 66 |
+
|
| 67 |
+
You must respond with a single JSON object containing exactly two keys:
|
| 68 |
+
"action_type" : one of [analyze_symptoms, request_more_info, recommend_specialist,
|
| 69 |
+
select_hospital, book_appointment, call_ambulance,
|
| 70 |
+
provide_temp_guidance]
|
| 71 |
+
"target" : a string value relevant to the action, or null
|
| 72 |
+
|
| 73 |
+
Severity levels for analyze_symptoms: low | moderate | high | critical
|
| 74 |
+
|
| 75 |
+
Rules:
|
| 76 |
+
- For life-threatening emergencies (SpOβ < 85 %, unconscious, etc.) β call_ambulance.
|
| 77 |
+
- Do NOT book an appointment in a critical emergency.
|
| 78 |
+
- Pick the FIRST hospital in the nearby_hospitals list as the best option.
|
| 79 |
+
- Stop after taking a terminal action (book_appointment or call_ambulance).
|
| 80 |
+
- Never repeat the same action twice.
|
| 81 |
+
"""
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def build_user_message(obs, step: int) -> str:
|
| 85 |
+
return f"""Step {step} β Patient Status:
|
| 86 |
+
|
| 87 |
+
Symptoms : {obs.symptoms}
|
| 88 |
+
Lab Results : {json.dumps(obs.lab_report_summary, indent=2)}
|
| 89 |
+
Severity Score : {obs.severity_score:.2f}
|
| 90 |
+
Location : {obs.location}
|
| 91 |
+
Nearby Hospitals (in order of proximity/quality):
|
| 92 |
+
{chr(10).join(f' {i+1}. {h}' for i, h in enumerate(obs.nearby_hospitals))}
|
| 93 |
+
Available Specialists:
|
| 94 |
+
{chr(10).join(f' - {s}' for s in obs.available_specialists)}
|
| 95 |
+
Actions already taken: {obs.previous_actions or '(none)'}
|
| 96 |
+
|
| 97 |
+
What is your next action? Respond ONLY with valid JSON.
|
| 98 |
+
"""
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
# βββββββββββββββββββββββββββββββββββββββββββββ
|
| 102 |
+
# Agent loop
|
| 103 |
+
# βββββββββββββββββββββββββββββββββββββββββββββ
|
| 104 |
+
|
| 105 |
+
def parse_action(response_text: str) -> Optional[Action]:
|
| 106 |
+
"""Extract a valid Action from the model's raw JSON response."""
|
| 107 |
+
text = response_text.strip()
|
| 108 |
+
|
| 109 |
+
# Strip markdown code fences if present
|
| 110 |
+
if text.startswith("```"):
|
| 111 |
+
lines = text.splitlines()
|
| 112 |
+
text = "\n".join(lines[1:-1] if lines and lines[-1].strip() == "```" else lines[1:])
|
| 113 |
+
|
| 114 |
+
# Extract the first JSON object defensively (models sometimes add extra prose).
|
| 115 |
+
m = re.search(r"\{[\s\S]*\}", text)
|
| 116 |
+
if m:
|
| 117 |
+
text = m.group(0)
|
| 118 |
+
|
| 119 |
+
try:
|
| 120 |
+
data = json.loads(text)
|
| 121 |
+
except (json.JSONDecodeError, ValueError) as exc:
|
| 122 |
+
# Keep logs compatible with strict parsers: no free-form prefixes.
|
| 123 |
+
print(f"[STEP] event=parse_error detail={str(exc)[:120]!r}")
|
| 124 |
+
return None
|
| 125 |
+
|
| 126 |
+
action_type = str(data.get("action_type", "request_more_info")).strip()
|
| 127 |
+
target = data.get("target")
|
| 128 |
+
if action_type not in VALID_ACTION_TYPES:
|
| 129 |
+
return Action(action_type="request_more_info", target=None)
|
| 130 |
+
if target is not None and not isinstance(target, str):
|
| 131 |
+
# Keep schema strict: targets are strings or null.
|
| 132 |
+
target = str(target)
|
| 133 |
+
return Action(action_type=action_type, target=target)
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def rules_agent(obs) -> Action:
|
| 137 |
+
"""
|
| 138 |
+
Deterministic baseline policy.
|
| 139 |
+
Designed to be fully offline and reproducible for judge evaluation.
|
| 140 |
+
"""
|
| 141 |
+
labs = obs.lab_report_summary or {}
|
| 142 |
+
symptoms = (obs.symptoms or "").lower()
|
| 143 |
+
|
| 144 |
+
# 1) Emergency detection / severity inference
|
| 145 |
+
spo2_raw = str(labs.get("spo2", "")).lower()
|
| 146 |
+
gcs_raw = str(labs.get("consciousness", "")).lower()
|
| 147 |
+
emergency_signals = any(
|
| 148 |
+
s in spo2_raw for s in ["78", "79", "80", "81", "82", "83", "84"]
|
| 149 |
+
) or ("unresponsive" in gcs_raw) or ("cyanotic" in symptoms) or ("collapse" in symptoms)
|
| 150 |
+
|
| 151 |
+
if not any(a.startswith("analyze_symptoms:") for a in obs.previous_actions):
|
| 152 |
+
if emergency_signals:
|
| 153 |
+
return Action(action_type="analyze_symptoms", target="critical")
|
| 154 |
+
# STEMI-ish signals
|
| 155 |
+
ecg = str(labs.get("ecg_finding", "")).lower()
|
| 156 |
+
troponin = str(labs.get("troponin_i", "")).lower()
|
| 157 |
+
if "st-segment elevation" in ecg or "elevated" in troponin:
|
| 158 |
+
return Action(action_type="analyze_symptoms", target="high")
|
| 159 |
+
# Default outpatient
|
| 160 |
+
return Action(action_type="analyze_symptoms", target="low")
|
| 161 |
+
|
| 162 |
+
# 2) Route to specialist
|
| 163 |
+
if not any(a.startswith("recommend_specialist:") for a in obs.previous_actions):
|
| 164 |
+
if emergency_signals:
|
| 165 |
+
return Action(action_type="recommend_specialist", target="Emergency Doctor")
|
| 166 |
+
# Cardiology cues
|
| 167 |
+
ecg = str(labs.get("ecg_finding", "")).lower()
|
| 168 |
+
troponin = str(labs.get("troponin_i", "")).lower()
|
| 169 |
+
if "st-segment elevation" in ecg or "elevated" in troponin:
|
| 170 |
+
return Action(action_type="recommend_specialist", target="Cardiologist")
|
| 171 |
+
return Action(action_type="recommend_specialist", target="General Physician")
|
| 172 |
+
|
| 173 |
+
# 3) Choose hospital (prefer first listed)
|
| 174 |
+
if not any(a.startswith("select_hospital:") for a in obs.previous_actions):
|
| 175 |
+
best = obs.nearby_hospitals[0] if obs.nearby_hospitals else "General Hospital"
|
| 176 |
+
return Action(action_type="select_hospital", target=best)
|
| 177 |
+
|
| 178 |
+
# 4) Close episode
|
| 179 |
+
if emergency_signals:
|
| 180 |
+
return Action(action_type="call_ambulance", target=None)
|
| 181 |
+
return Action(action_type="book_appointment", target=None)
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def run_episode(client: Optional[OpenAI], difficulty: str, agent: str) -> Dict[str, Any]:
|
| 185 |
+
"""Run a complete episode for the given difficulty and return the summary."""
|
| 186 |
+
env = MediRouteEnv()
|
| 187 |
+
obs = env.reset(difficulty=difficulty)
|
| 188 |
+
conversation: List[Dict[str, str]] = []
|
| 189 |
+
step = 0
|
| 190 |
+
episode_start = time.time()
|
| 191 |
+
|
| 192 |
+
print(f"[START] difficulty={difficulty.upper()} agent={agent} symptoms={obs.symptoms!r}")
|
| 193 |
+
|
| 194 |
+
while step < MAX_STEPS_PER_EPISODE:
|
| 195 |
+
step += 1
|
| 196 |
+
if agent == "rules":
|
| 197 |
+
action = rules_agent(obs)
|
| 198 |
+
else:
|
| 199 |
+
user_msg = build_user_message(obs, step)
|
| 200 |
+
conversation.append({"role": "user", "content": user_msg})
|
| 201 |
+
|
| 202 |
+
# ββ Call the model ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 203 |
+
assistant_text = ""
|
| 204 |
+
for attempt in range(2):
|
| 205 |
+
try:
|
| 206 |
+
if client is None:
|
| 207 |
+
raise RuntimeError("OpenAI client not initialized.")
|
| 208 |
+
response = client.chat.completions.create(
|
| 209 |
+
model=MODEL_NAME,
|
| 210 |
+
messages=[{"role": "system", "content": SYSTEM_PROMPT}] + conversation,
|
| 211 |
+
temperature=0.0, # deterministic (to the extent the backend supports it)
|
| 212 |
+
max_tokens=256,
|
| 213 |
+
)
|
| 214 |
+
assistant_text = response.choices[0].message.content or ""
|
| 215 |
+
except Exception as exc:
|
| 216 |
+
print(f"[STEP] step={step} event=llm_error detail={str(exc)[:160]!r}")
|
| 217 |
+
assistant_text = ""
|
| 218 |
+
break
|
| 219 |
+
|
| 220 |
+
action = parse_action(assistant_text)
|
| 221 |
+
if action is not None:
|
| 222 |
+
break
|
| 223 |
+
|
| 224 |
+
# One corrective retry: ask for strict JSON only.
|
| 225 |
+
conversation.append({"role": "assistant", "content": assistant_text})
|
| 226 |
+
conversation.append(
|
| 227 |
+
{
|
| 228 |
+
"role": "user",
|
| 229 |
+
"content": "Your last response was invalid. Respond with ONLY a JSON object with keys action_type and target.",
|
| 230 |
+
}
|
| 231 |
+
)
|
| 232 |
+
|
| 233 |
+
if assistant_text:
|
| 234 |
+
conversation.append({"role": "assistant", "content": assistant_text})
|
| 235 |
+
|
| 236 |
+
if action is None:
|
| 237 |
+
action = Action(action_type="request_more_info", target=None)
|
| 238 |
+
|
| 239 |
+
# ββ Step environment ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 240 |
+
result = env.step(action)
|
| 241 |
+
|
| 242 |
+
reward_sign = "+" if result.reward >= 0 else ""
|
| 243 |
+
print(
|
| 244 |
+
f"[STEP {step}] action={action.action_type} "
|
| 245 |
+
f"target={action.target!r} "
|
| 246 |
+
f"reward={reward_sign}{result.reward:.2f} "
|
| 247 |
+
f"total={result.info.get('total_reward', 0):.2f} "
|
| 248 |
+
f"done={result.done}"
|
| 249 |
+
)
|
| 250 |
+
|
| 251 |
+
obs = result.observation
|
| 252 |
+
|
| 253 |
+
if result.done:
|
| 254 |
+
break
|
| 255 |
+
|
| 256 |
+
elapsed = time.time() - episode_start
|
| 257 |
+
summary = env.state().previous_actions # all actions taken
|
| 258 |
+
|
| 259 |
+
final_info = result.info if step > 0 else {}
|
| 260 |
+
episode_summary = final_info.get("episode_summary", {})
|
| 261 |
+
total_reward = final_info.get("total_reward", 0.0)
|
| 262 |
+
|
| 263 |
+
print(f"[END] difficulty={difficulty.upper()} agent={agent} "
|
| 264 |
+
f"score={total_reward:.4f} "
|
| 265 |
+
f"passed={episode_summary.get('passed', False)} "
|
| 266 |
+
f"steps={step} "
|
| 267 |
+
f"elapsed={elapsed:.1f}s "
|
| 268 |
+
f"breakdown={json.dumps(episode_summary.get('breakdown', {}))}")
|
| 269 |
+
|
| 270 |
+
return {
|
| 271 |
+
"difficulty": difficulty,
|
| 272 |
+
"score": total_reward,
|
| 273 |
+
"passed": episode_summary.get("passed", False),
|
| 274 |
+
"steps": step,
|
| 275 |
+
"elapsed_seconds": round(elapsed, 2),
|
| 276 |
+
"breakdown": episode_summary.get("breakdown", {}),
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
|
| 280 |
+
# βββββββββββββββββββββββββββββββββββββββββββββ
|
| 281 |
+
# Main
|
| 282 |
+
# βββββββββββββββββββββββββββββββββββββββββββββ
|
| 283 |
+
|
| 284 |
+
def main() -> None:
|
| 285 |
+
parser = argparse.ArgumentParser(description="MediRoute OpenEnv β Baseline Inference")
|
| 286 |
+
parser.add_argument(
|
| 287 |
+
"--difficulty",
|
| 288 |
+
choices=["easy", "medium", "hard", "all"],
|
| 289 |
+
default="all",
|
| 290 |
+
help="Which task(s) to run (default: all)",
|
| 291 |
+
)
|
| 292 |
+
parser.add_argument(
|
| 293 |
+
"--agent",
|
| 294 |
+
choices=["llm", "rules"],
|
| 295 |
+
default="llm",
|
| 296 |
+
help="Agent policy: llm (OpenAI-compatible) or rules (offline deterministic baseline).",
|
| 297 |
+
)
|
| 298 |
+
args = parser.parse_args()
|
| 299 |
+
|
| 300 |
+
# Keep output machine-parseable: rely on [START]/[STEP]/[END] markers.
|
| 301 |
+
|
| 302 |
+
client: Optional[OpenAI] = None
|
| 303 |
+
if args.agent == "llm":
|
| 304 |
+
try:
|
| 305 |
+
from openai import OpenAI as OpenAIClient # type: ignore
|
| 306 |
+
except ImportError:
|
| 307 |
+
print("[ERROR] openai package not found. Install it or run with: --agent rules")
|
| 308 |
+
sys.exit(1)
|
| 309 |
+
client = OpenAIClient(api_key=API_KEY, base_url=API_BASE_URL)
|
| 310 |
+
|
| 311 |
+
difficulties = ALL_DIFFICULTIES if args.difficulty == "all" else [args.difficulty]
|
| 312 |
+
results = []
|
| 313 |
+
|
| 314 |
+
for diff in difficulties:
|
| 315 |
+
result = run_episode(client, diff, agent=args.agent)
|
| 316 |
+
results.append(result)
|
| 317 |
+
|
| 318 |
+
# ββ Final leaderboard βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 319 |
+
avg_score = sum(r["score"] for r in results) / len(results)
|
| 320 |
+
|
| 321 |
+
# Emit one final [END] summary line for strict log parsers.
|
| 322 |
+
print(f"[END] summary average_score={avg_score:.4f} results={json.dumps(results)}")
|
| 323 |
+
|
| 324 |
+
|
| 325 |
+
if __name__ == "__main__":
|
| 326 |
+
main()
|
lifeline-ai/.gitignore
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
| 2 |
+
|
| 3 |
+
# dependencies
|
| 4 |
+
/node_modules
|
| 5 |
+
/.pnp
|
| 6 |
+
.pnp.*
|
| 7 |
+
.yarn/*
|
| 8 |
+
!.yarn/patches
|
| 9 |
+
!.yarn/plugins
|
| 10 |
+
!.yarn/releases
|
| 11 |
+
!.yarn/versions
|
| 12 |
+
|
| 13 |
+
# testing
|
| 14 |
+
/coverage
|
| 15 |
+
|
| 16 |
+
# next.js
|
| 17 |
+
/.next/
|
| 18 |
+
/out/
|
| 19 |
+
|
| 20 |
+
# production
|
| 21 |
+
/build
|
| 22 |
+
|
| 23 |
+
# misc
|
| 24 |
+
.DS_Store
|
| 25 |
+
*.pem
|
| 26 |
+
|
| 27 |
+
# debug
|
| 28 |
+
npm-debug.log*
|
| 29 |
+
yarn-debug.log*
|
| 30 |
+
yarn-error.log*
|
| 31 |
+
.pnpm-debug.log*
|
| 32 |
+
|
| 33 |
+
# env files (can opt-in for committing if needed)
|
| 34 |
+
.env*
|
| 35 |
+
|
| 36 |
+
# vercel
|
| 37 |
+
.vercel
|
| 38 |
+
|
| 39 |
+
# typescript
|
| 40 |
+
*.tsbuildinfo
|
| 41 |
+
next-env.d.ts
|
lifeline-ai/AGENTS.md
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!-- BEGIN:nextjs-agent-rules -->
|
| 2 |
+
# This is NOT the Next.js you know
|
| 3 |
+
|
| 4 |
+
This version has breaking changes β APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
|
| 5 |
+
<!-- END:nextjs-agent-rules -->
|
lifeline-ai/CLAUDE.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
@AGENTS.md
|
lifeline-ai/README.md
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# LifeLine AI (Phase 2 MVP)
|
| 2 |
+
|
| 3 |
+
**LifeLine AI** is a production-style full-stack healthcare emergency assistance demo:
|
| 4 |
+
- symptom intake + report upload
|
| 5 |
+
- AI-assisted triage (possible/likely wording; not a diagnosis)
|
| 6 |
+
- nearby hospital finder (cards + demo map pins)
|
| 7 |
+
- appointment booking flow + confirmation modal
|
| 8 |
+
- a visually prominent **SOS button** (ambulance dispatch simulation with ETA + tracking code)
|
| 9 |
+
|
| 10 |
+
This folder is the Phase 2 MVP website built after the MediRoute OpenEnv simulation.
|
| 11 |
+
|
| 12 |
+
---
|
| 13 |
+
|
| 14 |
+
## What We Built
|
| 15 |
+
|
| 16 |
+
- **OpenEnv environment** from Phase 1 (`MediRouteEnv`) with deterministic grading.
|
| 17 |
+
- **3 graded tasks** (easy / medium / hard) for healthcare routing and escalation.
|
| 18 |
+
- **PyTorch clinical reasoning layer** in `backend/app/hf_torch.py`.
|
| 19 |
+
- **Hugging Face transformer inference** for symptom-to-department and urgency scoring.
|
| 20 |
+
- **Hospital ranking engine** (best rated / closest / fastest route) with realistic cards.
|
| 21 |
+
- **Emergency SOS workflow** with dispatch simulation, live ETA, and status progression.
|
| 22 |
+
|
| 23 |
+
---
|
| 24 |
+
|
| 25 |
+
## Tech stack
|
| 26 |
+
|
| 27 |
+
- **Frontend**: Next.js (App Router) + React + TypeScript + Tailwind CSS
|
| 28 |
+
- **Backend**: FastAPI + Pydantic
|
| 29 |
+
- **AI**:
|
| 30 |
+
- Hugging Face Transformers pipeline (`facebook/bart-large-mnli`) via **PyTorch**
|
| 31 |
+
- OpenAI-compatible fallback path
|
| 32 |
+
- deterministic heuristic fallback for offline demos
|
| 33 |
+
- **Data**: mock hospital dataset (`backend/data/hospitals.json`)
|
| 34 |
+
|
| 35 |
+
---
|
| 36 |
+
|
| 37 |
+
## Run locally (recommended)
|
| 38 |
+
|
| 39 |
+
### 1) Backend API
|
| 40 |
+
|
| 41 |
+
```bash
|
| 42 |
+
cd lifeline-ai/backend
|
| 43 |
+
python3 -m venv .venv
|
| 44 |
+
source .venv/bin/activate
|
| 45 |
+
pip install -r requirements.txt
|
| 46 |
+
./run.sh
|
| 47 |
+
```
|
| 48 |
+
|
| 49 |
+
Backend runs at `http://localhost:8000`.
|
| 50 |
+
|
| 51 |
+
Optional (LLM mode):
|
| 52 |
+
|
| 53 |
+
```bash
|
| 54 |
+
export OPENAI_API_KEY="..."
|
| 55 |
+
export API_BASE_URL="https://api.openai.com/v1"
|
| 56 |
+
export MODEL_NAME="gpt-4o-mini"
|
| 57 |
+
```
|
| 58 |
+
|
| 59 |
+
If `OPENAI_API_KEY` is **not** set, the backend uses **deterministic demo triage**.
|
| 60 |
+
|
| 61 |
+
### 2) Frontend web app
|
| 62 |
+
|
| 63 |
+
In a second terminal:
|
| 64 |
+
|
| 65 |
+
```bash
|
| 66 |
+
cd lifeline-ai
|
| 67 |
+
npm install
|
| 68 |
+
npm run dev
|
| 69 |
+
```
|
| 70 |
+
|
| 71 |
+
Open `http://localhost:3000`.
|
| 72 |
+
|
| 73 |
+
---
|
| 74 |
+
|
| 75 |
+
## Demo flow
|
| 76 |
+
|
| 77 |
+
1. On the landing page (`/`), use a demo preset (cardiac / fever / collapse) or type symptoms manually.
|
| 78 |
+
2. Submit β results page (`/results`) with:
|
| 79 |
+
- possible condition
|
| 80 |
+
- urgency level
|
| 81 |
+
- recommended department
|
| 82 |
+
- temporary precautions
|
| 83 |
+
- recommended next step
|
| 84 |
+
3. Explore hospitals (`/hospitals`) with sorting:
|
| 85 |
+
- best rated
|
| 86 |
+
- closest
|
| 87 |
+
- fastest route
|
| 88 |
+
+ a demo map with pins
|
| 89 |
+
4. Book an appointment (`/book`) and view a confirmation modal.
|
| 90 |
+
5. Hit the **SOS** button (bottom-right) anytime for ambulance dispatch simulation.
|
| 91 |
+
|
| 92 |
+
---
|
| 93 |
+
|
| 94 |
+
## Configuration
|
| 95 |
+
|
| 96 |
+
Frontend:
|
| 97 |
+
- `NEXT_PUBLIC_API_BASE` (default `http://localhost:8000`)
|
| 98 |
+
|
| 99 |
+
Backend:
|
| 100 |
+
- `USE_HF_LOCAL` (default `1`, enables local Hugging Face + PyTorch inference path)
|
| 101 |
+
- `OPENAI_API_KEY` (enables LLM mode)
|
| 102 |
+
- `API_BASE_URL` (OpenAI-compatible base URL)
|
| 103 |
+
- `MODEL_NAME` (model id)
|
| 104 |
+
|
| 105 |
+
---
|
| 106 |
+
|
| 107 |
+
## Sponsor-aligned architecture
|
| 108 |
+
|
| 109 |
+
- `backend/app/hf_torch.py`:
|
| 110 |
+
- loads a Hugging Face transformer pipeline with `framework="pt"`
|
| 111 |
+
- performs symptom classification into department labels
|
| 112 |
+
- performs urgency scoring into low/medium/high/emergency
|
| 113 |
+
- returns confidence score and model metadata for UI display
|
| 114 |
+
- `backend/app/ai.py`:
|
| 115 |
+
- first tries Hugging Face + PyTorch path
|
| 116 |
+
- falls back to OpenAI-compatible model
|
| 117 |
+
- then deterministic heuristic fallback
|
| 118 |
+
- `src/app/results/page.tsx`:
|
| 119 |
+
- displays provider/model/confidence as a visible judge-facing proof point
|
| 120 |
+
- includes live confidence progress UI while inference is running
|
| 121 |
+
|
| 122 |
+
---
|
| 123 |
+
|
| 124 |
+
## Sponsor-friendly pitch lines
|
| 125 |
+
|
| 126 |
+
- **Hugging Face**: "We run transformer-based triage classification using a Hugging Face pipeline to map symptom narratives into urgency and department recommendations."
|
| 127 |
+
- **PyTorch**: "Our inference path is explicitly PyTorch-backed (`framework='pt'`), giving us transparent, production-style model loading and confidence scoring."
|
| 128 |
+
- **Why this matters**: "This combines interpretable clinical routing with real-time emergency UX, making AI assistance actionable in under two minutes during a crisis demo."
|
| 129 |
+
|
lifeline-ai/backend/app/ai.py
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
import re
|
| 5 |
+
from typing import Optional
|
| 6 |
+
|
| 7 |
+
import httpx
|
| 8 |
+
|
| 9 |
+
from .hf_torch import classify_with_hf_pytorch
|
| 10 |
+
from .models import AnalyzeRequest, AnalyzeResponse
|
| 11 |
+
|
| 12 |
+
# Optional local embedding resources (cached)
|
| 13 |
+
_emb_model = None
|
| 14 |
+
_emb_documents = None
|
| 15 |
+
_emb_doc_embeddings = None
|
| 16 |
+
|
| 17 |
+
DISCLAIMER = (
|
| 18 |
+
"This is an AI-assisted triage suggestion, not a medical diagnosis. "
|
| 19 |
+
"If symptoms are severe or worsening, seek in-person medical care immediately."
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _heuristic_triage(req: AnalyzeRequest) -> AnalyzeResponse:
|
| 24 |
+
s = req.symptoms.lower()
|
| 25 |
+
|
| 26 |
+
emergency_signals = any(
|
| 27 |
+
kw in s
|
| 28 |
+
for kw in [
|
| 29 |
+
"unresponsive",
|
| 30 |
+
"faint",
|
| 31 |
+
"collapsed",
|
| 32 |
+
"blue lips",
|
| 33 |
+
"cyanotic",
|
| 34 |
+
"severe bleeding",
|
| 35 |
+
"cannot breathe",
|
| 36 |
+
"no breathing",
|
| 37 |
+
"seizure",
|
| 38 |
+
]
|
| 39 |
+
)
|
| 40 |
+
cardiac_signals = any(
|
| 41 |
+
kw in s
|
| 42 |
+
for kw in [
|
| 43 |
+
"chest pain",
|
| 44 |
+
"shortness of breath",
|
| 45 |
+
"pain radiating",
|
| 46 |
+
"left arm",
|
| 47 |
+
"tightness",
|
| 48 |
+
"pressure in chest",
|
| 49 |
+
]
|
| 50 |
+
)
|
| 51 |
+
infection_signals = any(kw in s for kw in ["fever", "sore throat", "cough", "chills"])
|
| 52 |
+
|
| 53 |
+
if emergency_signals:
|
| 54 |
+
return AnalyzeResponse(
|
| 55 |
+
possible_condition="Possible emergency event (airway/breathing/circulation concern)",
|
| 56 |
+
urgency="emergency",
|
| 57 |
+
recommended_department="Emergency",
|
| 58 |
+
temporary_precautions=[
|
| 59 |
+
"Call local emergency services immediately.",
|
| 60 |
+
"If trained, start basic life support and keep the airway open.",
|
| 61 |
+
"Do not give food or drink if the person is unconscious.",
|
| 62 |
+
],
|
| 63 |
+
recommended_next_step="Use SOS to request an ambulance now. Go to the nearest emergency-capable hospital.",
|
| 64 |
+
disclaimer=DISCLAIMER,
|
| 65 |
+
confidence_note="Heuristic demo mode (offline).",
|
| 66 |
+
confidence_score=0.72,
|
| 67 |
+
model_provider="heuristic",
|
| 68 |
+
model_name="rules-v1",
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
if cardiac_signals:
|
| 72 |
+
return AnalyzeResponse(
|
| 73 |
+
possible_condition="Possible cardiac concern",
|
| 74 |
+
urgency="high",
|
| 75 |
+
recommended_department="Cardiology",
|
| 76 |
+
temporary_precautions=[
|
| 77 |
+
"Stop exertion and sit upright.",
|
| 78 |
+
"If symptoms worsen, do not drive yourselfβuse ambulance support.",
|
| 79 |
+
"If prescribed by a clinician previously, follow your emergency plan.",
|
| 80 |
+
],
|
| 81 |
+
recommended_next_step="Visit the nearest hospital within 30 minutes; prefer a cardiac-capable center.",
|
| 82 |
+
disclaimer=DISCLAIMER,
|
| 83 |
+
confidence_note="Heuristic demo mode (offline).",
|
| 84 |
+
confidence_score=0.76,
|
| 85 |
+
model_provider="heuristic",
|
| 86 |
+
model_name="rules-v1",
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
if infection_signals:
|
| 90 |
+
return AnalyzeResponse(
|
| 91 |
+
possible_condition="Possible infection / flu-like illness",
|
| 92 |
+
urgency="medium",
|
| 93 |
+
recommended_department="General Medicine",
|
| 94 |
+
temporary_precautions=[
|
| 95 |
+
"Hydrate and rest.",
|
| 96 |
+
"Monitor temperature and breathing.",
|
| 97 |
+
"Seek urgent care if severe shortness of breath, confusion, or persistent high fever occurs.",
|
| 98 |
+
],
|
| 99 |
+
recommended_next_step="Book an appointment with a general physician within 24β48 hours.",
|
| 100 |
+
disclaimer=DISCLAIMER,
|
| 101 |
+
confidence_note="Heuristic demo mode (offline).",
|
| 102 |
+
confidence_score=0.68,
|
| 103 |
+
model_provider="heuristic",
|
| 104 |
+
model_name="rules-v1",
|
| 105 |
+
)
|
| 106 |
+
|
| 107 |
+
return AnalyzeResponse(
|
| 108 |
+
possible_condition="Possible non-specific concern",
|
| 109 |
+
urgency="low",
|
| 110 |
+
recommended_department="General Medicine",
|
| 111 |
+
temporary_precautions=[
|
| 112 |
+
"Monitor symptoms and avoid triggers.",
|
| 113 |
+
"Write down symptom timeline and any medications taken.",
|
| 114 |
+
],
|
| 115 |
+
recommended_next_step="If symptoms persist, book a routine appointment.",
|
| 116 |
+
disclaimer=DISCLAIMER,
|
| 117 |
+
confidence_note="Heuristic demo mode (offline).",
|
| 118 |
+
confidence_score=0.55,
|
| 119 |
+
model_provider="heuristic",
|
| 120 |
+
model_name="rules-v1",
|
| 121 |
+
)
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def _hf_pytorch_triage(req: AnalyzeRequest) -> Optional[AnalyzeResponse]:
|
| 125 |
+
"""
|
| 126 |
+
Sponsor-facing inference path:
|
| 127 |
+
Uses a Hugging Face transformer pipeline running on PyTorch.
|
| 128 |
+
Returns None on any runtime/dependency issue to preserve resilient fallback.
|
| 129 |
+
"""
|
| 130 |
+
try:
|
| 131 |
+
pred = classify_with_hf_pytorch(req.symptoms)
|
| 132 |
+
except Exception:
|
| 133 |
+
return None
|
| 134 |
+
|
| 135 |
+
dept = pred.department
|
| 136 |
+
urgency = pred.urgency
|
| 137 |
+
|
| 138 |
+
if urgency == "emergency":
|
| 139 |
+
possible = "Possible life-threatening emergency"
|
| 140 |
+
next_step = "Use SOS now and proceed to the nearest emergency-capable hospital."
|
| 141 |
+
precautions = [
|
| 142 |
+
"Call emergency services immediately.",
|
| 143 |
+
"Keep the patient still and monitor breathing.",
|
| 144 |
+
"Do not delay for non-essential procedures.",
|
| 145 |
+
]
|
| 146 |
+
elif urgency == "high":
|
| 147 |
+
possible = "Possible acute clinical concern requiring urgent care"
|
| 148 |
+
next_step = "Visit a hospital within 30 minutes; prioritize facilities with critical care support."
|
| 149 |
+
precautions = [
|
| 150 |
+
"Avoid physical exertion.",
|
| 151 |
+
"Keep communication available and avoid driving alone if worsening.",
|
| 152 |
+
"Prepare prior records/medication list for triage.",
|
| 153 |
+
]
|
| 154 |
+
elif urgency == "medium":
|
| 155 |
+
possible = "Possible moderate clinical issue"
|
| 156 |
+
next_step = "Book an appointment soon and monitor changes over the next 24 hours."
|
| 157 |
+
precautions = [
|
| 158 |
+
"Hydrate and rest.",
|
| 159 |
+
"Track symptom progression.",
|
| 160 |
+
"Escalate to urgent care if worsening rapidly.",
|
| 161 |
+
]
|
| 162 |
+
else:
|
| 163 |
+
possible = "Possible mild clinical issue"
|
| 164 |
+
next_step = "Monitor symptoms and schedule a routine consultation if persistent."
|
| 165 |
+
precautions = [
|
| 166 |
+
"Continue observation and rest.",
|
| 167 |
+
"Record onset, triggers, and duration of symptoms.",
|
| 168 |
+
]
|
| 169 |
+
|
| 170 |
+
return AnalyzeResponse(
|
| 171 |
+
possible_condition=possible,
|
| 172 |
+
urgency=urgency, # type: ignore[arg-type]
|
| 173 |
+
recommended_department=dept,
|
| 174 |
+
temporary_precautions=precautions,
|
| 175 |
+
recommended_next_step=next_step,
|
| 176 |
+
disclaimer=DISCLAIMER,
|
| 177 |
+
confidence_note=f"Hugging Face transformer inference via PyTorch ({pred.model_name}).",
|
| 178 |
+
confidence_score=pred.confidence,
|
| 179 |
+
model_provider=pred.provider,
|
| 180 |
+
model_name=pred.model_name,
|
| 181 |
+
)
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def _local_embeddings_triage(req: AnalyzeRequest) -> Optional[AnalyzeResponse]:
|
| 185 |
+
"""
|
| 186 |
+
Use a local sentence-transformers model to find the closest matching
|
| 187 |
+
condition from a small medical dataset (`medical_data.json`) located
|
| 188 |
+
at the repository root (meta/medical_data.json). This is a best-effort
|
| 189 |
+
offline fallback and returns None on any error to preserve existing
|
| 190 |
+
fallback behavior.
|
| 191 |
+
"""
|
| 192 |
+
global _emb_model, _emb_documents, _emb_doc_embeddings
|
| 193 |
+
try:
|
| 194 |
+
from sentence_transformers import SentenceTransformer, util
|
| 195 |
+
import json
|
| 196 |
+
import os
|
| 197 |
+
except Exception:
|
| 198 |
+
return None
|
| 199 |
+
|
| 200 |
+
try:
|
| 201 |
+
# Lazy-load model and document embeddings to avoid repeated expensive work
|
| 202 |
+
if _emb_model is None:
|
| 203 |
+
model_name = os.getenv("EMBEDDING_MODEL", "sentence-transformers/embeddinggemma-300m-medical")
|
| 204 |
+
_emb_model = SentenceTransformer(model_name)
|
| 205 |
+
|
| 206 |
+
if _emb_documents is None or _emb_doc_embeddings is None:
|
| 207 |
+
# medical_data.json is located at repo root
|
| 208 |
+
repo_root = os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
|
| 209 |
+
data_path = os.path.join(repo_root, "medical_data.json")
|
| 210 |
+
if not os.path.exists(data_path):
|
| 211 |
+
return None
|
| 212 |
+
with open(data_path, "r") as fh:
|
| 213 |
+
medical_data = json.load(fh)
|
| 214 |
+
|
| 215 |
+
# Build searchable text in the same format as test.py
|
| 216 |
+
documents = [
|
| 217 |
+
f"{item['condition']}. Symptoms: {item['symptoms']}. Causes: {item['causes']}. Precautions: {item['precautions']}. Doctor advice: {item['see_doctor']}"
|
| 218 |
+
for item in medical_data
|
| 219 |
+
]
|
| 220 |
+
|
| 221 |
+
if not documents:
|
| 222 |
+
return None
|
| 223 |
+
|
| 224 |
+
_emb_documents = medical_data
|
| 225 |
+
_emb_doc_embeddings = _emb_model.encode(documents, convert_to_tensor=True)
|
| 226 |
+
except Exception:
|
| 227 |
+
return None
|
| 228 |
+
|
| 229 |
+
try:
|
| 230 |
+
# Encode query and compute similarity
|
| 231 |
+
query_embedding = _emb_model.encode(req.symptoms, convert_to_tensor=True)
|
| 232 |
+
scores = util.cos_sim(query_embedding, _emb_doc_embeddings)[0]
|
| 233 |
+
best_idx = int(scores.argmax().item())
|
| 234 |
+
best_score = float(scores[best_idx].item())
|
| 235 |
+
best_match = _emb_documents[best_idx]
|
| 236 |
+
except Exception:
|
| 237 |
+
return None
|
| 238 |
+
|
| 239 |
+
# Build a conservative AnalyzeResponse from the matched document
|
| 240 |
+
possible = best_match.get("condition", "Possible concern")
|
| 241 |
+
precautions = best_match.get("precautions", []) or []
|
| 242 |
+
next_step = best_match.get("see_doctor", "Follow up with a clinician if concerned.")
|
| 243 |
+
|
| 244 |
+
return AnalyzeResponse(
|
| 245 |
+
possible_condition=str(possible),
|
| 246 |
+
urgency=str(best_match.get("urgency", "low")),
|
| 247 |
+
recommended_department=str(best_match.get("department", "General Medicine")),
|
| 248 |
+
temporary_precautions=[str(x) for x in precautions][:6],
|
| 249 |
+
recommended_next_step=str(next_step),
|
| 250 |
+
disclaimer=DISCLAIMER,
|
| 251 |
+
confidence_note="Local embeddings-based match",
|
| 252 |
+
confidence_score=min(max(best_score, 0.0), 1.0),
|
| 253 |
+
model_provider="local-embeddings",
|
| 254 |
+
model_name=os.getenv("EMBEDDING_MODEL", "sentence-transformers/embeddinggemma-300m-medical"),
|
| 255 |
+
)
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
async def analyze(req: AnalyzeRequest) -> AnalyzeResponse:
|
| 259 |
+
"""
|
| 260 |
+
AI abstraction layer:
|
| 261 |
+
- If LLM env vars are set, call an OpenAI-compatible endpoint.
|
| 262 |
+
- Otherwise fall back to deterministic heuristic triage (demo mode).
|
| 263 |
+
"""
|
| 264 |
+
# 1) Hugging Face + PyTorch local pipeline (preferred sponsor path)
|
| 265 |
+
if os.getenv("USE_HF_LOCAL", "1").strip() not in {"0", "false", "False"}:
|
| 266 |
+
hf_result = _hf_pytorch_triage(req)
|
| 267 |
+
if hf_result is not None:
|
| 268 |
+
return hf_result
|
| 269 |
+
|
| 270 |
+
api_key = os.getenv("OPENAI_API_KEY", "").strip()
|
| 271 |
+
base_url = os.getenv("API_BASE_URL", "https://api.openai.com/v1").strip()
|
| 272 |
+
model = os.getenv("MODEL_NAME", "gpt-4o-mini").strip()
|
| 273 |
+
|
| 274 |
+
if not api_key:
|
| 275 |
+
# Try a local embeddings-based match before falling back to heuristics.
|
| 276 |
+
if os.getenv("USE_LOCAL_EMBEDDINGS", "1").strip() not in {"0", "false", "False"}:
|
| 277 |
+
emb = _local_embeddings_triage(req)
|
| 278 |
+
if emb is not None:
|
| 279 |
+
return emb
|
| 280 |
+
|
| 281 |
+
return _heuristic_triage(req)
|
| 282 |
+
|
| 283 |
+
system = (
|
| 284 |
+
"You are LifeLine AI, a healthcare triage assistant. "
|
| 285 |
+
"You must not provide a diagnosis. Use 'possible'/'likely' wording. "
|
| 286 |
+
"Return STRICT JSON with keys: possible_condition, urgency, recommended_department, "
|
| 287 |
+
"temporary_precautions (array of strings), recommended_next_step, confidence_note. "
|
| 288 |
+
"Urgency must be one of: low, medium, high, emergency."
|
| 289 |
+
)
|
| 290 |
+
user = (
|
| 291 |
+
f"Symptoms: {req.symptoms}\n"
|
| 292 |
+
f"Location: {req.location}\n"
|
| 293 |
+
f"Uploaded files: {req.uploaded_files}\n"
|
| 294 |
+
"Respond with JSON only."
|
| 295 |
+
)
|
| 296 |
+
|
| 297 |
+
payload = {
|
| 298 |
+
"model": model,
|
| 299 |
+
"messages": [
|
| 300 |
+
{"role": "system", "content": system},
|
| 301 |
+
{"role": "user", "content": user},
|
| 302 |
+
],
|
| 303 |
+
"temperature": 0.0,
|
| 304 |
+
"max_tokens": 300,
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
url = base_url.rstrip("/") + "/chat/completions"
|
| 308 |
+
headers = {"Authorization": f"Bearer {api_key}"}
|
| 309 |
+
|
| 310 |
+
async with httpx.AsyncClient(timeout=25.0) as client:
|
| 311 |
+
r = await client.post(url, json=payload, headers=headers)
|
| 312 |
+
r.raise_for_status()
|
| 313 |
+
data = r.json()
|
| 314 |
+
text = (data["choices"][0]["message"]["content"] or "").strip()
|
| 315 |
+
|
| 316 |
+
# Extract JSON object defensively
|
| 317 |
+
m = re.search(r"\{[\s\S]*\}", text)
|
| 318 |
+
if not m:
|
| 319 |
+
return _heuristic_triage(req)
|
| 320 |
+
import json
|
| 321 |
+
|
| 322 |
+
try:
|
| 323 |
+
obj = json.loads(m.group(0))
|
| 324 |
+
except Exception:
|
| 325 |
+
return _heuristic_triage(req)
|
| 326 |
+
|
| 327 |
+
urgency = obj.get("urgency", "medium")
|
| 328 |
+
if urgency not in {"low", "medium", "high", "emergency"}:
|
| 329 |
+
urgency = "medium"
|
| 330 |
+
|
| 331 |
+
tp = obj.get("temporary_precautions", [])
|
| 332 |
+
if not isinstance(tp, list):
|
| 333 |
+
tp = [str(tp)]
|
| 334 |
+
|
| 335 |
+
return AnalyzeResponse(
|
| 336 |
+
possible_condition=str(obj.get("possible_condition", "Possible concern")),
|
| 337 |
+
urgency=urgency, # type: ignore[arg-type]
|
| 338 |
+
recommended_department=str(obj.get("recommended_department", "General Medicine")),
|
| 339 |
+
temporary_precautions=[str(x) for x in tp][:6],
|
| 340 |
+
recommended_next_step=str(obj.get("recommended_next_step", "Consider in-person care if symptoms worsen.")),
|
| 341 |
+
disclaimer=DISCLAIMER,
|
| 342 |
+
confidence_note=str(obj.get("confidence_note", "LLM mode")),
|
| 343 |
+
confidence_score=float(obj.get("confidence_score", 0.7)),
|
| 344 |
+
model_provider="openai-compatible",
|
| 345 |
+
model_name=model,
|
| 346 |
+
)
|
| 347 |
+
|
lifeline-ai/backend/app/hf_torch.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
from functools import lru_cache
|
| 5 |
+
from typing import List
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
@dataclass
|
| 9 |
+
class HFResult:
|
| 10 |
+
department: str
|
| 11 |
+
urgency: str
|
| 12 |
+
confidence: float
|
| 13 |
+
provider: str
|
| 14 |
+
model_name: str
|
| 15 |
+
framework: str
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
DEPARTMENT_LABELS: List[str] = [
|
| 19 |
+
"Cardiology",
|
| 20 |
+
"Emergency",
|
| 21 |
+
"General Medicine",
|
| 22 |
+
"Pulmonology",
|
| 23 |
+
"Neurology",
|
| 24 |
+
]
|
| 25 |
+
|
| 26 |
+
URGENCY_LABELS: List[str] = ["low", "medium", "high", "emergency"]
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
@lru_cache(maxsize=1)
|
| 30 |
+
def _load_zero_shot_pipeline():
|
| 31 |
+
"""
|
| 32 |
+
Load Hugging Face transformer pipeline on PyTorch backend.
|
| 33 |
+
This is intentionally isolated so fallback mode still works if deps are absent.
|
| 34 |
+
"""
|
| 35 |
+
from transformers import pipeline
|
| 36 |
+
|
| 37 |
+
# framework='pt' makes the PyTorch path explicit for judges/sponsors.
|
| 38 |
+
return pipeline(
|
| 39 |
+
task="zero-shot-classification",
|
| 40 |
+
model="facebook/bart-large-mnli",
|
| 41 |
+
framework="pt",
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def classify_with_hf_pytorch(symptoms: str) -> HFResult:
|
| 46 |
+
pipe = _load_zero_shot_pipeline()
|
| 47 |
+
|
| 48 |
+
dep_pred = pipe(symptoms, candidate_labels=DEPARTMENT_LABELS, multi_label=False)
|
| 49 |
+
urg_pred = pipe(symptoms, candidate_labels=URGENCY_LABELS, multi_label=False)
|
| 50 |
+
|
| 51 |
+
dep_label = str(dep_pred["labels"][0])
|
| 52 |
+
dep_score = float(dep_pred["scores"][0])
|
| 53 |
+
urg_label = str(urg_pred["labels"][0]).lower()
|
| 54 |
+
urg_score = float(urg_pred["scores"][0])
|
| 55 |
+
|
| 56 |
+
# Conservative combined confidence
|
| 57 |
+
confidence = max(0.0, min(1.0, (dep_score * 0.55) + (urg_score * 0.45)))
|
| 58 |
+
|
| 59 |
+
return HFResult(
|
| 60 |
+
department=dep_label,
|
| 61 |
+
urgency=urg_label if urg_label in URGENCY_LABELS else "medium",
|
| 62 |
+
confidence=confidence,
|
| 63 |
+
provider="huggingface",
|
| 64 |
+
model_name="facebook/bart-large-mnli",
|
| 65 |
+
framework="pytorch",
|
| 66 |
+
)
|
| 67 |
+
|
lifeline-ai/backend/app/main.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import List, Literal, Optional
|
| 4 |
+
import os
|
| 5 |
+
import httpx
|
| 6 |
+
|
| 7 |
+
from fastapi import FastAPI, File, Form, UploadFile
|
| 8 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 9 |
+
|
| 10 |
+
from .ai import analyze as ai_analyze
|
| 11 |
+
from .models import (
|
| 12 |
+
AnalyzeRequest,
|
| 13 |
+
AnalyzeResponse,
|
| 14 |
+
AppointmentCreateRequest,
|
| 15 |
+
AppointmentCreateResponse,
|
| 16 |
+
HospitalsResponse,
|
| 17 |
+
SosRequest,
|
| 18 |
+
SosResponse,
|
| 19 |
+
)
|
| 20 |
+
from .store import DB, compute_demo_eta_seconds, generate_sos_tracking_code, sort_hospitals, hospitals_for_location
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
app = FastAPI(title="LifeLine AI API", version="1.0.0")
|
| 24 |
+
|
| 25 |
+
app.add_middleware(
|
| 26 |
+
CORSMiddleware,
|
| 27 |
+
allow_origins=["*"],
|
| 28 |
+
allow_credentials=False,
|
| 29 |
+
allow_methods=["*"],
|
| 30 |
+
allow_headers=["*"],
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
@app.get("/health")
|
| 35 |
+
def health() -> dict:
|
| 36 |
+
return {"status": "ok"}
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
@app.post("/upload")
|
| 40 |
+
async def upload_files(files: List[UploadFile] = File(default=[])) -> dict:
|
| 41 |
+
"""
|
| 42 |
+
Demo-safe upload endpoint:
|
| 43 |
+
- accepts PDFs/images/prescriptions
|
| 44 |
+
- does not persist to disk by default (to keep MVP simple)
|
| 45 |
+
- returns filenames so the analyze endpoint can reference them
|
| 46 |
+
"""
|
| 47 |
+
names = []
|
| 48 |
+
for f in files:
|
| 49 |
+
# Read a small amount to validate stream; don't store.
|
| 50 |
+
_ = await f.read(1024)
|
| 51 |
+
names.append(f.filename or "upload")
|
| 52 |
+
return {"uploaded_files": names}
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
@app.post("/analyze", response_model=AnalyzeResponse)
|
| 56 |
+
async def analyze(symptoms: str = Form(...), location: str = Form(...), uploaded_files: str = Form("[]")) -> AnalyzeResponse:
|
| 57 |
+
"""
|
| 58 |
+
Triage analysis endpoint.
|
| 59 |
+
Uses an AI abstraction layer:
|
| 60 |
+
- LLM mode if env vars set (OPENAI_API_KEY + optional API_BASE_URL/MODEL_NAME)
|
| 61 |
+
- otherwise deterministic heuristic mode for instant demos
|
| 62 |
+
"""
|
| 63 |
+
import json
|
| 64 |
+
|
| 65 |
+
try:
|
| 66 |
+
files = json.loads(uploaded_files)
|
| 67 |
+
if not isinstance(files, list):
|
| 68 |
+
files = []
|
| 69 |
+
except Exception:
|
| 70 |
+
files = []
|
| 71 |
+
|
| 72 |
+
req = AnalyzeRequest(symptoms=symptoms, location=location, uploaded_files=[str(x) for x in files][:10])
|
| 73 |
+
return await ai_analyze(req)
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
@app.get("/hospitals", response_model=HospitalsResponse)
|
| 77 |
+
def hospitals(
|
| 78 |
+
location: str,
|
| 79 |
+
sort: Literal["best_rated", "closest", "fastest_route"] = "closest",
|
| 80 |
+
lat: Optional[float] = None,
|
| 81 |
+
lng: Optional[float] = None,
|
| 82 |
+
) -> HospitalsResponse:
|
| 83 |
+
# If client provides coordinates, adjust distances/etas based on that location
|
| 84 |
+
if lat is not None and lng is not None:
|
| 85 |
+
hs = sort_hospitals(hospitals_for_location(DB.hospitals, lat, lng), sort)
|
| 86 |
+
else:
|
| 87 |
+
hs = sort_hospitals(DB.hospitals, sort)
|
| 88 |
+
return HospitalsResponse(location=location, sort=sort, hospitals=hs)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
@app.post("/appointments", response_model=AppointmentCreateResponse)
|
| 92 |
+
def create_appointment(req: AppointmentCreateRequest) -> AppointmentCreateResponse:
|
| 93 |
+
appt = DB.create_appointment(req)
|
| 94 |
+
return AppointmentCreateResponse(appointment=appt)
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
@app.get("/appointments")
|
| 98 |
+
def list_appointments() -> dict:
|
| 99 |
+
return {"appointments": [a.model_dump() for a in DB.list_appointments()]}
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
@app.post("/sos", response_model=SosResponse)
|
| 103 |
+
def sos(req: SosRequest) -> SosResponse:
|
| 104 |
+
# Always pick the closest hospital for SOS demo.
|
| 105 |
+
hs = sort_hospitals(DB.hospitals, "fastest_route")
|
| 106 |
+
nearest = hs[0]
|
| 107 |
+
eta_seconds = compute_demo_eta_seconds(nearest)
|
| 108 |
+
return SosResponse(
|
| 109 |
+
nearest_hospital=nearest,
|
| 110 |
+
eta_seconds=eta_seconds,
|
| 111 |
+
tracking_code=generate_sos_tracking_code(),
|
| 112 |
+
message="Ambulance dispatched. Stay calmβhelp is on the way.",
|
| 113 |
+
meta={"location": req.location, "symptoms": req.symptoms or ""},
|
| 114 |
+
)
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
@app.get("/reverse-geocode")
|
| 118 |
+
def reverse_geocode(lat: float, lng: float) -> dict:
|
| 119 |
+
"""
|
| 120 |
+
Server-side reverse geocoding endpoint.
|
| 121 |
+
Uses provider configured by GEOCODER_PROVIDER (default: nominatim).
|
| 122 |
+
Returns JSON: { display_name, lat, lng } or raises HTTPException on error.
|
| 123 |
+
"""
|
| 124 |
+
provider = os.getenv("GEOCODER_PROVIDER", "nominatim")
|
| 125 |
+
try:
|
| 126 |
+
if provider == "nominatim":
|
| 127 |
+
url = f"https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat={lat}&lon={lng}"
|
| 128 |
+
headers = {"User-Agent": "LifeLineAI-Demo/1.0"}
|
| 129 |
+
r = httpx.get(url, headers=headers, timeout=10.0)
|
| 130 |
+
r.raise_for_status()
|
| 131 |
+
data = r.json()
|
| 132 |
+
return {"display_name": data.get("display_name", ""), "lat": lat, "lng": lng}
|
| 133 |
+
else:
|
| 134 |
+
# Unsupported provider configured
|
| 135 |
+
from fastapi import HTTPException
|
| 136 |
+
|
| 137 |
+
raise HTTPException(status_code=501, detail=f"Geocoder provider '{provider}' not implemented")
|
| 138 |
+
except Exception as e:
|
| 139 |
+
from fastapi import HTTPException
|
| 140 |
+
|
| 141 |
+
raise HTTPException(status_code=502, detail=f"Reverse geocode failed: {e}")
|
| 142 |
+
|
lifeline-ai/backend/app/models.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Any, Dict, List, Literal, Optional
|
| 4 |
+
|
| 5 |
+
from pydantic import BaseModel, Field
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
UrgencyLevel = Literal["low", "medium", "high", "emergency"]
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class AnalyzeRequest(BaseModel):
|
| 12 |
+
symptoms: str = Field(..., min_length=3, max_length=4000)
|
| 13 |
+
location: str = Field(..., min_length=2, max_length=200)
|
| 14 |
+
# Filenames only; actual file bytes are uploaded separately.
|
| 15 |
+
uploaded_files: List[str] = Field(default_factory=list)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class AnalyzeResponse(BaseModel):
|
| 19 |
+
possible_condition: str
|
| 20 |
+
urgency: UrgencyLevel
|
| 21 |
+
recommended_department: str
|
| 22 |
+
temporary_precautions: List[str]
|
| 23 |
+
recommended_next_step: str
|
| 24 |
+
disclaimer: str
|
| 25 |
+
confidence_note: str
|
| 26 |
+
confidence_score: float = Field(..., ge=0.0, le=1.0)
|
| 27 |
+
model_provider: str = "heuristic"
|
| 28 |
+
model_name: str = "rule-based"
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class Hospital(BaseModel):
|
| 32 |
+
id: str
|
| 33 |
+
name: str
|
| 34 |
+
distance_km: float = Field(..., ge=0.0)
|
| 35 |
+
eta_minutes: int = Field(..., ge=1, le=180)
|
| 36 |
+
rating: float = Field(..., ge=0.0, le=5.0)
|
| 37 |
+
specialties: List[str] = Field(default_factory=list)
|
| 38 |
+
availability: Literal["open", "limited", "busy"] = "open"
|
| 39 |
+
address: str
|
| 40 |
+
phone: str
|
| 41 |
+
lat: float
|
| 42 |
+
lng: float
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
class HospitalsResponse(BaseModel):
|
| 46 |
+
location: str
|
| 47 |
+
sort: Literal["best_rated", "closest", "fastest_route"] = "closest"
|
| 48 |
+
hospitals: List[Hospital]
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
class AppointmentCreateRequest(BaseModel):
|
| 52 |
+
hospital_id: str
|
| 53 |
+
department: str
|
| 54 |
+
doctor: str
|
| 55 |
+
time_slot: str
|
| 56 |
+
patient_name: str = Field(..., min_length=2, max_length=120)
|
| 57 |
+
patient_phone: str = Field(..., min_length=6, max_length=40)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
class Appointment(BaseModel):
|
| 61 |
+
id: str
|
| 62 |
+
hospital_id: str
|
| 63 |
+
hospital_name: str
|
| 64 |
+
department: str
|
| 65 |
+
doctor: str
|
| 66 |
+
time_slot: str
|
| 67 |
+
patient_name: str
|
| 68 |
+
patient_phone: str
|
| 69 |
+
status: Literal["confirmed", "cancelled"] = "confirmed"
|
| 70 |
+
created_at_iso: str
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
class AppointmentCreateResponse(BaseModel):
|
| 74 |
+
appointment: Appointment
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
class SosRequest(BaseModel):
|
| 78 |
+
location: str
|
| 79 |
+
symptoms: Optional[str] = None
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
class SosResponse(BaseModel):
|
| 83 |
+
status: Literal["ambulance_dispatched"] = "ambulance_dispatched"
|
| 84 |
+
nearest_hospital: Hospital
|
| 85 |
+
eta_seconds: int = Field(..., ge=30, le=3600)
|
| 86 |
+
tracking_code: str
|
| 87 |
+
message: str
|
| 88 |
+
meta: Dict[str, Any] = Field(default_factory=dict)
|
| 89 |
+
|
lifeline-ai/backend/app/store.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import os
|
| 5 |
+
import time
|
| 6 |
+
import uuid
|
| 7 |
+
from datetime import datetime, timezone
|
| 8 |
+
from typing import Dict, List, Literal, Optional
|
| 9 |
+
|
| 10 |
+
from .models import Appointment, AppointmentCreateRequest, Hospital
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def _now_iso() -> str:
|
| 14 |
+
return datetime.now(timezone.utc).isoformat()
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def load_hospitals() -> List[Hospital]:
|
| 18 |
+
base = os.path.dirname(os.path.dirname(__file__))
|
| 19 |
+
path = os.path.join(base, "data", "hospitals.json")
|
| 20 |
+
with open(path, "r", encoding="utf-8") as f:
|
| 21 |
+
raw = json.load(f)
|
| 22 |
+
return [Hospital(**h) for h in raw]
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def sort_hospitals(hospitals: List[Hospital], sort: Literal["best_rated", "closest", "fastest_route"]) -> List[Hospital]:
|
| 26 |
+
if sort == "best_rated":
|
| 27 |
+
return sorted(hospitals, key=lambda h: (-h.rating, h.eta_minutes, h.distance_km))
|
| 28 |
+
if sort == "fastest_route":
|
| 29 |
+
return sorted(hospitals, key=lambda h: (h.eta_minutes, h.distance_km, -h.rating))
|
| 30 |
+
return sorted(hospitals, key=lambda h: (h.distance_km, h.eta_minutes, -h.rating))
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
| 34 |
+
"""Return the great-circle distance between two points in kilometers."""
|
| 35 |
+
from math import radians, sin, cos, asin, sqrt
|
| 36 |
+
|
| 37 |
+
lat1, lon1, lat2, lon2 = map(radians, (lat1, lon1, lat2, lon2))
|
| 38 |
+
dlat = lat2 - lat1
|
| 39 |
+
dlon = lon2 - lon1
|
| 40 |
+
a = sin(dlat / 2) ** 2 + cos(lat1) * cos(lat2) * sin(dlon / 2) ** 2
|
| 41 |
+
c = 2 * asin(sqrt(a))
|
| 42 |
+
return 6371.0 * c
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def hospitals_for_location(hospitals: List[Hospital], lat: float, lng: float) -> List[Hospital]:
|
| 46 |
+
"""
|
| 47 |
+
Return a new list of Hospital objects with distance_km and eta_minutes adjusted
|
| 48 |
+
according to the provided lat/lng (user location). This is used for demo proximity
|
| 49 |
+
sorting when the client provides coordinates.
|
| 50 |
+
"""
|
| 51 |
+
new: List[Hospital] = []
|
| 52 |
+
for h in hospitals:
|
| 53 |
+
dist = _haversine_km(lat, lng, h.lat, h.lng)
|
| 54 |
+
# Estimate ETA: assume average driving speed ~40 km/h -> minutes = dist/40*60 = dist*1.5
|
| 55 |
+
eta = max(3, int(round(dist * 1.5)))
|
| 56 |
+
# Create a shallow copy with updated fields
|
| 57 |
+
nh = Hospital(**{**h.model_dump(), "distance_km": round(dist, 2), "eta_minutes": eta})
|
| 58 |
+
new.append(nh)
|
| 59 |
+
return new
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
class InMemoryDB:
|
| 63 |
+
def __init__(self) -> None:
|
| 64 |
+
self._appointments: Dict[str, Appointment] = {}
|
| 65 |
+
self._hospitals = load_hospitals()
|
| 66 |
+
|
| 67 |
+
@property
|
| 68 |
+
def hospitals(self) -> List[Hospital]:
|
| 69 |
+
return self._hospitals
|
| 70 |
+
|
| 71 |
+
def get_hospital(self, hospital_id: str) -> Optional[Hospital]:
|
| 72 |
+
for h in self._hospitals:
|
| 73 |
+
if h.id == hospital_id:
|
| 74 |
+
return h
|
| 75 |
+
return None
|
| 76 |
+
|
| 77 |
+
def create_appointment(self, req: AppointmentCreateRequest) -> Appointment:
|
| 78 |
+
hosp = self.get_hospital(req.hospital_id)
|
| 79 |
+
hospital_name = hosp.name if hosp else "Unknown Hospital"
|
| 80 |
+
appt = Appointment(
|
| 81 |
+
id="appt_" + uuid.uuid4().hex[:12],
|
| 82 |
+
hospital_id=req.hospital_id,
|
| 83 |
+
hospital_name=hospital_name,
|
| 84 |
+
department=req.department,
|
| 85 |
+
doctor=req.doctor,
|
| 86 |
+
time_slot=req.time_slot,
|
| 87 |
+
patient_name=req.patient_name,
|
| 88 |
+
patient_phone=req.patient_phone,
|
| 89 |
+
status="confirmed",
|
| 90 |
+
created_at_iso=_now_iso(),
|
| 91 |
+
)
|
| 92 |
+
self._appointments[appt.id] = appt
|
| 93 |
+
return appt
|
| 94 |
+
|
| 95 |
+
def list_appointments(self) -> List[Appointment]:
|
| 96 |
+
return sorted(self._appointments.values(), key=lambda a: a.created_at_iso, reverse=True)
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
DB = InMemoryDB()
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def generate_sos_tracking_code() -> str:
|
| 103 |
+
return "SOS-" + uuid.uuid4().hex[:8].upper()
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def compute_demo_eta_seconds(hospital: Hospital) -> int:
|
| 107 |
+
# Convert minutes to seconds with a small deterministic variation.
|
| 108 |
+
jitter = int((hospital.distance_km * 7) % 20)
|
| 109 |
+
return max(60, hospital.eta_minutes * 60 + jitter)
|
| 110 |
+
|
lifeline-ai/backend/data/hospitals.json
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"id": "hosp_city_clinic",
|
| 4 |
+
"name": "City Clinic",
|
| 5 |
+
"distance_km": 2.4,
|
| 6 |
+
"eta_minutes": 9,
|
| 7 |
+
"rating": 4.6,
|
| 8 |
+
"specialties": ["General Medicine", "ENT", "Pediatrics"],
|
| 9 |
+
"availability": "open",
|
| 10 |
+
"address": "12 Market St, Downtown",
|
| 11 |
+
"phone": "+1-555-0101",
|
| 12 |
+
"lat": 12.9721,
|
| 13 |
+
"lng": 77.5933
|
| 14 |
+
},
|
| 15 |
+
{
|
| 16 |
+
"id": "hosp_downtown_med",
|
| 17 |
+
"name": "Downtown Medical Center",
|
| 18 |
+
"distance_km": 3.1,
|
| 19 |
+
"eta_minutes": 11,
|
| 20 |
+
"rating": 4.2,
|
| 21 |
+
"specialties": ["General Medicine", "Diagnostics", "Orthopedics"],
|
| 22 |
+
"availability": "limited",
|
| 23 |
+
"address": "88 River Rd, Downtown",
|
| 24 |
+
"phone": "+1-555-0102",
|
| 25 |
+
"lat": 12.9699,
|
| 26 |
+
"lng": 77.5965
|
| 27 |
+
},
|
| 28 |
+
{
|
| 29 |
+
"id": "hosp_westside_heart",
|
| 30 |
+
"name": "Westside Heart Center",
|
| 31 |
+
"distance_km": 6.8,
|
| 32 |
+
"eta_minutes": 18,
|
| 33 |
+
"rating": 4.8,
|
| 34 |
+
"specialties": ["Cardiology", "Cath Lab", "ICU"],
|
| 35 |
+
"availability": "busy",
|
| 36 |
+
"address": "5 Sunset Ave, Westside",
|
| 37 |
+
"phone": "+1-555-0201",
|
| 38 |
+
"lat": 12.9657,
|
| 39 |
+
"lng": 77.5852
|
| 40 |
+
},
|
| 41 |
+
{
|
| 42 |
+
"id": "hosp_general",
|
| 43 |
+
"name": "General Hospital",
|
| 44 |
+
"distance_km": 5.5,
|
| 45 |
+
"eta_minutes": 16,
|
| 46 |
+
"rating": 4.1,
|
| 47 |
+
"specialties": ["Emergency", "General Surgery", "Internal Medicine"],
|
| 48 |
+
"availability": "open",
|
| 49 |
+
"address": "200 Main Blvd, Central",
|
| 50 |
+
"phone": "+1-555-0301",
|
| 51 |
+
"lat": 12.9764,
|
| 52 |
+
"lng": 77.6009
|
| 53 |
+
},
|
| 54 |
+
{
|
| 55 |
+
"id": "hosp_northside_icu",
|
| 56 |
+
"name": "Northside Hospital (ICU)",
|
| 57 |
+
"distance_km": 7.9,
|
| 58 |
+
"eta_minutes": 20,
|
| 59 |
+
"rating": 4.7,
|
| 60 |
+
"specialties": ["Emergency", "ICU", "Pulmonology"],
|
| 61 |
+
"availability": "busy",
|
| 62 |
+
"address": "77 North Ring Rd, Northside",
|
| 63 |
+
"phone": "+1-555-0401",
|
| 64 |
+
"lat": 12.9894,
|
| 65 |
+
"lng": 77.5801
|
| 66 |
+
}
|
| 67 |
+
]
|
| 68 |
+
|
lifeline-ai/backend/requirements.txt
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi>=0.110.0
|
| 2 |
+
uvicorn[standard]>=0.27.0
|
| 3 |
+
pydantic>=2.0.0
|
| 4 |
+
python-multipart>=0.0.9
|
| 5 |
+
httpx>=0.26.0
|
| 6 |
+
transformers>=4.40.0
|
| 7 |
+
torch>=2.2.0
|
| 8 |
+
sentence-transformers>=2.2.2
|
lifeline-ai/backend/run.sh
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
set -euo pipefail
|
| 3 |
+
|
| 4 |
+
uvicorn app.main:app --host 0.0.0.0 --port "${PORT:-8000}" --reload
|
| 5 |
+
|
lifeline-ai/eslint.config.mjs
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { defineConfig, globalIgnores } from "eslint/config";
|
| 2 |
+
import nextVitals from "eslint-config-next/core-web-vitals";
|
| 3 |
+
import nextTs from "eslint-config-next/typescript";
|
| 4 |
+
|
| 5 |
+
const eslintConfig = defineConfig([
|
| 6 |
+
...nextVitals,
|
| 7 |
+
...nextTs,
|
| 8 |
+
// Override default ignores of eslint-config-next.
|
| 9 |
+
globalIgnores([
|
| 10 |
+
// Default ignores of eslint-config-next:
|
| 11 |
+
".next/**",
|
| 12 |
+
"out/**",
|
| 13 |
+
"build/**",
|
| 14 |
+
"next-env.d.ts",
|
| 15 |
+
]),
|
| 16 |
+
]);
|
| 17 |
+
|
| 18 |
+
export default eslintConfig;
|
lifeline-ai/next.config.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { NextConfig } from "next";
|
| 2 |
+
|
| 3 |
+
const nextConfig: NextConfig = {
|
| 4 |
+
/* config options here */
|
| 5 |
+
};
|
| 6 |
+
|
| 7 |
+
export default nextConfig;
|
lifeline-ai/package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
lifeline-ai/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "lifeline-ai",
|
| 3 |
+
"version": "0.1.0",
|
| 4 |
+
"private": true,
|
| 5 |
+
"scripts": {
|
| 6 |
+
"dev": "next dev",
|
| 7 |
+
"build": "next build",
|
| 8 |
+
"start": "next start",
|
| 9 |
+
"lint": "eslint"
|
| 10 |
+
},
|
| 11 |
+
"dependencies": {
|
| 12 |
+
"next": "16.2.2",
|
| 13 |
+
"react": "19.2.4",
|
| 14 |
+
"react-dom": "19.2.4"
|
| 15 |
+
},
|
| 16 |
+
"devDependencies": {
|
| 17 |
+
"@tailwindcss/postcss": "^4",
|
| 18 |
+
"@types/node": "^20",
|
| 19 |
+
"@types/react": "^19",
|
| 20 |
+
"@types/react-dom": "^19",
|
| 21 |
+
"eslint": "^9",
|
| 22 |
+
"eslint-config-next": "16.2.2",
|
| 23 |
+
"tailwindcss": "^4",
|
| 24 |
+
"typescript": "^5"
|
| 25 |
+
}
|
| 26 |
+
}
|
lifeline-ai/postcss.config.mjs
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const config = {
|
| 2 |
+
plugins: {
|
| 3 |
+
"@tailwindcss/postcss": {},
|
| 4 |
+
},
|
| 5 |
+
};
|
| 6 |
+
|
| 7 |
+
export default config;
|
lifeline-ai/public/file.svg
ADDED
|
|
lifeline-ai/public/globe.svg
ADDED
|
|
lifeline-ai/public/next.svg
ADDED
|
|
lifeline-ai/public/vercel.svg
ADDED
|
|
lifeline-ai/public/window.svg
ADDED
|
|
lifeline-ai/src/app/book/page.tsx
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useEffect, useMemo, useState } from "react";
|
| 4 |
+
import { Button, Card, Container, Pill, TopNav } from "@/components/ui";
|
| 5 |
+
import { Appointment, createAppointment, getHospitals, Hospital } from "@/lib/api";
|
| 6 |
+
import { SOSButton } from "@/components/SOSButton";
|
| 7 |
+
|
| 8 |
+
const DOCTORS: Record<string, string[]> = {
|
| 9 |
+
Cardiology: ["Dr. Meera Iyer", "Dr. Rahul Menon", "Dr. Sana Patel"],
|
| 10 |
+
Emergency: ["Dr. Arjun Rao", "Dr. Kavya Nair"],
|
| 11 |
+
"General Medicine": ["Dr. Neha Singh", "Dr. Vikram Das"],
|
| 12 |
+
ENT: ["Dr. Ananya Shah"],
|
| 13 |
+
};
|
| 14 |
+
|
| 15 |
+
function timeSlots() {
|
| 16 |
+
return [
|
| 17 |
+
"Today 10:30 AM",
|
| 18 |
+
"Today 12:00 PM",
|
| 19 |
+
"Today 4:30 PM",
|
| 20 |
+
"Tomorrow 9:30 AM",
|
| 21 |
+
"Tomorrow 2:00 PM",
|
| 22 |
+
];
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
export default function BookPage() {
|
| 26 |
+
const [location, setLocation] = useState("Downtown");
|
| 27 |
+
const [hospitals, setHospitals] = useState<Hospital[]>([]);
|
| 28 |
+
const [hospitalId, setHospitalId] = useState<string>("");
|
| 29 |
+
const [department, setDepartment] = useState<string>("General Medicine");
|
| 30 |
+
const [doctor, setDoctor] = useState<string>("");
|
| 31 |
+
const [timeSlot, setTimeSlot] = useState<string>(timeSlots()[0]);
|
| 32 |
+
const [patientName, setPatientName] = useState<string>("");
|
| 33 |
+
const [patientPhone, setPatientPhone] = useState<string>("");
|
| 34 |
+
const [busy, setBusy] = useState(false);
|
| 35 |
+
const [confirmation, setConfirmation] = useState<Appointment | null>(null);
|
| 36 |
+
|
| 37 |
+
useEffect(() => {
|
| 38 |
+
let cancelled = false;
|
| 39 |
+
getHospitals(location, "closest").then((r) => {
|
| 40 |
+
if (cancelled) return;
|
| 41 |
+
setHospitals(r.hospitals);
|
| 42 |
+
setHospitalId((prev) => prev || r.hospitals[0]?.id || "");
|
| 43 |
+
});
|
| 44 |
+
return () => {
|
| 45 |
+
cancelled = true;
|
| 46 |
+
};
|
| 47 |
+
}, [location]);
|
| 48 |
+
|
| 49 |
+
useEffect(() => {
|
| 50 |
+
const list = DOCTORS[department] ?? ["Dr. Available Clinician"];
|
| 51 |
+
setDoctor(list[0]);
|
| 52 |
+
}, [department]);
|
| 53 |
+
|
| 54 |
+
const selectedHospital = useMemo(() => hospitals.find((h) => h.id === hospitalId) ?? null, [hospitals, hospitalId]);
|
| 55 |
+
const departments = useMemo(() => Object.keys(DOCTORS), []);
|
| 56 |
+
const doctors = useMemo(() => DOCTORS[department] ?? [], [department]);
|
| 57 |
+
|
| 58 |
+
const canBook =
|
| 59 |
+
hospitalId &&
|
| 60 |
+
department &&
|
| 61 |
+
doctor &&
|
| 62 |
+
timeSlot &&
|
| 63 |
+
patientName.trim().length >= 2 &&
|
| 64 |
+
patientPhone.trim().length >= 6 &&
|
| 65 |
+
!busy;
|
| 66 |
+
|
| 67 |
+
async function book() {
|
| 68 |
+
if (!canBook) return;
|
| 69 |
+
setBusy(true);
|
| 70 |
+
try {
|
| 71 |
+
const res = await createAppointment({
|
| 72 |
+
hospital_id: hospitalId,
|
| 73 |
+
department,
|
| 74 |
+
doctor,
|
| 75 |
+
time_slot: timeSlot,
|
| 76 |
+
patient_name: patientName.trim(),
|
| 77 |
+
patient_phone: patientPhone.trim(),
|
| 78 |
+
});
|
| 79 |
+
setConfirmation(res.appointment);
|
| 80 |
+
} finally {
|
| 81 |
+
setBusy(false);
|
| 82 |
+
}
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
return (
|
| 86 |
+
<div className="min-h-full bg-gradient-to-b from-white to-slate-50">
|
| 87 |
+
<TopNav />
|
| 88 |
+
<main className="py-10 sm:py-14">
|
| 89 |
+
<Container>
|
| 90 |
+
<div>
|
| 91 |
+
<Pill className="bg-blue-50 text-blue-700">Appointment booking</Pill>
|
| 92 |
+
<h1 className="mt-3 text-3xl font-extrabold tracking-tight text-slate-900">Book a visit</h1>
|
| 93 |
+
<p className="mt-2 text-slate-600">Select hospital, department, doctor, and a time slot. Demo uses mock availability.</p>
|
| 94 |
+
</div>
|
| 95 |
+
|
| 96 |
+
<div className="mt-8 grid gap-6 lg:grid-cols-3">
|
| 97 |
+
<div className="lg:col-span-2 space-y-6">
|
| 98 |
+
<Card>
|
| 99 |
+
<div className="grid gap-4 sm:grid-cols-2">
|
| 100 |
+
<div>
|
| 101 |
+
<div className="text-sm font-semibold text-slate-900">Location</div>
|
| 102 |
+
<input
|
| 103 |
+
value={location}
|
| 104 |
+
onChange={(e) => setLocation(e.target.value)}
|
| 105 |
+
className="mt-2 w-full rounded-2xl border border-slate-200 bg-white px-4 py-3 text-sm outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100"
|
| 106 |
+
/>
|
| 107 |
+
<div className="mt-2 text-xs text-slate-500">Tip: try Downtown / Westside / Northside</div>
|
| 108 |
+
</div>
|
| 109 |
+
<div>
|
| 110 |
+
<div className="text-sm font-semibold text-slate-900">Hospital</div>
|
| 111 |
+
<select
|
| 112 |
+
value={hospitalId}
|
| 113 |
+
onChange={(e) => setHospitalId(e.target.value)}
|
| 114 |
+
className="mt-2 w-full rounded-2xl border border-slate-200 bg-white px-4 py-3 text-sm outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100"
|
| 115 |
+
>
|
| 116 |
+
{hospitals.map((h) => (
|
| 117 |
+
<option key={h.id} value={h.id}>
|
| 118 |
+
{h.name} β’ {h.eta_minutes} min
|
| 119 |
+
</option>
|
| 120 |
+
))}
|
| 121 |
+
</select>
|
| 122 |
+
{selectedHospital && (
|
| 123 |
+
<div className="mt-2 text-xs text-slate-500">{selectedHospital.address}</div>
|
| 124 |
+
)}
|
| 125 |
+
</div>
|
| 126 |
+
|
| 127 |
+
<div>
|
| 128 |
+
<div className="text-sm font-semibold text-slate-900">Department</div>
|
| 129 |
+
<select
|
| 130 |
+
value={department}
|
| 131 |
+
onChange={(e) => setDepartment(e.target.value)}
|
| 132 |
+
className="mt-2 w-full rounded-2xl border border-slate-200 bg-white px-4 py-3 text-sm outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100"
|
| 133 |
+
>
|
| 134 |
+
{departments.map((d) => (
|
| 135 |
+
<option key={d} value={d}>
|
| 136 |
+
{d}
|
| 137 |
+
</option>
|
| 138 |
+
))}
|
| 139 |
+
</select>
|
| 140 |
+
</div>
|
| 141 |
+
<div>
|
| 142 |
+
<div className="text-sm font-semibold text-slate-900">Doctor</div>
|
| 143 |
+
<select
|
| 144 |
+
value={doctor}
|
| 145 |
+
onChange={(e) => setDoctor(e.target.value)}
|
| 146 |
+
className="mt-2 w-full rounded-2xl border border-slate-200 bg-white px-4 py-3 text-sm outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100"
|
| 147 |
+
>
|
| 148 |
+
{doctors.map((d) => (
|
| 149 |
+
<option key={d} value={d}>
|
| 150 |
+
{d}
|
| 151 |
+
</option>
|
| 152 |
+
))}
|
| 153 |
+
</select>
|
| 154 |
+
<div className="mt-2 text-xs font-semibold text-emerald-700">Specialist verified</div>
|
| 155 |
+
</div>
|
| 156 |
+
|
| 157 |
+
<div className="sm:col-span-2">
|
| 158 |
+
<div className="text-sm font-semibold text-slate-900">Time slot</div>
|
| 159 |
+
<div className="mt-2 flex flex-wrap gap-2">
|
| 160 |
+
{timeSlots().map((t) => (
|
| 161 |
+
<button
|
| 162 |
+
key={t}
|
| 163 |
+
onClick={() => setTimeSlot(t)}
|
| 164 |
+
className={`rounded-xl border px-3 py-2 text-sm font-semibold ${
|
| 165 |
+
timeSlot === t
|
| 166 |
+
? "border-blue-200 bg-blue-600 text-white"
|
| 167 |
+
: "border-slate-200 bg-white text-slate-800 hover:bg-slate-50"
|
| 168 |
+
}`}
|
| 169 |
+
>
|
| 170 |
+
{t}
|
| 171 |
+
</button>
|
| 172 |
+
))}
|
| 173 |
+
</div>
|
| 174 |
+
</div>
|
| 175 |
+
</div>
|
| 176 |
+
</Card>
|
| 177 |
+
|
| 178 |
+
<Card>
|
| 179 |
+
<div className="text-sm font-semibold text-slate-900">Patient details</div>
|
| 180 |
+
<div className="mt-4 grid gap-4 sm:grid-cols-2">
|
| 181 |
+
<div>
|
| 182 |
+
<div className="text-xs font-semibold text-slate-600">Full name</div>
|
| 183 |
+
<input
|
| 184 |
+
value={patientName}
|
| 185 |
+
onChange={(e) => setPatientName(e.target.value)}
|
| 186 |
+
className="mt-2 w-full rounded-2xl border border-slate-200 bg-white px-4 py-3 text-sm outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100"
|
| 187 |
+
placeholder="Your name"
|
| 188 |
+
/>
|
| 189 |
+
</div>
|
| 190 |
+
<div>
|
| 191 |
+
<div className="text-xs font-semibold text-slate-600">Phone</div>
|
| 192 |
+
<input
|
| 193 |
+
value={patientPhone}
|
| 194 |
+
onChange={(e) => setPatientPhone(e.target.value)}
|
| 195 |
+
className="mt-2 w-full rounded-2xl border border-slate-200 bg-white px-4 py-3 text-sm outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100"
|
| 196 |
+
placeholder="+1 555 0123"
|
| 197 |
+
/>
|
| 198 |
+
</div>
|
| 199 |
+
</div>
|
| 200 |
+
<div className="mt-5 flex justify-end">
|
| 201 |
+
<Button onClick={book} disabled={!canBook}>
|
| 202 |
+
{busy ? "Booking..." : "Confirm appointment"}
|
| 203 |
+
</Button>
|
| 204 |
+
</div>
|
| 205 |
+
</Card>
|
| 206 |
+
</div>
|
| 207 |
+
|
| 208 |
+
<div className="space-y-6">
|
| 209 |
+
<Card>
|
| 210 |
+
<div className="text-sm font-semibold text-slate-900">Reminder</div>
|
| 211 |
+
<p className="mt-2 text-sm text-slate-600">
|
| 212 |
+
If symptoms are severe (chest pain, breathing trouble, unresponsive), use SOS instead of booking.
|
| 213 |
+
</p>
|
| 214 |
+
<div className="mt-4">
|
| 215 |
+
<Button variant="danger" onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}>
|
| 216 |
+
Emergency guidance
|
| 217 |
+
</Button>
|
| 218 |
+
</div>
|
| 219 |
+
</Card>
|
| 220 |
+
</div>
|
| 221 |
+
</div>
|
| 222 |
+
</Container>
|
| 223 |
+
</main>
|
| 224 |
+
|
| 225 |
+
{confirmation && (
|
| 226 |
+
<div className="fixed inset-0 z-50 grid place-items-end bg-slate-900/50 p-4 sm:place-items-center">
|
| 227 |
+
<div className="w-full max-w-md animate-fade-up">
|
| 228 |
+
<Card>
|
| 229 |
+
<div className="flex items-start justify-between gap-3">
|
| 230 |
+
<div>
|
| 231 |
+
<div className="text-lg font-extrabold text-slate-900">Appointment confirmed</div>
|
| 232 |
+
<div className="mt-1 text-sm text-slate-600">Confirmation ID: {confirmation.id}</div>
|
| 233 |
+
</div>
|
| 234 |
+
<button
|
| 235 |
+
className="rounded-xl px-3 py-2 text-sm font-semibold text-slate-600 hover:bg-slate-50"
|
| 236 |
+
onClick={() => setConfirmation(null)}
|
| 237 |
+
>
|
| 238 |
+
Close
|
| 239 |
+
</button>
|
| 240 |
+
</div>
|
| 241 |
+
<div className="mt-5 space-y-2 text-sm text-slate-800">
|
| 242 |
+
<div>
|
| 243 |
+
<span className="font-semibold">Hospital:</span> {confirmation.hospital_name}
|
| 244 |
+
</div>
|
| 245 |
+
<div>
|
| 246 |
+
<span className="font-semibold">Department:</span> {confirmation.department}
|
| 247 |
+
</div>
|
| 248 |
+
<div>
|
| 249 |
+
<span className="font-semibold">Doctor:</span> {confirmation.doctor}
|
| 250 |
+
</div>
|
| 251 |
+
<div>
|
| 252 |
+
<span className="font-semibold">Time:</span> {confirmation.time_slot}
|
| 253 |
+
</div>
|
| 254 |
+
</div>
|
| 255 |
+
<div className="mt-6 flex justify-end">
|
| 256 |
+
<Button onClick={() => setConfirmation(null)}>Done</Button>
|
| 257 |
+
</div>
|
| 258 |
+
</Card>
|
| 259 |
+
</div>
|
| 260 |
+
</div>
|
| 261 |
+
)}
|
| 262 |
+
|
| 263 |
+
<SOSButton />
|
| 264 |
+
</div>
|
| 265 |
+
);
|
| 266 |
+
}
|
| 267 |
+
|
lifeline-ai/src/app/favicon.ico
ADDED
|
|
lifeline-ai/src/app/globals.css
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@import "tailwindcss";
|
| 2 |
+
|
| 3 |
+
:root {
|
| 4 |
+
--background: #ffffff;
|
| 5 |
+
--foreground: #0f172a; /* slate-900 */
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
@theme inline {
|
| 9 |
+
--color-background: var(--background);
|
| 10 |
+
--color-foreground: var(--foreground);
|
| 11 |
+
--font-sans: var(--font-geist-sans);
|
| 12 |
+
--font-mono: var(--font-geist-mono);
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
/* Dark mode disabled to ensure consistent visibility */
|
| 16 |
+
/* @media (prefers-color-scheme: dark) {
|
| 17 |
+
:root {
|
| 18 |
+
--background: #020617;
|
| 19 |
+
--foreground: #e2e8f0;
|
| 20 |
+
}
|
| 21 |
+
} */
|
| 22 |
+
|
| 23 |
+
/* Body background and color are set explicitly in layout.tsx */
|
| 24 |
+
/* to ensure consistent light mode appearance */
|
| 25 |
+
|
| 26 |
+
::selection {
|
| 27 |
+
background: rgba(59, 130, 246, 0.25);
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
@keyframes fadeUp {
|
| 31 |
+
from {
|
| 32 |
+
opacity: 0;
|
| 33 |
+
transform: translateY(8px);
|
| 34 |
+
}
|
| 35 |
+
to {
|
| 36 |
+
opacity: 1;
|
| 37 |
+
transform: translateY(0);
|
| 38 |
+
}
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
@keyframes pulseSoft {
|
| 42 |
+
0%,
|
| 43 |
+
100% {
|
| 44 |
+
transform: scale(1);
|
| 45 |
+
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.35);
|
| 46 |
+
}
|
| 47 |
+
50% {
|
| 48 |
+
transform: scale(1.03);
|
| 49 |
+
box-shadow: 0 0 0 10px rgba(239, 68, 68, 0.08);
|
| 50 |
+
}
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
@keyframes bounceSoft {
|
| 54 |
+
0%,
|
| 55 |
+
100% {
|
| 56 |
+
transform: translateY(0);
|
| 57 |
+
}
|
| 58 |
+
50% {
|
| 59 |
+
transform: translateY(-4px);
|
| 60 |
+
}
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
@keyframes shimmer {
|
| 64 |
+
0% {
|
| 65 |
+
background-position: -200% 0;
|
| 66 |
+
}
|
| 67 |
+
100% {
|
| 68 |
+
background-position: 200% 0;
|
| 69 |
+
}
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
.animate-fade-up {
|
| 73 |
+
animation: fadeUp 380ms ease-out both;
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
.animate-pulse-soft {
|
| 77 |
+
animation: pulseSoft 2s ease-in-out infinite;
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
.animate-bounce-soft {
|
| 81 |
+
animation: bounceSoft 1.6s ease-in-out infinite;
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
.loading-shimmer {
|
| 85 |
+
background: linear-gradient(90deg, #eef2ff 25%, #f8fafc 37%, #eef2ff 63%);
|
| 86 |
+
background-size: 300% 100%;
|
| 87 |
+
animation: shimmer 1.2s linear infinite;
|
| 88 |
+
}
|
lifeline-ai/src/app/hospitals/page.tsx
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useEffect, useMemo, useState } from "react";
|
| 4 |
+
import { useSearchParams } from "next/navigation";
|
| 5 |
+
import { getHospitals, Hospital } from "@/lib/api";
|
| 6 |
+
import { Button, Card, Container, Pill, TopNav } from "@/components/ui";
|
| 7 |
+
import { SOSButton } from "@/components/SOSButton";
|
| 8 |
+
|
| 9 |
+
function Stars({ rating }: { rating: number }) {
|
| 10 |
+
const full = Math.round(rating);
|
| 11 |
+
return (
|
| 12 |
+
<div className="flex items-center gap-1">
|
| 13 |
+
{Array.from({ length: 5 }).map((_, i) => (
|
| 14 |
+
<span key={i} className={i < full ? "text-amber-500" : "text-slate-200"}>
|
| 15 |
+
β
|
| 16 |
+
</span>
|
| 17 |
+
))}
|
| 18 |
+
<span className="ml-2 text-xs font-semibold text-slate-600">{rating.toFixed(1)}</span>
|
| 19 |
+
</div>
|
| 20 |
+
);
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
function AvailabilityPill({ v }: { v: Hospital["availability"] }) {
|
| 24 |
+
if (v === "busy") return <Pill className="bg-orange-500 text-white">Busy</Pill>;
|
| 25 |
+
if (v === "limited") return <Pill className="bg-blue-600 text-white">Limited</Pill>;
|
| 26 |
+
return <Pill className="bg-emerald-600 text-white">Open</Pill>;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
export default function HospitalsPage() {
|
| 30 |
+
const sp = useSearchParams();
|
| 31 |
+
const initialLocation = sp.get("location") || "Downtown";
|
| 32 |
+
|
| 33 |
+
const [location, setLocation] = useState(initialLocation);
|
| 34 |
+
const [sort, setSort] = useState<"best_rated" | "closest" | "fastest_route">("closest");
|
| 35 |
+
const [hospitals, setHospitals] = useState<Hospital[]>([]);
|
| 36 |
+
const [geo, setGeo] = useState<{ lat: number; lng: number } | null>(null);
|
| 37 |
+
|
| 38 |
+
useEffect(() => {
|
| 39 |
+
let cancelled = false;
|
| 40 |
+
getHospitals(location, sort, geo?.lat ?? null, geo?.lng ?? null)
|
| 41 |
+
.then((r) => {
|
| 42 |
+
if (cancelled) return;
|
| 43 |
+
setHospitals(r.hospitals);
|
| 44 |
+
})
|
| 45 |
+
.catch(() => {
|
| 46 |
+
if (cancelled) return;
|
| 47 |
+
setHospitals([]);
|
| 48 |
+
});
|
| 49 |
+
return () => {
|
| 50 |
+
cancelled = true;
|
| 51 |
+
};
|
| 52 |
+
}, [location, sort, geo]);
|
| 53 |
+
|
| 54 |
+
function enableGeolocation() {
|
| 55 |
+
if (!navigator.geolocation) return;
|
| 56 |
+
navigator.geolocation.getCurrentPosition((p) => {
|
| 57 |
+
const lat = p.coords.latitude;
|
| 58 |
+
const lng = p.coords.longitude;
|
| 59 |
+
setGeo({ lat, lng });
|
| 60 |
+
// Update textual location for visibility
|
| 61 |
+
setLocation(`${lat.toFixed(4)},${lng.toFixed(4)}`);
|
| 62 |
+
// Immediately fetch hospitals for this location
|
| 63 |
+
getHospitals(`${lat.toFixed(4)},${lng.toFixed(4)}`, sort, lat, lng).then((r) => setHospitals(r.hospitals)).catch(() => {});
|
| 64 |
+
});
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
const bounds = useMemo(() => {
|
| 68 |
+
if (!hospitals.length) return null;
|
| 69 |
+
const lats = hospitals.map((h) => h.lat);
|
| 70 |
+
const lngs = hospitals.map((h) => h.lng);
|
| 71 |
+
const minLat = Math.min(...lats);
|
| 72 |
+
const maxLat = Math.max(...lats);
|
| 73 |
+
const minLng = Math.min(...lngs);
|
| 74 |
+
const maxLng = Math.max(...lngs);
|
| 75 |
+
return { minLat, maxLat, minLng, maxLng };
|
| 76 |
+
}, [hospitals]);
|
| 77 |
+
|
| 78 |
+
return (
|
| 79 |
+
<div className="min-h-full bg-gradient-to-b from-white to-slate-50">
|
| 80 |
+
<TopNav />
|
| 81 |
+
<main className="py-10 sm:py-14">
|
| 82 |
+
<Container>
|
| 83 |
+
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
| 84 |
+
<div>
|
| 85 |
+
<Pill className="bg-blue-50 text-blue-700">Nearby hospital finder</Pill>
|
| 86 |
+
<h1 className="mt-3 text-3xl font-extrabold tracking-tight text-slate-900">Hospitals near you</h1>
|
| 87 |
+
<p className="mt-2 text-slate-600">
|
| 88 |
+
Compare <span className="font-semibold">best rated</span>, <span className="font-semibold">closest</span>, or{" "}
|
| 89 |
+
<span className="font-semibold">fastest route</span>.
|
| 90 |
+
</p>
|
| 91 |
+
</div>
|
| 92 |
+
<div className="grid gap-3 sm:grid-cols-2">
|
| 93 |
+
<div>
|
| 94 |
+
<div className="text-xs font-semibold text-slate-600">Location</div>
|
| 95 |
+
<input
|
| 96 |
+
value={location}
|
| 97 |
+
onChange={(e) => setLocation(e.target.value)}
|
| 98 |
+
aria-label="Location"
|
| 99 |
+
className="mt-2 w-full rounded-2xl border border-slate-200 bg-white px-4 py-3 text-sm outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100"
|
| 100 |
+
/>
|
| 101 |
+
</div>
|
| 102 |
+
<div>
|
| 103 |
+
<div className="text-xs font-semibold text-slate-600">Sort</div>
|
| 104 |
+
<div className="mt-2 flex gap-2">
|
| 105 |
+
<Button variant={sort === "best_rated" ? "primary" : "secondary"} onClick={() => setSort("best_rated")}>
|
| 106 |
+
Best rated
|
| 107 |
+
</Button>
|
| 108 |
+
<Button variant={sort === "closest" ? "primary" : "secondary"} onClick={() => setSort("closest")}>
|
| 109 |
+
Closest
|
| 110 |
+
</Button>
|
| 111 |
+
<Button variant={sort === "fastest_route" ? "primary" : "secondary"} onClick={() => setSort("fastest_route")}>
|
| 112 |
+
Fastest
|
| 113 |
+
</Button>
|
| 114 |
+
</div>
|
| 115 |
+
</div>
|
| 116 |
+
</div>
|
| 117 |
+
<div className="sm:ml-auto">
|
| 118 |
+
<Button variant="secondary" onClick={enableGeolocation}>
|
| 119 |
+
Use my location
|
| 120 |
+
</Button>
|
| 121 |
+
</div>
|
| 122 |
+
</div>
|
| 123 |
+
|
| 124 |
+
<div className="mt-8 grid gap-6 lg:grid-cols-3">
|
| 125 |
+
<div className="lg:col-span-2 space-y-4">
|
| 126 |
+
{!hospitals.length && <div className="text-sm text-slate-600">Loading hospitalsβ¦</div>}
|
| 127 |
+
{hospitals.map((h) => {
|
| 128 |
+
const erWait = Math.max(8, Math.round(h.eta_minutes * 1.2 + (5 - h.rating) * 4));
|
| 129 |
+
const doctorMins = Math.max(6, Math.round(h.eta_minutes * 0.65));
|
| 130 |
+
const beds = h.availability === "open" ? "Beds: 12 available" : h.availability === "limited" ? "Beds: 4 available" : "Beds: 1 available";
|
| 131 |
+
return (
|
| 132 |
+
<Card key={h.id}>
|
| 133 |
+
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between group">
|
| 134 |
+
<div className="space-y-2">
|
| 135 |
+
<div className="flex items-center gap-2">
|
| 136 |
+
<div className="text-lg font-extrabold text-slate-900">{h.name}</div>
|
| 137 |
+
<AvailabilityPill v={h.availability} />
|
| 138 |
+
<Pill className="bg-emerald-100 text-emerald-800">Verified hospital</Pill>
|
| 139 |
+
</div>
|
| 140 |
+
<Stars rating={h.rating} />
|
| 141 |
+
<div className="text-sm text-slate-600">{h.address}</div>
|
| 142 |
+
<div className="flex flex-wrap gap-2 text-xs">
|
| 143 |
+
<Pill className="bg-blue-50 text-blue-700">Doctor available in {doctorMins} mins</Pill>
|
| 144 |
+
<Pill className="bg-slate-100 text-slate-700">ER wait {erWait} mins</Pill>
|
| 145 |
+
<Pill className="bg-violet-100 text-violet-700">{beds}</Pill>
|
| 146 |
+
</div>
|
| 147 |
+
<div className="flex flex-wrap gap-2">
|
| 148 |
+
{h.specialties.slice(0, 5).map((s) => (
|
| 149 |
+
<Pill key={s} className="bg-slate-100 text-slate-700">
|
| 150 |
+
{s} β’ verified specialist
|
| 151 |
+
</Pill>
|
| 152 |
+
))}
|
| 153 |
+
</div>
|
| 154 |
+
</div>
|
| 155 |
+
<div className="grid gap-2 rounded-2xl border border-slate-200 bg-slate-50 p-4 transition group-hover:-translate-y-0.5">
|
| 156 |
+
<div className="text-xs font-semibold text-slate-600">Distance</div>
|
| 157 |
+
<div className="text-sm font-extrabold text-slate-900">{h.distance_km.toFixed(1)} km</div>
|
| 158 |
+
<div className="text-xs font-semibold text-slate-600">ETA</div>
|
| 159 |
+
<div className="text-sm font-extrabold text-slate-900">{h.eta_minutes} min</div>
|
| 160 |
+
{geo && (
|
| 161 |
+
<a
|
| 162 |
+
className="text-xs font-semibold text-blue-700 hover:underline"
|
| 163 |
+
target="_blank"
|
| 164 |
+
rel="noreferrer"
|
| 165 |
+
href={`https://www.google.com/maps/dir/?api=1&origin=${geo.lat},${geo.lng}&destination=${h.lat},${h.lng}&travelmode=driving`}
|
| 166 |
+
>
|
| 167 |
+
Open live route
|
| 168 |
+
</a>
|
| 169 |
+
)}
|
| 170 |
+
<a className="text-xs font-semibold text-blue-700 hover:underline" href={`tel:${h.phone}`}>
|
| 171 |
+
Call {h.phone}
|
| 172 |
+
</a>
|
| 173 |
+
</div>
|
| 174 |
+
</div>
|
| 175 |
+
</Card>
|
| 176 |
+
)})}
|
| 177 |
+
</div>
|
| 178 |
+
|
| 179 |
+
<div className="space-y-6">
|
| 180 |
+
<Card>
|
| 181 |
+
<div className="text-sm font-semibold text-slate-900">Map (demo)</div>
|
| 182 |
+
<p className="mt-2 text-sm text-slate-600">
|
| 183 |
+
Demo map pins. In production, replace with Google Maps / Mapbox. Pins are plotted from mock hospital coordinates.
|
| 184 |
+
</p>
|
| 185 |
+
<div className="mt-4 overflow-hidden rounded-2xl border border-slate-200 bg-slate-50">
|
| 186 |
+
<div className="relative h-[280px] w-full">
|
| 187 |
+
<div className="absolute inset-0 bg-[radial-gradient(circle_at_30%_20%,rgba(59,130,246,0.18),transparent_35%),radial-gradient(circle_at_70%_70%,rgba(16,185,129,0.18),transparent_40%)]" />
|
| 188 |
+
<div className="absolute inset-0 p-4">
|
| 189 |
+
{bounds && hospitals.map((h) => {
|
| 190 |
+
const x = ((h.lng - bounds.minLng) / (bounds.maxLng - bounds.minLng || 1)) * 100;
|
| 191 |
+
const y = (1 - (h.lat - bounds.minLat) / (bounds.maxLat - bounds.minLat || 1)) * 100;
|
| 192 |
+
return (
|
| 193 |
+
<div
|
| 194 |
+
key={h.id}
|
| 195 |
+
className="absolute -translate-x-1/2 -translate-y-1/2"
|
| 196 |
+
style={{ left: `${x}%`, top: `${y}%` }}
|
| 197 |
+
title={h.name}
|
| 198 |
+
>
|
| 199 |
+
<div className="grid h-9 w-9 place-items-center rounded-full bg-blue-600 text-white shadow-md">
|
| 200 |
+
+
|
| 201 |
+
</div>
|
| 202 |
+
</div>
|
| 203 |
+
);
|
| 204 |
+
})}
|
| 205 |
+
<div className="absolute bottom-3 left-3 rounded-xl bg-white/90 px-3 py-2 text-xs font-semibold text-slate-700 shadow-sm">
|
| 206 |
+
{sort === "best_rated" ? "Best rated" : sort === "fastest_route" ? "Fastest route" : "Closest"}
|
| 207 |
+
</div>
|
| 208 |
+
</div>
|
| 209 |
+
</div>
|
| 210 |
+
</div>
|
| 211 |
+
</Card>
|
| 212 |
+
</div>
|
| 213 |
+
</div>
|
| 214 |
+
</Container>
|
| 215 |
+
</main>
|
| 216 |
+
<SOSButton />
|
| 217 |
+
</div>
|
| 218 |
+
);
|
| 219 |
+
}
|
| 220 |
+
|
lifeline-ai/src/app/layout.tsx
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Metadata } from "next";
|
| 2 |
+
import { Geist, Geist_Mono } from "next/font/google";
|
| 3 |
+
import "./globals.css";
|
| 4 |
+
|
| 5 |
+
const geistSans = Geist({
|
| 6 |
+
variable: "--font-geist-sans",
|
| 7 |
+
subsets: ["latin"],
|
| 8 |
+
});
|
| 9 |
+
|
| 10 |
+
const geistMono = Geist_Mono({
|
| 11 |
+
variable: "--font-geist-mono",
|
| 12 |
+
subsets: ["latin"],
|
| 13 |
+
});
|
| 14 |
+
|
| 15 |
+
export const metadata: Metadata = {
|
| 16 |
+
title: "LifeLine AI",
|
| 17 |
+
description: "Emergency assistance: triage, hospitals, booking, SOS.",
|
| 18 |
+
};
|
| 19 |
+
|
| 20 |
+
export default function RootLayout({
|
| 21 |
+
children,
|
| 22 |
+
}: Readonly<{
|
| 23 |
+
children: React.ReactNode;
|
| 24 |
+
}>) {
|
| 25 |
+
return (
|
| 26 |
+
<html
|
| 27 |
+
lang="en"
|
| 28 |
+
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
| 29 |
+
>
|
| 30 |
+
<body className="min-h-full flex flex-col bg-white text-slate-900">
|
| 31 |
+
{children}
|
| 32 |
+
</body>
|
| 33 |
+
</html>
|
| 34 |
+
);
|
| 35 |
+
}
|
lifeline-ai/src/app/not-found.tsx
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import Link from "next/link";
|
| 2 |
+
import { Container, TopNav } from "@/components/ui";
|
| 3 |
+
|
| 4 |
+
export default function NotFound() {
|
| 5 |
+
return (
|
| 6 |
+
<div className="min-h-full bg-gradient-to-b from-white to-slate-50">
|
| 7 |
+
<TopNav />
|
| 8 |
+
<main className="py-16">
|
| 9 |
+
<Container>
|
| 10 |
+
<div className="rounded-2xl border border-slate-200 bg-white p-8 text-center shadow-sm">
|
| 11 |
+
<div className="text-2xl font-extrabold text-slate-900">Page not found</div>
|
| 12 |
+
<p className="mt-2 text-slate-600">Return to symptom input to continue the demo.</p>
|
| 13 |
+
<Link
|
| 14 |
+
href="/"
|
| 15 |
+
className="mt-6 inline-flex rounded-xl bg-blue-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-blue-700"
|
| 16 |
+
>
|
| 17 |
+
Go home
|
| 18 |
+
</Link>
|
| 19 |
+
</div>
|
| 20 |
+
</Container>
|
| 21 |
+
</main>
|
| 22 |
+
</div>
|
| 23 |
+
);
|
| 24 |
+
}
|
| 25 |
+
|
lifeline-ai/src/app/page.tsx
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useMemo, useState, useEffect } from "react";
|
| 4 |
+
import { useRouter } from "next/navigation";
|
| 5 |
+
import { Button, Card, Container, Pill, TopNav } from "@/components/ui";
|
| 6 |
+
import { uploadFiles } from "@/lib/api";
|
| 7 |
+
import { DEMO_CASES } from "@/lib/demo";
|
| 8 |
+
|
| 9 |
+
export default function Home() {
|
| 10 |
+
const router = useRouter();
|
| 11 |
+
const [symptoms, setSymptoms] = useState("");
|
| 12 |
+
const [location, setLocation] = useState("");
|
| 13 |
+
const [coords, setCoords] = useState<{ lat: number; lng: number } | null>(null);
|
| 14 |
+
const [toast, setToast] = useState<{ msg: string; kind: "info" | "error" } | null>(null);
|
| 15 |
+
const [files, setFiles] = useState<File[]>([]);
|
| 16 |
+
const [busy, setBusy] = useState(false);
|
| 17 |
+
const canSubmit = symptoms.trim().length >= 3 && location.trim().length >= 2 && !busy;
|
| 18 |
+
|
| 19 |
+
const fileNote = useMemo(() => {
|
| 20 |
+
if (files.length === 0) return "Upload optional reports (PDF/images/prescriptions).";
|
| 21 |
+
return `${files.length} file(s) selected. We'll attach filenames to your analysis.`;
|
| 22 |
+
}, [files.length]);
|
| 23 |
+
|
| 24 |
+
async function onSubmit() {
|
| 25 |
+
if (!canSubmit) return;
|
| 26 |
+
setBusy(true);
|
| 27 |
+
try {
|
| 28 |
+
let uploaded_files: string[] = [];
|
| 29 |
+
if (files.length) {
|
| 30 |
+
const up = await uploadFiles(files);
|
| 31 |
+
uploaded_files = up.uploaded_files ?? [];
|
| 32 |
+
}
|
| 33 |
+
const payload = { symptoms: symptoms.trim(), location: location.trim(), uploaded_files };
|
| 34 |
+
sessionStorage.setItem("lifeline:last_intake", JSON.stringify(payload));
|
| 35 |
+
router.push("/results");
|
| 36 |
+
} finally {
|
| 37 |
+
setBusy(false);
|
| 38 |
+
}
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
function enableGeolocation() {
|
| 42 |
+
if (!navigator.geolocation) return;
|
| 43 |
+
navigator.geolocation.getCurrentPosition(
|
| 44 |
+
(p) => {
|
| 45 |
+
const lat = p.coords.latitude;
|
| 46 |
+
const lng = p.coords.longitude;
|
| 47 |
+
// store coords and show a quick toast while we reverse-geocode
|
| 48 |
+
setCoords({ lat, lng });
|
| 49 |
+
setToast({ msg: "Location detected β resolving addressβ¦", kind: "info" });
|
| 50 |
+
reverseGeocodeAndSet(lat, lng);
|
| 51 |
+
},
|
| 52 |
+
(err) => {
|
| 53 |
+
console.warn("geolocation error", err);
|
| 54 |
+
setToast({ msg: "Could not get your location (permission denied or error).", kind: "error" });
|
| 55 |
+
},
|
| 56 |
+
{ enableHighAccuracy: true, timeout: 8000 }
|
| 57 |
+
);
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
useEffect(() => {
|
| 61 |
+
// Auto-detect on page load (try to get permission silently)
|
| 62 |
+
// Delay slightly to avoid immediate permission prompt in some browsers
|
| 63 |
+
const t = setTimeout(() => {
|
| 64 |
+
try {
|
| 65 |
+
enableGeolocation();
|
| 66 |
+
} catch (e) {
|
| 67 |
+
// ignore
|
| 68 |
+
}
|
| 69 |
+
}, 600);
|
| 70 |
+
return () => clearTimeout(t);
|
| 71 |
+
}, []);
|
| 72 |
+
|
| 73 |
+
async function reverseGeocodeAndSet(lat: number, lng: number) {
|
| 74 |
+
try {
|
| 75 |
+
const apiBase = process.env.NEXT_PUBLIC_API_BASE ?? "";
|
| 76 |
+
const url = `${apiBase}/reverse-geocode?lat=${encodeURIComponent(lat)}&lng=${encodeURIComponent(lng)}`;
|
| 77 |
+
const res = await fetch(url);
|
| 78 |
+
if (!res.ok) throw new Error("geocode failed");
|
| 79 |
+
const j = await res.json();
|
| 80 |
+
const name = j.display_name || `${lat.toFixed(4)},${lng.toFixed(4)}`;
|
| 81 |
+
setLocation(name);
|
| 82 |
+
setToast({ msg: `Detected location: ${name}`, kind: "info" });
|
| 83 |
+
// clear toast after a short delay
|
| 84 |
+
setTimeout(() => setToast(null), 4500);
|
| 85 |
+
} catch (e) {
|
| 86 |
+
setLocation(`${lat.toFixed(4)},${lng.toFixed(4)}`);
|
| 87 |
+
setToast({ msg: "Detected coordinates but failed to resolve address.", kind: "error" });
|
| 88 |
+
setTimeout(() => setToast(null), 4500);
|
| 89 |
+
}
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
function fillDemo(id: string) {
|
| 93 |
+
const c = DEMO_CASES.find((d) => d.id === id);
|
| 94 |
+
if (!c) return;
|
| 95 |
+
setSymptoms(c.symptoms);
|
| 96 |
+
setLocation(c.location);
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
return (
|
| 100 |
+
<div className="min-h-full bg-gradient-to-b from-white to-slate-50">
|
| 101 |
+
<TopNav />
|
| 102 |
+
{/* Simple toast */}
|
| 103 |
+
{toast && (
|
| 104 |
+
<div className={`fixed left-1/2 top-20 -translate-x-1/2 z-50 rounded-xl px-4 py-3 shadow-md ${
|
| 105 |
+
toast.kind === "error" ? "bg-red-600 text-white" : "bg-blue-600 text-white"
|
| 106 |
+
}`} role="status">
|
| 107 |
+
{toast.msg}
|
| 108 |
+
</div>
|
| 109 |
+
)}
|
| 110 |
+
<main className="py-10 sm:py-14">
|
| 111 |
+
<Container>
|
| 112 |
+
<div className="grid gap-8 lg:grid-cols-2 lg:items-start">
|
| 113 |
+
<div className="space-y-5">
|
| 114 |
+
<Pill className="bg-blue-50 text-blue-700">Not a diagnosis β’ Demo-ready triage</Pill>
|
| 115 |
+
<h1 className="text-3xl font-extrabold tracking-tight text-slate-900 sm:text-4xl">
|
| 116 |
+
Get urgent guidance and the best next stepβfast.
|
| 117 |
+
</h1>
|
| 118 |
+
<p className="text-slate-600 leading-7">
|
| 119 |
+
LifeLine AI helps you understand symptom urgency, find nearby hospitals, book an appointment, or request
|
| 120 |
+
emergency help. It always uses <span className="font-semibold">possible / likely</span> wordingβnever a final diagnosis.
|
| 121 |
+
</p>
|
| 122 |
+
|
| 123 |
+
<div className="grid gap-3 sm:grid-cols-3">
|
| 124 |
+
{DEMO_CASES.map((d) => (
|
| 125 |
+
<button
|
| 126 |
+
key={d.id}
|
| 127 |
+
onClick={() => fillDemo(d.id)}
|
| 128 |
+
className="rounded-2xl border border-slate-200 bg-white p-4 text-left shadow-sm transition hover:-translate-y-0.5 hover:border-blue-200 hover:shadow-md"
|
| 129 |
+
>
|
| 130 |
+
<div className="text-sm font-semibold text-slate-900">{d.title}</div>
|
| 131 |
+
<div className="mt-1 text-xs text-slate-500">Autofill demo</div>
|
| 132 |
+
</button>
|
| 133 |
+
))}
|
| 134 |
+
</div>
|
| 135 |
+
</div>
|
| 136 |
+
|
| 137 |
+
<Card>
|
| 138 |
+
<div className="space-y-4">
|
| 139 |
+
<div>
|
| 140 |
+
<div className="text-sm font-semibold text-slate-900">Describe symptoms</div>
|
| 141 |
+
<textarea
|
| 142 |
+
value={symptoms}
|
| 143 |
+
onChange={(e) => setSymptoms(e.target.value)}
|
| 144 |
+
placeholder="I have chest pain and shortness of breath"
|
| 145 |
+
className="mt-2 min-h-[120px] w-full resize-none rounded-2xl border border-slate-200 bg-white px-4 py-3 text-sm outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100"
|
| 146 |
+
/>
|
| 147 |
+
</div>
|
| 148 |
+
|
| 149 |
+
<div className="grid gap-3 sm:grid-cols-2">
|
| 150 |
+
<div>
|
| 151 |
+
<div className="text-sm font-semibold text-slate-900">Location</div>
|
| 152 |
+
<div className="mt-2 flex items-center gap-2">
|
| 153 |
+
<input
|
| 154 |
+
value={location}
|
| 155 |
+
onChange={(e) => setLocation(e.target.value)}
|
| 156 |
+
placeholder="Downtown / Westside / Northside"
|
| 157 |
+
className="flex-1 rounded-2xl border border-slate-200 bg-white px-4 py-3 text-sm outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100"
|
| 158 |
+
/>
|
| 159 |
+
<Button variant="secondary" onClick={enableGeolocation}>
|
| 160 |
+
Use my location
|
| 161 |
+
</Button>
|
| 162 |
+
</div>
|
| 163 |
+
{coords && (
|
| 164 |
+
<div className="mt-2 flex items-center gap-2">
|
| 165 |
+
<div className="rounded-lg bg-slate-50 px-3 py-2 text-sm text-slate-700">
|
| 166 |
+
<div className="font-semibold">Detected</div>
|
| 167 |
+
<div className="text-xs">{location}</div>
|
| 168 |
+
<div className="text-xs text-slate-500">{coords.lat.toFixed(4)}, {coords.lng.toFixed(4)}</div>
|
| 169 |
+
</div>
|
| 170 |
+
<Button variant="secondary" onClick={() => { setCoords(null); setLocation(""); }}>
|
| 171 |
+
Clear
|
| 172 |
+
</Button>
|
| 173 |
+
</div>
|
| 174 |
+
)}
|
| 175 |
+
</div>
|
| 176 |
+
<div>
|
| 177 |
+
<div className="text-sm font-semibold text-slate-900">Upload reports (optional)</div>
|
| 178 |
+
<input
|
| 179 |
+
type="file"
|
| 180 |
+
multiple
|
| 181 |
+
accept="application/pdf,image/*"
|
| 182 |
+
aria-label="Upload reports"
|
| 183 |
+
onChange={(e) => setFiles(Array.from(e.target.files ?? []))}
|
| 184 |
+
className="mt-2 block w-full text-sm file:mr-3 file:rounded-xl file:border-0 file:bg-slate-100 file:px-3 file:py-2 file:text-slate-900 hover:file:bg-slate-200"
|
| 185 |
+
/>
|
| 186 |
+
<div className="mt-2 text-xs text-slate-500">{fileNote}</div>
|
| 187 |
+
<div className="mt-1 text-[11px] font-semibold text-emerald-700">Secure upload indicator: encrypted in transit (demo)</div>
|
| 188 |
+
</div>
|
| 189 |
+
</div>
|
| 190 |
+
|
| 191 |
+
<div className="rounded-2xl border border-blue-100 bg-blue-50 p-4 text-sm text-blue-900">
|
| 192 |
+
<div className="font-semibold">Safety note</div>
|
| 193 |
+
<div className="mt-1 text-blue-800/90">
|
| 194 |
+
If you suspect a life-threatening emergency, use the SOS button for immediate ambulance dispatch simulation.
|
| 195 |
+
</div>
|
| 196 |
+
</div>
|
| 197 |
+
|
| 198 |
+
<div className="flex items-center justify-between gap-3">
|
| 199 |
+
<div className="text-xs text-slate-500">
|
| 200 |
+
By continuing, you agree this is informational only.
|
| 201 |
+
</div>
|
| 202 |
+
<Button onClick={onSubmit} disabled={!canSubmit}>
|
| 203 |
+
{busy ? "Analyzing..." : "Submit"}
|
| 204 |
+
</Button>
|
| 205 |
+
</div>
|
| 206 |
+
</div>
|
| 207 |
+
</Card>
|
| 208 |
+
</div>
|
| 209 |
+
</Container>
|
| 210 |
+
</main>
|
| 211 |
+
</div>
|
| 212 |
+
);
|
| 213 |
+
}
|
lifeline-ai/src/app/results/page.tsx
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useEffect, useMemo, useState } from "react";
|
| 4 |
+
import Link from "next/link";
|
| 5 |
+
import { analyze, AnalyzeResult } from "@/lib/api";
|
| 6 |
+
import { urgencyMeta } from "@/lib/demo";
|
| 7 |
+
import { Button, Card, Container, Pill, TopNav } from "@/components/ui";
|
| 8 |
+
import { SOSButton } from "@/components/SOSButton";
|
| 9 |
+
|
| 10 |
+
type Intake = { symptoms: string; location: string; uploaded_files: string[] };
|
| 11 |
+
|
| 12 |
+
export default function ResultsPage() {
|
| 13 |
+
const [intake] = useState<Intake | null>(() => {
|
| 14 |
+
if (typeof window === "undefined") return null;
|
| 15 |
+
try {
|
| 16 |
+
const raw = sessionStorage.getItem("lifeline:last_intake");
|
| 17 |
+
return raw ? (JSON.parse(raw) as Intake) : null;
|
| 18 |
+
} catch {
|
| 19 |
+
return null;
|
| 20 |
+
}
|
| 21 |
+
});
|
| 22 |
+
const [result, setResult] = useState<AnalyzeResult | null>(null);
|
| 23 |
+
const [error, setError] = useState<string | null>(null);
|
| 24 |
+
const [liveConfidence, setLiveConfidence] = useState(0.25);
|
| 25 |
+
|
| 26 |
+
useEffect(() => {
|
| 27 |
+
if (!intake) return;
|
| 28 |
+
let cancelled = false;
|
| 29 |
+
analyze(intake)
|
| 30 |
+
.then((r) => {
|
| 31 |
+
if (cancelled) return;
|
| 32 |
+
setResult(r);
|
| 33 |
+
})
|
| 34 |
+
.catch((e) => {
|
| 35 |
+
if (cancelled) return;
|
| 36 |
+
setError(e?.message ?? "Failed to analyze");
|
| 37 |
+
})
|
| 38 |
+
.finally(() => {
|
| 39 |
+
if (cancelled) return;
|
| 40 |
+
});
|
| 41 |
+
return () => {
|
| 42 |
+
cancelled = true;
|
| 43 |
+
};
|
| 44 |
+
}, [intake]);
|
| 45 |
+
|
| 46 |
+
useEffect(() => {
|
| 47 |
+
if (result || error) return;
|
| 48 |
+
const t = window.setInterval(() => {
|
| 49 |
+
setLiveConfidence((v) => Math.min(0.94, v + 0.03));
|
| 50 |
+
}, 350);
|
| 51 |
+
return () => window.clearInterval(t);
|
| 52 |
+
}, [result, error]);
|
| 53 |
+
|
| 54 |
+
const meta = useMemo(() => {
|
| 55 |
+
if (!result) return null;
|
| 56 |
+
return urgencyMeta(result.urgency);
|
| 57 |
+
}, [result]);
|
| 58 |
+
|
| 59 |
+
return (
|
| 60 |
+
<div className="min-h-full bg-gradient-to-b from-white to-slate-50">
|
| 61 |
+
<TopNav />
|
| 62 |
+
<main className="py-10 sm:py-14">
|
| 63 |
+
<Container>
|
| 64 |
+
<div className="flex items-start justify-between gap-4">
|
| 65 |
+
<div>
|
| 66 |
+
<Pill className="bg-slate-100 text-slate-800">AI-assisted triage β’ Not a diagnosis</Pill>
|
| 67 |
+
<h1 className="mt-3 text-3xl font-extrabold tracking-tight text-slate-900">Your results</h1>
|
| 68 |
+
<p className="mt-2 max-w-2xl text-slate-600">
|
| 69 |
+
We provide <span className="font-semibold">possible / likely</span> guidance only. If you feel unsafe, use SOS.
|
| 70 |
+
</p>
|
| 71 |
+
</div>
|
| 72 |
+
<Link
|
| 73 |
+
href="/"
|
| 74 |
+
className="rounded-xl border border-slate-200 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50"
|
| 75 |
+
>
|
| 76 |
+
New check
|
| 77 |
+
</Link>
|
| 78 |
+
</div>
|
| 79 |
+
|
| 80 |
+
<div className="mt-8 grid gap-6 lg:grid-cols-3">
|
| 81 |
+
<div className="lg:col-span-2 space-y-6">
|
| 82 |
+
<Card>
|
| 83 |
+
<div className="text-sm font-semibold text-slate-900">Your input</div>
|
| 84 |
+
<div className="mt-3 grid gap-3 sm:grid-cols-2">
|
| 85 |
+
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4">
|
| 86 |
+
<div className="text-xs font-semibold text-slate-600">Symptoms</div>
|
| 87 |
+
<div className="mt-1 text-sm text-slate-900">{intake?.symptoms || "β"}</div>
|
| 88 |
+
</div>
|
| 89 |
+
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4">
|
| 90 |
+
<div className="text-xs font-semibold text-slate-600">Location</div>
|
| 91 |
+
<div className="mt-1 text-sm text-slate-900">{intake?.location || "β"}</div>
|
| 92 |
+
<div className="mt-2 text-xs text-slate-500">
|
| 93 |
+
Attachments: {intake?.uploaded_files?.length ? intake.uploaded_files.join(", ") : "none"}
|
| 94 |
+
</div>
|
| 95 |
+
</div>
|
| 96 |
+
</div>
|
| 97 |
+
</Card>
|
| 98 |
+
|
| 99 |
+
<Card>
|
| 100 |
+
<div className="flex items-start justify-between gap-4">
|
| 101 |
+
<div>
|
| 102 |
+
<div className="text-sm font-semibold text-slate-900">AI analysis</div>
|
| 103 |
+
<div className="mt-1 text-xs text-slate-500">
|
| 104 |
+
{result?.confidence_note ?? "Running live model inference..."} β’ Always verify with a clinician
|
| 105 |
+
</div>
|
| 106 |
+
</div>
|
| 107 |
+
{meta && <Pill className={`${meta.pill} ${result?.urgency === "emergency" ? "animate-pulse-soft" : ""}`}>{meta.label} urgency</Pill>}
|
| 108 |
+
</div>
|
| 109 |
+
|
| 110 |
+
{!result && !error && intake && (
|
| 111 |
+
<div className="mt-5 space-y-2">
|
| 112 |
+
<div className="loading-shimmer h-4 w-40 rounded-lg" />
|
| 113 |
+
<div className="loading-shimmer h-4 w-full rounded-lg" />
|
| 114 |
+
<div className="loading-shimmer h-4 w-5/6 rounded-lg" />
|
| 115 |
+
<div className="pt-2">
|
| 116 |
+
<div className="mb-1 flex items-center justify-between text-[11px] font-semibold text-blue-700">
|
| 117 |
+
<span>Live confidence update</span>
|
| 118 |
+
<span>{Math.round(liveConfidence * 100)}%</span>
|
| 119 |
+
</div>
|
| 120 |
+
<div className="h-2 w-full rounded-full bg-blue-100">
|
| 121 |
+
<div className="h-2 rounded-full bg-blue-600 transition-all duration-300" style={{ width: `${Math.round(liveConfidence * 100)}%` }} />
|
| 122 |
+
</div>
|
| 123 |
+
</div>
|
| 124 |
+
</div>
|
| 125 |
+
)}
|
| 126 |
+
{error && <div className="mt-5 text-sm text-red-700">{error}</div>}
|
| 127 |
+
|
| 128 |
+
{result && (
|
| 129 |
+
<div className="mt-5 grid gap-4 sm:grid-cols-2 animate-fade-up">
|
| 130 |
+
<div className="rounded-2xl border border-slate-200 p-4">
|
| 131 |
+
<div className="text-xs font-semibold text-slate-600">Possible condition</div>
|
| 132 |
+
<div className="mt-1 text-base font-bold text-slate-900">{result.possible_condition}</div>
|
| 133 |
+
</div>
|
| 134 |
+
<div className="rounded-2xl border border-slate-200 p-4">
|
| 135 |
+
<div className="text-xs font-semibold text-slate-600">Recommended department</div>
|
| 136 |
+
<div className="mt-1 text-base font-bold text-slate-900">{result.recommended_department}</div>
|
| 137 |
+
</div>
|
| 138 |
+
<div className="rounded-2xl border border-blue-200 bg-blue-50 p-4 sm:col-span-2">
|
| 139 |
+
<div className="text-xs font-semibold text-blue-700">Model inference</div>
|
| 140 |
+
<div className="mt-1 text-sm text-blue-900">
|
| 141 |
+
Powered by <span className="font-bold">{result.model_provider}</span> model <span className="font-bold">{result.model_name}</span>
|
| 142 |
+
{" "}with confidence <span className="font-bold">{Math.round(result.confidence_score * 100)}%</span>.
|
| 143 |
+
</div>
|
| 144 |
+
</div>
|
| 145 |
+
<div className="rounded-2xl border border-slate-200 p-4 sm:col-span-2">
|
| 146 |
+
<div className="text-xs font-semibold text-slate-600">Recommended next step</div>
|
| 147 |
+
<div className="mt-1 text-sm text-slate-900">{result.recommended_next_step}</div>
|
| 148 |
+
</div>
|
| 149 |
+
<div className="rounded-2xl border border-slate-200 p-4 sm:col-span-2">
|
| 150 |
+
<div className="text-xs font-semibold text-slate-600">Temporary precautions</div>
|
| 151 |
+
<ul className="mt-2 list-disc space-y-1 pl-5 text-sm text-slate-900">
|
| 152 |
+
{result.temporary_precautions.map((p, i) => (
|
| 153 |
+
<li key={i}>{p}</li>
|
| 154 |
+
))}
|
| 155 |
+
</ul>
|
| 156 |
+
</div>
|
| 157 |
+
<div className="rounded-2xl border border-amber-200 bg-amber-50 p-4 sm:col-span-2">
|
| 158 |
+
<div className="text-xs font-semibold text-amber-900">Disclaimer</div>
|
| 159 |
+
<div className="mt-1 text-xs text-amber-900/90">{result.disclaimer}</div>
|
| 160 |
+
</div>
|
| 161 |
+
</div>
|
| 162 |
+
)}
|
| 163 |
+
</Card>
|
| 164 |
+
</div>
|
| 165 |
+
|
| 166 |
+
<div className="space-y-6">
|
| 167 |
+
<Card>
|
| 168 |
+
<div className="text-sm font-semibold text-slate-900">Next actions</div>
|
| 169 |
+
<div className="mt-4 grid gap-3">
|
| 170 |
+
<Link
|
| 171 |
+
href={`/hospitals?location=${encodeURIComponent(intake?.location ?? "")}`}
|
| 172 |
+
className="rounded-2xl border border-slate-200 bg-white p-4 hover:bg-slate-50"
|
| 173 |
+
>
|
| 174 |
+
<div className="text-sm font-bold text-slate-900">Find nearby hospitals</div>
|
| 175 |
+
<div className="mt-1 text-xs text-slate-500">Compare best-rated, closest, fastest route</div>
|
| 176 |
+
</Link>
|
| 177 |
+
<Link
|
| 178 |
+
href="/book"
|
| 179 |
+
className="rounded-2xl border border-slate-200 bg-white p-4 hover:bg-slate-50"
|
| 180 |
+
>
|
| 181 |
+
<div className="text-sm font-bold text-slate-900">Book an appointment</div>
|
| 182 |
+
<div className="mt-1 text-xs text-slate-500">Choose doctor + time slot</div>
|
| 183 |
+
</Link>
|
| 184 |
+
</div>
|
| 185 |
+
</Card>
|
| 186 |
+
|
| 187 |
+
<Card>
|
| 188 |
+
<div className="text-sm font-semibold text-slate-900">If this feels urgent</div>
|
| 189 |
+
<p className="mt-2 text-sm text-slate-600">
|
| 190 |
+
Use the SOS button to simulate ambulance dispatch with ETA and nearest hospital.
|
| 191 |
+
</p>
|
| 192 |
+
<div className="mt-4">
|
| 193 |
+
<Button variant="danger" onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}>
|
| 194 |
+
Safety reminder
|
| 195 |
+
</Button>
|
| 196 |
+
</div>
|
| 197 |
+
</Card>
|
| 198 |
+
</div>
|
| 199 |
+
</div>
|
| 200 |
+
</Container>
|
| 201 |
+
</main>
|
| 202 |
+
<SOSButton />
|
| 203 |
+
</div>
|
| 204 |
+
);
|
| 205 |
+
}
|
| 206 |
+
|
lifeline-ai/src/components/SOSButton.tsx
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useEffect, useMemo, useRef, useState } from "react";
|
| 4 |
+
import { Button, Card, Pill } from "@/components/ui";
|
| 5 |
+
import { sos } from "@/lib/api";
|
| 6 |
+
|
| 7 |
+
function formatEta(seconds: number) {
|
| 8 |
+
const m = Math.floor(seconds / 60);
|
| 9 |
+
const s = seconds % 60;
|
| 10 |
+
return `${m}:${String(s).padStart(2, "0")}`;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
export function SOSButton() {
|
| 14 |
+
const [open, setOpen] = useState(false);
|
| 15 |
+
const [busy, setBusy] = useState(false);
|
| 16 |
+
const [status, setStatus] = useState<"idle" | "dispatched">("idle");
|
| 17 |
+
const [eta, setEta] = useState<number>(0);
|
| 18 |
+
const [tracking, setTracking] = useState<string>("");
|
| 19 |
+
const [hospitalName, setHospitalName] = useState<string>("");
|
| 20 |
+
const [statusTick, setStatusTick] = useState(0);
|
| 21 |
+
const timerRef = useRef<number | null>(null);
|
| 22 |
+
|
| 23 |
+
const last = useMemo(() => {
|
| 24 |
+
if (typeof window === "undefined") return null;
|
| 25 |
+
try {
|
| 26 |
+
const raw = sessionStorage.getItem("lifeline:last_intake");
|
| 27 |
+
return raw ? (JSON.parse(raw) as { location?: string; symptoms?: string }) : null;
|
| 28 |
+
} catch {
|
| 29 |
+
return null;
|
| 30 |
+
}
|
| 31 |
+
}, []);
|
| 32 |
+
|
| 33 |
+
useEffect(() => {
|
| 34 |
+
return () => {
|
| 35 |
+
if (timerRef.current) window.clearInterval(timerRef.current);
|
| 36 |
+
};
|
| 37 |
+
}, []);
|
| 38 |
+
|
| 39 |
+
async function requestSOS() {
|
| 40 |
+
setBusy(true);
|
| 41 |
+
try {
|
| 42 |
+
const location = last?.location || "Downtown";
|
| 43 |
+
const symptoms = last?.symptoms || undefined;
|
| 44 |
+
const res = await sos(location, symptoms);
|
| 45 |
+
setStatus("dispatched");
|
| 46 |
+
setEta(res.eta_seconds);
|
| 47 |
+
setTracking(res.tracking_code);
|
| 48 |
+
setHospitalName(res.nearest_hospital.name);
|
| 49 |
+
if (timerRef.current) window.clearInterval(timerRef.current);
|
| 50 |
+
timerRef.current = window.setInterval(() => {
|
| 51 |
+
setEta((t) => (t > 0 ? t - 1 : 0));
|
| 52 |
+
setStatusTick((v) => v + 1);
|
| 53 |
+
}, 1000);
|
| 54 |
+
} finally {
|
| 55 |
+
setBusy(false);
|
| 56 |
+
}
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
const progress = status === "dispatched" ? Math.max(0, Math.min(100, 100 - Math.floor((eta / 900) * 100))) : 0;
|
| 60 |
+
const dispatchMsg =
|
| 61 |
+
statusTick % 6 < 2
|
| 62 |
+
? "Nearest ICU notified"
|
| 63 |
+
: statusTick % 6 < 4
|
| 64 |
+
? "Ambulance en route"
|
| 65 |
+
: "Paramedic team preparing arrival";
|
| 66 |
+
|
| 67 |
+
return (
|
| 68 |
+
<>
|
| 69 |
+
<button
|
| 70 |
+
aria-label="Emergency SOS"
|
| 71 |
+
onClick={() => setOpen(true)}
|
| 72 |
+
className="animate-bounce-soft fixed bottom-5 right-5 z-50 flex h-16 w-16 items-center justify-center rounded-full bg-red-600 text-white shadow-lg shadow-red-600/30 ring-1 ring-red-200 hover:bg-red-700 active:scale-[0.98] focus:outline-none focus:ring-4 focus:ring-red-200"
|
| 73 |
+
>
|
| 74 |
+
<span className="text-sm font-extrabold tracking-wide">SOS</span>
|
| 75 |
+
</button>
|
| 76 |
+
|
| 77 |
+
{open && (
|
| 78 |
+
<div className="fixed inset-0 z-50 grid place-items-end bg-slate-950/70 p-4 sm:place-items-center">
|
| 79 |
+
<div className="w-full max-w-md animate-fade-up">
|
| 80 |
+
<Card>
|
| 81 |
+
<div className="flex items-start justify-between gap-3">
|
| 82 |
+
<div>
|
| 83 |
+
<div className="text-lg font-extrabold text-slate-900">Emergency SOS</div>
|
| 84 |
+
<div className="mt-1 text-sm text-slate-600">
|
| 85 |
+
This is a demo simulation of ambulance dispatch. If this is real, call your local emergency number.
|
| 86 |
+
</div>
|
| 87 |
+
</div>
|
| 88 |
+
<button
|
| 89 |
+
className="rounded-xl px-3 py-2 text-sm font-semibold text-slate-600 hover:bg-slate-50"
|
| 90 |
+
onClick={() => setOpen(false)}
|
| 91 |
+
>
|
| 92 |
+
Close
|
| 93 |
+
</button>
|
| 94 |
+
</div>
|
| 95 |
+
|
| 96 |
+
<div className="mt-5 space-y-3">
|
| 97 |
+
{status === "idle" ? (
|
| 98 |
+
<>
|
| 99 |
+
<div className="rounded-2xl border border-red-100 bg-red-50 p-4 text-sm text-red-900">
|
| 100 |
+
<div className="font-semibold">When to use SOS</div>
|
| 101 |
+
<ul className="mt-2 list-disc pl-5 text-red-800/90 space-y-1">
|
| 102 |
+
<li>Severe chest pain, trouble breathing</li>
|
| 103 |
+
<li>Unresponsive / seizure / severe bleeding</li>
|
| 104 |
+
<li>Blue lips or sudden collapse</li>
|
| 105 |
+
</ul>
|
| 106 |
+
</div>
|
| 107 |
+
<Button variant="danger" onClick={requestSOS} disabled={busy}>
|
| 108 |
+
{busy ? "Dispatching..." : "Request Ambulance"}
|
| 109 |
+
</Button>
|
| 110 |
+
</>
|
| 111 |
+
) : (
|
| 112 |
+
<div className="rounded-2xl border border-emerald-100 bg-emerald-50 p-4">
|
| 113 |
+
<div className="flex items-center justify-between gap-3">
|
| 114 |
+
<Pill className="animate-pulse-soft bg-emerald-600 text-white">Ambulance dispatched</Pill>
|
| 115 |
+
<div className="text-xs font-semibold text-emerald-900">Tracking: {tracking}</div>
|
| 116 |
+
</div>
|
| 117 |
+
<div className="mt-3 text-sm text-emerald-900">
|
| 118 |
+
Nearest hospital: <span className="font-semibold">{hospitalName}</span>
|
| 119 |
+
</div>
|
| 120 |
+
<div className="mt-2 text-sm text-emerald-900">
|
| 121 |
+
ETA: <span className="font-extrabold tabular-nums">{formatEta(eta)}</span>
|
| 122 |
+
</div>
|
| 123 |
+
<div className="mt-3 rounded-xl border border-emerald-200 bg-white/60 p-2">
|
| 124 |
+
<div className="h-2 w-full rounded-full bg-emerald-100">
|
| 125 |
+
<div className="h-2 rounded-full bg-emerald-600 transition-all duration-700" style={{ width: `${progress}%` }} />
|
| 126 |
+
</div>
|
| 127 |
+
<div className="mt-1 text-[11px] font-semibold text-emerald-800">{dispatchMsg}</div>
|
| 128 |
+
</div>
|
| 129 |
+
<div className="mt-3 text-xs text-emerald-800/90">
|
| 130 |
+
Stay calm. If safe, unlock the door and keep phone volume on.
|
| 131 |
+
</div>
|
| 132 |
+
</div>
|
| 133 |
+
)}
|
| 134 |
+
</div>
|
| 135 |
+
</Card>
|
| 136 |
+
</div>
|
| 137 |
+
</div>
|
| 138 |
+
)}
|
| 139 |
+
</>
|
| 140 |
+
);
|
| 141 |
+
}
|
| 142 |
+
|
lifeline-ai/src/components/ui.tsx
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import Link from "next/link";
|
| 2 |
+
import { ReactNode } from "react";
|
| 3 |
+
|
| 4 |
+
export function Container({ children }: { children: ReactNode }) {
|
| 5 |
+
return <div className="mx-auto w-full max-w-6xl px-4 sm:px-6 lg:px-8">{children}</div>;
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
export function Card({ children }: { children: ReactNode }) {
|
| 9 |
+
return (
|
| 10 |
+
<div className="rounded-3xl border border-slate-200/90 bg-white shadow-[0_8px_30px_rgba(2,6,23,0.06)] transition hover:shadow-[0_12px_36px_rgba(2,6,23,0.09)]">
|
| 11 |
+
<div className="p-5 sm:p-6 md:p-7">{children}</div>
|
| 12 |
+
</div>
|
| 13 |
+
);
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
export function Button({
|
| 17 |
+
children,
|
| 18 |
+
onClick,
|
| 19 |
+
type = "button",
|
| 20 |
+
variant = "primary",
|
| 21 |
+
disabled,
|
| 22 |
+
}: {
|
| 23 |
+
children: ReactNode;
|
| 24 |
+
onClick?: () => void;
|
| 25 |
+
type?: "button" | "submit";
|
| 26 |
+
variant?: "primary" | "secondary" | "danger";
|
| 27 |
+
disabled?: boolean;
|
| 28 |
+
}) {
|
| 29 |
+
const base =
|
| 30 |
+
"inline-flex min-h-11 items-center justify-center gap-2 rounded-2xl px-4 py-2.5 text-sm font-semibold transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-60 disabled:cursor-not-allowed";
|
| 31 |
+
const styles =
|
| 32 |
+
variant === "danger"
|
| 33 |
+
? "bg-red-600 text-white shadow-md shadow-red-600/25 hover:-translate-y-0.5 hover:bg-red-700"
|
| 34 |
+
: variant === "secondary"
|
| 35 |
+
? "bg-white text-slate-900 border border-slate-200 hover:-translate-y-0.5 hover:bg-slate-50"
|
| 36 |
+
: "bg-blue-600 text-white shadow-md shadow-blue-600/20 hover:-translate-y-0.5 hover:bg-blue-700";
|
| 37 |
+
return (
|
| 38 |
+
<button type={type} onClick={onClick} disabled={disabled} className={`${base} ${styles}`}>
|
| 39 |
+
{children}
|
| 40 |
+
</button>
|
| 41 |
+
);
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
export function Pill({ children, className = "" }: { children: ReactNode; className?: string }) {
|
| 45 |
+
return (
|
| 46 |
+
<span className={`inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold ${className}`}>
|
| 47 |
+
{children}
|
| 48 |
+
</span>
|
| 49 |
+
);
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
export function TopNav() {
|
| 53 |
+
return (
|
| 54 |
+
<header className="sticky top-0 z-30 border-b border-slate-200/70 bg-white/85 backdrop-blur">
|
| 55 |
+
<Container>
|
| 56 |
+
<div className="flex h-16 items-center justify-between">
|
| 57 |
+
<Link href="/" className="flex items-center gap-2">
|
| 58 |
+
<div className="grid h-10 w-10 place-items-center rounded-2xl bg-gradient-to-br from-blue-500 to-blue-700 text-white font-black shadow-md shadow-blue-600/25">
|
| 59 |
+
LL
|
| 60 |
+
</div>
|
| 61 |
+
<div className="leading-tight">
|
| 62 |
+
<div className="text-sm font-extrabold tracking-tight text-slate-900">LifeLine AI</div>
|
| 63 |
+
<div className="text-xs text-slate-500">Emergency assistance MVP</div>
|
| 64 |
+
</div>
|
| 65 |
+
</Link>
|
| 66 |
+
<nav className="flex items-center gap-2 text-sm">
|
| 67 |
+
<Link className="rounded-lg px-3 py-2 text-slate-600 hover:bg-slate-50 hover:text-slate-900" href="/hospitals">
|
| 68 |
+
Hospitals
|
| 69 |
+
</Link>
|
| 70 |
+
<Link className="rounded-lg px-3 py-2 text-slate-600 hover:bg-slate-50 hover:text-slate-900" href="/book">
|
| 71 |
+
Book
|
| 72 |
+
</Link>
|
| 73 |
+
</nav>
|
| 74 |
+
</div>
|
| 75 |
+
</Container>
|
| 76 |
+
</header>
|
| 77 |
+
);
|
| 78 |
+
}
|
| 79 |
+
|
lifeline-ai/src/lib/api.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export type Urgency = "low" | "medium" | "high" | "emergency";
|
| 2 |
+
|
| 3 |
+
export type AnalyzeResult = {
|
| 4 |
+
possible_condition: string;
|
| 5 |
+
urgency: Urgency;
|
| 6 |
+
recommended_department: string;
|
| 7 |
+
temporary_precautions: string[];
|
| 8 |
+
recommended_next_step: string;
|
| 9 |
+
disclaimer: string;
|
| 10 |
+
confidence_note: string;
|
| 11 |
+
confidence_score: number;
|
| 12 |
+
model_provider: string;
|
| 13 |
+
model_name: string;
|
| 14 |
+
};
|
| 15 |
+
|
| 16 |
+
export type Hospital = {
|
| 17 |
+
id: string;
|
| 18 |
+
name: string;
|
| 19 |
+
distance_km: number;
|
| 20 |
+
eta_minutes: number;
|
| 21 |
+
rating: number;
|
| 22 |
+
specialties: string[];
|
| 23 |
+
availability: "open" | "limited" | "busy";
|
| 24 |
+
address: string;
|
| 25 |
+
phone: string;
|
| 26 |
+
lat: number;
|
| 27 |
+
lng: number;
|
| 28 |
+
};
|
| 29 |
+
|
| 30 |
+
export type HospitalsResponse = {
|
| 31 |
+
location: string;
|
| 32 |
+
sort: "best_rated" | "closest" | "fastest_route";
|
| 33 |
+
hospitals: Hospital[];
|
| 34 |
+
};
|
| 35 |
+
|
| 36 |
+
export type AppointmentRequest = {
|
| 37 |
+
hospital_id: string;
|
| 38 |
+
department: string;
|
| 39 |
+
doctor: string;
|
| 40 |
+
time_slot: string;
|
| 41 |
+
patient_name: string;
|
| 42 |
+
patient_phone: string;
|
| 43 |
+
};
|
| 44 |
+
|
| 45 |
+
export type Appointment = {
|
| 46 |
+
id: string;
|
| 47 |
+
hospital_id: string;
|
| 48 |
+
hospital_name: string;
|
| 49 |
+
department: string;
|
| 50 |
+
doctor: string;
|
| 51 |
+
time_slot: string;
|
| 52 |
+
patient_name: string;
|
| 53 |
+
patient_phone: string;
|
| 54 |
+
status: "confirmed" | "cancelled";
|
| 55 |
+
created_at_iso: string;
|
| 56 |
+
};
|
| 57 |
+
|
| 58 |
+
export type SosResponse = {
|
| 59 |
+
status: "ambulance_dispatched";
|
| 60 |
+
nearest_hospital: Hospital;
|
| 61 |
+
eta_seconds: number;
|
| 62 |
+
tracking_code: string;
|
| 63 |
+
message: string;
|
| 64 |
+
};
|
| 65 |
+
|
| 66 |
+
const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? "http://localhost:8000";
|
| 67 |
+
|
| 68 |
+
export async function uploadFiles(files: File[]) {
|
| 69 |
+
const fd = new FormData();
|
| 70 |
+
for (const f of files) fd.append("files", f);
|
| 71 |
+
const res = await fetch(`${API_BASE}/upload`, { method: "POST", body: fd });
|
| 72 |
+
if (!res.ok) throw new Error(`Upload failed (${res.status})`);
|
| 73 |
+
return (await res.json()) as { uploaded_files: string[] };
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
export async function analyze(payload: {
|
| 77 |
+
symptoms: string;
|
| 78 |
+
location: string;
|
| 79 |
+
uploaded_files: string[];
|
| 80 |
+
}) {
|
| 81 |
+
const fd = new FormData();
|
| 82 |
+
fd.set("symptoms", payload.symptoms);
|
| 83 |
+
fd.set("location", payload.location);
|
| 84 |
+
fd.set("uploaded_files", JSON.stringify(payload.uploaded_files ?? []));
|
| 85 |
+
const res = await fetch(`${API_BASE}/analyze`, { method: "POST", body: fd });
|
| 86 |
+
if (!res.ok) throw new Error(`Analyze failed (${res.status})`);
|
| 87 |
+
return (await res.json()) as AnalyzeResult;
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
export async function getHospitals(
|
| 91 |
+
location: string,
|
| 92 |
+
sort: HospitalsResponse["sort"],
|
| 93 |
+
lat?: number | null,
|
| 94 |
+
lng?: number | null
|
| 95 |
+
) {
|
| 96 |
+
const url = new URL(`${API_BASE}/hospitals`);
|
| 97 |
+
url.searchParams.set("location", location);
|
| 98 |
+
url.searchParams.set("sort", sort);
|
| 99 |
+
if (typeof lat === "number" && typeof lng === "number") {
|
| 100 |
+
url.searchParams.set("lat", String(lat));
|
| 101 |
+
url.searchParams.set("lng", String(lng));
|
| 102 |
+
}
|
| 103 |
+
const res = await fetch(url.toString());
|
| 104 |
+
if (!res.ok) throw new Error(`Hospitals failed (${res.status})`);
|
| 105 |
+
return (await res.json()) as HospitalsResponse;
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
export async function createAppointment(req: AppointmentRequest) {
|
| 109 |
+
const res = await fetch(`${API_BASE}/appointments`, {
|
| 110 |
+
method: "POST",
|
| 111 |
+
headers: { "Content-Type": "application/json" },
|
| 112 |
+
body: JSON.stringify(req),
|
| 113 |
+
});
|
| 114 |
+
if (!res.ok) throw new Error(`Appointment failed (${res.status})`);
|
| 115 |
+
return (await res.json()) as { appointment: Appointment };
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
export async function sos(location: string, symptoms?: string) {
|
| 119 |
+
const res = await fetch(`${API_BASE}/sos`, {
|
| 120 |
+
method: "POST",
|
| 121 |
+
headers: { "Content-Type": "application/json" },
|
| 122 |
+
body: JSON.stringify({ location, symptoms }),
|
| 123 |
+
});
|
| 124 |
+
if (!res.ok) throw new Error(`SOS failed (${res.status})`);
|
| 125 |
+
return (await res.json()) as SosResponse;
|
| 126 |
+
}
|
| 127 |
+
|
lifeline-ai/src/lib/demo.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export const DEMO_CASES = [
|
| 2 |
+
{
|
| 3 |
+
id: "demo_cardiac",
|
| 4 |
+
title: "Emergency cardiac case",
|
| 5 |
+
symptoms: "I have chest pain and shortness of breath. The pain is radiating to my left arm.",
|
| 6 |
+
location: "Westside",
|
| 7 |
+
},
|
| 8 |
+
{
|
| 9 |
+
id: "demo_fever",
|
| 10 |
+
title: "Fever & sore throat",
|
| 11 |
+
symptoms: "I have fever and sore throat for the last 2 days.",
|
| 12 |
+
location: "Downtown",
|
| 13 |
+
},
|
| 14 |
+
{
|
| 15 |
+
id: "demo_collapse",
|
| 16 |
+
title: "Unresponsive collapse",
|
| 17 |
+
symptoms: "Person collapsed and is unresponsive. Blue lips and very slow breathing.",
|
| 18 |
+
location: "Northside",
|
| 19 |
+
},
|
| 20 |
+
] as const;
|
| 21 |
+
|
| 22 |
+
export function urgencyMeta(urgency: "low" | "medium" | "high" | "emergency") {
|
| 23 |
+
switch (urgency) {
|
| 24 |
+
case "emergency":
|
| 25 |
+
return { label: "Emergency", pill: "bg-red-600 text-white", border: "border-red-200" };
|
| 26 |
+
case "high":
|
| 27 |
+
return { label: "High", pill: "bg-orange-500 text-white", border: "border-orange-200" };
|
| 28 |
+
case "medium":
|
| 29 |
+
return { label: "Medium", pill: "bg-blue-600 text-white", border: "border-blue-200" };
|
| 30 |
+
default:
|
| 31 |
+
return { label: "Low", pill: "bg-emerald-600 text-white", border: "border-emerald-200" };
|
| 32 |
+
}
|
| 33 |
+
}
|
| 34 |
+
|
lifeline-ai/tsconfig.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"compilerOptions": {
|
| 3 |
+
"target": "ES2017",
|
| 4 |
+
"lib": ["dom", "dom.iterable", "esnext"],
|
| 5 |
+
"allowJs": true,
|
| 6 |
+
"skipLibCheck": true,
|
| 7 |
+
"strict": true,
|
| 8 |
+
"noEmit": true,
|
| 9 |
+
"esModuleInterop": true,
|
| 10 |
+
"module": "esnext",
|
| 11 |
+
"moduleResolution": "bundler",
|
| 12 |
+
"resolveJsonModule": true,
|
| 13 |
+
"isolatedModules": true,
|
| 14 |
+
"jsx": "react-jsx",
|
| 15 |
+
"incremental": true,
|
| 16 |
+
"plugins": [
|
| 17 |
+
{
|
| 18 |
+
"name": "next"
|
| 19 |
+
}
|
| 20 |
+
],
|
| 21 |
+
"paths": {
|
| 22 |
+
"@/*": ["./src/*"]
|
| 23 |
+
}
|
| 24 |
+
},
|
| 25 |
+
"include": [
|
| 26 |
+
"next-env.d.ts",
|
| 27 |
+
"**/*.ts",
|
| 28 |
+
"**/*.tsx",
|
| 29 |
+
".next/types/**/*.ts",
|
| 30 |
+
".next/dev/types/**/*.ts",
|
| 31 |
+
"**/*.mts"
|
| 32 |
+
],
|
| 33 |
+
"exclude": ["node_modules"]
|
| 34 |
+
}
|
medical_data.json
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"condition": "Common Cold",
|
| 4 |
+
"symptoms": "sneezing, runny nose, cough, sore throat, mild fever, congestion",
|
| 5 |
+
"causes": "Usually caused by viral infections affecting the upper respiratory tract.",
|
| 6 |
+
"precautions": "Drink warm fluids, rest well, maintain hygiene, and avoid cold exposure.",
|
| 7 |
+
"see_doctor": "Consult a doctor if symptoms last more than a week or breathing becomes difficult."
|
| 8 |
+
},
|
| 9 |
+
{
|
| 10 |
+
"condition": "Flu",
|
| 11 |
+
"symptoms": "fever, cough, fatigue, body pain, chills, headache, weakness",
|
| 12 |
+
"causes": "Caused by the influenza virus and spreads through respiratory droplets.",
|
| 13 |
+
"precautions": "Take rest, stay hydrated, isolate if contagious, and monitor temperature.",
|
| 14 |
+
"see_doctor": "See a doctor if fever is very high, weakness is severe, or symptoms worsen."
|
| 15 |
+
},
|
| 16 |
+
{
|
| 17 |
+
"condition": "Pneumonia",
|
| 18 |
+
"symptoms": "fever, cough, chest pain, shortness of breath, fatigue, chills",
|
| 19 |
+
"causes": "Caused by bacterial, viral, or fungal infection in the lungs.",
|
| 20 |
+
"precautions": "Rest, drink fluids, avoid smoking, and monitor breathing closely.",
|
| 21 |
+
"see_doctor": "Seek immediate medical help if breathing difficulty or chest pain is severe."
|
| 22 |
+
},
|
| 23 |
+
{
|
| 24 |
+
"condition": "Asthma",
|
| 25 |
+
"symptoms": "wheezing, shortness of breath, chest tightness, cough, difficulty breathing",
|
| 26 |
+
"causes": "Triggered by allergens, dust, smoke, exercise, or respiratory infections.",
|
| 27 |
+
"precautions": "Avoid triggers, keep inhaler nearby, and avoid dusty or smoky environments.",
|
| 28 |
+
"see_doctor": "Seek urgent care if breathing becomes difficult or inhaler does not help."
|
| 29 |
+
},
|
| 30 |
+
{
|
| 31 |
+
"condition": "Bronchitis",
|
| 32 |
+
"symptoms": "cough, mucus, fatigue, chest discomfort, mild fever, sore throat",
|
| 33 |
+
"causes": "Usually caused by viral infections or irritation of the airways.",
|
| 34 |
+
"precautions": "Rest, stay hydrated, avoid smoking, and use steam inhalation if needed.",
|
| 35 |
+
"see_doctor": "Consult a doctor if cough persists for more than 3 weeks or breathing worsens."
|
| 36 |
+
},
|
| 37 |
+
{
|
| 38 |
+
"condition": "COVID-19",
|
| 39 |
+
"symptoms": "fever, cough, fatigue, loss of smell, sore throat, body pain, shortness of breath",
|
| 40 |
+
"causes": "Caused by the SARS-CoV-2 virus and spreads through droplets or close contact.",
|
| 41 |
+
"precautions": "Isolate, hydrate, monitor oxygen levels if possible, and rest well.",
|
| 42 |
+
"see_doctor": "Seek medical care if breathing difficulty, chest pain, or low oxygen occurs."
|
| 43 |
+
},
|
| 44 |
+
{
|
| 45 |
+
"condition": "Tuberculosis",
|
| 46 |
+
"symptoms": "persistent cough, weight loss, night sweats, fever, chest pain, fatigue",
|
| 47 |
+
"causes": "Caused by Mycobacterium tuberculosis infection, mainly affecting the lungs.",
|
| 48 |
+
"precautions": "Seek early treatment, avoid spreading infection, and follow medical therapy strictly.",
|
| 49 |
+
"see_doctor": "See a doctor if cough lasts more than 2 weeks or blood appears in sputum."
|
| 50 |
+
},
|
| 51 |
+
{
|
| 52 |
+
"condition": "Sinusitis",
|
| 53 |
+
"symptoms": "headache, facial pain, nasal congestion, fever, runny nose, pressure around eyes",
|
| 54 |
+
"causes": "Usually caused by infection or inflammation of the sinus cavities.",
|
| 55 |
+
"precautions": "Steam inhalation, hydration, rest, and nasal saline rinses may help.",
|
| 56 |
+
"see_doctor": "Consult a doctor if symptoms are severe or last more than 10 days."
|
| 57 |
+
},
|
| 58 |
+
{
|
| 59 |
+
"condition": "Migraine",
|
| 60 |
+
"symptoms": "headache, nausea, vomiting, sensitivity to light, sensitivity to sound, dizziness",
|
| 61 |
+
"causes": "Can be triggered by stress, lack of sleep, hormonal changes, or certain foods.",
|
| 62 |
+
"precautions": "Rest in a dark quiet room, stay hydrated, and avoid known triggers.",
|
| 63 |
+
"see_doctor": "Seek medical help if headache is sudden, severe, or different from usual migraines."
|
| 64 |
+
},
|
| 65 |
+
{
|
| 66 |
+
"condition": "Tension Headache",
|
| 67 |
+
"symptoms": "headache, pressure around head, neck pain, stress, mild dizziness",
|
| 68 |
+
"causes": "Often caused by stress, poor posture, eye strain, or muscle tension.",
|
| 69 |
+
"precautions": "Rest, hydration, posture correction, and stress reduction can help.",
|
| 70 |
+
"see_doctor": "See a doctor if headaches become frequent or severe."
|
| 71 |
+
},
|
| 72 |
+
{
|
| 73 |
+
"condition": "Hypertension",
|
| 74 |
+
"symptoms": "headache, dizziness, blurred vision, nosebleeds, chest discomfort",
|
| 75 |
+
"causes": "Can result from genetics, stress, obesity, high salt intake, or underlying disease.",
|
| 76 |
+
"precautions": "Reduce salt, exercise, manage stress, and monitor blood pressure regularly.",
|
| 77 |
+
"see_doctor": "Consult a doctor if blood pressure is consistently high or symptoms worsen."
|
| 78 |
+
},
|
| 79 |
+
{
|
| 80 |
+
"condition": "Hypotension",
|
| 81 |
+
"symptoms": "dizziness, fainting, weakness, blurred vision, nausea",
|
| 82 |
+
"causes": "May be caused by dehydration, blood loss, heart problems, or low blood sugar.",
|
| 83 |
+
"precautions": "Drink fluids, avoid sudden standing, and eat balanced meals.",
|
| 84 |
+
"see_doctor": "See a doctor if fainting, confusion, or repeated dizziness occurs."
|
| 85 |
+
},
|
| 86 |
+
{
|
| 87 |
+
"condition": "Diabetes",
|
| 88 |
+
"symptoms": "frequent urination, increased thirst, fatigue, blurred vision, weight loss, hunger",
|
| 89 |
+
"causes": "Caused by high blood sugar levels due to insulin-related problems.",
|
| 90 |
+
"precautions": "Control sugar intake, exercise, stay hydrated, and monitor glucose levels.",
|
| 91 |
+
"see_doctor": "Consult a doctor if symptoms are persistent or sugar levels are abnormal."
|
| 92 |
+
},
|
| 93 |
+
{
|
| 94 |
+
"condition": "Hypoglycemia",
|
| 95 |
+
"symptoms": "sweating, shaking, hunger, dizziness, weakness, confusion, rapid heartbeat",
|
| 96 |
+
"causes": "Caused by low blood sugar, often due to skipping meals or diabetes medication.",
|
| 97 |
+
"precautions": "Take glucose or sugary food immediately and monitor symptoms.",
|
| 98 |
+
"see_doctor": "Seek urgent help if confusion, unconsciousness, or seizures occur."
|
| 99 |
+
},
|
| 100 |
+
{
|
| 101 |
+
"condition": "Anemia",
|
| 102 |
+
"symptoms": "fatigue, weakness, pale skin, dizziness, shortness of breath, headache",
|
| 103 |
+
"causes": "Often caused by iron deficiency, blood loss, or low red blood cell production.",
|
| 104 |
+
"precautions": "Eat iron-rich foods, stay hydrated, and follow prescribed supplements.",
|
| 105 |
+
"see_doctor": "Consult a doctor if fatigue is severe or symptoms persist."
|
| 106 |
+
},
|
| 107 |
+
{
|
| 108 |
+
"condition": "Dehydration",
|
| 109 |
+
"symptoms": "dry mouth, dizziness, fatigue, dark urine, thirst, headache",
|
| 110 |
+
"causes": "Caused by excessive fluid loss through sweating, vomiting, diarrhea, or poor intake.",
|
| 111 |
+
"precautions": "Drink oral fluids, rest, and avoid excessive heat exposure.",
|
| 112 |
+
"see_doctor": "Seek care if confusion, very low urine output, or severe weakness occurs."
|
| 113 |
+
},
|
| 114 |
+
{
|
| 115 |
+
"condition": "Gastroenteritis",
|
| 116 |
+
"symptoms": "diarrhea, vomiting, stomach pain, nausea, fever, dehydration",
|
| 117 |
+
"causes": "Usually caused by viral or bacterial infection of the digestive tract.",
|
| 118 |
+
"precautions": "Drink ORS, eat light foods, rest, and maintain hygiene.",
|
| 119 |
+
"see_doctor": "See a doctor if there is blood in stool, severe dehydration, or persistent vomiting."
|
| 120 |
+
},
|
| 121 |
+
{
|
| 122 |
+
"condition": "Food Poisoning",
|
| 123 |
+
"symptoms": "vomiting, diarrhea, stomach cramps, nausea, fever, weakness",
|
| 124 |
+
"causes": "Caused by contaminated food or drink containing bacteria, viruses, or toxins.",
|
| 125 |
+
"precautions": "Hydrate well, avoid heavy foods, and rest.",
|
| 126 |
+
"see_doctor": "Seek medical help if symptoms are severe, prolonged, or involve dehydration."
|
| 127 |
+
},
|
| 128 |
+
{
|
| 129 |
+
"condition": "Acidity",
|
| 130 |
+
"symptoms": "burning chest pain, stomach discomfort, bloating, sour taste, nausea",
|
| 131 |
+
"causes": "Often caused by excess stomach acid, spicy foods, stress, or irregular eating habits.",
|
| 132 |
+
"precautions": "Avoid spicy/oily foods, eat smaller meals, and avoid lying down after eating.",
|
| 133 |
+
"see_doctor": "Consult a doctor if symptoms are frequent or severe."
|
| 134 |
+
},
|
| 135 |
+
{
|
| 136 |
+
"condition": "Gastritis",
|
| 137 |
+
"symptoms": "stomach pain, nausea, bloating, vomiting, burning sensation, loss of appetite",
|
| 138 |
+
"causes": "Caused by irritation or inflammation of the stomach lining.",
|
| 139 |
+
"precautions": "Avoid alcohol, spicy foods, and take prescribed medication if advised.",
|
| 140 |
+
"see_doctor": "See a doctor if vomiting blood, black stools, or severe pain occurs."
|
| 141 |
+
},
|
| 142 |
+
{
|
| 143 |
+
"condition": "Appendicitis",
|
| 144 |
+
"symptoms": "lower right abdominal pain, nausea, vomiting, fever, loss of appetite",
|
| 145 |
+
"causes": "Caused by inflammation of the appendix, often due to blockage or infection.",
|
| 146 |
+
"precautions": "Avoid eating or drinking and seek urgent medical evaluation.",
|
| 147 |
+
"see_doctor": "Go to the emergency room immediately if appendicitis is suspected."
|
| 148 |
+
},
|
| 149 |
+
{
|
| 150 |
+
"condition": "Urinary Tract Infection",
|
| 151 |
+
"symptoms": "burning urination, frequent urination, lower abdominal pain, fever, cloudy urine",
|
| 152 |
+
"causes": "Usually caused by bacterial infection in the urinary tract.",
|
| 153 |
+
"precautions": "Drink plenty of water, maintain hygiene, and do not hold urine.",
|
| 154 |
+
"see_doctor": "Consult a doctor if fever, back pain, or worsening symptoms occur."
|
| 155 |
+
},
|
| 156 |
+
{
|
| 157 |
+
"condition": "Kidney Stones",
|
| 158 |
+
"symptoms": "severe side pain, blood in urine, nausea, vomiting, painful urination",
|
| 159 |
+
"causes": "Caused by mineral and salt deposits forming in the kidneys.",
|
| 160 |
+
"precautions": "Drink lots of water and avoid dehydration.",
|
| 161 |
+
"see_doctor": "Seek urgent medical help if pain is severe or urine is blocked."
|
| 162 |
+
},
|
| 163 |
+
{
|
| 164 |
+
"condition": "Menstrual Cramps",
|
| 165 |
+
"symptoms": "lower abdominal pain, back pain, nausea, fatigue, mood changes",
|
| 166 |
+
"causes": "Caused by uterine muscle contractions during menstruation.",
|
| 167 |
+
"precautions": "Rest, warm compress, hydration, and light movement may help.",
|
| 168 |
+
"see_doctor": "Consult a doctor if pain is unusually severe or disrupts daily life."
|
| 169 |
+
},
|
| 170 |
+
{
|
| 171 |
+
"condition": "PCOS",
|
| 172 |
+
"symptoms": "irregular periods, acne, weight gain, excess hair growth, hair thinning",
|
| 173 |
+
"causes": "A hormonal disorder affecting ovulation and metabolism.",
|
| 174 |
+
"precautions": "Maintain healthy diet, exercise, and follow medical advice.",
|
| 175 |
+
"see_doctor": "Consult a gynecologist if symptoms persist or worsen."
|
| 176 |
+
},
|
| 177 |
+
{
|
| 178 |
+
"condition": "Pregnancy Nausea",
|
| 179 |
+
"symptoms": "nausea, vomiting, fatigue, missed period, dizziness",
|
| 180 |
+
"causes": "Usually caused by hormonal changes during early pregnancy.",
|
| 181 |
+
"precautions": "Eat small frequent meals, stay hydrated, and rest well.",
|
| 182 |
+
"see_doctor": "See a doctor if vomiting is severe or dehydration occurs."
|
| 183 |
+
},
|
| 184 |
+
{
|
| 185 |
+
"condition": "Allergic Rhinitis",
|
| 186 |
+
"symptoms": "sneezing, itchy nose, runny nose, watery eyes, congestion",
|
| 187 |
+
"causes": "Triggered by allergens such as pollen, dust, mold, or pet dander.",
|
| 188 |
+
"precautions": "Avoid allergens, keep surroundings clean, and use prescribed allergy medication.",
|
| 189 |
+
"see_doctor": "Consult a doctor if symptoms are frequent or severe."
|
| 190 |
+
},
|
| 191 |
+
{
|
| 192 |
+
"condition": "Skin Allergy",
|
| 193 |
+
"symptoms": "itching, rash, redness, swelling, hives",
|
| 194 |
+
"causes": "Can be triggered by foods, medicines, insect bites, or contact irritants.",
|
| 195 |
+
"precautions": "Avoid suspected triggers, keep skin cool, and avoid scratching.",
|
| 196 |
+
"see_doctor": "Seek medical help if swelling affects breathing or rash becomes severe."
|
| 197 |
+
},
|
| 198 |
+
{
|
| 199 |
+
"condition": "Fungal Infection",
|
| 200 |
+
"symptoms": "itching, skin rash, redness, scaling, irritation",
|
| 201 |
+
"causes": "Caused by fungal growth on the skin, often in warm or moist areas.",
|
| 202 |
+
"precautions": "Keep skin dry, maintain hygiene, and avoid sharing towels or clothes.",
|
| 203 |
+
"see_doctor": "Consult a doctor if rash spreads or does not improve."
|
| 204 |
+
},
|
| 205 |
+
{
|
| 206 |
+
"condition": "Eczema",
|
| 207 |
+
"symptoms": "itching, dry skin, rash, redness, skin irritation",
|
| 208 |
+
"causes": "Linked to skin barrier problems, allergies, and inflammation.",
|
| 209 |
+
"precautions": "Moisturize regularly, avoid irritants, and keep skin hydrated.",
|
| 210 |
+
"see_doctor": "See a doctor if rash is severe, infected, or persistent."
|
| 211 |
+
},
|
| 212 |
+
{
|
| 213 |
+
"condition": "Psoriasis",
|
| 214 |
+
"symptoms": "red patches, scaling, itching, dry skin, joint discomfort",
|
| 215 |
+
"causes": "An immune-related skin condition causing rapid skin cell buildup.",
|
| 216 |
+
"precautions": "Moisturize skin, avoid triggers, and follow prescribed treatment.",
|
| 217 |
+
"see_doctor": "Consult a dermatologist if symptoms worsen or affect daily life."
|
| 218 |
+
},
|
| 219 |
+
{
|
| 220 |
+
"condition": "Conjunctivitis",
|
| 221 |
+
"symptoms": "red eyes, itching eyes, watery eyes, discharge, irritation",
|
| 222 |
+
"causes": "Can be caused by viral, bacterial, or allergic inflammation of the eye.",
|
| 223 |
+
"precautions": "Avoid touching eyes, maintain hygiene, and do not share towels.",
|
| 224 |
+
"see_doctor": "See a doctor if pain, blurred vision, or severe redness occurs."
|
| 225 |
+
},
|
| 226 |
+
{
|
| 227 |
+
"condition": "Ear Infection",
|
| 228 |
+
"symptoms": "ear pain, fever, hearing difficulty, discharge, irritability",
|
| 229 |
+
"causes": "Usually caused by bacterial or viral infection in the ear.",
|
| 230 |
+
"precautions": "Avoid inserting objects in ear and follow treatment advice.",
|
| 231 |
+
"see_doctor": "Consult a doctor if fever, discharge, or severe pain occurs."
|
| 232 |
+
},
|
| 233 |
+
{
|
| 234 |
+
"condition": "Throat Infection",
|
| 235 |
+
"symptoms": "sore throat, fever, difficulty swallowing, swollen glands, cough",
|
| 236 |
+
"causes": "Can be caused by viral or bacterial infection.",
|
| 237 |
+
"precautions": "Warm fluids, rest, and avoid cold foods or irritants.",
|
| 238 |
+
"see_doctor": "See a doctor if swallowing becomes difficult or fever persists."
|
| 239 |
+
},
|
| 240 |
+
{
|
| 241 |
+
"condition": "Tonsillitis",
|
| 242 |
+
"symptoms": "sore throat, fever, swollen tonsils, bad breath, difficulty swallowing",
|
| 243 |
+
"causes": "Caused by viral or bacterial infection of the tonsils.",
|
| 244 |
+
"precautions": "Warm fluids, rest, and maintain hydration.",
|
| 245 |
+
"see_doctor": "Consult a doctor if symptoms are severe or recurrent."
|
| 246 |
+
},
|
| 247 |
+
{
|
| 248 |
+
"condition": "Dengue",
|
| 249 |
+
"symptoms": "high fever, headache, body pain, rash, nausea, weakness",
|
| 250 |
+
"causes": "Caused by dengue virus transmitted by mosquito bites.",
|
| 251 |
+
"precautions": "Stay hydrated, rest, avoid mosquito exposure, and monitor symptoms.",
|
| 252 |
+
"see_doctor": "Seek immediate care if bleeding, severe weakness, or abdominal pain occurs."
|
| 253 |
+
},
|
| 254 |
+
{
|
| 255 |
+
"condition": "Malaria",
|
| 256 |
+
"symptoms": "fever, chills, sweating, headache, vomiting, body pain",
|
| 257 |
+
"causes": "Caused by Plasmodium parasites transmitted through mosquito bites.",
|
| 258 |
+
"precautions": "Use mosquito protection, rest, and seek early treatment.",
|
| 259 |
+
"see_doctor": "Consult a doctor immediately if malaria is suspected."
|
| 260 |
+
},
|
| 261 |
+
{
|
| 262 |
+
"condition": "Typhoid",
|
| 263 |
+
"symptoms": "fever, stomach pain, weakness, headache, diarrhea, constipation",
|
| 264 |
+
"causes": "Caused by Salmonella typhi infection, often through contaminated food or water.",
|
| 265 |
+
"precautions": "Drink safe water, eat hygienic food, and rest well.",
|
| 266 |
+
"see_doctor": "See a doctor if persistent fever or severe weakness occurs."
|
| 267 |
+
},
|
| 268 |
+
{
|
| 269 |
+
"condition": "Hepatitis",
|
| 270 |
+
"symptoms": "yellow eyes, yellow skin, fatigue, nausea, abdominal pain, dark urine",
|
| 271 |
+
"causes": "Caused by liver inflammation due to viruses, alcohol, or other liver injury.",
|
| 272 |
+
"precautions": "Avoid alcohol, rest, hydrate, and seek medical evaluation.",
|
| 273 |
+
"see_doctor": "Consult a doctor immediately if jaundice appears."
|
| 274 |
+
},
|
| 275 |
+
{
|
| 276 |
+
"condition": "Jaundice",
|
| 277 |
+
"symptoms": "yellow eyes, yellow skin, fatigue, dark urine, abdominal discomfort",
|
| 278 |
+
"causes": "Often linked to liver disease, bile duct problems, or excess bilirubin.",
|
| 279 |
+
"precautions": "Avoid alcohol and seek medical evaluation promptly.",
|
| 280 |
+
"see_doctor": "See a doctor as soon as jaundice is noticed."
|
| 281 |
+
},
|
| 282 |
+
{
|
| 283 |
+
"condition": "Heart Attack",
|
| 284 |
+
"symptoms": "chest pain, sweating, shortness of breath, nausea, left arm pain, dizziness",
|
| 285 |
+
"causes": "Caused by blocked blood flow to the heart muscle.",
|
| 286 |
+
"precautions": "This is a medical emergency. Rest and seek emergency help immediately.",
|
| 287 |
+
"see_doctor": "Call emergency services immediately if heart attack symptoms are present."
|
| 288 |
+
},
|
| 289 |
+
{
|
| 290 |
+
"condition": "Angina",
|
| 291 |
+
"symptoms": "chest pain, chest pressure, shortness of breath, fatigue, arm discomfort",
|
| 292 |
+
"causes": "Caused by reduced blood flow to the heart muscle.",
|
| 293 |
+
"precautions": "Avoid exertion and seek medical evaluation.",
|
| 294 |
+
"see_doctor": "Consult a doctor urgently if chest pain is recurrent or worsening."
|
| 295 |
+
},
|
| 296 |
+
{
|
| 297 |
+
"condition": "Stroke",
|
| 298 |
+
"symptoms": "sudden weakness, facial droop, slurred speech, confusion, dizziness, headache",
|
| 299 |
+
"causes": "Caused by interrupted blood flow or bleeding in the brain.",
|
| 300 |
+
"precautions": "This is a medical emergency and needs immediate hospital care.",
|
| 301 |
+
"see_doctor": "Call emergency services immediately if stroke symptoms appear."
|
| 302 |
+
},
|
| 303 |
+
{
|
| 304 |
+
"condition": "Epilepsy",
|
| 305 |
+
"symptoms": "seizures, loss of consciousness, staring spells, confusion, jerking movements",
|
| 306 |
+
"causes": "Caused by abnormal electrical activity in the brain.",
|
| 307 |
+
"precautions": "Ensure safety during seizures and avoid dangerous environments.",
|
| 308 |
+
"see_doctor": "Seek medical evaluation for any seizure episode."
|
| 309 |
+
},
|
| 310 |
+
{
|
| 311 |
+
"condition": "Anxiety Disorder",
|
| 312 |
+
"symptoms": "restlessness, rapid heartbeat, sweating, fear, chest tightness, overthinking",
|
| 313 |
+
"causes": "Can be related to stress, mental health factors, or life events.",
|
| 314 |
+
"precautions": "Practice breathing exercises, sleep well, and reduce stress triggers.",
|
| 315 |
+
"see_doctor": "Consult a doctor or mental health professional if symptoms interfere with daily life."
|
| 316 |
+
},
|
| 317 |
+
{
|
| 318 |
+
"condition": "Depression",
|
| 319 |
+
"symptoms": "sadness, fatigue, sleep problems, loss of interest, low mood, appetite changes",
|
| 320 |
+
"causes": "Can result from biological, psychological, and social factors.",
|
| 321 |
+
"precautions": "Seek support, maintain routine, sleep well, and avoid isolation.",
|
| 322 |
+
"see_doctor": "Consult a professional if symptoms persist or affect daily functioning."
|
| 323 |
+
},
|
| 324 |
+
{
|
| 325 |
+
"condition": "Insomnia",
|
| 326 |
+
"symptoms": "difficulty sleeping, fatigue, irritability, poor concentration, headache",
|
| 327 |
+
"causes": "Often caused by stress, anxiety, poor sleep habits, or health conditions.",
|
| 328 |
+
"precautions": "Maintain a sleep routine, reduce screen time, and avoid caffeine late in the day.",
|
| 329 |
+
"see_doctor": "See a doctor if sleep problems are frequent or severe."
|
| 330 |
+
},
|
| 331 |
+
{
|
| 332 |
+
"condition": "Obesity",
|
| 333 |
+
"symptoms": "weight gain, fatigue, joint pain, shortness of breath, snoring",
|
| 334 |
+
"causes": "Can result from excess calorie intake, low activity, hormonal issues, or genetics.",
|
| 335 |
+
"precautions": "Follow a balanced diet, regular exercise, and healthy sleep habits.",
|
| 336 |
+
"see_doctor": "Consult a doctor for persistent weight-related health concerns."
|
| 337 |
+
},
|
| 338 |
+
{
|
| 339 |
+
"condition": "Arthritis",
|
| 340 |
+
"symptoms": "joint pain, stiffness, swelling, reduced movement, inflammation",
|
| 341 |
+
"causes": "Can be due to wear and tear, autoimmune disease, or joint inflammation.",
|
| 342 |
+
"precautions": "Gentle exercise, weight management, and medical treatment can help.",
|
| 343 |
+
"see_doctor": "Consult a doctor if pain or swelling persists."
|
| 344 |
+
},
|
| 345 |
+
{
|
| 346 |
+
"condition": "Back Pain",
|
| 347 |
+
"symptoms": "lower back pain, stiffness, muscle tightness, reduced movement",
|
| 348 |
+
"causes": "Can result from muscle strain, poor posture, injury, or spinal problems.",
|
| 349 |
+
"precautions": "Maintain posture, avoid heavy lifting, and use proper back support.",
|
| 350 |
+
"see_doctor": "See a doctor if pain is severe, persistent, or causes weakness."
|
| 351 |
+
},
|
| 352 |
+
{
|
| 353 |
+
"condition": "Sprain",
|
| 354 |
+
"symptoms": "joint pain, swelling, bruising, difficulty moving, tenderness",
|
| 355 |
+
"causes": "Caused by overstretching or tearing of ligaments.",
|
| 356 |
+
"precautions": "Rest, ice, compression, and elevation can help.",
|
| 357 |
+
"see_doctor": "Consult a doctor if swelling or pain is severe or walking is difficult."
|
| 358 |
+
}
|
| 359 |
+
]
|
models.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
models.py β Typed Pydantic models for the MediRoute OpenEnv environment.
|
| 3 |
+
|
| 4 |
+
These models define the complete interface contract for the AI agent:
|
| 5 |
+
- Observation: what the agent perceives at each step
|
| 6 |
+
- Action: what the agent can do
|
| 7 |
+
- StepResult: what the environment returns after each action
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
from typing import Any, Dict, List, Optional
|
| 13 |
+
|
| 14 |
+
from pydantic import BaseModel, Field
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
# βββββββββββββββββββββββββββββββββββββββββββββ
|
| 18 |
+
# Observation
|
| 19 |
+
# βββββββββββββββββββββββββββββββββββββββββββββ
|
| 20 |
+
class Observation(BaseModel):
|
| 21 |
+
"""Everything the agent can see about the current patient and environment."""
|
| 22 |
+
|
| 23 |
+
symptoms: str = Field(
|
| 24 |
+
..., description="Free-text description of the patient's chief complaints."
|
| 25 |
+
)
|
| 26 |
+
lab_report_summary: Dict[str, Any] = Field(
|
| 27 |
+
default_factory=dict,
|
| 28 |
+
description="Key lab / vital results (e.g. {'bp': '160/100', 'spo2': '98%'}).",
|
| 29 |
+
)
|
| 30 |
+
severity_score: float = Field(
|
| 31 |
+
0.0,
|
| 32 |
+
ge=0.0,
|
| 33 |
+
le=1.0,
|
| 34 |
+
description="Numeric severity 0 (trivial) β 1 (life-threatening). "
|
| 35 |
+
"Starts at 0; updated by the environment after analysis.",
|
| 36 |
+
)
|
| 37 |
+
location: str = Field(..., description="Patient's current geographic area/district.")
|
| 38 |
+
nearby_hospitals: List[str] = Field(
|
| 39 |
+
default_factory=list,
|
| 40 |
+
description="Ordered list of hospitals reachable from the patient's location.",
|
| 41 |
+
)
|
| 42 |
+
available_specialists: List[str] = Field(
|
| 43 |
+
default_factory=list,
|
| 44 |
+
description="Specialists currently on-call or available for consultation.",
|
| 45 |
+
)
|
| 46 |
+
previous_actions: List[str] = Field(
|
| 47 |
+
default_factory=list,
|
| 48 |
+
description="Ordered list of actions already taken in this episode "
|
| 49 |
+
"(format: '<action_type>:<target>').",
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
class Config:
|
| 53 |
+
validate_assignment = True
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
# βββββββββββββββββββββββββββββββββββββββββββββ
|
| 57 |
+
# Action
|
| 58 |
+
# βββββββββββββββββββββββββββββββββββββββββββββ
|
| 59 |
+
VALID_ACTION_TYPES = {
|
| 60 |
+
"analyze_symptoms",
|
| 61 |
+
"request_more_info",
|
| 62 |
+
"recommend_specialist",
|
| 63 |
+
"select_hospital",
|
| 64 |
+
"book_appointment",
|
| 65 |
+
"call_ambulance",
|
| 66 |
+
"provide_temp_guidance",
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
class Action(BaseModel):
|
| 71 |
+
"""A single action the agent submits to the environment."""
|
| 72 |
+
|
| 73 |
+
action_type: str = Field(
|
| 74 |
+
...,
|
| 75 |
+
description=(
|
| 76 |
+
"One of: analyze_symptoms | request_more_info | recommend_specialist | "
|
| 77 |
+
"select_hospital | book_appointment | call_ambulance | provide_temp_guidance"
|
| 78 |
+
),
|
| 79 |
+
)
|
| 80 |
+
target: Optional[str] = Field(
|
| 81 |
+
None,
|
| 82 |
+
description=(
|
| 83 |
+
"Contextual target of the action, e.g. severity level, specialist name, "
|
| 84 |
+
"hospital name, or None for actions that don't require a target."
|
| 85 |
+
),
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
def validate_action_type(self) -> bool:
|
| 89 |
+
return self.action_type in VALID_ACTION_TYPES
|
| 90 |
+
|
| 91 |
+
def as_key(self) -> str:
|
| 92 |
+
"""Canonical string representation used for deduplication."""
|
| 93 |
+
return f"{self.action_type}:{self.target}"
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
# βββββββββββββββββββββββββββββββββββββββββββββ
|
| 97 |
+
# StepResult (returned by env.step())
|
| 98 |
+
# βββββββββββββββββββββββββββββββββββββββββββββ
|
| 99 |
+
class StepResult(BaseModel):
|
| 100 |
+
"""The structured return value from MediRouteEnv.step()."""
|
| 101 |
+
|
| 102 |
+
observation: Observation = Field(..., description="Updated environment observation.")
|
| 103 |
+
reward: float = Field(
|
| 104 |
+
...,
|
| 105 |
+
description="Incremental reward earned by this single action (can be negative).",
|
| 106 |
+
)
|
| 107 |
+
done: bool = Field(
|
| 108 |
+
..., description="Whether the episode has terminated after this action."
|
| 109 |
+
)
|
| 110 |
+
info: Dict[str, Any] = Field(
|
| 111 |
+
default_factory=dict,
|
| 112 |
+
description="Diagnostic extras: total_reward, raw_step_reward, error messages.",
|
| 113 |
+
)
|
openenv.yaml
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# MediRoute OpenEnv configuration
|
| 2 |
+
# Compatible with the OpenEnv specification
|
| 3 |
+
|
| 4 |
+
env_id: mediroute-openenv-v1
|
| 5 |
+
version: "1.0.0"
|
| 6 |
+
name: "MediRoute OpenEnv"
|
| 7 |
+
description: >
|
| 8 |
+
A medical triage and hospital routing simulation environment where an AI agent
|
| 9 |
+
analyses patient symptoms and lab results, determines severity, recommends the
|
| 10 |
+
correct specialist, selects the best nearby hospital, books appointments, and
|
| 11 |
+
escalates emergencies with ambulance dispatch.
|
| 12 |
+
|
| 13 |
+
author: "MediRoute Team"
|
| 14 |
+
license: MIT
|
| 15 |
+
|
| 16 |
+
# ββ Environment entry-point ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 17 |
+
entrypoint:
|
| 18 |
+
module: environment
|
| 19 |
+
class: MediRouteEnv
|
| 20 |
+
|
| 21 |
+
# ββ Task definitions βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 22 |
+
tasks:
|
| 23 |
+
- id: easy
|
| 24 |
+
difficulty: easy
|
| 25 |
+
description: "Mild illness β fever and sore throat. Classify low severity, recommend GP, book appointment."
|
| 26 |
+
max_steps: 6
|
| 27 |
+
passing_score: 0.5
|
| 28 |
+
|
| 29 |
+
- id: medium
|
| 30 |
+
difficulty: medium
|
| 31 |
+
description: "Cardiology case β chest pain, high BP, ECG abnormality. Recommend cardiologist, select cardiac hospital."
|
| 32 |
+
max_steps: 8
|
| 33 |
+
passing_score: 0.6
|
| 34 |
+
|
| 35 |
+
- id: hard
|
| 36 |
+
difficulty: hard
|
| 37 |
+
description: "Life-threatening emergency β severe chest pain, SpOβ crash, unresponsive. Dispatch ambulance immediately."
|
| 38 |
+
max_steps: 6
|
| 39 |
+
passing_score: 0.5
|
| 40 |
+
|
| 41 |
+
# ββ Action space βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 42 |
+
action_space:
|
| 43 |
+
type: discrete
|
| 44 |
+
actions:
|
| 45 |
+
- analyze_symptoms
|
| 46 |
+
- request_more_info
|
| 47 |
+
- recommend_specialist
|
| 48 |
+
- select_hospital
|
| 49 |
+
- book_appointment
|
| 50 |
+
- call_ambulance
|
| 51 |
+
- provide_temp_guidance
|
| 52 |
+
|
| 53 |
+
# ββ Observation space ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 54 |
+
observation_space:
|
| 55 |
+
symptoms:
|
| 56 |
+
type: string
|
| 57 |
+
description: "Free-text patient complaints"
|
| 58 |
+
lab_report_summary:
|
| 59 |
+
type: object
|
| 60 |
+
description: "Key lab / vital results"
|
| 61 |
+
severity_score:
|
| 62 |
+
type: float
|
| 63 |
+
range: [0.0, 1.0]
|
| 64 |
+
description: "Numeric severity score"
|
| 65 |
+
location:
|
| 66 |
+
type: string
|
| 67 |
+
description: "Patient geographic area"
|
| 68 |
+
nearby_hospitals:
|
| 69 |
+
type: list[string]
|
| 70 |
+
description: "Candidate hospitals sorted by proximity/quality"
|
| 71 |
+
available_specialists:
|
| 72 |
+
type: list[string]
|
| 73 |
+
description: "On-call specialists"
|
| 74 |
+
previous_actions:
|
| 75 |
+
type: list[string]
|
| 76 |
+
description: "Action history for this episode"
|
| 77 |
+
|
| 78 |
+
# ββ Reward structure βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 79 |
+
reward:
|
| 80 |
+
range: [0.0, 1.0]
|
| 81 |
+
components:
|
| 82 |
+
correct_severity_classification: +0.30
|
| 83 |
+
correct_specialist_recommendation: +0.30
|
| 84 |
+
correct_hospital_selection: +0.20
|
| 85 |
+
successful_appointment_booking: +0.20
|
| 86 |
+
correct_emergency_escalation: +0.50
|
| 87 |
+
wrong_department: -0.20
|
| 88 |
+
duplicate_action: -0.30
|
| 89 |
+
unnecessary_ambulance: -0.30
|
| 90 |
+
ambulance_missed_in_emergency: -0.30
|
| 91 |
+
|
| 92 |
+
# ββ Inference configuration ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 93 |
+
inference:
|
| 94 |
+
script: inference.py
|
| 95 |
+
env_vars:
|
| 96 |
+
- OPENAI_API_KEY
|
| 97 |
+
- API_BASE_URL
|
| 98 |
+
- MODEL_NAME
|
| 99 |
+
- HF_TOKEN
|
| 100 |
+
max_runtime_minutes: 20
|
| 101 |
+
supports_hf_spaces: true
|
| 102 |
+
|
| 103 |
+
# ββ Runtime constraints ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 104 |
+
runtime:
|
| 105 |
+
cpu_only: true
|
| 106 |
+
max_ram_gb: 8
|
| 107 |
+
max_vcpu: 2
|
requirements.txt
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
pydantic>=2.0.0
|
| 2 |
+
openai>=1.10.0
|
tasks.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
tasks.py β Deterministic task definitions for MediRoute OpenEnv.
|
| 3 |
+
|
| 4 |
+
Each task is a fully specified medical scenario with:
|
| 5 |
+
- Initial state (symptoms, labs, location, nearby hospitals, specialists)
|
| 6 |
+
- Ground-truth expectations used by the grader
|
| 7 |
+
- Difficulty metadata
|
| 8 |
+
|
| 9 |
+
Tasks are purely data; no side-effects happen here.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
from typing import Any, Dict
|
| 15 |
+
|
| 16 |
+
# βββββββββββββββββββββββββββββββββββββββββββββ
|
| 17 |
+
# Task Registry
|
| 18 |
+
# βββββββββββββββββββββββββββββββββββββββββββββ
|
| 19 |
+
|
| 20 |
+
TASKS: Dict[str, Dict[str, Any]] = {
|
| 21 |
+
# ββ EASY ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 22 |
+
"easy": {
|
| 23 |
+
"difficulty": "easy",
|
| 24 |
+
"description": "Mild illness β fever and sore throat.",
|
| 25 |
+
"symptoms": "Patient reports fever (101.5 Β°F) and sore throat for 2 days.",
|
| 26 |
+
"lab_report_summary": {
|
| 27 |
+
"temperature_f": 101.5,
|
| 28 |
+
"strep_rapid_test": "positive",
|
| 29 |
+
"wbc": "11,200 / Β΅L (mildly elevated)",
|
| 30 |
+
},
|
| 31 |
+
# Initial severity β agent must classify this
|
| 32 |
+
"severity_score": 0.0,
|
| 33 |
+
"location": "Downtown",
|
| 34 |
+
"nearby_hospitals": [
|
| 35 |
+
"City Clinic",
|
| 36 |
+
"Downtown Medical Center",
|
| 37 |
+
"Northside Hospital",
|
| 38 |
+
],
|
| 39 |
+
"available_specialists": [
|
| 40 |
+
"General Physician",
|
| 41 |
+
"ENT Specialist",
|
| 42 |
+
"Cardiologist",
|
| 43 |
+
"Emergency Doctor",
|
| 44 |
+
],
|
| 45 |
+
# Ground-truth answers for graders
|
| 46 |
+
"expected_severity": "low",
|
| 47 |
+
"expected_specialist": "General Physician",
|
| 48 |
+
"expected_hospital": "City Clinic",
|
| 49 |
+
"requires_ambulance": False,
|
| 50 |
+
"terminal_actions": {"book_appointment", "provide_temp_guidance"},
|
| 51 |
+
"max_steps": 6,
|
| 52 |
+
},
|
| 53 |
+
|
| 54 |
+
# ββ MEDIUM ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 55 |
+
"medium": {
|
| 56 |
+
"difficulty": "medium",
|
| 57 |
+
"description": "Cardiology case β chest pain, high BP, ECG abnormality.",
|
| 58 |
+
"symptoms": (
|
| 59 |
+
"55-year-old male with crushing chest pain radiating to left arm, "
|
| 60 |
+
"persistent for 30 minutes. Hypertension history."
|
| 61 |
+
),
|
| 62 |
+
"lab_report_summary": {
|
| 63 |
+
"blood_pressure": "165/105 mmHg",
|
| 64 |
+
"ecg_finding": "ST-segment elevation in leads II, III, aVF",
|
| 65 |
+
"troponin_i": "0.9 ng/mL (elevated)",
|
| 66 |
+
"heart_rate": "102 bpm",
|
| 67 |
+
},
|
| 68 |
+
"severity_score": 0.0,
|
| 69 |
+
"location": "Westside",
|
| 70 |
+
"nearby_hospitals": [
|
| 71 |
+
"Westside Heart Center",
|
| 72 |
+
"General Hospital",
|
| 73 |
+
"City Clinic",
|
| 74 |
+
],
|
| 75 |
+
"available_specialists": [
|
| 76 |
+
"Cardiologist",
|
| 77 |
+
"General Physician",
|
| 78 |
+
"Emergency Doctor",
|
| 79 |
+
"ENT Specialist",
|
| 80 |
+
],
|
| 81 |
+
"expected_severity": "high",
|
| 82 |
+
"expected_specialist": "Cardiologist",
|
| 83 |
+
"expected_hospital": "Westside Heart Center",
|
| 84 |
+
"requires_ambulance": False,
|
| 85 |
+
"terminal_actions": {"book_appointment", "provide_temp_guidance"},
|
| 86 |
+
"max_steps": 8,
|
| 87 |
+
},
|
| 88 |
+
|
| 89 |
+
# ββ HARD ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 90 |
+
"hard": {
|
| 91 |
+
"difficulty": "hard",
|
| 92 |
+
"description": "Life-threatening emergency β severe chest pain, SpOβ crash, unresponsive.",
|
| 93 |
+
"symptoms": (
|
| 94 |
+
"Elderly female found unresponsive. Bystander reports sudden "
|
| 95 |
+
"severe chest pain followed by collapse. Lips cyanotic."
|
| 96 |
+
),
|
| 97 |
+
"lab_report_summary": {
|
| 98 |
+
"spo2": "78 % (critical)",
|
| 99 |
+
"pulse": "thready / barely palpable",
|
| 100 |
+
"consciousness": "GCS 3 β unresponsive",
|
| 101 |
+
"respiratory_rate": "6 breaths/min",
|
| 102 |
+
},
|
| 103 |
+
"severity_score": 0.0,
|
| 104 |
+
"location": "Northside",
|
| 105 |
+
"nearby_hospitals": [
|
| 106 |
+
"Northside Hospital (ICU)",
|
| 107 |
+
"General Hospital",
|
| 108 |
+
"Westside Heart Center",
|
| 109 |
+
],
|
| 110 |
+
"available_specialists": [
|
| 111 |
+
"Emergency Doctor",
|
| 112 |
+
"Cardiologist",
|
| 113 |
+
"General Physician",
|
| 114 |
+
],
|
| 115 |
+
"expected_severity": "critical",
|
| 116 |
+
"expected_specialist": "Emergency Doctor",
|
| 117 |
+
"expected_hospital": "Northside Hospital (ICU)",
|
| 118 |
+
"requires_ambulance": True,
|
| 119 |
+
"terminal_actions": {"call_ambulance", "book_appointment", "provide_temp_guidance"},
|
| 120 |
+
"max_steps": 6,
|
| 121 |
+
},
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def get_task(difficulty: str) -> Dict[str, Any]:
|
| 126 |
+
"""Return a defensive copy of the task definition for the given difficulty."""
|
| 127 |
+
key = difficulty.lower().strip()
|
| 128 |
+
if key not in TASKS:
|
| 129 |
+
raise ValueError(
|
| 130 |
+
f"Unknown difficulty '{difficulty}'. "
|
| 131 |
+
f"Available options: {sorted(TASKS.keys())}"
|
| 132 |
+
)
|
| 133 |
+
# Deep copy primitive fields; lists/dicts are re-created automatically
|
| 134 |
+
import copy
|
| 135 |
+
return copy.deepcopy(TASKS[key])
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def list_tasks() -> Dict[str, str]:
|
| 139 |
+
"""Return a {difficulty: description} summary for logging / display."""
|
| 140 |
+
return {k: v["description"] for k, v in TASKS.items()}
|
test.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
from sentence_transformers import SentenceTransformer, util
|
| 3 |
+
|
| 4 |
+
print("Loading medical model...")
|
| 5 |
+
model = SentenceTransformer("sentence-transformers/embeddinggemma-300m-medical")
|
| 6 |
+
print("Model loaded!\n")
|
| 7 |
+
|
| 8 |
+
# Load dataset
|
| 9 |
+
with open("medical_data.json", "r") as f:
|
| 10 |
+
medical_data = json.load(f)
|
| 11 |
+
|
| 12 |
+
# Build searchable text
|
| 13 |
+
documents = [
|
| 14 |
+
f"{item['condition']}. Symptoms: {item['symptoms']}. Causes: {item['causes']}. Precautions: {item['precautions']}. Doctor advice: {item['see_doctor']}"
|
| 15 |
+
for item in medical_data
|
| 16 |
+
]
|
| 17 |
+
|
| 18 |
+
# Encode disease database
|
| 19 |
+
doc_embeddings = model.encode(documents, convert_to_tensor=True)
|
| 20 |
+
|
| 21 |
+
# Input
|
| 22 |
+
user_input = input("Enter patient symptoms: ").strip().lower()
|
| 23 |
+
|
| 24 |
+
# Encode user symptoms
|
| 25 |
+
query_embedding = model.encode(user_input, convert_to_tensor=True)
|
| 26 |
+
|
| 27 |
+
# Find best match
|
| 28 |
+
scores = util.cos_sim(query_embedding, doc_embeddings)[0]
|
| 29 |
+
best_match_idx = scores.argmax().item()
|
| 30 |
+
best_match = medical_data[best_match_idx]
|
| 31 |
+
|
| 32 |
+
# Output
|
| 33 |
+
print("\n==============================")
|
| 34 |
+
print(" LIFELINE AI REPORT ")
|
| 35 |
+
print("==============================\n")
|
| 36 |
+
print(f"Entered Symptoms : {user_input}")
|
| 37 |
+
print(f"Possible Condition : {best_match['condition']}")
|
| 38 |
+
print(f"Causes : {best_match['causes']}")
|
| 39 |
+
print(f"Precautions : {best_match['precautions']}")
|
| 40 |
+
print(f"When to See Doctor : {best_match['see_doctor']}")
|
| 41 |
+
print(f"Confidence Score : {scores[best_match_idx]:.4f}")
|
| 42 |
+
print("\nβ οΈ Disclaimer: This is an AI-assisted suggestion, not a final medical diagnosis.")
|