Live web enrichment (SearXNG) + PostGIS spatial stack
Milestone 1 — live web search: - mock/server.js: /search + /search-status proxy to the self-hosted SearXNG (third leg of the 3-source blend; engine endpoint stays server-side), /spatial + /spatial-status proxy to the spatial service - mock/app.js: background enrichment of suggestion cards — each candidate gets a web element (discovery cards + "you might like" rows) that is checked via SearXNG in the background (one in-flight query per place, 1 h TTL, failures retry in ~5 min) and APPENDS sourced facts with provenance URLs; never reorders/rewrites the plan. New LLM tools: web_search (cite URLs) and poi_near (PostGIS). Status badge gains 'web' / 'spatial'. - styles.css: .cc-web live-facts blocks, t-web verified chip Milestone 2 — PostGIS spatial DB (code complete; needs docker access to run — see spatial/README.md): - spatial/: docker-compose (postgis/postgis:16-3.4 + osm2pgsql:16 one-shot importer + spatiald), schema.sql (poi table w/ GIST index, spatial_extract bookkeeping, refresh_poi()), import.sh for the PBF extracts in ../osm, spatiald (Go, lib/pq): /health, /near (ST_DWithin), /nearest (KNN), /corridor (ST_Buffer along a route), with kind/name/extract/limit filters
This commit is contained in:
parent
ca32d20a50
commit
c367eba5cb
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -8,6 +8,7 @@ osm/build/
|
||||||
|
|
||||||
# Go
|
# Go
|
||||||
router/router
|
router/router
|
||||||
|
spatial/spatiald
|
||||||
router/bench
|
router/bench
|
||||||
router/scripts/__pycache__/
|
router/scripts/__pycache__/
|
||||||
|
|
||||||
|
|
|
||||||
118
mock/app.js
118
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; }
|
try { const r = await fetch('/llm-status'); const j = await r.json(); llmOn = !!j.llm; }
|
||||||
catch { llmOn = false; }
|
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 `<span class="web-src"><a href="${esc(r.url)}" target="_blank" rel="noopener">${esc(host)}</a></span>` +
|
||||||
|
`<span class="web-snip">${esc(snip.slice(0, 150))}${snip.length > 150 ? '…' : ''}</span>`;
|
||||||
|
}).join('<br>');
|
||||||
|
} else if (webBusy.has(c.id)) {
|
||||||
|
n.classList.add('busy');
|
||||||
|
n.innerHTML = '🌐 checking <b>' + esc(c.name) + '</b>…';
|
||||||
|
} 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]));
|
const esc = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||||
// Compact itinerary snapshot (the design doc's LLM context form): stops with
|
// Compact itinerary snapshot (the design doc's LLM context form): stops with
|
||||||
// times + stays + bookings. No geometry, no tool output. layout() fills
|
// 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 };
|
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) => {
|
route_between: async (a) => {
|
||||||
const A = resolvePlace(a.from), B = resolvePlace(a.to);
|
const A = resolvePlace(a.from), B = resolvePlace(a.to);
|
||||||
if (!A) return { error: `cannot resolve "from" = "${a.from}"` };
|
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' +
|
' 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' +
|
' 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' +
|
' 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' +
|
' 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' +
|
' 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' +
|
' 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' +
|
'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' +
|
'- 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' +
|
'- 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' +
|
'- 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' +
|
'- 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' +
|
'- 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'));
|
chips.append(el('span', 'pchip', c.price ? '~' + c.price + '€ / ' + (isHotel ? 'night' : 'person') : 'free'));
|
||||||
card.append(chips);
|
card.append(chips);
|
||||||
if (c.tags?.length) card.append(el('div', 'cc-tags', c.tags.map(t => '#' + t).join(' ')));
|
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 acts = el('div', 'cc-acts');
|
||||||
const pick = el('button', 'btn primary small', isHotel ? 'Make this the hotel' : 'Choose this');
|
const pick = el('button', 'btn primary small', isHotel ? 'Make this the hotel' : 'Choose this');
|
||||||
pick.onclick = () => chooseCand(c);
|
pick.onclick = () => chooseCand(c);
|
||||||
|
|
@ -1616,6 +1725,8 @@ function renderSugg() {
|
||||||
row.append(el('span', 'sr-name', c.name));
|
row.append(el('span', 'sr-name', c.name));
|
||||||
row.append(el('span', 'pchip', `↦ ${walkMin(a.at, c.at)} min`));
|
row.append(el('span', 'pchip', `↦ ${walkMin(a.at, c.at)} min`));
|
||||||
row.append(el('span', 'pchip', c.price ? '~' + c.price + '€' : 'free'));
|
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', '⚖');
|
const cmp = el('button', 'btn ghost tiny', '⚖');
|
||||||
cmp.title = 'Compare';
|
cmp.title = 'Compare';
|
||||||
cmp.onclick = () => toggleCompare(itemFromCand(c, slot));
|
cmp.onclick = () => toggleCompare(itemFromCand(c, slot));
|
||||||
|
|
@ -2366,10 +2477,11 @@ function enterTrip(id) {
|
||||||
renderHotelMks(); renderModeToggle();
|
renderHotelMks(); renderModeToggle();
|
||||||
renderDocChips();
|
renderDocChips();
|
||||||
// this trip may live on a different OSRM extract — re-check before routing
|
// 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');
|
const b = $('#offline-badge');
|
||||||
if (b && offlineState !== 'ready') b.textContent = badgeText();
|
if (b && offlineState !== 'ready') b.textContent = badgeText();
|
||||||
if (routerOn) enrichLegs(day);
|
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);
|
if (!day.legs.length) rebuildLegs(day); else enrichLegs(day);
|
||||||
renderAll();
|
renderAll();
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,12 @@ const LLM = {
|
||||||
ctx: 131072,
|
ctx: 131072,
|
||||||
maxTokens: +(process.env.LLM_MAX_TOKENS || 8192), // thinking + answer share this budget
|
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 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 PROFILE = { foot: 'walking', walk: 'walking', bike: 'cycling', cycle: 'cycling', car: 'driving', drive: 'driving', transit: 'driving' };
|
||||||
const CACHE = path.join(__dirname, '.tilecache');
|
const CACHE = path.join(__dirname, '.tilecache');
|
||||||
|
|
@ -371,6 +377,79 @@ http.createServer((req, res) => {
|
||||||
return;
|
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/<path> 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) --------------------
|
// ---- document ingestion (flight/booking → text) --------------------
|
||||||
// POST /parse-doc — body { filename, mime, b64 }. Returns { ok, kind,
|
// POST /parse-doc — body { filename, mime, b64 }. Returns { ok, kind,
|
||||||
// name, text, chars, truncated }. PDFs go through pdftotext; emails (.eml
|
// name, text, chars, truncated }. PDFs go through pdftotext; emails (.eml
|
||||||
|
|
|
||||||
|
|
@ -132,6 +132,17 @@ button { font: inherit; }
|
||||||
.t-search::before { content: "~"; font-weight: 800; }
|
.t-search::before { content: "~"; font-weight: 800; }
|
||||||
.t-llm { background: transparent; border: 1.5px dashed #b9c0cc; color: var(--ink2); }
|
.t-llm { background: transparent; border: 1.5px dashed #b9c0cc; color: var(--ink2); }
|
||||||
.t-llm::after { content: "est"; font-size: 9px; opacity: .8; }
|
.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; }
|
.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; }
|
.sug-note { font-size: 11px; color: var(--ink2); font-style: italic; }
|
||||||
|
|
||||||
|
|
|
||||||
12
spatial/Dockerfile
Normal file
12
spatial/Dockerfile
Normal file
|
|
@ -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"]
|
||||||
81
spatial/README.md
Normal file
81
spatial/README.md
Normal file
|
|
@ -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.
|
||||||
60
spatial/docker-compose.yml
Normal file
60
spatial/docker-compose.yml
Normal file
|
|
@ -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:
|
||||||
5
spatial/go.mod
Normal file
5
spatial/go.mod
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
module trips/spatial
|
||||||
|
|
||||||
|
go 1.27
|
||||||
|
|
||||||
|
require github.com/lib/pq v1.10.9
|
||||||
2
spatial/go.sum
Normal file
2
spatial/go.sum
Normal file
|
|
@ -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=
|
||||||
52
spatial/import.sh
Executable file
52
spatial/import.sh
Executable file
|
|
@ -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 <<SQL
|
||||||
|
INSERT INTO spatial_extract (name) VALUES ('${NAME}')
|
||||||
|
ON CONFLICT (name) DO NOTHING;
|
||||||
|
SELECT refresh_poi('${NAME}');
|
||||||
|
SQL
|
||||||
|
|
||||||
|
# 4. report
|
||||||
|
docker compose exec -T postgis psql -U trips -d trips -c \
|
||||||
|
"SELECT name, poi_count, to_char(imported_at,'YYYY-MM-DD HH24:MI') AS imported
|
||||||
|
FROM spatial_extract ORDER BY name;"
|
||||||
|
|
||||||
|
echo "done — extract '$NAME' is queryable. Start the service:"
|
||||||
|
echo " docker compose up -d spatiald # :5005, proxied by the mock server at /spatial/*"
|
||||||
405
spatial/main.go
Normal file
405
spatial/main.go
Normal file
|
|
@ -0,0 +1,405 @@
|
||||||
|
// spatiald — the PostGIS query service for the trip planner.
|
||||||
|
//
|
||||||
|
// Native spatial queries over the osm2pgsql-loaded OSM extracts:
|
||||||
|
//
|
||||||
|
// GET /health { ok, extracts: {name: poi_count} }
|
||||||
|
// GET /near?lat=&lng=&r= ST_DWithin radius search
|
||||||
|
// GET /nearest?lat=&lng= KNN (ORDER BY geom <-> 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
|
||||||
|
}
|
||||||
98
spatial/schema.sql
Normal file
98
spatial/schema.sql
Normal file
|
|
@ -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;
|
||||||
Loading…
Reference in New Issue
Block a user