From 38565c3c6dd2e73b6237d8c85607f121361ba7f5 Mon Sep 17 00:00:00 2001 From: Maurice Date: Wed, 24 Jun 2026 22:23:13 +0200 Subject: [PATCH] feat(extract): fill transport/booking fields, geocode endpoints, assign days - rental car: request+map dropoffLocation, emit pickup->return from/to endpoints, set a location string (G1/G2/G3). - geocode endpoints (stations/stops/terminals/rental desks) on confirm via Nominatim; mapper now emits coordless named endpoints and confirm persists only the geocoded ones (G6). - assign every dated booking to the nearest trip day so it still shows when slightly out of range, and keep hotel accommodation from vanishing when a check date misses (G5/G10). - fix bus mislabelled as train + add bus_number metadata (G7/G8), flag malformed boats (G9), accept root start/end time for events (G11). - raise the local-LLM timeout to 300s for CPU-only Ollama. --- .../booking-import/booking-import.service.ts | 30 ++++++++++++- .../nest/booking-import/kitinerary-mapper.ts | 42 ++++++++++++++----- .../nest/booking-import/kitinerary.types.ts | 5 ++- .../clients/openai-compatible.client.ts | 6 +-- .../src/nest/llm-parse/llm-parse.service.ts | 1 + server/src/nest/llm-parse/llm-prompt.ts | 2 +- server/src/services/reservationService.ts | 10 ++++- .../src/reservation/ki-reservation.schema.ts | 1 + shared/src/reservation/reservation.schema.ts | 6 ++- 9 files changed, 81 insertions(+), 22 deletions(-) diff --git a/server/src/nest/booking-import/booking-import.service.ts b/server/src/nest/booking-import/booking-import.service.ts index 12938d24..3e3449f0 100644 --- a/server/src/nest/booking-import/booking-import.service.ts +++ b/server/src/nest/booking-import/booking-import.service.ts @@ -17,8 +17,12 @@ function resolveDayId(tripId: string, iso: string | null | undefined): number | if (!iso) return null; const date = iso.slice(0, 10); if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return null; - const row = db.prepare('SELECT id FROM days WHERE trip_id = ? AND date = ? LIMIT 1').get(tripId, date) as { id: number } | undefined; - return row?.id ?? null; + const exact = db.prepare('SELECT id FROM days WHERE trip_id = ? AND date = ? LIMIT 1').get(tripId, date) as { id: number } | undefined; + if (exact) return exact.id; + // Clamp to the nearest trip day so an out-of-range / unmatched check-in still + // resolves and the accommodation row is inserted. + const nearest = db.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY ABS(JULIANDAY(date) - JULIANDAY(?)) ASC, date ASC LIMIT 1').get(tripId, date) as { id: number } | undefined; + return nearest?.id ?? null; } @Injectable() @@ -163,6 +167,28 @@ export class BookingImportService { broadcast(tripId, 'place:created', { place }, socketId); } + // Geocode transport endpoints (stations/stops/terminals/rental desks) that + // arrived without coords, so the route draws and map pins appear. The LLM + // and kitinerary rarely supply geo for non-airport endpoints. + if (Array.isArray(reservationData.endpoints)) { + for (const ep of reservationData.endpoints) { + if ((ep.lat == null || ep.lng == null) && ep.name) { + try { + const hit = (await searchNominatim(ep.name))[0]; + if (hit?.lat != null && hit?.lng != null) { + ep.lat = hit.lat; + ep.lng = hit.lng; + } + } catch { + // geocoding failure is non-fatal + } + } + } + // Persist only coord'd endpoints (reservation_endpoints needs lat/lng); + // ungeocodable ones still appeared in the preview's From→To. + reservationData.endpoints = reservationData.endpoints.filter((ep) => ep.lat != null && ep.lng != null); + } + // Build create_accommodation for hotel reservations. // start_day_id / end_day_id are resolved from check-in/out ISO dates so // the accommodation row is actually inserted (createReservation gates on them). diff --git a/server/src/nest/booking-import/kitinerary-mapper.ts b/server/src/nest/booking-import/kitinerary-mapper.ts index c0ada0e4..555059f0 100644 --- a/server/src/nest/booking-import/kitinerary-mapper.ts +++ b/server/src/nest/booking-import/kitinerary-mapper.ts @@ -189,8 +189,9 @@ function mapTrain(r: KiReservation, source: ParsedBookingItem['source']): Parsed const endpoints: ParsedEndpoint[] = []; const dc = coords(t.departureStation?.geo); const ac = coords(t.arrivalStation?.geo); - if (dc) endpoints.push({ role: 'from', sequence: 0, name: depName, code: null, lat: dc.lat, lng: dc.lng, timezone: null, local_time: depTime, local_date: depDate }); - if (ac) endpoints.push({ role: 'to', sequence: 1, name: arrName, code: null, lat: ac.lat, lng: ac.lng, timezone: null, local_time: arrTime, local_date: arrDate }); + // Push named endpoints even without coords — confirm() geocodes them later. + if (t.departureStation?.name) endpoints.push({ role: 'from', sequence: 0, name: depName, code: null, lat: dc?.lat ?? null, lng: dc?.lng ?? null, timezone: null, local_time: depTime, local_date: depDate }); + if (t.arrivalStation?.name) endpoints.push({ role: 'to', sequence: 1, name: arrName, code: null, lat: ac?.lat ?? null, lng: ac?.lng ?? null, timezone: null, local_time: arrTime, local_date: arrDate }); return { type: 'train', @@ -220,10 +221,10 @@ function mapBus(r: KiReservation, source: ParsedBookingItem['source']): ParsedBo const endpoints: ParsedEndpoint[] = []; const dc = coords(b.departureBusStop?.geo); const ac = coords(b.arrivalBusStop?.geo); - if (dc) endpoints.push({ role: 'from', sequence: 0, name: depName, code: null, lat: dc.lat, lng: dc.lng, timezone: null, local_time: depTime, local_date: depDate }); - if (ac) endpoints.push({ role: 'to', sequence: 1, name: arrName, code: null, lat: ac.lat, lng: ac.lng, timezone: null, local_time: arrTime, local_date: arrDate }); + if (b.departureBusStop?.name) endpoints.push({ role: 'from', sequence: 0, name: depName, code: null, lat: dc?.lat ?? null, lng: dc?.lng ?? null, timezone: null, local_time: depTime, local_date: depDate }); + if (b.arrivalBusStop?.name) endpoints.push({ role: 'to', sequence: 1, name: arrName, code: null, lat: ac?.lat ?? null, lng: ac?.lng ?? null, timezone: null, local_time: arrTime, local_date: arrDate }); - return { type: 'train', title, reservation_time: toIsoString(b.departureTime), reservation_end_time: toIsoString(b.arrivalTime), confirmation_number: r.reservationNumber ?? null, endpoints, needs_review: endpoints.length < 2, source }; + return { type: 'bus', title, reservation_time: toIsoString(b.departureTime), reservation_end_time: toIsoString(b.arrivalTime), confirmation_number: r.reservationNumber ?? null, metadata: busId ? { bus_number: busId } : undefined, endpoints, needs_review: endpoints.length < 2, source }; } function mapBoat(r: KiReservation, source: ParsedBookingItem['source']): ParsedBookingItem | null { @@ -240,10 +241,10 @@ function mapBoat(r: KiReservation, source: ParsedBookingItem['source']): ParsedB const endpoints: ParsedEndpoint[] = []; const dc = coords(b.departureBoatTerminal?.geo); const ac = coords(b.arrivalBoatTerminal?.geo); - if (dc) endpoints.push({ role: 'from', sequence: 0, name: depName, code: null, lat: dc.lat, lng: dc.lng, timezone: null, local_time: depTime, local_date: depDate }); - if (ac) endpoints.push({ role: 'to', sequence: 1, name: arrName, code: null, lat: ac.lat, lng: ac.lng, timezone: null, local_time: arrTime, local_date: arrDate }); + if (b.departureBoatTerminal?.name) endpoints.push({ role: 'from', sequence: 0, name: depName, code: null, lat: dc?.lat ?? null, lng: dc?.lng ?? null, timezone: null, local_time: depTime, local_date: depDate }); + if (b.arrivalBoatTerminal?.name) endpoints.push({ role: 'to', sequence: 1, name: arrName, code: null, lat: ac?.lat ?? null, lng: ac?.lng ?? null, timezone: null, local_time: arrTime, local_date: arrDate }); - return { type: 'cruise', title, reservation_time: toIsoString(b.departureTime), reservation_end_time: toIsoString(b.arrivalTime), confirmation_number: r.reservationNumber ?? null, endpoints, source }; + return { type: 'cruise', title, reservation_time: toIsoString(b.departureTime), reservation_end_time: toIsoString(b.arrivalTime), confirmation_number: r.reservationNumber ?? null, endpoints, needs_review: endpoints.length < 2, source }; } function mapLodging(r: KiReservation, source: ParsedBookingItem['source']): ParsedBookingItem | null { @@ -287,10 +288,31 @@ function mapRentalCar(r: KiReservation, source: ParsedBookingItem['source']): Pa const title = [company, carName].filter(Boolean).join(' — ') || 'Rental Car'; const pickup = r.pickupLocation as KiReservation['pickupLocation']; + const dropoff = r.dropoffLocation as KiReservation['dropoffLocation']; const pc = coords(pickup?.geo); + const drc = coords(dropoff?.geo); const venue: ParsedVenue | undefined = pickup?.name ? { name: pickup.name, ...(pc ?? {}), address: formatAddress(pickup.address) ?? undefined } : undefined; - return { type: 'car', title, reservation_time: toIsoString(r.pickupTime), reservation_end_time: toIsoString(r.dropoffTime), confirmation_number: r.reservationNumber ?? null, ...(venue ? { _venue: venue } : {}), source }; + // Pickup → return as from/to endpoints (coords optional; confirm() geocodes). + const { date: puDate, time: puTime } = splitIso(r.pickupTime); + const { date: doDate, time: doTime } = splitIso(r.dropoffTime); + const endpoints: ParsedEndpoint[] = []; + if (pickup?.name) endpoints.push({ role: 'from', sequence: 0, name: pickup.name, code: null, lat: pc?.lat ?? null, lng: pc?.lng ?? null, timezone: null, local_time: puTime, local_date: puDate }); + if (dropoff?.name) endpoints.push({ role: 'to', sequence: 1, name: dropoff.name, code: null, lat: drc?.lat ?? null, lng: drc?.lng ?? null, timezone: null, local_time: doTime, local_date: doDate }); + + return { + type: 'car', + title, + reservation_time: toIsoString(r.pickupTime), + reservation_end_time: toIsoString(r.dropoffTime), + confirmation_number: r.reservationNumber ?? null, + location: formatAddress(pickup?.address) ?? pickup?.name ?? null, + ...(company ? { metadata: { rental_company: company } } : {}), + endpoints, + needs_review: endpoints.length < 2, + ...(venue ? { _venue: venue } : {}), + source, + }; } function mapEvent(r: KiReservation, source: ParsedBookingItem['source']): ParsedBookingItem | null { @@ -301,7 +323,7 @@ function mapEvent(r: KiReservation, source: ParsedBookingItem['source']): Parsed const c = coords(loc?.geo); const venue: ParsedVenue | undefined = loc?.name ? { name: loc.name, ...(c ?? {}), address: formatAddress(loc.address) ?? undefined } : undefined; - return { type: 'event', title: e.name, reservation_time: toIsoString(e.startDate), reservation_end_time: toIsoString(e.endDate), confirmation_number: r.reservationNumber ?? null, location: loc ? (formatAddress(loc.address) ?? loc.name ?? null) : null, ...(venue ? { _venue: venue } : {}), source }; + return { type: 'event', title: e.name, reservation_time: toIsoString(e.startDate ?? r.startTime), reservation_end_time: toIsoString(e.endDate ?? r.endTime), confirmation_number: r.reservationNumber ?? null, location: loc ? (formatAddress(loc.address) ?? loc.name ?? null) : null, ...(venue ? { _venue: venue } : {}), source }; } // --------------------------------------------------------------------------- diff --git a/server/src/nest/booking-import/kitinerary.types.ts b/server/src/nest/booking-import/kitinerary.types.ts index b97fcd9e..f36b61fc 100644 --- a/server/src/nest/booking-import/kitinerary.types.ts +++ b/server/src/nest/booking-import/kitinerary.types.ts @@ -134,6 +134,7 @@ export interface KiReservation { endTime?: KiDateTimeish; reservationFor?: Record; pickupLocation?: KiEventVenue; + dropoffLocation?: KiEventVenue; [key: string]: unknown; } @@ -143,8 +144,8 @@ export interface ParsedEndpoint { sequence: number; name: string; code: string | null; - lat: number; - lng: number; + lat: number | null; + lng: number | null; timezone: string | null; local_time: string | null; local_date: string | null; diff --git a/server/src/nest/llm-parse/clients/openai-compatible.client.ts b/server/src/nest/llm-parse/clients/openai-compatible.client.ts index 376eabc4..d996c573 100644 --- a/server/src/nest/llm-parse/clients/openai-compatible.client.ts +++ b/server/src/nest/llm-parse/clients/openai-compatible.client.ts @@ -1,8 +1,8 @@ import type { LlmExtractionClient, LlmExtractionInput } from '../llm-provider.interface'; -// Generous: a local model (Ollama) may cold-load several GB before its first -// token, and longer documents push inference past a minute. -const TIMEOUT_MS = 180_000; +// Generous: a local CPU model (Ollama, no GPU) may cold-load several GB and then +// take a few minutes on a longer document before the first token. +const TIMEOUT_MS = 300_000; const MAX_TOKENS = 4096; /** diff --git a/server/src/nest/llm-parse/llm-parse.service.ts b/server/src/nest/llm-parse/llm-parse.service.ts index 00db8b1c..cc3a3241 100644 --- a/server/src/nest/llm-parse/llm-parse.service.ts +++ b/server/src/nest/llm-parse/llm-parse.service.ts @@ -108,6 +108,7 @@ const ROOT_KEYS = new Set([ 'startTime', 'endTime', 'pickupLocation', + 'dropoffLocation', 'reservationFor', ]); diff --git a/server/src/nest/llm-parse/llm-prompt.ts b/server/src/nest/llm-parse/llm-prompt.ts index 247dcac5..1b03f346 100644 --- a/server/src/nest/llm-parse/llm-prompt.ts +++ b/server/src/nest/llm-parse/llm-prompt.ts @@ -23,7 +23,7 @@ export function buildSystemPrompt(): string { ' BoatReservation: { name, departureBoatTerminal:{name,geo}, arrivalBoatTerminal:{name,geo}, departureTime, arrivalTime }', ' LodgingReservation: { name, address, geo:{latitude,longitude}, telephone, url } — put check-in/out in root "checkinTime"/"checkoutTime"', ' FoodEstablishmentReservation: { name, address, geo, telephone, url } — put booking time in root "startTime"/"endTime"', - ' RentalCarReservation: { name, model, make, rentalCompany:{name} } — put pickup/dropoff in root "pickupTime"/"dropoffTime" and "pickupLocation":{name,address,geo}', + ' RentalCarReservation: { name, model, make, rentalCompany:{name} } — put pickup/dropoff times in root "pickupTime"/"dropoffTime", and the pickup AND return stations in root "pickupLocation" and "dropoffLocation", each {name,address,geo:{latitude,longitude}}', ' EventReservation / TouristAttractionVisit: { name, startDate, endDate, location:{name,address,geo} }', 'Extract EVERY flight/segment in the document, including return legs — a round trip has TWO OR MORE flights, and each row of a flight table is a separate reservation. Do NOT stop after the first.', "Each flight shares the booking's reservationNumber. Use the date shown for that specific flight as its departureTime; if a flight lists only one date (no separate arrival time), leave arrivalTime null — never reuse another flight's date.", diff --git a/server/src/services/reservationService.ts b/server/src/services/reservationService.ts index 39ddfeb9..81a53eb3 100644 --- a/server/src/services/reservationService.ts +++ b/server/src/services/reservationService.ts @@ -53,10 +53,16 @@ function resolveDayIdFromTime( if (!time) return null; const datePart = time.slice(0, 10); if (!/^\d{4}-\d{2}-\d{2}$/.test(datePart)) return null; - const row = db + const exact = db .prepare('SELECT id FROM days WHERE trip_id = ? AND date = ? LIMIT 1') .get(tripId, datePart) as { id: number } | undefined; - return row?.id ?? null; + if (exact) return exact.id; + // Fallback: clamp to the nearest day in the trip so a booking whose exact date + // has no day row (or sits just outside the span) still lands on a day. + const nearest = db + .prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY ABS(JULIANDAY(date) - JULIANDAY(?)) ASC, date ASC LIMIT 1') + .get(tripId, datePart) as { id: number } | undefined; + return nearest?.id ?? null; } // After a trip's date range changes, generateDays positionally re-dates the day rows diff --git a/shared/src/reservation/ki-reservation.schema.ts b/shared/src/reservation/ki-reservation.schema.ts index 142e807d..8dc19ad6 100644 --- a/shared/src/reservation/ki-reservation.schema.ts +++ b/shared/src/reservation/ki-reservation.schema.ts @@ -70,6 +70,7 @@ export const KI_RESERVATION_JSON_SCHEMA = { // Type-specific payload (Flight/Train/Lodging/…): open object. reservationFor: { type: 'object', additionalProperties: true }, pickupLocation: { type: 'object', additionalProperties: true }, + dropoffLocation: { type: 'object', additionalProperties: true }, }, required: ['@type'], }, diff --git a/shared/src/reservation/reservation.schema.ts b/shared/src/reservation/reservation.schema.ts index 5987d988..70543999 100644 --- a/shared/src/reservation/reservation.schema.ts +++ b/shared/src/reservation/reservation.schema.ts @@ -143,8 +143,10 @@ const bookingImportEndpointSchema = z.object({ sequence: z.number(), name: z.string(), code: z.string().nullable(), - lat: z.number(), - lng: z.number(), + // Nullable: the mapper emits named endpoints without coords; confirm() geocodes + // them, and only the coord'd ones are persisted. + lat: z.number().nullable(), + lng: z.number().nullable(), timezone: z.string().nullable(), local_time: z.string().nullable(), local_date: z.string().nullable(),