fix(llm-parse): tolerate non-strict JSON from Gemini and similar models

Gemini reached through the OpenAI-compatible endpoint returns
JavaScript-object-literal output — single-quoted strings, unquoted keys,
trailing commas — which strict JSON.parse rejects. The reservation list
then came back empty and the booking-import UI showed nothing (#1638).

Add a shared parseLenientJson() that tries strict JSON first and falls
back to JSON5 (which accepts exactly that relaxed superset), and route
both the OpenAI-compatible and Ollama-native parse paths through it,
replacing the two identical strict parsers.
This commit is contained in:
fbnlrz
2026-07-21 22:44:39 +02:00
committed by Maurice
parent 791468e5b3
commit 9ad3507e57
7 changed files with 105 additions and 26 deletions
+1
View File
@@ -21232,6 +21232,7 @@
"fast-xml-parser": "^5.5.10",
"helmet": "^8.1.0",
"jimp": "^1.6.1",
"json5": "^2.2.3",
"jsonwebtoken": "^9.0.2",
"node-cron": "^4.2.1",
"nodemailer": "^9.0.1",
+1
View File
@@ -40,6 +40,7 @@
"fast-xml-parser": "^5.5.10",
"helmet": "^8.1.0",
"jimp": "^1.6.1",
"json5": "^2.2.3",
"jsonwebtoken": "^9.0.2",
"node-cron": "^4.2.1",
"nodemailer": "^9.0.1",
@@ -1,5 +1,6 @@
import type { LlmExtractionClient, LlmExtractionInput } from '../llm-provider.interface';
import { isNuExtractModel, buildNuExtractUserText, nuExtractToKiReservations } from './nuextract';
import { parseLenientJson } from '../lenient-json';
import { safeFetchLlm } from '../../../utils/ssrfGuard';
// Generous: a local CPU model (Ollama, no GPU) may cold-load several GB and then
@@ -110,27 +111,16 @@ export class OpenAiCompatibleClient implements LlmExtractionClient {
}
}
/** Strip code fences and JSON.parse; `null` on failure. */
function parseJson(content: string | undefined | null): unknown {
if (!content) return null;
const stripped = content.trim().replace(/^```(?:json)?/i, '').replace(/```$/, '').trim();
try {
return JSON.parse(stripped);
} catch {
return null;
}
}
/** Parse a NuExtract response and map its flat template output to KiReservation nodes. */
function parseNuExtract(content: string | undefined | null): Record<string, unknown>[] {
return nuExtractToKiReservations(parseJson(content));
return nuExtractToKiReservations(parseLenientJson(content));
}
const USER_TEXT = 'Extract every travel reservation from the following document as schema.org JSON-LD.';
/** Tolerant parse: strip code fences, JSON.parse, pull `reservations`. `[]` on failure. */
/** Tolerant parse: strip code fences, JSON(5).parse, pull `reservations`. `[]` on failure. */
function parseReservations(content: string | undefined | null): Record<string, unknown>[] {
const parsed = parseJson(content);
const parsed = parseLenientJson(content);
if (Array.isArray(parsed)) return parsed as Record<string, unknown>[];
if (parsed && typeof parsed === 'object' && Array.isArray((parsed as { reservations?: unknown }).reservations)) {
return (parsed as { reservations: Record<string, unknown>[] }).reservations;
+31
View File
@@ -0,0 +1,31 @@
import JSON5 from 'json5';
/**
* Parse LLM output that is *meant* to be JSON but may not be strict JSON.
*
* Cloud providers reached through the OpenAI-compatible endpoint don't all honour
* `response_format` faithfully — Gemini in particular emits JavaScript-object-literal
* text: single-quoted strings, unquoted keys, and trailing commas (#1638), e.g.
*
* [ { '@type': 'LodgingReservation', checkinTime: '2026-08-28T00:00:00', price: 146.25, } ]
*
* Strict `JSON.parse` throws on all three, so the reservation list came back empty and
* the UI showed nothing. We try strict JSON first (the common, cheapest path) and fall
* back to JSON5, which accepts exactly that relaxed superset. Returns `null` on failure.
*
* The leading/trailing code-fence strip stays here because some models still wrap the
* payload in a ```json fence even when asked for raw JSON.
*/
export function parseLenientJson(content: string | undefined | null): unknown {
if (!content) return null;
const stripped = content.trim().replace(/^```(?:json)?/i, '').replace(/```$/, '').trim();
try {
return JSON.parse(stripped);
} catch {
try {
return JSON5.parse(stripped);
} catch {
return null;
}
}
}
@@ -11,6 +11,7 @@
* tool/response_format and keep using the existing clients.)
*/
import { parseLenientJson } from '../lenient-json';
import { safeFetchLlm } from '../../../utils/ssrfGuard';
const TIMEOUT_MS = 300_000;
@@ -34,17 +35,6 @@ export function toNativeBase(baseUrl: string): string {
return baseUrl.replace(/\/+$/, '').replace(/\/v1$/, '');
}
/** Strip code fences and JSON.parse; returns null on failure. */
function parseJson(content: string | undefined | null): unknown {
if (!content) return null;
const stripped = content.trim().replace(/^```(?:json)?/i, '').replace(/```$/, '').trim();
try {
return JSON.parse(stripped);
} catch {
return null;
}
}
/**
* Run one schema-constrained chat completion against Ollama's native `/api/chat`.
* Returns the parsed JSON object (constrained to `schema`), or null if the request
@@ -94,6 +84,6 @@ export async function extractEnforced(input: EnforcedExtractInput): Promise<Reco
}
const data = (await res.json()) as { message?: { content?: string } };
const parsed = parseJson(data.message?.content);
const parsed = parseLenientJson(data.message?.content);
return parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : null;
}
@@ -47,6 +47,23 @@ describe('OpenAiCompatibleClient', () => {
expect(out).toEqual([{ '@type': 'TrainReservation' }]);
});
it('tolerates single-quoted, non-strict JSON output (Gemini, #1638)', async () => {
mockFetch(() =>
jsonResponse({
choices: [
{
message: {
content:
"[{ '@type': 'LodgingReservation', checkinTime: '2026-08-28T00:00:00', price: 146.25, }]",
},
},
],
}),
);
const out = await new OpenAiCompatibleClient().extract(baseInput);
expect(out).toEqual([{ '@type': 'LodgingReservation', checkinTime: '2026-08-28T00:00:00', price: 146.25 }]);
});
it('returns [] on malformed content', async () => {
mockFetch(() => jsonResponse({ choices: [{ message: { content: 'not json' } }] }));
expect(await new OpenAiCompatibleClient().extract(baseInput)).toEqual([]);
@@ -0,0 +1,49 @@
import { describe, it, expect } from 'vitest';
import { parseLenientJson } from '../../../../src/nest/llm-parse/lenient-json';
describe('parseLenientJson', () => {
it('parses strict JSON', () => {
expect(parseLenientJson('{"a":1,"b":"x"}')).toEqual({ a: 1, b: 'x' });
});
it('strips a ```json code fence', () => {
expect(parseLenientJson('```json\n[{"@type":"FlightReservation"}]\n```')).toEqual([
{ '@type': 'FlightReservation' },
]);
});
it('parses single-quoted, unquoted-key, trailing-comma output (Gemini, #1638)', () => {
const gemini = `[
{
'@type': 'LodgingReservation',
checkinTime: '2026-08-28T00:00:00',
checkoutTime: '2026-08-30T11:00:00',
price: 146.25,
priceCurrency: 'EUR',
}
]`;
expect(parseLenientJson(gemini)).toEqual([
{
'@type': 'LodgingReservation',
checkinTime: '2026-08-28T00:00:00',
checkoutTime: '2026-08-30T11:00:00',
price: 146.25,
priceCurrency: 'EUR',
},
]);
});
it('parses a code-fenced non-strict object', () => {
expect(parseLenientJson("```\n{ reservations: [{ '@type': 'TrainReservation' }] }\n```")).toEqual({
reservations: [{ '@type': 'TrainReservation' }],
});
});
it('returns null on empty or truly unparseable input', () => {
expect(parseLenientJson('')).toBeNull();
expect(parseLenientJson(null)).toBeNull();
expect(parseLenientJson(undefined)).toBeNull();
expect(parseLenientJson('this is prose, not json')).toBeNull();
});
});