fix(booking-import): say why a recognised booking could not be imported

A document the model read correctly could still come back as "No reservations
could be extracted". Every type mapper can answer null when the node carries no
reservationFor, or one with no name, and both call sites pushed the item only
if there was one — with no else. The unknown-@type branch next to them does
warn, so a node TREK did not recognise was reported while a node it recognised
but could not map vanished: no item, no warning, nothing in the log. Because
the file did produce a node, the "no reservations found" fallback was skipped
too, and the preview arrived empty and silent. Every recognised type could
disappear this way, on both the kitinerary and the AI path.

Each of them now warns, naming the file, the index, the type and the reason.

Three smaller silences went with it. The debug line labelled "Raw LLM Response"
printed the already-parsed array, so its inspect formatting — single quotes,
bare keys — read as a provider emitting broken JSON and sent this report at the
JSON parser, which has handled that shape since 4.0.0. A single reservation
answered on its own, with no array and no wrapper, was dropped by the list
unwrapper although the schema downstream would have taken it. And a response
nobody could read returned an empty list, which is the same answer as a voucher
with no booking in it; it now raises, on the Anthropic client too, where a
reply cut off at the token limit produced exactly this report's symptom. The
plugin AI surface keeps its old empty answer, since a value is what a plugin
contracts for.

The retry ladder gained a rung that sends no response_format at all, for
providers that support neither json_schema nor json_object.

Closes #2375
This commit is contained in:
Maurice
2026-09-16 13:01:05 +02:00
parent 2a7ad1e241
commit 7ccc8dc03d
10 changed files with 352 additions and 26 deletions
@@ -61,6 +61,7 @@ import { DbRpc } from '../../../src/nest/plugins/host/rpc/db.rpc';
import type { PluginUserSettingsService } from '../../../src/nest/plugins/plugin-user-settings.service';
import { MetaRpc } from '../../../src/nest/plugins/host/rpc/meta.rpc';
import { HostSurfaceRpc } from '../../../src/nest/plugins/host/rpc/host-surface.rpc';
import { UnreadableLlmResponse } from '../../../src/nest/llm-parse/clients/openai-compatible.client';
import { getPluginDataDb, closePluginDataDb } from '../../../src/nest/plugins/host/plugin-host-state';
import { db as mockDb } from '../../../src/db/database';
import { DatabaseService } from '../../../src/nest/database/database.service';
@@ -472,6 +473,27 @@ describe('HostSurfaceRpc — users, broadcasts, notify, ai, oauth, scheduler', (
expect((await call(makeHost('surface', 'ai:invoke'), 'ai.complete', { prompt: 'p' }, 5)).result).toEqual({ text: '' });
});
/*
* The clients raise on a response nothing could read so the booking import can
* warn per file (#2375). This surface answered an empty value for a model that
* talks prose long before that, and it is versioned on its own, so the plugin
* keeps the empty value — while a request that genuinely failed still has to
* reach it as an error.
*/
it('HOSTRPC-026a an unreadable answer stays empty here, a failed request does not (#2375)', async () => {
const host = makeHost('surface', 'ai:invoke');
llmExtract.mockRejectedValueOnce(new UnreadableLlmResponse('the model did not answer with JSON: Sure, happy to help.'));
expect((await call(host, 'ai.complete', { prompt: 'p' }, 5)).result).toEqual({ text: '' });
llmExtract.mockRejectedValueOnce(new UnreadableLlmResponse('the model returned an empty response'));
expect((await call(host, 'ai.extract', { text: 't', jsonSchema: { type: 'object' } }, 5)).result).toEqual({ results: [] });
llmExtract.mockRejectedValueOnce(new Error('LLM request failed (503): upstream is down'));
const failed = await call(host, 'ai.complete', { prompt: 'p' }, 5);
expect(failed.error?.code).toBe('HOST_ERROR');
expect(failed.error?.message).toMatch(/503/);
});
it('HOSTRPC-027 oauth.getToken returns only the acting user\'s access token', async () => {
const host = makeHost('surface', 'oauth:client');
expect((await call(host, 'oauth.getToken', {}, 5)).result).toEqual({ accessToken: 'tok-5' });