fix(import): a booking the AI reads without its venue is asked for one, and a failed import does not look like a success

Gemini behind the OpenAI-compatible provider decodes against the JSON schema
TREK sends and fills declared properties only. reservationFor was an open
object without any, so the model left it out: a Booking.com stay came back
with its dates and price but no hotel name, and the mapper had to drop it
(#2477). It is the same cause as #1638, whose fix only made the JSON readable.

The schema now declares every field the prompt names inside reservationFor,
for venues, flights, trains, buses, boats, events and rental cars, at most two
levels deep, and requires it; the car desks at the root get the same venue
fields. Every object stays open to keys the prompt does not name.

On the cloud AI path an answer is cleaned up before it is mapped. Exact
duplicate nodes are dropped, so one stay is one booking. priceCurrency is read
as an ISO 4217 code ("EURials" is EUR, a lone symbol maps to its code); when
the model names none, a priced node takes the currency of the document's
total, and otherwise the field goes instead of saving a code nobody can
convert. The kitinerary path is untouched.

The import upload decodes file names as UTF-8 like every other upload route.
"Bestätigung.pdf" reached the warnings as mojibake, and the review could not
attach the file to the booking it created, because it matches on the name.
The OpenAI-compatible client logs which response_format the answering
attempt carried, metadata only, so the next report shows whether the schema
reached the model at all.

The background import card showed a green tick beside "no reservations could
be extracted". A parse that found nothing now wears the warning mark instead;
it is the same card on the desktop and on the phone.
This commit is contained in:
Maurice
2026-09-23 15:50:43 +02:00
parent 05f533dd0d
commit 41b14ebc2a
14 changed files with 752 additions and 18 deletions
+95 -10
View File
@@ -11,9 +11,9 @@ import { z } from 'zod';
* missing fields, so validation only guarantees each node is an object with a
* string `@type`; everything else passes through untouched.
* - `KI_RESERVATION_JSON_SCHEMA` — the JSON Schema handed to the LLM providers
* (OpenAI-compatible `response_format`, Anthropic tool `input_schema`). It is
* descriptive guidance, kept deliberately permissive on `reservationFor` so the
* model can include type-specific fields; the prompt names the exact fields.
* (OpenAI-compatible `response_format`, Anthropic tool `input_schema`). It
* declares every field the prompt names and still lets anything else through,
* so a model is steered but never refused for adding a field of its own.
*/
/** The `@type` values `mapReservations()` recognises (its switch + flight grouping). */
@@ -39,11 +39,97 @@ export const kiReservationArraySchema = z.object({
});
export type KiReservationArrayDto = z.infer<typeof kiReservationArraySchema>;
/** Coordinates as schema.org `GeoCoordinates`. */
const GEO_JSON_SCHEMA = {
type: 'object',
additionalProperties: true,
properties: { latitude: { type: 'number' }, longitude: { type: 'number' } },
} as const;
/** A station, a bus stop or a boat terminal. */
const STOP_JSON_SCHEMA = {
type: 'object',
additionalProperties: true,
properties: { name: { type: 'string' }, geo: GEO_JSON_SCHEMA },
} as const;
const AIRPORT_JSON_SCHEMA = {
type: 'object',
additionalProperties: true,
properties: { iataCode: { type: 'string' }, name: { type: 'string' }, geo: GEO_JSON_SCHEMA },
} as const;
/** A place somebody goes to: an event location, a car pickup or return desk. */
const VENUE_JSON_SCHEMA = {
type: 'object',
additionalProperties: true,
properties: {
name: { type: 'string' },
address: { type: 'string' },
geo: GEO_JSON_SCHEMA,
telephone: { type: 'string' },
url: { type: 'string' },
},
} as const;
/**
* The type-specific payload, one union of what the prompt asks for per `@type`.
*
* It used to be an open object with no properties at all, and a provider that
* decodes against the schema (Gemini through its OpenAI-compatible endpoint)
* fills declared properties only. It answered with every root field it could
* see and no `reservationFor`, so a hotel arrived without its name and the
* mapper had to drop it (#1638, #2477). Each field is optional, since a flight
* has no venue name and a hotel has no flight number, and nothing is nested
* deeper than two objects below this one.
*/
const RESERVATION_FOR_JSON_SCHEMA = {
type: 'object',
additionalProperties: true,
properties: {
// The venue itself: lodging, restaurant, event, boat, rental car.
name: { type: 'string' },
address: { type: 'string' },
telephone: { type: 'string' },
url: { type: 'string' },
geo: GEO_JSON_SCHEMA,
// Flight.
flightNumber: { type: 'string' },
airline: {
type: 'object',
additionalProperties: true,
properties: { name: { type: 'string' }, iataCode: { type: 'string' } },
},
departureAirport: AIRPORT_JSON_SCHEMA,
arrivalAirport: AIRPORT_JSON_SCHEMA,
departureTime: { type: 'string' },
arrivalTime: { type: 'string' },
// Train, bus and boat.
trainNumber: { type: 'string' },
trainName: { type: 'string' },
departureStation: STOP_JSON_SCHEMA,
arrivalStation: STOP_JSON_SCHEMA,
busNumber: { type: 'string' },
busName: { type: 'string' },
departureBusStop: STOP_JSON_SCHEMA,
arrivalBusStop: STOP_JSON_SCHEMA,
departureBoatTerminal: STOP_JSON_SCHEMA,
arrivalBoatTerminal: STOP_JSON_SCHEMA,
// Event and rental car.
startDate: { type: 'string' },
endDate: { type: 'string' },
location: VENUE_JSON_SCHEMA,
model: { type: 'string' },
make: { type: 'string' },
rentalCompany: { type: 'object', additionalProperties: true, properties: { name: { type: 'string' } } },
},
} as const;
/**
* JSON Schema for the providers' structured-output entry points. Object root
* (both OpenAI and Anthropic want an object, not a bare array). `reservationFor`
* is intentionally open (`additionalProperties: true`) so the model can fill the
* type-specific sub-object; the prompt enumerates the field names per `@type`.
* is required and spells out its fields (see above); `additionalProperties`
* stays open on every object, so a field the prompt does not name still passes.
*/
export const KI_RESERVATION_JSON_SCHEMA = {
type: 'object',
@@ -65,17 +151,16 @@ export const KI_RESERVATION_JSON_SCHEMA = {
dropoffTime: { type: 'string' },
startTime: { type: 'string' },
endTime: { type: 'string' },
// Type-specific payload (Flight/Train/Lodging/…): open object.
reservationFor: { type: 'object', additionalProperties: true },
pickupLocation: { type: 'object', additionalProperties: true },
dropoffLocation: { type: 'object', additionalProperties: true },
reservationFor: RESERVATION_FOR_JSON_SCHEMA,
pickupLocation: VENUE_JSON_SCHEMA,
dropoffLocation: VENUE_JSON_SCHEMA,
seat: { type: 'string' },
class: { type: 'string' },
platform: { type: 'string' },
price: { type: ['number', 'string'] },
priceCurrency: { type: 'string' },
},
required: ['@type'],
required: ['@type', 'reservationFor'],
},
},
},