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>
This commit is contained in:
Greg Pomerantz 2026-09-09 13:20:47 -04:00
parent c7b5ecb8da
commit 88dc0c5323
4 changed files with 298 additions and 7 deletions

View File

@ -114,6 +114,75 @@ function resolveStop(ref) {
return null; return null;
} }
// ---- flight-anchor reconfiguration: the user drops a real ticket and the
// plan re-anchors around the booked arrival/departure (times + locations) ----
const AIRPORTS = {
bog: [4.7016, -74.1469], ctg: [10.4622, -75.4385], smr: [11.1165, -74.2330],
mtr: [8.7443, -75.8744], clo: [1.2804, -77.1733], elc: [6.8731, -71.6474],
mde: [6.2447, -75.5801], baq: [3.5757, -76.5716], cuc: [5.6958, -75.6504],
mia: [25.7959, -80.2870], jfk: [40.6413, -73.7781], lax: [33.9416, -118.4085],
sfo: [37.6213, -122.3790], yyz: [43.6771, -79.6240], yvr: [49.1939, -123.1833],
lhr: [51.4700, -0.4543], cdg: [49.0097, 2.5479], ams: [52.3105, 4.7683],
fra: [50.0379, 8.5622], mad: [40.4983, -3.5676], bcn: [41.2974, 2.0833],
zrh: [47.4647, 8.5492], mex: [19.4363, -99.0721], lim: [-12.0219, -77.1143],
};
const ARR_DAY_END = 21 * 60 + 30; // an arrival day should wrap by ~21:30
const DOWS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
function parseHM(t) {
const m = /^(\d{1,2}):(\d{2})$/.exec(String(t || '').trim());
if (!m) return null;
const h = +m[1], mn = +m[2];
return (h > 23 || mn > 59) ? null : h * 60 + mn;
}
const CITY_AIRPORT = {
'santa marta': 'smr', cartagena: 'ctg', marta: 'smr', bogota: 'bog', 'bogotá': 'bog',
barranquilla: 'baq', medellin: 'mde', 'medellín': 'mde', 'santa fe': 'mde',
'el banquete': 'elc', palmira: 'cvc', cali: 'cvc', toronto: 'yyz',
'new york': 'jfk', london: 'lhr', paris: 'cdg', amsterdam: 'ams', frankfurt: 'fra',
madrid: 'mad', barcelona: 'bcn', zurich: 'zrh', 'zürich': 'zrh', mexico: 'mex', lima: 'lim',
};
function airportOf(ref) {
if (!ref) return null;
const s = String(ref).trim(), up = s.toUpperCase();
const code = (up.match(/\b([A-Z]{3})\b/) || [])[1];
let key = code ? code.toLowerCase() : null;
if (!key) { const cl = s.toLowerCase(); for (const [city, c] of Object.entries(CITY_AIRPORT)) if (cl.includes(city)) { key = c; break; } }
const hit = key && AIRPORTS[key];
return hit ? { code: code || key.toUpperCase(), at: hit } : null;
}
function driveMin(ap, hotelAt) { // rough airport<->hotel taxi time
if (!ap || !hotelAt) return null;
const km = haversine(ap.at, hotelAt) / 1000;
return Math.min(90, Math.max(20, Math.round(km / 35 * 60)));
}
function relabelDay(d, idx, dateStr) {
const [y, mo, da] = String(dateStr).split('-').map(Number);
const dt = new Date(Date.UTC(y, mo - 1, da));
d.date = dateStr;
d.label = 'Day ' + idx + ' · ' + DOWS[dt.getUTCDay()] + ' ' + da;
}
function rangeStr(a, b) {
const f = s => { const [y, mo, da] = s.split('-').map(Number); return da + ' ' + MONTHS[mo - 1]; };
return a && b ? (a === b ? f(a) : f(a) + '' + f(b)) : '';
}
// drop stops (visits first; keep breakfast/meals/transit) until the day fits endMin
function trimDayTo(d, endMin, removed) {
rebuildLegs(d);
let guard = 0;
while (layout(d) > endMin && d.stops.length > 1 && guard++ < 60) {
let idx = -1;
for (let i = d.stops.length - 1; i >= 0; i--) if (d.stops[i].slot === 'visit') { idx = i; break; }
if (idx < 0) for (let i = d.stops.length - 1; i >= 0; i--)
if (d.stops[i].slot !== 'breakfast' && d.stops[i].slot !== 'transit') { idx = i; break; }
if (idx < 0) break;
removed.push(d.stops[idx].name);
d.stops.splice(idx, 1);
rebuildLegs(d);
}
}
const TOOLS = { const TOOLS = {
// ---- queries (read-only) ---- // ---- queries (read-only) ----
itinerary_summary: () => ({ plan: llmContext() }), itinerary_summary: () => ({ plan: llmContext() }),
@ -227,6 +296,109 @@ const TOOLS = {
rebuildLegs(d); layout(d); commit(`[llm] ${d.label} starts ${fmt(d.startMin)}`); renderAll(); rebuildLegs(d); layout(d); commit(`[llm] ${d.label} starts ${fmt(d.startMin)}`); renderAll();
return { ok: true, action: 'set_start', day: d.label, start: fmt(d.startMin), day_ends: fmt(layout(d)) }; return { ok: true, action: 'set_start', day: d.label, start: fmt(d.startMin), day_ends: fmt(layout(d)) };
}, },
// Re-anchor the whole trip around a real booked flight (arrival + departure).
// Deterministic: re-dates the day window, records the flights as hard-anchor
// bookings, shortens the arrival day (start after landing+transfer) and the
// departure day (wrap before the flight), and trims what no longer fits.
apply_flight_anchors: (a) => {
const arr = a.arrival || {}, dep = a.departure || {};
const arrMin = parseHM(arr.time), depMin = parseHM(dep.time);
if (arrMin == null) return { error: 'arrival.time must be "HH:MM" (got "' + arr.time + '")' };
if (depMin == null) return { error: 'departure.time must be "HH:MM" (got "' + dep.time + '")' };
const ids = Object.keys(days).sort();
const first = days[ids[0]], last = days[ids[ids.length - 1]];
const hotelFirst = (first.base && first.base.at) || (stays[0] && stays[0].at);
const hotelLast = (last.base && last.base.at) || (stays[stays.length - 1] && stays[stays.length - 1].at);
const arrAp = airportOf(arr.airport), depAp = airportOf(dep.airport);
const arrTransfer = driveMin(arrAp, hotelFirst) != null ? driveMin(arrAp, hotelFirst) : 60; // land → hotel
const depTransfer = driveMin(depAp, hotelLast) != null ? driveMin(depAp, hotelLast) : 60; // hotel → airport
const removed = [], notes = [];
// 1) re-date across the real [arrival, departure] window — spread the plan's
// days evenly between the booked first and last date (contiguous when the
// day counts match, monotonic when they don't)
if (arr.date && dep.date) {
const pa = (y, m, d) => Date.UTC(+y, m - 1, +d);
const [ay, am, ad] = arr.date.split('-'), [by, bm, bd] = dep.date.split('-');
const A = pa(ay, am, ad), B = pa(by, bm, bd);
const N = ids.length;
if (B >= A) {
const spanDays = Math.round((B - A) / 86400000) + 1;
if (N === 1) { if (first.date !== arr.date) relabelDay(first, 1, arr.date); }
else {
for (let i = 0; i < N; i++)
relabelDay(days[ids[i]], i + 1, new Date(A + Math.round(i * (B - A) / (N - 1))).toISOString().slice(0, 10));
if (spanDays !== N) notes.push('the booked window is ' + spanDays + ' days but the plan has ' + N + ' — I spread the ' + N + ' days evenly across ' + rangeStr(arr.date, dep.date));
}
}
}
// 2) record the booked flights as hard anchors — the user's real ticket
// supersedes any placeholder flight records (hotels stay untouched);
// dropping all flights first keeps a re-apply idempotent
M.trip.bookings = (M.trip.bookings || []).filter(b => b.type !== 'flight');
const flightRec = (role, o, ap, t) => ({
type: 'flight', source: 'user_flight', status: 'booked',
name: (o.flight ? o.flight + ' · ' : '') + (ap ? ap.code : (o.city || 'flight')),
ref: o.ref || (role === 'arrival' ? 'arr-booking' : 'dep-booking'),
from: o.from || '—', to: o.to || '—', date: o.date || '',
depart: role === 'departure' ? t : (o.depart || ''), arrive: role === 'arrival' ? t : (o.arrive || ''),
stations: ap ? { from: role === 'arrival' ? null : ap.at, to: role === 'arrival' ? ap.at : null } : {},
});
M.trip.bookings.push(flightRec('arrival', arr, arrAp, arr.time));
M.trip.bookings.push(flightRec('departure', dep, depAp, dep.time));
const rng = rangeStr(arr.date, dep.date);
if (rng) {
// idempotent: replace a trailing date range in any of the forms we emit or
// the dataset uses ("30 Sep12 Oct" | "2128 Sep" | "21 Sep")
const RE = /(\d{1,2}\s+[A-Za-z]{3}\s*[\u2013\u2014-]\s*\d{1,2}\s+[A-Za-z]{3}|\d{1,2}\s*[\u2013\u2014-]\s*\d{1,2}\s+[A-Za-z]{3}|\d{1,2}\s+[A-Za-z]{3})\s*$/;
const t = M.trip.title.replace(RE, rng);
if (t !== M.trip.title) M.trip.title = t;
}
// 3) arrival day: start after landing + transfer; drop the meaningless
// breakfast; shorten the waking window; trim what no longer fits
first.startMin = arrMin + arrTransfer;
for (let i = first.stops.length - 1; i >= 0; i--)
if (first.stops[i].slot === 'breakfast') { removed.push(first.stops[i].name); first.stops.splice(i, 1); }
const arrEnd = Math.min(ARR_DAY_END, first.startMin + (first.wakingHours || 12) * 60);
first.wakingHours = Math.max(2, Math.round((arrEnd - first.startMin) / 60));
trimDayTo(first, first.startMin + first.wakingHours * 60, removed);
// 4) departure day: keep only check-out + the flight; size the flight block
// so the day lands exactly on the booked departure time
last.isDeparture = true;
last.startMin = Math.min(last.startMin || 540, 8 * 60);
last.stops.forEach(s => { if (s.slot !== 'transit' && s.slot !== 'breakfast') removed.push(s.name); });
last.stops = last.stops.filter(s => s.slot === 'transit' || s.slot === 'breakfast');
if (!last.stops.some(s => s.slot === 'breakfast'))
last.stops.unshift({ id: 'CHKOUT', name: 'Breakfast & check-out', kind: 'meal', mealKind: 'breakfast', slot: 'breakfast', state: 'planned', at: hotelLast || [0, 0], dur: 45, durConf: 3 });
if (!last.stops.some(s => s.slot === 'transit'))
last.stops.push({ id: 'FLYOUT', name: 'Flight out — ' + (dep.airport || 'airport'), kind: 'transit', slot: 'transit', state: 'planned', at: (depAp && depAp.at) || hotelLast || [0, 0], dur: 60, durConf: 3 });
rebuildLegs(last);
let pre = last.startMin;
for (let i = 0; i < last.stops.length; i++) {
const s = last.stops[i];
if (s.slot === 'transit') { s.dur = Math.max(30, depMin - pre); break; }
pre += s.dur; if (last.legs[i]) pre += last.legs[i].dur;
}
last.wakingHours = Math.max(1, Math.round((depMin - last.startMin) / 60));
// 5) commit
ids.forEach(id => rebuildLegs(days[id]));
commit('[flight] re-anchored around booked arrival + departure');
renderAll();
const fs2 = dayStats(first), ls2 = dayStats(last);
return {
ok: true,
arrival: { city: arr.city, airport: arr.airport, date: arr.date, time: arr.time, land_to_hotel_min: arrTransfer },
departure: { city: dep.city, airport: dep.airport, date: dep.date, time: dep.time, hotel_to_airport_min: depTransfer },
first_day: { id: first.id, label: first.label, start: fmt(first.startMin), end: fmt(fs2.end), stops: first.stops.length },
last_day: { id: last.id, label: last.label, start: fmt(last.startMin), end: fmt(ls2.end), stops: last.stops.length },
removed: removed, notes: notes,
};
},
}; };
function llmSystemPrompt() { function llmSystemPrompt() {
@ -247,14 +419,16 @@ function llmSystemPrompt() {
' remove_stop(stopId) — remove a stop from its day. stopId is a stop id or its name.\n' + ' remove_stop(stopId) — remove a stop from its day. stopId is a stop id or its name.\n' +
' move_stop(stopId, toDayId) — move a stop to another day. stopId is a stop id or its name.\n' + ' move_stop(stopId, toDayId) — move a stop to another day. stopId is a stop id or its name.\n' +
' set_duration(stopId, minutes) — change a stop\'s duration in minutes. stopId is a stop id or its name.\n' + ' set_duration(stopId, minutes) — change a stop\'s duration in minutes. stopId is a stop id or its name.\n' +
' set_start(dayId, time) — set a day\'s start time as "HH:MM".\n\n' + ' set_start(dayId, time) — set a day\'s start time as "HH:MM".\n' +
' apply_flight_anchors({arrival, departure}) — re-anchor the WHOLE trip around real booked flights. arrival/departure = {date:"YYYY-MM-DD", time:"HH:MM" local, airport:"code or name e.g. CTG", city, from?, to?, flight?, ref?}. Re-dates the day window, records the flights, shortens the arrival day (start after landing+transfer) and the departure day (wrap before the flight), and trims stops that no longer fit. Use this when the user provides their actual ticket/booking.\n\n' +
'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 (14 sentences), plain text, no markdown.\n' + '- Answer concisely (14 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.\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\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' +
'- When a real flight/booking document is attached, read out the arrival and departure (date, local time, airport, city), then call apply_flight_anchors exactly once. If the document is ambiguous or its dates dont match the plans, ask me to confirm before applying.\n\n' +
'Current plan:\n' + llmContext()); 'Current plan:\n' + llmContext());
} }
@ -1356,7 +1530,7 @@ let history = []; // [{v, label, at, days, stays}]
let hIdx = -1; // pointer into history let hIdx = -1; // pointer into history
const commit = (label) => { const commit = (label) => {
history = history.slice(0, hIdx + 1); // drop any redo branch history = history.slice(0, hIdx + 1); // drop any redo branch
history.push({ v: ++version, label, at: Date.now(), days: deep(days), stays: deep(stays) }); history.push({ v: ++version, label, at: Date.now(), days: deep(days), stays: deep(stays), bookings: deep(M.trip.bookings), title: M.trip.title });
hIdx = history.length - 1; hIdx = history.length - 1;
if (history.length > 60) { history.shift(); hIdx--; } if (history.length > 60) { history.shift(); hIdx--; }
renderVersionPill(); renderVersionPill();
@ -1368,6 +1542,8 @@ function restore(idx, silent) {
Object.keys(days).forEach(k => { Object.assign(days[k], deep(s.days[k])); }); Object.keys(days).forEach(k => { Object.assign(days[k], deep(s.days[k])); });
stays = deep(s.stays); stays = deep(s.stays);
staySeq = stays.reduce((m, o) => Math.max(m, +String(o.id).replace(/\D/g, '') || 0), 1); staySeq = stays.reduce((m, o) => Math.max(m, +String(o.id).replace(/\D/g, '') || 0), 1);
if (s.bookings) M.trip.bookings = deep(s.bookings);
if (s.title != null) M.trip.title = s.title;
closeDiscover(); closeL3(); closeDiscover(); closeL3();
rebuildLegs(day); // snapshots may predate route enrichment — re-upgrade legs rebuildLegs(day); // snapshots may predate route enrichment — re-upgrade legs
renderHotelMks(); renderBaseChip(); renderHotelMks(); renderBaseChip();
@ -1620,6 +1796,60 @@ function handleUser(v) {
$('#chat-send').onclick = chatSend; $('#chat-send').onclick = chatSend;
$('#chat-input').addEventListener('keydown', e => e.key === 'Enter' && chatSend()); $('#chat-input').addEventListener('keydown', e => e.key === 'Enter' && chatSend());
// ---- flight/booking ingestion: drop a real ticket, the model re-anchors ----
function fileToB64(file) {
return new Promise((res, rej) => {
const r = new FileReader();
r.onload = () => res(String(r.result).split(',')[1] || '');
r.onerror = () => rej(r.error);
r.readAsDataURL(file);
});
}
async function ingestDoc(file) {
if (!file) return;
if (scriptBusy) { msg('ai', 'One moment — Im still working on the last request.'); return; }
const note = msg('user', '📎 ' + esc(file.name) + ' · ' + Math.max(1, Math.round(file.size / 1024)) + ' KB — reading…');
let b64;
try { b64 = await fileToB64(file); }
catch { note.remove(); return msg('ai', 'Couldnt read that file.'); }
try {
const r = await fetch('/parse-doc', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename: file.name, mime: file.type || '', b64 }) });
const j = await r.json();
if (!j.ok) { note.remove(); return msg('ai', '⚠️ ' + esc(j.error || 'couldnt read that file')); }
note.remove();
reconfigureFromDoc(j);
} catch { note.remove(); msg('ai', 'The document reader is unreachable right now.'); }
}
function reconfigureFromDoc(j) {
msg('user', '📎 ' + esc(j.name) + ' <span class="src">' + (j.kind || 'doc') + ' · ' + (j.chars || 0) + ' chars — reconfigure the trip around my flights</span>');
if (!llmOn) return msg('ai', 'I read the booking (' + (j.chars || 0) + ' chars), but the model is offline so I cant reconfigure around it yet. Ill do it as soon as its back.');
const prompt =
'Ive attached my real flight booking for this trip (extracted text below).\n\n' +
'DOCUMENT “' + (j.name || 'booking') + '”\n' +
'<document>\n' + (j.text || '') + '\n</document>\n\n' +
'Reconfigure this trip around it:\n' +
'1. Read the ARRIVAL (date, local time, airport, city) and the DEPARTURE (date, local time, airport, city). With connections, the arrival is where the trip starts (first city) and the departure where it ends (last city).\n' +
'2. Call apply_flight_anchors exactly once with those two anchors.\n' +
'3. Confirm what changed — the new first and last day with their start/end times, and any stops that had to go. Keep it to a couple of sentences.';
askLLM(prompt);
}
// drag & drop a file anywhere on the chat panel
(function () {
const chat = $('#chat'); if (!chat) return;
const hasFiles = e => e.dataTransfer && Array.prototype.slice.call(e.dataTransfer.types || []).indexOf('Files') >= 0;
chat.addEventListener('dragover', e => { if (hasFiles(e)) { e.preventDefault(); chat.classList.add('dropping'); } });
chat.addEventListener('dragenter', e => { if (hasFiles(e)) { e.preventDefault(); chat.classList.add('dropping'); } });
chat.addEventListener('dragleave', e => { if (!chat.contains(e.relatedTarget)) chat.classList.remove('dropping'); });
chat.addEventListener('drop', e => { e.preventDefault(); chat.classList.remove('dropping'); const f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0]; if (f) ingestDoc(f); });
})();
// 📎 attach button (same path as drag & drop)
const docFile = $('#doc-file');
const docAttach = $('#doc-attach');
if (docAttach && docFile) {
docAttach.onclick = () => docFile.click();
docFile.addEventListener('change', e => { const f = e.target.files && e.target.files[0]; if (f) ingestDoc(f); e.target.value = ''; });
}
// ---------------- landing → parse → plan ---------------- // ---------------- landing → parse → plan ----------------
// parsed-booking preview, derived from the active dataset (city-aware) // parsed-booking preview, derived from the active dataset (city-aware)
function sampleRows() { function sampleRows() {
@ -1665,7 +1895,7 @@ let persistT = 0;
function persistTrip() { function persistTrip() {
clearTimeout(persistT); clearTimeout(persistT);
const hist = history.slice(0, hIdx + 1).slice(-25); // drop the redo tail, cap at 25 const hist = history.slice(0, hIdx + 1).slice(-25); // drop the redo tail, cap at 25
persistT = setTimeout(() => tripStore.save(tripId, { days, stays, version, hIdx: hist.length - 1, history: hist }), 250); persistT = setTimeout(() => tripStore.save(tripId, { days, stays, version, hIdx: hist.length - 1, history: hist, bookings: M.trip.bookings, title: M.trip.title }), 250);
} }
function renderTripMenu() { function renderTripMenu() {
$('#crumb').innerHTML = `🧳 <span class="t-name">${M.trip.title}</span> <span class="caret">▾</span>`; $('#crumb').innerHTML = `🧳 <span class="t-name">${M.trip.title}</span> <span class="caret">▾</span>`;
@ -1693,7 +1923,11 @@ function enterTrip(id) {
staySeq = stays.reduce((m, o) => Math.max(m, +String(o.id).replace(/\D/g, '') || 0), 1); staySeq = stays.reduce((m, o) => Math.max(m, +String(o.id).replace(/\D/g, '') || 0), 1);
// resume this trip's saved state (edits + version history), if any // resume this trip's saved state (edits + version history), if any
const saved = tripStore.load(id); const saved = tripStore.load(id);
if (saved) { days = deep(saved.days); stays = deep(saved.stays); version = saved.version; history = saved.history; hIdx = saved.hIdx; } if (saved) {
days = deep(saved.days); stays = deep(saved.stays); version = saved.version; history = saved.history; hIdx = saved.hIdx;
if (saved.bookings) M.trip.bookings = deep(saved.bookings);
if (saved.title != null) M.trip.title = saved.title;
}
// reset per-trip UI state // reset per-trip UI state
compareItems = []; compareItems = [];
closeDiscover(); closeL3(); closeDiscover(); closeL3();

View File

@ -112,7 +112,9 @@
<aside id="chat"> <aside id="chat">
<div id="chat-body"></div> <div id="chat-body"></div>
<div id="chat-input-row"> <div id="chat-input-row">
<input id="chat-input" type="text" placeholder="Ask, direct — or “find a lunch spot”…" /> <input id="doc-file" type="file" accept=".pdf,.txt,.ics,.html,.htm,.csv,.json" hidden />
<button class="btn small" id="doc-attach" title="Drop or attach your flight booking (PDF or text) to reconfigure the trip around it">📎</button>
<input id="chat-input" type="text" placeholder="Ask, direct — or drop a booking…" />
<button class="btn primary small" id="chat-send"></button> <button class="btn primary small" id="chat-send"></button>
</div> </div>
</aside> </aside>

View File

@ -5,6 +5,8 @@
const http = require('http'); const http = require('http');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const os = require('os');
const { execFile } = require('child_process');
const PORT = 8077; const PORT = 8077;
const ROOT = __dirname; const ROOT = __dirname;
@ -161,6 +163,50 @@ http.createServer((req, res) => {
return; 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 ------------------------------------------------------- // ---- static -------------------------------------------------------
let p = u.pathname === '/' ? '/index.html' : u.pathname; let p = u.pathname === '/' ? '/index.html' : u.pathname;
const file = path.join(ROOT, p); const file = path.join(ROOT, p);

View File

@ -80,7 +80,16 @@ button { font: inherit; }
.mapwrap, #mapwrap { position: relative; pointer-events: none; } /* let wheel/drag reach the Leaflet map underneath */ .mapwrap, #mapwrap { position: relative; pointer-events: none; } /* let wheel/drag reach the Leaflet map underneath */
#chat { background: var(--card); border-left: 1px solid var(--line); display: flex; flex-direction: column; } #chat { background: var(--card); border-left: 1px solid var(--line); display: flex; flex-direction: column; }
#chat-body { flex: 1; overflow-y: auto; padding: 14px; display: flex; flex-direction: column; gap: 10px; } #chat-body { flex: 1; overflow-y: auto; padding: 14px; display: flex; flex-direction: column; gap: 10px; }
#chat-input-row { display: flex; gap: 8px; padding: 12px; border-top: 1px solid var(--line); } #chat-input-row { display: flex; gap: 8px; padding: 12px; border-top: 1px solid var(--line); align-items: center; }
#doc-attach { cursor: pointer; line-height: 1; }
#chat { position: relative; }
#chat.dropping::after {
content: 'Drop the flight booking to reconfigure the trip';
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
font: 600 15px/1.4 var(--sans, system-ui, sans-serif); color: #2f6df6; text-align: center;
background: rgba(238, 244, 255, 0.88); border: 3px dashed #2f6df6; border-radius: 12px;
pointer-events: none; z-index: 20; padding: 24px;
}
#chat-input { flex: 1; border: 1.5px solid var(--line); border-radius: 10px; padding: 10px 12px; font-size: 13.5px; outline: none; } #chat-input { flex: 1; border: 1.5px solid var(--line); border-radius: 10px; padding: 10px 12px; font-size: 13.5px; outline: none; }
#chat-input:focus { border-color: var(--accent); } #chat-input:focus { border-color: var(--accent); }