mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user