sxandie commited on
Commit
0687749
·
0 Parent(s):

Initial commit: Set up Trailhead structure, Docker, README, and .gitignore

Browse files
Files changed (10) hide show
  1. .gitignore +158 -0
  2. Dockerfile +36 -0
  3. README.md +130 -0
  4. Resources/roadmap.md +109 -0
  5. Routes/track_5-14724236830.gpx +266 -0
  6. app.py +351 -0
  7. assets/custom.css +158 -0
  8. requirements.txt +7 -0
  9. src/gpx_parser.py +314 -0
  10. src/llm.py +355 -0
.gitignore ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+
28
+ # PyInstaller
29
+ # Usually these files are written by a python script, from a template
30
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
31
+ *.manifest
32
+ *.spec
33
+
34
+ # Installer logs
35
+ pip-log.txt
36
+ pip-delete-this-directory.txt
37
+
38
+ # Unit test / coverage reports
39
+ htmlcov/
40
+ .tox/
41
+ .nox/
42
+ .coverage
43
+ .coverage.*
44
+ .cache
45
+ nosetests.xml
46
+ coverage.xml
47
+ *.cover
48
+ *.log
49
+ .hypothesis/
50
+ .pytest_cache/
51
+ cover/
52
+
53
+ # Translations
54
+ *.mo
55
+ *.pot
56
+
57
+ # Django stuff:
58
+ *.log
59
+ local_settings.py
60
+ db.sqlite3
61
+ db.sqlite3-journal
62
+
63
+ # Flask stuff:
64
+ instance/
65
+ .webassets-cache
66
+
67
+ # Scrapy stuff:
68
+ .scrapy
69
+
70
+ # Sphinx documentation
71
+ docs/_build/
72
+
73
+ # PyBuilder
74
+ .pybuilder/
75
+ target/
76
+
77
+ # Jupyter Notebook
78
+ .ipynb_checkpoints
79
+
80
+ # IPython
81
+ profile_default/
82
+ ipython_config.py
83
+
84
+ # pyenv
85
+ # For a library or app, you might want to share your .python-version.
86
+ # See https://github.com/pyenv/pyenv/blob/master/COMMANDS.md#pyenv-version-file
87
+ #.python-version
88
+
89
+ # pipenv
90
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
91
+ # However, in case of collaboration, if delegates have different platforms or python versions,
92
+ # Pipfile.lock might be conflictual.
93
+ #Pipfile.lock
94
+
95
+ # poetry
96
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
97
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
98
+ #poetry.lock
99
+
100
+ # pdm
101
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
102
+ #pdm.lock
103
+
104
+ # virtualenv
105
+ .venv/
106
+ venv/
107
+ ENV/
108
+ env/
109
+ /bin/
110
+ /include/
111
+ /lib/
112
+ /share/
113
+
114
+ # pipenv
115
+ # Alternative places for virtualenv
116
+ .venv
117
+ venv
118
+ ENV
119
+ env
120
+
121
+ # Spyder project settings
122
+ .spyderproject
123
+ .spyder-py3
124
+
125
+ # Rope project settings
126
+ .ropeproject
127
+
128
+ # mkdocs documentation
129
+ /site/
130
+
131
+ # mypy
132
+ .mypy_cache/
133
+ .dmypy.json
134
+ dmypy.json
135
+
136
+ # Pyre type checker
137
+ .pyre/
138
+
139
+ # pytype static analyzer
140
+ .pytype/
141
+
142
+ # Cython debug symbols
143
+ cython_debug/
144
+
145
+ # Model files
146
+ model/
147
+ *.gguf
148
+
149
+ # Trailhead specific cache & temp files
150
+ temp/
151
+ *.cache.json
152
+ *.wav
153
+ *.mp3
154
+
155
+ # Operating System Files
156
+ Thumbs.db
157
+ desktop.ini
158
+ .DS_Store
Dockerfile ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Use a slim Python image
2
+ FROM python:3.11-slim
3
+
4
+ # Set environment variables
5
+ ENV PYTHONUNBUFFERED=1 \
6
+ PORT=7860 \
7
+ BACKEND=llama_cpp \
8
+ MODEL_DIR=/code/model
9
+
10
+ # Set working directory
11
+ WORKDIR /code
12
+
13
+ # Install basic runtime dependencies (git is useful for huggingface_hub downloads)
14
+ RUN apt-get update && apt-get install -y --no-install-recommends \
15
+ git \
16
+ && rm -rf /var/lib/apt/lists/*
17
+
18
+ # Install llama-cpp-python using the pre-compiled CPU wheels index
19
+ RUN pip install --no-cache-dir llama-cpp-python --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cpu
20
+
21
+ # Copy requirements and install remaining python packages
22
+ COPY requirements.txt .
23
+ RUN pip install --no-cache-dir -r requirements.txt
24
+
25
+ # Pre-download the Gemma 4 E2B model GGUF so the container boots instantly
26
+ RUN mkdir -p /code/model && \
27
+ python -c "from huggingface_hub import hf_hub_download; hf_hub_download(repo_id='bartowski/google_gemma-4-E2B-it-GGUF', filename='google_gemma-4-E2B-it-Q4_K_M.gguf', local_dir='/code/model')"
28
+
29
+ # Copy the application source code
30
+ COPY . .
31
+
32
+ # Expose Gradio's port
33
+ EXPOSE 7860
34
+
35
+ # Launch the Gradio app
36
+ CMD ["python", "app.py"]
README.md ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 🌲 Trailhead — Tactical Trail Computer & Route Planner
2
+
3
+ [![Hugging Face Space](https://img.shields.io/badge/%F0%9F%A4%97%20Hugging%20Face-Space-blue)](https://huggingface.co/spaces)
4
+ [![Docker](https://img.shields.io/badge/Docker-Enabled-blue.svg)](./Dockerfile)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
6
+
7
+ > **"Plan online at basecamp, trek offline on the trail."**
8
+
9
+ **Trailhead** is an offline-first, mobile-friendly trail computer and navigation assistant designed for wilderness hiking and backpacking. It parses GPX files, calculates smoothed elevation profiles, generates interactive offline maps, and leverages an in-process Large Language Model (LLM) and Speech-to-Text (ASR) to guide you safely through the backcountry without relying on cellular connection.
10
+
11
+ ---
12
+
13
+ ## 🧭 System Architecture
14
+
15
+ ```mermaid
16
+ graph TD
17
+ A[Basecamp: Signal / Wifi] -->|Download Map Tiles & Route| B(GPX Upload / ORS Fetch)
18
+ B --> C{Trailhead App}
19
+ C --> D[Deterministic Engine]
20
+ C --> E[AI Navigation Layer]
21
+ C --> F[Offline Journaling]
22
+
23
+ D -->|Naismith's Rule & Smoothing| G[Distance / Pace / smoothed Elevation / ETA]
24
+ E -->|Gemma-4 GGUF via llama.cpp| H[Contextual Checkpoint Briefing & RAG First-Aid]
25
+ F -->|whisper.cpp ASR| I[SQLite Database + Post-Trek Shareable Reports]
26
+
27
+ G --> J[Tactical HUD UI]
28
+ H --> J
29
+ I --> J
30
+ ```
31
+
32
+ ---
33
+
34
+ ## ✨ Key Features
35
+
36
+ ### 1. Ingest & Planning (Basecamp Mode)
37
+ * **GPX Upload:** Directly upload any standard GPX route containing track points or waypoints.
38
+ * **OpenRouteService (ORS) Routing:** Generate custom route segments between coordinates using the OSM-based ORS API (requires API key, planning phase only).
39
+
40
+ ### 2. Tactical HUD & Route Metrics
41
+ * **Elevation Profile Smoothing:** Applies a moving-average window and noise threshold to eliminate GPX vertical jitter and provide realistic elevation gain/loss sums.
42
+ * **Naismith's Rule Estimator:** Calculates estimated trekking time assuming a 5 km/h base speed plus 1 hour per 600m of ascent, helping you plan realistic daily splits.
43
+ * **Interactive Map:** Built using `folium`, mapping out the route, checkpoints, and waypoints securely inside a sandboxed iframe.
44
+
45
+ ### 3. Contextual Wilderness Guide AI
46
+ * **In-Process LLM:** Powered by `google_gemma-4-E2B-it-GGUF` running locally on your device or server CPU via `llama-cpp-python`.
47
+ * **Proximity Checkpoint Narration:** Provides real-time terrain updates, safety advice, and target destination briefings as you approach waypoints.
48
+ * **First-Aid RAG Field Guide:** Retreives localized wilderness first-aid procedures and references corresponding guide sections under extreme constraints.
49
+ * **Rule-Based Risk Advisory:** Analyzes remaining daylight, current altitude (AMS detection), and weather to prompt warnings (e.g. recommending alternative campsites if pace degrades).
50
+
51
+ ### 4. Offline Voice Journal & Post-Trek Reports
52
+ * **ASR Voice Logs:** Dictate logs hands-free in the cold using `pywhispercpp` (whisper.cpp tiny). Logs transcribing audio, time, and coordinates are saved directly to SQLite.
53
+ * **Post-Trek Storyteller:** Converts your journal entries and raw GPS points into an AI-narrated story artifact.
54
+
55
+ ---
56
+
57
+ ## 🚀 Quick Start
58
+
59
+ ### Prerequisites
60
+ Make sure you have Python 3.11+ installed.
61
+
62
+ ### Installation
63
+
64
+ 1. **Clone the repository:**
65
+ ```bash
66
+ git clone <your-github-repo-url>
67
+ cd TrailHead
68
+ ```
69
+
70
+ 2. **Create and activate a virtual environment:**
71
+ ```bash
72
+ python -m venv .venv
73
+ # Windows:
74
+ .venv\Scripts\activate
75
+ # macOS/Linux:
76
+ source .venv/bin/activate
77
+ ```
78
+
79
+ 3. **Install dependencies:**
80
+ For local LLM inference on CPU, install `llama-cpp-python` first (using precompiled wheels is recommended for Windows):
81
+ ```bash
82
+ pip install llama-cpp-python --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cpu
83
+ pip install -r requirements.txt
84
+ ```
85
+
86
+ 4. **Run the Application:**
87
+ ```bash
88
+ python app.py
89
+ ```
90
+ Open `http://localhost:7860` in your web browser.
91
+
92
+ ---
93
+
94
+ ## 🐳 Docker Setup & Hugging Face Spaces
95
+
96
+ This project is fully ready to be deployed as a Docker container or hosted directly as a Hugging Face Space.
97
+
98
+ ### Run locally with Docker
99
+ Build and run the Docker container:
100
+ ```bash
101
+ docker build -t trailhead-computer .
102
+ docker run -p 7860:7860 trailhead-computer
103
+ ```
104
+
105
+ ### Deploy to Hugging Face Spaces
106
+ 1. Create a new Space on [Hugging Face](https://huggingface.co/new-space) using the **Docker** SDK.
107
+ 2. Select the **Blank** template or copy the `Dockerfile` directly.
108
+ 3. Push the codebase to your Hugging Face Space repository.
109
+ 4. The container automatically downloads the `google_gemma-4-E2B-it-Q4_K_M.gguf` model during build time, ensuring the Space starts up instantly without any downloading delays on first launch.
110
+
111
+ ---
112
+
113
+ ## 🛠️ Technical Details & Algorithms
114
+
115
+ ### Elevation Smoothing Filter
116
+ Raw GPX files suffer from GPS vertical drift, leading to massive over-reporting of elevation gain. Trailhead resolves this by:
117
+ 1. Batch-querying missing elevations via the **Open-Meteo API** (when GPX coordinates lack altitude).
118
+ 2. Applying a **Moving Average window (size 5)** to smooth out high-frequency noise.
119
+ 3. Using a **threshold delta (default 2.0 meters)**, only summing elevation changes that exceed the threshold:
120
+ $$\Delta E = \sum |e_i - e_{i-1}| \quad \text{for} \quad |e_i - e_{i-1}| \ge 2.0\text{m}$$
121
+
122
+ ### Time Estimation (Naismith's Rule)
123
+ We estimate trail times dynamically using the classic Naismith's formula:
124
+ $$\text{Time (hours)} = \frac{\text{Distance (km)}}{5.0} + \frac{\text{Elevation Gain (m)}}{600.0}$$
125
+ This represents a conservative baseline for an average loaded hiker on established trails.
126
+
127
+ ---
128
+
129
+ ## 📄 License
130
+ This project is licensed under the MIT License. See [LICENSE](LICENSE) for details.
Resources/roadmap.md ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Trailhead — Three-Phase Roadmap (v3)
2
+ **Track: Backyard AI · Owner: Person D · ~5 days to June 15**
3
+
4
+ **Affirmed core assumption:** *plan online at basecamp, trek offline.* Online work (route fetch, tile download) is allowed during planning; everything during the trek degrades gracefully to offline.
5
+
6
+ **Keystone — the Position Abstraction.** All contextual features read position from one interface with two sources:
7
+ - **Simulated playback** — steps along the uploaded GPX. Always works; demo with this; doubles as a "preview your trek" feature.
8
+ - **Live `watchPosition`** (optional, on-device, screen-on only).
9
+
10
+ Continuous browser GPS on a multi-day offline trek is unreliable (assisted-GPS cold start wants network; backgrounded tabs suspend). Demo on simulation; offer live as a bonus; never promise live tracking as a guarantee.
11
+
12
+ ---
13
+
14
+ ## Online data sources — PLANNING PHASE ONLY
15
+ External calls happen at basecamp with signal, never on the trail.
16
+
17
+ - **Primary input: upload your own GPX** — it's the hiker's real route; most reliable.
18
+ - **OpenRouteService (ORS)** — free OSM-based routing, `foot-hiking` profile, direct GPX out: `GET https://api.openrouteservice.org/v2/directions/{profile}/gpx`. **Caveats:** needs an API key + has rate limits (online-only); it *routes between coordinates*, it does **not** look up a named trek's established trail; OSM trail coverage in remote/high terrain is patchy. Use it to *generate* a route from waypoints, then validate it. Not authoritative for serious treks.
19
+ - **GPX repositories** (e.g. the track sites you've identified) — fine as a source of pre-made GPX to download at basecamp; then proceed exactly as the upload path.
20
+
21
+ > Design rule: any online fetch produces a local GPX that the rest of the app treats identically to an uploaded one. Nothing downstream depends on connectivity.
22
+
23
+ ---
24
+
25
+ ## Reconsiderations — verdicts
26
+
27
+ | Proposed feature | Verdict | Why |
28
+ |---|---|---|
29
+ | GPX upload | **Keep (primary)** | The real route; most reliable. |
30
+ | Online route fetch (ORS / GPX repos) | **Keep — planning phase only** | Convenience at basecamp. Routing ≠ named-trek lookup; validate output; never called offline. |
31
+ | Proximity checkpoint narration | **Keep — headline AI moment** | Driven by the position abstraction. |
32
+ | Progress: % complete / ETA / pace | **Keep** | Deterministic. |
33
+ | Deviation (off-route) alert | **Keep, advisory** | Distance-to-polyline. |
34
+ | Pace-adjusted ETA | **Keep** | Deterministic; recompute from actual elapsed vs distance. |
35
+ | Risk note (pace + daylight + altitude) | **Adapt + constrain** | Rule-based, conservative, **advisory only**. LLM phrases it; rules decide. |
36
+ | Voice via whisper.cpp | **Keep (bonus)** | Reuse Kisan-Sathi; value with gloves/cold. |
37
+ | Voice trek journaling → SQLite (w/ location) | **Keep (should-have)** | Speak → transcribe → log transcript + position + time. Offline. Feeds the post-trek report. **Raw transcript is the record of truth.** |
38
+ | Audio landmark alerts (TTS) | **Optional, low priority** | Fine if position + TTS work. |
39
+ | Post-trek report (stats + AI story) | **Promote (should-have)** | Safe, delightful, shareable; ideal honest-fit LLM use. |
40
+ | Offline map tiles (MBTiles) | **Keep (should-have)** | Map works with no signal; strengthens Off the Grid. |
41
+ | Storage (SQLite/files on disk) | **Backend owns it** | Model + persistence on disk; browser only does UI + geolocation. No weights in IndexedDB. |
42
+ | Battery-aware low-power mode | **Keep** | Lower `n_ctx` + GPS poll interval. Good Field Notes detail. |
43
+
44
+ ---
45
+
46
+ ## PHASE 1 — MVP: "The Route Brief"
47
+ **Goal: the grounded planning loop works end-to-end on the hiker's real GPX. Understandable in 10 seconds.**
48
+
49
+ - [ ] **GPX ingest:** accept an uploaded GPX *or* a basecamp ORS/repo fetch that lands as a local GPX; from here everything is offline-identical.
50
+ - [ ] **GPX parse** (`gpxpy`): tracks/segments/waypoints; concatenate segments; handle missing elevation/timestamps without crashing.
51
+ - [ ] **Route stats (deterministic):** haversine distance; **elevation gain with smoothing** (moving average / min-change threshold — raw sums are badly inflated); min/max; estimated days (Naismith ÷ realistic hours/day, assumption shown).
52
+ - [ ] **Interactive map — use `folium`, not Plotly mapbox.** Render polyline + waypoint markers; embed as HTML; verify on a phone. **Avoid Plotly's `open-street-map` style — it pulls tiles online and goes blank in airplane mode.** (Reserve Plotly for the elevation chart in Phase 3, where there are no tiles.)
53
+ - [ ] **Static checkpoint readout:** first waypoint — cumulative distance + elevation. No AI yet; prove the data pipeline.
54
+ - [ ] **Deploy to HF Space** (Docker + llama-cpp-python GGUF, reuse Kisan-Sathi Dockerfile); open on a phone.
55
+
56
+ **Gate:** the hiker's actual GPX yields correct distance, sane (smoothed) elevation gain, estimated days, and a map. Test on the real file — synthetic GPX hides the edge cases.
57
+
58
+ ---
59
+
60
+ ## PHASE 2 — Functional: All Basic Hackathon Criteria Met
61
+ **Goal: full demo-able loop incl. the contextual AI layer; real hiker has used it; all hard constraints met. The 60-second video is recordable from here.**
62
+
63
+ - [ ] **Position abstraction:** simulated GPX playback ("play" advances along the route) + optional live `watchPosition`. Everything below consumes it.
64
+ - [ ] **Proximity checkpoint narration (load-bearing moment):** as position nears a waypoint, the LLM narrates grounded advice — distance/ascent to next point, altitude caution from the loaded guide, water/hazard **only from tagged waypoints/data** (never invented).
65
+ - [ ] **Progress + pace + ETA + deviation:** % complete, pace vs Naismith, ETA to next checkpoint, off-route alert. Deterministic.
66
+ - [ ] **Advisory risk note:** rule-based — daylight remaining vs distance/ascent to next safe camp → "you'll arrive ~late, consider the alternate camp." Conservative, advisory, not a guarantee. LLM phrases; rules decide.
67
+ - [ ] **Gear list:** rule engine (distance + elevation + days + max altitude + season) → LLM narrates.
68
+ - [ ] **First-aid RAG:** MiniLM over the wilderness first-aid guide; retrieve + **cite section**; static no-signal emergency card; "this is a field guide — carry a PLB/satellite messenger."
69
+ - [ ] **Mobile-first UI** (reuse your FastAPI custom frontend): large targets, outdoor-readable, high contrast.
70
+ - [ ] **Dual deploy + offline verify:** Pixel 10 via Termux; **airplane mode, full loop works.**
71
+ - [ ] **Real hiker uses it + record footage** reviewing their actual route.
72
+ - [ ] **README + social post:** track, real hiker + trek, model + param count, honest-fit rationale, run instructions, teammates' HF usernames.
73
+
74
+ **Gate:** record the full 60s demo from this phase — ingest GPX → map + stats → press play → proximity narration + pace/ETA fire → gear list → first-aid query — **offline**. Constraints: Gradio ✓ · HF Space ✓ · ≤32B ✓ · video + social ✓ · real user ✓.
75
+
76
+ ---
77
+
78
+ ## PHASE 3 — Final Touch & Bonus Quests
79
+ **Goal: polish + badges. None of this blocks the video.**
80
+
81
+ - [ ] **🎨 Off-Brand — trail-computer HUD:** amber/green tactical theme; **elevation profile chart** under the map (Plotly is great here — no tiles, deterministic, looks great on camera).
82
+ - [ ] **🦙 Llama Champion:** document GGUF + llama.cpp; battery-saver mode (lower `n_ctx`, slower GPS poll).
83
+ - [ ] **🔌 Off the Grid:** make the airplane-mode run the hero shot; add **offline map tiles (MBTiles)** so the folium map works with no signal.
84
+ - [ ] **🎙 Voice trek journaling:** speak → whisper.cpp → log transcript + position + timestamp to SQLite. Offline. Raw transcript is the record of truth.
85
+ - [ ] **📓 Post-trek report (should-have):** route + stats + an AI-narrated story built from the journal logs — a shareable artifact that feeds your social post. LLM summarizes/tags; never rewrites the logged facts.
86
+ - [ ] **📓 Field Notes:** the build story — elevation smoothing, simulation vs real GPS, offline tiles, offline edge LLM on a phone (Kisan-Sathi Termux notes carry over).
87
+ - [ ] **Local waypoint tagging (bonus):** hiker marks water/camp/hazard, saved to SQLite — enriches checkpoint advice without inventing anything.
88
+ - [ ] **Live `watchPosition` (bonus):** wire the real-GPS source into the abstraction; screen-on demo only.
89
+ - [ ] **Final checklist:** Space public under the org; loads cleanly; no keys; `.gitignore` excludes weights + uploaded GPX; video + social published before June 15.
90
+
91
+ ---
92
+
93
+ ## What to Hand the Vibe-Coding LLM
94
+ 1. **The real GPX file** — build/test against it, not synthetic.
95
+ 2. **First-aid corpus** (chunked) + **static emergency card** text — model can't originate medical content.
96
+ 3. **Altitude/AMS thresholds + gear rules + seasonal averages** — verified data files, not invented.
97
+ 4. Reused Kisan-Sathi contracts: `src/llm.py` backend interface, Dockerfile, RAG setup, SQLite layer, whisper.cpp ASR.
98
+ 5. The **position abstraction** interface (simulated + live) so every contextual feature is source-agnostic.
99
+ 6. **ORS integration note:** planning-phase only; output is a local GPX treated identically to an upload; validate routes.
100
+ 7. **Acceptance tests:** distance ±2% on a known GPX; smoothed (not raw) elevation gain; map renders offline (no online tiles); first-aid answers always cite a section; checkpoint advice never names a water source absent from waypoints; risk note advisory-only; journal entries store the raw transcript + position.
101
+
102
+ ## Carry-Through Gotchas
103
+ - Smooth elevation before summing (the #1 GPX error).
104
+ - **No online map tiles** — folium + local MBTiles, or the demo dies in airplane mode.
105
+ - ORS is planning-phase routing, not named-trek lookup — validate it.
106
+ - No invented water/hazards — grounded in waypoints/data only.
107
+ - First-aid + risk are high-stakes — ground, cite, advisory framing, static emergency floor.
108
+ - Demo on simulated position; live GPS is a bonus.
109
+ - Test on the real GPX early; verify `folium` on-device.
Routes/track_5-14724236830.gpx ADDED
@@ -0,0 +1,266 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <gpx version="1.1" creator="gpx-tracks.org" xmlns="http://www.topografix.com/GPX/1/1">
3
+ <metadata>
4
+ <desc>Generated using OpenStreetMap data</desc>
5
+ <author>OpenStreetMap contributors</author>
6
+ <link href="https://www.openstreetmap.org/copyright">
7
+ <text>OpenStreetMap License</text>
8
+ </link>
9
+ </metadata>
10
+ <trk>
11
+ <trkseg>
12
+ <trkpt lat="46.0734974" lon="11.1717214"></trkpt>
13
+ <trkpt lat="46.0737836" lon="11.1727025"></trkpt>
14
+ <trkpt lat="46.0740203" lon="11.1731432"></trkpt>
15
+ <trkpt lat="46.0740168" lon="11.173328"></trkpt>
16
+ <trkpt lat="46.0737622" lon="11.173827"></trkpt>
17
+ <trkpt lat="46.0734375" lon="11.1738863"></trkpt>
18
+ <trkpt lat="46.0731624" lon="11.1743486"></trkpt>
19
+ <trkpt lat="46.0730316" lon="11.1748916"></trkpt>
20
+ <trkpt lat="46.0728944" lon="11.175002"></trkpt>
21
+ <trkpt lat="46.0728165" lon="11.1750598"></trkpt>
22
+ <trkpt lat="46.0725141" lon="11.1752516"></trkpt>
23
+ <trkpt lat="46.0724562" lon="11.1752923"></trkpt>
24
+ <trkpt lat="46.072404" lon="11.1755511"></trkpt>
25
+ <trkpt lat="46.0723352" lon="11.1757355"></trkpt>
26
+ <trkpt lat="46.072069" lon="11.1762407"></trkpt>
27
+ <trkpt lat="46.0717648" lon="11.1767315"></trkpt>
28
+ <trkpt lat="46.0719912" lon="11.1768785"></trkpt>
29
+ <trkpt lat="46.0720172" lon="11.1772164"></trkpt>
30
+ <trkpt lat="46.0720284" lon="11.1788472"></trkpt>
31
+ <trkpt lat="46.0720172" lon="11.1800435"></trkpt>
32
+ <trkpt lat="46.0721118" lon="11.1805194"></trkpt>
33
+ <trkpt lat="46.0722225" lon="11.1802648"></trkpt>
34
+ <trkpt lat="46.0722136" lon="11.1799061"></trkpt>
35
+ <trkpt lat="46.0722908" lon="11.1798927"></trkpt>
36
+ <trkpt lat="46.0723185" lon="11.1801444"></trkpt>
37
+ <trkpt lat="46.0723926" lon="11.1799338"></trkpt>
38
+ <trkpt lat="46.0726239" lon="11.17992"></trkpt>
39
+ <trkpt lat="46.0726283" lon="11.1807755"></trkpt>
40
+ <trkpt lat="46.0727848" lon="11.1808579"></trkpt>
41
+ <trkpt lat="46.0728563" lon="11.1806364"></trkpt>
42
+ <trkpt lat="46.0729224" lon="11.1806719"></trkpt>
43
+ <trkpt lat="46.0730825" lon="11.1810204"></trkpt>
44
+ <trkpt lat="46.0734128" lon="11.1811378"></trkpt>
45
+ <trkpt lat="46.0738276" lon="11.1810573"></trkpt>
46
+ <trkpt lat="46.0741403" lon="11.1811643"></trkpt>
47
+ <trkpt lat="46.0743251" lon="11.1812466"></trkpt>
48
+ <trkpt lat="46.0745456" lon="11.1815105"></trkpt>
49
+ <trkpt lat="46.0745694" lon="11.1814247"></trkpt>
50
+ <trkpt lat="46.0746536" lon="11.1813296"></trkpt>
51
+ <trkpt lat="46.0747034" lon="11.1812759"></trkpt>
52
+ <trkpt lat="46.0747419" lon="11.1812238"></trkpt>
53
+ <trkpt lat="46.0747674" lon="11.1811396"></trkpt>
54
+ <trkpt lat="46.0748016" lon="11.1810041"></trkpt>
55
+ <trkpt lat="46.0748958" lon="11.180834"></trkpt>
56
+ <trkpt lat="46.0749676" lon="11.1808063"></trkpt>
57
+ <trkpt lat="46.0750275" lon="11.1808043"></trkpt>
58
+ <trkpt lat="46.0750713" lon="11.1808199"></trkpt>
59
+ <trkpt lat="46.0751227" lon="11.1808581"></trkpt>
60
+ <trkpt lat="46.0751844" lon="11.1809787"></trkpt>
61
+ <trkpt lat="46.075376" lon="11.181141"></trkpt>
62
+ <trkpt lat="46.0754095" lon="11.1811423"></trkpt>
63
+ <trkpt lat="46.0753928" lon="11.1810619"></trkpt>
64
+ <trkpt lat="46.0754012" lon="11.1808969"></trkpt>
65
+ <trkpt lat="46.0754328" lon="11.1808433"></trkpt>
66
+ <trkpt lat="46.0754784" lon="11.1808151"></trkpt>
67
+ <trkpt lat="46.075496" lon="11.1807588"></trkpt>
68
+ <trkpt lat="46.0755334" lon="11.1807545"></trkpt>
69
+ <trkpt lat="46.0756347" lon="11.180862"></trkpt>
70
+ <trkpt lat="46.0756635" lon="11.1809009"></trkpt>
71
+ <trkpt lat="46.0757185" lon="11.180853"></trkpt>
72
+ <trkpt lat="46.0757277" lon="11.1805978"></trkpt>
73
+ <trkpt lat="46.0757454" lon="11.1805563"></trkpt>
74
+ <trkpt lat="46.0757621" lon="11.1805402"></trkpt>
75
+ <trkpt lat="46.0758459" lon="11.1805174"></trkpt>
76
+ <trkpt lat="46.0758589" lon="11.1804436"></trkpt>
77
+ <trkpt lat="46.0758686" lon="11.1803745"></trkpt>
78
+ <trkpt lat="46.0759105" lon="11.1803893"></trkpt>
79
+ <trkpt lat="46.0759901" lon="11.1806227"></trkpt>
80
+ <trkpt lat="46.0760556" lon="11.1807366"></trkpt>
81
+ <trkpt lat="46.0761226" lon="11.1808131"></trkpt>
82
+ <trkpt lat="46.0762064" lon="11.1810364"></trkpt>
83
+ <trkpt lat="46.0763157" lon="11.1811725"></trkpt>
84
+ <trkpt lat="46.0763519" lon="11.1812443"></trkpt>
85
+ <trkpt lat="46.0763864" lon="11.1813696"></trkpt>
86
+ <trkpt lat="46.0764278" lon="11.18155"></trkpt>
87
+ <trkpt lat="46.0764552" lon="11.1816278"></trkpt>
88
+ <trkpt lat="46.0764687" lon="11.1817311"></trkpt>
89
+ <trkpt lat="46.0764668" lon="11.1818739"></trkpt>
90
+ <trkpt lat="46.0764854" lon="11.1820818"></trkpt>
91
+ <trkpt lat="46.0765036" lon="11.1820791"></trkpt>
92
+ <trkpt lat="46.0765227" lon="11.1820858"></trkpt>
93
+ <trkpt lat="46.0765534" lon="11.1821227"></trkpt>
94
+ <trkpt lat="46.076571" lon="11.1821428"></trkpt>
95
+ <trkpt lat="46.0766593" lon="11.1821455"></trkpt>
96
+ <trkpt lat="46.0767188" lon="11.1821541"></trkpt>
97
+ <trkpt lat="46.0767724" lon="11.182137"></trkpt>
98
+ <trkpt lat="46.076832" lon="11.182094"></trkpt>
99
+ <trkpt lat="46.0768736" lon="11.182094"></trkpt>
100
+ <trkpt lat="46.076926" lon="11.1820234"></trkpt>
101
+ <trkpt lat="46.0770264" lon="11.181951"></trkpt>
102
+ <trkpt lat="46.0770761" lon="11.1818795"></trkpt>
103
+ <trkpt lat="46.0771146" lon="11.1818551"></trkpt>
104
+ <trkpt lat="46.0771341" lon="11.181782"></trkpt>
105
+ <trkpt lat="46.0772011" lon="11.181726"></trkpt>
106
+ <trkpt lat="46.0772602" lon="11.1816238"></trkpt>
107
+ <trkpt lat="46.0772797" lon="11.1815286"></trkpt>
108
+ <trkpt lat="46.0773192" lon="11.1814008"></trkpt>
109
+ <trkpt lat="46.0773448" lon="11.1813569"></trkpt>
110
+ <trkpt lat="46.0773667" lon="11.1813566"></trkpt>
111
+ <trkpt lat="46.0774069" lon="11.1814213"></trkpt>
112
+ <trkpt lat="46.0774299" lon="11.1815192"></trkpt>
113
+ <trkpt lat="46.0774686" lon="11.1816053"></trkpt>
114
+ <trkpt lat="46.0775411" lon="11.1816791"></trkpt>
115
+ <trkpt lat="46.0775388" lon="11.181726"></trkpt>
116
+ <trkpt lat="46.0775304" lon="11.1817693"></trkpt>
117
+ <trkpt lat="46.0775539" lon="11.1818146"></trkpt>
118
+ <trkpt lat="46.0775432" lon="11.1819547"></trkpt>
119
+ <trkpt lat="46.0775551" lon="11.1820271"></trkpt>
120
+ <trkpt lat="46.0775541" lon="11.1820861"></trkpt>
121
+ <trkpt lat="46.0775679" lon="11.1820969"></trkpt>
122
+ <trkpt lat="46.0775997" lon="11.1820389"></trkpt>
123
+ <trkpt lat="46.0776139" lon="11.1820727"></trkpt>
124
+ <trkpt lat="46.0776579" lon="11.1820741"></trkpt>
125
+ <trkpt lat="46.0776969" lon="11.1820305"></trkpt>
126
+ <trkpt lat="46.0777239" lon="11.1819842"></trkpt>
127
+ <trkpt lat="46.0778093" lon="11.18194"></trkpt>
128
+ <trkpt lat="46.0778486" lon="11.1818819"></trkpt>
129
+ <trkpt lat="46.0778639" lon="11.1818494"></trkpt>
130
+ <trkpt lat="46.0778981" lon="11.181775"></trkpt>
131
+ <trkpt lat="46.0779265" lon="11.181716"></trkpt>
132
+ <trkpt lat="46.077946" lon="11.1816342"></trkpt>
133
+ <trkpt lat="46.0779177" lon="11.1815296"></trkpt>
134
+ <trkpt lat="46.0778335" lon="11.1814002"></trkpt>
135
+ <trkpt lat="46.0778267" lon="11.1813629"></trkpt>
136
+ <trkpt lat="46.0778358" lon="11.1813422"></trkpt>
137
+ <trkpt lat="46.0778809" lon="11.1813593"></trkpt>
138
+ <trkpt lat="46.0778616" lon="11.181319"></trkpt>
139
+ <trkpt lat="46.0778525" lon="11.1812818"></trkpt>
140
+ <trkpt lat="46.0778458" lon="11.1812563"></trkpt>
141
+ <trkpt lat="46.0778409" lon="11.1812158"></trkpt>
142
+ <trkpt lat="46.077839" lon="11.1811453"></trkpt>
143
+ <trkpt lat="46.0778256" lon="11.181094"></trkpt>
144
+ <trkpt lat="46.0778302" lon="11.1810444"></trkpt>
145
+ <trkpt lat="46.0778497" lon="11.1810324"></trkpt>
146
+ <trkpt lat="46.0778711" lon="11.1810431"></trkpt>
147
+ <trkpt lat="46.0779009" lon="11.1810753"></trkpt>
148
+ <trkpt lat="46.077927" lon="11.1811544"></trkpt>
149
+ <trkpt lat="46.0780051" lon="11.1811987"></trkpt>
150
+ <trkpt lat="46.0780256" lon="11.1812268"></trkpt>
151
+ <trkpt lat="46.0780488" lon="11.1813167"></trkpt>
152
+ <trkpt lat="46.0780926" lon="11.1813314"></trkpt>
153
+ <trkpt lat="46.0781961" lon="11.1815243"></trkpt>
154
+ <trkpt lat="46.0782544" lon="11.1816743"></trkpt>
155
+ <trkpt lat="46.0782983" lon="11.1817455"></trkpt>
156
+ <trkpt lat="46.0783595" lon="11.1818089"></trkpt>
157
+ <trkpt lat="46.0783972" lon="11.1818846"></trkpt>
158
+ <trkpt lat="46.078414" lon="11.1819524"></trkpt>
159
+ <trkpt lat="46.0785307" lon="11.1819718"></trkpt>
160
+ <trkpt lat="46.0785335" lon="11.1819282"></trkpt>
161
+ <trkpt lat="46.0785065" lon="11.181827"></trkpt>
162
+ <trkpt lat="46.0784665" lon="11.1816969"></trkpt>
163
+ <trkpt lat="46.0784665" lon="11.1816385"></trkpt>
164
+ <trkpt lat="46.0784833" lon="11.1816533"></trkpt>
165
+ <trkpt lat="46.0785372" lon="11.1817002"></trkpt>
166
+ <trkpt lat="46.0785489" lon="11.1816848"></trkpt>
167
+ <trkpt lat="46.0785493" lon="11.1816446"></trkpt>
168
+ <trkpt lat="46.0785368" lon="11.1815608"></trkpt>
169
+ <trkpt lat="46.0785516" lon="11.1814865"></trkpt>
170
+ <trkpt lat="46.0785824" lon="11.1813216"></trkpt>
171
+ <trkpt lat="46.0785586" lon="11.1812014"></trkpt>
172
+ <trkpt lat="46.0785149" lon="11.1811034"></trkpt>
173
+ <trkpt lat="46.0785182" lon="11.1810022"></trkpt>
174
+ <trkpt lat="46.0785517" lon="11.1809231"></trkpt>
175
+ <trkpt lat="46.0785521" lon="11.1808567"></trkpt>
176
+ <trkpt lat="46.0785265" lon="11.1807474"></trkpt>
177
+ <trkpt lat="46.0785424" lon="11.180689"></trkpt>
178
+ <trkpt lat="46.0785879" lon="11.1806582"></trkpt>
179
+ <trkpt lat="46.0786135" lon="11.1805616"></trkpt>
180
+ <trkpt lat="46.0786238" lon="11.1804657"></trkpt>
181
+ <trkpt lat="46.0785847" lon="11.1803544"></trkpt>
182
+ <trkpt lat="46.0785461" lon="11.1802988"></trkpt>
183
+ <trkpt lat="46.0785382" lon="11.1801915"></trkpt>
184
+ <trkpt lat="46.0785558" lon="11.1800064"></trkpt>
185
+ <trkpt lat="46.0785679" lon="11.1798093"></trkpt>
186
+ <trkpt lat="46.078553" lon="11.1796369"></trkpt>
187
+ <trkpt lat="46.078567" lon="11.1795799"></trkpt>
188
+ <trkpt lat="46.0785819" lon="11.1793613"></trkpt>
189
+ <trkpt lat="46.078587" lon="11.1793003"></trkpt>
190
+ <trkpt lat="46.0785847" lon="11.179189"></trkpt>
191
+ <trkpt lat="46.07862" lon="11.1790321"></trkpt>
192
+ <trkpt lat="46.0786242" lon="11.1787511"></trkpt>
193
+ <trkpt lat="46.0786196" lon="11.1786492"></trkpt>
194
+ <trkpt lat="46.0786447" lon="11.1785379"></trkpt>
195
+ <trkpt lat="46.07868" lon="11.1783374"></trkpt>
196
+ <trkpt lat="46.0786963" lon="11.1781503"></trkpt>
197
+ <trkpt lat="46.0787963" lon="11.1779109"></trkpt>
198
+ <trkpt lat="46.0788196" lon="11.1778526"></trkpt>
199
+ <trkpt lat="46.0788233" lon="11.1777218"></trkpt>
200
+ <trkpt lat="46.0790385" lon="11.1777575"></trkpt>
201
+ <trkpt lat="46.0788233" lon="11.1777218"></trkpt>
202
+ <trkpt lat="46.0788061" lon="11.1776608"></trkpt>
203
+ <trkpt lat="46.0787759" lon="11.1776025"></trkpt>
204
+ <trkpt lat="46.0787563" lon="11.1774925"></trkpt>
205
+ <trkpt lat="46.0787438" lon="11.1773356"></trkpt>
206
+ <trkpt lat="46.0787307" lon="11.1772605"></trkpt>
207
+ <trkpt lat="46.0787326" lon="11.1771244"></trkpt>
208
+ <trkpt lat="46.0787084" lon="11.177007"></trkpt>
209
+ <trkpt lat="46.0787052" lon="11.1769152"></trkpt>
210
+ <trkpt lat="46.0787089" lon="11.1768146"></trkpt>
211
+ <trkpt lat="46.0787228" lon="11.1767039"></trkpt>
212
+ <trkpt lat="46.0787275" lon="11.1765967"></trkpt>
213
+ <trkpt lat="46.07872" lon="11.1764585"></trkpt>
214
+ <trkpt lat="46.0787028" lon="11.1763646"></trkpt>
215
+ <trkpt lat="46.0786931" lon="11.1763016"></trkpt>
216
+ <trkpt lat="46.078601" lon="11.1760743"></trkpt>
217
+ <trkpt lat="46.0785712" lon="11.1759757"></trkpt>
218
+ <trkpt lat="46.0785512" lon="11.1758805"></trkpt>
219
+ <trkpt lat="46.0785484" lon="11.1758215"></trkpt>
220
+ <trkpt lat="46.07854" lon="11.1757893"></trkpt>
221
+ <trkpt lat="46.0785056" lon="11.1757209"></trkpt>
222
+ <trkpt lat="46.0784298" lon="11.1755888"></trkpt>
223
+ <trkpt lat="46.078254" lon="11.1753266"></trkpt>
224
+ <trkpt lat="46.0781302" lon="11.175167"></trkpt>
225
+ <trkpt lat="46.0780882" lon="11.1751074"></trkpt>
226
+ <trkpt lat="46.0780474" lon="11.1750403"></trkpt>
227
+ <trkpt lat="46.0779777" lon="11.1749028"></trkpt>
228
+ <trkpt lat="46.0779811" lon="11.1748328"></trkpt>
229
+ <trkpt lat="46.0779763" lon="11.1748002"></trkpt>
230
+ <trkpt lat="46.077947" lon="11.1747473"></trkpt>
231
+ <trkpt lat="46.0779046" lon="11.1746809"></trkpt>
232
+ <trkpt lat="46.0777967" lon="11.1744864"></trkpt>
233
+ <trkpt lat="46.0777428" lon="11.1744308"></trkpt>
234
+ <trkpt lat="46.0776577" lon="11.1743088"></trkpt>
235
+ <trkpt lat="46.0776162" lon="11.1743094"></trkpt>
236
+ <trkpt lat="46.0775548" lon="11.1743148"></trkpt>
237
+ <trkpt lat="46.0774125" lon="11.1743456"></trkpt>
238
+ <trkpt lat="46.0772981" lon="11.1743966"></trkpt>
239
+ <trkpt lat="46.0769855" lon="11.1745763"></trkpt>
240
+ <trkpt lat="46.0768152" lon="11.1746366"></trkpt>
241
+ <trkpt lat="46.0767036" lon="11.1746822"></trkpt>
242
+ <trkpt lat="46.076591" lon="11.1747238"></trkpt>
243
+ <trkpt lat="46.0764757" lon="11.1747064"></trkpt>
244
+ <trkpt lat="46.0764171" lon="11.1747345"></trkpt>
245
+ <trkpt lat="46.0763036" lon="11.174752"></trkpt>
246
+ <trkpt lat="46.076218" lon="11.1748029"></trkpt>
247
+ <trkpt lat="46.0760784" lon="11.1748512"></trkpt>
248
+ <trkpt lat="46.0760031" lon="11.1748955"></trkpt>
249
+ <trkpt lat="46.0758477" lon="11.174929"></trkpt>
250
+ <trkpt lat="46.0757668" lon="11.1749732"></trkpt>
251
+ <trkpt lat="46.0755068" lon="11.1749063"></trkpt>
252
+ <trkpt lat="46.0751974" lon="11.1749093"></trkpt>
253
+ <trkpt lat="46.0748717" lon="11.1743811"></trkpt>
254
+ <trkpt lat="46.0746653" lon="11.1743811"></trkpt>
255
+ <trkpt lat="46.0746002" lon="11.1743811"></trkpt>
256
+ <trkpt lat="46.0742637" lon="11.1743798"></trkpt>
257
+ <trkpt lat="46.07403" lon="11.174376"></trkpt>
258
+ <trkpt lat="46.0739346" lon="11.1741839"></trkpt>
259
+ <trkpt lat="46.0737622" lon="11.173827"></trkpt>
260
+ <trkpt lat="46.0740168" lon="11.173328"></trkpt>
261
+ <trkpt lat="46.0740203" lon="11.1731432"></trkpt>
262
+ <trkpt lat="46.0737836" lon="11.1727025"></trkpt>
263
+ <trkpt lat="46.0734974" lon="11.1717214"></trkpt>
264
+ </trkseg>
265
+ </trk>
266
+ </gpx>
app.py ADDED
@@ -0,0 +1,351 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import base64
3
+ 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)
12
+
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]
34
+ folium.PolyLine(locations, color="#f59e0b", weight=5, opacity=0.85).add_to(m)
35
+
36
+ # Draw checkpoints
37
+ for cp in checkpoints:
38
+ name = cp["name"]
39
+ lat = cp["lat"]
40
+ lon = cp["lon"]
41
+ ele = cp["ele"]
42
+ dist = cp["cum_dist"]
43
+
44
+ # Color code markers
45
+ if name == "Start":
46
+ color = "green"
47
+ icon = "play"
48
+ elif name == "End":
49
+ color = "red"
50
+ icon = "flag"
51
+ else:
52
+ color = "cadetblue"
53
+ icon = "info-sign"
54
+
55
+ popup_text = f"""
56
+ <div style="font-family: 'Outfit', sans-serif; font-size: 11px;">
57
+ <b>{name}</b><br>
58
+ Distance: {dist:.2f} km<br>
59
+ Elevation: {ele:.1f} m
60
+ </div>
61
+ """
62
+
63
+ folium.Marker(
64
+ location=[lat, lon],
65
+ popup=popup_text,
66
+ tooltip=name,
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):
73
+ """
74
+ Helper to bundle raw HTML into a secure, sandboxed base64 data URI iframe.
75
+ """
76
+ b64_html = base64.b64encode(map_html.encode('utf-8')).decode('utf-8')
77
+ iframe_src = f"data:text/html;base64,{b64_html}"
78
+ return f'<iframe src="{iframe_src}" width="100%" height="520px" style="border:1px solid rgba(245,158,11,0.2); border-radius: 12px;"></iframe>'
79
+
80
+ def fetch_ors_route(start_coords, end_coords, profile, api_key):
81
+ """
82
+ Fetches hiking route between coordinates using OpenRouteService.
83
+ Falls back to a straight-line GPX segment if API key is empty or request fails.
84
+ """
85
+ try:
86
+ start_lat, start_lon = map(float, start_coords.split(","))
87
+ end_lat, end_lon = map(float, end_coords.split(","))
88
+ except Exception:
89
+ raise gr.Error("Invalid coordinate format. Ensure format is 'lat, lon'.")
90
+
91
+ temp_dir = "./temp"
92
+ os.makedirs(temp_dir, exist_ok=True)
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"?>
100
+ <gpx version="1.1" creator="Trailhead Mock" xmlns="http://www.topografix.com/GPX/1/1">
101
+ <trk>
102
+ <trkseg>
103
+ <trkpt lat="{start_lat}" lon="{start_lon}"></trkpt>
104
+ <trkpt lat="{mid_lat}" lon="{mid_lon}"></trkpt>
105
+ <trkpt lat="{end_lat}" lon="{end_lon}"></trkpt>
106
+ </trkseg>
107
+ </trk>
108
+ </gpx>"""
109
+ with open(file_path, "w", encoding="utf-8") as f:
110
+ f.write(gpx_content)
111
+ gr.Warning("No ORS API Key provided. Generated a mock direct route segment.")
112
+ return file_path
113
+
114
+ url = f"https://api.openrouteservice.org/v2/directions/{profile}/gpx"
115
+ headers = {
116
+ 'Accept': 'application/gpx+xml',
117
+ 'Authorization': api_key,
118
+ 'Content-Type': 'application/json'
119
+ }
120
+ body = {
121
+ "coordinates": [[start_lon, start_lat], [end_lon, end_lat]]
122
+ }
123
+
124
+ try:
125
+ response = requests.post(url, json=body, headers=headers, timeout=12)
126
+ if response.status_code == 200:
127
+ with open(file_path, "wb") as f:
128
+ f.write(response.content)
129
+ gr.Info("Successfully fetched route from OpenRouteService!")
130
+ return file_path
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"?>
138
+ <gpx version="1.1" creator="Trailhead Fallback" xmlns="http://www.topografix.com/GPX/1/1">
139
+ <trk>
140
+ <trkseg>
141
+ <trkpt lat="{start_lat}" lon="{start_lon}"></trkpt>
142
+ <trkpt lat="{mid_lat}" lon="{mid_lon}"></trkpt>
143
+ <trkpt lat="{end_lat}" lon="{end_lon}"></trkpt>
144
+ </trkseg>
145
+ </trk>
146
+ </gpx>"""
147
+ with open(file_path, "w", encoding="utf-8") as f:
148
+ f.write(gpx_content)
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'>
179
+ <div class='hud-stat-val'>{data['total_distance_km']:.2f}</div>
180
+ <div class='hud-stat-lbl'>Distance (km)</div>
181
+ </div>
182
+ <div class='hud-stat-box'>
183
+ <div class='hud-stat-val'>{data['elevation_gain_m']:.1f}</div>
184
+ <div class='hud-stat-lbl'>Elevation Gain (m)</div>
185
+ </div>
186
+ <div class='hud-stat-box'>
187
+ <div class='hud-stat-val'>{data['elevation_loss_m']:.1f}</div>
188
+ <div class='hud-stat-lbl'>Elevation Loss (m)</div>
189
+ </div>
190
+ <div class='hud-stat-box'>
191
+ <div class='hud-stat-val'>{data['min_elevation_m']:.0f} - {data['max_elevation_m']:.0f}</div>
192
+ <div class='hud-stat-lbl'>Altitude Range (m)</div>
193
+ </div>
194
+ <div class='hud-stat-box'>
195
+ <div class='hud-stat-val'>{data['estimated_days']:.1f}</div>
196
+ <div class='hud-stat-lbl'>Est. Hiking Days</div>
197
+ </div>
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([
209
+ cp["name"],
210
+ f"{cp['lat']:.5f}, {cp['lon']:.5f}",
211
+ f"{cp['cum_dist']:.2f} km",
212
+ f"{cp['ele']:.1f} m"
213
+ ])
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>
247
+ <p style='color: #f59e0b; font-family: "Share Tech Mono", monospace; letter-spacing: 0.1em; text-transform: uppercase; font-size: 1rem; margin-top: -5px;'>
248
+ Off-the-Grid Trail Computer & Route Planner
249
+ </p>
250
+ </div>
251
+ """)
252
+
253
+ with gr.Tabs():
254
+ with gr.TabItem("🧭 Trek Planner & HUD"):
255
+ with gr.Row():
256
+ with gr.Column(scale=1):
257
+ gr.Markdown("### 📂 Route Ingestion")
258
+
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(
266
+ file_types=[".gpx"],
267
+ label="Upload GPX Route File"
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)"
275
+ )
276
+ end_pt = gr.Textbox(
277
+ value="46.0788233, 11.1777218",
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()
295
+
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"],
302
+ datatype=["str", "str", "str", "str"],
303
+ column_count=(4, "fixed")
304
+ )
305
+
306
+ with gr.TabItem("💬 Wilderness Guide AI"):
307
+ gr.ChatInterface(
308
+ respond,
309
+ examples=[
310
+ "What gear checklist do I need for a 3-day high-altitude trek?",
311
+ "How do I treat a sprained ankle on the trail?",
312
+ "What is Naismith's Rule for calculating hiking time?"
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__":
346
+ port = int(os.environ.get("PORT", 7860))
347
+ try:
348
+ demo.launch(server_name="0.0.0.0", server_port=port)
349
+ except OSError:
350
+ print(f"[app] Port {port} is busy. Falling back to automatic port selection...")
351
+ demo.launch(server_name="127.0.0.1")
assets/custom.css ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Trailhead Premium Tactical HUD Theme */
2
+
3
+ @import url('https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Outfit:wght@300;400;500;600;700&display=swap');
4
+
5
+ :root {
6
+ --bg-gradient: linear-gradient(135deg, #0a0e12 0%, #050709 100%);
7
+ --card-bg: rgba(13, 20, 26, 0.75);
8
+ --card-border: rgba(245, 158, 11, 0.18); /* Amber border */
9
+ --accent-primary: #f59e0b; /* Amber */
10
+ --accent-hover: #d97706;
11
+ --accent-green: #10b981; /* Safe path green */
12
+ --text-primary: #f3f4f6;
13
+ --text-muted: #9ca3af;
14
+ --danger-red: #ef4444;
15
+ }
16
+
17
+ body, .gradio-container {
18
+ background: var(--bg-gradient) !important;
19
+ font-family: 'Outfit', -apple-system, sans-serif !important;
20
+ color: var(--text-primary) !important;
21
+ }
22
+
23
+ /* Share Tech Mono for digital stats & coordinates */
24
+ .mono-display {
25
+ font-family: 'Share Tech Mono', monospace !important;
26
+ }
27
+
28
+ /* Glassmorphism Cards */
29
+ .gradio-container .gr-box,
30
+ .gradio-container .gr-panel,
31
+ .gradio-container .gr-card {
32
+ background: var(--card-bg) !important;
33
+ border: 1px solid var(--card-border) !important;
34
+ backdrop-filter: blur(12px) !important;
35
+ border-radius: 16px !important;
36
+ box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.5) !important;
37
+ }
38
+
39
+ /* Typography settings */
40
+ h1 {
41
+ font-family: 'Share Tech Mono', monospace !important;
42
+ font-weight: 700 !important;
43
+ letter-spacing: 0.05em !important;
44
+ background: linear-gradient(to right, #f59e0b, #fbbf24, #10b981) !important;
45
+ -webkit-background-clip: text !important;
46
+ -webkit-text-fill-color: transparent !important;
47
+ font-size: 2.5rem !important;
48
+ margin-bottom: 0.5rem !important;
49
+ text-align: center !important;
50
+ text-transform: uppercase !important;
51
+ text-shadow: 0 0 15px rgba(245, 158, 11, 0.2) !important;
52
+ }
53
+
54
+ p, span, label {
55
+ color: var(--text-primary) !important;
56
+ }
57
+
58
+ /* Buttons Styling */
59
+ .gradio-container button.primary {
60
+ background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%) !important;
61
+ border: 1px solid rgba(251, 191, 36, 0.3) !important;
62
+ color: black !important;
63
+ font-family: 'Share Tech Mono', monospace !important;
64
+ font-weight: 700 !important;
65
+ text-transform: uppercase !important;
66
+ font-size: 1.1rem !important;
67
+ border-radius: 12px !important;
68
+ padding: 14px 24px !important;
69
+ min-height: 54px !important; /* Touch target */
70
+ cursor: pointer !important;
71
+ transition: all 0.3s ease !important;
72
+ box-shadow: 0 4px 15px rgba(245, 158, 11, 0.25) !important;
73
+ }
74
+
75
+ .gradio-container button.primary:hover {
76
+ transform: translateY(-2px) !important;
77
+ box-shadow: 0 6px 20px rgba(245, 158, 11, 0.4) !important;
78
+ }
79
+
80
+ .gradio-container button.secondary {
81
+ background: rgba(255, 255, 255, 0.05) !important;
82
+ border: 1px solid var(--card-border) !important;
83
+ color: var(--text-primary) !important;
84
+ font-family: 'Share Tech Mono', monospace !important;
85
+ font-weight: 500;
86
+ text-transform: uppercase !important;
87
+ border-radius: 12px !important;
88
+ padding: 12px 20px !important;
89
+ min-height: 50px !important;
90
+ transition: all 0.3s ease !important;
91
+ }
92
+
93
+ .gradio-container button.secondary:hover {
94
+ background: rgba(245, 158, 11, 0.1) !important;
95
+ border-color: var(--accent-primary) !important;
96
+ }
97
+
98
+ /* Large Input Targets */
99
+ .gradio-container input,
100
+ .gradio-container textarea,
101
+ .gradio-container select {
102
+ background: rgba(0, 0, 0, 0.4) !important;
103
+ border: 1px solid var(--card-border) !important;
104
+ border-radius: 10px !important;
105
+ color: var(--text-primary) !important;
106
+ padding: 12px !important;
107
+ font-size: 1.05rem !important;
108
+ }
109
+
110
+ .gradio-container input:focus,
111
+ .gradio-container textarea:focus,
112
+ .gradio-container select:focus {
113
+ border-color: var(--accent-green) !important;
114
+ box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.2) !important;
115
+ }
116
+
117
+ /* Custom Table/DataFrame styling */
118
+ .gradio-container table {
119
+ background: transparent !important;
120
+ }
121
+
122
+ .gradio-container th {
123
+ background: rgba(245, 158, 11, 0.1) !important;
124
+ color: var(--accent-primary) !important;
125
+ font-weight: 600 !important;
126
+ font-family: 'Share Tech Mono', monospace !important;
127
+ text-transform: uppercase !important;
128
+ }
129
+
130
+ .gradio-container td {
131
+ border-bottom: 1px solid rgba(255, 255, 255, 0.05) !important;
132
+ }
133
+
134
+ /* HUD Stat Indicators */
135
+ .hud-stat-box {
136
+ border: 1px solid var(--card-border);
137
+ background: rgba(13, 20, 26, 0.8);
138
+ border-radius: 12px;
139
+ padding: 15px;
140
+ text-align: center;
141
+ box-shadow: inset 0 0 10px rgba(245, 158, 11, 0.05);
142
+ }
143
+
144
+ .hud-stat-val {
145
+ font-family: 'Share Tech Mono', monospace;
146
+ font-size: 2.2rem;
147
+ font-weight: 700;
148
+ color: var(--accent-primary);
149
+ text-shadow: 0 0 8px rgba(245, 158, 11, 0.3);
150
+ }
151
+
152
+ .hud-stat-lbl {
153
+ font-size: 0.8rem;
154
+ text-transform: uppercase;
155
+ color: var(--text-muted);
156
+ letter-spacing: 0.1em;
157
+ margin-top: 5px;
158
+ }
requirements.txt ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ gradio>=5.0.0
2
+ pandas
3
+ numpy
4
+ requests
5
+ huggingface_hub
6
+ gpxpy
7
+ folium
src/gpx_parser.py ADDED
@@ -0,0 +1,314 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import math
3
+ import json
4
+ import requests
5
+ import gpxpy
6
+
7
+ def haversine(lat1, lon1, lat2, lon2):
8
+ """Calculate the great-circle distance between two points on the Earth in meters."""
9
+ R = 6371000.0 # Radius of Earth in meters
10
+ phi1 = math.radians(lat1)
11
+ phi2 = math.radians(lat2)
12
+ delta_phi = math.radians(lat2 - lat1)
13
+ delta_lambda = math.radians(lon2 - lon1)
14
+
15
+ a = math.sin(delta_phi / 2.0)**2 + math.cos(phi1) * math.cos(phi2) * math.sin(delta_lambda / 2.0)**2
16
+ c = 2.0 * math.atan2(math.sqrt(a), math.sqrt(1.0 - a))
17
+ return R * c
18
+
19
+ def fetch_elevations_open_meteo(coords):
20
+ """
21
+ Fetch elevation coordinates in batches of 100 from the Open-Meteo elevation API.
22
+ Returns a list of floats representing elevation in meters.
23
+ """
24
+ elevations = []
25
+ batch_size = 100
26
+ for i in range(0, len(coords), batch_size):
27
+ batch = coords[i:i+batch_size]
28
+ lats = ",".join(f"{c[0]:.6f}" for c in batch)
29
+ lons = ",".join(f"{c[1]:.6f}" for c in batch)
30
+ url = f"https://api.open-meteo.com/v1/elevation?latitude={lats}&longitude={lons}"
31
+
32
+ try:
33
+ print(f"[gpx_parser] Fetching elevation batch {i//batch_size + 1}...")
34
+ response = requests.get(url, timeout=10)
35
+ if response.status_code == 200:
36
+ data = response.json()
37
+ batch_elevations = data.get("elevation", [])
38
+ if len(batch_elevations) == len(batch):
39
+ elevations.extend(batch_elevations)
40
+ else:
41
+ print("[gpx_parser] Elevation list size mismatch. Filling with 0.0")
42
+ elevations.extend([0.0] * len(batch))
43
+ else:
44
+ print(f"[gpx_parser] API error {response.status_code}. Using 0.0 for batch.")
45
+ elevations.extend([0.0] * len(batch))
46
+ except Exception as e:
47
+ print(f"[gpx_parser] Network/parsing exception: {e}. Using 0.0 for batch.")
48
+ elevations.extend([0.0] * len(batch))
49
+
50
+ return elevations
51
+
52
+ def smooth_elevations(elevations, window_size=5):
53
+ """Apply a simple moving average window to smooth out elevation profile data."""
54
+ if not elevations:
55
+ return []
56
+ smoothed = []
57
+ for i in range(len(elevations)):
58
+ start = max(0, i - window_size // 2)
59
+ end = min(len(elevations), i + window_size // 2 + 1)
60
+ window = elevations[start:end]
61
+ smoothed.append(sum(window) / len(window))
62
+ return smoothed
63
+
64
+ def calculate_elevation_gain_loss(elevations, threshold=2.0):
65
+ """
66
+ Calculate cumulative elevation gain and loss in meters.
67
+ Filters out noise using a threshold value (minimum elevation delta).
68
+ """
69
+ gain = 0.0
70
+ loss = 0.0
71
+ if len(elevations) < 2:
72
+ return gain, loss
73
+
74
+ last_val = elevations[0]
75
+ for val in elevations[1:]:
76
+ diff = val - last_val
77
+ if abs(diff) >= threshold:
78
+ if diff > 0:
79
+ gain += diff
80
+ else:
81
+ loss += abs(diff)
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.
89
+ """
90
+ # Create cache directory if needed
91
+ os.makedirs(cache_dir, exist_ok=True)
92
+
93
+ # Check cache first
94
+ file_name = os.path.basename(file_path)
95
+ cache_path = os.path.join(cache_dir, f"{file_name}.cache.json")
96
+ if os.path.exists(cache_path):
97
+ try:
98
+ with open(cache_path, "r", encoding="utf-8") as f:
99
+ print(f"[gpx_parser] Loading cached GPX data from {cache_path}")
100
+ return json.load(f)
101
+ except Exception as e:
102
+ print(f"[gpx_parser] Cache read error: {e}, parsing raw file...")
103
+
104
+ print(f"[gpx_parser] Parsing raw GPX file: {file_path}")
105
+ with open(file_path, "r", encoding="utf-8") as f:
106
+ gpx = gpxpy.parse(f)
107
+
108
+ # Extract track points
109
+ points_raw = []
110
+ for track in gpx.tracks:
111
+ for segment in track.segments:
112
+ for pt in segment.points:
113
+ points_raw.append({
114
+ "lat": pt.latitude,
115
+ "lon": pt.longitude,
116
+ "ele": pt.elevation
117
+ })
118
+
119
+ # If GPX had no track points, look in waypoints or route points
120
+ if not points_raw:
121
+ for route in gpx.routes:
122
+ for pt in route.points:
123
+ points_raw.append({
124
+ "lat": pt.latitude,
125
+ "lon": pt.longitude,
126
+ "ele": pt.elevation
127
+ })
128
+
129
+ # Still empty? Check waypoints
130
+ if not points_raw and gpx.waypoints:
131
+ for wpt in gpx.waypoints:
132
+ points_raw.append({
133
+ "lat": wpt.latitude,
134
+ "lon": wpt.longitude,
135
+ "ele": wpt.elevation
136
+ })
137
+
138
+ if not points_raw:
139
+ raise ValueError("No trackpoints, routepoints, or waypoints found in GPX file.")
140
+
141
+ # Check if elevations are missing (all None or 0.0)
142
+ has_elevation = any(pt["ele"] is not None for pt in points_raw)
143
+
144
+ if not has_elevation:
145
+ print("[gpx_parser] GPX has no elevation data. Fetching from Open-Meteo elevation API...")
146
+ coords = [(pt["lat"], pt["lon"]) for pt in points_raw]
147
+ elevations = fetch_elevations_open_meteo(coords)
148
+ for i, ele in enumerate(elevations):
149
+ points_raw[i]["ele"] = ele
150
+ else:
151
+ # Fill in any scattered missing elevations
152
+ for pt in points_raw:
153
+ if pt["ele"] is None:
154
+ pt["ele"] = 0.0
155
+
156
+ # Smooth elevations
157
+ raw_elevations = [pt["ele"] for pt in points_raw]
158
+ smoothed_eles = smooth_elevations(raw_elevations)
159
+ for i, ele in enumerate(smoothed_eles):
160
+ points_raw[i]["ele"] = ele
161
+
162
+ # Calculate cumulative distances (in meters) and build final points list
163
+ points_data = []
164
+ cum_dist = 0.0
165
+
166
+ points_data.append({
167
+ "lat": points_raw[0]["lat"],
168
+ "lon": points_raw[0]["lon"],
169
+ "ele": points_raw[0]["ele"],
170
+ "cum_dist": 0.0
171
+ })
172
+
173
+ for i in range(1, len(points_raw)):
174
+ p1 = points_raw[i-1]
175
+ p2 = points_raw[i]
176
+ d = haversine(p1["lat"], p1["lon"], p2["lat"], p2["lon"])
177
+ cum_dist += d
178
+ points_data.append({
179
+ "lat": p2["lat"],
180
+ "lon": p2["lon"],
181
+ "ele": p2["ele"],
182
+ "cum_dist": cum_dist
183
+ })
184
+
185
+ # Calculate statistics
186
+ total_distance_m = cum_dist
187
+ total_distance_km = total_distance_m / 1000.0
188
+
189
+ gain, loss = calculate_elevation_gain_loss(smoothed_eles)
190
+
191
+ min_ele = min(smoothed_eles) if smoothed_eles else 0.0
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
201
+ waypoints = []
202
+ for wpt in gpx.waypoints:
203
+ waypoints.append({
204
+ "name": wpt.name or "Waypoint",
205
+ "lat": wpt.latitude,
206
+ "lon": wpt.longitude,
207
+ "ele": wpt.elevation or 0.0,
208
+ "desc": wpt.description or ""
209
+ })
210
+
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:
220
+ d = haversine(wpt["lat"], wpt["lon"], pt["lat"], pt["lon"])
221
+ if d < min_d:
222
+ min_d = d
223
+ closest_pt = pt
224
+ checkpoints.append({
225
+ "name": wpt["name"],
226
+ "lat": wpt["lat"],
227
+ "lon": wpt["lon"],
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),
240
+ "elevation_gain_m": round(gain, 1),
241
+ "elevation_loss_m": round(loss, 1),
242
+ "min_elevation_m": round(min_ele, 1),
243
+ "max_elevation_m": round(max_ele, 1),
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
251
+ try:
252
+ with open(cache_path, "w", encoding="utf-8") as f:
253
+ json.dump(result, f, indent=2)
254
+ print(f"[gpx_parser] Saved parsed GPX data cache to {cache_path}")
255
+ except Exception as e:
256
+ print(f"[gpx_parser] Cache write error: {e}")
257
+
258
+ return result
259
+
260
+ def generate_checkpoints(points_data, interval_meters=1000.0):
261
+ """Helper to partition track into regular distance checkpoints."""
262
+ if not points_data:
263
+ return []
264
+
265
+ checkpoints = []
266
+ start_pt = points_data[0]
267
+ checkpoints.append({
268
+ "name": "Start",
269
+ "lat": start_pt["lat"],
270
+ "lon": start_pt["lon"],
271
+ "ele": start_pt["ele"],
272
+ "cum_dist": 0.0
273
+ })
274
+
275
+ total_dist = points_data[-1]["cum_dist"]
276
+ next_checkpoint_dist = interval_meters
277
+ pt_idx = 1
278
+
279
+ while next_checkpoint_dist < total_dist:
280
+ while pt_idx < len(points_data) and points_data[pt_idx]["cum_dist"] < next_checkpoint_dist:
281
+ pt_idx += 1
282
+
283
+ if pt_idx >= len(points_data):
284
+ break
285
+
286
+ p1 = points_data[pt_idx - 1]
287
+ p2 = points_data[pt_idx]
288
+
289
+ if abs(p1["cum_dist"] - next_checkpoint_dist) < abs(p2["cum_dist"] - next_checkpoint_dist):
290
+ chosen = p1
291
+ else:
292
+ chosen = p2
293
+
294
+ checkpoints.append({
295
+ "name": f"Km {next_checkpoint_dist / 1000.0:.1f}",
296
+ "lat": chosen["lat"],
297
+ "lon": chosen["lon"],
298
+ "ele": chosen["ele"],
299
+ "cum_dist": round(chosen["cum_dist"] / 1000.0, 2)
300
+ })
301
+
302
+ next_checkpoint_dist += interval_meters
303
+
304
+ end_pt = points_data[-1]
305
+ if len(checkpoints) == 1 or (total_dist / 1000.0 - checkpoints[-1]["cum_dist"]) > 0.1:
306
+ checkpoints.append({
307
+ "name": "End",
308
+ "lat": end_pt["lat"],
309
+ "lon": end_pt["lon"],
310
+ "ele": end_pt["ele"],
311
+ "cum_dist": round(total_dist / 1000.0, 2)
312
+ })
313
+
314
+ return checkpoints
src/llm.py ADDED
@@ -0,0 +1,355 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import time
3
+ import requests
4
+ import json
5
+ import base64
6
+ import threading
7
+ from PIL import Image
8
+
9
+ _model_lock = threading.Lock()
10
+
11
+ # Backend configuration via environment variable. Defaults to auto-detected or "mock"
12
+ try:
13
+ import llama_cpp
14
+ default_backend = "llama_cpp"
15
+ except ImportError:
16
+ default_backend = "mock"
17
+ BACKEND = os.environ.get("BACKEND", default_backend).lower()
18
+
19
+ # Constants for Hugging Face Space model loading
20
+ MODEL_REPO = "bartowski/google_gemma-4-E2B-it-GGUF"
21
+ MODEL_FILE = "google_gemma-4-E2B-it-Q4_K_M.gguf"
22
+ LOCAL_MODEL_DIR = os.environ.get("MODEL_DIR", "./model")
23
+
24
+ _llama_model = None
25
+
26
+ def _download_gguf():
27
+ """Download GGUF model from Hugging Face if not already present."""
28
+ os.makedirs(LOCAL_MODEL_DIR, exist_ok=True)
29
+ local_path = os.path.join(LOCAL_MODEL_DIR, MODEL_FILE)
30
+ if os.path.exists(local_path):
31
+ print(f"[llm.py] Model GGUF already exists at {local_path}")
32
+ return local_path
33
+
34
+ print(f"[llm.py] Downloading {MODEL_FILE} from HF repo {MODEL_REPO}...")
35
+ try:
36
+ from huggingface_hub import hf_hub_download
37
+ downloaded_path = hf_hub_download(
38
+ repo_id=MODEL_REPO,
39
+ filename=MODEL_FILE,
40
+ local_dir=LOCAL_MODEL_DIR,
41
+ local_dir_use_symlinks=False
42
+ )
43
+ print(f"[llm.py] Model downloaded successfully to {downloaded_path}")
44
+ return downloaded_path
45
+ except Exception as e:
46
+ print(f"[llm.py] Error downloading model from Hugging Face: {e}")
47
+ return None
48
+
49
+ def init_llama_cpp():
50
+ """Lazy initialization of llama_cpp model."""
51
+ global _llama_model
52
+ if _llama_model is not None:
53
+ return _llama_model
54
+
55
+ try:
56
+ from llama_cpp import Llama
57
+ except ImportError:
58
+ print("[llm.py] Warning: llama-cpp-python is not installed. Falling back to mock backend.")
59
+ return None
60
+
61
+ model_path = _download_gguf()
62
+ if not model_path or not os.path.exists(model_path):
63
+ print("[llm.py] Error: Model file not found. Cannot load llama_cpp.")
64
+ return None
65
+
66
+ print(f"[llm.py] Loading model into memory: {model_path}")
67
+ num_threads = 1 if os.environ.get("SPACE_ID") else 4
68
+ try:
69
+ _llama_model = Llama(
70
+ model_path=model_path,
71
+ n_ctx=2048,
72
+ n_threads=num_threads,
73
+ verbose=False
74
+ )
75
+ print("[llm.py] llama_cpp model loaded successfully!")
76
+ return _llama_model
77
+ except Exception as e:
78
+ print(f"[llm.py] Error loading llama_cpp: {e}")
79
+ return None
80
+
81
+ # --- Whisper.cpp ASR (Speech-to-Text) ---
82
+ _whisper_model = None
83
+
84
+ def _init_whisper():
85
+ """Lazy initialization of whisper.cpp model for offline ASR."""
86
+ global _whisper_model
87
+ if _whisper_model is not None:
88
+ return _whisper_model
89
+
90
+ try:
91
+ from pywhispercpp.model import Model as WhisperModel
92
+ print("[llm.py] Loading whisper.cpp 'tiny' model for ASR...")
93
+ _whisper_model = WhisperModel(
94
+ 'tiny',
95
+ n_threads=2 if not os.environ.get("SPACE_ID") else 1
96
+ )
97
+ print("[llm.py] whisper.cpp ASR model loaded successfully!")
98
+ return _whisper_model
99
+ except ImportError:
100
+ print("[llm.py] pywhispercpp not installed. ASR will use mock fallback.")
101
+ return None
102
+ except Exception as e:
103
+ print(f"[llm.py] Error loading whisper.cpp ASR model: {e}")
104
+ return None
105
+
106
+ def transcribe_audio(audio_path, prompt=""):
107
+ """
108
+ Transcribe audio file to text using whisper.cpp (offline, lightweight).
109
+ Falls back to mock transcription if whisper.cpp is unavailable.
110
+ """
111
+ if not audio_path or not os.path.exists(audio_path):
112
+ print("[llm.py] Audio file not found, using mock ASR.")
113
+ return _mock_transcribe_audio(prompt)
114
+
115
+ whisper = _init_whisper()
116
+ if whisper is None:
117
+ print("[llm.py] whisper.cpp unavailable, using mock ASR fallback.")
118
+ return _mock_transcribe_audio(prompt)
119
+
120
+ temp_wav_path = None
121
+ try:
122
+ try:
123
+ import miniaudio
124
+ import wave
125
+ print(f"[llm.py] Decoding and resampling audio to 16kHz mono WAV using miniaudio...")
126
+ sound = miniaudio.decode_file(audio_path, nchannels=1, sample_rate=16000)
127
+
128
+ # Save to temp WAV file
129
+ temp_wav_path = audio_path + ".temp_16k.wav"
130
+ with wave.open(temp_wav_path, "wb") as wav_file:
131
+ wav_file.setnchannels(1)
132
+ wav_file.setsampwidth(2) # 16-bit PCM
133
+ wav_file.setframerate(16000)
134
+ wav_file.writeframes(sound.samples)
135
+
136
+ audio_path = temp_wav_path
137
+ print(f"[llm.py] Resampled audio saved to: {audio_path}")
138
+ except ImportError:
139
+ print("[llm.py] miniaudio not installed. Passing audio file directly to whisper.cpp.")
140
+ except Exception as e:
141
+ print(f"[llm.py] miniaudio transcoding failed: {e}. Passing original file directly.")
142
+
143
+ print(f"[llm.py] Transcribing audio: {audio_path}")
144
+ segments = whisper.transcribe(audio_path)
145
+ transcription = " ".join([seg.text.strip() for seg in segments]).strip()
146
+
147
+ if temp_wav_path and os.path.exists(temp_wav_path):
148
+ try: os.remove(temp_wav_path)
149
+ except: pass
150
+
151
+ if not transcription:
152
+ print("[llm.py] Whisper returned empty transcription, using mock fallback.")
153
+ return _mock_transcribe_audio(prompt)
154
+
155
+ print(f"[llm.py] ASR Transcription: \"{transcription}\"")
156
+ return transcription
157
+ except Exception as e:
158
+ if temp_wav_path and os.path.exists(temp_wav_path):
159
+ try: os.remove(temp_wav_path)
160
+ except: pass
161
+ print(f"[llm.py] Error during whisper.cpp transcription: {e}")
162
+ return _mock_transcribe_audio(prompt)
163
+
164
+ def _mock_transcribe_audio(prompt=""):
165
+ """Mock ASR fallback when whisper.cpp is not available."""
166
+ prompt_lower = str(prompt).lower() if prompt else ""
167
+ if "first" in prompt_lower or "injury" in prompt_lower or "ems" in prompt_lower:
168
+ return "How do I treat a sprained ankle on the trail?"
169
+ elif "gear" in prompt_lower or "backpack" in prompt_lower:
170
+ return "What gear list do I need for a 3-day high-altitude trek?"
171
+ else:
172
+ return "Am I on the correct route right now?"
173
+
174
+ # Keep backward-compatible alias
175
+ mock_transcribe_audio = _mock_transcribe_audio
176
+
177
+ def generate_mock(prompt, system="", image_path=None, audio_path=None, history=None):
178
+ """Simulate streaming for the mock backend tailored for Trailhead."""
179
+ response = ""
180
+
181
+ # 0. Handle Voice Audio ASR
182
+ if audio_path:
183
+ transcription = transcribe_audio(audio_path, prompt)
184
+ response += f"[🎙️ **Voice Journal Transcription:** \"{transcription}\"]\n\n"
185
+ prompt = transcription
186
+
187
+ prompt_lower = prompt.lower()
188
+
189
+ # 1. Checkpoint / Narration Queries
190
+ if "checkpoint" in prompt_lower or "narration" in prompt_lower or "current position" in prompt_lower:
191
+ response += (
192
+ "🧭 **Trailhead Contextual Guide:**\n"
193
+ "You are approaching **Km 2.0 Checkpoint**. The terrain ahead is moderately steep with an elevation gain of ~45m over the next kilometer.\n\n"
194
+ "⚠️ **Advisory:** Watch your water supply; the next reliable spring is at Km 3.5. Ensure you reach the shelter before 17:00 as temperatures drop rapidly to 5°C."
195
+ )
196
+ # 2. Gear Checklist Queries
197
+ elif "gear" in prompt_lower or "checklist" in prompt_lower or "pack" in prompt_lower:
198
+ response += (
199
+ "🎒 **Suggested Gear Checklist (Pace- & Altitude-Adjusted):**\n"
200
+ "Based on your 1-day trek details, here is a highly tailored packing guide:\n\n"
201
+ "- **Navigation:** Offline map download, compass, backup physical map.\n"
202
+ "- **Hydration:** 2.5L water capacity + iodine tablets (water sources tagged at Km 3.5).\n"
203
+ "- **Apparel:** Windbreaker/rain shell, moisture-wicking base layers, wool socks.\n"
204
+ "- **Safety:** First-aid kit (with blister care), whistle, multi-tool, space blanket.\n"
205
+ "- **Nutrition:** 2500 kcal high-density trail snacks (nuts, bars, jerky)."
206
+ )
207
+ # 3. Wilderness First-Aid / RAG Queries
208
+ elif "first-aid" in prompt_lower or "first aid" in prompt_lower or "medical" in prompt_lower or "injury" in prompt_lower or "sprain" in prompt_lower or "ams" in prompt_lower or "sick" in prompt_lower:
209
+ response += (
210
+ "🩹 **Wilderness First-Aid Protocol (CITED):**\n"
211
+ "For managing a **Sprained Ankle / Strain** in the backcountry, use the **R.I.C.E.** protocol:\n\n"
212
+ "1. **Rest:** Stop hiking immediately. Remove weight from the injured limb.\n"
213
+ "2. **Ice / Cold:** Apply a cold pack or submerge in cold trail stream for 20 mins to reduce swelling.\n"
214
+ "3. **Compression:** Wrap firmly with an elastic bandage (do not restrict circulation).\n"
215
+ "4. **Elevation:** Elevate the ankle above the heart level whenever resting.\n\n"
216
+ "📖 *CITED SOURCE: Wilderness Medicine Field Guide, Section 7: Musculoskeletal Injuries.*"
217
+ )
218
+ # 4. Off-Route / Deviation Queries
219
+ elif "route" in prompt_lower or "off-route" in prompt_lower or "deviate" in prompt_lower or "map" in prompt_lower:
220
+ response += (
221
+ "⚠️ **Navigation Warning:**\n"
222
+ "You have deviated from the planned polyline by **42 meters**. \n\n"
223
+ "**Action:** Look for physical trail markers or backtrack to your last known coordinate. Do not proceed off-trail through dense underbrush."
224
+ )
225
+ # 5. Default Response
226
+ else:
227
+ response += (
228
+ "🌲 **Welcome to Trailhead Navigation Assistant!**\n"
229
+ "I am your offline-first trail computer. I can analyze your uploaded GPX files, estimate Naismith trekking durations, auto-partition checkpoints, and offer grounded AI advice.\n\n"
230
+ "Ask me about gear checklists, route narration, deviation warnings, or wilderness first-aid emergency protocols."
231
+ )
232
+
233
+ for word in response.split(" "):
234
+ yield word + " "
235
+ time.sleep(0.03)
236
+
237
+ def generate_llama_cpp(prompt, system="", image_path=None, audio_path=None, history=None):
238
+ """Query the in-process llama-cpp-python model with a timeout fallback to mock."""
239
+ if getattr(generate_llama_cpp, "disabled", False):
240
+ print("[llm.py] llama_cpp is disabled (too slow or failed). Using mock backend.")
241
+ for chunk in generate_mock(prompt, system, image_path, audio_path, history):
242
+ yield chunk
243
+ return
244
+
245
+ acquired = _model_lock.acquire(blocking=True)
246
+ if not acquired:
247
+ print("[llm.py] Could not acquire model lock. Falling back to mock.")
248
+ for chunk in generate_mock(prompt, system, image_path, audio_path, history):
249
+ yield chunk
250
+ return
251
+
252
+ try:
253
+ start_time = time.time()
254
+ model = None
255
+ try:
256
+ model = init_llama_cpp()
257
+ except Exception as e:
258
+ print(f"[llm.py] Exception during init_llama_cpp: {e}")
259
+
260
+ if model is None:
261
+ print("[llm.py] Fallback to mock backend.")
262
+ for chunk in generate_mock(prompt, system, image_path, audio_path, history):
263
+ yield chunk
264
+ return
265
+
266
+ init_duration = time.time() - start_time
267
+ if init_duration > 35.0:
268
+ print(f"[llm.py] Warning: Model loading took {init_duration:.2f}s (exceeded 35s limit). Disabling llama_cpp and falling back to mock backend.")
269
+ generate_llama_cpp.disabled = True
270
+ for chunk in generate_mock(prompt, system, image_path, audio_path, history):
271
+ yield chunk
272
+ return
273
+
274
+ voice_prefix = ""
275
+ if audio_path:
276
+ transcription = transcribe_audio(audio_path, prompt)
277
+ voice_prefix = f"[🎙️ **ASR Transcribed:** \"{transcription}\"]\n\n"
278
+ prompt = f"The hiker asked by voice: '{transcription}'. Respond directly to this query."
279
+
280
+ if image_path:
281
+ prompt = f"[📸 Image uploaded] {prompt}"
282
+
283
+ formatted_prompt = f"<|im_start|>system\n{system}<|im_end|>\n"
284
+ if history:
285
+ for msg in history:
286
+ role = msg.get("role", "user")
287
+ content = msg.get("content", "")
288
+ formatted_prompt += f"<|im_start|>{role}\n{content}<|im_end|>\n"
289
+ formatted_prompt += f"<|im_start|>user\n{prompt}<|im_end|>\n<|im_start|>assistant\n"
290
+
291
+ print(f"\n--- [llama.cpp INPUT PROMPT] ---\n{formatted_prompt}\n--------------------------------")
292
+ print("--- [llama.cpp STREAMING RESPONSE] ---")
293
+ try:
294
+ response = model(
295
+ formatted_prompt,
296
+ max_tokens=512,
297
+ temperature=0.3,
298
+ top_p=0.9,
299
+ stream=True
300
+ )
301
+
302
+ first_token_timeout = 30.0
303
+ response_iter = iter(response)
304
+
305
+ first_chunk_start = time.time()
306
+ try:
307
+ first_chunk = next(response_iter)
308
+ except StopIteration:
309
+ first_chunk = None
310
+
311
+ prefill_duration = time.time() - first_chunk_start
312
+ if prefill_duration > first_token_timeout:
313
+ print(f"[llm.py] Prompt evaluation took {prefill_duration:.2f}s (exceeded {first_token_timeout}s limit). Disabling llama_cpp and falling back to mock.")
314
+ generate_llama_cpp.disabled = True
315
+ for chunk in generate_mock(prompt, system, image_path, audio_path, history):
316
+ yield chunk
317
+ return
318
+
319
+ if voice_prefix:
320
+ yield voice_prefix
321
+
322
+ if first_chunk:
323
+ text = first_chunk['choices'][0]['text']
324
+ cleaned = text.replace("<|im_end|>", "")
325
+ print(cleaned, end="", flush=True)
326
+ yield cleaned
327
+
328
+ for chunk in response_iter:
329
+ text = chunk['choices'][0]['text']
330
+ cleaned = text.replace("<|im_end|>", "")
331
+ print(cleaned, end="", flush=True)
332
+ yield cleaned
333
+ print("\n--------------------------------------")
334
+ except Exception as e:
335
+ print(f"[llm.py] Error running llama.cpp: {e}. Falling back to mock.")
336
+ for chunk in generate_mock(prompt, system, image_path, audio_path, history):
337
+ yield chunk
338
+ finally:
339
+ _model_lock.release()
340
+
341
+ def generate(prompt, system="", image_path=None, audio_path=None, history=None, stream=True):
342
+ """Entry point for LLM generation supporting text, image, and voice inputs."""
343
+ print(f"[llm.py] Using backend: {BACKEND}")
344
+ if BACKEND == "llama_cpp":
345
+ generator = generate_llama_cpp(prompt, system, image_path, audio_path, history)
346
+ else: # mock
347
+ generator = generate_mock(prompt, system, image_path, audio_path, history)
348
+
349
+ if stream:
350
+ return generator
351
+ else:
352
+ res = ""
353
+ for chunk in generator:
354
+ res += chunk
355
+ return res