diff --git a/.gitignore b/.gitignore
index 2268952..e2be789 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,6 +8,7 @@ osm/build/
# Go
router/router
+spatial/spatiald
router/bench
router/scripts/__pycache__/
diff --git a/mock/app.js b/mock/app.js
index 315de93..f6a1049 100644
--- a/mock/app.js
+++ b/mock/app.js
@@ -51,7 +51,82 @@ async function checkLLM() {
try { const r = await fetch('/llm-status'); const j = await r.json(); llmOn = !!j.llm; }
catch { llmOn = false; }
}
-const badgeText = () => 'πΆ online' + (routerOn ? ' Β· router' : '') + (llmOn ? ' Β· llm' : '');
+// ---------------- web search client (SearXNG via the /search proxy) ------
+// Live facts (opening hours, fees, seasonal notes) with provenance URLs β
+// the third leg of the 3-source blend. Enrichment APPENDS to suggestion
+// cards; it never reorders, removes, or rewrites the plan (DESIGN.md
+// "streaming enrichment").
+let webOn = false;
+async function checkWeb() {
+ try { const r = await fetch('/search-status'); webOn = !!(await r.json()).web; }
+ catch { webOn = false; }
+}
+// ---------------- spatial client (PostGIS spatiald via /spatial proxy) --
+let spatialOn = false, spatialPoi = 0;
+async function checkSpatial() {
+ try { const r = await fetch('/spatial-status'); const j = await r.json(); spatialOn = !!j.spatial; spatialPoi = j.poi_count || 0; }
+ catch { spatialOn = false; }
+}
+const badgeText = () => 'πΆ online' + (routerOn ? ' Β· router' : '') + (llmOn ? ' Β· llm' : '') + (webOn ? ' Β· web' : '') + (spatialOn ? ' Β· spatial' : '');
+const WEB_TTL = 60 * 60 * 1000; // a cached live check is good for an hour
+const tripCity = () => (M?.trip?.places?.[0]?.name) || (M?.trip?.title || '').split(' ')[0] || '';
+const webQuery = (c, cat) => {
+ const tail = cat === 'hotel' ? 'price per night reviews'
+ : ['lunch', 'dinner', 'breakfast', 'snack'].includes(cat) ? 'menu prices opening hours'
+ : 'opening hours entry fee';
+ return `"${c.name}" ${tripCity()} ${tail}`;
+};
+// Suggestion surfaces (discovery cards, "you might like" rows) embed a web
+// element via webEl(c, cat); it is re-rendered on every refresh, so results
+// are kept on the candidate (c.web) and every live node for that place is
+// refreshed via the data-webfor attribute.
+const webBusy = new Set();
+function webEl(c, cat) {
+ const n = el('div', 'cc-web');
+ n.dataset.webfor = c.id;
+ n.dataset.webcat = cat || '';
+ refreshWebEl(n, c);
+ return n;
+}
+function refreshWebEl(n, c) {
+ if (!n || !n.isConnected) return;
+ if (c.web) {
+ n.classList.remove('busy');
+ if (c.web.failed) { n.innerHTML = 'π live check unavailable (retry in a few minutes)'; return; }
+ const rs = (c.web.results || []).filter(r => r.url);
+ if (!rs.length) { n.innerHTML = 'π no live results'; return; }
+ n.innerHTML = rs.slice(0, 3).map(r => {
+ const host = (() => { try { return new URL(r.url).hostname.replace(/^www\./, ''); } catch { return r.source || 'web'; } })();
+ const snip = (r.snippet || r.title || '').replace(/Missing:.*$/i, '').trim();
+ return `${esc(host)}` +
+ `${esc(snip.slice(0, 150))}${snip.length > 150 ? 'β¦' : ''}`;
+ }).join('
');
+ } else if (webBusy.has(c.id)) {
+ n.classList.add('busy');
+ n.innerHTML = 'π checking ' + esc(c.name) + 'β¦';
+ } else n.innerHTML = '';
+}
+// fire-and-forget: at most one in-flight query per place, cached for WEB_TTL
+async function enrichWeb(c, cat) {
+ if (!webOn || !c) return;
+ if (c.web && Date.now() - c.web.at < WEB_TTL) return;
+ if (webBusy.has(c.id)) return;
+ webBusy.add(c.id);
+ document.querySelectorAll(`[data-webfor="${c.id}"]`).forEach(n => refreshWebEl(n, c));
+ try {
+ const r = await fetch('/search?q=' + encodeURIComponent(webQuery(c, cat)) + '&count=3');
+ const j = await r.json();
+ if (!j.ok) throw new Error(j.error || 'search failed');
+ c.web = { q: webQuery(c, cat), at: Date.now(), results: j.results || [] };
+ } catch {
+ // remember the failure so live nodes can show it, but age it out fast
+ // (retry after ~5 min, not after the full TTL)
+ c.web = { q: webQuery(c, cat), at: Date.now() - (WEB_TTL - 5 * 60 * 1000), results: [], failed: true };
+ } finally {
+ webBusy.delete(c.id);
+ document.querySelectorAll(`[data-webfor="${c.id}"]`).forEach(n => refreshWebEl(n, c));
+ }
+}
const esc = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
// Compact itinerary snapshot (the design doc's LLM context form): stops with
// times + stays + bookings. No geometry, no tool output. layout() fills
@@ -311,6 +386,36 @@ const TOOLS = {
walk_from_hotel_min: hotelWalkMin(p.at), url: c?.url || null, summary: c?.detail?.summary || null };
},
+ // LIVE web search (SearXNG) β for anything that can change between the
+ // data snapshot and the trip: opening hours, fees, seasonal events, prices.
+ // Results carry provenance URLs; the model must cite the ones it used.
+ web_search: async (a) => {
+ const q = String(a.query || '').trim();
+ if (!q) return { error: 'query is required' };
+ if (!webOn) return { error: 'the web search backend is offline β say so and fall back to what the plan already knows' };
+ const r = await fetch('/search?q=' + encodeURIComponent(q) + '&count=5' + (a.categories ? '&categories=' + encodeURIComponent(a.categories) : ''));
+ const j = await r.json();
+ if (!j.ok) return { error: j.error || 'search failed' };
+ return { count: j.count, results: (j.results || []).map(x => ({ title: x.title, url: x.url, source: x.source, snippet: x.snippet.slice(0, 300) })) };
+ },
+
+ // POI lookup against the local PostGIS extract (native ST_DWithin / KNN /
+ // corridor queries β real OSM data, not the curated demo pools).
+ poi_near: async (a) => {
+ if (!spatialOn) return { error: 'the spatial database is offline β use search_places instead' };
+ const lat = +a.lat, lng = +a.lng, r = Math.min(50000, Math.max(100, +a.radius_m || 500));
+ if (!isFinite(lat) || !isFinite(lng)) return { error: 'lat/lng must be numbers (lat, lng in degrees)' };
+ let qs = `lat=${lat}&lng=${lng}&r=${r}`;
+ if (a.kind) qs += '&kind=' + encodeURIComponent(a.kind); // e.g. amenity=restaurant, tourism=museum
+ if (a.name) qs += '&name=' + encodeURIComponent(a.name);
+ if (a.corridor) qs += '&corridor=' + encodeURIComponent(a.corridor); // 'lng,lat;lng,lat;β¦' route polyline
+ const rj = await fetch('/spatial/' + (a.corridor ? 'corridor' : 'near') + '?' + qs);
+ const j = await rj.json();
+ if (j.error) return { error: j.error };
+ return { count: (j.results || []).length, results: (j.results || []).map(x =>
+ ({ name: x.name, kind: x.kind, dist_m: Math.round(x.dist_m), opening_hours: x.opening_hours || null, fee: x.fee || null, website: x.website || null })) };
+ },
+
route_between: async (a) => {
const A = resolvePlace(a.from), B = resolvePlace(a.to);
if (!A) return { error: `cannot resolve "from" = "${a.from}"` };
@@ -535,6 +640,8 @@ function llmSystemPrompt() {
' itinerary_summary() β the current plan: bookings, stays, and each day\'s scheduled stops with times.\n' +
' search_places(category, region?) β candidate places. category β {' + cats.join(', ') + '} ; region β {' + regions.join(', ') + '} or omit. Returns id, name, price_eur, dur_min, tags, pitch, walk_from_hotel_min.\n' +
' place_facts(id) β full facts for one place (id or name): price, duration, tags, pitch, walk from hotel, summary.\n' +
+ ' web_search(query) β LIVE web search. Use for anything that can change since the data snapshot: opening hours, entry fees, seasonal events, current prices, whether a place still exists. Returns results with source URLs β always cite the URL(s) you relied on in your answer.\n' +
+ ' poi_near(lat, lng, radius_m?, kind?, name?) β real POIs from the local OSM/PostGIS extract (e.g. kind "amenity=restaurant"). Use when the curated pools are empty or the user asks "what is around X".\n' +
' route_between(from, to) β real walking minutes + km between two places ("hotel" or a place id/name).\n' +
' review_plan(dayId?) β deterministic checks: day overrun vs the waking window, meals at implausible hours, duplicates. dayId optional.\n' +
' add_stop(dayId, slot, candidateId, state?) β put a candidate on a day. slot β lunch,dinner,visit. If the slot is taken the current stop is demoted to a backup (a swap). state β planned,idea,backup (default planned). candidateId comes from search_places.\n' +
@@ -546,7 +653,7 @@ function llmSystemPrompt() {
'Guidelines:\n' +
'- dayId is the day id like "D1" or "D2" β shown in brackets in the plan rows, e.g. [D1 Β· 2026-09-12]. Never use the calendar date as a dayId.\n' +
'- Answer concisely (1β4 sentences), plain text, no markdown.\n' +
- '- For any factual question about times, distances, or prices, CALL the tool β do not guess.\n' +
+ '- For any factual question about times, distances, or prices, CALL the tool β do not guess. For CURRENT facts ("is it open?", "how much now", seasonal), prefer web_search and cite its source URLs.\n' +
'- For a requested change, call the matching fix-op, then confirm in one sentence what changed. The user can undo any edit.\n' +
'- Before a big rework, consider review_plan() and mention any issues it reports.\n' +
'- Be efficient: call each tool at most once unless you need to, and never re-fetch facts you already have. A fix-op alone is enough β do not re-search or re-fetch the stop you are moving/editing.\n' +
@@ -1097,6 +1204,8 @@ function candCard(c, anchor, isHotel) {
chips.append(el('span', 'pchip', c.price ? '~' + c.price + 'β¬ / ' + (isHotel ? 'night' : 'person') : 'free'));
card.append(chips);
if (c.tags?.length) card.append(el('div', 'cc-tags', c.tags.map(t => '#' + t).join(' ')));
+ card.append(webEl(c, isHotel ? 'hotel' : disc.cat)); // live web facts + provenance (background)
+ enrichWeb(c, isHotel ? 'hotel' : disc.cat);
const acts = el('div', 'cc-acts');
const pick = el('button', 'btn primary small', isHotel ? 'Make this the hotel' : 'Choose this');
pick.onclick = () => chooseCand(c);
@@ -1616,6 +1725,8 @@ function renderSugg() {
row.append(el('span', 'sr-name', c.name));
row.append(el('span', 'pchip', `β¦ ${walkMin(a.at, c.at)} min`));
row.append(el('span', 'pchip', c.price ? '~' + c.price + 'β¬' : 'free'));
+ const wn = webEl(c, slot); wn.classList.add('cc-web-sugg'); row.append(wn);
+ enrichWeb(c, slot);
const cmp = el('button', 'btn ghost tiny', 'β');
cmp.title = 'Compare';
cmp.onclick = () => toggleCompare(itemFromCand(c, slot));
@@ -2366,10 +2477,11 @@ function enterTrip(id) {
renderHotelMks(); renderModeToggle();
renderDocChips();
// this trip may live on a different OSRM extract β re-check before routing
- Promise.all([checkRouter(), checkLLM()]).then(() => {
+ Promise.all([checkRouter(), checkLLM(), checkWeb(), checkSpatial()]).then(() => {
const b = $('#offline-badge');
if (b && offlineState !== 'ready') b.textContent = badgeText();
if (routerOn) enrichLegs(day);
+ if (webOn && $('#sugg-block')) renderSugg(); // web came up late β enrich the suggestion rows now
});
if (!day.legs.length) rebuildLegs(day); else enrichLegs(day);
renderAll();
diff --git a/mock/server.js b/mock/server.js
index 894c4b2..9b1482e 100644
--- a/mock/server.js
+++ b/mock/server.js
@@ -36,6 +36,12 @@ const LLM = {
ctx: 131072,
maxTokens: +(process.env.LLM_MAX_TOKENS || 8192), // thinking + answer share this budget
};
+// Web search backend: self-hosted SearXNG (JSON API). The UI only ever sends
+// questions; the engine endpoint stays server-side. This is the third leg of
+// the DESIGN.md 3-source blend (OSM ground truth β LLM knowledge β live web).
+const SEARXNG = { base: process.env.SEARXNG_BASE || 'http://192.168.3.4:30053' };
+// PostGIS spatial service (see ../spatial/): live once the import has run.
+const SPATIAL = { url: process.env.SPATIAL_URL || 'http://localhost:5005' };
const routerFor = (u) => ROUTERS[u.searchParams.get('router') || 'northeast'] || ROUTERS.northeast;
const PROFILE = { foot: 'walking', walk: 'walking', bike: 'cycling', cycle: 'cycling', car: 'driving', drive: 'driving', transit: 'driving' };
const CACHE = path.join(__dirname, '.tilecache');
@@ -371,6 +377,79 @@ http.createServer((req, res) => {
return;
}
+ // ---- web search (proxy -> SearXNG) --------------------------------
+ // GET /search-status β availability probe (HEAD on the root; it does NOT
+ // touch the upstream engines, unlike a real query)
+ if (u.pathname === '/search-status') {
+ fetch(SEARXNG.base + '/', { method: 'HEAD', signal: AbortSignal.timeout(4000) })
+ .then(r => {
+ res.setHeader('Access-Control-Allow-Origin', '*');
+ res.setHeader('Content-Type', 'application/json');
+ res.writeHead(200);
+ res.end(JSON.stringify({ web: r.ok, base: SEARXNG.base }));
+ })
+ .catch(() => {
+ res.setHeader('Access-Control-Allow-Origin', '*');
+ res.setHeader('Content-Type', 'application/json'); res.writeHead(200);
+ res.end(JSON.stringify({ web: false, base: SEARXNG.base }));
+ });
+ return;
+ }
+ // GET /search?q=β¦&categories=β¦&count=β¦ β normalized result list with
+ // provenance (url + engine). SearXNG's JSON API wants format=json; the
+ // proxy caps results and strips the noise fields the UI doesn't render.
+ if (u.pathname === '/search') {
+ const q = u.searchParams.get('q') || '';
+ const count = Math.min(10, Math.max(1, +(u.searchParams.get('count') || 5)));
+ const cats = u.searchParams.get('categories') || 'general';
+ if (!q.trim()) { res.writeHead(400, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify({ ok: false, error: 'q is required' })); }
+ const up = `${SEARXNG.base}/search?q=${encodeURIComponent(q)}&format=json&categories=${encodeURIComponent(cats)}&pageno=1`;
+ const t0 = Date.now();
+ fetch(up, { signal: AbortSignal.timeout(20000) }).then(async r => {
+ if (!r.ok) throw new Error('searxng http ' + r.status);
+ const j = await r.json();
+ const results = (j.results || []).slice(0, count).map(x => ({
+ title: String(x.title || ''), url: String(x.url || ''),
+ source: String(x.engine || ''), snippet: String(x.content || ''),
+ })).filter(x => x.url);
+ console.log(`[search] ${JSON.stringify(q).slice(0, 80)} β ${results.length} results in ${Date.now() - t0}ms`);
+ res.setHeader('Access-Control-Allow-Origin', '*');
+ res.setHeader('Content-Type', 'application/json');
+ res.writeHead(200);
+ res.end(JSON.stringify({ ok: true, query: q, count: results.length, results }));
+ }).catch(e => {
+ res.setHeader('Access-Control-Allow-Origin', '*');
+ res.setHeader('Content-Type', 'application/json'); res.writeHead(200);
+ res.end(JSON.stringify({ ok: false, error: 'search failed: ' + e.message }));
+ });
+ return;
+ }
+
+ // ---- spatial backend (proxy -> PostGIS spatiald) -------------------
+ // /spatial-status probes /health; /spatial/ forwards verbatim
+ // (query string included) β /near, /nearest, /corridor.
+ if (u.pathname === '/spatial-status' || u.pathname.startsWith('/spatial/')) {
+ const up = u.pathname === '/spatial-status'
+ ? SPATIAL.url + '/health'
+ : SPATIAL.url + u.pathname.slice('/spatial'.length); // keep query string
+ const isProbe = u.pathname === '/spatial-status';
+ fetch(up, { signal: AbortSignal.timeout(15000) }).then(async r => {
+ const t = await r.text();
+ res.setHeader('Access-Control-Allow-Origin', '*');
+ res.setHeader('Content-Type', 'application/json');
+ res.writeHead(200);
+ if (!isProbe) return res.end(t); // forward the service body verbatim
+ let j = {}; try { j = JSON.parse(t); } catch {}
+ res.end(JSON.stringify({ spatial: r.ok && !!j.ok, url: SPATIAL.url, ...j }));
+ }).catch(() => {
+ res.setHeader('Access-Control-Allow-Origin', '*');
+ res.setHeader('Content-Type', 'application/json'); res.writeHead(200);
+ if (u.pathname === '/spatial-status') return res.end(JSON.stringify({ spatial: false, url: SPATIAL.url }));
+ res.end(JSON.stringify({ error: 'spatial backend unreachable at ' + SPATIAL.url }));
+ });
+ return;
+ }
+
// ---- document ingestion (flight/booking β text) --------------------
// POST /parse-doc β body { filename, mime, b64 }. Returns { ok, kind,
// name, text, chars, truncated }. PDFs go through pdftotext; emails (.eml
diff --git a/mock/styles.css b/mock/styles.css
index 50d9adb..52d5ea7 100644
--- a/mock/styles.css
+++ b/mock/styles.css
@@ -132,6 +132,17 @@ button { font: inherit; }
.t-search::before { content: "~"; font-weight: 800; }
.t-llm { background: transparent; border: 1.5px dashed #b9c0cc; color: var(--ink2); }
.t-llm::after { content: "est"; font-size: 9px; opacity: .8; }
+.t-web { background: #e3f4e6; color: #1d7a35; }
+.t-web::before { content: "β"; font-weight: 800; }
+/* live web facts on suggestion cards (background SearXNG enrichment) */
+.cc-web { margin-top: 7px; font-size: 11px; color: var(--ink2); line-height: 1.5;
+ border-top: 1px dashed var(--line); padding-top: 6px; }
+.cc-web:empty { display: none; }
+.cc-web.busy { opacity: .75; font-style: italic; }
+.cc-web .web-src a { color: #1d4f9c; font-weight: 700; text-decoration: none; }
+.cc-web .web-src a:hover { text-decoration: underline; }
+.cc-web .web-snip { display: block; margin: 1px 0 3px; }
+.cc-web-sugg { border-top: none; padding-top: 2px; }
.pchip { font-size: 11.5px; color: var(--ink2); font-weight: 600; white-space: nowrap; }
.sug-note { font-size: 11px; color: var(--ink2); font-style: italic; }
diff --git a/spatial/Dockerfile b/spatial/Dockerfile
new file mode 100644
index 0000000..036aa56
--- /dev/null
+++ b/spatial/Dockerfile
@@ -0,0 +1,12 @@
+# spatiald β PostGIS query service (see main.go for endpoints)
+FROM golang:1.27 AS build
+WORKDIR /src
+COPY go.mod go.sum ./
+RUN go mod download
+COPY main.go .
+RUN CGO_ENABLED=0 go build -o /spatiald .
+
+FROM debian:bookworm-slim
+COPY --from=build /spatiald /usr/local/bin/spatiald
+EXPOSE 5005
+ENTRYPOINT ["spatiald"]
diff --git a/spatial/README.md b/spatial/README.md
new file mode 100644
index 0000000..db975d2
--- /dev/null
+++ b/spatial/README.md
@@ -0,0 +1,81 @@
+# spatial β PostGIS source of truth
+
+Native spatial queries for the trip planner (DESIGN.md Β§Data: "PostGIS
+(Postgres) β import PBF via osm2pgsql; gives SQL spatial queries (radius,
+nearest, bbox)"). Replaces the flat-file/Overpass approximations.
+
+## Stack
+
+| Piece | What it is |
+|---|---|
+| `docker-compose.yml` | `postgis/postgis:16-3.4` (DB, port 5432) + one-shot `osm2pgsql:16` importer + `spatiald` (query service, port 5005) |
+| `schema.sql` | runs on first boot: `poi` table (named POIs as points, GIST index), `spatial_extract` bookkeeping, `refresh_poi()` |
+| `import.sh` | loads a PBF from `../osm/` via osm2pgsql, refreshes `poi` |
+| `main.go` (spatiald) | JSON query service over `poi` |
+| `Dockerfile` | builds spatiald |
+
+## Prerequisites
+
+* Docker with the current user in the `docker` group (on this box:
+ one-time `sudo usermod -aG docker $USER`, then re-login).
+* PBF extracts in `../osm/` (already present: `nh.osm.pbf`,
+ `colombia.osm.pbf`, US state extracts).
+
+## Usage
+
+```bash
+docker compose up -d postgis # first: runs schema.sql
+./import.sh ../osm/nh.osm.pbf nh # load New England (default)
+./import.sh ../osm/colombia.osm.pbf colombia
+docker compose up -d spatiald # query service on :5005
+```
+
+The mock server (`mock/server.js`) proxies it: `GET /spatial/*` β `:5005`,
+plus `GET /spatial-status` for the UI badge. When the service is up, the
+chat assistant gains the `poi_near` tool automatically.
+
+## spatiald endpoints
+
+All return `{"count":N,"results":[{extract,osm_id,name,kind,opening_hours,
+fee,website,addr_city,dist_m},β¦]}`. Common filters:
+
+* `extract=nh` β one loaded extract
+* `kind=amenity=restaurant` β exact kind, or `kind=restaurant` (any family)
+* `name=cafΓ©` β ILIKE substring
+* `limit=20` (max 200)
+
+| Endpoint | Query | SQL core |
+|---|---|---|
+| `GET /health` | β | extract bookkeeping |
+| `GET /near` | `lat,lng,r(m; default 500)` | `ST_DWithin(geography, β¦, r)` + KNN ordering |
+| `GET /nearest` | `lat,lng` | `ORDER BY geom <-> point` |
+| `GET /corridor` | `points=lng,lat;β¦`, `r` | `ST_DWithin(geom::geography, ST_Buffer(line::geography, r))` |
+
+Examples:
+
+```bash
+curl 'localhost:5005/near?lat=42.35&lng=-71.06&r=1000&kind=restaurant'
+curl 'localhost:5005/nearest?lat=10.40&lng=-75.54&kind=tourism&limit=5'
+curl 'localhost:5005/corridor?r=300&kind=fuel&points=-71.06,42.35;-71.07,42.36'
+```
+
+## Data notes
+
+* `poi` covers every **named** node (or polygon way, via centroid) with an
+ `amenity`/`tourism`/`shop`/`leisure`/`historic`/`place` tag β the kinds the
+ planner asks about. Unnamed amenities (e.g. a nameless kiosk) are out of
+ scope for v1.
+* `opening_hours`, `fee`, `website`, `addr_city` are carried straight from
+ the OSM tags; the live web enrichment (SearXNG, `mock/app.js`) fills gaps
+ the tags don't have (the 3-source blend).
+* Multiple extracts coexist in one DB, disambiguated by `poi.extract`
+ (set automatically by `import.sh`).
+* pgvector (semantic POI index, later milestone) gets its own init script +
+ embedding importer β the base image doesn't ship the `vector` package.
+
+## Roadmap hooks
+
+* `bbox` queries: trivial addition (`ST_Contains(ST_MakeEnvelope,β¦)`).
+* Routing-graph join: OSRM/GraphHopper geometries can be loaded into the
+ same DB (`route_geom` table) for true along-route analytics instead of
+ the current buffer-over-polyline.
diff --git a/spatial/docker-compose.yml b/spatial/docker-compose.yml
new file mode 100644
index 0000000..4d6c220
--- /dev/null
+++ b/spatial/docker-compose.yml
@@ -0,0 +1,60 @@
+# PostGIS spatial stack for the trip planner.
+#
+# docker compose up -d postgis # start the DB (schema.sql runs on first boot)
+# ./import.sh ../osm/nh.osm.pbf # import an OSM extract (one-shot osm2pgsql)
+# docker compose run --rm spatiald # start the query service on :5005
+#
+# The PBF extracts live in ../osm (gitignored). osm2pgsql runs as a one-shot
+# container so the host needs no osm2pgsql/osmium installation.
+services:
+ postgis:
+ image: postgis/postgis:16-3.4
+ container_name: trips-postgis
+ environment:
+ POSTGRES_DB: trips
+ POSTGRES_USER: trips
+ POSTGRES_PASSWORD: trips
+ ports:
+ - "5432:5432"
+ volumes:
+ - pgdata:/var/lib/postgresql/data
+ # initdb scripts run in lexicographic order, once, on an empty volume
+ - ./schema.sql:/docker-entrypoint-initdb.d/10-schema.sql:ro
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U trips -d trips"]
+ interval: 5s
+ timeout: 3s
+ retries: 12
+
+ # One-shot importer. `docker compose run --rm importer` (or import.sh)
+ # loads the PBF named in PBF_FILE into the DB. The osm2pgsql image runs
+ # as uid 1001 and needs the PBF readable β import.sh bind-mounts ../osm.
+ importer:
+ image: osm2pgsql/osm2pgsql:16
+ user: "1001:1001"
+ network_mode: "service:postgis"
+ environment:
+ PBF_FILE: /data/nh.osm.pbf
+ # --create --clean: fresh load every time (extracts are small enough);
+ # slim mode keeps the RAM footprint sane; disable-operations saves time.
+ ARGS: >-
+ --host=postgis --port=5432 --database=trips --user=trips
+ --password=trips --create --clean --slim
+ --flat-nodes=/tmp/flat.nodes --disable-operations
+ --import-strips=10
+ volumes:
+ - ../osm:/data:ro
+
+ spatiald:
+ build: .
+ container_name: trips-spatiald
+ environment:
+ SPATIAL_DSN: "host=postgis port=5432 user=trips password=trips dbname=trips sslmode=disable"
+ ports:
+ - "5005:5005"
+ depends_on:
+ postgis:
+ condition: service_healthy
+
+volumes:
+ pgdata:
diff --git a/spatial/go.mod b/spatial/go.mod
new file mode 100644
index 0000000..0292972
--- /dev/null
+++ b/spatial/go.mod
@@ -0,0 +1,5 @@
+module trips/spatial
+
+go 1.27
+
+require github.com/lib/pq v1.10.9
diff --git a/spatial/go.sum b/spatial/go.sum
new file mode 100644
index 0000000..aeddeae
--- /dev/null
+++ b/spatial/go.sum
@@ -0,0 +1,2 @@
+github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
+github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
diff --git a/spatial/import.sh b/spatial/import.sh
new file mode 100755
index 0000000..92e80f4
--- /dev/null
+++ b/spatial/import.sh
@@ -0,0 +1,52 @@
+#!/usr/bin/env bash
+# Import an OSM PBF extract into the local PostGIS stack via osm2pgsql.
+#
+# ./import.sh [file.osm.pbf] [extract-name]
+#
+# file.osm.pbf default: ../osm/nh.osm.pbf (path may be absolute)
+# extract-name default: basename without extension
+#
+# The PBF must be readable by uid 1001 (the osm2pgsql image user) β the
+# script bind-mounts its directory read-only. After the load, the poi
+# table is refreshed (refresh_poi()) and the spatial_extract row updated.
+set -euo pipefail
+cd "$(dirname "$0")"
+
+PBF=${1:-../osm/nh.osm.pbf}
+NAME=${2:-$(basename "${PBF%.osm.pbf}")}
+ABS=$(realpath "$PBF")
+[[ -f $ABS ]] || { echo "no such PBF: $ABS" >&2; exit 1; }
+
+# 1. DB up (first boot runs schema.sql)
+docker compose up -d postgis
+echo "waiting for postgis to be healthyβ¦"
+for i in $(seq 1 60); do
+ if docker compose exec -T postgis pg_isready -U trips -d trips >/dev/null 2>&1; then break; fi
+ sleep 2
+ [[ $i -eq 60 ]] && { echo "postgis never became healthy" >&2; exit 1; }
+done
+
+# 2. osm2pgsql load (one-shot container, PBF bind-mounted ro)
+docker run --rm \
+ --user 1001:1001 \
+ --network "trips_default" \
+ -v "$(dirname "$ABS"):/data:ro" \
+ -e PBF_FILE="/data/$(basename "$ABS")" \
+ -e ARGS="--host=postgis --port=5432 --database=trips --user=trips --password=trips \
+--create --clean --slim --flat-nodes=/tmp/flat.nodes --disable-operations" \
+ osm2pgsql/osm2pgsql:16
+
+# 3. refresh the queryable poi table + extract bookkeeping
+docker compose exec -T postgis psql -U trips -d trips -v ON_ERROR_STOP=1 < point)
+// GET /corridor?points=&r= corridor buffering along a route
+//
+// Optional filters on all three:
+//
+// extract=nh restrict to one loaded extract
+// kind=amenity=restaurant exact kind match, or one of the bare values
+// (restaurant, museum, cafe, β¦)
+// name=cafΓ© ILIKE substring on the POI name
+// limit=20 default 20, max 200
+//
+// /corridor's `points` is "lng,lat;lng,lat;β¦" (a router polyline, downsampled
+// is fine β the buffer does the smoothing work).
+package main
+
+import (
+ "database/sql"
+ "encoding/json"
+ "flag"
+ "fmt"
+ "log"
+ "net/http"
+ "strconv"
+ "strings"
+ "time"
+
+ _ "github.com/lib/pq"
+)
+
+var (
+ dsn *sql.DB
+ addr string
+)
+
+type poi struct {
+ Extract string `json:"extract"`
+ OsmID int64 `json:"osm_id"`
+ OsmType string `json:"osm_type"`
+ Name string `json:"name"`
+ Kind string `json:"kind"`
+ OpenH *string `json:"opening_hours,omitempty"`
+ Fee *string `json:"fee,omitempty"`
+ Website *string `json:"website,omitempty"`
+ AddrCity *string `json:"addr_city,omitempty"`
+ DistM float64 `json:"dist_m"`
+}
+
+func main() {
+ dsnFlag := flag.String("dsn",
+ "host=localhost port=5432 user=trips password=trips dbname=trips sslmode=disable",
+ "PostgreSQL/PostGIS DSN")
+ addrFlag := flag.String("addr", ":5005", "listen address")
+ flag.Parse()
+ addr = *addrFlag
+
+ var err error
+ dsn, err = sql.Open("postgres", *dsnFlag)
+ if err != nil {
+ log.Fatalf("sql.Open: %v", err)
+ }
+ dsn.SetMaxOpenConns(8)
+ dsn.SetConnMaxLifetime(30 * time.Minute)
+ if err = dsn.Ping(); err != nil {
+ log.Printf("spatiald: DB not reachable yet (%v) β serving /health as down until it is", err)
+ }
+
+ mux := http.NewServeMux()
+ mux.HandleFunc("/health", handleHealth)
+ mux.HandleFunc("/near", handleNear)
+ mux.HandleFunc("/nearest", handleNearest)
+ mux.HandleFunc("/corridor", handleCorridor)
+ log.Printf("spatiald: listening on %s (dsn: %s)", addr, *dsnFlag)
+ srv := &http.Server{Addr: addr, Handler: mux, ReadHeaderTimeout: 5 * time.Second}
+ if err = srv.ListenAndServe(); err != nil {
+ log.Fatal(err)
+ }
+}
+
+func cors(w http.ResponseWriter) {
+ w.Header().Set("Access-Control-Allow-Origin", "*")
+ w.Header().Set("Content-Type", "application/json")
+}
+
+func jerr(w http.ResponseWriter, code int, msg string) {
+ cors(w)
+ w.WriteHeader(code)
+ fmt.Fprintf(w, `{"error":%q}`, strings.ReplaceAll(msg, `"`, `\"`))
+}
+
+// common filters β (where clause, args). kind accepts "amenity=restaurant"
+// (exact) or "restaurant" (match the tag value across all kind families).
+func filters(q map[string][]string) (string, []any, error) {
+ var conds []string
+ var args []any
+ if e := first(q, "extract"); e != "" {
+ conds = append(conds, "poi.extract = $%d")
+ args = append(args, e)
+ }
+ if k := first(q, "kind"); k != "" {
+ if strings.Contains(k, "=") {
+ conds = append(conds, "poi.kind = $%d")
+ args = append(args, k)
+ } else {
+ // bare value: match the part after '=' in any kind family
+ conds = append(conds, "poi.kind LIKE $%d")
+ args = append(args, "%="+k)
+ }
+ }
+ if n := first(q, "name"); n != "" {
+ conds = append(conds, "poi.name ILIKE $%d")
+ args = append(args, "%"+n+"%")
+ }
+ if len(conds) == 0 {
+ return "", args, nil
+ }
+ return " WHERE " + strings.Join(conds, " AND "), args, nil
+}
+
+func first(q map[string][]string, k string) string {
+ if v := q[k]; len(v) > 0 {
+ return strings.TrimSpace(v[0])
+ }
+ return ""
+}
+
+func limitOf(q map[string][]string) int {
+ l, err := strconv.Atoi(first(q, "limit"))
+ if err != nil || l < 1 {
+ return 20
+ }
+ if l > 200 {
+ return 200
+ }
+ return l
+}
+
+// rows β pois; the SELECT must end with a distance column in metres.
+func scanPois(rs *sql.Rows) ([]poi, error) {
+ var out []poi
+ for rs.Next() {
+ var p poi
+ if err := rs.Scan(&p.Extract, &p.OsmID, &p.OsmType, &p.Name, &p.Kind,
+ &p.OpenH, &p.Fee, &p.Website, &p.AddrCity, &p.DistM); err != nil {
+ return nil, err
+ }
+ out = append(out, p)
+ }
+ return out, rs.Err()
+}
+
+func writePois(w http.ResponseWriter, pois []poi) {
+ if pois == nil {
+ pois = []poi{}
+ }
+ cors(w)
+ fmt.Fprintf(w, `{"count":%d,"results":`, len(pois))
+ b, _ := json.Marshal(pois)
+ w.Write(b)
+ w.Write([]byte("}"))
+}
+
+func handleHealth(w http.ResponseWriter, r *http.Request) {
+ cors(w)
+ type ext struct {
+ Name string `json:"name"`
+ PoiCount *int64 `json:"poi_count"`
+ Imported *string `json:"imported_at,omitempty"`
+ }
+ healthy := dsn.Ping() == nil
+ var extracts []ext
+ if healthy {
+ rows, err := dsn.Query(`SELECT name, poi_count, to_char(imported_at, 'YYYY-MM-DD"T"HH24:MI:SSOF') FROM spatial_extract ORDER BY name`)
+ if err == nil {
+ for rows.Next() {
+ var e ext
+ rows.Scan(&e.Name, &e.PoiCount, &e.Imported)
+ extracts = append(extracts, e)
+ }
+ rows.Close()
+ }
+ }
+ if extracts == nil {
+ extracts = []ext{}
+ }
+ fmt.Fprintf(w, `{"ok":%v,"postgis":%v,"extracts":`, healthy, healthy)
+ b, _ := json.Marshal(extracts)
+ w.Write(b)
+ w.Write([]byte("}"))
+}
+
+const poiCols = `poi.extract, poi.osm_id, poi.osm_type, poi.name, poi.kind,
+ poi.opening_hours, poi.fee, poi.website, poi.addr_city`
+
+func handleNear(w http.ResponseWriter, r *http.Request) {
+ q := r.URL.Query()
+ lat, lng, err := point(q)
+ if err != nil {
+ jerr(w, 400, err.Error())
+ return
+ }
+ rad, err := radius(q)
+ if err != nil {
+ jerr(w, 400, err.Error())
+ return
+ }
+ where, args, _ := filters(q)
+ args = append(args, lat, lng, rad, limitOf(q))
+ // point geometry first, then geography cast for the radius test, then KNN
+ // ordering among hits β ST_DWithin(geography) uses the GIST index.
+ query := `SELECT ` + poiCols + `,
+ ST_Distance_Sphere(poi.geom, ST_SetSRID(ST_MakePoint($2, $1), 4326)) AS dist_m
+ FROM poi` + where + `
+ AND ST_DWithin(poi.geom::geography, ST_SetSRID(ST_MakePoint($2, $1), 4326)::geography, $3)
+ ORDER BY poi.geom <-> ST_SetSRID(ST_MakePoint($2, $1), 4326)
+ LIMIT $4`
+ // renumber: filters consume $1..$n, so shift the point/radius/limit params
+ query, args = renumber(query, args, q)
+ if query == "" {
+ jerr(w, 500, "param renumbering failed")
+ return
+ }
+ rows, err := dsn.Query(query, args...)
+ if err != nil {
+ jerr(w, 502, "query: "+err.Error())
+ return
+ }
+ pois, err := scanPois(rows)
+ rows.Close()
+ if err != nil {
+ jerr(w, 502, err.Error())
+ return
+ }
+ writePois(w, pois)
+}
+
+func handleNearest(w http.ResponseWriter, r *http.Request) {
+ q := r.URL.Query()
+ lat, lng, err := point(q)
+ if err != nil {
+ jerr(w, 400, err.Error())
+ return
+ }
+ where, args, _ := filters(q)
+ args = append(args, lat, lng, limitOf(q))
+ query := `SELECT ` + poiCols + `,
+ ST_Distance_Sphere(poi.geom, ST_SetSRID(ST_MakePoint($2, $1), 4326)) AS dist_m
+ FROM poi` + where + `
+ ORDER BY poi.geom <-> ST_SetSRID(ST_MakePoint($2, $1), 4326)
+ LIMIT $3`
+ query, args = renumber(query, args, q)
+ if query == "" {
+ jerr(w, 500, "param renumbering failed")
+ return
+ }
+ rows, err := dsn.Query(query, args...)
+ if err != nil {
+ jerr(w, 502, "query: "+err.Error())
+ return
+ }
+ pois, err := scanPois(rows)
+ rows.Close()
+ if err != nil {
+ jerr(w, 502, err.Error())
+ return
+ }
+ writePois(w, pois)
+}
+
+func handleCorridor(w http.ResponseWriter, r *http.Request) {
+ q := r.URL.Query()
+ rad, err := radius(q)
+ if err != nil {
+ jerr(w, 400, err.Error())
+ return
+ }
+ pts := parsePoints(first(q, "points"))
+ if len(pts) < 2 {
+ jerr(w, 400, "points must be 'lng,lat;lng,lat;β¦' with at least 2 points")
+ return
+ }
+ where, args, _ := filters(q)
+ var coords []string
+ for _, p := range pts {
+ coords = append(coords, fmt.Sprintf("%s %s", strconv.FormatFloat(p[1], 'f', 6, 64), strconv.FormatFloat(p[0], 'f', 6, 64)))
+ }
+ line := "LINESTRING(" + strings.Join(coords, ",") + ")"
+ args = append(args, line, rad, limitOf(q))
+ // distance to a line uses ST_Distance(geography) (ST_Distance_Sphere is
+ // point-to-point only); the corridor is a buffered geography band.
+ query := `SELECT ` + poiCols + `,
+ ST_Distance(poi.geom::geography, ST_SetSRID(ST_GeomFromText($1), 4326)::geography) AS dist_m
+ FROM poi` + where + `
+ AND ST_DWithin(poi.geom::geography,
+ ST_Buffer(ST_SetSRID(ST_GeomFromText($1), 4326)::geography, $2), true)
+ ORDER BY dist_m
+ LIMIT $3`
+ query, args = renumber(query, args, q)
+ if query == "" {
+ jerr(w, 500, "param renumbering failed")
+ return
+ }
+ rows, err := dsn.Query(query, args...)
+ if err != nil {
+ jerr(w, 502, "query: "+err.Error())
+ return
+ }
+ pois, err := scanPois(rows)
+ rows.Close()
+ if err != nil {
+ jerr(w, 502, err.Error())
+ return
+ }
+ writePois(w, pois)
+}
+
+// point/radius parse the lat/lng (degrees) and r (metres) query params.
+func point(q map[string][]string) (lat, lng float64, err error) {
+ lat, err = strconv.ParseFloat(first(q, "lat"), 64)
+ if err != nil {
+ return 0, 0, fmt.Errorf("lat is required (decimal degrees)")
+ }
+ lng, err = strconv.ParseFloat(first(q, "lng"), 64)
+ if err != nil {
+ return 0, 0, fmt.Errorf("lng is required (decimal degrees)")
+ }
+ if lat < -90 || lat > 90 || lng < -180 || lng > 180 {
+ return 0, 0, fmt.Errorf("lat/lng out of range")
+ }
+ return lat, lng, nil
+}
+
+func radius(q map[string][]string) (float64, error) {
+ s := first(q, "r")
+ if s == "" {
+ return 500, nil
+ }
+ v, err := strconv.ParseFloat(s, 64)
+ if err != nil || v < 50 || v > 50000 {
+ return 0, fmt.Errorf("r must be 50..50000 metres")
+ }
+ return v, nil
+}
+
+// parsePoints: "lng,lat;lng,lat" β [[lng,lat],β¦] in that order.
+func parsePoints(s string) [][2]float64 {
+ var out [][2]float64
+ for _, pair := range strings.Split(s, ";") {
+ pair = strings.TrimSpace(pair)
+ if pair == "" {
+ continue
+ }
+ xy := strings.Split(pair, ",")
+ if len(xy) != 2 {
+ continue
+ }
+ lng, e1 := strconv.ParseFloat(strings.TrimSpace(xy[0]), 64)
+ lat, e2 := strconv.ParseFloat(strings.TrimSpace(xy[1]), 64)
+ if e1 == nil && e2 == nil {
+ out = append(out, [2]float64{lng, lat})
+ }
+ }
+ return out
+}
+
+// renumber: the WHERE clause (from filters) already contains $1..$n in the
+// order filters() appended its args. The trailing point/radius/limit params
+// were written as $1,$2,$3,$4 β rewrite them to $n+1, $n+2, β¦ so the arg
+// list (filter args first, then point args) lines up.
+func renumber(query string, args []any, q map[string][]string) (string, []any) {
+ n := 0
+ if e := first(q, "extract"); e != "" {
+ n++
+ }
+ if k := first(q, "kind"); k != "" {
+ n++
+ }
+ if nn := first(q, "name"); nn != "" {
+ n++
+ }
+ var b strings.Builder
+ for i := 0; i < len(query); i++ {
+ c := query[i]
+ if c == '$' && i+1 < len(query) && query[i+1] >= '1' && query[i+1] <= '9' {
+ // count-digit placeholders only β the filter params are < 10
+ d := int(query[i+1] - '0')
+ if d <= n {
+ b.WriteByte(c)
+ b.WriteByte(query[i+1])
+ i++
+ continue
+ }
+ fmt.Fprintf(&b, "$%d", n+d)
+ i++
+ continue
+ }
+ b.WriteByte(c)
+ }
+ return b.String(), args
+}
diff --git a/spatial/schema.sql b/spatial/schema.sql
new file mode 100644
index 0000000..f261594
--- /dev/null
+++ b/spatial/schema.sql
@@ -0,0 +1,98 @@
+-- PostGIS schema for the trip planner (runs once, on first container boot).
+-- osm2pgsql loads the standard planet_osm_* tables; we then materialize the
+-- POI points we query on, with a GIST index for ST_DWithin / KNN.
+
+CREATE EXTENSION IF NOT EXISTS postgis;
+
+-- Named data sets (so multi-extract setups β e.g. northeast + colombia β can
+-- be imported into one DB and disambiguated).
+CREATE TABLE IF NOT EXISTS spatial_extract (
+ name text PRIMARY KEY,
+ imported_at timestamptz NOT NULL DEFAULT now(),
+ bounds box, -- from osmium (in degrees)
+ poi_count bigint
+);
+
+-- Searchable POIs: every named node/way of the interesting kinds, as a point.
+-- osm2pgsql's default table schema (see README: --default-schema / stock).
+CREATE TABLE IF NOT EXISTS poi (
+ extract text NOT NULL REFERENCES spatial_extract(name) ON DELETE CASCADE,
+ osm_id bigint NOT NULL,
+ osm_type char NOT NULL, -- 'N' | 'W'
+ name text NOT NULL,
+ kind text NOT NULL, -- dominant tag: amenity=, tourism=, β¦
+ amenity text,
+ tourism text,
+ shop text,
+ leisure text,
+ historic text,
+ place text,
+ opening_hours text,
+ fee text, -- OSM fee tag / fee:* values
+ website text,
+ addr_city text,
+ geom geometry(Point, 4326) NOT NULL,
+ PRIMARY KEY (extract, osm_type, osm_id)
+);
+
+CREATE INDEX IF NOT EXISTS poi_geom_gist ON poi USING GIST (geom);
+CREATE INDEX IF NOT EXISTS poi_kind ON poi (extract, kind);
+CREATE INDEX IF NOT EXISTS poi_name_trgm ON poi USING GIN (to_tsvector('simple', name));
+
+-- pgvector (semantic POI index) is a later milestone: it needs the vector
+-- package in the image (postgis/postgis doesn't ship it), so it gets its own
+-- init script + embedding importer instead of living in this base schema.
+
+-- Helper: refresh the poi table from a freshly loaded extract.
+-- (Invoked by import.sh; kept here so the SQL lives in one place.)
+CREATE OR REPLACE FUNCTION refresh_poi(p_extract text) RETURNS void AS $$
+BEGIN
+ DELETE FROM poi WHERE extract = p_extract;
+ INSERT INTO poi (extract, osm_id, osm_type, name, kind, amenity, tourism, shop,
+ leisure, historic, place, opening_hours, fee, website, addr_city, geom)
+ WITH cand AS (
+ SELECT 'N'::char AS t, n.osm_id, n.name, n.amenity, n.tourism, n.shop,
+ n.leisure, n.historic, n.place, n.opening_hours, n.fee, n.website,
+ n.addr_city, ST_SetSRID(ST_MakePoint(n.lon, n.lat), 4326) AS geom
+ FROM planet_osm_node n
+ WHERE n.name IS NOT NULL AND n.name <> ''
+ AND (n.amenity IS NOT NULL OR n.tourism IS NOT NULL OR n.shop IS NOT NULL
+ OR n.leisure IS NOT NULL OR n.historic IS NOT NULL OR n.place IS NOT NULL)
+ UNION ALL
+ SELECT 'W'::char, w.osm_id, w.name, w.amenity, w.tourism, w.shop,
+ w.leisure, w.historic, w.place, w.opening_hours, w.fee, w.website,
+ w.addr_city, ST_Centroid(w.geom)
+ FROM planet_osm_way w
+ WHERE w.name IS NOT NULL AND w.name <> ''
+ AND (w.amenity IS NOT NULL OR w.tourism IS NOT NULL OR w.shop IS NOT NULL
+ OR w.leisure IS NOT NULL OR w.historic IS NOT NULL)
+ AND ST_GeometryType(w.geom) = 'ST_Polygon' -- areas only (gardens, parksβ¦)
+ )
+ SELECT p_extract, osm_id, t, name,
+ COALESCE(
+ CASE WHEN amenity IS NOT NULL THEN 'amenity=' || amenity
+ END,
+ CASE WHEN tourism IS NOT NULL THEN 'tourism=' || tourism
+ END,
+ CASE WHEN shop IS NOT NULL THEN 'shop=' || shop
+ END,
+ CASE WHEN leisure IS NOT NULL THEN 'leisure=' || leisure
+ END,
+ CASE WHEN historic IS NOT NULL THEN 'historic=' || historic
+ END,
+ CASE WHEN place IS NOT NULL THEN 'place=' || place
+ END
+ ),
+ amenity, tourism, shop, leisure, historic, place,
+ opening_hours, fee, website, addr_city, geom
+ FROM cand
+ WHERE COALESCE(amenity, tourism, shop, leisure, historic, place) IS NOT NULL;
+
+ UPDATE spatial_extract
+ SET poi_count = (SELECT count(*) FROM poi WHERE extract = p_extract),
+ imported_at = now()
+ WHERE name = p_extract;
+
+ ANALYZE poi;
+END;
+$$ LANGUAGE plpgsql;