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(
|
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) : [];
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user