diff --git a/client/src/components/BackgroundTasks/BackgroundTasksWidget.test.tsx b/client/src/components/BackgroundTasks/BackgroundTasksWidget.test.tsx new file mode 100644 index 000000000..3883a5f5e --- /dev/null +++ b/client/src/components/BackgroundTasks/BackgroundTasksWidget.test.tsx @@ -0,0 +1,45 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { screen } from '@testing-library/react' +import { render } from '../../../tests/helpers/render' +import { useBackgroundTasksStore, type BackgroundImportTask } from '../../store/backgroundTasksStore' +import BackgroundTasksWidget from './BackgroundTasksWidget' + +vi.mock('../../api/websocket', () => ({ addListener: vi.fn(), removeListener: vi.fn() })) +vi.mock('../../api/client', () => ({ + // Keep the rehydrate/poll backstops pending so the seeded state is what renders. + reservationsApi: { importJobStatus: vi.fn(() => new Promise(() => {})) }, +})) + +const task = (overrides: Partial = {}): BackgroundImportTask => ({ + id: 'j1', + tripId: 't1', + label: 'voucher.pdf', + status: 'done', + done: 0, + total: 1, + items: [], + warnings: [], + ...overrides, +}) + +beforeEach(() => { + vi.clearAllMocks() + useBackgroundTasksStore.setState({ tasks: [] }) +}) + +describe('BackgroundTasksWidget', () => { + it('shows the warnings when a finished job produced no items', () => { + const warning = 'voucher.pdf: AI parsing failed — LLM request failed (400): response_format unsupported' + useBackgroundTasksStore.setState({ tasks: [task({ warnings: [warning] })] }) + render() + expect(screen.getByText('No reservations could be extracted from the uploaded files.')).toBeInTheDocument() + expect(screen.getByText(warning)).toBeInTheDocument() + }) + + it('shows only the empty-preview note when there are no warnings', () => { + useBackgroundTasksStore.setState({ tasks: [task()] }) + render() + expect(screen.getByText('No reservations could be extracted from the uploaded files.')).toBeInTheDocument() + expect(screen.queryByText(/AI parsing failed/)).not.toBeInTheDocument() + }) +}) diff --git a/client/src/components/BackgroundTasks/BackgroundTasksWidget.tsx b/client/src/components/BackgroundTasks/BackgroundTasksWidget.tsx index b53a3e57a..977833e4e 100644 --- a/client/src/components/BackgroundTasks/BackgroundTasksWidget.tsx +++ b/client/src/components/BackgroundTasks/BackgroundTasksWidget.tsx @@ -136,7 +136,14 @@ export default function BackgroundTasksWidget() { {t('common.import')} ) : ( -
{t('reservations.import.previewEmpty')}
+
+ {t('reservations.import.previewEmpty')} + {(task.warnings?.length ?? 0) > 0 && ( +
+ {task.warnings!.join('\n')} +
+ )} +
) )} diff --git a/server/src/nest/llm-parse/clients/openai-compatible.client.ts b/server/src/nest/llm-parse/clients/openai-compatible.client.ts index 31ea8820a..c3352deec 100644 --- a/server/src/nest/llm-parse/clients/openai-compatible.client.ts +++ b/server/src/nest/llm-parse/clients/openai-compatible.client.ts @@ -18,6 +18,10 @@ const MAX_TOKENS = 4096; * template inlined in a single user message, no system prompt and no * `response_format` (see ./nuextract.ts) — that's how the fine-tune expects to * be driven; the generic instruct path applies to every other model. + * + * Structured output is requested as `json_schema` first; servers that only + * support `json_object` (DeepSeek, Mistral, some vLLM/llama.cpp) reject that + * with a 400, so the request is retried once in `json_object` mode. */ export class OpenAiCompatibleClient implements LlmExtractionClient { async extract(input: LlmExtractionInput): Promise[]> { @@ -39,7 +43,7 @@ export class OpenAiCompatibleClient implements LlmExtractionClient { }); } - const body = { + const baseBody = { model: input.model, max_tokens: MAX_TOKENS, // Extraction is a deterministic task — Ollama defaults to 0.7, which makes @@ -53,33 +57,24 @@ export class OpenAiCompatibleClient implements LlmExtractionClient { { role: 'system', content: input.prompt }, { role: 'user', content: userContent }, ], - ...(nuextract - ? {} - : { - response_format: { - type: 'json_schema' as const, - json_schema: { name: 'reservations', schema: input.jsonSchema, strict: false }, - }, - }), }; + const body = nuextract + ? baseBody + : { + ...baseBody, + response_format: { + type: 'json_schema' as const, + json_schema: { name: 'reservations', schema: input.jsonSchema, strict: false }, + }, + }; - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), TIMEOUT_MS); - let res: Response; - try { - // baseUrl is user-configurable — guard it against pointing at the cloud - // metadata endpoint, while still allowing a local/LAN Ollama. - res = await safeFetchLlm(url, { - method: 'POST', - signal: controller.signal, - headers: { - 'content-type': 'application/json', - ...(input.apiKey ? { authorization: `Bearer ${input.apiKey}` } : {}), - }, - body: JSON.stringify(body), - }); - } finally { - clearTimeout(timer); + let res = await this.send(url, body, input.apiKey); + // Servers that only support `json_object` (DeepSeek, Mistral, some + // vLLM/llama.cpp) reject `json_schema` with a 400 — retry once in + // `json_object` mode. The system prompt already dictates the exact output + // shape (and mentions JSON, which json_object mode requires). + if (!res.ok && res.status === 400 && !nuextract) { + res = await this.send(url, { ...baseBody, response_format: { type: 'json_object' as const } }, input.apiKey); } if (!res.ok) { @@ -93,6 +88,26 @@ export class OpenAiCompatibleClient implements LlmExtractionClient { const content = data.choices?.[0]?.message?.content; return nuextract ? parseNuExtract(content) : parseReservations(content); } + + private async send(url: string, body: unknown, apiKey?: string): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), TIMEOUT_MS); + try { + // baseUrl is user-configurable — guard it against pointing at the cloud + // metadata endpoint, while still allowing a local/LAN Ollama. + return await safeFetchLlm(url, { + method: 'POST', + signal: controller.signal, + headers: { + 'content-type': 'application/json', + ...(apiKey ? { authorization: `Bearer ${apiKey}` } : {}), + }, + body: JSON.stringify(body), + }); + } finally { + clearTimeout(timer); + } + } } /** Strip code fences and JSON.parse; `null` on failure. */ diff --git a/server/src/nest/llm-parse/llm-parse.service.ts b/server/src/nest/llm-parse/llm-parse.service.ts index 322ea08be..e2b7ce6ae 100644 --- a/server/src/nest/llm-parse/llm-parse.service.ts +++ b/server/src/nest/llm-parse/llm-parse.service.ts @@ -71,6 +71,7 @@ export class LlmParseService { } } } catch (err) { + console.error(`[llm-parse] Could not read "${file.originalName}":`, err instanceof Error ? err.message : err); return { kiItems: [], warnings: [`${file.originalName}: could not read file — ${err instanceof Error ? err.message : String(err)}`], @@ -90,6 +91,7 @@ export class LlmParseService { }); return { kiItems: routed.kiItems, warnings: [...warnings, ...routed.warnings] }; } catch (err) { + console.error(`[llm-parse] AI parsing failed for "${file.originalName}" (provider=${config.provider}):`, err instanceof Error ? err.message : err); return { kiItems: [], warnings: [`${file.originalName}: AI parsing failed — ${err instanceof Error ? err.message : String(err)}`], @@ -102,6 +104,7 @@ export class LlmParseService { raw = await createLlmClient(config).extract(input); console.debug('[DEBUG] Raw LLM Response: ', raw); } catch (err) { + console.error(`[llm-parse] AI parsing failed for "${file.originalName}" (provider=${config.provider}):`, err instanceof Error ? err.message : err); return { kiItems: [], warnings: [`${file.originalName}: AI parsing failed — ${err instanceof Error ? err.message : String(err)}`], diff --git a/server/tests/unit/nest/llm-parse/clients.test.ts b/server/tests/unit/nest/llm-parse/clients.test.ts index 961905a37..78562b2a2 100644 --- a/server/tests/unit/nest/llm-parse/clients.test.ts +++ b/server/tests/unit/nest/llm-parse/clients.test.ts @@ -55,6 +55,41 @@ describe('OpenAiCompatibleClient', () => { it('throws on non-2xx', async () => { mockFetch(() => jsonResponse({ error: 'bad' }, false, 401)); await expect(new OpenAiCompatibleClient().extract(baseInput)).rejects.toThrow(/401/); + expect(safeFetchLlmMock).toHaveBeenCalledTimes(1); // no retry on non-400 + }); + + 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)) + .mockResolvedValueOnce( + jsonResponse({ choices: [{ message: { content: '{"reservations":[{"@type":"FlightReservation"}]}' } }] }), + ); + const out = await new OpenAiCompatibleClient().extract(baseInput); + expect(out).toEqual([{ '@type': 'FlightReservation' }]); + expect(safeFetchLlmMock).toHaveBeenCalledTimes(2); + + const first = JSON.parse((safeFetchLlmMock.mock.calls[0][1] as RequestInit).body as string); + const second = JSON.parse((safeFetchLlmMock.mock.calls[1][1] as RequestInit).body as string); + expect(first.response_format.type).toBe('json_schema'); + expect(second.response_format).toEqual({ type: 'json_object' }); + expect(second.messages).toEqual(first.messages); + expect(second.model).toBe(first.model); + }); + + it('throws when the json_object retry also fails (400 twice)', async () => { + safeFetchLlmMock + .mockResolvedValueOnce(jsonResponse({ error: 'no json_schema' }, false, 400)) + .mockResolvedValueOnce(jsonResponse({ error: 'no json_object either' }, false, 400)); + await expect(new OpenAiCompatibleClient().extract(baseInput)).rejects.toThrow(/400/); + expect(safeFetchLlmMock).toHaveBeenCalledTimes(2); + }); + + it('does not retry the NuExtract path on 400', async () => { + safeFetchLlmMock.mockResolvedValueOnce(jsonResponse({ error: 'bad' }, false, 400)); + await expect( + new OpenAiCompatibleClient().extract({ ...baseInput, model: 'hf.co/numind/NuExtract-2.0-2B-GGUF:latest' }), + ).rejects.toThrow(/400/); + expect(safeFetchLlmMock).toHaveBeenCalledTimes(1); }); it('sends an image natively as image_url but never a file/pdf part', async () => { diff --git a/server/tests/unit/nest/llm-parse/llm-parse.service.test.ts b/server/tests/unit/nest/llm-parse/llm-parse.service.test.ts index c96b96fc4..56045320f 100644 --- a/server/tests/unit/nest/llm-parse/llm-parse.service.test.ts +++ b/server/tests/unit/nest/llm-parse/llm-parse.service.test.ts @@ -122,6 +122,14 @@ describe('LlmParseService', () => { 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'); @@ -159,6 +167,15 @@ describe('LlmParseService', () => { 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);