Build a stdlib-only Python 3.9+ project called "walkguide" (no pip dependencies; optional external tools:
Ollama for a local Gemma model, Piper for offline TTS). It has two products that share code:
A) WALK GUIDE: an offline-first audio guide that narrates places near you while you walk.
B) TOUR PACK: for small tour operators; turns their stop list + notes into an offline, narrated, self-guided
tour (a static folder: index.html + audio files).
FILES
- sources.py: haversine, bearing, compass(deg)->"north-east" etc. Fetch POIs from OpenStreetMap Overpass
(two endpoint fallbacks; query named historic / tourism(museum|artwork|attraction|viewpoint|gallery) /
leisure(park|garden|nature_reserve) / place_of_worship / natural(peak|water|spring|wood) plus trees with
species; "out center tags 80") and Wikipedia geosearch (generator=geosearch, extracts+coordinates, intro,
plaintext, 4 sentences). Convert OSM tags to plain English sentences ("It is a church.", "It dates from
1890."). Skip OSM places with fewer than 2 facts. Merge by case-insensitive name (Wikipedia wins, add up
to 3 OSM facts). Cache per 0.01-degree cell (~1.1 km, fetch radius 800 m around the cell centre) as JSON
files; only cache when BOTH sources succeeded. Support offline mode (cache only).
nearby(lat, lon, cache_dir, offline, max_dist) returns POIs with dist and bearing, sorted by distance.
- narrator.py: build a prompt (Place, Location, up to 8 "Fact:" lines) and call Ollama /api/chat
(stream false, temperature 0.4, num_predict 140, default model gemma3:1b). Accept a custom system prompt
and max sentence count. GROUNDING GUARD: reject output if any number is not in the source text (name +
location + facts), or if any capitalised word that is not the first word of a sentence is not in the
source (allow "I" and compass words). On rejection retry once at temperature 0.1, then fall back to a
deterministic template built from the facts. Strip markdown, trim to N sentences. Return (text, meta)
with meta = {source: "llm"|"template", attempts, rejected: [...], latency_s}. Never crash if Ollama is down.
- speak.py: make_speaker(piper_model) returns a blocking speak(text) using, in order: Piper (+ aplay/
afplay/paplay/ffplay), termux-tts-speak, macOS say, espeak-ng/espeak, Windows System.Speech; None if
nothing is found. synth(text, base_path, piper_model) writes <base>.wav (Piper, say -o ... --data-format=
LEI16@22050, or espeak -w), converts to .mp3 if ffmpeg exists, returns the path or None.
- app.py (walk loop; CLI via argparse): location sources: --lat/--lon (fixed, 3 ticks), --walk
"lat,lon;lat,lon" (interpolated every 20 m), --gpx file, --termux (termux-location JSON, retry every 5 s
on failure). Flags: --model, --ollama-host, --piper-model, --trigger 60 (m), --cooldown 15, --tick 2,
--offline, --prefetch (cache 3x3 cells then exit), --no-speak, --reset. Each tick: track heading from
movement >5 m, find unvisited POIs within the trigger distance, pick the best by richness (fact length,
+2 for Wikipedia) minus dist/40, describe position as "about 40 metres ahead of you / on your left /
behind you / on your right" (or a compass direction when heading is unknown), narrate, speak, persist
visited IDs, append to metrics.jsonl, honour the cooldown. Simulated walks use a virtual clock at
1.4 m/s; --termux uses the wall clock. Print a summary on exit (narrations, model vs template,
guard rejections, mean latency) and save walks/walk-<timestamp>.json + .html.
- summary.py: build_html(walk) makes a self-contained Strava-style HTML page: SVG route map (equirectangular
projection fitted to the box, start green, end red, numbered pins for narrated places), stats (distance,
time, pace, places, avg model latency, guard rejections), a narration timeline (elapsed time, place,
position, sentence, badge "model name" or "template fallback", guard blocks). Light/dark via
prefers-color-scheme, mobile responsive, no external resources. Also --demo (fictional sample data)
and summary.py walks/x.json.
- tourpack.py: input JSON {title, operator, stops:[{name, lat, lon, notes}]}. For each stop: split the
operator's notes into sentences (FIRST and highest priority), optionally add up to 3 OSM/Wikipedia facts
when the name clearly matches or a Wikipedia POI is within 40 m (--no-enrich disables), location text
"Stop i of N", narrate with a tour-guide system prompt (3-5 short spoken sentences, use ONLY the facts,
start by saying which stop this is), then synth audio. Output tours/<slug>/index.html, audio/, and
build_report.json (per-stop source, latency, rejections, audio yes/no). index.html is ONE static file with
the tour data embedded (escape "</"): SVG map with dashed route + numbered pins (current / played
states), tap-to-play stops with transcript text, an <audio> element, an "Auto-play when I arrive"
checkbox using navigator.geolocation.watchPosition (arrival radius 40 m, play each stop once, don't
interrupt playback, needs HTTPS), a blue "you" dot, and a fallback to speechSynthesis when a stop has
no audio file. Light/dark, mobile-first, no external resources.
- tests.py: plain-assert offline tests (guard accepts grounded text and rejects invented numbers/names,
template fallback works with no model, model-offline falls back, walk interpolation, where() phrasing,
offline cache round-trip).
- sample_tour.json (clearly FICTIONAL stops) and README.md (setup for Ollama/Piper/Termux, all commands).
RULES
- Stdlib only. Never crash on network failure: print a short notice and degrade gracefully.
- Every sentence spoken must pass the grounding guard or be replaced by the template.
- Do not invent facts about real places in any sample data; label samples as fictional.
- After writing, run tests.py, run python3 tourpack.py sample_tour.json --model "" --no-enrich, syntax-check
the player's JavaScript, and fix anything that fails. Report what you could not test (live network, Ollama,
real TTS, browser rendering).