Retry malformed structured LLM outputs
This commit is contained in:
@@ -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) : [];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user