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.
This commit is contained in:
Maurice
2026-06-24 22:23:13 +02:00
committed by Maurice
parent a1cbc11169
commit 38565c3c6d
9 changed files with 81 additions and 22 deletions
@@ -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).
@@ -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 };
}
// ---------------------------------------------------------------------------
@@ -134,6 +134,7 @@ export interface KiReservation {
endTime?: KiDateTimeish;
reservationFor?: Record<string, unknown>;
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;
@@ -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;
/**
@@ -108,6 +108,7 @@ const ROOT_KEYS = new Set([
'startTime',
'endTime',
'pickupLocation',
'dropoffLocation',
'reservationFor',
]);
+1 -1
View File
@@ -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.",
+8 -2
View File
@@ -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