trips/mock/server.js
Greg Pomerantz 88dc0c5323 mock: drop a real flight booking → LLM re-anchors the trip
The user drags a flight ticket (PDF/text/ics/html) onto the chat panel,
or uses the new 📎 button. The doc is text-extracted server-side
(pdftotext for PDFs; images rejected with a clear "no OCR" message),
then the LLM reads it, extracts the confirmed arrival + departure
(date, local time, airport, city) and calls a new deterministic tool,
apply_flight_anchors, which reconfigures the trip:

- re-dates the day window across the booked [arrival, departure] span
  (contiguous when the day counts match, evenly spread when they don't)
- records the flights as hard-anchor bookings, replacing placeholders
- shortens the arrival day (start after landing + a real airport→hotel
  taxi time) and the departure day (ends exactly at the booked time)
- trims stops that no longer fit and updates the trip title's range

Undo/redo and reload persistence now version the trip's bookings + title
alongside days/stays, so a re-configuration reverts and reloads cleanly.
Server gains POST /parse-doc. Verified E2E (LLM extraction + reconfigure),
idempotency, drag-and-drop, and undo; non-flight agent unaffected.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-09 13:20:47 -04:00

220 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Mock server: static UI + local tile backend (proxy -> OSM, disk-cached)
// + routing backend (proxy -> local OSRM instances; per-trip extracts).
// The UI only ever talks to localhost; swap the upstream for a real
// tileserver-gl / OSRM later without touching the frontend.
const http = require('http');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { execFile } = require('child_process');
const PORT = 8077;
const ROOT = __dirname;
// One upstream OSRM per dataset. `probe` is a short routable pair inside the
// extract, used by /router-status (health checks return Ok for 0-distance
// queries only when the graph has edges there).
const ROUTERS = {
northeast: {
url: process.env.OSRM_NORTHEAST || 'http://localhost:5000',
probe: '-71.06,42.35;-71.07,42.36', // Boston, car extract
},
colombia: {
url: process.env.OSRM_COLOMBIA || 'http://localhost:5003',
probe: '-75.5478,10.3954;-75.5400,10.4020', // Cartagena, foot extract
},
};
// LLM backend: local llama.cpp OpenAI-compatible server (see ~/.pi/agent/
// models.json). Model + budget are pinned server-side; the client only ever
// sends messages. Qwen3.8-27B-UD3-Q5-dual: --parallel 2, --ctx-size 131072.
const LLM = {
base: process.env.LLM_BASE || 'http://192.168.3.7:1234',
model: process.env.LLM_MODEL || 'Qwen3.8-27B-UD3-Q5-dual',
ctx: 131072,
maxTokens: +(process.env.LLM_MAX_TOKENS || 8192), // thinking + answer share this budget
};
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');
fs.mkdirSync(CACHE, { recursive: true });
const mime = {
'.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css',
'.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png',
};
http.createServer((req, res) => {
const u = new URL(req.url, 'http://x');
// ---- routing backend (proxy -> local OSRM) ------------------------
// /route?mode=foot&points=lng,lat;lng,lat[;...]
// /table?mode=foot&points=lng,lat;lng,lat[;...]
if (u.pathname === '/route' || u.pathname === '/table') {
const kind = u.pathname.slice(1);
const mode = u.searchParams.get('mode') || 'foot';
const points = u.searchParams.get('points') || '';
const profile = PROFILE[mode] || 'driving';
let osrmPath = `/${kind}/v1/${profile}/${encodeURIComponent(points)}`;
if (kind === 'route') osrmPath += '?overview=full&alternatives=false&steps=false';
else {
// table: durations from the first point (the anchor) to every point
const n = points.split(';').filter(Boolean).length;
const targets = Array.from({ length: n }, (_, i) => i).join(',');
osrmPath += `?annotations=duration,distance&sources=0&targets=${targets}`;
}
const up = routerFor(u).url;
const fetchUp = fetch(up + osrmPath).then(r => r.text()).catch(() => null);
fetchUp.then(body => {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Content-Type', 'application/json');
if (body == null) { res.writeHead(502); return res.end(JSON.stringify({ code: 'ProxyError' })); }
res.writeHead(200); res.end(body);
});
return;
}
// routing availability probe (so the UI can label router vs estimate)
if (u.pathname === '/router-status') {
const rt = routerFor(u);
fetch(rt.url + '/route/v1/walking/' + rt.probe + '?overview=false').then(r => r.text())
.then(t => { let ok = false; try { ok = JSON.parse(t).code === 'Ok' && JSON.parse(t).routes?.[0]?.distance > 0; } catch (e) {}
res.setHeader('Content-Type', 'application/json'); res.writeHead(200); res.end(JSON.stringify({ router: ok, osrm: rt.url, key: rt === ROUTERS.northeast ? 'northeast' : 'colombia' })); })
.catch(() => { res.setHeader('Content-Type', 'application/json'); res.writeHead(200); res.end(JSON.stringify({ router: false, osrm: rt.url })); });
return;
}
// ---- tile backend -------------------------------------------------
if (u.pathname.startsWith('/tiles/')) {
const parts = u.pathname.split('/');
const [z, x, y] = [parts[2], parts[3], (parts[4] || '').replace(/\.png$/, '')];
if (!/^\d+$/.test(z) || !/^\d+$/.test(x) || !/^\d+$/.test(y)) { res.writeHead(400); return res.end(); }
const file = path.join(CACHE, `${z}/${x}`, `${y}.png`);
const upstream = `https://tile.openstreetmap.org/${z}/${x}/${y}.png`;
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Content-Type', 'image/png');
res.setHeader('Cache-Control', 'public, max-age=86400');
if (fs.existsSync(file)) { res.writeHead(200); fs.createReadStream(file).pipe(res); return; }
fs.mkdirSync(path.dirname(file), { recursive: true });
const fail = (code) => { if (!res.headersSent) { res.writeHead(code); res.end(); } else res.destroy(); };
// buffer the whole tile (≤100 KB), write the cache file, then reply —
// no stream-splitting races with a client that reads fast
fetch(upstream, { headers: { 'User-Agent': 'mapmock-dev/0.1 (local tile proxy)', 'Referer': `http://localhost:${PORT}/` } })
.then(async r => {
if (!r.ok) { return fail(r.status); }
const buf = Buffer.from(await r.arrayBuffer());
if (buf.length < 20) { return fail(502); } // empty/garbage tile
try { fs.writeFileSync(file, buf); } catch (e) {}
if (res.writableEnded) return;
res.writeHead(200); res.end(buf);
})
.catch(() => fail(502));
return;
}
// ---- LLM backend (proxy -> local llama.cpp server) ----------------
// GET /llm-status — availability probe (mirrors /router-status)
if (u.pathname === '/llm-status') {
fetch(LLM.base + '/v1/models').then(r => r.json())
.then(j => {
const m = (j.data || []).find(x => x.id === LLM.model);
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Content-Type', 'application/json');
res.writeHead(200);
res.end(JSON.stringify({
llm: !!(m && m.status && m.status.value === 'loaded'),
model: LLM.model, base: LLM.base, ctx: LLM.ctx,
status: (m && m.status && m.status.value) || 'unknown',
}));
})
.catch(() => {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Content-Type', 'application/json');
res.writeHead(200); res.end(JSON.stringify({ llm: false, model: LLM.model, base: LLM.base, status: 'unreachable' }));
});
return;
}
// POST /llm-chat — body { messages, max_tokens?, temperature? }.
// Non-streaming; the model is a reasoning model, so the server-side budget
// must cover hidden thinking plus the visible answer.
if (u.pathname === '/llm-chat' && req.method === 'POST') {
let body = '';
req.on('data', c => { body += c; if (body.length > 2e6) req.destroy(); });
req.on('end', () => {
let in0;
try { in0 = JSON.parse(body); } catch { res.writeHead(400); return res.end('bad json'); }
const payload = {
model: LLM.model,
messages: Array.isArray(in0.messages) ? in0.messages : [],
max_tokens: in0.max_tokens || LLM.maxTokens,
temperature: in0.temperature ?? 0.7,
stream: false,
};
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Content-Type', 'application/json');
fetch(LLM.base + '/v1/chat/completions', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload),
}).then(async r => {
const t = await r.text();
res.writeHead(r.ok ? 200 : 502);
res.end(t);
}).catch(() => {
res.writeHead(502);
res.end(JSON.stringify({ error: 'LLM server unreachable at ' + LLM.base }));
});
});
return;
}
// ---- document ingestion (flight/booking → text) --------------------
// POST /parse-doc — body { filename, mime, b64 }. Returns { ok, kind,
// name, text, chars }. PDFs go through pdftotext; text/ics/csv/json read
// directly; html tags are stripped. Images are rejected (no OCR here).
if (u.pathname === '/parse-doc' && req.method === 'POST') {
let body = '';
req.on('data', c => { body += c; if (body.length > 8e6) req.destroy(); });
req.on('end', () => {
let in0; try { in0 = JSON.parse(body); } catch { res.writeHead(400); return res.end('bad json'); }
const send = j => { res.setHeader('Content-Type', 'application/json'); res.writeHead(200); res.end(JSON.stringify(j)); };
const name = String(in0.filename || 'document');
const ext = path.extname(name).toLowerCase();
const mime = String(in0.mime || '');
if (/^image\//.test(mime) || ['.png', '.jpg', '.jpeg', '.webp', '.gif', '.bmp'].includes(ext))
return send({ ok: false, error: 'Screenshots and images arent supported yet (no OCR on this box). Save the booking as a PDF, or copy its text and paste it.' });
let buf; try { buf = Buffer.from(String(in0.b64 || ''), 'base64'); } catch { return send({ ok: false, error: 'couldnt read the file data' }); }
if (!buf.length) return send({ ok: false, error: 'that file looks empty' });
if (buf.length > 4e6) return send({ ok: false, error: 'file too large (max ~4 MB)' });
const finish = (text) => {
text = String(text || '').replace(/\r\n/g, '\n').replace(/\t/g, ' ').replace(/[ \u00a0]{2,}/g, ' ').replace(/\n{3,}/g, '\n\n').trim();
if (!text) return send({ ok: false, error: 'no readable text found in that file' });
send({ ok: true, kind: ext.replace('.', '') || 'text', name, text: text.slice(0, 20000), chars: text.length });
};
if (ext === '.pdf' || mime.includes('pdf')) {
const tmp = path.join(os.tmpdir(), 'parse-doc-' + Date.now() + '-' + Math.floor(Math.random() * 1e5) + '.pdf');
fs.writeFile(tmp, buf, (werr) => {
if (werr) return send({ ok: false, error: 'couldnt write temp file' });
execFile('pdftotext', ['-layout', tmp, '-'], (err, stdout) => {
try { fs.unlinkSync(tmp); } catch {}
finish(err ? '' : stdout);
});
});
return;
}
let text = buf.toString('utf8');
if (ext === '.html' || ext === '.htm' || mime.includes('html'))
text = text.replace(/<script[\s\S]*?<\/script>/gi, ' ').replace(/<style[\s\S]*?<\/style>/gi, ' ')
.replace(/<br[^>]*>/gi, '\n').replace(/<\/?p[^>]*>/gi, '\n').replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&#39;|&apos;/g, "'").replace(/&quot;/g, '"');
finish(text);
});
return;
}
// ---- static -------------------------------------------------------
let p = u.pathname === '/' ? '/index.html' : u.pathname;
const file = path.join(ROOT, p);
if (!file.startsWith(ROOT)) { res.writeHead(403); return res.end(); }
fs.readFile(file, (e, d) => {
if (e) { res.writeHead(404); return res.end('not found'); }
res.writeHead(200, { 'Content-Type': mime[path.extname(file)] || 'application/octet-stream' });
res.end(d);
});
}).listen(PORT, '0.0.0.0', () => console.log(`map mock: http://localhost:${PORT} (tiles: /tiles/{z}/{x}/{y}.png · llm: ${LLM.base} ${LLM.model})`));