Retry malformed structured LLM outputs

This commit is contained in:
syntaxbullet
2026-06-17 15:29:48 +02:00
parent 6d192c48a1
commit a8b36ec2c7

View File

@@ -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<string, unknown> | 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<T>(
client: OpenAI,
model: string,
messages: Array<{ role: "system" | "user"; content: string }>,
schemaName: string,
schema: Record<string, unknown>,
context: string,
sessionId?: string,
trace?: {
operation: LlmCallTrace["operation"];
label: string;
onLlmCall?: SummarizeCompanyOptions["onLlmCall"];
},
): Promise<T> {
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<T>(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<Partial<LlmScoringAssessment> & {
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<string, unknown> | 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<Partial<LlmScoringAssessment> & {
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<Partial<FrageMitAntwort>> }>(
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<string, unknown> | 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<Partial<FrageMitAntwort>> }>(
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<SWOTAnalyse> {
const completion = await createStructuredCompletion(
const parsed = await createParsedStructuredCompletion<Partial<SWOTAnalyse>>(
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<string, unknown> | 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<Partial<SWOTAnalyse>>(content, "Could not parse SWOT analysis JSON");
const toStringArray = (value: unknown): string[] =>
Array.isArray(value) ? value.map((item) => String(item).trim()).filter(Boolean) : [];