mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -1,4 +1,4 @@
|
||||
// FE-W4BGT-001 to FE-W4BGT-022
|
||||
// FE-W4BGT-001 to FE-W4BGT-024
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { screen, act, waitFor } from '@testing-library/react'
|
||||
import { render, fireEvent } from '../../../tests/helpers/render'
|
||||
@@ -276,3 +276,30 @@ describe('BackgroundTasksWidget — AI retry', () => {
|
||||
expect(await screen.findByText('No model configured')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* The reporter's phone showed a green tick beside "No reservations could be
|
||||
* extracted" and two warnings (#2477). A parse that found nothing is not a
|
||||
* success, so it wears the warning mark; the tick stays for a parse that did.
|
||||
*/
|
||||
describe('BackgroundTasksWidget: the mark on a finished job (#2477)', () => {
|
||||
it('FE-W4BGT-023: a job that found nothing shows the warning mark, not the success tick', () => {
|
||||
useBackgroundTasksStore.setState({
|
||||
tasks: [task({ items: [], warnings: ['Incomplete LodgingReservation in booking.pdf[0] (no reservationFor)'] })],
|
||||
})
|
||||
const { baseElement } = render(<BackgroundTasksWidget />)
|
||||
|
||||
expect(screen.getByText('No reservations could be extracted from the uploaded files.')).toBeInTheDocument()
|
||||
expect(baseElement.querySelector('svg.lucide-alert-triangle')).not.toBeNull()
|
||||
expect(baseElement.querySelector('svg.lucide-check-circle2')).toBeNull()
|
||||
expect(baseElement.querySelector('.animate-spin')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-W4BGT-024: a job with items keeps the success tick, even with warnings', () => {
|
||||
useBackgroundTasksStore.setState({ tasks: [task({ items: [{ id: 1 }] as never, warnings: ['could not locate "X"'] })] })
|
||||
const { baseElement } = render(<BackgroundTasksWidget />)
|
||||
|
||||
expect(baseElement.querySelector('svg.lucide-check-circle2')).not.toBeNull()
|
||||
expect(baseElement.querySelector('svg.lucide-alert-triangle')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { Loader2, CheckCircle2, AlertCircle, X } from 'lucide-react'
|
||||
import { Loader2, CheckCircle2, AlertCircle, AlertTriangle, X } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { addListener, removeListener } from '../../api/websocket'
|
||||
import { reservationsApi, healthApi } from '../../api/client'
|
||||
@@ -141,7 +141,11 @@ export default function BackgroundTasksWidget() {
|
||||
>
|
||||
<div style={{ flexShrink: 0, marginTop: 1 }}>
|
||||
{(task.status === 'running' || (task.status === 'done' && task.items === undefined)) && <Loader2 size={16} className="animate-spin" color="var(--accent)" />}
|
||||
{task.status === 'done' && task.items !== undefined && <CheckCircle2 size={16} color="#10b981" />}
|
||||
{task.status === 'done' && task.items !== undefined && task.items.length > 0 && <CheckCircle2 size={16} color="#10b981" />}
|
||||
{/* A parse that found nothing is not a success, whatever the job
|
||||
status says: the same card used to show a green tick next to
|
||||
"no reservations could be extracted" (#2477). */}
|
||||
{task.status === 'done' && task.items?.length === 0 && <AlertTriangle size={16} color="var(--warning)" />}
|
||||
{task.status === 'error' && <AlertCircle size={16} color="#ef4444" />}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -44,6 +44,12 @@ function rejectsTemperature(detail: string): boolean {
|
||||
&& /unsupported|not supported|does not support|only the default/i.test(detail);
|
||||
}
|
||||
|
||||
/** The `response_format` the given attempt carried, by its type. */
|
||||
function sentFormat(shape: RequestShape, nuextract: boolean): string {
|
||||
if (nuextract || shape.noResponseFormat) return 'none';
|
||||
return shape.jsonObject ? 'json_object' : 'json_schema';
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenAI-compatible chat-completions client. Covers both the "openai" cloud
|
||||
* provider and the "local" provider (Ollama / vLLM / llama.cpp / LM Studio),
|
||||
@@ -170,6 +176,10 @@ export class OpenAiCompatibleClient implements LlmExtractionClient {
|
||||
if (!res.ok) {
|
||||
throw new Error(`LLM request failed (${res.status}): ${detail.slice(0, 300)}`);
|
||||
}
|
||||
// Which rung answered. A report could not tell whether the schema reached the
|
||||
// model at all or the ladder had fallen back to the prompt alone (#2477).
|
||||
// Metadata only, so it is the same on a managed install.
|
||||
console.debug(`[DEBUG] LLM answered with response_format=${sentFormat(shape, nuextract)}`);
|
||||
|
||||
const data = (await res.json()) as {
|
||||
choices?: { message?: { content?: string } }[];
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* The ISO 4217 codes this runtime knows. Node ships full ICU, so this is the
|
||||
* complete list; a build without `Intl.supportedValuesOf` leaves it empty and
|
||||
* `toIsoCurrency` then settles for the shape of a code.
|
||||
*/
|
||||
const ISO_CURRENCIES: ReadonlySet<string> = new Set(
|
||||
typeof Intl.supportedValuesOf === 'function' ? Intl.supportedValuesOf('currency') : [],
|
||||
);
|
||||
|
||||
/** A symbol standing on its own. `$` alone is read as USD, as the extractor does. */
|
||||
const SYMBOLS: ReadonlyMap<string, string> = new Map([
|
||||
['€', 'EUR'],
|
||||
['£', 'GBP'],
|
||||
['¥', 'JPY'],
|
||||
['$', 'USD'],
|
||||
]);
|
||||
|
||||
/**
|
||||
* The currency an AI answer named, as an ISO 4217 code, or undefined when it
|
||||
* names none.
|
||||
*
|
||||
* A model's `priceCurrency` is whatever it made of the document: `EUR`, `eur`,
|
||||
* `€`, `Euros`, and once `EURials` (#2477). That value used to reach the cost
|
||||
* form untouched. It is read as an exact code in any case first, then as the
|
||||
* code it starts with when those three letters are a real code (`EURials` and
|
||||
* `Euros` are EUR, `LEK` is nothing, since the lek is ALL), then as a lone
|
||||
* currency symbol. Anything else is undefined, so the caller can fall back to
|
||||
* the document or drop the field instead of passing on a currency nobody can
|
||||
* convert.
|
||||
*/
|
||||
export function toIsoCurrency(raw: unknown, known: ReadonlySet<string> = ISO_CURRENCIES): string | undefined {
|
||||
if (typeof raw !== 'string') return undefined;
|
||||
const value = raw.trim().toUpperCase();
|
||||
if (!value) return undefined;
|
||||
const isCode = (code: string) => (known.size > 0 ? known.has(code) : /^[A-Z]{3}$/.test(code));
|
||||
if (isCode(value)) return value;
|
||||
const prefix = /^[A-Z]{3}/.exec(value)?.[0];
|
||||
if (prefix && isCode(prefix)) return prefix;
|
||||
return SYMBOLS.get(value);
|
||||
}
|
||||
@@ -4,7 +4,8 @@ import { LlmConfigResolver } from './llm-config.resolver';
|
||||
import { buildSystemPrompt, KI_RESERVATION_JSON_SCHEMA } from './llm-prompt';
|
||||
import type { LlmExtractionInput } from './llm-provider.interface';
|
||||
import { isPdf, extractText } from './text-extract';
|
||||
import { routeExtraction, detectFlightNumbers } from './router/extraction-router';
|
||||
import { routeExtraction, detectFlightNumbers, extractTotalPrice } from './router/extraction-router';
|
||||
import { toIsoCurrency } from './currency-code';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { kiReservationSchema } from '@trek/shared';
|
||||
import { RuntimeEnvService } from '../app-config/runtime-env.service';
|
||||
@@ -130,10 +131,21 @@ export class LlmParseService {
|
||||
}
|
||||
|
||||
const kiItems: KiReservation[] = [];
|
||||
// A model can list one stay twice, and did (#1638, #2477): two identical
|
||||
// nodes are one booking, not two hotels. Only exact copies, and only on this
|
||||
// path; the kitinerary extractor reports what the document holds.
|
||||
const seen = new Set<string>();
|
||||
for (const node of raw) {
|
||||
const result = kiReservationSchema.safeParse(node);
|
||||
if (result.success) kiItems.push(normalizeNode(result.data) as unknown as KiReservation);
|
||||
else warnings.push(`${file.originalName}: skipped an unrecognized AI result`);
|
||||
if (!result.success) {
|
||||
warnings.push(`${file.originalName}: skipped an unrecognized AI result`);
|
||||
continue;
|
||||
}
|
||||
const normalized = normalizeCurrency(normalizeNode(result.data), input.text);
|
||||
const key = stableKey(normalized);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
kiItems.push(normalized as unknown as KiReservation);
|
||||
}
|
||||
|
||||
return { kiItems, warnings };
|
||||
@@ -182,3 +194,31 @@ function normalizeNode(node: Record<string, unknown>): Record<string, unknown> {
|
||||
out.reservationFor = reservationFor;
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The node with its `priceCurrency` as an ISO 4217 code, or without one.
|
||||
*
|
||||
* What the model wrote wins when it reads as a code. Otherwise, and only when
|
||||
* the node carries a price, the currency comes from the document's own total
|
||||
* (a Booking.com print shows `€ 89,35`). When neither yields a code the field
|
||||
* is dropped: an empty currency lets the cost form pick one, a made up one
|
||||
* (`EURials`, #2477) is saved and cannot be converted.
|
||||
*/
|
||||
function normalizeCurrency(node: Record<string, unknown>, documentText: string | undefined): Record<string, unknown> {
|
||||
const hasPrice = node.price != null && node.price !== '';
|
||||
if (node.priceCurrency == null && !hasPrice) return node;
|
||||
const code =
|
||||
toIsoCurrency(node.priceCurrency) ??
|
||||
(hasPrice && documentText ? toIsoCurrency(extractTotalPrice(documentText)?.currency) : undefined);
|
||||
const { priceCurrency: _dropped, ...rest } = node;
|
||||
return code ? { ...rest, priceCurrency: code } : rest;
|
||||
}
|
||||
|
||||
/** JSON with every object's keys sorted, so two equal nodes give one string. */
|
||||
function stableKey(value: unknown): string {
|
||||
return JSON.stringify(value, (_key, v: unknown) =>
|
||||
v && typeof v === 'object' && !Array.isArray(v)
|
||||
? Object.fromEntries(Object.entries(v as Record<string, unknown>).sort(([a], [b]) => a.localeCompare(b)))
|
||||
: v,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -35,6 +35,11 @@ const MAX_FILES = 5;
|
||||
const UPLOAD = {
|
||||
storage: memoryStorage(),
|
||||
limits: { fileSize: MAX_FILE_BYTES, files: MAX_FILES },
|
||||
// Like every other upload route (storage-upload.factory.ts). Multer reads the
|
||||
// name as latin1 otherwise, so "Bestätigung.pdf" came back as "Bestätigung.pdf"
|
||||
// in the warnings, and the review could not find the file to attach it to the
|
||||
// booking, because the client matches on the name it sent (#2477).
|
||||
defParamCharset: 'utf8' as const,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* Booking import e2e (#2477): a Booking.com print read by Gemini through its
|
||||
* OpenAI-compatible endpoint, driven through the real upload route.
|
||||
*
|
||||
* Real: the JwtAuthGuard and TripAccessGuard, multer on the import route, the
|
||||
* text extraction of a PDF, LlmParseService, the OpenAI-compatible client with
|
||||
* its request body, and the kitinerary mapper. Replaced: the kitinerary binary
|
||||
* (not installed, as on the reporter's instance, so the AI path runs) and the
|
||||
* one outbound call, `safeFetchLlm`, which answers the way Gemini did.
|
||||
*
|
||||
* The PDF is built here from synthetic text; the reporter's document is never
|
||||
* part of the repository.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import type { Server } from 'http';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { sessionCookie } from './harness';
|
||||
|
||||
const { canAccessTrip, safeFetchLlm } = vi.hoisted(() => ({ canAccessTrip: vi.fn(), safeFetchLlm: vi.fn() }));
|
||||
// The temp db is born inside the factory, which runs before anything imports it,
|
||||
// and read back below through the mocked module itself.
|
||||
vi.mock('../../src/db/database', async () => {
|
||||
const { default: Database } = await import('better-sqlite3');
|
||||
const tmp = new Database(':memory:');
|
||||
tmp.exec('PRAGMA journal_mode = WAL');
|
||||
tmp.exec('PRAGMA foreign_keys = ON');
|
||||
return {
|
||||
db: tmp,
|
||||
canAccessTrip,
|
||||
isOwner: vi.fn(() => true),
|
||||
getPlaceWithTags: vi.fn(),
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
};
|
||||
});
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||
vi.mock('../../src/utils/ssrfGuard', async (orig) => ({ ...(await orig<Record<string, unknown>>()), safeFetchLlm }));
|
||||
|
||||
import { db } from '../../src/db/database';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { DatabaseModule } from '../../src/nest/database/database.module';
|
||||
import { RealtimeModule } from '../../src/nest/realtime/realtime.module';
|
||||
import { ReservationImportModule } from '../../src/nest/reservation-import/reservation-import.module';
|
||||
import { KitineraryExtractorService } from '../../src/nest/booking-import/kitinerary-extractor.service';
|
||||
import { NotificationsService } from '../../src/nest/notifications/notifications.service';
|
||||
import { PermissionsService } from '../../src/nest/permissions/permissions.service';
|
||||
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
||||
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
|
||||
|
||||
/** A one-page PDF whose text layer holds `lines`, in the standard Helvetica. */
|
||||
function pdfWithText(lines: string[]): Buffer {
|
||||
// PDF string literal: escape \ ( ), and write the euro sign as its WinAnsi byte.
|
||||
const esc = (s: string) => s.replace(/[\\()]/g, (c) => '\\' + c).replace(/€/g, '\x80');
|
||||
const content = ['BT', '/F1 10 Tf', '12 TL', '20 780 Td', ...lines.map((l) => `(${esc(l)}) '`), 'ET'].join('\n');
|
||||
const objects = [
|
||||
'<< /Type /Catalog /Pages 2 0 R >>',
|
||||
'<< /Type /Pages /Kids [3 0 R] /Count 1 >>',
|
||||
'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>',
|
||||
`<< /Length ${Buffer.byteLength(content, 'latin1')} >>\nstream\n${content}\nendstream`,
|
||||
'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>',
|
||||
];
|
||||
let body = '%PDF-1.4\n';
|
||||
const offsets: number[] = [];
|
||||
objects.forEach((o, i) => {
|
||||
offsets.push(Buffer.byteLength(body, 'latin1'));
|
||||
body += `${i + 1} 0 obj\n${o}\nendobj\n`;
|
||||
});
|
||||
const xref = Buffer.byteLength(body, 'latin1');
|
||||
const rows = offsets.map((n) => `${String(n).padStart(10, '0')} 00000 n \n`).join('');
|
||||
body += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n${rows}`;
|
||||
body += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`;
|
||||
return Buffer.from(body, 'latin1');
|
||||
}
|
||||
|
||||
const PRINT = pdfWithText([
|
||||
'Harbour View Inn',
|
||||
'Address: Example Road 1, 1000 Sample Town',
|
||||
'Preis (für 2 Gäste)',
|
||||
'€ 89,35',
|
||||
'ANREISE 6 SEPTEMBER 13:00 ABREISE 7 SEPTEMBER 11:00',
|
||||
]);
|
||||
|
||||
/** Gemini's chat-completions answer carrying `reservations` as its content. */
|
||||
function geminiAnswers(reservations: unknown[]) {
|
||||
safeFetchLlm.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ choices: [{ message: { content: JSON.stringify({ reservations }) } }] }),
|
||||
text: async () => '',
|
||||
} as unknown as Response);
|
||||
}
|
||||
|
||||
/** The node Gemini returned in the report: every declared root field, no venue. */
|
||||
const bareStay = {
|
||||
'@type': 'LodgingReservation',
|
||||
checkinTime: '2026-09-06T13:00:00',
|
||||
checkoutTime: '2026-09-07T11:00:00',
|
||||
price: 89.35,
|
||||
priceCurrency: 'EURials',
|
||||
};
|
||||
|
||||
describe('Booking import e2e (#2477): a schema-bound provider on the upload route', () => {
|
||||
let server: Server;
|
||||
let app: Awaited<ReturnType<typeof build>>;
|
||||
let tripId: number;
|
||||
|
||||
async function build() {
|
||||
const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, RealtimeModule, ReservationImportModule] })
|
||||
.overrideProvider(KitineraryExtractorService)
|
||||
.useValue({ onModuleInit: () => {}, isAvailable: () => false, extract: vi.fn(), describe: () => ({ available: false }) })
|
||||
.overrideProvider(NotificationsService)
|
||||
.useValue({ send: vi.fn().mockResolvedValue(undefined) })
|
||||
.compile();
|
||||
const nest = moduleRef.createNestApplication();
|
||||
nest.use(cookieParser());
|
||||
nest.useGlobalFilters(new TrekExceptionFilter());
|
||||
nest.useGlobalPipes(new ZodValidationPipe());
|
||||
await nest.init();
|
||||
return nest;
|
||||
}
|
||||
|
||||
const upload = (fileName: string, mode: string) =>
|
||||
request(server)
|
||||
.post(`/api/trips/${tripId}/reservations/import/booking`)
|
||||
.set('Cookie', sessionCookie(1))
|
||||
.field('mode', mode)
|
||||
.attach('files', PRINT, { filename: fileName, contentType: 'application/pdf' });
|
||||
|
||||
const sentSchema = () => {
|
||||
const body = JSON.parse((safeFetchLlm.mock.calls[0][1] as RequestInit).body as string);
|
||||
return body.response_format.json_schema.schema.properties.reservations.items;
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
createTables(db);
|
||||
runMigrations(db);
|
||||
db.prepare(
|
||||
"INSERT INTO users (id, username, email, password_hash, role, password_version) VALUES (1, 'e2e-user', 'e2e@example.test', 'x', 'user', 0)",
|
||||
).run();
|
||||
tripId = Number(db.prepare("INSERT INTO trips (user_id, title) VALUES (1, 'Albania')").run().lastInsertRowid);
|
||||
// The instance config the reporter ran: Gemini behind the "openai" provider.
|
||||
db.prepare(
|
||||
`INSERT INTO addons (id, name, type, enabled, config) VALUES ('llm_parsing', 'AI Parsing', 'integration', 1, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET enabled = 1, config = excluded.config`,
|
||||
).run(JSON.stringify({ provider: 'openai', model: 'gemini-3.5-flash', baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai' }));
|
||||
app = await build();
|
||||
vi.spyOn(app.get(PermissionsService), 'checkPermission').mockReturnValue(true);
|
||||
server = app.getHttpServer();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
canAccessTrip.mockImplementation((id: unknown) => db.prepare('SELECT * FROM trips WHERE id = ?').get(id));
|
||||
safeFetchLlm.mockReset();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('401 without a cookie', async () => {
|
||||
const res = await request(server).post(`/api/trips/${tripId}/reservations/import/booking`);
|
||||
expect(res.status).toBe(401);
|
||||
expect(safeFetchLlm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sends Gemini a reservationFor with declared venue fields, and requires it', async () => {
|
||||
geminiAnswers([]);
|
||||
await upload('booking.pdf', 'force-ai');
|
||||
|
||||
const item = sentSchema();
|
||||
expect(item.required).toEqual(['@type', 'reservationFor']);
|
||||
expect(item.properties.reservationFor.properties).toMatchObject({
|
||||
name: { type: 'string' },
|
||||
address: { type: 'string' },
|
||||
telephone: { type: 'string' },
|
||||
});
|
||||
// The PDF went as text, and its euro amount survived the extraction.
|
||||
const user = JSON.parse((safeFetchLlm.mock.calls[0][1] as RequestInit).body as string).messages[1].content[0].text;
|
||||
expect(user).toContain('Harbour View Inn');
|
||||
expect(user).toContain('€ 89,35');
|
||||
});
|
||||
|
||||
it("the reported answer: two venue-less nodes give one warning, with the file's own name", async () => {
|
||||
geminiAnswers([bareStay, { ...bareStay }]);
|
||||
const res = await upload('Bestätigung_1234567890.PDF', 'force-ai');
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.items).toEqual([]);
|
||||
// One warning, not two: the copy is gone before the mapper sees it.
|
||||
expect(res.body.warnings).toHaveLength(1);
|
||||
expect(res.body.warnings[0]).toMatch(/^Incomplete LodgingReservation in Bestätigung_1234567890\.PDF\[0\] \(no reservationFor\)/);
|
||||
expect(res.body.files).toEqual([{ fileName: 'Bestätigung_1234567890.PDF', aiAvailable: true, aiUsed: true }]);
|
||||
});
|
||||
|
||||
it('once Gemini fills the venue: one hotel in EUR, attached to the uploaded file', async () => {
|
||||
const stay = { ...bareStay, reservationFor: { name: 'Harbour View Inn', address: 'Example Road 1, 1000 Sample Town' } };
|
||||
geminiAnswers([stay, { ...stay }]);
|
||||
const res = await upload('Bestätigung_1.pdf', 'force-ai');
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.warnings).toEqual([]);
|
||||
expect(res.body.items).toHaveLength(1);
|
||||
expect(res.body.items[0]).toMatchObject({
|
||||
type: 'hotel',
|
||||
title: 'Harbour View Inn',
|
||||
needs_review: true,
|
||||
_accommodation: { check_in: '2026-09-06T13:00', check_out: '2026-09-07T11:00' },
|
||||
metadata: { price: 89.35, priceCurrency: 'EUR' },
|
||||
source: { fileName: 'Bestätigung_1.pdf', index: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
it("the upload dialog's mode reaches the same path, and a currency the model made up comes from the document", async () => {
|
||||
geminiAnswers([{ ...bareStay, priceCurrency: 'ZZZ', reservationFor: { name: 'Harbour View Inn' } }]);
|
||||
const res = await upload('Bestätigung_2.pdf', 'fallback-on-empty');
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.items).toHaveLength(1);
|
||||
expect(res.body.items[0].metadata.priceCurrency).toBe('EUR');
|
||||
expect(res.body.items[0].source.fileName).toBe('Bestätigung_2.pdf');
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { HttpException } from '@nestjs/common';
|
||||
import { HttpException, type Type } from '@nestjs/common';
|
||||
import { INTERCEPTORS_METADATA } from '@nestjs/common/constants';
|
||||
import { ReservationImportController } from '../../../../src/nest/reservation-import/reservation-import.controller';
|
||||
import type { BookingImportService } from '../../../../src/nest/booking-import/booking-import.service';
|
||||
import type { User } from '../../../../src/types';
|
||||
@@ -60,3 +61,22 @@ describe('ReservationImportController.preview', () => {
|
||||
expect(svc.preview).toHaveBeenCalledWith([expect.anything()], 'no-ai', 1);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Multer decodes a multipart filename as latin1 unless told otherwise, and the
|
||||
* booking import did not tell it: "Bestätigung.pdf" reached the warnings as
|
||||
* "Bestätigung.pdf", and the review could not attach the file it no longer
|
||||
* recognised by name (#2477). Read off the interceptor both upload routes carry.
|
||||
*/
|
||||
describe('ReservationImportController upload options', () => {
|
||||
const multerOf = (handler: keyof ReservationImportController) => {
|
||||
const [Interceptor] = Reflect.getMetadata(INTERCEPTORS_METADATA, ReservationImportController.prototype[handler]) as Type<{ multer: { defParamCharset: string; limits: unknown } }>[];
|
||||
return new Interceptor().multer;
|
||||
};
|
||||
|
||||
it.each(['preview', 'previewAsync'] as const)('%s decodes non-ASCII filenames as UTF-8', (handler) => {
|
||||
const multer = multerOf(handler);
|
||||
expect(multer.defParamCharset).toBe('utf8');
|
||||
expect(multer.limits).toEqual({ fileSize: 10 * 1024 * 1024, files: 5 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -287,6 +287,38 @@ describe('OpenAiCompatibleClient', () => {
|
||||
parts = JSON.parse((fetchFn.mock.calls[1][1] as RequestInit).body as string).messages[1].content;
|
||||
expect(parts.every((p: any) => p.type !== 'file' && p.type !== 'image_url')).toBe(true);
|
||||
});
|
||||
|
||||
/**
|
||||
* A report could not say whether the schema ever reached the model or the
|
||||
* ladder had fallen back to the prompt alone (#2477). The debug line names
|
||||
* the rung that answered, and nothing of the booking.
|
||||
*/
|
||||
it('logs which response_format the answering attempt carried (#2477)', async () => {
|
||||
const debug = vi.spyOn(console, 'debug').mockImplementation(() => {});
|
||||
const ok = () => jsonResponse({ choices: [{ message: { content: '{"reservations":[]}' } }] });
|
||||
const refused = () => jsonResponse({ error: 'no' }, false, 400);
|
||||
const lines = () => debug.mock.calls.map(c => String(c[0])).filter(l => l.includes('response_format='));
|
||||
try {
|
||||
safeFetchLlmMock.mockResolvedValueOnce(ok());
|
||||
await new OpenAiCompatibleClient().extract(baseInput);
|
||||
safeFetchLlmMock.mockResolvedValueOnce(refused()).mockResolvedValueOnce(ok());
|
||||
await new OpenAiCompatibleClient().extract(baseInput);
|
||||
safeFetchLlmMock.mockResolvedValueOnce(refused()).mockResolvedValueOnce(refused()).mockResolvedValueOnce(ok());
|
||||
await new OpenAiCompatibleClient().extract(baseInput);
|
||||
safeFetchLlmMock.mockResolvedValueOnce(jsonResponse({ choices: [{ message: { content: '{}' } }] }));
|
||||
await new OpenAiCompatibleClient().extract({ ...baseInput, model: 'hf.co/numind/NuExtract-2.0-2B-GGUF:latest' });
|
||||
|
||||
expect(lines()).toEqual([
|
||||
'[DEBUG] LLM answered with response_format=json_schema',
|
||||
'[DEBUG] LLM answered with response_format=json_object',
|
||||
'[DEBUG] LLM answered with response_format=none',
|
||||
'[DEBUG] LLM answered with response_format=none',
|
||||
]);
|
||||
expect(debug.mock.calls.flat().join(' ')).not.toContain('Flight AB123');
|
||||
} finally {
|
||||
debug.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('OpenAiCompatibleClient — NuExtract path', () => {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { toIsoCurrency } from '../../../../src/nest/llm-parse/currency-code';
|
||||
|
||||
describe('toIsoCurrency', () => {
|
||||
it('keeps an ISO 4217 code in any case and spacing', () => {
|
||||
expect(toIsoCurrency('EUR')).toBe('EUR');
|
||||
expect(toIsoCurrency('eur')).toBe('EUR');
|
||||
expect(toIsoCurrency(' chf ')).toBe('CHF');
|
||||
expect(toIsoCurrency('ALL')).toBe('ALL');
|
||||
});
|
||||
|
||||
it('reads a value that starts with a real code as that code (#2477)', () => {
|
||||
expect(toIsoCurrency('EURials')).toBe('EUR');
|
||||
expect(toIsoCurrency('Euros')).toBe('EUR');
|
||||
expect(toIsoCurrency('EUR 89,35')).toBe('EUR');
|
||||
});
|
||||
|
||||
it('maps a currency symbol that stands alone', () => {
|
||||
expect(toIsoCurrency('€')).toBe('EUR');
|
||||
expect(toIsoCurrency('£')).toBe('GBP');
|
||||
expect(toIsoCurrency('¥')).toBe('JPY');
|
||||
expect(toIsoCurrency(' $ ')).toBe('USD');
|
||||
});
|
||||
|
||||
it('answers undefined for anything that is not a currency it can name', () => {
|
||||
// The lek is ALL; LEK is no code, and its prefix is none either.
|
||||
expect(toIsoCurrency('LEK')).toBeUndefined();
|
||||
expect(toIsoCurrency('XYZ')).toBeUndefined();
|
||||
// A dollar with a country in front is not guessed as USD.
|
||||
expect(toIsoCurrency('CA$')).toBeUndefined();
|
||||
expect(toIsoCurrency('US$')).toBeUndefined();
|
||||
expect(toIsoCurrency('€€')).toBeUndefined();
|
||||
expect(toIsoCurrency('')).toBeUndefined();
|
||||
expect(toIsoCurrency(' ')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('answers undefined for a value that is not a string', () => {
|
||||
expect(toIsoCurrency(null)).toBeUndefined();
|
||||
expect(toIsoCurrency(undefined)).toBeUndefined();
|
||||
expect(toIsoCurrency(978)).toBeUndefined();
|
||||
expect(toIsoCurrency({ code: 'EUR' })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('settles for the shape of a code when the runtime lists no currencies', () => {
|
||||
const none = new Set<string>();
|
||||
expect(toIsoCurrency('eur', none)).toBe('EUR');
|
||||
expect(toIsoCurrency('EURials', none)).toBe('EUR');
|
||||
expect(toIsoCurrency('XYZ', none)).toBe('XYZ');
|
||||
expect(toIsoCurrency('€', none)).toBe('EUR');
|
||||
expect(toIsoCurrency('CA$', none)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -20,7 +20,12 @@ const { routeExtraction, detectFlightNumbers } = vi.hoisted(() => ({
|
||||
routeExtraction: vi.fn(),
|
||||
detectFlightNumbers: vi.fn(() => [] as string[]),
|
||||
}));
|
||||
vi.mock('../../../../src/nest/llm-parse/router/extraction-router', () => ({ routeExtraction, detectFlightNumbers }));
|
||||
// The router's pure helpers stay real: the currency fallback reads the document's
|
||||
// total through extractTotalPrice.
|
||||
vi.mock('../../../../src/nest/llm-parse/router/extraction-router', async (orig) => {
|
||||
const actual = await orig() as Record<string, unknown>;
|
||||
return { ...actual, routeExtraction, detectFlightNumbers };
|
||||
});
|
||||
|
||||
import { LlmParseService } from '../../../../src/nest/llm-parse/llm-parse.service';
|
||||
import type { LlmConfigResolver } from '../../../../src/nest/llm-parse/llm-config.resolver';
|
||||
@@ -188,3 +193,94 @@ describe('LlmParseService', () => {
|
||||
expect(res.warnings[0]).toContain('corrupt pdf');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* What a schema-bound provider answered for one Booking.com stay (#2477): the
|
||||
* same node twice, and a currency the model made up. Synthetic values in the
|
||||
* reporter's shape.
|
||||
*/
|
||||
describe('LlmParseService: cleaning up an AI answer (#2477)', () => {
|
||||
const stay = (over: Record<string, unknown> = {}) => ({
|
||||
'@type': 'LodgingReservation',
|
||||
checkinTime: '2026-09-06T13:00:00',
|
||||
checkoutTime: '2026-09-07T11:00:00',
|
||||
price: 89.35,
|
||||
priceCurrency: 'EURials',
|
||||
reservationFor: { name: 'Harbour View Inn', address: 'Example Road 1' },
|
||||
...over,
|
||||
});
|
||||
const PRINT = ['Harbour View Inn', 'PREIS', '1 Zimmer € 74,46', 'Preis', '(für 2 Gäste)', '€ 89,35'].join('\n');
|
||||
|
||||
it('collapses exact duplicate nodes from one AI answer', async () => {
|
||||
extractText.mockResolvedValue(PRINT);
|
||||
extract.mockResolvedValue([stay(), stay()]);
|
||||
const res = await svc().parse(file('Bestätigung_1.pdf', '%PDF'), 1);
|
||||
expect(res.kiItems).toHaveLength(1);
|
||||
expect(res.warnings).toEqual([]);
|
||||
});
|
||||
|
||||
it("collapses the reporter's two nodes without a venue into one as well", async () => {
|
||||
extractText.mockResolvedValue(PRINT);
|
||||
const bare = { '@type': 'LodgingReservation', checkinTime: '2026-09-06T13:00:00', checkoutTime: '2026-09-07T11:00:00', price: 89.35, priceCurrency: 'EURials' };
|
||||
extract.mockResolvedValue([bare, { ...bare }]);
|
||||
const res = await svc().parse(file('Bestätigung_1.pdf', '%PDF'), 1);
|
||||
expect(res.kiItems).toEqual([{ ...bare, priceCurrency: 'EUR' }]);
|
||||
});
|
||||
|
||||
it('treats the same fields in another key order as the same node', async () => {
|
||||
const a = stay();
|
||||
const b = { reservationFor: { address: 'Example Road 1', name: 'Harbour View Inn' }, priceCurrency: 'EURials', price: 89.35, checkoutTime: a.checkoutTime, checkinTime: a.checkinTime, '@type': a['@type'] };
|
||||
extract.mockResolvedValue([a, b]);
|
||||
const res = await svc().parse(file('a.txt'), 1);
|
||||
expect(res.kiItems).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('keeps distinct nodes that only look alike', async () => {
|
||||
extract.mockResolvedValue([stay(), stay({ checkinTime: '2026-09-07T13:00:00', checkoutTime: '2026-09-08T11:00:00' })]);
|
||||
const res = await svc().parse(file('a.txt'), 1);
|
||||
expect(res.kiItems).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('turns a garbled priceCurrency into the code it starts with', async () => {
|
||||
extract.mockResolvedValue([stay()]);
|
||||
const res = await svc().parse(file('a.txt'), 1);
|
||||
expect(res.kiItems[0].priceCurrency).toBe('EUR');
|
||||
});
|
||||
|
||||
it("falls back to the document's currency symbol when the model named no currency", async () => {
|
||||
extractText.mockResolvedValue(PRINT);
|
||||
extract.mockResolvedValue([stay({ priceCurrency: 'ZZZ' }), stay({ priceCurrency: undefined, checkinTime: '2026-10-01T15:00:00' })]);
|
||||
const res = await svc().parse(file('b.pdf', '%PDF'), 1);
|
||||
expect(res.kiItems.map((n) => n.priceCurrency)).toEqual(['EUR', 'EUR']);
|
||||
});
|
||||
|
||||
it('drops the currency when neither the model nor the document names one', async () => {
|
||||
extractText.mockResolvedValue('Harbour View Inn, 89.35 total');
|
||||
extract.mockResolvedValue([stay({ priceCurrency: 'ZZZ' })]);
|
||||
const res = await svc().parse(file('b.txt'), 1);
|
||||
expect(res.kiItems[0]).not.toHaveProperty('priceCurrency');
|
||||
expect(res.kiItems[0].price).toBe(89.35);
|
||||
});
|
||||
|
||||
it('asks the document only when the node has a price', async () => {
|
||||
extractText.mockResolvedValue(PRINT);
|
||||
extract.mockResolvedValue([stay({ price: undefined, priceCurrency: 'Pesos' })]);
|
||||
const res = await svc().parse(file('b.pdf', '%PDF'), 1);
|
||||
expect(res.kiItems[0]).not.toHaveProperty('priceCurrency');
|
||||
});
|
||||
|
||||
it('has no document to ask on the native PDF path', async () => {
|
||||
resolveLlmConfig.mockReturnValue(cfg({ provider: 'anthropic' }));
|
||||
extract.mockResolvedValue([stay({ priceCurrency: 'ZZZ' })]);
|
||||
const res = await svc().parse(file('b.pdf', '%PDF'), 1);
|
||||
expect(extractText).not.toHaveBeenCalled();
|
||||
expect(res.kiItems[0]).not.toHaveProperty('priceCurrency');
|
||||
});
|
||||
|
||||
it('leaves a node without price and currency as it came', async () => {
|
||||
const plain = { '@type': 'FlightReservation', reservationFor: { flightNumber: 'X1' } };
|
||||
extract.mockResolvedValue([plain]);
|
||||
const res = await svc().parse(file('a.txt'), 1);
|
||||
expect(res.kiItems).toEqual([plain]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,18 @@ import { describe, it, expect } from 'vitest';
|
||||
import { buildSystemPrompt, KI_RESERVATION_JSON_SCHEMA } from '../../../../src/nest/llm-parse/llm-prompt';
|
||||
import { KI_RESERVATION_TYPES } from '@trek/shared';
|
||||
|
||||
/** The slice of JSON Schema these assertions read. */
|
||||
interface SchemaNode {
|
||||
type?: string | readonly string[];
|
||||
properties?: Record<string, SchemaNode>;
|
||||
items?: SchemaNode;
|
||||
required?: readonly string[];
|
||||
additionalProperties?: boolean;
|
||||
}
|
||||
|
||||
const root = KI_RESERVATION_JSON_SCHEMA as SchemaNode;
|
||||
const item = root.properties.reservations.items;
|
||||
|
||||
describe('llm-prompt', () => {
|
||||
it('names every recognized @type the mapper supports', () => {
|
||||
const prompt = buildSystemPrompt();
|
||||
@@ -23,4 +35,54 @@ describe('llm-prompt', () => {
|
||||
expect(item.properties['@type'].enum).toEqual([...KI_RESERVATION_TYPES]);
|
||||
expect(item.required).toContain('@type');
|
||||
});
|
||||
|
||||
/**
|
||||
* Gemini decodes against the schema and fills declared properties only. An
|
||||
* open `reservationFor` without any came back missing, and every hotel lost
|
||||
* its name (#1638, #2477).
|
||||
*/
|
||||
it('declares the venue fields on reservationFor so a schema-constrained provider can fill them (#2477)', () => {
|
||||
const rf = item.properties.reservationFor;
|
||||
|
||||
expect(item.required).toEqual(['@type', 'reservationFor']);
|
||||
expect(rf.type).toBe('object');
|
||||
expect(rf.additionalProperties).toBe(true);
|
||||
for (const field of ['name', 'address', 'telephone', 'url']) expect(rf.properties[field]).toEqual({ type: 'string' });
|
||||
expect(rf.properties.geo.properties).toEqual({ latitude: { type: 'number' }, longitude: { type: 'number' } });
|
||||
});
|
||||
|
||||
it('declares every nested object the prompt names, per type (#2477)', () => {
|
||||
const rf = item.properties.reservationFor.properties;
|
||||
const keys = (node: SchemaNode) => Object.keys(node.properties ?? {});
|
||||
|
||||
expect(keys(rf.departureAirport)).toEqual(['iataCode', 'name', 'geo']);
|
||||
expect(keys(rf.airline)).toEqual(['name', 'iataCode']);
|
||||
for (const stop of ['departureStation', 'arrivalBusStop', 'arrivalBoatTerminal']) {
|
||||
expect(keys(rf[stop])).toEqual(['name', 'geo']);
|
||||
}
|
||||
expect(keys(rf.location)).toEqual(['name', 'address', 'geo', 'telephone', 'url']);
|
||||
expect(keys(rf.rentalCompany)).toEqual(['name']);
|
||||
// The car desks sit at the root, as the prompt says, with the same venue fields.
|
||||
expect(item.properties.pickupLocation).toEqual(rf.location);
|
||||
expect(item.properties.dropoffLocation).toEqual(rf.location);
|
||||
});
|
||||
|
||||
it('nests no object deeper than two levels below reservationFor (#2477)', () => {
|
||||
const depth = (node: SchemaNode): number =>
|
||||
node.type === 'object' && node.properties
|
||||
? 1 + Math.max(0, ...Object.values(node.properties).map(depth))
|
||||
: 0;
|
||||
// reservationFor itself, then e.g. departureAirport, then its geo.
|
||||
expect(depth(item.properties.reservationFor)).toBe(3);
|
||||
});
|
||||
|
||||
it('every object in the schema but the root stays open to extra fields', () => {
|
||||
const walk = (node: SchemaNode | undefined, path: string): string[] => {
|
||||
if (!node) return [];
|
||||
const own = node.type === 'object' && path !== '' && node.additionalProperties !== true ? [path] : [];
|
||||
const children = Object.entries(node.properties ?? {}).flatMap(([k, v]) => walk(v, `${path}.${k}`));
|
||||
return [...own, ...children, ...walk(node.items, `${path}[]`)];
|
||||
};
|
||||
expect(walk(root, '')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -166,3 +166,39 @@ describe('kitinerary mapper — a recognised type that cannot be mapped (#2375)'
|
||||
expect(warnings).toEqual(['Unknown type "RocketLaunchReservation" in mars.eml[0] — skipped']);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The node a schema-bound provider writes once `reservationFor` has declared
|
||||
* fields: the venue inside it, times and price at the root, as the prompt asks.
|
||||
* Synthetic data in the shape of a Booking.com print (#2477).
|
||||
*/
|
||||
describe('kitinerary mapper: a prompt-shaped AI lodging (#2477)', () => {
|
||||
it('maps a LodgingReservation with reservationFor.name to one hotel with its stay and price', () => {
|
||||
const { items, warnings } = mapReservations([
|
||||
{
|
||||
'@type': 'LodgingReservation',
|
||||
checkinTime: '2026-09-06T13:00:00',
|
||||
checkoutTime: '2026-09-07T11:00:00',
|
||||
price: 89.35,
|
||||
priceCurrency: 'EUR',
|
||||
reservationFor: {
|
||||
name: 'Harbour View Inn',
|
||||
address: 'Example Road 1, 1000 Sample Town',
|
||||
telephone: '+00 000 000 000',
|
||||
},
|
||||
},
|
||||
] as Parameters<typeof mapReservations>[0], 'Bestätigung_1.pdf');
|
||||
|
||||
expect(warnings).toEqual([]);
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]).toMatchObject({
|
||||
type: 'hotel',
|
||||
title: 'Harbour View Inn',
|
||||
location: 'Example Road 1, 1000 Sample Town',
|
||||
_venue: { name: 'Harbour View Inn', phone: '+00 000 000 000' },
|
||||
_accommodation: { check_in: '2026-09-06T13:00', check_out: '2026-09-07T11:00' },
|
||||
metadata: { check_in_time: '13:00', check_out_time: '11:00', price: 89.35, priceCurrency: 'EUR' },
|
||||
source: { fileName: 'Bestätigung_1.pdf', index: 0 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user