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;