sxandie commited on
Commit
262624f
·
1 Parent(s): 1bb6e2c

feat: Phase 2 - Functional simulation, OSM POI Overpass integration, and first-aid RAG

Browse files
Files changed (6) hide show
  1. MR_DESCRIPTION.md +24 -28
  2. app.py +339 -63
  3. src/data/first_aid_guide.json +22 -0
  4. src/gpx_parser.py +199 -8
  5. src/rag.py +81 -0
  6. test_overpass.py +48 -0
MR_DESCRIPTION.md CHANGED
@@ -1,39 +1,35 @@
1
- # Merge Request / Pull Request Description: Initial Codebase Setup
2
 
3
  ## 📝 Overview
4
- This Merge Request initializes the **Trailhead** Git repository. It configures the project settings, excludes local development artifacts, establishes container packaging setup, and adds user-facing documentation detailing the system architecture and features.
5
 
6
  ## 🚀 Key Changes
7
- 1. **Repository Setup**: Initialized local git configuration and renamed the default branch to `main`.
8
- 2. **Environment Filtering (`.gitignore`)**: Added a standard `.gitignore` for Python environments to exclude:
9
- - Python byte-code/compiled cache (`__pycache__/`, `*.pyc`)
10
- - Virtual environments (`.venv/`, `venv/`, `env/`)
11
- - Large model binary weights (`*.gguf`, `model/`)
12
- - Operating system artifacts (`Thumbs.db`, `.DS_Store`)
13
- - Local execution directories (`temp/`, `*.cache.json`)
14
- 3. **Documentation (`README.md`)**:
15
- - Outlined core features: GPX parsing, offline metrics (smoothed elevation gain, Naismith's time estimation), Wilderness Guide LLM (`google_gemma-4-E2B-it-GGUF` via `llama.cpp`), and voice-journaling.
16
- - Built a Mermaid architecture flow diagram.
17
- - Added setup and Docker deployment guides.
18
- 4. **Containerization (`Dockerfile`)**: Configured the build setup, including pre-compiled CPU wheels for `llama-cpp-python` and pre-download steps for Gemma 4.
19
 
20
  ## 🛠️ Verification Done
21
- - Verified that untracked directories like `.venv/` and `temp/` are successfully ignored.
22
- - Ran `git status` to ensure clean staging.
23
- - Created local initial commit on branch `main`.
24
 
25
  ---
26
 
27
  ## 📋 Steps to Push to GitHub
28
- If you haven't created the repository on GitHub yet:
29
- 1. Go to [GitHub - Create a New Repository](https://github.com/new).
30
- 2. Set the repository name to `TrailHead`. Do **not** initialize it with a README, gitignore, or license (since we have already created them).
31
- 3. Copy the remote URL (e.g., `https://github.com/your-username/TrailHead.git`).
32
- 4. Run the following commands in your terminal:
33
- ```bash
34
- # Add the remote repository
35
- git remote add origin <your-github-repo-url>
36
 
37
- # Push the main branch to GitHub
38
- git push -u origin main
39
- ```
 
1
+ # Merge Request / Pull Request Description: Phase 2 - Functional + POI Integration
2
 
3
  ## 📝 Overview
4
+ This Merge Request delivers **Phase 2: Functional + POI Integration** for the **Trailhead** project. It introduces live and offline Points of Interest (POI) extraction via OpenStreetMap (Overpass API), integrates a local Wilderness First-Aid medical manual RAG search, and implements a full route simulation player with metrics HUD and proximity alert indicators.
5
 
6
  ## 🚀 Key Changes
7
+ 1. **POI Integration (`gpx_parser.py`)**:
8
+ * Fetches key POIs (drinking water, spring, huts, shelters, campsites) dynamically within a custom bounding box of the track via the **OSM Overpass API**.
9
+ * Filters amenities locally using the **Haversine formula** within a 150m buffer of the route.
10
+ * Serializes/saves POIs into GPX XML `<extensions>` and `<wpt>` tags via `save_enhanced_gpx` for offline capabilities.
11
+ 2. **Wilderness First-Aid Guide (`first_aid_guide.json` & `rag.py`)**:
12
+ * Created a wilderness medical corpus covering 5 key sections (bleeding, hypothermia, heat, altitude sickness, musculoskeletal).
13
+ * Implemented keyword intersection matching in `rag.py` to ground first-aid queries, returning relevant guide text along with section citations.
14
+ 3. **Simulation HUD & Playback UI (`app.py`)**:
15
+ * Added simulation controls (Play, Pause, Speed slider, Reset).
16
+ * Displays dashboard metrics (Route progress %, distance walked, altitude, and dynamic ETA to next checkpoint).
17
+ * Sounds/shows offline proximity alerts when within 150m of any drinking water, camp, or hut.
18
+ * Integrated Wilderness First-Aid tab with static emergency cards and RAG manual search.
19
 
20
  ## 🛠️ Verification Done
21
+ * Created [test_overpass.py](file:///c:/Users/skushwaha/Documents/hckthn/TrailHead/test_overpass.py) to parse the preloaded Trento Track.
22
+ * Verified Overpass API (`https://overpass-api.de/api/interpreter`) fetched, filtered, and returned 2 drinking water amenities successfully within a 150m buffer.
23
+ * Validated that the first-aid RAG keyword search retrieves correct sections and references them.
24
 
25
  ---
26
 
27
  ## 📋 Steps to Push to GitHub
28
+ ```bash
29
+ # Add the remote repository (if not already linked)
30
+ git remote add origin <your-github-repo-url>
31
+
32
+ # Push all files to main
33
+ git push -u origin main
34
+ ```
 
35
 
 
 
 
app.py CHANGED
@@ -4,8 +4,9 @@ import requests
4
  import gradio as gr
5
  import pandas as pd
6
  import folium
7
- from src.gpx_parser import parse_gpx_file
8
  import src.llm as llm
 
9
 
10
  # Initialize cache and temp folders
11
  os.makedirs("./temp", exist_ok=True)
@@ -13,21 +14,37 @@ os.makedirs("./temp", exist_ok=True)
13
  # Preloaded route path
14
  PRELOADED_ROUTE_PATH = r"C:\Users\skushwaha\Documents\hckthn\TrailHead\Routes\track_5-14724236830.gpx"
15
 
16
- def generate_folium_map(points, checkpoints):
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  """
18
- Generate interactive folium map.
19
  """
20
  if not points:
21
- # Default centered map
22
  m = folium.Map(location=[46.0734974, 11.1717214], zoom_start=13)
23
  return m._repr_html_()
24
 
25
- # Center map on the middle point of the track
26
- mid_idx = len(points) // 2
27
- start_lat = points[mid_idx]["lat"]
28
- start_lon = points[mid_idx]["lon"]
29
-
30
- m = folium.Map(location=[start_lat, start_lon], zoom_start=14)
 
 
 
 
31
 
32
  # Draw track polyline
33
  locations = [(p["lat"], p["lon"]) for p in points]
@@ -41,7 +58,6 @@ def generate_folium_map(points, checkpoints):
41
  ele = cp["ele"]
42
  dist = cp["cum_dist"]
43
 
44
- # Color code markers
45
  if name == "Start":
46
  color = "green"
47
  icon = "play"
@@ -59,7 +75,6 @@ def generate_folium_map(points, checkpoints):
59
  Elevation: {ele:.1f} m
60
  </div>
61
  """
62
-
63
  folium.Marker(
64
  location=[lat, lon],
65
  popup=popup_text,
@@ -67,6 +82,45 @@ def generate_folium_map(points, checkpoints):
67
  icon=folium.Icon(color=color, icon=icon)
68
  ).add_to(m)
69
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
  return m._repr_html_()
71
 
72
  def get_map_iframe(map_html):
@@ -93,7 +147,6 @@ def fetch_ors_route(start_coords, end_coords, profile, api_key):
93
  file_path = os.path.join(temp_dir, "ors_fetched_route.gpx")
94
 
95
  if not api_key:
96
- # Create a mock straight-line GPX (3 coordinates: start, mid, end) for demo purposes
97
  mid_lat = (start_lat + end_lat) / 2.0
98
  mid_lon = (start_lon + end_lon) / 2.0
99
  gpx_content = f"""<?xml version="1.0" encoding="UTF-8"?>
@@ -131,7 +184,6 @@ def fetch_ors_route(start_coords, end_coords, profile, api_key):
131
  else:
132
  raise ValueError(f"ORS returned status {response.status_code}")
133
  except Exception as e:
134
- # Fallback straight-line
135
  mid_lat = (start_lat + end_lat) / 2.0
136
  mid_lon = (start_lon + end_lon) / 2.0
137
  gpx_content = f"""<?xml version="1.0" encoding="UTF-8"?>
@@ -149,30 +201,10 @@ def fetch_ors_route(start_coords, end_coords, profile, api_key):
149
  gr.Warning(f"ORS Fetch failed ({e}). Generated straight-line fallback route.")
150
  return file_path
151
 
152
- def handle_route_update(preloaded_sel, uploaded_file, start_coords, end_coords, profile, api_key, request: gr.Request = None):
153
- # Determine which file to parse
154
- file_path = PRELOADED_ROUTE_PATH
155
-
156
- # Check trigger source
157
- # We can inspect input priority or simply prioritize upload -> fetch -> preloaded
158
- if uploaded_file is not None:
159
- file_path = uploaded_file.name
160
- elif start_coords and end_coords:
161
- # If coordinates are changed and user hits the trigger, we can fetch
162
- # However, to avoid automatic fetching on load, we only fetch when this is called via button click.
163
- # Since this function handles all triggers, we'll let app buttons set a temporary flag.
164
- pass
165
-
166
- try:
167
- data = parse_gpx_file(file_path)
168
- except Exception as e:
169
- return (
170
- f"<div style='color:#ef4444; padding:15px; border:1px solid #ef4444; border-radius:8px;'>Error loading GPX: {e}</div>",
171
- f"<iframe srcdoc='<h3 style=\"color:red;\">Error rendering map: {e}</h3>' width='100%' height='520px'></iframe>",
172
- []
173
- )
174
-
175
- # Generate Stats HUD
176
  stats_html = f"""
177
  <div style='display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 15px; margin-bottom: 20px;'>
178
  <div class='hud-stat-box'>
@@ -198,11 +230,9 @@ def handle_route_update(preloaded_sel, uploaded_file, start_coords, end_coords,
198
  </div>
199
  """
200
 
201
- # Generate Folium Map
202
- map_html = generate_folium_map(data["points"], data["checkpoints"])
203
  map_iframe = get_map_iframe(map_html)
204
 
205
- # Format Checkpoint List for Dataframe
206
  checkpoint_table_data = []
207
  for cp in data["checkpoints"]:
208
  checkpoint_table_data.append([
@@ -214,33 +244,215 @@ def handle_route_update(preloaded_sel, uploaded_file, start_coords, end_coords,
214
 
215
  return stats_html, map_iframe, checkpoint_table_data
216
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
217
  def handle_ors_fetch_click(start_coords, end_coords, profile, api_key):
218
- """Button click handler for fetching online routes."""
219
  try:
220
  route_file = fetch_ors_route(start_coords, end_coords, profile, api_key)
221
- return handle_route_update(None, None, start_coords, end_coords, profile, api_key)
 
 
 
 
 
 
 
 
 
222
  except Exception as e:
223
  return (
224
- f"<div style='color:#ef4444; padding:15px; border:1px solid #ef4444; border-radius:8px;'>ORS Routing Error: {e}</div>",
225
- gr.update(),
226
- gr.update()
 
 
 
 
 
227
  )
228
 
229
- # --- Gradio Chatbot Integration ---
230
- def respond(message, history):
231
- # Enforce streaming for better UX
232
- response_accumulator = ""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
233
  system_prompt = (
234
- "You are Trailhead Guide, a helpful and knowledgeable wilderness trekking expert.\n"
235
- "You help hikers prepare for routes, review gear checklists, and learn wilderness first-aid.\n"
236
- "Be professional, concise, and safety-oriented. Emphasize offline preparedness."
237
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
238
  for token in llm.generate(message, system=system_prompt, history=history, stream=True):
239
  response_accumulator += token
240
  yield response_accumulator
241
 
242
  # --- Gradio Blocks UI ---
243
  with gr.Blocks(css="assets/custom.css", title="Trailhead — Tactical Trail Computer") as demo:
 
 
 
 
244
  gr.HTML("""
245
  <div style='text-align: center; padding: 10px 0;'>
246
  <h1>🌲 Trailhead 🌲</h1>
@@ -250,6 +462,9 @@ with gr.Blocks(css="assets/custom.css", title="Trailhead — Tactical Trail Comp
250
  </div>
251
  """)
252
 
 
 
 
253
  with gr.Tabs():
254
  with gr.TabItem("🧭 Trek Planner & HUD"):
255
  with gr.Row():
@@ -259,7 +474,7 @@ with gr.Blocks(css="assets/custom.css", title="Trailhead — Tactical Trail Comp
259
  preloaded_route = gr.Dropdown(
260
  choices=["Preloaded Route: Trento Track"],
261
  value="Preloaded Route: Trento Track",
262
- label="Preloaded Routes (Trento, Italy)"
263
  )
264
 
265
  upload_file = gr.File(
@@ -268,7 +483,6 @@ with gr.Blocks(css="assets/custom.css", title="Trailhead — Tactical Trail Comp
268
  )
269
 
270
  with gr.Accordion("🔌 Fetch Online Route (Basecamp Mode)", open=False):
271
- gr.Markdown("Generate route paths between waypoints using OpenRouteService.")
272
  start_pt = gr.Textbox(
273
  value="46.0734974, 11.1717214",
274
  label="Start Coordinates (Lat, Lon)"
@@ -278,17 +492,26 @@ with gr.Blocks(css="assets/custom.css", title="Trailhead — Tactical Trail Comp
278
  label="End Coordinates (Lat, Lon)"
279
  )
280
  ors_profile = gr.Dropdown(
281
- choices=["foot-hiking", "foot-walking", "cycling-mountain"],
282
  value="foot-hiking",
283
  label="Profile"
284
  )
285
  ors_api_key = gr.Textbox(
286
  type="password",
287
- label="OpenRouteService API Key (Optional)",
288
- placeholder="Paste your API key here..."
289
  )
290
  fetch_route_btn = gr.Button("Fetch & Load Route", variant="secondary")
291
 
 
 
 
 
 
 
 
 
 
 
292
  with gr.Column(scale=2):
293
  # Stats display
294
  stats_display = gr.HTML()
@@ -296,6 +519,9 @@ with gr.Blocks(css="assets/custom.css", title="Trailhead — Tactical Trail Comp
296
  # Interactive Map display
297
  map_display = gr.HTML()
298
 
 
 
 
299
  with gr.Accordion("📋 Route Checkpoint Briefing", open=True):
300
  checkpoint_table = gr.DataFrame(
301
  headers=["Checkpoint", "Coordinates", "Cumulative Distance", "Altitude"],
@@ -303,6 +529,16 @@ with gr.Blocks(css="assets/custom.css", title="Trailhead — Tactical Trail Comp
303
  column_count=(4, "fixed")
304
  )
305
 
 
 
 
 
 
 
 
 
 
 
306
  with gr.TabItem("💬 Wilderness Guide AI"):
307
  gr.ChatInterface(
308
  respond,
@@ -313,33 +549,73 @@ with gr.Blocks(css="assets/custom.css", title="Trailhead — Tactical Trail Comp
313
  ]
314
  )
315
 
316
- # --- Triggers ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
317
  # Load default route on startup
318
  demo.load(
319
  fn=handle_route_update,
320
  inputs=[preloaded_route, upload_file, gr.State(""), gr.State(""), gr.State(""), gr.State("")],
321
- outputs=[stats_display, map_display, checkpoint_table]
322
  )
323
 
324
  # Preloaded selection change
325
  preloaded_route.change(
326
  fn=handle_route_update,
327
  inputs=[preloaded_route, gr.State(None), gr.State(""), gr.State(""), gr.State(""), gr.State("")],
328
- outputs=[stats_display, map_display, checkpoint_table]
329
  )
330
 
331
  # Uploaded file change
332
  upload_file.change(
333
  fn=handle_route_update,
334
  inputs=[gr.State(None), upload_file, gr.State(""), gr.State(""), gr.State(""), gr.State("")],
335
- outputs=[stats_display, map_display, checkpoint_table]
336
  )
337
 
338
  # Fetch route button click
339
  fetch_route_btn.click(
340
  fn=handle_ors_fetch_click,
341
  inputs=[start_pt, end_pt, ors_profile, ors_api_key],
342
- outputs=[stats_display, map_display, checkpoint_table]
 
 
 
 
 
 
 
343
  )
344
 
345
  if __name__ == "__main__":
 
4
  import gradio as gr
5
  import pandas as pd
6
  import folium
7
+ from src.gpx_parser import parse_gpx_file, haversine, save_enhanced_gpx
8
  import src.llm as llm
9
+ import src.rag as rag
10
 
11
  # Initialize cache and temp folders
12
  os.makedirs("./temp", exist_ok=True)
 
14
  # Preloaded route path
15
  PRELOADED_ROUTE_PATH = r"C:\Users\skushwaha\Documents\hckthn\TrailHead\Routes\track_5-14724236830.gpx"
16
 
17
+ EMERGENCY_CARD = """
18
+ ## 🚨 IMMEDIATE BACKCOUNTRY EMERGENCY CARD (OFFLINE)
19
+ If you encounter a medical crisis with no cellular signal, follow these basic steps:
20
+
21
+ 1. **Severe Bleeding:** Apply direct pressure with clean dressing. Elevate limb. Use tourniquet if blood is spurting.
22
+ 2. **Hypothermia:** Wrap in windproof shell/sleeping bag. Replace wet clothes. Provide warm sweet drinks.
23
+ 3. **Heat Stroke:** Move to shade. Actively cool by wetting skin and fanning. Sip cool water.
24
+ 4. **Altitude Illness (AMS/HAPE/HACE):** Descend immediately. Do not ascend. Administer oxygen if available.
25
+ 5. **Ankle Sprain (R.I.C.E):** Rest the joint. Ice or apply cold pack. Compress with elastic bandage. Elevate limb.
26
+
27
+ *Disclaimer: This guide is for offline reference only. Always carry a PLB/satellite communicator on remote trails.*
28
+ """
29
+
30
+ def generate_folium_map(points, checkpoints, pois, hiker_pos=None):
31
  """
32
+ Generate interactive folium map rendering track, checkpoints, POIs, and current hiker pos.
33
  """
34
  if not points:
 
35
  m = folium.Map(location=[46.0734974, 11.1717214], zoom_start=13)
36
  return m._repr_html_()
37
 
38
+ # Center map on current hiker position or middle of track
39
+ if hiker_pos:
40
+ center_lat, center_lon = hiker_pos["lat"], hiker_pos["lon"]
41
+ zoom_val = 15
42
+ else:
43
+ mid_idx = len(points) // 2
44
+ center_lat, center_lon = points[mid_idx]["lat"], points[mid_idx]["lon"]
45
+ zoom_val = 14
46
+
47
+ m = folium.Map(location=[center_lat, center_lon], zoom_start=zoom_val)
48
 
49
  # Draw track polyline
50
  locations = [(p["lat"], p["lon"]) for p in points]
 
58
  ele = cp["ele"]
59
  dist = cp["cum_dist"]
60
 
 
61
  if name == "Start":
62
  color = "green"
63
  icon = "play"
 
75
  Elevation: {ele:.1f} m
76
  </div>
77
  """
 
78
  folium.Marker(
79
  location=[lat, lon],
80
  popup=popup_text,
 
82
  icon=folium.Icon(color=color, icon=icon)
83
  ).add_to(m)
84
 
85
+ # Draw POIs
86
+ for poi in pois:
87
+ poi_type = poi["type"]
88
+ icon_color = "purple"
89
+ icon_name = "info-sign"
90
+
91
+ if "water" in poi_type or "spring" in poi_type:
92
+ icon_color = "blue"
93
+ icon_name = "tint"
94
+ elif "camp" in poi_type:
95
+ icon_color = "orange"
96
+ icon_name = "home"
97
+ elif "hut" in poi_type or "shelter" in poi_type:
98
+ icon_color = "green"
99
+ icon_name = "home"
100
+
101
+ popup_text = f"""
102
+ <div style="font-family: 'Outfit', sans-serif; font-size: 11px;">
103
+ <b>{poi['name']}</b><br>
104
+ Type: {poi_type.replace('_', ' ').title()}<br>
105
+ Distance to Route: {poi['distance']:.1f} m
106
+ </div>
107
+ """
108
+ folium.Marker(
109
+ location=[poi["lat"], poi["lon"]],
110
+ popup=popup_text,
111
+ tooltip=poi['name'],
112
+ icon=folium.Icon(color=icon_color, icon=icon_name)
113
+ ).add_to(m)
114
+
115
+ # Draw hiker's simulated position
116
+ if hiker_pos:
117
+ folium.Marker(
118
+ location=[hiker_pos["lat"], hiker_pos["lon"]],
119
+ popup=f"Current: {hiker_pos['cum_dist']/1000.0:.2f}km<br>Ele: {hiker_pos['ele']:.1f}m",
120
+ tooltip="Hiker Position",
121
+ icon=folium.Icon(color="red", icon="user")
122
+ ).add_to(m)
123
+
124
  return m._repr_html_()
125
 
126
  def get_map_iframe(map_html):
 
147
  file_path = os.path.join(temp_dir, "ors_fetched_route.gpx")
148
 
149
  if not api_key:
 
150
  mid_lat = (start_lat + end_lat) / 2.0
151
  mid_lon = (start_lon + end_lon) / 2.0
152
  gpx_content = f"""<?xml version="1.0" encoding="UTF-8"?>
 
184
  else:
185
  raise ValueError(f"ORS returned status {response.status_code}")
186
  except Exception as e:
 
187
  mid_lat = (start_lat + end_lat) / 2.0
188
  mid_lon = (start_lon + end_lon) / 2.0
189
  gpx_content = f"""<?xml version="1.0" encoding="UTF-8"?>
 
201
  gr.Warning(f"ORS Fetch failed ({e}). Generated straight-line fallback route.")
202
  return file_path
203
 
204
+ def format_route_view(data):
205
+ """
206
+ Format route data for presentation inside stats display and checkpoint lists.
207
+ """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
208
  stats_html = f"""
209
  <div style='display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 15px; margin-bottom: 20px;'>
210
  <div class='hud-stat-box'>
 
230
  </div>
231
  """
232
 
233
+ map_html = generate_folium_map(data["points"], data["checkpoints"], data.get("pois", []))
 
234
  map_iframe = get_map_iframe(map_html)
235
 
 
236
  checkpoint_table_data = []
237
  for cp in data["checkpoints"]:
238
  checkpoint_table_data.append([
 
244
 
245
  return stats_html, map_iframe, checkpoint_table_data
246
 
247
+ def handle_route_update(preloaded_sel, uploaded_file, start_coords, end_coords, profile, api_key):
248
+ file_path = PRELOADED_ROUTE_PATH
249
+ if uploaded_file is not None:
250
+ file_path = uploaded_file.name
251
+
252
+ try:
253
+ data = parse_gpx_file(file_path)
254
+ except Exception as e:
255
+ import traceback
256
+ traceback.print_exc()
257
+ return (
258
+ f"<div style='color:#ef4444;'>Error parsing GPX: {e}</div>",
259
+ "",
260
+ [],
261
+ {},
262
+ 0,
263
+ gr.update(active=False),
264
+ "",
265
+ ""
266
+ )
267
+
268
+ stats_html, map_iframe, checkpoint_table_data = format_route_view(data)
269
+ # Save a copy with POIs saved to disk
270
+ try:
271
+ enhanced_file = os.path.join("./temp", "trek_with_poi.gpx")
272
+ save_enhanced_gpx(file_path, enhanced_file, data.get("pois", []))
273
+ except Exception as ex:
274
+ print(f"[app] Error saving enhanced GPX: {ex}")
275
+
276
+ return stats_html, map_iframe, checkpoint_table_data, data, 0, gr.update(active=False), "", ""
277
+
278
  def handle_ors_fetch_click(start_coords, end_coords, profile, api_key):
 
279
  try:
280
  route_file = fetch_ors_route(start_coords, end_coords, profile, api_key)
281
+ data = parse_gpx_file(route_file)
282
+ stats_html, map_iframe, checkpoint_table_data = format_route_view(data)
283
+
284
+ try:
285
+ enhanced_file = os.path.join("./temp", "trek_with_poi.gpx")
286
+ save_enhanced_gpx(route_file, enhanced_file, data.get("pois", []))
287
+ except Exception as ex:
288
+ print(f"[app] Error saving enhanced GPX: {ex}")
289
+
290
+ return stats_html, map_iframe, checkpoint_table_data, data, 0, gr.update(active=False), "", ""
291
  except Exception as e:
292
  return (
293
+ f"<div style='color:#ef4444;'>Error: {e}</div>",
294
+ "",
295
+ [],
296
+ {},
297
+ 0,
298
+ gr.update(active=False),
299
+ "",
300
+ ""
301
  )
302
 
303
+ # --- Playback Simulation Loop ---
304
+ def step_simulation(current_idx, route_data, speed):
305
+ if not route_data or "points" not in route_data:
306
+ return current_idx, gr.update(), gr.update(), gr.update(), gr.update()
307
+
308
+ points = route_data["points"]
309
+ checkpoints = route_data["checkpoints"]
310
+ pois = route_data.get("pois", [])
311
+
312
+ if current_idx >= len(points):
313
+ return current_idx, gr.update(), gr.update(), gr.update(), gr.update()
314
+
315
+ step_size = int(speed)
316
+ next_idx = current_idx + step_size
317
+ if next_idx >= len(points):
318
+ next_idx = len(points) - 1
319
+
320
+ current_pt = points[next_idx]
321
+
322
+ lat = current_pt["lat"]
323
+ lon = current_pt["lon"]
324
+ ele = current_pt["ele"]
325
+ cum_dist = current_pt["cum_dist"]
326
+
327
+ total_dist = points[-1]["cum_dist"]
328
+ pct_complete = (cum_dist / total_dist) * 100.0 if total_dist > 0 else 0.0
329
+
330
+ # Proximity alerts check
331
+ active_alerts = []
332
+ for poi in pois:
333
+ d = haversine(lat, lon, poi["lat"], poi["lon"])
334
+ if d <= 150.0:
335
+ icon_map = {
336
+ "drinking_water": "💧",
337
+ "spring": "💧",
338
+ "water_point": "💧",
339
+ "alpine_hut": "🏡",
340
+ "camp_site": "⛺",
341
+ "shelter": "🛡️"
342
+ }
343
+ icon = icon_map.get(poi["type"], "📍")
344
+ active_alerts.append(f"<div style='background:rgba(245,158,11,0.15); border:1px solid #f59e0b; padding:10px; border-radius:8px; margin-bottom:5px; color:#f59e0b;'>{icon} <b>PROXIMITY:</b> {poi['name']} is {d:.0f}m away! ({poi['type'].replace('_', ' ').title()})</div>")
345
+
346
+ # Checkpoint ETA progress
347
+ next_cp = None
348
+ for cp in checkpoints:
349
+ if cp["cum_dist"] * 1000.0 > cum_dist:
350
+ next_cp = cp
351
+ break
352
+
353
+ eta_text = "N/A"
354
+ if next_cp:
355
+ dist_to_cp = (next_cp["cum_dist"] * 1000.0) - cum_dist
356
+ eta_sec = dist_to_cp / 1.38
357
+ eta_text = f"{int(eta_sec // 60)}m {int(eta_sec % 60)}s"
358
+
359
+ alerts_html = "".join(active_alerts) if active_alerts else "<div style='color:var(--text-muted);'>No active proximity alerts.</div>"
360
+
361
+ # Render updated folium map
362
+ map_html = generate_folium_map(points, checkpoints, pois, hiker_pos=current_pt)
363
+ map_iframe = get_map_iframe(map_html)
364
+
365
+ # Live HUD Panel
366
+ hud_html = f"""
367
+ <div style='display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 15px; margin-bottom: 20px;'>
368
+ <div class='hud-stat-box'>
369
+ <div class='hud-stat-val mono-display'>{pct_complete:.1f}%</div>
370
+ <div class='hud-stat-lbl'>Route Progress</div>
371
+ </div>
372
+ <div class='hud-stat-box'>
373
+ <div class='hud-stat-val mono-display'>{cum_dist/1000.0:.2f} km</div>
374
+ <div class='hud-stat-lbl'>Distance Hiked</div>
375
+ </div>
376
+ <div class='hud-stat-box'>
377
+ <div class='hud-stat-val mono-display'>{ele:.1f} m</div>
378
+ <div class='hud-stat-lbl'>Current Altitude</div>
379
+ </div>
380
+ <div class='hud-stat-box'>
381
+ <div class='hud-stat-val mono-display'>{eta_text}</div>
382
+ <div class='hud-stat-lbl'>ETA to Next Point</div>
383
+ </div>
384
+ </div>
385
+ """
386
+
387
+ # Proximity narration brief
388
+ narration_html = ""
389
+ for cp in checkpoints:
390
+ cp_dist_m = cp["cum_dist"] * 1000.0
391
+ if abs(cum_dist - cp_dist_m) <= 150.0:
392
+ cautions = ""
393
+ if ele > 2400:
394
+ cautions = " WARNING: Altitude is above 2400m. Watch for AMS symptoms (headache, dizziness)."
395
+ narration_html = f"""
396
+ <div style='border-left: 4px solid var(--accent-primary); background: rgba(245,158,11,0.05); padding: 15px; border-radius: 0 8px 8px 0;'>
397
+ <b style='color:var(--accent-primary);'>📻 RADIO BRIEFING FOR {cp['name'].upper()}:</b>
398
+ <p style='margin-top: 5px; font-style: italic;'>
399
+ "Hiker, you have arrived at {cp['name']}. Current altitude is {ele:.1f}m.{cautions}"
400
+ </p>
401
+ </div>
402
+ """
403
+ break
404
+
405
+ return next_idx, hud_html, map_iframe, alerts_html, narration_html
406
+
407
+ # --- First-Aid Manual Search ---
408
+ def handle_first_aid_search(query):
409
+ if not query.strip():
410
+ return "Please enter symptoms or injury to search the manual."
411
+
412
+ grounding_text, sources = rag.retrieve_first_aid(query)
413
+ if not grounding_text:
414
+ return "No matching first-aid sections found in the manual. (Please carry a PLB/satellite communicator on remote trails)."
415
+
416
  system_prompt = (
417
+ "You are Trailhead Guide, an expert wilderness medicine counselor.\n"
418
+ "Provide a concise, direct, and actionable step-by-step first-aid protocol based on the provided guide context.\n"
419
+ "Cite the section at the end."
420
  )
421
+ prompt = f"Context:\n{grounding_text}\n\nQuestion: {query}\n\nAnswer:"
422
+ response = llm.generate(prompt, system=system_prompt, stream=False)
423
+
424
+ return f"### Retrieval Results ({', '.join(sources)})\n\n{response}"
425
+
426
+ # --- Chatbot Integration ---
427
+ def respond(message, history):
428
+ response_accumulator = ""
429
+ grounding_text, sources = rag.retrieve_first_aid(message)
430
+
431
+ if grounding_text:
432
+ source_cite = "Sources: " + ", ".join(sources)
433
+ system_prompt = (
434
+ "You are Trailhead Guide, a wilderness first-aid advisor.\n"
435
+ "Answer the query using ONLY the provided guide context.\n"
436
+ f"Context:\n{grounding_text}\n"
437
+ "Keep the instructions clear, numbered, and precise.\n"
438
+ f"Cite: '{source_cite}'."
439
+ )
440
+ else:
441
+ system_prompt = (
442
+ "You are Trailhead Guide, an expert hiking guide.\n"
443
+ "Provide helpful, concise trekking advice."
444
+ )
445
+
446
  for token in llm.generate(message, system=system_prompt, history=history, stream=True):
447
  response_accumulator += token
448
  yield response_accumulator
449
 
450
  # --- Gradio Blocks UI ---
451
  with gr.Blocks(css="assets/custom.css", title="Trailhead — Tactical Trail Computer") as demo:
452
+ # State management
453
+ route_state = gr.State({})
454
+ current_point_idx = gr.State(0)
455
+
456
  gr.HTML("""
457
  <div style='text-align: center; padding: 10px 0;'>
458
  <h1>🌲 Trailhead 🌲</h1>
 
462
  </div>
463
  """)
464
 
465
+ # Timer loop for simulation
466
+ timer = gr.Timer(value=0.2, active=False)
467
+
468
  with gr.Tabs():
469
  with gr.TabItem("🧭 Trek Planner & HUD"):
470
  with gr.Row():
 
474
  preloaded_route = gr.Dropdown(
475
  choices=["Preloaded Route: Trento Track"],
476
  value="Preloaded Route: Trento Track",
477
+ label="Preloaded Routes"
478
  )
479
 
480
  upload_file = gr.File(
 
483
  )
484
 
485
  with gr.Accordion("🔌 Fetch Online Route (Basecamp Mode)", open=False):
 
486
  start_pt = gr.Textbox(
487
  value="46.0734974, 11.1717214",
488
  label="Start Coordinates (Lat, Lon)"
 
492
  label="End Coordinates (Lat, Lon)"
493
  )
494
  ors_profile = gr.Dropdown(
495
+ choices=["foot-hiking", "foot-walking"],
496
  value="foot-hiking",
497
  label="Profile"
498
  )
499
  ors_api_key = gr.Textbox(
500
  type="password",
501
+ label="OpenRouteService API Key (Optional)"
 
502
  )
503
  fetch_route_btn = gr.Button("Fetch & Load Route", variant="secondary")
504
 
505
+ gr.Markdown("### 🎮 Trek Simulation Controls")
506
+ with gr.Row():
507
+ play_btn = gr.Button("▶ PLAY", variant="primary")
508
+ pause_btn = gr.Button("⏸ PAUSE", variant="secondary")
509
+ reset_btn = gr.Button("🔄 RESET", variant="secondary")
510
+ speed_slider = gr.Slider(minimum=1, maximum=20, step=1, value=1, label="Simulation Speed (Points per tick)")
511
+
512
+ gr.Markdown("### ⚠️ Active Proximity Alerts")
513
+ alerts_output = gr.HTML(value="<div style='color:var(--text-muted);'>No active proximity alerts.</div>")
514
+
515
  with gr.Column(scale=2):
516
  # Stats display
517
  stats_display = gr.HTML()
 
519
  # Interactive Map display
520
  map_display = gr.HTML()
521
 
522
+ # Narration briefing output
523
+ narration_output = gr.HTML(value="")
524
+
525
  with gr.Accordion("📋 Route Checkpoint Briefing", open=True):
526
  checkpoint_table = gr.DataFrame(
527
  headers=["Checkpoint", "Coordinates", "Cumulative Distance", "Altitude"],
 
529
  column_count=(4, "fixed")
530
  )
531
 
532
+ with gr.TabItem("🩺 Wilderness First-Aid"):
533
+ with gr.Row():
534
+ with gr.Column(scale=1):
535
+ gr.HTML(EMERGENCY_CARD)
536
+ with gr.Column(scale=1):
537
+ gr.Markdown("## 🔍 Wilderness First-Aid manual RAG Search")
538
+ rag_query = gr.Textbox(placeholder="What symptoms or injury do you want to query?", label="Query Symptoms")
539
+ rag_search_btn = gr.Button("Search manual", variant="primary")
540
+ rag_output = gr.Markdown(value="*Manual results will be displayed here.*")
541
+
542
  with gr.TabItem("💬 Wilderness Guide AI"):
543
  gr.ChatInterface(
544
  respond,
 
549
  ]
550
  )
551
 
552
+ # --- Simulation player bindings ---
553
+ timer.tick(
554
+ fn=step_simulation,
555
+ inputs=[current_point_idx, route_state, speed_slider],
556
+ outputs=[current_point_idx, stats_display, map_display, alerts_output, narration_output]
557
+ )
558
+
559
+ play_btn.click(
560
+ fn=lambda: gr.update(active=True),
561
+ inputs=[],
562
+ outputs=[timer]
563
+ )
564
+
565
+ pause_btn.click(
566
+ fn=lambda: gr.update(active=False),
567
+ inputs=[],
568
+ outputs=[timer]
569
+ )
570
+
571
+ def handle_reset(route):
572
+ pts = route.get("points", [])
573
+ if pts:
574
+ # Reformat map to reset pose
575
+ stats_html, map_iframe, checkpoint_table_data = format_route_view(route)
576
+ return 0, gr.update(active=False), map_iframe, stats_html, "", ""
577
+ return 0, gr.update(active=False), gr.update(), gr.update(), "", ""
578
+
579
+ reset_btn.click(
580
+ fn=handle_reset,
581
+ inputs=[route_state],
582
+ outputs=[current_point_idx, timer, map_display, stats_display, alerts_output, narration_output]
583
+ )
584
+
585
+ # --- Route Ingestion Triggers ---
586
  # Load default route on startup
587
  demo.load(
588
  fn=handle_route_update,
589
  inputs=[preloaded_route, upload_file, gr.State(""), gr.State(""), gr.State(""), gr.State("")],
590
+ outputs=[stats_display, map_display, checkpoint_table, route_state, current_point_idx, timer, alerts_output, narration_output]
591
  )
592
 
593
  # Preloaded selection change
594
  preloaded_route.change(
595
  fn=handle_route_update,
596
  inputs=[preloaded_route, gr.State(None), gr.State(""), gr.State(""), gr.State(""), gr.State("")],
597
+ outputs=[stats_display, map_display, checkpoint_table, route_state, current_point_idx, timer, alerts_output, narration_output]
598
  )
599
 
600
  # Uploaded file change
601
  upload_file.change(
602
  fn=handle_route_update,
603
  inputs=[gr.State(None), upload_file, gr.State(""), gr.State(""), gr.State(""), gr.State("")],
604
+ outputs=[stats_display, map_display, checkpoint_table, route_state, current_point_idx, timer, alerts_output, narration_output]
605
  )
606
 
607
  # Fetch route button click
608
  fetch_route_btn.click(
609
  fn=handle_ors_fetch_click,
610
  inputs=[start_pt, end_pt, ors_profile, ors_api_key],
611
+ outputs=[stats_display, map_display, checkpoint_table, route_state, current_point_idx, timer, alerts_output, narration_output]
612
+ )
613
+
614
+ # --- RAG Trigger ---
615
+ rag_search_btn.click(
616
+ fn=handle_first_aid_search,
617
+ inputs=[rag_query],
618
+ outputs=[rag_output]
619
  )
620
 
621
  if __name__ == "__main__":
src/data/first_aid_guide.json ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "section": "Section 1: Bleeding and Wound Management",
4
+ "text": "Control severe bleeding by applying direct pressure to the wound with a clean cloth or bandage. Keep the limb elevated. If bleeding does not stop after 10-15 minutes of continuous direct pressure, apply a tourniquet high and tight on the limb. Keep the wound clean using treated water. Cover with sterile dressings. Avoid direct contact with bodily fluids."
5
+ },
6
+ {
7
+ "section": "Section 2: Hypothermia and Frostbite",
8
+ "text": "Hypothermia occurs when the core body temperature drops below 35°C (95°F). Symptoms include shivering, confusion, and slurred speech. Treatment: Move the victim out of the wind/wet, replace wet clothing with dry layers, wrap in a space blanket or sleeping bag, and provide warm sweetened drinks if conscious. Frostbite: Do not rub the affected skin. Rewarm slowly in warm water (38-42°C)."
9
+ },
10
+ {
11
+ "section": "Section 3: Heat Exhaustion and Dehydration",
12
+ "text": "Heat exhaustion presents with heavy sweating, rapid pulse, dizziness, nausea, and headache. Dehydration worsens these symptoms. Treatment: Move the hiker to shade immediately, cool them down with wet cloths, and sip cool water with electrolytes. Do not allow rapid drinking. If they become confused or lose consciousness, it is Heat Stroke—a life-threatening emergency requiring rapid active cooling."
13
+ },
14
+ {
15
+ "section": "Section 4: Altitude Illnesses (AMS, HAPE, HACE)",
16
+ "text": "Acute Mountain Sickness (AMS) occurs above 2400 meters (8000 feet). Symptoms include headache, nausea, fatigue, and dizziness. Treatment: Stop ascending, rest, and hydrate. If symptoms do not improve within 24 hours, descend immediately. High Altitude Pulmonary Edema (HAPE) presents with breathlessness at rest and a cough. High Altitude Cerebral Edema (HACE) presents with confusion and loss of coordination (ataxia). Both HAPE and HACE are emergency conditions requiring immediate descent."
17
+ },
18
+ {
19
+ "section": "Section 5: Musculoskeletal Injuries (Sprains, Fractures)",
20
+ "text": "For ankle sprains or joint strains, follow the R.I.C.E. protocol: Rest the joint, Ice or apply cold pack (submerge in cold stream), Compress with elastic bandage, and Elevate the limb. For suspected fractures, immobilize the limb using splints (made of sticks, sleeping pads, or trekking poles) in the position found. Do not try to realign bones."
21
+ }
22
+ ]
src/gpx_parser.py CHANGED
@@ -82,7 +82,190 @@ def calculate_elevation_gain_loss(elevations, threshold=2.0):
82
  last_val = val
83
  return gain, loss
84
 
85
- def parse_gpx_file(file_path, cache_dir="./temp"):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  """
87
  Parse a GPX file, fetch missing elevations, smooth the profile,
88
  and compute trek statistics. Caches results locally to allow offline usage.
@@ -192,9 +375,7 @@ def parse_gpx_file(file_path, cache_dir="./temp"):
192
  max_ele = max(smoothed_eles) if smoothed_eles else 0.0
193
 
194
  # Naismith's Rule: 5 km/h base speed + 1 hour per 600m ascent
195
- # estimated_hours = (dist_km / 5.0) + (gain_m / 600.0)
196
  naismith_hours = (total_distance_km / 5.0) + (gain / 600.0)
197
- # Estimate days assuming 8 hours hiking per day
198
  estimated_days = max(1.0, naismith_hours / 8.0)
199
 
200
  # Pre-parse waypoints if they exist in GPX
@@ -211,9 +392,7 @@ def parse_gpx_file(file_path, cache_dir="./temp"):
211
  # Generate checkpoints
212
  checkpoints = []
213
  if waypoints:
214
- # Match waypoints to track points to find cumulative distance
215
  for wpt in waypoints:
216
- # Find closest track point
217
  min_d = float('inf')
218
  closest_pt = points_data[0]
219
  for pt in points_data:
@@ -228,12 +407,23 @@ def parse_gpx_file(file_path, cache_dir="./temp"):
228
  "ele": closest_pt["ele"],
229
  "cum_dist": closest_pt["cum_dist"] / 1000.0
230
  })
231
- # Sort by distance
232
  checkpoints.sort(key=lambda c: c["cum_dist"])
233
  else:
234
- # Auto-generate checkpoints every 1000 meters
235
  checkpoints = generate_checkpoints(points_data, interval_meters=1000.0)
236
 
 
 
 
 
 
 
 
 
 
 
 
 
 
237
  result = {
238
  "file_name": file_name,
239
  "total_distance_km": round(total_distance_km, 2),
@@ -244,7 +434,8 @@ def parse_gpx_file(file_path, cache_dir="./temp"):
244
  "estimated_days": round(estimated_days, 1),
245
  "naismith_hours": round(naismith_hours, 1),
246
  "points": points_data,
247
- "checkpoints": checkpoints
 
248
  }
249
 
250
  # Save cache
 
82
  last_val = val
83
  return gain, loss
84
 
85
+ def fetch_overpass_pois(min_lat, min_lon, max_lat, max_lon):
86
+ """
87
+ Fetch POIs (water, spring, huts, camps, shelter) from Overpass API in the bounding box.
88
+ """
89
+ url = "https://overpass-api.de/api/interpreter"
90
+ query = f"""
91
+ [out:json][timeout:20];
92
+ (
93
+ node["amenity"="drinking_water"]({min_lat:.5f},{min_lon:.5f},{max_lat:.5f},{max_lon:.5f});
94
+ node["natural"="spring"]({min_lat:.5f},{min_lon:.5f},{max_lat:.5f},{max_lon:.5f});
95
+ node["amenity"="water_point"]({min_lat:.5f},{min_lon:.5f},{max_lat:.5f},{max_lon:.5f});
96
+ node["tourism"="alpine_hut"]({min_lat:.5f},{min_lon:.5f},{max_lat:.5f},{max_lon:.5f});
97
+ node["tourism"="camp_site"]({min_lat:.5f},{min_lon:.5f},{max_lat:.5f},{max_lon:.5f});
98
+ node["amenity"="shelter"]({min_lat:.5f},{min_lon:.5f},{max_lat:.5f},{max_lon:.5f});
99
+ );
100
+ out body;
101
+ """
102
+ headers = {
103
+ 'User-Agent': 'TrailheadTrekPlanner/1.0 (skushwaha@hckthn.com)'
104
+ }
105
+ try:
106
+ print(f"[gpx_parser] Querying Overpass API for POIs in bbox: [{min_lat:.5f}, {min_lon:.5f}, {max_lat:.5f}, {max_lon:.5f}]...")
107
+ response = requests.post(url, data={'data': query}, headers=headers, timeout=15)
108
+ if response.status_code == 200:
109
+ data = response.json()
110
+ elements = data.get("elements", [])
111
+ pois = []
112
+ for el in elements:
113
+ lat = el.get("lat")
114
+ lon = el.get("lon")
115
+ tags = el.get("tags", {})
116
+
117
+ # Determine type
118
+ poi_type = "unknown"
119
+ if "amenity" in tags:
120
+ poi_type = tags["amenity"]
121
+ elif "natural" in tags:
122
+ poi_type = tags["natural"]
123
+ elif "tourism" in tags:
124
+ poi_type = tags["tourism"]
125
+
126
+ name = tags.get("name", tags.get("water", poi_type.replace("_", " ").title()))
127
+ pois.append({
128
+ "id": el.get("id"),
129
+ "lat": lat,
130
+ "lon": lon,
131
+ "type": poi_type,
132
+ "name": name
133
+ })
134
+ print(f"[gpx_parser] Overpass returned {len(pois)} raw POIs.")
135
+ return pois
136
+ else:
137
+ print(f"[gpx_parser] Overpass API returned status code {response.status_code}: {response.text}")
138
+ return []
139
+ except Exception as e:
140
+ print(f"[gpx_parser] Overpass query failed: {e}")
141
+ return []
142
+
143
+ def filter_pois_near_track(points, pois, buffer_meters=150.0):
144
+ """
145
+ Filter POIs that are within buffer_meters of the track.
146
+ Returns list of POIs with distance and closest track point index.
147
+ """
148
+ enhanced_pois = []
149
+ if not points or not pois:
150
+ return enhanced_pois
151
+
152
+ for poi in pois:
153
+ min_dist = float('inf')
154
+ closest_idx = -1
155
+
156
+ for idx, pt in enumerate(points):
157
+ d = haversine(poi["lat"], poi["lon"], pt["lat"], pt["lon"])
158
+ if d < min_dist:
159
+ min_dist = d
160
+ closest_idx = idx
161
+
162
+ if min_dist <= buffer_meters:
163
+ enhanced_pois.append({
164
+ "id": poi.get("id", 0),
165
+ "lat": poi["lat"],
166
+ "lon": poi["lon"],
167
+ "type": poi["type"],
168
+ "name": poi["name"],
169
+ "distance": round(min_dist, 1),
170
+ "track_index": closest_idx
171
+ })
172
+
173
+ print(f"[gpx_parser] Filtered {len(enhanced_pois)} POIs within {buffer_meters}m buffer.")
174
+ return enhanced_pois
175
+
176
+ def extract_pois_from_gpx(gpx):
177
+ """
178
+ Extract POIs from GPX waypoints and track point extensions.
179
+ Returns a list of POI dictionaries.
180
+ """
181
+ pois = []
182
+ # 1. Parse from waypoints
183
+ for wpt in gpx.waypoints:
184
+ desc = wpt.description or ""
185
+ poi_type = "unknown"
186
+ if "Type: " in desc:
187
+ parts = desc.split(",")
188
+ poi_type = parts[0].replace("Type: ", "").strip()
189
+ elif wpt.name:
190
+ # guess type from name/attributes
191
+ name_l = wpt.name.lower()
192
+ if "water" in name_l or "spring" in name_l or "fountain" in name_l:
193
+ poi_type = "drinking_water"
194
+ elif "camp" in name_l:
195
+ poi_type = "camp_site"
196
+ elif "hut" in name_l or "refuge" in name_l:
197
+ poi_type = "alpine_hut"
198
+ elif "shelter" in name_l:
199
+ poi_type = "shelter"
200
+
201
+ pois.append({
202
+ "lat": wpt.latitude,
203
+ "lon": wpt.longitude,
204
+ "name": wpt.name or "Waypoint",
205
+ "type": poi_type,
206
+ "distance": 0.0
207
+ })
208
+
209
+ # 2. Parse from track point extensions
210
+ idx = 0
211
+ for track in gpx.tracks:
212
+ for segment in track.segments:
213
+ for pt in segment.points:
214
+ if pt.extensions:
215
+ for ext in pt.extensions:
216
+ tag_name = ext.tag if hasattr(ext, 'tag') else ''
217
+ if 'poi' in tag_name:
218
+ poi_type = ext.attrib.get('type', 'unknown')
219
+ poi_name = ext.attrib.get('name', 'Waypoint')
220
+ try:
221
+ dist = float(ext.attrib.get('distance', 0.0))
222
+ except ValueError:
223
+ dist = 0.0
224
+ pois.append({
225
+ "lat": pt.latitude,
226
+ "lon": pt.longitude,
227
+ "name": poi_name,
228
+ "type": poi_type,
229
+ "distance": dist,
230
+ "track_index": idx
231
+ })
232
+ idx += 1
233
+ return pois
234
+
235
+ def save_enhanced_gpx(original_gpx_path, output_gpx_path, pois):
236
+ """
237
+ Save enhanced GPX file with POIs loaded as waypoints and extensions.
238
+ """
239
+ with open(original_gpx_path, "r", encoding="utf-8") as f:
240
+ gpx = gpxpy.parse(f)
241
+
242
+ # Overwrite waypoints
243
+ gpx.waypoints = []
244
+ for poi in pois:
245
+ wpt = gpxpy.gpx.GPXWaypoint(latitude=poi['lat'], longitude=poi['lon'], name=poi['name'])
246
+ wpt.description = f"Type: {poi['type']}, Distance from track: {poi['distance']:.1f}m"
247
+ gpx.waypoints.append(wpt)
248
+
249
+ # Add extensions to trackpoints
250
+ points = []
251
+ for track in gpx.tracks:
252
+ for segment in track.segments:
253
+ points.extend(segment.points)
254
+
255
+ import xml.etree.ElementTree as ET
256
+ for poi in pois:
257
+ idx = poi.get('track_index')
258
+ if idx is not None and 0 <= idx < len(points):
259
+ pt = points[idx]
260
+ # Create sub-element under extensions
261
+ poi_el = ET.Element('poi', type=poi['type'], name=poi['name'], distance=f"{poi['distance']:.1f}")
262
+ pt.extensions.append(poi_el)
263
+
264
+ with open(output_gpx_path, "w", encoding="utf-8") as f:
265
+ f.write(gpx.to_xml())
266
+ print(f"[gpx_parser] Saved enhanced GPX with {len(pois)} POIs to {output_gpx_path}")
267
+
268
+ def parse_gpx_file(file_path, cache_dir="./temp", buffer_meters=150.0):
269
  """
270
  Parse a GPX file, fetch missing elevations, smooth the profile,
271
  and compute trek statistics. Caches results locally to allow offline usage.
 
375
  max_ele = max(smoothed_eles) if smoothed_eles else 0.0
376
 
377
  # Naismith's Rule: 5 km/h base speed + 1 hour per 600m ascent
 
378
  naismith_hours = (total_distance_km / 5.0) + (gain / 600.0)
 
379
  estimated_days = max(1.0, naismith_hours / 8.0)
380
 
381
  # Pre-parse waypoints if they exist in GPX
 
392
  # Generate checkpoints
393
  checkpoints = []
394
  if waypoints:
 
395
  for wpt in waypoints:
 
396
  min_d = float('inf')
397
  closest_pt = points_data[0]
398
  for pt in points_data:
 
407
  "ele": closest_pt["ele"],
408
  "cum_dist": closest_pt["cum_dist"] / 1000.0
409
  })
 
410
  checkpoints.sort(key=lambda c: c["cum_dist"])
411
  else:
 
412
  checkpoints = generate_checkpoints(points_data, interval_meters=1000.0)
413
 
414
+ # Parse existing POIs from GPX
415
+ pois = extract_pois_from_gpx(gpx)
416
+
417
+ # If no POIs exist (like raw user upload), fetch from Overpass API (planning mode online)
418
+ if not pois:
419
+ lats = [pt["lat"] for pt in points_data]
420
+ lons = [pt["lon"] for pt in points_data]
421
+ min_lat, max_lat = min(lats) - 0.002, max(lats) + 0.002
422
+ min_lon, max_lon = min(lons) - 0.002, max(lons) + 0.002
423
+
424
+ raw_pois = fetch_overpass_pois(min_lat, min_lon, max_lat, max_lon)
425
+ pois = filter_pois_near_track(points_data, raw_pois, buffer_meters)
426
+
427
  result = {
428
  "file_name": file_name,
429
  "total_distance_km": round(total_distance_km, 2),
 
434
  "estimated_days": round(estimated_days, 1),
435
  "naismith_hours": round(naismith_hours, 1),
436
  "points": points_data,
437
+ "checkpoints": checkpoints,
438
+ "pois": pois
439
  }
440
 
441
  # Save cache
src/rag.py ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import json
3
+ import re
4
+
5
+ DATA_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data")
6
+ GUIDES_FILE = os.path.join(DATA_DIR, "first_aid_guide.json")
7
+
8
+ def retrieve_first_aid(query_text):
9
+ """
10
+ Search first_aid_guide.json for sections relevant to the query.
11
+ Returns (markdown_grounding_text, source_list) or (None, [])
12
+ """
13
+ if not os.path.exists(GUIDES_FILE):
14
+ print(f"[rag.py] Guide file not found at {GUIDES_FILE}")
15
+ return None, []
16
+
17
+ try:
18
+ with open(GUIDES_FILE, "r", encoding="utf-8") as f:
19
+ guides = json.load(f)
20
+ except Exception as e:
21
+ print(f"[rag.py] Error reading guides: {e}")
22
+ return None, []
23
+
24
+ query_text_lower = query_text.lower()
25
+ query_words = set(re.findall(r"\w+", query_text_lower))
26
+
27
+ matches = []
28
+
29
+ # Pre-defined keyword map for high relevance scores
30
+ keywords_map = {
31
+ "section 1": ["bleed", "wound", "cut", "blood", "bandage", "tourniquet", "injury"],
32
+ "section 2": ["cold", "hypothermia", "freeze", "frostbite", "shiver", "rewarm"],
33
+ "section 3": ["heat", "exhaustion", "dehydration", "stroke", "hot", "sunstroke", "sweat"],
34
+ "section 4": ["altitude", "ams", "hape", "hace", "headache", "dizzy", "mountain sickness", "nausea", "pulmonary", "cerebral"],
35
+ "section 5": ["sprain", "fracture", "break", "splint", "ankle", "joint", "bone", "rice", "strain"]
36
+ }
37
+
38
+ for guide in guides:
39
+ score = 0
40
+ section = guide.get("section", "")
41
+ text = guide.get("text", "")
42
+ section_lower = section.lower()
43
+
44
+ # 1. Map based matching
45
+ for key, words in keywords_map.items():
46
+ if key in section_lower:
47
+ for w in words:
48
+ if w in query_text_lower:
49
+ score += 3
50
+
51
+ # 2. General overlap matching
52
+ combined_text = (section + " " + text).lower()
53
+ for word in query_words:
54
+ if len(word) > 2 and word in combined_text:
55
+ score += 1
56
+
57
+ if score > 0:
58
+ matches.append((score, guide))
59
+
60
+ # Sort matches by score descending
61
+ matches.sort(key=lambda x: x[0], reverse=True)
62
+
63
+ if not matches:
64
+ return None, []
65
+
66
+ grounding_parts = []
67
+ sources = []
68
+
69
+ # Take top matching guide to ground the model response
70
+ for idx, (score, guide) in enumerate(matches[:1]):
71
+ sec = guide["section"]
72
+ txt = guide["text"]
73
+
74
+ grounding_parts.append(
75
+ f"### {sec}\n"
76
+ f"{txt}\n"
77
+ )
78
+ sources.append(sec)
79
+
80
+ grounding_text = "\n---\n".join(grounding_parts)
81
+ return grounding_text, sources
test_overpass.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import sys
3
+ from src.gpx_parser import parse_gpx_file, fetch_overpass_pois, filter_pois_near_track
4
+
5
+ def run_test():
6
+ gpx_path = r"C:\Users\skushwaha\Documents\hckthn\TrailHead\Routes\track_5-14724236830.gpx"
7
+ print(f"--- Testing GPX parsing and Overpass integration on: {gpx_path} ---")
8
+
9
+ if not os.path.exists(gpx_path):
10
+ print(f"Error: GPX file not found at {gpx_path}")
11
+ sys.exit(1)
12
+
13
+ # 1. Parse GPX file and see what's loaded
14
+ print("\n1. Running parse_gpx_file...")
15
+ result = parse_gpx_file(gpx_path)
16
+
17
+ print(f"File Name: {result['file_name']}")
18
+ print(f"Total Distance: {result['total_distance_km']} km")
19
+ print(f"Elevation Gain: {result['elevation_gain_m']} m")
20
+ print(f"Elevation Loss: {result['elevation_loss_m']} m")
21
+ print(f"Min/Max Elevation: {result['min_elevation_m']}m / {result['max_elevation_m']}m")
22
+ print(f"Number of points: {len(result['points'])}")
23
+ print(f"Number of checkpoints: {len(result['checkpoints'])}")
24
+
25
+ pois = result.get("pois", [])
26
+ print(f"Number of POIs parsed/fetched: {len(pois)}")
27
+ for i, poi in enumerate(pois[:10]):
28
+ print(f" [{i+1}] Name: {poi['name']}, Type: {poi['type']}, Distance from route: {poi.get('distance', 0.0)}m, Lat/Lon: {poi['lat']}, {poi['lon']}")
29
+
30
+ # 2. Test live Overpass API query directly using the bounding box of the track
31
+ print("\n2. Direct Test of Overpass API Query...")
32
+ points_data = result["points"]
33
+ lats = [pt["lat"] for pt in points_data]
34
+ lons = [pt["lon"] for pt in points_data]
35
+ min_lat, max_lat = min(lats) - 0.002, max(lats) + 0.002
36
+ min_lon, max_lon = min(lons) - 0.002, max(lons) + 0.002
37
+
38
+ print(f"Bounding box: [{min_lat:.5f}, {min_lon:.5f}, {max_lat:.5f}, {max_lon:.5f}]")
39
+ raw_pois = fetch_overpass_pois(min_lat, min_lon, max_lat, max_lon)
40
+ print(f"Overpass returned {len(raw_pois)} raw POIs.")
41
+
42
+ filtered_pois = filter_pois_near_track(points_data, raw_pois, buffer_meters=150.0)
43
+ print(f"Filtered {len(filtered_pois)} POIs within 150m buffer.")
44
+ for i, poi in enumerate(filtered_pois[:10]):
45
+ print(f" [{i+1}] Name: {poi['name']}, Type: {poi['type']}, Buffer Dist: {poi['distance']}m")
46
+
47
+ if __name__ == "__main__":
48
+ run_test()