Nothing12Man commited on
Commit
27158b3
Β·
0 Parent(s):

Initial lightweight hackathon submission

Browse files
Files changed (49) hide show
  1. .dockerignore +13 -0
  2. .gitignore +9 -0
  3. Dockerfile +45 -0
  4. README.md +232 -0
  5. app.py +104 -0
  6. environment.py +237 -0
  7. graders.py +162 -0
  8. inference.py +326 -0
  9. lifeline-ai/.gitignore +41 -0
  10. lifeline-ai/AGENTS.md +5 -0
  11. lifeline-ai/CLAUDE.md +1 -0
  12. lifeline-ai/README.md +129 -0
  13. lifeline-ai/backend/app/ai.py +347 -0
  14. lifeline-ai/backend/app/hf_torch.py +67 -0
  15. lifeline-ai/backend/app/main.py +142 -0
  16. lifeline-ai/backend/app/models.py +89 -0
  17. lifeline-ai/backend/app/store.py +110 -0
  18. lifeline-ai/backend/data/hospitals.json +68 -0
  19. lifeline-ai/backend/requirements.txt +8 -0
  20. lifeline-ai/backend/run.sh +5 -0
  21. lifeline-ai/eslint.config.mjs +18 -0
  22. lifeline-ai/next.config.ts +7 -0
  23. lifeline-ai/package-lock.json +0 -0
  24. lifeline-ai/package.json +26 -0
  25. lifeline-ai/postcss.config.mjs +7 -0
  26. lifeline-ai/public/file.svg +1 -0
  27. lifeline-ai/public/globe.svg +1 -0
  28. lifeline-ai/public/next.svg +1 -0
  29. lifeline-ai/public/vercel.svg +1 -0
  30. lifeline-ai/public/window.svg +1 -0
  31. lifeline-ai/src/app/book/page.tsx +267 -0
  32. lifeline-ai/src/app/favicon.ico +0 -0
  33. lifeline-ai/src/app/globals.css +88 -0
  34. lifeline-ai/src/app/hospitals/page.tsx +220 -0
  35. lifeline-ai/src/app/layout.tsx +35 -0
  36. lifeline-ai/src/app/not-found.tsx +25 -0
  37. lifeline-ai/src/app/page.tsx +213 -0
  38. lifeline-ai/src/app/results/page.tsx +206 -0
  39. lifeline-ai/src/components/SOSButton.tsx +142 -0
  40. lifeline-ai/src/components/ui.tsx +79 -0
  41. lifeline-ai/src/lib/api.ts +127 -0
  42. lifeline-ai/src/lib/demo.ts +34 -0
  43. lifeline-ai/tsconfig.json +34 -0
  44. medical_data.json +359 -0
  45. models.py +113 -0
  46. openenv.yaml +107 -0
  47. requirements.txt +2 -0
  48. tasks.py +140 -0
  49. 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.")