diff --git a/client/src/components/Planner/ReservationsPanel.tsx b/client/src/components/Planner/ReservationsPanel.tsx index aec9cb7f..98a7283c 100644 --- a/client/src/components/Planner/ReservationsPanel.tsx +++ b/client/src/components/Planner/ReservationsPanel.tsx @@ -312,7 +312,8 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo if (!hasEndpoints && meta.arrival_airport) cells.push({ label: t('reservations.meta.to'), value: meta.arrival_airport }) if (meta.train_number) cells.push({ label: t('reservations.meta.trainNumber'), value: meta.train_number }) if (meta.platform) cells.push({ label: t('reservations.meta.platform'), value: meta.platform }) - if (meta.seat) cells.push({ label: t('reservations.meta.seat'), value: meta.seat }) + if (meta.seat) cells.push({ label: t('reservations.meta.seat'), value: meta.seat + (meta.class ? ` · ${meta.class}` : '') }) + if (meta.price != null && meta.price !== '') cells.push({ label: t('reservations.price'), value: `${meta.price}${meta.priceCurrency ? ' ' + meta.priceCurrency : ''}` }) if (meta.check_in_time) cells.push({ label: t('reservations.meta.checkIn'), value: formatTime(meta.check_in_time, locale, timeFormat) + (meta.check_in_end_time ? ` – ${formatTime(meta.check_in_end_time, locale, timeFormat)}` : '') }) if (meta.check_out_time) cells.push({ label: t('reservations.meta.checkOut'), value: formatTime(meta.check_out_time, locale, timeFormat) }) if (cells.length === 0) return null diff --git a/server/src/nest/booking-import/kitinerary-mapper.ts b/server/src/nest/booking-import/kitinerary-mapper.ts index 555059f0..b6b52499 100644 --- a/server/src/nest/booking-import/kitinerary-mapper.ts +++ b/server/src/nest/booking-import/kitinerary-mapper.ts @@ -321,7 +321,7 @@ function mapEvent(r: KiReservation, source: ParsedBookingItem['source']): Parsed const loc = e.location; const c = coords(loc?.geo); - const venue: ParsedVenue | undefined = loc?.name ? { name: loc.name, ...(c ?? {}), address: formatAddress(loc.address) ?? undefined } : undefined; + const venue: ParsedVenue | undefined = loc?.name ? { name: loc.name, ...(c ?? {}), address: formatAddress(loc.address) ?? undefined, website: loc.url ?? undefined, phone: loc.telephone ?? undefined } : undefined; 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 }; } @@ -330,6 +330,33 @@ function mapEvent(r: KiReservation, source: ParsedBookingItem['source']): Parsed // Public // --------------------------------------------------------------------------- +/** Merge seat/class/platform/price into an item's metadata (type-agnostic). + * Models name these inconsistently and sometimes nest them under reservationFor, + * so check both levels and common aliases. The item's own metadata wins. */ +function applyCommonMeta(item: ParsedBookingItem, r: KiReservation): ParsedBookingItem { + const rf = (r.reservationFor && typeof r.reservationFor === 'object' ? r.reservationFor : {}) as Record; + const pick = (...keys: string[]): unknown => { + for (const k of keys) { + const v = (r as Record)[k] ?? rf[k]; + if (v != null && v !== '') return v; + } + return undefined; + }; + const m: Record = {}; + const seat = pick('seat', 'seatNumber'); + if (seat != null) m.seat = String(seat); + const cls = pick('class', 'bookingClass', 'fareClass', 'serviceClass', 'seatingType'); + if (cls != null) m.class = String(cls); + const platform = pick('platform', 'departurePlatform'); + if (platform != null) m.platform = String(platform); + const price = pick('price', 'priceAmount', 'totalPrice', 'total'); + if (price != null) m.price = price; + const cur = pick('priceCurrency', 'priceCurrencyISO4217Code', 'currency'); + if (cur != null) m.priceCurrency = String(cur); + if (Object.keys(m).length) item.metadata = { ...m, ...(item.metadata ?? {}) }; + return item; +} + export function mapReservations(kiItems: KiReservation[], fileName: string): { items: ParsedBookingItem[]; warnings: string[] } { const items: ParsedBookingItem[] = []; const warnings: string[] = []; @@ -353,7 +380,7 @@ export function mapReservations(kiItems: KiReservation[], fileName: string): { i group.push(kiItems[++i]); } item = group.length > 1 ? mapFlightGroup(group, source) : mapFlight(r, source); - if (item) items.push(item); + if (item) items.push(applyCommonMeta(item, r)); continue; } @@ -370,7 +397,7 @@ export function mapReservations(kiItems: KiReservation[], fileName: string): { i warnings.push(`Unknown type "${r['@type']}" in ${fileName}[${i}] — skipped`); } - if (item) items.push(item); + if (item) items.push(applyCommonMeta(item, r)); } return { items, warnings }; diff --git a/server/src/nest/booking-import/kitinerary.types.ts b/server/src/nest/booking-import/kitinerary.types.ts index f36b61fc..593daa04 100644 --- a/server/src/nest/booking-import/kitinerary.types.ts +++ b/server/src/nest/booking-import/kitinerary.types.ts @@ -112,6 +112,8 @@ export interface KiEventVenue { name?: string; address?: string | KiAddress; geo?: KiGeo; + telephone?: string; + url?: string; } export interface KiEvent { @@ -135,6 +137,11 @@ export interface KiReservation { reservationFor?: Record; pickupLocation?: KiEventVenue; dropoffLocation?: KiEventVenue; + seat?: string; + class?: string; + platform?: string; + price?: number | string; + priceCurrency?: string; [key: string]: unknown; } diff --git a/server/src/nest/llm-parse/llm-parse.service.ts b/server/src/nest/llm-parse/llm-parse.service.ts index fa103691..b14727fb 100644 --- a/server/src/nest/llm-parse/llm-parse.service.ts +++ b/server/src/nest/llm-parse/llm-parse.service.ts @@ -109,6 +109,11 @@ const ROOT_KEYS = new Set([ 'endTime', 'pickupLocation', 'dropoffLocation', + 'seat', + 'class', + 'platform', + 'price', + 'priceCurrency', 'reservationFor', ]); diff --git a/server/src/nest/llm-parse/llm-prompt.ts b/server/src/nest/llm-parse/llm-prompt.ts index 1b03f346..24df7871 100644 --- a/server/src/nest/llm-parse/llm-prompt.ts +++ b/server/src/nest/llm-parse/llm-prompt.ts @@ -24,7 +24,8 @@ export function buildSystemPrompt(): string { ' 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 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} }', + ' EventReservation / TouristAttractionVisit: { name, startDate, endDate, location:{name,address,geo,telephone,url} }', + 'When present, also include at the reservation ROOT: "seat", "class" (fare/cabin class), "platform" (trains/buses), and the total "price" (a number) with "priceCurrency" (ISO 4217 code, e.g. EUR).', '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.", 'If the document contains no recognizable reservation, return { "reservations": [] }.', diff --git a/shared/src/reservation/ki-reservation.schema.ts b/shared/src/reservation/ki-reservation.schema.ts index 8dc19ad6..38af4bbf 100644 --- a/shared/src/reservation/ki-reservation.schema.ts +++ b/shared/src/reservation/ki-reservation.schema.ts @@ -71,6 +71,11 @@ export const KI_RESERVATION_JSON_SCHEMA = { reservationFor: { type: 'object', additionalProperties: true }, pickupLocation: { type: 'object', additionalProperties: true }, dropoffLocation: { type: 'object', additionalProperties: true }, + seat: { type: 'string' }, + class: { type: 'string' }, + platform: { type: 'string' }, + price: { type: ['number', 'string'] }, + priceCurrency: { type: 'string' }, }, required: ['@type'], },