mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
Generated
+1
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user