From a8b36ec2c769161e1b8bde9e4985df6cab4c6b57 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Wed, 17 Jun 2026 15:29:48 +0200 Subject: [PATCH] Retry malformed structured LLM outputs --- packages/summarizer/summarizer.ts | 89 +++++++++++++++++-------------- 1 file changed, 48 insertions(+), 41 deletions(-) diff --git a/packages/summarizer/summarizer.ts b/packages/summarizer/summarizer.ts index eb19798..2bc2d3e 100644 --- a/packages/summarizer/summarizer.ts +++ b/packages/summarizer/summarizer.ts @@ -318,6 +318,46 @@ async function createStructuredCompletion( } } +function completionContent(completion: unknown, context: string): string { + const value = completion as { choices?: Array<{ finish_reason?: unknown; message?: unknown }> }; + const msg = value.choices?.[0]?.message as Record | undefined; + const content = (msg?.content ?? msg?.reasoning) as string | null | undefined; + if (!content?.trim()) { + throw new Error(`${context}: model returned no content (finish_reason: ${value.choices?.[0]?.finish_reason}).`); + } + return content; +} + +async function createParsedStructuredCompletion( + client: OpenAI, + model: string, + messages: Array<{ role: "system" | "user"; content: string }>, + schemaName: string, + schema: Record, + context: string, + sessionId?: string, + trace?: { + operation: LlmCallTrace["operation"]; + label: string; + onLlmCall?: SummarizeCompanyOptions["onLlmCall"]; + }, +): Promise { + let lastError: unknown; + for (let attempt = 1; attempt <= MAX_LLM_RETRY_ATTEMPTS; attempt += 1) { + try { + const completion = await createStructuredCompletion(client, model, messages, schemaName, schema, sessionId, trace); + return parseLlmJson(completionContent(completion, context), context); + } catch (error) { + lastError = error; + if (attempt >= MAX_LLM_RETRY_ATTEMPTS) { + throw error; + } + await sleep(retryDelayMs(attempt)); + } + } + throw lastError; +} + async function summarizeText( client: OpenAI, model: string, @@ -892,7 +932,9 @@ async function assessTrafficLight( scoringModel: SCORING_MODEL, }; - const completion = await createStructuredCompletion( + const parsed = await createParsedStructuredCompletion & { + begruendung?: string; + }>( client, model, [ @@ -970,22 +1012,10 @@ ${JSON.stringify(data, null, 2)}`, }, required: ["begruendung", "ausschlussgruende", "dimensionen"], }, + "Could not parse traffic light assessment JSON", sessionId, { operation: "scoring", label: "Ampelbewertung", onLlmCall }, ); - - const msg = completion.choices[0]?.message as Record | undefined; - const content = (msg?.content ?? msg?.reasoning) as string | null | undefined; - - if (!content?.trim()) { - throw new Error( - `Model returned no content for traffic light assessment (finish_reason: ${completion.choices[0]?.finish_reason}).`, - ); - } - - const parsed = parseLlmJson & { - begruendung?: string; - }>(content, "Could not parse traffic light assessment JSON"); const ausschlussgruende = Array.isArray(parsed.ausschlussgruende) ? parsed.ausschlussgruende.map((flag) => String(flag).trim()).filter(Boolean) : []; @@ -1019,7 +1049,7 @@ async function segmentMultiQuestionAnswer( return fragen; } - const completion = await createStructuredCompletion( + const parsed = await createParsedStructuredCompletion<{ fragen?: Array> }>( client, model, [ @@ -1074,23 +1104,10 @@ Vorgaben: }, required: ["fragen"], }, + `Could not parse answer segmentation JSON for ${fallbackLabel}`, sessionId, { operation: "segmentierung", label: fallbackLabel, onLlmCall }, ); - - const msg = completion.choices[0]?.message as Record | undefined; - const content = (msg?.content ?? msg?.reasoning) as string | null | undefined; - - if (!content?.trim()) { - throw new Error( - `Model returned no content for answer segmentation (finish_reason: ${completion.choices[0]?.finish_reason}).`, - ); - } - - const parsed = parseLlmJson<{ fragen?: Array> }>( - content, - `Could not parse answer segmentation JSON for ${fallbackLabel}`, - ); const byId = new Map((parsed.fragen ?? []).map((frage) => [String(frage.id ?? ""), frage])); return fragen.map((frage) => { @@ -1122,7 +1139,7 @@ async function analyzeSWOT( sessionId?: string, onLlmCall?: SummarizeCompanyOptions["onLlmCall"], ): Promise { - const completion = await createStructuredCompletion( + const parsed = await createParsedStructuredCompletion>( client, model, [ @@ -1161,20 +1178,10 @@ ${JSON.stringify(data, null, 2)}`, }, required: ["staerken", "schwaechen", "chancen", "risiken"], }, + "Could not parse SWOT analysis JSON", sessionId, { operation: "swot", label: "SWOT-Analyse", onLlmCall }, ); - - const msg = completion.choices[0]?.message as Record | undefined; - const content = (msg?.content ?? msg?.reasoning) as string | null | undefined; - - if (!content?.trim()) { - throw new Error( - `Model returned no content for SWOT analysis (finish_reason: ${completion.choices[0]?.finish_reason}).`, - ); - } - - const parsed = parseLlmJson>(content, "Could not parse SWOT analysis JSON"); const toStringArray = (value: unknown): string[] => Array.isArray(value) ? value.map((item) => String(item).trim()).filter(Boolean) : [];