mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 07:26:30 +00:00
214984f398
Adds an in-app assistant that reads and plans the open trip. Users connect their own provider (Sign in with ChatGPT, or an Anthropic, OpenAI, Gemini or OpenRouter key) or use the one the admin set up, including a custom OpenAI-compatible server. It is an addon and only shows while a connection is usable. The chat runs on the existing MCP tools, pinned to the trip. Every change is held as a change card the user applies or discards, and applied changes can be undone. On the desktop it is a floating window that can be moved and resized; on the phone it is fullscreen. Search results show as place cards that can go onto a day, weather lookups as a strip, proposed places as ghost markers on the map with a before/after switch, and an answer can be shared into the trip chat. Booking files can be dropped in and are read into change cards.
702 lines
32 KiB
TypeScript
702 lines
32 KiB
TypeScript
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 readInstanceEndpoint = 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<string, unknown>;
|
|
return { ...actual, renderPdfPages };
|
|
});
|
|
vi.mock('../../../../src/nest/llm-parse/router/ollama-format.client', async (orig) => {
|
|
const actual = (await orig()) as Record<string, unknown>;
|
|
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<string, unknown>;
|
|
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<string, unknown>;
|
|
return { ...actual, routeExtraction, routeImageExtraction, detectFlightNumbers };
|
|
});
|
|
|
|
const cfg = (over: Record<string, unknown> = {}) => ({ provider: 'openai', model: 'm', vision: 'off', ...over });
|
|
const llmConfigStub = { resolve: resolveLlmConfig, readInstanceEndpoint } 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('hands the AI assistant the parsing endpoint the resolver reads', async () => {
|
|
readInstanceEndpoint.mockResolvedValueOnce({ provider: 'openai', model: 'gpt-x', baseUrl: undefined });
|
|
expect(await svc().instanceEndpoint()).toEqual({ provider: 'openai', model: 'gpt-x', baseUrl: undefined });
|
|
});
|
|
|
|
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<string, unknown> = {}) => ({
|
|
'@type': 'LodgingReservation',
|
|
checkinTime: '2026-09-06T13:00:00',
|
|
checkoutTime: '2026-09-07T11:00:00',
|
|
price: 89.35,
|
|
priceCurrency: 'EURials',
|
|
reservationFor: { name: 'Harbour View Inn', address: 'Example Road 1' },
|
|
...over,
|
|
});
|
|
const PRINT = ['Harbour View Inn', 'PREIS', '1 Zimmer € 74,46', 'Preis', '(für 2 Gäste)', '€ 89,35'].join('\n');
|
|
|
|
it('collapses exact duplicate nodes from one AI answer', async () => {
|
|
extractText.mockResolvedValue(PRINT);
|
|
extract.mockResolvedValue([stay(), stay()]);
|
|
const res = await svc().parse(file('Bestätigung_1.pdf', '%PDF'), 1);
|
|
expect(res.kiItems).toHaveLength(1);
|
|
expect(res.warnings).toEqual([]);
|
|
});
|
|
|
|
it("collapses the reporter's two nodes without a venue into one as well", async () => {
|
|
extractText.mockResolvedValue(PRINT);
|
|
const bare = {
|
|
'@type': 'LodgingReservation',
|
|
checkinTime: '2026-09-06T13:00:00',
|
|
checkoutTime: '2026-09-07T11:00:00',
|
|
price: 89.35,
|
|
priceCurrency: 'EURials',
|
|
};
|
|
extract.mockResolvedValue([bare, { ...bare }]);
|
|
const res = await svc().parse(file('Bestätigung_1.pdf', '%PDF'), 1);
|
|
expect(res.kiItems).toEqual([{ ...bare, priceCurrency: 'EUR' }]);
|
|
});
|
|
|
|
it('treats the same fields in another key order as the same node', async () => {
|
|
const a = stay();
|
|
const b = {
|
|
reservationFor: { address: 'Example Road 1', name: 'Harbour View Inn' },
|
|
priceCurrency: 'EURials',
|
|
price: 89.35,
|
|
checkoutTime: a.checkoutTime,
|
|
checkinTime: a.checkinTime,
|
|
'@type': a['@type'],
|
|
};
|
|
extract.mockResolvedValue([a, b]);
|
|
const res = await svc().parse(file('a.txt'), 1);
|
|
expect(res.kiItems).toHaveLength(1);
|
|
});
|
|
|
|
it('keeps distinct nodes that only look alike', async () => {
|
|
extract.mockResolvedValue([
|
|
stay(),
|
|
stay({ checkinTime: '2026-09-07T13:00:00', checkoutTime: '2026-09-08T11:00:00' }),
|
|
]);
|
|
const res = await svc().parse(file('a.txt'), 1);
|
|
expect(res.kiItems).toHaveLength(2);
|
|
});
|
|
|
|
it('turns a garbled priceCurrency into the code it starts with', async () => {
|
|
extract.mockResolvedValue([stay()]);
|
|
const res = await svc().parse(file('a.txt'), 1);
|
|
expect(res.kiItems[0].priceCurrency).toBe('EUR');
|
|
});
|
|
|
|
it("falls back to the document's currency symbol when the model named no currency", async () => {
|
|
extractText.mockResolvedValue(PRINT);
|
|
extract.mockResolvedValue([
|
|
stay({ priceCurrency: 'ZZZ' }),
|
|
stay({ priceCurrency: undefined, checkinTime: '2026-10-01T15:00:00' }),
|
|
]);
|
|
const res = await svc().parse(file('b.pdf', '%PDF'), 1);
|
|
expect(res.kiItems.map((n) => n.priceCurrency)).toEqual(['EUR', 'EUR']);
|
|
});
|
|
|
|
it('drops the currency when neither the model nor the document names one', async () => {
|
|
extractText.mockResolvedValue('Harbour View Inn, 89.35 total');
|
|
extract.mockResolvedValue([stay({ priceCurrency: 'ZZZ' })]);
|
|
const res = await svc().parse(file('b.txt'), 1);
|
|
expect(res.kiItems[0]).not.toHaveProperty('priceCurrency');
|
|
expect(res.kiItems[0].price).toBe(89.35);
|
|
});
|
|
|
|
it('asks the document only when the node has a price', async () => {
|
|
extractText.mockResolvedValue(PRINT);
|
|
extract.mockResolvedValue([stay({ price: undefined, priceCurrency: 'Pesos' })]);
|
|
const res = await svc().parse(file('b.pdf', '%PDF'), 1);
|
|
expect(res.kiItems[0]).not.toHaveProperty('priceCurrency');
|
|
});
|
|
|
|
it('has no document to ask on the native PDF path', async () => {
|
|
resolveLlmConfig.mockReturnValue(cfg({ provider: 'anthropic' }));
|
|
extract.mockResolvedValue([stay({ priceCurrency: 'ZZZ' })]);
|
|
const res = await svc().parse(file('b.pdf', '%PDF'), 1);
|
|
// 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<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');
|
|
});
|
|
});
|