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'; import { describe, it, expect, vi, beforeEach } from 'vitest'; // LlmConfigResolver is constructor-injected — a stub instance instead of the // old path mock (same behaviors as before the DI move). const resolveLlmConfig = vi.fn(); const { createLlmClient, extract } = vi.hoisted(() => { const extract = vi.fn(); return { createLlmClient: vi.fn(() => ({ extract })), extract }; }); vi.mock('../../../../src/nest/llm-parse/llm-client.factory', () => ({ createLlmClient })); const { extractEnforced } = vi.hoisted(() => ({ extractEnforced: vi.fn() })); const { renderPdfPages } = vi.hoisted(() => ({ renderPdfPages: vi.fn() })); vi.mock('../../../../src/nest/llm-parse/image-input', async (orig) => { const actual = (await orig()) as Record; return { ...actual, renderPdfPages }; }); vi.mock('../../../../src/nest/llm-parse/router/ollama-format.client', async (orig) => { const actual = (await orig()) as Record; return { ...actual, extractEnforced }; }); const { extractText } = vi.hoisted(() => ({ extractText: vi.fn(async () => 'Flight AB123') })); vi.mock('../../../../src/nest/llm-parse/text-extract', async (orig) => { const actual = (await orig()) as Record; return { ...actual, extractText }; }); const { routeExtraction, routeImageExtraction, detectFlightNumbers } = vi.hoisted(() => ({ routeExtraction: vi.fn(), routeImageExtraction: vi.fn(), detectFlightNumbers: vi.fn(() => [] as string[]), })); // 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; return { ...actual, routeExtraction, routeImageExtraction, detectFlightNumbers }; }); const cfg = (over: Record = {}) => ({ provider: 'openai', model: 'm', vision: 'off', ...over }); const llmConfigStub = { resolve: resolveLlmConfig } as unknown as LlmConfigResolver; const modelCapabilities = vi.fn(); const svc = () => new LlmParseService( llmConfigStub, { isManaged: () => false } as unknown as RuntimeEnvService, { modelCapabilities } as unknown as LlmLocalService, ); const file = (name: string, body = 'Flight AB123') => ({ buffer: Buffer.from(body), originalName: name }); /** The first bytes of a PNG that claims the given size: enough for the pixel cap to refuse it. */ function pngClaiming(w: number, h: number): Buffer { const out = Buffer.alloc(33); Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(out, 0); out.writeUInt32BE(13, 8); out.write('IHDR', 12, 'latin1'); out.writeUInt32BE(w, 16); out.writeUInt32BE(h, 20); return out; } beforeEach(() => { vi.clearAllMocks(); resolveLlmConfig.mockReturnValue(cfg()); extract.mockResolvedValue([{ '@type': 'FlightReservation' }]); extractText.mockResolvedValue('Flight AB123'); detectFlightNumbers.mockReturnValue([]); routeExtraction.mockResolvedValue({ kiItems: [{ '@type': 'LodgingReservation' }], warnings: [] }); routeImageExtraction.mockResolvedValue({ kiItems: [{ '@type': 'TrainReservation' }], warnings: [] }); modelCapabilities.mockResolvedValue(null); renderPdfPages.mockResolvedValue([]); }); describe('LlmParseService', () => { it('isAvailable reflects whether a config resolves', async () => { resolveLlmConfig.mockReturnValueOnce(null); expect(await svc().isAvailable(1)).toBe(false); expect(await svc().isAvailable(1)).toBe(true); }); it('returns a not-configured warning when no config resolves', async () => { resolveLlmConfig.mockReturnValue(null); const res = await svc().parse(file('a.txt'), 1); expect(res.kiItems).toEqual([]); expect(res.warnings[0]).toMatch(/not configured/i); expect(extract).not.toHaveBeenCalled(); }); it('sends extracted text for a text-like file', async () => { const res = await svc().parse(file('a.txt'), 1); expect(res.kiItems).toEqual([{ '@type': 'FlightReservation' }]); const input = extract.mock.calls[0][0]; expect(input.text).toBe('Flight AB123'); expect(input.file).toBeUndefined(); }); it('extracts text for a pdf on the OpenAI-compatible/local path (no native bytes)', async () => { extractText.mockResolvedValue('Hotel X'); await svc().parse(file('a.pdf', '%PDF'), 1); const input = extract.mock.calls[0][0]; expect(input.text).toBe('Hotel X'); expect(input.file).toBeUndefined(); }); it('sends a pdf as native bytes only for Anthropic', async () => { resolveLlmConfig.mockReturnValue(cfg({ provider: 'anthropic' })); await svc().parse(file('a.pdf', '%PDF'), 1); const input = extract.mock.calls[0][0]; expect(input.file).toEqual({ mimeType: 'application/pdf', data: expect.any(Buffer) }); expect(input.text).toBeUndefined(); // 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 () => { extractText.mockResolvedValue(' '); const res = await svc().parse(file('a.pdf', '%PDF'), 1); expect(res.kiItems).toEqual([]); expect(res.warnings[0]).toMatch(/no readable text/i); expect(extract).not.toHaveBeenCalled(); }); it('folds flattened type fields into reservationFor (small-model output)', async () => { extract.mockResolvedValue([ { '@type': 'FlightReservation', reservationNumber: 'ABC', flightNumber: 'EZY1357', airline: { iataCode: 'EG' }, departureAirport: { iataCode: 'GEG' }, arrivalAirport: { iataCode: 'AMS' }, departureTime: '2026-06-11T10:00:00', }, ]); const res = await svc().parse(file('a.txt'), 1); const item = res.kiItems[0] as any; expect(item.reservationNumber).toBe('ABC'); expect(item.reservationFor).toMatchObject({ flightNumber: 'EZY1357', departureAirport: { iataCode: 'GEG' } }); // root-level keys are not duplicated into reservationFor expect(item.reservationFor.reservationNumber).toBeUndefined(); }); it('leaves already-nested reservationFor untouched', async () => { extract.mockResolvedValue([{ '@type': 'FlightReservation', reservationFor: { flightNumber: 'X1' } }]); const res = await svc().parse(file('a.txt'), 1); expect((res.kiItems[0] as any).reservationFor).toEqual({ flightNumber: 'X1' }); }); it('drops nodes without a string @type and warns', async () => { extract.mockResolvedValue([{ '@type': 'FlightReservation' }, { foo: 'bar' }]); const res = await svc().parse(file('a.txt'), 1); expect(res.kiItems).toEqual([{ '@type': 'FlightReservation' }]); expect(res.warnings.some((w) => /unrecognized/i.test(w))).toBe(true); }); it('degrades to a warning when the client throws', async () => { extract.mockRejectedValue(new Error('boom')); const res = await svc().parse(file('a.txt'), 1); expect(res.kiItems).toEqual([]); expect(res.warnings[0]).toMatch(/AI parsing failed/i); }); it('logs the swallowed client error to console.error', async () => { const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); extract.mockRejectedValue(new Error('boom')); await svc().parse(file('a.txt'), 1); expect(spy).toHaveBeenCalledWith(expect.stringContaining('[llm-parse]'), 'boom'); spy.mockRestore(); }); it('routes the local provider through the extraction router instead of the single-shot client', async () => { resolveLlmConfig.mockReturnValue(cfg({ provider: 'local', baseUrl: 'http://ollama:11434/v1', apiKey: 'k' })); extractText.mockResolvedValue('Hotel booking'); routeExtraction.mockResolvedValue({ kiItems: [{ '@type': 'LodgingReservation' }], warnings: ['note'] }); const res = await svc().parse(file('a.txt'), 1); expect(res.kiItems).toEqual([{ '@type': 'LodgingReservation' }]); expect(res.warnings).toEqual(['note']); expect(extract).not.toHaveBeenCalled(); expect(routeExtraction).toHaveBeenCalledWith('Hotel booking', { baseUrl: 'http://ollama:11434/v1', model: 'm', apiKey: 'k', }); }); it('keeps the wide text cap (16k) for a local flight itinerary but tightens it (6k) otherwise', async () => { const long = 'x'.repeat(7000); extractText.mockResolvedValue(long); resolveLlmConfig.mockReturnValue(cfg({ provider: 'local' })); detectFlightNumbers.mockReturnValue(['AB123']); await svc().parse(file('flights.txt'), 1); expect(routeExtraction.mock.calls[0][0]).toHaveLength(7000); // under the 16k cap, untouched vi.clearAllMocks(); resolveLlmConfig.mockReturnValue(cfg({ provider: 'local' })); extractText.mockResolvedValue(long); detectFlightNumbers.mockReturnValue([]); routeExtraction.mockResolvedValue({ kiItems: [], warnings: [] }); 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 () => { resolveLlmConfig.mockReturnValue(cfg({ provider: 'local' })); routeExtraction.mockRejectedValue(new Error('ollama down')); const res = await svc().parse(file('a.txt'), 1); expect(res.kiItems).toEqual([]); expect(res.warnings[0]).toMatch(/AI parsing failed/i); }); it('logs the swallowed router error to console.error', async () => { const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); resolveLlmConfig.mockReturnValue(cfg({ provider: 'local' })); routeExtraction.mockRejectedValue(new Error('ollama down')); await svc().parse(file('a.txt'), 1); expect(spy).toHaveBeenCalledWith(expect.stringContaining('[llm-parse]'), 'ollama down'); spy.mockRestore(); }); it('warns when the file cannot be read (text extraction throws)', async () => { extractText.mockRejectedValue(new Error('corrupt pdf')); const res = await svc().parse(file('a.pdf', '%PDF'), 1); expect(res.kiItems).toEqual([]); expect(res.warnings[0]).toMatch(/could not read file/i); 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 = {}) => ({ '@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); // 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'); }); 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]); }); }); describe('LlmParseService: a photo', () => { it('readsImages follows on and off whatever the provider, and never asks a server for them', async () => { resolveLlmConfig.mockReturnValue(cfg({ provider: 'local', vision: 'on' })); await expect(svc().readsImages(1)).resolves.toBe(true); resolveLlmConfig.mockReturnValue(cfg({ provider: 'local', vision: 'off' })); await expect(svc().readsImages(1)).resolves.toBe(false); resolveLlmConfig.mockReturnValue(cfg({ provider: 'openai', vision: 'on' })); await expect(svc().readsImages(1)).resolves.toBe(true); expect(modelCapabilities).not.toHaveBeenCalled(); }); it('readsImages on auto asks a local server, and answers no for a cloud provider', async () => { resolveLlmConfig.mockReturnValue( cfg({ provider: 'local', vision: 'auto', baseUrl: 'http://ollama:11434/v1', model: 'qwen3.5:4b', apiKey: 'proxy-key', }), ); modelCapabilities.mockResolvedValue(['completion', 'vision']); await expect(svc().readsImages(1)).resolves.toBe(true); // With the key the extraction sends, so an Ollama behind an auth proxy answers. expect(modelCapabilities).toHaveBeenCalledWith('http://ollama:11434/v1', 'qwen3.5:4b', 'proxy-key'); modelCapabilities.mockResolvedValue(['completion']); await expect(svc().readsImages(1)).resolves.toBe(false); modelCapabilities.mockResolvedValue(null); await expect(svc().readsImages(1)).resolves.toBe(false); modelCapabilities.mockClear(); for (const provider of ['openai', 'anthropic']) { resolveLlmConfig.mockReturnValue(cfg({ provider, vision: 'auto' })); await expect(svc().readsImages(1)).resolves.toBe(false); } expect(modelCapabilities).not.toHaveBeenCalled(); }); it('readsImages is false when AI parsing is not configured', async () => { resolveLlmConfig.mockReturnValue(null); await expect(svc().readsImages(1)).resolves.toBe(false); }); it('refuses a photo the model cannot read, without calling it', async () => { const res = await svc().parse(file('ticket.jpg', 'jpeg'), 1); expect(res.kiItems).toEqual([]); expect(res.warnings[0]).toMatch(/does not read images/); expect(extract).not.toHaveBeenCalled(); expect(extractText).not.toHaveBeenCalled(); }); it('sends a photo as an image to a cloud provider, with no text', async () => { for (const provider of ['openai', 'anthropic']) { extract.mockClear(); resolveLlmConfig.mockReturnValue(cfg({ provider, vision: 'on' })); await svc().parse(file('ticket.PNG', 'not really a png'), 1); const input = extract.mock.calls[0][0]; // Bytes that do not decode go as they came rather than being refused. expect(input.file).toEqual({ mimeType: 'image/png', data: Buffer.from('not really a png') }); expect(input.text).toBeUndefined(); } expect(extractText).not.toHaveBeenCalled(); }); it('sends a photo through the local router with the image attached', async () => { resolveLlmConfig.mockReturnValue(cfg({ provider: 'local', vision: 'on', baseUrl: 'http://ollama:11434/v1' })); const res = await svc().parse(file('ticket.jpeg', 'jpeg bytes'), 1); expect(res.kiItems).toEqual([{ '@type': 'TrainReservation' }]); expect(routeImageExtraction).toHaveBeenCalledWith( [Buffer.from('jpeg bytes')], expect.objectContaining({ baseUrl: 'http://ollama:11434/v1' }), ); expect(routeExtraction).not.toHaveBeenCalled(); expect(extract).not.toHaveBeenCalled(); }); it('turns a photo too large to decode into a warning, not a throw', async () => { resolveLlmConfig.mockReturnValue(cfg({ vision: 'on' })); const res = await svc().parse({ buffer: pngClaiming(20000, 20000), originalName: 'ticket.png' }, 1); expect(res.kiItems).toEqual([]); expect(res.warnings[0]).toMatch(/^ticket\.png: could not read file .*20000 x 20000 pixels/); expect(extract).not.toHaveBeenCalled(); }); it('leaves a PDF on its text path when the model reads images', async () => { resolveLlmConfig.mockReturnValue(cfg({ vision: 'on' })); await svc().parse(file('a.pdf', '%PDF'), 1); expect(extract.mock.calls[0][0].file).toBeUndefined(); expect(extractText).toHaveBeenCalled(); }); }); describe('LlmParseService.readReceipt', () => { const RECEIPT = { merchant: 'Bäckerei', date: '2026-09-20', total: 7.5, currency: 'EUR', items: [{ name: 'Brezel', price: 2.5 }], }; it('says why when AI parsing is not set up, the file is not a photo, or the model reads no images', async () => { resolveLlmConfig.mockReturnValue(null); expect((await svc().readReceipt(file('r.jpg'), 1)).warnings[0]).toMatch(/not configured/); resolveLlmConfig.mockReturnValue(cfg({ vision: 'on' })); expect(await svc().readReceipt(file('r.pdf'), 1)).toEqual({ receipt: null, warnings: ['r.pdf: not a photo'] }); resolveLlmConfig.mockReturnValue(cfg({ vision: 'off' })); expect((await svc().readReceipt(file('r.jpg'), 1)).warnings[0]).toMatch(/does not read images/); expect(extract).not.toHaveBeenCalled(); expect(extractEnforced).not.toHaveBeenCalled(); }); it("reads through Ollama's native chat on a local provider, with the photo and the receipt schema", async () => { resolveLlmConfig.mockReturnValue( cfg({ provider: 'local', vision: 'on', baseUrl: 'http://ollama:11434/v1', model: 'qwen3.5:4b' }), ); extractEnforced.mockResolvedValue(RECEIPT); await expect(svc().readReceipt(file('r.jpg', 'jpeg'), 1)).resolves.toEqual({ receipt: RECEIPT, warnings: [] }); const call = extractEnforced.mock.calls[0][0]; expect(call).toMatchObject({ baseUrl: 'http://ollama:11434/v1', model: 'qwen3.5:4b', images: [Buffer.from('jpeg').toString('base64')], }); expect(call.schema.required).toContain('total'); // The grammar holds Ollama to one flat receipt, and the prompt says the same. expect(call.system).toContain('Return ONLY a JSON object of the form { "merchant"'); expect(extract).not.toHaveBeenCalled(); }); it('asks a cloud client for a receipts list with the photo attached, and keeps the first', async () => { resolveLlmConfig.mockReturnValue(cfg({ provider: 'anthropic', vision: 'on' })); extract.mockResolvedValue([RECEIPT, { total: 1 }]); await expect(svc().readReceipt(file('r.png', 'png'), 1)).resolves.toEqual({ receipt: RECEIPT, warnings: [] }); const input = extract.mock.calls[0][0]; expect(input.rootKey).toBe('receipts'); expect(input.file).toEqual({ mimeType: 'image/png', data: Buffer.from('png') }); expect(input.text).toBeUndefined(); expect(input.prompt).toContain('Return ONLY a JSON object of the form { "receipts": ['); }); it('refuses a photo too large to decode with a warning, before asking the model', async () => { resolveLlmConfig.mockReturnValue(cfg({ vision: 'on' })); const res = await svc().readReceipt({ buffer: pngClaiming(20000, 20000), originalName: 'r.png' }, 1); expect(res).toEqual({ receipt: null, warnings: ['r.png: the photo is 20000 x 20000 pixels, more than the 40 megapixels TREK reads'], }); expect(extract).not.toHaveBeenCalled(); }); it('answers a warning, not a throw, when the model fails or reads nothing', async () => { resolveLlmConfig.mockReturnValue(cfg({ vision: 'on' })); extract.mockRejectedValue(new Error('429')); const failed = await svc().readReceipt(file('r.jpg'), 1); expect(failed.receipt).toBeNull(); expect(failed.warnings[0]).toMatch(/AI parsing failed — 429/); extract.mockResolvedValue([]); expect(await svc().readReceipt(file('r.jpg'), 1)).toEqual({ receipt: null, warnings: ['r.jpg: no receipt could be read'], }); }); }); describe('LlmParseService: a scanned PDF', () => { const page = (n: string) => ({ mimeType: 'image/jpeg', data: Buffer.from(n) }); beforeEach(() => extractText.mockResolvedValue(' ')); it('sends the pages as images when the text layer is empty and the model reads images', async () => { resolveLlmConfig.mockReturnValue(cfg({ vision: 'on' })); renderPdfPages.mockResolvedValue([page('p1'), page('p2')]); await svc().parse(file('scan.pdf', '%PDF'), 1); const input = extract.mock.calls[0][0]; expect(input.file).toEqual(page('p1')); expect(input.pageImages).toEqual([page('p2')]); expect(input.text).toBeUndefined(); }); it('hands the local router every page', async () => { resolveLlmConfig.mockReturnValue(cfg({ provider: 'local', vision: 'on' })); renderPdfPages.mockResolvedValue([page('p1'), page('p2')]); await svc().parse(file('scan.pdf', '%PDF'), 1); expect(routeImageExtraction).toHaveBeenCalledWith([Buffer.from('p1'), Buffer.from('p2')], expect.anything()); expect(routeExtraction).not.toHaveBeenCalled(); }); it('keeps the warning, and draws nothing, when the model reads no images', async () => { resolveLlmConfig.mockReturnValue(cfg({ vision: 'off' })); const res = await svc().parse(file('scan.pdf', '%PDF'), 1); expect(res.warnings[0]).toMatch(/no readable text found \(a scanned PDF needs a model that reads images\)/); expect(renderPdfPages).not.toHaveBeenCalled(); expect(extract).not.toHaveBeenCalled(); }); it('keeps the warning when no page could be drawn', async () => { resolveLlmConfig.mockReturnValue(cfg({ vision: 'on' })); const res = await svc().parse(file('scan.pdf', '%PDF'), 1); expect(res.warnings[0]).toMatch(/no readable text found/); expect(extract).not.toHaveBeenCalled(); }); it('does not draw an empty text file', async () => { resolveLlmConfig.mockReturnValue(cfg({ vision: 'on' })); await svc().parse(file('empty.txt', ''), 1); 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[]; 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[]; expect(first).toHaveProperty('price', 200); expect(second).not.toHaveProperty('price'); }); });