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( async function summarizeText(
client: OpenAI, client: OpenAI,
model: string, model: string,
@@ -892,7 +932,9 @@ async function assessTrafficLight(
scoringModel: SCORING_MODEL, scoringModel: SCORING_MODEL,
}; };
const completion = await createStructuredCompletion( const parsed = await createParsedStructuredCompletion<Partial<LlmScoringAssessment> & {
begruendung?: string;
}>(
client, client,
model, model,
[ [
@@ -970,22 +1012,10 @@ ${JSON.stringify(data, null, 2)}`,
}, },
required: ["begruendung", "ausschlussgruende", "dimensionen"], required: ["begruendung", "ausschlussgruende", "dimensionen"],
}, },
"Could not parse traffic light assessment JSON",
sessionId, sessionId,
{ operation: "scoring", label: "Ampelbewertung", onLlmCall }, { 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) const ausschlussgruende = Array.isArray(parsed.ausschlussgruende)
? parsed.ausschlussgruende.map((flag) => String(flag).trim()).filter(Boolean) ? parsed.ausschlussgruende.map((flag) => String(flag).trim()).filter(Boolean)
: []; : [];
@@ -1019,7 +1049,7 @@ async function segmentMultiQuestionAnswer(
return fragen; return fragen;
} }
const completion = await createStructuredCompletion( const parsed = await createParsedStructuredCompletion<{ fragen?: Array<Partial<FrageMitAntwort>> }>(
client, client,
model, model,
[ [
@@ -1074,23 +1104,10 @@ Vorgaben:
}, },
required: ["fragen"], required: ["fragen"],
}, },
`Could not parse answer segmentation JSON for ${fallbackLabel}`,
sessionId, sessionId,
{ operation: "segmentierung", label: fallbackLabel, onLlmCall }, { 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])); const byId = new Map((parsed.fragen ?? []).map((frage) => [String(frage.id ?? ""), frage]));
return fragen.map((frage) => { return fragen.map((frage) => {
@@ -1122,7 +1139,7 @@ async function analyzeSWOT(
sessionId?: string, sessionId?: string,
onLlmCall?: SummarizeCompanyOptions["onLlmCall"], onLlmCall?: SummarizeCompanyOptions["onLlmCall"],
): Promise<SWOTAnalyse> { ): Promise<SWOTAnalyse> {
const completion = await createStructuredCompletion( const parsed = await createParsedStructuredCompletion<Partial<SWOTAnalyse>>(
client, client,
model, model,
[ [
@@ -1161,20 +1178,10 @@ ${JSON.stringify(data, null, 2)}`,
}, },
required: ["staerken", "schwaechen", "chancen", "risiken"], required: ["staerken", "schwaechen", "chancen", "risiken"],
}, },
"Could not parse SWOT analysis JSON",
sessionId, sessionId,
{ operation: "swot", label: "SWOT-Analyse", onLlmCall }, { 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[] => const toStringArray = (value: unknown): string[] =>
Array.isArray(value) ? value.map((item) => String(item).trim()).filter(Boolean) : []; Array.isArray(value) ? value.map((item) => String(item).trim()).filter(Boolean) : [];