fix(ai-import): read a long document's end and charge a shared total once (#2652, #2651)

Cloud models got the first 4,000 characters, so totals and lodging sections
further down never reached them, without a word. They now get 24,000 with the
start and the end kept and a warning when the middle is left out; a server
whose context is too small is asked once more with 8,000. A total the model
repeated on every flight of one booking stays on the first flight only, when
the document prints it once as a total, and the prompt says so too.
This commit is contained in:
Maurice
2026-10-14 10:45:38 +02:00
committed by Maurice
parent dae20c166a
commit 4ceade9d8b
14 changed files with 581 additions and 17 deletions
@@ -0,0 +1,80 @@
import { parseAmount } from './clients/nuextract';
import { toMinor } from '@trek/shared';
type Node = Record<string, unknown>;
/** Lines that state a sum rather than a fare per leg or a seat surcharge. */
const TOTAL_LINE = /total|gesamt|summe|betrag|amount|paid|bezahlt/i;
/** One number as printed: digits with their grouping and decimal marks, never two numbers glued together. */
const NUMBER = /\d[\d.,]*/g;
/**
* How many lines of the document print this amount (in hundredths), and whether
* one of them is a total line. Printed once per leg it is a fare per leg, even
* on lines labelled "Amount", not one total the model repeated.
*/
function amountLines(text: string, minor: number): { lines: number; onTotal: boolean } {
let lines = 0;
let onTotal = false;
for (const line of text.split('\n')) {
const printed = (line.match(NUMBER) ?? []).some((token) => {
const amount = parseAmount(token);
return amount != null && toMinor(amount) === minor;
});
if (!printed) continue;
lines += 1;
if (TOTAL_LINE.test(line)) onTotal = true;
}
return { lines, onTotal };
}
const reference = (node: Node) =>
String(node.reservationNumber ?? '')
.trim()
.toUpperCase();
const departure = (node: Node) => {
const time = (node.reservationFor as { departureTime?: unknown } | undefined)?.departureTime;
return typeof time === 'string' ? time : '';
};
const priceOf = (node: Node): number | null => {
const amount = parseAmount(node.price);
return amount != null && amount > 0 ? toMinor(amount) : null;
};
/**
* A model reading a round trip with one total for the whole booking puts that
* total on every flight, and saving both legs then books the purchase twice
* (#2651). When the flights plainly share it, the price stays on the first
* flight to depart and leaves the others.
*
* Plainly means: only flights, all on one booking reference, at least two of
* them priced, every price the same amount in the same currency, and the
* document printing that amount on a total line, on fewer lines than there are
* priced flights. Fares that differ, separate references, mixed currencies,
* other booking types, or no document text to check against (a PDF read
* natively, a photo) leave the nodes as they are.
*/
export function collapseRepeatedBookingTotal(nodes: Node[], documentText: string | undefined): Node[] {
if (!documentText || nodes.length < 2) return nodes;
if (!nodes.every((node) => node['@type'] === 'FlightReservation')) return nodes;
const ref = reference(nodes[0]);
if (!ref || !nodes.every((node) => reference(node) === ref)) return nodes;
const priced = nodes.filter((node) => priceOf(node) != null);
const minor = priced.length >= 2 ? priceOf(priced[0]) : null;
if (minor == null) return nodes;
const currency = priced[0].priceCurrency;
if (!priced.every((node) => priceOf(node) === minor && node.priceCurrency === currency)) return nodes;
const printed = amountLines(documentText, minor);
if (!printed.onTotal || printed.lines >= priced.length) return nodes;
// The first to depart keeps it; a tie keeps the order the model gave.
const keeper = priced.reduce((first, node) => (departure(node) < departure(first) ? node : first));
return nodes.map((node) => {
if (node === keeper || priceOf(node) == null) return node;
const { price: _price, priceCurrency: _currency, ...rest } = node;
return rest;
});
}
@@ -2,6 +2,7 @@ import { readEnv } from '../../../app-config';
import { safeFetchLlm } from '../../../utils/ssrfGuard';
import { toReservationList } from '../lenient-json';
import type { LlmExtractionClient, LlmExtractionInput } from '../llm-provider.interface';
import { isContextOverflow, LlmContextOverflowError } from './context-overflow';
import { UnreadableLlmResponse } from './openai-compatible.client';
const MAX_TOKENS = 8192;
@@ -79,7 +80,10 @@ export class AnthropicClient implements LlmExtractionClient {
if (!res.ok) {
const detail = await res.text().catch(() => '');
throw new Error(`Anthropic request failed (${res.status}): ${detail.slice(0, 300)}`);
const message = `Anthropic request failed (${res.status}): ${detail.slice(0, 300)}`;
// A gateway in front of a smaller model can run out of context (#2652, #2655).
if (res.status === 400 && isContextOverflow(detail)) throw new LlmContextOverflowError(message);
throw new Error(message);
}
const data = (await res.json()) as {
@@ -0,0 +1,17 @@
/**
* Raised when a server refuses a request because the prompt does not fit its
* context. Only a shorter text can help, so the request is not retried in
* another shape: the caller cuts the document down and asks once more (#2652).
*/
export class LlmContextOverflowError extends Error {}
/**
* The wordings OpenAI, vLLM, llama.cpp, LiteLLM and Anthropic use for a prompt
* that is too long for the model's context.
*/
const CONTEXT_OVERFLOW =
/maximum context length|context[_ ]length[_ ]exceeded|context window|prompt is too long|too many tokens|exceeds the context/i;
export function isContextOverflow(detail: string): boolean {
return CONTEXT_OVERFLOW.test(detail);
}
@@ -2,6 +2,7 @@ import { readEnv } from '../../../app-config';
import { safeFetchLlm } from '../../../utils/ssrfGuard';
import { parseLenientJson, toReservationList } from '../lenient-json';
import type { LlmExtractionClient, LlmExtractionInput } from '../llm-provider.interface';
import { isContextOverflow, LlmContextOverflowError } from './context-overflow';
import { isNuExtractModel, buildNuExtractUserText, nuExtractToKiReservations } from './nuextract';
const MAX_TOKENS = 4096;
@@ -153,6 +154,9 @@ export class OpenAiCompatibleClient implements LlmExtractionClient {
// system prompt already dictates the exact output shape and mentions JSON,
// which json_object mode requires.
while (!res.ok && res.status === 400) {
// No response_format makes a prompt shorter, so a context overflow ends the ladder.
if (isContextOverflow(detail))
throw new LlmContextOverflowError(`LLM request failed (400): ${detail.slice(0, 300)}`);
let named = false;
if (!tried.tokenParam && detail.includes('max_completion_tokens')) {
shape.tokenParam = 'max_completion_tokens';
+64 -10
View File
@@ -1,5 +1,7 @@
import { RuntimeEnvService } from '../app-config/runtime-env.service';
import type { KiReservation } from '../booking-import/kitinerary.types';
import { collapseRepeatedBookingTotal } from './booking-total';
import { LlmContextOverflowError } from './clients/context-overflow';
import { toIsoCurrency } from './currency-code';
import { capImage, imageMimeType, renderPdfPages } from './image-input';
import { createLlmClient } from './llm-client.factory';
@@ -24,6 +26,13 @@ import {
} from './router/extraction-router';
import { extractEnforced } from './router/ollama-format.client';
import { isPdf, extractText } from './text-extract';
import {
CLOUD_TEXT_WINDOW,
COMPACT_TEXT_WINDOW,
fitTextWindow,
LOCAL_FLIGHT_TEXT_WINDOW,
LOCAL_TEXT_WINDOW,
} from './text-window';
import { Injectable } from '@nestjs/common';
import { kiReservationSchema, type ReceiptScanResult } from '@trek/shared';
@@ -155,6 +164,9 @@ export class LlmParseService {
baseUrl: config.baseUrl,
apiKey: config.apiKey,
};
// The whole extracted text, kept for a second, shorter try (see extractFitting).
let fullText: string | undefined;
const longDocument = `${file.originalName}: long document, the middle part was not read`;
const imageType = imageMimeType(file.originalName);
if (imageType && !(await this.visionFor(config))) {
@@ -173,18 +185,25 @@ export class LlmParseService {
);
} else if (config.provider === 'anthropic' && isPdf(file.originalName)) {
input.file = { mimeType: MIME_BY_EXT['.pdf'], data: file.buffer };
// Not for the model, which reads the PDF itself: only to check its prices against (#2651).
fullText = await extractText(file.buffer, file.originalName).catch(() => undefined);
console.debug(
`[DEBUG] Extracted (native PDF, ${file.buffer.length} bytes) sent to ${config.provider}: ${file.originalName}`,
);
} else {
input.text = await extractText(file.buffer, file.originalName);
// Cap the text fed to the model. A flight itinerary lists its legs throughout a long
// document, so it keeps a generous window; a single booking has the essentials up top,
// so cap it tighter to keep CPU prompt-eval fast (a 11-page rental voucher was ~200s at
// 16k, the booking data sits in the first ~2k). Cloud single-shot keeps the tight cap.
const MAX_EXTRACT_CHARS =
config.provider !== 'local' ? 4000 : detectFlightNumbers(input.text).length > 0 ? 16000 : 6000;
if (input.text.length > MAX_EXTRACT_CHARS) input.text = input.text.slice(0, MAX_EXTRACT_CHARS);
const extracted = await extractText(file.buffer, file.originalName);
fullText = extracted;
// Cap the text fed to the model (the windows and why are in text-window.ts). A longer
// document loses its middle, never its end, where the total is, and the import says so.
const textWindow =
config.provider !== 'local'
? CLOUD_TEXT_WINDOW
: detectFlightNumbers(extracted).length > 0
? LOCAL_FLIGHT_TEXT_WINDOW
: LOCAL_TEXT_WINDOW;
const fitted = fitTextWindow(extracted, textWindow);
input.text = fitted.text;
if (fitted.omitted > 0) warnings.push(longDocument);
// The extracted text IS the booking: traveller name, address, booking
// reference. On a centrally administered install that would land in the
// operator's log, which is a processing nobody asked for and nobody needs.
@@ -253,7 +272,9 @@ export class LlmParseService {
let raw: Record<string, unknown>[];
try {
raw = await createLlmClient(config).extract(input);
raw = await this.extractFitting(input, config, fullText, () => {
if (!warnings.includes(longDocument)) warnings.push(longDocument);
});
// Same reason: the model answers with the fields it read out of the document.
// Parsed, not raw — this prints the nodes the client already read out of the
// response, so its single quotes and bare keys are util.inspect's, not the
@@ -289,7 +310,40 @@ export class LlmParseService {
kiItems.push(normalized as unknown as KiReservation);
}
return { kiItems, warnings };
// The whole document, not the window the model read: a total sits at its end.
const priced = collapseRepeatedBookingTotal(kiItems as unknown as Record<string, unknown>[], fullText);
return { kiItems: priced as unknown as KiReservation[], warnings };
}
/**
* One extraction call, and one more with a shorter text when the server says
* the prompt does not fit its context. Every cloud provider gets the full
* window first, OpenAI-compatible gateways included (#2652); a self-hosted
* server with a small context then gets the compact window instead of failing.
*/
private async extractFitting(
input: LlmExtractionInput,
config: ResolvedLlmConfig,
fullText: string | undefined,
onShortened: () => void,
): Promise<Record<string, unknown>[]> {
const client = createLlmClient(config);
try {
return await client.extract(input);
} catch (err) {
// A PDF read natively has no text window to shorten; a second try would only send more.
if (
!(err instanceof LlmContextOverflowError) ||
input.file ||
!fullText ||
fullText.length <= COMPACT_TEXT_WINDOW
) {
throw err;
}
input.text = fitTextWindow(fullText, COMPACT_TEXT_WINDOW).text;
onShortened();
return await client.extract(input);
}
}
}
+1
View File
@@ -28,6 +28,7 @@ export function buildSystemPrompt(): string {
'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.",
'A total for the whole booking belongs on exactly one reservation, the first leg or segment to depart; never repeat it on each leg. Keep prices that the document states separately for each leg.',
'If the document contains no recognizable reservation, return { "reservations": [] }.',
].join('\n');
}
+52
View File
@@ -0,0 +1,52 @@
/**
* How much extracted text a cloud model is sent. A confirmation keeps its total
* and payment block near the end, and 4,000 characters cut both off a long email
* without a word (#2652). 24,000 characters are about 6,000 input tokens, far
* under the context of any current cloud model, and cost roughly one or two cents
* per import on a mid-tier model, a fraction of a cent on a small one.
*/
export const CLOUD_TEXT_WINDOW = 24_000;
/**
* A local model reads on the CPU, where every character costs prompt-eval time. A
* flight itinerary lists its legs throughout a long document, so it keeps a
* generous window; a single booking has the essentials up top (an 11-page rental
* voucher took ~200s at 16k, its booking data sat in the first ~2k).
*/
export const LOCAL_FLIGHT_TEXT_WINDOW = 16_000;
export const LOCAL_TEXT_WINDOW = 6_000;
/**
* The second try for a server that refused the cloud window for its context
* length: a self-hosted vLLM or llama.cpp with 8k tokens takes this together
* with the prompt, the schema and the answer (#2652).
*/
export const COMPACT_TEXT_WINDOW = 8_000;
/** Share of a window kept from the start; the rest comes from the end. */
const HEAD_SHARE = 0.75;
/** How far a cut may move to land on a line break instead of inside a line. */
const LINE_SNAP = 200;
/**
* The text cut to `window` characters, keeping the start and the end. The legs
* and names sit up top, the total and the payment summary at the bottom, so the
* middle is what goes. The cuts land on line breaks where one is close, so an
* amount is never split in half, and a marker says how much is missing.
* `omitted` is 0 when the text fit.
*/
export function fitTextWindow(text: string, window: number): { text: string; omitted: number } {
if (text.length <= window) return { text, omitted: 0 };
let headEnd = Math.floor(window * HEAD_SHARE);
let tailStart = text.length - (window - headEnd);
const lineBefore = text.lastIndexOf('\n', headEnd);
if (lineBefore > 0 && lineBefore > headEnd - LINE_SNAP) headEnd = lineBefore;
const lineAfter = text.indexOf('\n', tailStart - 1);
if (lineAfter !== -1 && lineAfter < tailStart + LINE_SNAP) tailStart = lineAfter + 1;
const omitted = tailStart - headEnd;
return {
text: `${text.slice(0, headEnd)}\n[... ${omitted} characters omitted ...]\n${text.slice(tailStart)}`,
omitted,
};
}
@@ -0,0 +1,90 @@
import { collapseRepeatedBookingTotal } from '../../../../src/nest/llm-parse/booking-total';
import { describe, expect, it } from 'vitest';
const leg = (
flightNumber: string,
departureTime: string,
price?: unknown,
{ currency = 'USD', ref = 'DEMO77', type = 'FlightReservation' } = {},
): Record<string, unknown> => ({
'@type': type,
reservationNumber: ref,
...(price === undefined ? {} : { price, priceCurrency: currency }),
reservationFor: { flightNumber, departureTime },
});
const prices = (nodes: Record<string, unknown>[]) => nodes.map((n) => n.price ?? null);
const TOTAL = 'Payment summary\nTotal paid: USD 200.00';
describe('collapseRepeatedBookingTotal (#2651)', () => {
it('keeps a total the flights repeat on the first to depart, whatever order the model gave', () => {
const nodes = [leg('EX101', '2031-02-08T07:00', 200), leg('EX100', '2031-02-05T12:00', 200)];
const out = collapseRepeatedBookingTotal(nodes, TOTAL);
expect(prices(out)).toEqual([null, 200]);
expect(out[0]).not.toHaveProperty('priceCurrency');
expect(out[1]).toHaveProperty('priceCurrency', 'USD');
});
it('leaves fares the document states per leg alone, even when they are equal', () => {
const text = 'Fare EX100 USD 200.00\nFare EX101 USD 200.00\nTotal paid: USD 400.00';
const nodes = [leg('EX100', '2031-02-05T12:00', 200), leg('EX101', '2031-02-08T07:00', 200)];
expect(prices(collapseRepeatedBookingTotal(nodes, text))).toEqual([200, 200]);
});
it('leaves a fare printed on every leg alone, even on a line labelled Amount', () => {
const text = 'Flight LH1 Amount 89.00 EUR\nFlight LH2 Amount 89.00 EUR\nTotal 178.00 EUR';
const nodes = [
leg('LH1', '2031-02-05T12:00', 89, { currency: 'EUR' }),
leg('LH2', '2031-02-08T07:00', 89, { currency: 'EUR' }),
];
expect(collapseRepeatedBookingTotal(nodes, text)).toBe(nodes);
});
it('leaves different amounts, references, currencies and mixed booking types alone', () => {
const cases = [
[leg('EX100', '2031-02-05T12:00', 120), leg('EX101', '2031-02-08T07:00', 80)],
[leg('EX100', '2031-02-05T12:00', 200, { ref: 'A1' }), leg('EX101', '2031-02-08T07:00', 200, { ref: 'B2' })],
[leg('EX100', '2031-02-05T12:00', 200), leg('EX101', '2031-02-08T07:00', 200, { currency: 'EUR' })],
[leg('EX100', '2031-02-05T12:00', 200), leg('Stay', '2031-02-05T15:00', 200, { type: 'LodgingReservation' })],
[leg('EX100', '2031-02-05T12:00', 200, { ref: '' }), leg('EX101', '2031-02-08T07:00', 200, { ref: '' })],
];
for (const nodes of cases) expect(collapseRepeatedBookingTotal(nodes, TOTAL)).toBe(nodes);
});
it('does nothing without document text to check, or when the text names no such total', () => {
const nodes = [leg('EX100', '2031-02-05T12:00', 200), leg('EX101', '2031-02-08T07:00', 200)];
expect(collapseRepeatedBookingTotal(nodes, undefined)).toBe(nodes);
expect(collapseRepeatedBookingTotal(nodes, 'Fare conditions apply.')).toBe(nodes);
});
it('compares in cents and reads a printed amount as one number', () => {
const nodes = [leg('EX100', '2031-02-05T12:00', '200.00'), leg('EX101', '2031-02-08T07:00', 200)];
// The date on the same line must not run into the amount.
expect(prices(collapseRepeatedBookingTotal(nodes, '2031-02-05 Total paid USD 200.00'))).toEqual(['200.00', null]);
const euro = [
leg('LH1', '2031-02-05T12:00', 1234.5, { currency: 'EUR' }),
leg('LH2', '2031-02-08T07:00', 1234.5, { currency: 'EUR' }),
];
expect(prices(collapseRepeatedBookingTotal(euro, 'Gesamtbetrag: 1.234,50 EUR'))).toEqual([1234.5, null]);
});
it('leaves an unpriced leg as it is and does not change the nodes it was given', () => {
const nodes = [
leg('EX100', '2031-02-05T12:00', 200),
leg('EX101', '2031-02-08T07:00', 200),
leg('EX102', '2031-02-09T07:00'),
];
const snapshot = JSON.parse(JSON.stringify(nodes));
const out = collapseRepeatedBookingTotal(nodes, TOTAL);
expect(prices(out)).toEqual([200, null, null]);
expect(out[2]).toBe(nodes[2]);
expect(nodes).toEqual(snapshot);
});
it('needs two priced flights to call it a repeat', () => {
const nodes = [leg('EX100', '2031-02-05T12:00', 200), leg('EX101', '2031-02-08T07:00')];
expect(collapseRepeatedBookingTotal(nodes, TOTAL)).toBe(nodes);
expect(collapseRepeatedBookingTotal([nodes[0]], TOTAL)).toEqual([nodes[0]]);
});
});
@@ -1,5 +1,6 @@
import { readEnv } from '../../../../src/app-config';
import { AnthropicClient } from '../../../../src/nest/llm-parse/clients/anthropic.client';
import { LlmContextOverflowError } from '../../../../src/nest/llm-parse/clients/context-overflow';
import { OpenAiCompatibleClient } from '../../../../src/nest/llm-parse/clients/openai-compatible.client';
import type { LlmExtractionInput } from '../../../../src/nest/llm-parse/llm-provider.interface';
import {
@@ -143,6 +144,23 @@ describe('OpenAiCompatibleClient', () => {
expect(safeFetchLlmMock).toHaveBeenCalledTimes(1); // no retry on non-400
});
it('stops at a context-length 400: no response_format makes the prompt shorter (#2652)', async () => {
mockFetch(() =>
jsonResponse(
{
error: {
message:
"This model's maximum context length is 8192 tokens. However, your messages resulted in 9500 tokens.",
},
},
false,
400,
),
);
await expect(new OpenAiCompatibleClient().extract(baseInput)).rejects.toBeInstanceOf(LlmContextOverflowError);
expect(safeFetchLlmMock).toHaveBeenCalledTimes(1);
});
it('retries once with json_object when the server rejects json_schema (400)', async () => {
safeFetchLlmMock
.mockResolvedValueOnce(jsonResponse({ error: { message: 'response_format type is unavailable' } }, false, 400))
@@ -523,6 +541,34 @@ describe('OpenAiCompatibleClient — NuExtract path', () => {
});
describe('AnthropicClient', () => {
it('talks to a Messages gateway set as the base URL, and to Anthropic without one (#2655)', async () => {
const fetchFn = mockFetch(() =>
jsonResponse({
stop_reason: 'tool_use',
content: [{ type: 'tool_use', name: 'emit_reservations', input: { reservations: [] } }],
}),
);
await new AnthropicClient().extract({ ...baseInput, apiKey: 'k', baseUrl: 'http://gateway.lan:4000/' });
await new AnthropicClient().extract({ ...baseInput, apiKey: 'k' });
expect(fetchFn.mock.calls[0][0]).toBe('http://gateway.lan:4000/v1/messages');
expect(fetchFn.mock.calls[1][0]).toBe('https://api.anthropic.com/v1/messages');
const body = JSON.parse((fetchFn.mock.calls[0][1] as RequestInit).body as string);
expect(body.tool_choice).toMatchObject({ type: 'tool', name: 'emit_reservations' });
});
it('tells a prompt too long for a gateway apart from other refusals', async () => {
mockFetch(() =>
jsonResponse({ error: { message: 'prompt is too long: 210000 tokens > 200000 maximum' } }, false, 400),
);
await expect(new AnthropicClient().extract({ ...baseInput, apiKey: 'k' })).rejects.toBeInstanceOf(
LlmContextOverflowError,
);
mockFetch(() => jsonResponse({ error: { message: 'invalid x-api-key' } }, false, 401));
const refused = new AnthropicClient().extract({ ...baseInput, apiKey: 'k' });
await expect(refused).rejects.toThrow(/401/);
await expect(refused).rejects.not.toBeInstanceOf(LlmContextOverflowError);
});
it('sends the pages of a scanned PDF as image blocks after the first', async () => {
const fetchFn = mockFetch(() =>
jsonResponse({
@@ -7,6 +7,7 @@ import {
routeExtraction,
routeImageExtraction,
} from '../../../../src/nest/llm-parse/router/extraction-router';
import { fitTextWindow } from '../../../../src/nest/llm-parse/text-window';
import { describe, it, expect, vi, beforeEach } from 'vitest';
@@ -176,6 +177,26 @@ describe('routeExtraction', () => {
expect(flat.currency).toBe('EUR');
});
it('reads a long document cut to its start and end like the whole one, the marker changes nothing (#2652)', async () => {
const legs = 'Example Airways\nEX 101 LIS-OPO 2026-11-03 08:10\nEX 102 OPO-LIS 2026-11-07 19:40\nPNR: EX4Y2Z';
const text = `${legs}\n${'Fare conditions apply.\n'.repeat(1000)}Total amount: 200.00 USD\n`;
const fitted = fitTextWindow(text, 6000);
expect(fitted.text).toMatch(/\[\.\.\. \d+ characters omitted \.\.\.\]/);
expect(detectFlightNumbers(fitted.text)).toEqual(['EX101', 'EX102']);
extractEnforced.mockResolvedValue({
flights: [
{ vehicle_number: 'EX101', from_code: 'LIS', to_code: 'OPO', departure_time: '2026-11-03T08:10' },
{ vehicle_number: 'EX102', from_code: 'OPO', to_code: 'LIS', departure_time: '2026-11-07T19:40' },
],
});
await routeExtraction(fitted.text, CTX);
const flats = mapToKi.mock.calls[0][0];
expect(flats).toHaveLength(2);
expect(flats[0].booking_reference).toBe('EX4Y2Z');
expect(flats[0].price).toBe('200.00');
expect(flats[0].currency).toBe('USD');
});
it("lets the document's currency override the model but keeps a price the model already found", async () => {
extractEnforced.mockResolvedValue({
name: 'B&B Hotel',
@@ -1,4 +1,5 @@
import type { RuntimeEnvService } from '../../../../src/nest/app-config/runtime-env.service';
import { LlmContextOverflowError } from '../../../../src/nest/llm-parse/clients/context-overflow';
import type { LlmConfigResolver } from '../../../../src/nest/llm-parse/llm-config.resolver';
import type { LlmLocalService } from '../../../../src/nest/llm-parse/llm-local.service';
import { LlmParseService } from '../../../../src/nest/llm-parse/llm-parse.service';
@@ -116,7 +117,8 @@ describe('LlmParseService', () => {
const input = extract.mock.calls[0][0];
expect(input.file).toEqual({ mimeType: 'application/pdf', data: expect.any(Buffer) });
expect(input.text).toBeUndefined();
expect(extractText).not.toHaveBeenCalled();
// Read only to check a repeated booking total against (#2651); the model never sees it.
expect(extractText).toHaveBeenCalledTimes(1);
});
it('warns when a pdf yields no readable text (e.g. a scan)', async () => {
@@ -204,8 +206,13 @@ describe('LlmParseService', () => {
extractText.mockResolvedValue(long);
detectFlightNumbers.mockReturnValue([]);
routeExtraction.mockResolvedValue({ kiItems: [], warnings: [] });
await svc().parse(file('hotel.txt'), 1);
expect(routeExtraction.mock.calls[0][0]).toHaveLength(6000); // single booking → tighter cap
const res = await svc().parse(file('hotel.txt'), 1);
// single booking → tighter cap, keeping the start and the end
const sent = routeExtraction.mock.calls[0][0] as string;
expect(sent).toBe(`${'x'.repeat(4500)}
[... 1000 characters omitted ...]
${'x'.repeat(1500)}`);
expect(res.warnings).toEqual(['hotel.txt: long document, the middle part was not read']);
});
it('degrades to a warning when the local router throws', async () => {
@@ -332,7 +339,8 @@ describe('LlmParseService: cleaning up an AI answer (#2477)', () => {
resolveLlmConfig.mockReturnValue(cfg({ provider: 'anthropic' }));
extract.mockResolvedValue([stay({ priceCurrency: 'ZZZ' })]);
const res = await svc().parse(file('b.pdf', '%PDF'), 1);
expect(extractText).not.toHaveBeenCalled();
// The text read for the total check is not a document the currency is asked from.
expect(extract.mock.calls[0][0].text).toBeUndefined();
expect(res.kiItems[0]).not.toHaveProperty('priceCurrency');
});
@@ -553,3 +561,135 @@ describe('LlmParseService: a scanned PDF', () => {
expect(renderPdfPages).not.toHaveBeenCalled();
});
});
/**
* A long confirmation keeps its total near the end (#2652): two Example Airways
* legs up top, then itinerary and footer text, then the payment block. Synthetic.
*/
describe('LlmParseService: a long document', () => {
const legs = [
'Example Airways booking confirmation',
'Booking reference: EX4Y2Z',
'EX 101 Lisbon (LIS) to Porto (OPO) 2026-11-03 08:10 - 09:05',
'EX 102 Porto (OPO) to Lisbon (LIS) 2026-11-07 19:40 - 20:35',
].join('\n');
const filler = (chars: number) =>
'Baggage, check-in and fare conditions apply as published. '.repeat(Math.ceil(chars / 58)).slice(0, chars);
const confirmation = (fillerChars: number) =>
`${legs}\n${filler(fillerChars)}\nPayment summary\nTotal paid: USD 200.00\nThank you for flying with us.`;
it('sends an early total and leaves a short document untouched, without a warning', async () => {
const doc = `${legs}\nTotal paid: USD 200.00\n${filler(1500)}`;
extractText.mockResolvedValue(doc);
const res = await svc().parse(file('confirmation.txt'), 1);
expect(extract.mock.calls[0][0].text).toBe(doc);
expect(res.warnings).toEqual([]);
});
it('sends a total that sits past the first 4,000 characters to a cloud model', async () => {
const doc = confirmation(6000);
expect(doc.indexOf('Total paid')).toBeGreaterThan(4000);
extractText.mockResolvedValue(doc);
const res = await svc().parse(file('confirmation.txt'), 1);
const sent = extract.mock.calls[0][0].text as string;
expect(sent).toBe(doc);
expect(sent).toContain('Total paid: USD 200.00');
expect(res.warnings).toEqual([]);
});
it('keeps the start and the end of a document longer than the window, and says so', async () => {
const doc = confirmation(60_000);
extractText.mockResolvedValue(doc);
const res = await svc().parse(file('confirmation.txt'), 1);
const sent = extract.mock.calls[0][0].text as string;
expect(sent.length).toBeLessThan(doc.length);
expect(sent.startsWith(legs)).toBe(true);
expect(sent).toContain('Total paid: USD 200.00');
expect(sent.endsWith('Thank you for flying with us.')).toBe(true);
expect(sent).toMatch(/\[\.\.\. \d+ characters omitted \.\.\.\]/);
expect(res.warnings).toEqual(['confirmation.txt: long document, the middle part was not read']);
});
it('keeps the start and the end for a local model too, inside its own window', async () => {
resolveLlmConfig.mockReturnValue(cfg({ provider: 'local' }));
routeExtraction.mockResolvedValue({ kiItems: [{ '@type': 'FlightReservation' }], warnings: ['note'] });
const doc = confirmation(9000);
extractText.mockResolvedValue(doc);
const res = await svc().parse(file('confirmation.txt'), 1);
const sent = routeExtraction.mock.calls[0][0] as string;
expect(sent.startsWith(legs)).toBe(true);
expect(sent).toContain('Total paid: USD 200.00');
expect(sent).toMatch(/\[\.\.\. \d+ characters omitted \.\.\.\]/);
expect(res.warnings).toEqual(['confirmation.txt: long document, the middle part was not read', 'note']);
});
});
/** Too long for a small self-hosted server, and a total shared by two flights (#2651, #2652). */
describe('LlmParseService: a server that runs out of context, and a shared booking total', () => {
const legs =
'Example Airways\nBooking reference: DEMO77\nEX100 LIS-OPO 2031-02-05 12:00\nEX101 OPO-LIS 2031-02-08 07:00';
const doc = `${legs}\n${'Fare conditions apply.\n'.repeat(1500)}Payment summary\nTotal paid: USD 200.00\n`;
const flight = (n: string, dep: string) => ({
'@type': 'FlightReservation',
reservationNumber: 'DEMO77',
price: 200,
priceCurrency: 'USD',
reservationFor: { '@type': 'Flight', flightNumber: n, departureTime: dep },
});
it('asks once more with the compact window when the server says the prompt does not fit', async () => {
extractText.mockResolvedValue(doc);
extract
.mockRejectedValueOnce(
new LlmContextOverflowError("LLM request failed (400): This model's maximum context length is 8192 tokens"),
)
.mockResolvedValueOnce([{ '@type': 'FlightReservation' }]);
const res = await svc().parse(file('confirmation.txt'), 1);
expect(extract).toHaveBeenCalledTimes(2);
const second = extract.mock.calls[1][0].text as string;
expect(second.length).toBeLessThan(8_100);
expect(second.startsWith(legs)).toBe(true);
expect(second).toContain('Total paid: USD 200.00');
expect(res.kiItems).toHaveLength(1);
expect(res.warnings).toEqual(['confirmation.txt: long document, the middle part was not read']);
});
it('does not retry a PDF read natively, which has no text window to shorten', async () => {
resolveLlmConfig.mockReturnValue(cfg({ provider: 'anthropic' }));
extractText.mockResolvedValue(doc);
extract.mockRejectedValueOnce(new LlmContextOverflowError('Anthropic request failed (400): prompt is too long'));
const res = await svc().parse(file('ticket.pdf', '%PDF'), 1);
expect(extract).toHaveBeenCalledTimes(1);
expect(res.warnings).toHaveLength(1);
expect(res.warnings[0]).toMatch(/AI parsing failed/);
});
it('does not retry any other failure', async () => {
extractText.mockResolvedValue(doc);
extract.mockRejectedValueOnce(new Error('LLM request failed (401): bad key'));
const res = await svc().parse(file('confirmation.txt'), 1);
expect(extract).toHaveBeenCalledTimes(1);
expect(res.warnings[0]).toMatch(/AI parsing failed/);
});
it('keeps a total both flights repeat on the first to depart only', async () => {
extractText.mockResolvedValue(doc);
extract.mockResolvedValue([flight('EX101', '2031-02-08T07:00:00'), flight('EX100', '2031-02-05T12:00:00')]);
const res = await svc().parse(file('confirmation.txt'), 1);
const [late, early] = res.kiItems as unknown as Record<string, unknown>[];
expect(early).toMatchObject({ price: 200, priceCurrency: 'USD' });
expect(late).not.toHaveProperty('price');
expect(late).not.toHaveProperty('priceCurrency');
});
it('checks a native PDF against its text too, without sending that text', async () => {
resolveLlmConfig.mockReturnValue(cfg({ provider: 'anthropic' }));
extractText.mockResolvedValue(doc);
extract.mockResolvedValue([flight('EX100', '2031-02-05T12:00:00'), flight('EX101', '2031-02-08T07:00:00')]);
const res = await svc().parse(file('ticket.pdf', '%PDF'), 1);
expect(extract.mock.calls[0][0].text).toBeUndefined();
const [first, second] = res.kiItems as unknown as Record<string, unknown>[];
expect(first).toHaveProperty('price', 200);
expect(second).not.toHaveProperty('price');
});
});
@@ -21,6 +21,12 @@ describe('llm-prompt', () => {
for (const t of KI_RESERVATION_TYPES) expect(prompt).toContain(t);
});
it('puts a total for the whole booking on one leg, not on each (#2651)', () => {
const prompt = buildSystemPrompt();
expect(prompt).toContain('A total for the whole booking belongs on exactly one reservation');
expect(prompt).toContain('never repeat it on each leg');
});
it('instructs JSON-only output wrapped in reservations', () => {
const prompt = buildSystemPrompt();
expect(prompt).toMatch(/"reservations"/);
@@ -0,0 +1,45 @@
import {
CLOUD_TEXT_WINDOW,
fitTextWindow,
LOCAL_FLIGHT_TEXT_WINDOW,
LOCAL_TEXT_WINDOW,
} from '../../../../src/nest/llm-parse/text-window';
import { describe, it, expect } from 'vitest';
describe('fitTextWindow', () => {
it('leaves a text that fits as it is', () => {
expect(fitTextWindow('short', 10)).toEqual({ text: 'short', omitted: 0 });
expect(fitTextWindow('x'.repeat(10), 10)).toEqual({ text: 'x'.repeat(10), omitted: 0 });
});
it('keeps three quarters from the start and a quarter from the end around a marker', () => {
const text = 'a'.repeat(5000) + 'b'.repeat(5000);
const { text: out, omitted } = fitTextWindow(text, 4000);
expect(omitted).toBe(6000);
expect(out).toBe(`${'a'.repeat(3000)}\n[... 6000 characters omitted ...]\n${'b'.repeat(1000)}`);
});
it('cuts on a nearby line break, so a line at either cut stays whole', () => {
const lines = Array.from({ length: 400 }, (_, i) => `Line ${String(i).padStart(3, '0')}: Total paid USD 200.00`);
const text = lines.join('\n');
const { text: out, omitted } = fitTextWindow(text, 4000);
const [head, tail] = out.split(/\n\[\.\.\. \d+ characters omitted \.\.\.\]\n/);
for (const part of [head, tail]) for (const line of part.split('\n')) expect(lines).toContain(line);
expect(head.length + tail.length).toBeLessThanOrEqual(4000);
expect(head.length + tail.length + omitted).toBe(text.length);
expect(tail.endsWith(lines[lines.length - 1])).toBe(true);
});
it('cuts inside a line when no line break is close', () => {
const text = `${'a'.repeat(2000)}\n${'b'.repeat(8000)}`;
const { text: out } = fitTextWindow(text, 4000);
expect(out.startsWith(`${'a'.repeat(2000)}\n${'b'.repeat(999)}\n[...`)).toBe(true);
});
it('gives a cloud model a far wider window than a local one, and keeps the local ones', () => {
expect(CLOUD_TEXT_WINDOW).toBe(24_000);
expect(LOCAL_FLIGHT_TEXT_WINDOW).toBe(16_000);
expect(LOCAL_TEXT_WINDOW).toBe(6_000);
});
});