Files
bmp-project/packages/summarizer/summarizer.ts
2026-06-17 15:39:04 +02:00

1595 lines
51 KiB
TypeScript

import { existsSync } from "node:fs";
import { readdir } from "node:fs/promises";
import { join } from "node:path";
import OpenAI from "openai";
import * as XLSX from "xlsx";
import { z } from "zod";
import { SCORING_MODEL, type Ampelfarbe } from "./scoring-model";
import {
calculateScoringResult,
deriveTrafficLight,
type LlmScoringAssessment,
type ScoringDimension,
} from "./scoring";
import {
buildLlmDossier,
createPseudonymizer,
type LlmDossier,
type PrivacyAudit,
type Pseudonymizer,
} from "./privacy";
import { AI_DISCLAIMER_TEXT, AI_DISCLAIMER_TITLE, writeJuryReport } from "./report";
import { isSafeStem } from "../../security";
import { dataPath } from "../../deployPaths";
export interface FrageMitAntwort {
id?: string;
text: string;
antwort?: string;
confidence?: number;
abgedeckt?: boolean;
quelle?: "pdf_direkt" | "llm_segmentierung";
}
export interface SegmentierungsQualitaet {
durchschnitt_confidence: number;
abgedeckte_fragen: number;
gesamt_fragen: number;
niedriges_vertrauen: boolean;
}
export interface AntwortEintrag {
fieldName?: string;
label?: string;
antwortFormat?: "einzelfrage" | "mehrfachfrage_ein_antwortfeld";
zuordnungsmodus?: "einzelfrage" | "gemeinsame_antwort" | "llm_segmentiert";
fragen?: FrageMitAntwort[];
antwort?: string;
zusammenfassung?: string;
segmentierung?: SegmentierungsQualitaet;
}
export interface ExtractedData {
kontakt?: Record<string, unknown>;
unternehmen?: Record<string, unknown>;
fragen?: Record<string, AntwortEintrag>;
kriterium?: Record<string, AntwortEintrag>;
[key: string]: unknown;
}
export interface Ampelbewertung {
farbe: Ampelfarbe;
gesamtScore?: number;
begruendung: string;
ausschlussgruende?: string[];
unbewertbareKriterien?: number;
missingDataWarnings?: string[];
dimensionen?: ScoringDimension[];
}
export interface SWOTAnalyse {
staerken: string[];
schwaechen: string[];
chancen: string[];
risiken: string[];
}
export interface SummaryData extends ExtractedData {
fragen?: Record<string, AntwortEintrag>;
kriterium?: Record<string, AntwortEintrag>;
ampelbewertung?: Ampelbewertung;
swot_analyse?: SWOTAnalyse;
_privacy?: PrivacyAudit;
_schemaVersion?: number;
_summarizedAt?: string;
}
export type LlmCallStatus = "running" | "done" | "error";
export interface LlmCallTrace {
id: string;
operation: "segmentierung" | "zusammenfassung" | "scoring" | "swot";
label: string;
model: string;
attempt: number;
maxAttempts: number;
status: LlmCallStatus;
startedAt: string;
finishedAt?: string;
durationMs?: number;
request: Record<string, unknown>;
response?: Record<string, unknown>;
error?: string;
}
export interface SummarizeCompanyOptions {
onLlmCall?: (trace: LlmCallTrace) => void;
onCompanyInput?: (stem: string, input: ExtractedData) => void;
onCompanyOutput?: (stem: string, output: SummaryData) => void;
}
const OUTPUTS_DIR =
process.env.BMP_EXTRACTOR_OUTPUTS_DIR ??
dataPath(join(import.meta.dir, "../extractor/outputs"), "extractor", "outputs");
const XLSX_TEMPLATE_PATH = join(import.meta.dir, "template.xlsx");
const OPENROUTER_PRIVACY_PROVIDER = {
data_collection: "deny",
zdr: true,
} as const;
const MAX_LLM_RETRY_ATTEMPTS = Math.max(1, Number(process.env.BMP_LLM_RETRY_ATTEMPTS ?? 3));
const LLM_RETRY_BASE_DELAY_MS = Math.max(0, Number(process.env.BMP_LLM_RETRY_BASE_DELAY_MS ?? 750));
const frageMitAntwortSchema = z.object({
id: z.string(),
text: z.string(),
antwort: z.string(),
confidence: z.number().min(0).max(1),
}).strict();
const segmentierungResponseSchema = z.object({
fragen: z.array(frageMitAntwortSchema),
}).strict();
const scoringSubcriterionSchema = z.object({
id: z.string(),
farbe: z.enum(["gruen", "gelb", "rot", "unbewertbar"]),
evidence: z.string(),
begruendung: z.string(),
confidence: z.number().min(0).max(1),
missingReason: z.string(),
}).strict();
const scoringResponseSchema = z.object({
begruendung: z.string().optional(),
ausschlussgruende: z.array(z.string()),
dimensionen: z.array(z.object({
id: z.string(),
subcriteria: z.array(scoringSubcriterionSchema),
}).strict()),
}).strict();
const swotResponseSchema = z.object({
staerken: z.array(z.string()),
schwaechen: z.array(z.string()),
chancen: z.array(z.string()),
risiken: z.array(z.string()),
}).strict();
function createClient(): OpenAI {
const apiKey = process.env.OPENROUTER_API_KEY;
if (!apiKey) throw new Error("OPENROUTER_API_KEY is not set in environment");
return new OpenAI({
baseURL: "https://openrouter.ai/api/v1",
apiKey,
defaultHeaders: {
"HTTP-Referer": "https://github.com/bmp-rewrite",
"X-Title": "bmp-rewrite summarizer",
},
});
}
function buildQuestionContext(entry: AntwortEintrag, fallbackLabel: string): string {
const frageText = (entry.fragen ?? []).map((frage, index) => `${index + 1}. ${frage.text}`).join("\n");
const label = entry.label?.trim() || fallbackLabel;
return frageText ? `${label}\nZugehörige Fragen:\n${frageText}` : label;
}
function jsonSchemaFormat(name: string, schema: Record<string, unknown>) {
return {
type: "json_schema",
json_schema: {
name,
strict: true,
schema,
},
} as never;
}
function serializeCompletion(completion: unknown): Record<string, unknown> {
const value = completion as {
id?: unknown;
model?: unknown;
created?: unknown;
usage?: unknown;
choices?: Array<{ finish_reason?: unknown; message?: unknown }>;
};
return {
id: value.id,
model: value.model,
created: value.created,
usage: value.usage,
choices: value.choices?.map((choice) => ({
finish_reason: choice.finish_reason,
message: choice.message,
})),
};
}
function beginLlmTrace(
onLlmCall: SummarizeCompanyOptions["onLlmCall"] | undefined,
operation: LlmCallTrace["operation"],
label: string,
model: string,
request: Record<string, unknown>,
attempt: number,
maxAttempts: number,
): LlmCallTrace {
const trace: LlmCallTrace = {
id: crypto.randomUUID(),
operation,
label,
model,
attempt,
maxAttempts,
status: "running",
startedAt: new Date().toISOString(),
request,
};
onLlmCall?.(trace);
return trace;
}
function finishLlmTrace(
onLlmCall: SummarizeCompanyOptions["onLlmCall"] | undefined,
trace: LlmCallTrace,
result: { response?: unknown; error?: unknown },
): void {
const finishedAt = new Date();
const startedAt = new Date(trace.startedAt);
onLlmCall?.({
...trace,
status: result.error ? "error" : "done",
finishedAt: finishedAt.toISOString(),
durationMs: finishedAt.getTime() - startedAt.getTime(),
response: result.response ? serializeCompletion(result.response) : undefined,
error: result.error ? String(result.error) : undefined,
});
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function retryDelayMs(attempt: number): number {
if (LLM_RETRY_BASE_DELAY_MS <= 0) return 0;
const jitter = Math.floor(Math.random() * Math.min(250, LLM_RETRY_BASE_DELAY_MS));
return LLM_RETRY_BASE_DELAY_MS * 2 ** Math.max(0, attempt - 1) + jitter;
}
function shouldRetryLlmError(error: unknown): boolean {
const status = (error as { status?: unknown; code?: unknown })?.status;
if (typeof status === "number") {
return status === 408 || status === 409 || status === 425 || status === 429 || status >= 500;
}
const message = String(error).toLowerCase();
return [
"timeout",
"timed out",
"rate limit",
"temporarily",
"econnreset",
"fetch failed",
"socket",
"overloaded",
"provider returned error",
].some((needle) => message.includes(needle));
}
async function createCompletionWithRetry(
client: OpenAI,
request: Record<string, unknown>,
trace: {
operation: LlmCallTrace["operation"];
label: string;
model: string;
onLlmCall?: SummarizeCompanyOptions["onLlmCall"];
} | undefined,
) {
let lastError: unknown;
for (let attempt = 1; attempt <= MAX_LLM_RETRY_ATTEMPTS; attempt += 1) {
const llmTrace = trace
? beginLlmTrace(trace.onLlmCall, trace.operation, trace.label, trace.model, request, attempt, MAX_LLM_RETRY_ATTEMPTS)
: undefined;
try {
const completion = await client.chat.completions.create(request as never);
if (llmTrace) finishLlmTrace(trace?.onLlmCall, llmTrace, { response: completion });
return completion;
} catch (error) {
lastError = error;
if (llmTrace) finishLlmTrace(trace?.onLlmCall, llmTrace, { error });
if (attempt >= MAX_LLM_RETRY_ATTEMPTS || !shouldRetryLlmError(error)) {
throw error;
}
await sleep(retryDelayMs(attempt));
}
}
throw lastError;
}
async function createStructuredCompletion(
client: OpenAI,
model: string,
messages: Array<{ role: "system" | "user"; content: string }>,
schemaName: string,
schema: Record<string, unknown>,
sessionId?: string,
trace?: {
operation: LlmCallTrace["operation"];
label: string;
onLlmCall?: SummarizeCompanyOptions["onLlmCall"];
},
) {
const structuredRequest = {
model,
response_format: jsonSchemaFormat(schemaName, schema),
messages,
provider: OPENROUTER_PRIVACY_PROVIDER,
usage: { include: true },
session_id: sessionId,
};
const primaryTrace = trace
? { ...trace, model }
: undefined;
try {
const completion = await createCompletionWithRetry(client, structuredRequest as Record<string, unknown>, primaryTrace);
return completion;
} catch (error) {
const message = String(error);
if (!message.includes("400") && !message.toLowerCase().includes("provider returned error")) {
throw error;
}
const fallbackRequest = {
model,
response_format: { type: "json_object" } as never,
messages,
provider: OPENROUTER_PRIVACY_PROVIDER,
usage: { include: true },
session_id: sessionId,
};
const fallbackTrace = trace
? { ...trace, label: `${trace.label} (JSON fallback)`, model }
: undefined;
return createCompletionWithRetry(client, fallbackRequest as Record<string, unknown>, fallbackTrace);
}
}
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>,
validator: z.ZodType<T>,
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);
const content = completionContent(completion, context);
try {
return parseAndValidateLlmJson(content, context, validator);
} catch (error) {
lastError = error;
return await correctStructuredJson(
client,
model,
content,
error,
schemaName,
schema,
validator,
context,
sessionId,
trace,
);
}
} 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,
text: string,
context: string,
sessionId?: string,
trace?: {
label: string;
onLlmCall?: SummarizeCompanyOptions["onLlmCall"];
},
): Promise<string> {
const request = {
model,
messages: [
{
role: "system",
content:
"Du bist ein präziser Assistent, der Unternehmensantworten aus Bewerbungsunterlagen auf Deutsch verdichtet. Erstelle eine kurze, prägnante Zusammenfassung, die zwingend kürzer als die Originalantwort ist. Nenne nur Informationen, die in der Originalantwort ausdrücklich enthalten sind. Füge keine Details, Kennzahlen, Bewertungen, Schlussfolgerungen oder Interpretationen hinzu, die nicht explizit genannt werden. Behalte die wichtigsten Aussagen bei, streiche Redundanzen und bleibe sachlich nah am Original.",
},
{
role: "user",
content: `Kontext:\n${context}\n\nFasse die folgende Antwort zusammen:\n\n${text}`,
},
],
provider: OPENROUTER_PRIVACY_PROVIDER,
usage: { include: true },
session_id: sessionId,
};
const llmTrace = trace
? { operation: "zusammenfassung" as const, label: trace.label, model, onLlmCall: trace.onLlmCall }
: undefined;
const completion = await createCompletionWithRetry(client, request as Record<string, unknown>, llmTrace);
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 (finish_reason: ${completion.choices[0]?.finish_reason}). Try a non-reasoning model such as openai/gpt-4o-mini.`,
);
}
return content.trim();
}
function extractJsonObject(text: string): string {
const trimmed = text.trim();
if (trimmed.startsWith("```")) {
const match = trimmed.match(/```(?:json)?\s*([\s\S]*?)\s*```/i);
if (match?.[1]) return match[1].trim();
}
return trimmed;
}
function escapeInvalidJsonBackslashes(text: string): string {
return text.replace(/\\(?:u(?![0-9a-fA-F]{4})|(?!["\\/bfnrtu]))/g, "\\\\");
}
function parseLlmJson<T>(content: string, context: string): T {
const json = extractJsonObject(content);
try {
return JSON.parse(json) as T;
} catch (error) {
const repaired = escapeInvalidJsonBackslashes(json);
if (repaired !== json) {
try {
return JSON.parse(repaired) as T;
} catch {
// Fall through to the contextual error below.
}
}
const message = error instanceof Error ? error.message : String(error);
throw new SyntaxError(`${context}: ${message}`);
}
}
function validationMessage(error: unknown): string {
if (error instanceof z.ZodError) {
return error.issues
.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`)
.join("\n");
}
return errorMessage(error);
}
function parseAndValidateLlmJson<T>(content: string, context: string, validator: z.ZodType<T>): T {
const parsed = parseLlmJson<unknown>(content, context);
const result = validator.safeParse(parsed);
if (!result.success) {
throw new SyntaxError(`${context}: ${validationMessage(result.error)}`);
}
return result.data;
}
async function correctStructuredJson<T>(
client: OpenAI,
model: string,
invalidContent: string,
validationError: unknown,
schemaName: string,
schema: Record<string, unknown>,
validator: z.ZodType<T>,
context: string,
sessionId?: string,
trace?: {
operation: LlmCallTrace["operation"];
label: string;
onLlmCall?: SummarizeCompanyOptions["onLlmCall"];
},
): Promise<T> {
const correctionContext = `${context} correction`;
const completion = await createStructuredCompletion(
client,
model,
[
{
role: "system",
content:
"Du reparierst fehlerhafte JSON-Ausgaben. Antworte ausschliesslich mit gueltigem JSON, das dem Schema entspricht. Erfinde keine neuen Informationen.",
},
{
role: "user",
content: `Die folgende JSON-Ausgabe konnte nicht verarbeitet werden.
Fehler:
${validationMessage(validationError)}
Erwartetes JSON Schema:
${JSON.stringify(schema, null, 2)}
Fehlerhafte Ausgabe:
${invalidContent}
Korrigiere nur Syntax, Typen, fehlende Pflichtfelder und enum-Werte. Antworte ausschliesslich mit dem korrigierten JSON.`,
},
],
`${schemaName}_correction`,
schema,
sessionId,
trace ? { ...trace, label: `${trace.label} (JSON-Korrektur)` } : undefined,
);
return parseAndValidateLlmJson(completionContent(completion, correctionContext), correctionContext, validator);
}
function summarizeSegmentierungsQualitaet(fragen: FrageMitAntwort[] | undefined): SegmentierungsQualitaet | undefined {
const items = fragen ?? [];
if (!items.length) return undefined;
const covered = items.filter((frage) => frage.abgedeckt).length;
const confidences = items
.map((frage) => frage.confidence)
.filter((value): value is number => typeof value === "number");
const average = confidences.length
? confidences.reduce((sum, value) => sum + value, 0) / confidences.length
: 0;
const lowConfidenceCount = items.filter((frage) => (frage.confidence ?? 0) < 0.5).length;
return {
durchschnitt_confidence: Number(average.toFixed(2)),
abgedeckte_fragen: covered,
gesamt_fragen: items.length,
niedriges_vertrauen: average < 0.6 || lowConfidenceCount > Math.floor(items.length / 2),
};
}
function formatQuestionAnswers(fragen: FrageMitAntwort[] | undefined): string {
return (fragen ?? [])
.map((frage, index) => {
const titel = frage.id || `frage_${index + 1}`;
const antwort = frage.antwort?.trim() || "";
const confidence = typeof frage.confidence === "number"
? ` (Confidence: ${frage.confidence.toFixed(2)})`
: "";
const status = frage.abgedeckt === false ? " [nicht abgedeckt]" : "";
return antwort ? `${titel}${confidence}${status}: ${antwort}` : status ? `${titel}${confidence}${status}` : "";
})
.filter(Boolean)
.join("\n\n");
}
function formatValue(value: unknown): string {
if (value == null) return "";
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean") return String(value);
if (Array.isArray(value)) return value.map((item) => formatValue(item)).join("; ");
return JSON.stringify(value);
}
function prettifyKey(key: string): string {
return key
.replaceAll("_", " ")
.replace(/([a-zäöüß])([A-Z])/g, "$1 $2")
.replace(/^./, (char) => char.toUpperCase());
}
function appendSectionRows(
rows: Array<Array<string>>,
titel: string,
data: Record<string, unknown> | undefined,
): void {
rows.push([titel, "", ""]);
if (!data) {
rows.push(["", "Keine Daten", ""]);
rows.push(["", "", ""]);
return;
}
for (const [key, value] of Object.entries(data)) {
rows.push(["", prettifyKey(key), formatValue(value)]);
}
rows.push(["", "", ""]);
}
interface UnterfrageLayout {
bereich: "fragen" | "kriterium";
key: string;
frageId: string;
kategorie: string;
unterkategorie: string;
}
const UNTERFRAGE_LAYOUT: UnterfrageLayout[] = [
{
bereich: "fragen",
key: "frage1",
frageId: "frage1_1",
kategorie: "ALLGEMEIN:K1",
unterkategorie: "Organisationale Kernkompetenz: K1a",
},
{
bereich: "fragen",
key: "frage2",
frageId: "frage2_1",
kategorie: "ALLGEMEIN",
unterkategorie: "Leistungsorientierung & Erfolgsmessung: K1B",
},
{
bereich: "fragen",
key: "frage3",
frageId: "frage3_1",
kategorie: "ALLGEMEIN",
unterkategorie: "Geschäftsmodell-Transformation: K1C",
},
{
bereich: "kriterium",
key: "robustheit_resilienz",
frageId: "robustheit_resilienz_1",
kategorie: "RESILIENZ",
unterkategorie: "Krisenmanagement & Reaktionsfähigkeit: K2A",
},
{
bereich: "kriterium",
key: "robustheit_resilienz",
frageId: "robustheit_resilienz_2",
kategorie: "RESILIENZ",
unterkategorie: "Risikomanagement & Frühwarnsystem: K2B",
},
{
bereich: "kriterium",
key: "robustheit_resilienz",
frageId: "robustheit_resilienz_3",
kategorie: "RESILIENZ",
unterkategorie: "Stakeholder-Management & Kommunikation: K3B",
},
{
bereich: "kriterium",
key: "robustheit_resilienz",
frageId: "robustheit_resilienz_4",
kategorie: "RESILIENZ",
unterkategorie: "Finanzielle Stabilität & Liquiditätsmanagement: K4B",
},
{
bereich: "kriterium",
key: "robustheit_resilienz",
frageId: "robustheit_resilienz_5",
kategorie: "RESILIENZ",
unterkategorie: "Regionale Vernetzung & Standortsicherung: K5B",
},
{
bereich: "kriterium",
key: "zukunftsfaehigkeit_innovation",
frageId: "zukunftsfaehigkeit_innovation_1",
kategorie: "INNOVATION & ZUKUNFTSFÄHIGKEIT",
unterkategorie: "Kundenorientierte Innovationsziele: K1C",
},
{
bereich: "kriterium",
key: "zukunftsfaehigkeit_innovation",
frageId: "zukunftsfaehigkeit_innovation_2",
kategorie: "INNOVATION & ZUKUNFTSFÄHIGKEIT",
unterkategorie: "Trendmonitoring & Experimentierkultur: K2C",
},
{
bereich: "kriterium",
key: "zukunftsfaehigkeit_innovation",
frageId: "zukunftsfaehigkeit_innovation_3",
kategorie: "INNOVATION & ZUKUNFTSFÄHIGKEIT",
unterkategorie: "Produkt- & Prozessinnovation: K3C",
},
{
bereich: "kriterium",
key: "zukunftsfaehigkeit_innovation",
frageId: "zukunftsfaehigkeit_innovation_4",
kategorie: "INNOVATION & ZUKUNFTSFÄHIGKEIT",
unterkategorie: "Innovationsnetzwerke & Partnerintegration: K4C",
},
{
bereich: "kriterium",
key: "zukunftsfaehigkeit_innovation",
frageId: "zukunftsfaehigkeit_innovation_5",
kategorie: "INNOVATION & ZUKUNFTSFÄHIGKEIT",
unterkategorie: "Wissensmanagement & Wissensinfrastruktur: K5C",
},
{
bereich: "kriterium",
key: "nachhaltigkeit_verantwortung",
frageId: "nachhaltigkeit_verantwortung_1",
kategorie: "NACHHALTIGKEIT & VERANTWORTUNG",
unterkategorie: "Ökologische Ressourceneffizienz: K6A",
},
{
bereich: "kriterium",
key: "nachhaltigkeit_verantwortung",
frageId: "nachhaltigkeit_verantwortung_2",
kategorie: "NACHHALTIGKEIT & VERANTWORTUNG",
unterkategorie: "Regionale Lieferkette & Corporate Social Responsibility:K6B",
},
{
bereich: "kriterium",
key: "nachhaltigkeit_verantwortung",
frageId: "nachhaltigkeit_verantwortung_3",
kategorie: "NACHHALTIGKEIT & VERANTWORTUNG",
unterkategorie: "Standortbindung & Regionalentwicklung: K6C",
},
{
bereich: "kriterium",
key: "attraktivitaet",
frageId: "attraktivitaet_1",
kategorie: "ARBEITGEBERATTRAKTIVITÄT",
unterkategorie: "Mitarbeitermotivation & Durchhaltevermögen: K7A",
},
{
bereich: "kriterium",
key: "attraktivitaet",
frageId: "attraktivitaet_2",
kategorie: "ARBEITGEBERATTRAKTIVITÄT",
unterkategorie: "Sinn & Purpose-Orientierung: K7B",
},
{
bereich: "kriterium",
key: "attraktivitaet",
frageId: "attraktivitaet_3",
kategorie: "ARBEITGEBERATTRAKTIVITÄT",
unterkategorie: "Personalentwicklung & Weiterbildung: K7C",
},
{
bereich: "kriterium",
key: "attraktivitaet",
frageId: "attraktivitaet_4",
kategorie: "ARBEITGEBERATTRAKTIVITÄT",
unterkategorie: "Employer Branding & Recruiting: K7D",
},
{
bereich: "kriterium",
key: "attraktivitaet",
frageId: "attraktivitaet_5",
kategorie: "ARBEITGEBERATTRAKTIVITÄT",
unterkategorie: "Unternehmenskultur & Kulturmessung: K7E",
},
];
function getAntwortEintrag(
data: SummaryData,
bereich: "fragen" | "kriterium",
key: string,
): AntwortEintrag | undefined {
return bereich === "fragen" ? data.fragen?.[key] : data.kriterium?.[key];
}
function buildStammdatenRows(data: SummaryData): string[][] {
const kontakt = (data.kontakt as Record<string, unknown> | undefined) ?? {};
const kontaktUnternehmen = (kontakt.unternehmen as Record<string, unknown> | undefined) ?? {};
const ansprechpartner = (kontakt.ansprechpartner as Record<string, unknown> | undefined) ?? {};
const unternehmensdaten = (data.unternehmen as Record<string, unknown> | undefined) ?? {};
const rows: string[][] = [["Bereich", "Feld", "Wert"]];
appendSectionRows(rows, "Kontakt", kontaktUnternehmen);
appendSectionRows(rows, "Ansprechpartner", ansprechpartner);
appendSectionRows(rows, "Unternehmensdaten", unternehmensdaten);
return rows;
}
function buildKategorienRows(data: SummaryData): string[][] {
const rows: string[][] = [["Label", "Fragen", "Originale Antwort", "KI - Zusammenfassung"]];
for (const value of Object.values(data.fragen ?? {})) {
rows.push([
value?.label ?? "",
(value?.fragen ?? []).map((frage) => frage.text).join("\n"),
value?.antwort ?? "",
value?.zusammenfassung ?? "",
]);
}
for (const value of Object.values(data.kriterium ?? {})) {
rows.push([
value?.label ?? "",
(value?.fragen ?? []).map((frage) => frage.text).join("\n"),
value?.antwort ?? "",
value?.zusammenfassung ?? "",
]);
}
return rows;
}
function buildFragenUndAntwortenRows(data: SummaryData): string[][] {
const rows: string[][] = [[
"Kategorie",
"Unterkategorie",
"Frage",
"Zugeordnete Orginale Antworten (Mehrfachfragen KI Segmentiert)",
]];
for (const layout of UNTERFRAGE_LAYOUT) {
const entry = getAntwortEintrag(data, layout.bereich, layout.key);
const frage = entry?.fragen?.find((item) => item.id === layout.frageId);
rows.push([
layout.kategorie,
layout.unterkategorie,
frage?.text ?? "",
frage?.antwort ?? "",
]);
}
return rows;
}
function buildAnalysenRows(data: SummaryData): string[][] {
const ampel = data.ampelbewertung;
return [
["Bereich", "Wert"],
["Ampelfarbe", ampel?.farbe ?? ""],
["Begruendung", ampel?.begruendung ?? ""],
["Ausschlussgruende", (ampel?.ausschlussgruende ?? []).join("; ")],
["Unbewertbare Kriterien", ampel?.unbewertbareKriterien == null ? "" : String(ampel.unbewertbareKriterien)],
["Fehlende Daten", (ampel?.missingDataWarnings ?? []).join("; ")],
["", ""],
["SWOT - Staerken", (data.swot_analyse?.staerken ?? []).join("; ")],
["SWOT - Schwaechen", (data.swot_analyse?.schwaechen ?? []).join("; ")],
["SWOT - Chancen", (data.swot_analyse?.chancen ?? []).join("; ")],
["SWOT - Risiken", (data.swot_analyse?.risiken ?? []).join("; ")],
];
}
function buildScoringRows(data: SummaryData): string[][] {
const rows: string[][] = [[
"Dimension",
"Dimension Gewicht (%)",
"Dimension Score",
"Subkriterium",
"Indikator",
"Subkriterium Gewicht (%)",
"Farbe",
"Score",
"Gewichteter Score",
"Confidence",
"Evidenz",
"Begruendung",
"Fehlende Daten",
]];
for (const dimension of data.ampelbewertung?.dimensionen ?? []) {
for (const subcriterion of dimension.subcriteria) {
rows.push([
dimension.name,
String(dimension.weight),
String(dimension.score),
subcriterion.name,
subcriterion.indikator,
String(subcriterion.weight),
subcriterion.farbe,
subcriterion.score == null ? "" : String(subcriterion.score),
String(subcriterion.weightedScore),
String(subcriterion.confidence),
subcriterion.evidence,
subcriterion.begruendung,
subcriterion.missingReason,
]);
}
}
return rows;
}
function buildDisclaimerRows(): string[][] {
return [
[AI_DISCLAIMER_TITLE],
[AI_DISCLAIMER_TEXT],
];
}
async function writeTemplateWorkbook(data: SummaryData, xlsxPath: string): Promise<boolean> {
if (!existsSync(XLSX_TEMPLATE_PATH)) {
return false;
}
const ExcelJS = await import("exceljs");
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.readFile(XLSX_TEMPLATE_PATH);
const populateWorksheet = (sheetName: string, rows: string[][]) => {
const worksheet = workbook.getWorksheet(sheetName) ?? workbook.addWorksheet(sheetName);
const templateRowCount = worksheet.rowCount;
const templateColumnCount = worksheet.columnCount;
const rowCount = Math.max(templateRowCount, rows.length);
const colCount = Math.max(
templateColumnCount,
rows.reduce((max, row) => Math.max(max, row.length), 0),
);
for (let rowIndex = 1; rowIndex <= rowCount; rowIndex += 1) {
if (rowIndex > templateRowCount && templateRowCount > 0) {
const sourceRow = worksheet.getRow(templateRowCount);
const targetRow = worksheet.getRow(rowIndex);
if (sourceRow.height != null) {
targetRow.height = sourceRow.height;
}
}
for (let colIndex = 1; colIndex <= colCount; colIndex += 1) {
const cell = worksheet.getCell(rowIndex, colIndex);
if ((rowIndex > templateRowCount || colIndex > templateColumnCount) && templateRowCount > 0 && templateColumnCount > 0) {
const sourceCell = worksheet.getCell(
Math.min(rowIndex, templateRowCount),
Math.min(colIndex, templateColumnCount),
);
cell.style = structuredClone(sourceCell.style);
}
cell.value = rows[rowIndex - 1]?.[colIndex - 1] ?? "";
}
}
};
populateWorksheet("Stammdaten", buildStammdatenRows(data));
populateWorksheet("Kategorien", buildKategorienRows(data));
populateWorksheet("Fragen und Antworten", buildFragenUndAntwortenRows(data));
populateWorksheet("Analysen", buildAnalysenRows(data));
populateWorksheet("Scoring", buildScoringRows(data));
populateWorksheet("Disclaimer", buildDisclaimerRows());
await workbook.xlsx.writeFile(xlsxPath);
return true;
}
function createWorkbook(data: SummaryData): XLSX.WorkBook {
const workbook = XLSX.utils.book_new();
const stammdatenSheet = XLSX.utils.aoa_to_sheet(buildStammdatenRows(data));
stammdatenSheet["!cols"] = [{ wch: 22 }, { wch: 28 }, { wch: 177.33 }];
XLSX.utils.book_append_sheet(workbook, stammdatenSheet, "Stammdaten");
const kategorienSheet = XLSX.utils.aoa_to_sheet(buildKategorienRows(data));
kategorienSheet["!cols"] = [{ wch: 25.93 }, { wch: 255.29 }, { wch: 255.29 }, { wch: 255.29 }];
XLSX.utils.book_append_sheet(workbook, kategorienSheet, "Kategorien");
const fragenSheet = XLSX.utils.aoa_to_sheet(buildFragenUndAntwortenRows(data));
fragenSheet["!cols"] = [{ wch: 28 }, { wch: 28 }, { wch: 255.29 }, { wch: 255.29 }];
XLSX.utils.book_append_sheet(workbook, fragenSheet, "Fragen und Antworten");
const analysenSheet = XLSX.utils.aoa_to_sheet(buildAnalysenRows(data));
analysenSheet["!cols"] = [{ wch: 24 }, { wch: 254.83 }];
XLSX.utils.book_append_sheet(workbook, analysenSheet, "Analysen");
const scoringSheet = XLSX.utils.aoa_to_sheet(buildScoringRows(data));
scoringSheet["!cols"] = [
{ wch: 24 },
{ wch: 18 },
{ wch: 16 },
{ wch: 30 },
{ wch: 30 },
{ wch: 18 },
{ wch: 14 },
{ wch: 12 },
{ wch: 18 },
{ wch: 12 },
{ wch: 80 },
{ wch: 80 },
{ wch: 50 },
];
XLSX.utils.book_append_sheet(workbook, scoringSheet, "Scoring");
const disclaimerSheet = XLSX.utils.aoa_to_sheet(buildDisclaimerRows());
disclaimerSheet["!cols"] = [{ wch: 140 }];
XLSX.utils.book_append_sheet(workbook, disclaimerSheet, "Disclaimer");
return workbook;
}
function buildScoringFallbackBegruendung(
farbe: Ampelfarbe,
scoring: ReturnType<typeof calculateScoringResult>,
ausschlussgruende: string[],
): string {
if (ausschlussgruende.length) {
return `Rot wegen automatischem Ausschluss: ${ausschlussgruende.join("; ")}.`;
}
const strongestDimensions = scoring.dimensionen
.toSorted((a, b) => b.score - a.score)
.slice(0, 2)
.map((dimension) => `${dimension.name} (${dimension.score})`)
.join(", ");
const missing = scoring.unbewertbareKriterien
? ` ${scoring.unbewertbareKriterien} Subkriterien waren mangels belastbarer Angaben unbewertbar.`
: "";
const meaning = farbe === "gruen"
? "fuer den weiteren Vergleich geeignet"
: "mit begruendeten Zweifeln fuer die weitere Betrachtung";
return `${farbe} (${meaning}). Staerkste Bereiche: ${strongestDimensions || "keine"}.${missing}`;
}
function fallbackScoringAssessment(reason: string): z.infer<typeof scoringResponseSchema> {
return {
begruendung: `Die automatische Detailbewertung konnte nicht vollstaendig ausgewertet werden: ${reason}`,
ausschlussgruende: [],
dimensionen: SCORING_MODEL.map((dimension) => ({
id: dimension.id,
subcriteria: dimension.subcriteria.map((subcriterion) => ({
id: subcriterion.id,
farbe: "unbewertbar",
evidence: "",
begruendung: "",
confidence: 0,
missingReason: `Modellantwort konnte nach ${MAX_LLM_RETRY_ATTEMPTS} Versuchen nicht als gueltiges JSON gelesen werden.`,
})),
})),
};
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
async function assessTrafficLight(
client: OpenAI,
model: string,
data: LlmDossier,
sessionId?: string,
onLlmCall?: SummarizeCompanyOptions["onLlmCall"],
): Promise<Ampelbewertung> {
const scoringInstructions = {
ausschlussgruende: [
"Inkubator / Grosskonzern-Tochter: Nicht Inhaber-gefuehrt bzw. staatlich",
"Stiftung als Bewerber: Stiftungen duerfen sich nicht bewerben",
"Zu kleines Unternehmen: Arbeitsbedingungen nicht Bayern-praegend bei sehr kleinen Betrieben",
],
scoringModel: SCORING_MODEL,
};
const messages: Array<{ role: "system" | "user"; content: string }> = [
{
role: "system",
content:
"Du bewertest Bewerbungen fuer einen Unternehmenspreis anhand eines gewichteten Kriterienmodells. Antworte ausschliesslich auf Deutsch und ausschliesslich als JSON.",
},
{
role: "user",
content: `Bewerte die folgende Bewerbung nach dem bereitgestellten Ampel- und Gewichtungsmodell.
Modell:
${JSON.stringify(scoringInstructions, null, 2)}
Vorgehen:
- Pruefe zuerst die automatischen Ausschlussgruende. Nenne nur Ausschlussgruende, die aus den Daten belastbar erkennbar sind.
- Bewerte danach jedes Subkriterium des scoringModel mit genau einer Farbe: gruen, gelb, rot oder unbewertbar.
- Verwende unbewertbar, wenn die Daten fuer ein Subkriterium fehlen oder zu uneindeutig sind. Erfinde keine Kennzahlen.
- Nutze die Schwellenwerte als Bewertungsrahmen. Wenn qualitative Angaben klar einem Schwellenwert entsprechen, darfst du qualitativ bewerten.
- Wichtig: rot auf Ebene der Gesamtbewertung bedeutet ausschliesslich "Bewerbung verwerfen" und wird nachgelagert nur bei automatischen Ausschlussgruenden vergeben. Niedrige oder fehlende Scores ohne Ausschlussgrund fuehren zu gelb, nicht rot.
- evidence muss eine kurze, konkrete Fundstelle oder Zusammenfassung aus den Bewerbungsdaten sein. Bei unbewertbar bleibt evidence leer.
- confidence muss zwischen 0 und 1 liegen.
- missingReason ist bei unbewertbar Pflicht, sonst leer.
- Die Gesamtfarbe und Gewichtung werden nachgelagert berechnet; liefere nur die Faktorbewertungen und eine kurze Gesamtbegruendung.
Wichtige Vorgaben:
- Antworte ausschliesslich als JSON.
- Verwende genau die Felder begruendung, ausschlussgruende und dimensionen.
- Gib jede Dimension und jedes Subkriterium aus dem Modell mit der jeweiligen id zurueck.
- Die begruendung soll kurz, konkret und auf Deutsch sein.
- Pruefe nur auf Basis der vorliegenden Daten.
Bewerbungsdaten:
${JSON.stringify(data, null, 2)}`,
},
];
let parsed: z.infer<typeof scoringResponseSchema>;
try {
parsed = await createParsedStructuredCompletion<z.infer<typeof scoringResponseSchema>>(
client,
model,
messages,
"ampelbewertung",
{
type: "object",
additionalProperties: false,
properties: {
begruendung: { type: "string" },
ausschlussgruende: {
type: "array",
items: { type: "string" },
},
dimensionen: {
type: "array",
items: {
type: "object",
additionalProperties: false,
properties: {
id: { type: "string" },
subcriteria: {
type: "array",
items: {
type: "object",
additionalProperties: false,
properties: {
id: { type: "string" },
farbe: { type: "string", enum: ["gruen", "gelb", "rot", "unbewertbar"] },
evidence: { type: "string" },
begruendung: { type: "string" },
confidence: { type: "number" },
missingReason: { type: "string" },
},
required: ["id", "farbe", "evidence", "begruendung", "confidence", "missingReason"],
},
},
},
required: ["id", "subcriteria"],
},
},
},
required: ["begruendung", "ausschlussgruende", "dimensionen"],
},
scoringResponseSchema,
"Could not parse traffic light assessment JSON",
sessionId,
{ operation: "scoring", label: "Ampelbewertung", onLlmCall },
);
} catch (error) {
parsed = fallbackScoringAssessment(errorMessage(error));
}
const ausschlussgruende = Array.isArray(parsed.ausschlussgruende)
? parsed.ausschlussgruende.map((flag) => String(flag).trim()).filter(Boolean)
: [];
const scoring = calculateScoringResult(SCORING_MODEL, {
ausschlussgruende,
dimensionen: Array.isArray(parsed.dimensionen) ? parsed.dimensionen : [],
});
const farbe = deriveTrafficLight(scoring, ausschlussgruende);
return {
farbe,
gesamtScore: scoring.gesamtScore,
begruendung: String(parsed.begruendung ?? "").trim() || buildScoringFallbackBegruendung(farbe, scoring, ausschlussgruende),
ausschlussgruende,
unbewertbareKriterien: scoring.unbewertbareKriterien,
missingDataWarnings: scoring.missingDataWarnings,
dimensionen: scoring.dimensionen,
};
}
async function segmentMultiQuestionAnswer(
client: OpenAI,
model: string,
entry: AntwortEintrag,
fallbackLabel: string,
sessionId?: string,
onLlmCall?: SummarizeCompanyOptions["onLlmCall"],
): Promise<FrageMitAntwort[]> {
const fragen = entry.fragen ?? [];
if (!fragen.length || entry.antwortFormat !== "mehrfachfrage_ein_antwortfeld") {
return fragen;
}
const messages: Array<{ role: "system" | "user"; content: string }> = [
{
role: "system",
content:
"Du ordnest eine gemeinsame Freitextantwort mehreren Unterfragen zu. Antworte ausschließlich auf Deutsch und ausschließlich als JSON mit dem Feld fragen. fragen muss ein Array mit Objekten der Form { id, text, antwort, confidence } sein. confidence muss zwischen 0 und 1 liegen.",
},
{
role: "user",
content: `Ordne die folgende gemeinsame Antwort den Unterfragen zu.
Bereich: ${entry.label?.trim() || fallbackLabel}
Unterfragen:
${fragen.map((frage, index) => `${index + 1}. [${frage.id ?? `frage_${index + 1}`}] ${frage.text}`).join("\n")}
Gemeinsame Antwort:
${entry.antwort ?? ""}
Vorgaben:
- Antworte ausschließlich als JSON mit dem Feld fragen.
- Gib für jede Unterfrage genau ein Objekt mit id, text, antwort und confidence zurück.
- antwort soll möglichst aus wörtlichen oder sehr nahen Textpassagen der Gesamtantwort bestehen.
- Füge keine Auslassungspunkte wie "..." oder „…“ ein, außer sie stehen exakt so im Original.
- confidence muss zwischen 0 und 1 liegen und angeben, wie sicher die Zuordnung ist.
- Wenn die Antwort eine Unterfrage nicht belastbar abdeckt, setze antwort auf einen leeren String und confidence auf 0.
- Erfinde nichts und paraphrasiere nicht unnötig. Bleibe möglichst nah am Originalinhalt.
- Mehrfachnennungen sind erlaubt, wenn ein Abschnitt mehrere Unterfragen klar beantwortet.
- Lasse keine Unterfrage aus.`,
},
];
let parsed: z.infer<typeof segmentierungResponseSchema>;
try {
parsed = await createParsedStructuredCompletion<z.infer<typeof segmentierungResponseSchema>>(
client,
model,
messages,
"segmentierung_unterfragen",
{
type: "object",
additionalProperties: false,
properties: {
fragen: {
type: "array",
items: {
type: "object",
additionalProperties: false,
properties: {
id: { type: "string" },
text: { type: "string" },
antwort: { type: "string" },
confidence: { type: "number" },
},
required: ["id", "text", "antwort", "confidence"],
},
},
},
required: ["fragen"],
},
segmentierungResponseSchema,
`Could not parse answer segmentation JSON for ${fallbackLabel}`,
sessionId,
{ operation: "segmentierung", label: fallbackLabel, onLlmCall },
);
} catch {
parsed = {
fragen: fragen.map((frage, index) => ({
id: frage.id ?? `frage_${index + 1}`,
text: frage.text,
antwort: "",
confidence: 0,
})),
};
}
const byId = new Map(parsed.fragen.map((frage) => [frage.id, frage]));
return fragen.map((frage, index) => {
const mapped = byId.get(String(frage.id ?? `frage_${index + 1}`));
const rawConfidence = mapped?.confidence;
const confidence =
typeof rawConfidence === "number"
? Math.max(0, Math.min(1, rawConfidence))
: Number.isFinite(Number(rawConfidence))
? Math.max(0, Math.min(1, Number(rawConfidence)))
: undefined;
const antwort = String(mapped?.antwort ?? "").trim();
return {
id: frage.id,
text: frage.text,
antwort,
confidence,
abgedeckt: Boolean(antwort),
quelle: "llm_segmentierung",
};
});
}
async function analyzeSWOT(
client: OpenAI,
model: string,
data: LlmDossier,
sessionId?: string,
onLlmCall?: SummarizeCompanyOptions["onLlmCall"],
): Promise<SWOTAnalyse> {
const messages: Array<{ role: "system" | "user"; content: string }> = [
{
role: "system",
content:
"Du analysierst Unternehmensbewerbungen und erstellst eine knappe SWOT-Analyse. Antworte ausschließlich auf Deutsch und ausschließlich als JSON mit den Feldern staerken, schwaechen, chancen und risiken. Jedes Feld muss ein Array aus kurzen Stichpunkten sein.",
},
{
role: "user",
content: `Erstelle auf Basis der folgenden Bewerbungsdaten eine SWOT-Analyse.
Vorgaben:
- Antworte ausschließlich als JSON.
- Verwende genau die Felder staerken, schwaechen, chancen und risiken.
- Jedes Feld muss ein Array aus kurzen, konkreten deutschen Stichpunkten sein.
- Nenne nur Punkte, die aus den vorliegenden Daten klar ableitbar sind.
- Erfinde nichts und ergänze keine externen Annahmen.
- Vermeide schwache oder spekulative Schwächen und Risiken.
- Wenn zu einem Bereich wenig belastbare Hinweise vorliegen, liefere lieber wenige Punkte als vage Aussagen.
- Die Stichpunkte sollen prägnant und entscheidungsrelevant sein.
Bewerbungsdaten:
${JSON.stringify(data, null, 2)}`,
},
];
let parsed: z.infer<typeof swotResponseSchema>;
try {
parsed = await createParsedStructuredCompletion<z.infer<typeof swotResponseSchema>>(
client,
model,
messages,
"swot_analyse",
{
type: "object",
additionalProperties: false,
properties: {
staerken: { type: "array", items: { type: "string" } },
schwaechen: { type: "array", items: { type: "string" } },
chancen: { type: "array", items: { type: "string" } },
risiken: { type: "array", items: { type: "string" } },
},
required: ["staerken", "schwaechen", "chancen", "risiken"],
},
swotResponseSchema,
"Could not parse SWOT analysis JSON",
sessionId,
{ operation: "swot", label: "SWOT-Analyse", onLlmCall },
);
} catch (error) {
parsed = {
staerken: [],
schwaechen: [],
chancen: [],
risiken: [`SWOT-Analyse konnte nicht automatisch erstellt werden: ${errorMessage(error)}`],
};
}
const toStringArray = (value: unknown): string[] =>
Array.isArray(value) ? value.map((item) => String(item).trim()).filter(Boolean) : [];
return {
staerken: toStringArray(parsed.staerken),
schwaechen: toStringArray(parsed.schwaechen),
chancen: toStringArray(parsed.chancen),
risiken: toStringArray(parsed.risiken),
};
}
export async function listCompanies(): Promise<string[]> {
let entries: string[];
try {
entries = await readdir(OUTPUTS_DIR);
} catch {
return [];
}
const companies: string[] = [];
for (const entry of entries) {
if (!isSafeStem(entry)) continue;
const jsonPath = join(OUTPUTS_DIR, entry, `${entry}.json`);
if (await Bun.file(jsonPath).exists()) {
companies.push(entry);
}
}
return companies;
}
export async function summarizeCompany(
stem: string,
model: string,
options: SummarizeCompanyOptions = {},
): Promise<SummaryData> {
if (!isSafeStem(stem)) {
throw new Error(`Invalid company id "${stem}"`);
}
const jsonPath = join(OUTPUTS_DIR, stem, `${stem}.json`);
const file = Bun.file(jsonPath);
if (!(await file.exists())) {
throw new Error(`No extracted JSON found for "${stem}"`);
}
const data: ExtractedData = await file.json();
options.onCompanyInput?.(stem, data);
const client = createClient();
const output: SummaryData = structuredClone(data);
const privacy: Pseudonymizer = createPseudonymizer(data);
const sessionId = `summarizer-${stem}-${crypto.randomUUID()}`;
// Summarize fragen answers
if (data.fragen) {
output.fragen = {};
for (const [key, value] of Object.entries(data.fragen)) {
const antwort = value?.antwort ?? "";
const safeValue = privacy.pseudonymizeEntry(value);
const safeAntwort = safeValue?.antwort ?? "";
const enrichedFragen =
antwort.trim() && value?.antwortFormat === "mehrfachfrage_ein_antwortfeld"
? await segmentMultiQuestionAnswer(client, model, safeValue, `Frage ${key}`, sessionId, options.onLlmCall)
: safeValue?.fragen;
const zusammenfassung = safeAntwort.trim()
? await summarizeText(
client,
model,
safeAntwort,
buildQuestionContext({ ...safeValue, fragen: enrichedFragen }, `Frage ${key}`),
sessionId,
{ label: `Frage ${key}`, onLlmCall: options.onLlmCall },
)
: "";
const segmentierung = summarizeSegmentierungsQualitaet(enrichedFragen);
output.fragen[key] = {
...value,
fragen: enrichedFragen,
zuordnungsmodus:
value?.antwortFormat === "mehrfachfrage_ein_antwortfeld"
? segmentierung?.niedriges_vertrauen
? "gemeinsame_antwort"
: "llm_segmentiert"
: value?.zuordnungsmodus,
antwort,
zusammenfassung,
segmentierung,
};
}
}
// Summarize kriterium answers
if (data.kriterium) {
output.kriterium = {};
const kriteriumLabels: Record<string, string> = {
robustheit_resilienz: "Robustheit & Resilienz",
zukunftsfaehigkeit_innovation: "Zukunftsfähigkeit & Innovation",
nachhaltigkeit_verantwortung: "Nachhaltigkeit & Verantwortung",
attraktivitaet: "Attraktivität",
};
for (const [key, value] of Object.entries(data.kriterium)) {
const antwort = value?.antwort ?? "";
const label = value?.label?.trim() || kriteriumLabels[key] || key;
const safeValue = privacy.pseudonymizeEntry(value);
const safeAntwort = safeValue?.antwort ?? "";
const safeLabel = safeValue?.label?.trim() || label;
const enrichedFragen =
antwort.trim() && value?.antwortFormat === "mehrfachfrage_ein_antwortfeld"
? await segmentMultiQuestionAnswer(client, model, safeValue, safeLabel, sessionId, options.onLlmCall)
: safeValue?.fragen;
const zusammenfassung = safeAntwort.trim()
? await summarizeText(
client,
model,
safeAntwort,
buildQuestionContext({ ...safeValue, fragen: enrichedFragen }, safeLabel),
sessionId,
{ label: safeLabel, onLlmCall: options.onLlmCall },
)
: "";
const segmentierung = summarizeSegmentierungsQualitaet(enrichedFragen);
output.kriterium[key] = {
...value,
fragen: enrichedFragen,
zuordnungsmodus:
value?.antwortFormat === "mehrfachfrage_ein_antwortfeld"
? segmentierung?.niedriges_vertrauen
? "gemeinsame_antwort"
: "llm_segmentiert"
: value?.zuordnungsmodus,
antwort,
zusammenfassung,
segmentierung,
};
}
}
const preparedScoringInput = privacy.pseudonymizeDossier(buildLlmDossier(output));
output.ampelbewertung = await assessTrafficLight(client, model, preparedScoringInput.safe, sessionId, options.onLlmCall);
const preparedSwotInput = privacy.pseudonymizeDossier(buildLlmDossier(output));
output.swot_analyse = await analyzeSWOT(client, model, preparedSwotInput.safe, sessionId, options.onLlmCall);
// Reverse pseudonyms in LLM-generated content
const reverse = privacy.reversePseudonyms;
for (const [key, frage] of Object.entries(output.fragen ?? {})) {
if (frage.zusammenfassung) {
frage.zusammenfassung = reverse(frage.zusammenfassung);
}
if (frage.fragen) {
for (const f of frage.fragen) {
if (f.antwort) {
f.antwort = reverse(f.antwort);
}
if (f.text) {
f.text = reverse(f.text);
}
}
}
}
for (const [key, kriterium] of Object.entries(output.kriterium ?? {})) {
if (kriterium.zusammenfassung) {
kriterium.zusammenfassung = reverse(kriterium.zusammenfassung);
}
if (kriterium.fragen) {
for (const f of kriterium.fragen) {
if (f.antwort) {
f.antwort = reverse(f.antwort);
}
if (f.text) {
f.text = reverse(f.text);
}
}
}
}
const ampel = output.ampelbewertung;
if (ampel) {
ampel.begruendung = reverse(ampel.begruendung);
ampel.ausschlussgruende = (ampel.ausschlussgruende ?? []).map(reverse);
for (const dimension of ampel.dimensionen ?? []) {
for (const sub of dimension.subcriteria) {
sub.evidence = reverse(sub.evidence);
sub.begruendung = reverse(sub.begruendung);
sub.missingReason = reverse(sub.missingReason);
}
}
}
const swot = output.swot_analyse;
if (swot) {
swot.staerken = swot.staerken.map(reverse);
swot.schwaechen = swot.schwaechen.map(reverse);
swot.chancen = swot.chancen.map(reverse);
swot.risiken = swot.risiken.map(reverse);
}
output._privacy = privacy.audit();
output._schemaVersion = 3;
output._summarizedAt = new Date().toISOString();
options.onCompanyOutput?.(stem, output);
return output;
}
export async function writeSummary(
stem: string,
data: SummaryData,
outputsDir: string,
): Promise<string[]> {
if (!isSafeStem(stem)) {
throw new Error(`Invalid company id "${stem}"`);
}
const { mkdir } = await import("node:fs/promises");
const outDir = join(outputsDir, stem);
await mkdir(outDir, { recursive: true });
const outputData = structuredClone(data) as SummaryData;
if (outputData.ampelbewertung) {
delete outputData.ampelbewertung.gesamtScore;
}
const jsonPath = join(outDir, `${stem}.json`);
await Bun.write(jsonPath, JSON.stringify(outputData, null, 2));
const xlsxPath = join(outDir, `${stem}.xlsx`);
if (!(await writeTemplateWorkbook(outputData, xlsxPath))) {
const workbook = createWorkbook(outputData);
const xlsxBuffer = XLSX.write(workbook, {
bookType: "xlsx",
type: "buffer",
cellStyles: true,
});
await Bun.write(xlsxPath, xlsxBuffer);
}
const reportPaths = await writeJuryReport(stem, outputData, outDir);
return [jsonPath, xlsxPath, ...reportPaths];
}