import { readFileSync } from "node:fs"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; import type { Ampelbewertung, SummaryData } from "./summarizer"; import type { ScoringDimension } from "./scoring"; interface JuryReportModel { companyName: string; generatedAt: string; trafficLight: string; verdict: string; companyFacts: Array<{ label: string; value: string }>; dimensions: ScoringDimension[]; differentiationProfile: DifferentiationProfile; credibilityProfile: CredibilityProfile; strengths: ReportFinding[]; concerns: ReportFinding[]; improvementPotentials: string[]; missingDataWarnings: string[]; swot: { staerken: string[]; schwaechen: string[]; chancen: string[]; risiken: string[]; }; } interface ReportFinding { dimension: string; name: string; color: string; text: string; } interface DifferentiationProfile { uniqueSellingPoint: string; category: string; evidenceStrength: "hoch" | "mittel" | "gering"; comparability: "einzigartig" | "selten" | "häufig"; signals: string[]; } interface CredibilityProfile { hardMetricCount: number; examplesOrCaseEvidence: "vorhanden" | "nicht vorhanden"; externalVerifiability: "hoch" | "mittel" | "gering"; answerConsistency: "hoch" | "mittel" | "niedrig"; signals: string[]; } interface QuestionSummaryRow { label: string; question: string; originalAnswer: string; aiAnswer: string; } export const AI_DISCLAIMER_TITLE = "Wichtiger Hinweis"; export const AI_DISCLAIMER_TEXT = "Die KI-generierten Zusammenfassungen, Bewertungen und Scoring-Ergebnisse können aufgrund nicht-deterministischen Modellverhaltens zwischen Durchläufen variieren. KI-Systeme können außerdem halluzinieren, Inhalte falsch gewichten oder Aussagen erzeugen, die nicht zuverlässig sind. Diese Ergebnisse dürfen nicht als verlässliche Entscheidungsgrundlage vertraut werden und müssen vor jeder Nutzung fachlich geprüft werden."; const BRAND_LOGO_PATH = join(import.meta.dir, "../../assets/bmp-logo.png"); function brandLogoDataUri(): string { try { return `data:image/png;base64,${readFileSync(BRAND_LOGO_PATH).toString("base64")}`; } catch { return ""; } } function readPath(value: unknown, path: string[]): unknown { let current = value; for (const key of path) { if (!current || typeof current !== "object" || !(key in current)) { return undefined; } current = (current as Record)[key]; } return current; } function asText(value: unknown): string { if (value == null) return ""; if (typeof value === "string") return value.trim(); if (typeof value === "number" || typeof value === "boolean") return String(value); return ""; } function escapeHtml(value: string): string { return value .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); } function formatDate(value: string): string { const date = new Date(value); if (Number.isNaN(date.getTime())) return value; return new Intl.DateTimeFormat("de-DE", { dateStyle: "medium", timeStyle: "short", }).format(date); } function trafficLightLabel(value: string): string { if (value === "gruen") return "Grün"; if (value === "gelb") return "Gelb"; if (value === "rot") return "Rot"; if (value === "unbewertbar") return "Unbewertbar"; return value || "-"; } function renderTrafficSignal(value: string): string { const color = value === "gruen" || value === "gelb" || value === "rot" ? value : ""; return `
Ampelbewertung ${escapeHtml(trafficLightLabel(value))}
`; } function sentenceFrom(text: string): string[] { return text .replaceAll(/\s+/g, " ") .split(/(?<=[.!?])\s+/) .map((sentence) => sentence.trim()) .filter(Boolean); } function collectReportText(data: SummaryData): string[] { const values: string[] = []; const add = (value: unknown) => { const text = asText(value); if (text) values.push(text); }; add(data.ampelbewertung?.begruendung); for (const warning of data.ampelbewertung?.missingDataWarnings ?? []) add(warning); for (const items of Object.values(data.swot_analyse ?? {})) { if (Array.isArray(items)) { for (const item of items) add(item); } } for (const dimension of data.ampelbewertung?.dimensionen ?? []) { add(dimension.name); for (const subcriterion of dimension.subcriteria) { add(subcriterion.name); add(subcriterion.indikator); add(subcriterion.evidence); add(subcriterion.begruendung); add(subcriterion.missingReason); } } for (const group of [data.fragen, data.kriterium]) { for (const entry of Object.values(group ?? {})) { add(entry.label); add(entry.antwort); add(entry.zusammenfassung); for (const frage of entry.fragen ?? []) { add(frage.text); add(frage.antwort); } } } return values; } function collectMatchingSignals(texts: string[], pattern: RegExp, limit = 3): string[] { const seen = new Set(); const signals: string[] = []; for (const text of texts) { for (const sentence of sentenceFrom(text)) { if (!pattern.test(sentence)) continue; pattern.lastIndex = 0; if (seen.has(sentence)) continue; seen.add(sentence); signals.push(sentence); if (signals.length >= limit) return signals; } } return signals; } function countHardMetrics(texts: string[]): number { const matches = texts .join("\n") .match(/\b\d+(?:[.,]\d+)?\s?(?:%|Prozent|Mio\.?|Millionen|Mrd\.?|Milliarden|Tsd\.?|Tausend|€|EUR|Jahre|Standorte|Mitarbeitende|Azubi|Auszubildende|Labels|Füllungen|Kunden|p\.a\.)\b/gi); return matches?.length ?? 0; } function averageConfidence(data: SummaryData): number | undefined { const values: number[] = []; for (const dimension of data.ampelbewertung?.dimensionen ?? []) { for (const subcriterion of dimension.subcriteria) { values.push(subcriterion.confidence); } } if (!values.length) return undefined; return values.reduce((sum, value) => sum + value, 0) / values.length; } function buildDifferentiationProfile(data: SummaryData): DifferentiationProfile { const texts = collectReportText(data); const uniqueSignals = collectMatchingSignals( texts, /\b(weltmarktführer|marktführer|einzigartig|einziger|einzige|alleinstell|revolution|patent|führend|integriert|innovativ|skalierbar|plattform|ki|künstliche intelligenz)\b/gi, ); const fallbackSignals = collectMatchingSignals(texts, /\b(innovation|nachhaltigkeit|resilienz|ausbildung|netzwerk|zertifiz|iso)\b/gi); const signals = uniqueSignals.length ? uniqueSignals : fallbackSignals; const coreSentence = signals[0] ?? data.ampelbewertung?.begruendung ?? "Kein klares Alleinstellungsmerkmal ableitbar."; const combined = signals.join(" ").toLowerCase(); let category = "Marktstellung"; if (/\b(innovation|ki|künstliche intelligenz|plattform|patent|technologie|digital)\b/i.test(combined)) { category = "Innovation"; } else if (/\b(nachhaltigkeit|ressourcen|co2|umwelt|sozial)\b/i.test(combined)) { category = "Nachhaltigkeit"; } else if (/\b(integration|mitarbeiter|ausbildung|kultur|diversity)\b/i.test(combined)) { category = "Integration / Kultur"; } const hardMetricCount = countHardMetrics(texts); const evidenceStrength = hardMetricCount >= 5 || uniqueSignals.length >= 2 ? "hoch" : signals.length ? "mittel" : "gering"; const comparability = /\b(einzigartig|einziger|einzige|weltmarktführer)\b/i.test(combined) ? "einzigartig" : /\b(marktführer|revolution|patent|führend)\b/i.test(combined) ? "selten" : "häufig"; return { uniqueSellingPoint: coreSentence, category, evidenceStrength, comparability, signals, }; } function buildCredibilityProfile(data: SummaryData): CredibilityProfile { const texts = collectReportText(data); const hardMetricCount = countHardMetrics(texts); const evidenceSignals = collectMatchingSignals( texts, /\b(\d+(?:[.,]\d+)?\s?(?:%|Mio\.?|Millionen|€|EUR)|case|referenz|kunde|projekt|zertifiz|iso|audit|ranking|award|preis|wachstum|umsatz)\b/gi, 4, ); const externalSignals = collectMatchingSignals( texts, /\b(iso|zertifiz|audit|ranking|award|preis|verband|mitgliedschaft|referenz|kunde|standard)\b/gi, 3, ); const confidence = averageConfidence(data); const missingCount = data.ampelbewertung?.missingDataWarnings?.length ?? 0; return { hardMetricCount, examplesOrCaseEvidence: evidenceSignals.length ? "vorhanden" : "nicht vorhanden", externalVerifiability: externalSignals.length >= 2 ? "hoch" : externalSignals.length ? "mittel" : "gering", answerConsistency: confidence == null ? "mittel" : confidence >= 0.8 && missingCount <= 6 ? "hoch" : confidence >= 0.65 && missingCount <= 12 ? "mittel" : "niedrig", signals: evidenceSignals, }; } function buildCompanyFacts(data: SummaryData): Array<{ label: string; value: string }> { const candidates: Array<[string, string[]]> = [ ["Branche", ["kontakt", "unternehmen", "branche"]], ["Rechtsform", ["kontakt", "unternehmen", "rechtsform"]], ["PLZ", ["kontakt", "unternehmen", "plz"]], ["Regierungsbezirk", ["kontakt", "unternehmen", "regierungsbezirk"]], ["Gründungsjahr", ["unternehmen", "gruendungsjahr"]], ["Mitarbeitende", ["unternehmen", "anzahl_mitarbeiter"]], ["Auszubildende", ["unternehmen", "anzahl_azubi"]], ["Standorte Deutschland", ["unternehmen", "standorte_deutschland"]], ["Standorte Ausland", ["unternehmen", "standorte_ausland"]], ]; return candidates .map(([label, path]) => ({ label, value: asText(readPath(data, path)) })) .filter((entry) => entry.value); } function collectFindings(ampel: Ampelbewertung | undefined): { strengths: ReportFinding[]; concerns: ReportFinding[]; } { const strengths: ReportFinding[] = []; const concerns: ReportFinding[] = []; for (const dimension of ampel?.dimensionen ?? []) { for (const subcriterion of dimension.subcriteria) { const text = subcriterion.evidence || subcriterion.begruendung || subcriterion.missingReason || ""; if (!text.trim()) continue; const finding: ReportFinding = { dimension: dimension.name, name: subcriterion.name, color: subcriterion.farbe, text, }; if (subcriterion.farbe === "gruen") { strengths.push(finding); } else if (subcriterion.farbe === "rot" || subcriterion.farbe === "gelb" || subcriterion.farbe === "unbewertbar") { concerns.push(finding); } } } return { strengths: strengths.slice(0, 6), concerns: concerns.slice(0, 6), }; } function buildJuryReportModel(data: SummaryData): JuryReportModel { const ampel = data.ampelbewertung; const companyName = asText(readPath(data, ["kontakt", "unternehmen", "name"])) || asText(readPath(data, ["unternehmen", "name"])) || "Unternehmen"; const findings = collectFindings(ampel); return { companyName, generatedAt: formatDate(data._summarizedAt ?? new Date().toISOString()), trafficLight: ampel?.farbe ?? "", verdict: ampel?.begruendung ?? "Keine Bewertung vorhanden.", companyFacts: buildCompanyFacts(data), dimensions: ampel?.dimensionen ?? [], differentiationProfile: buildDifferentiationProfile(data), credibilityProfile: buildCredibilityProfile(data), strengths: findings.strengths, concerns: findings.concerns, improvementPotentials: [ ...(data.swot_analyse?.schwaechen ?? []), ...(data.swot_analyse?.risiken ?? []), ...(ampel?.missingDataWarnings ?? []), ].slice(0, 8), missingDataWarnings: ampel?.missingDataWarnings?.slice(0, 10) ?? [], swot: { staerken: data.swot_analyse?.staerken ?? [], schwaechen: data.swot_analyse?.schwaechen ?? [], chancen: data.swot_analyse?.chancen ?? [], risiken: data.swot_analyse?.risiken ?? [], }, }; } function renderList(items: string[], emptyText: string): string { if (!items.length) return `

${escapeHtml(emptyText)}

`; return ``; } function renderFindings(items: ReportFinding[], emptyText: string): string { if (!items.length) return `

${escapeHtml(emptyText)}

`; return items .map((item) => `
${escapeHtml(item.dimension)} ${escapeHtml(trafficLightLabel(item.color))}

${escapeHtml(item.name)}

${escapeHtml(item.text)}

`) .join(""); } function normalizedScoreWidth(score: number): string { return `${Math.max(0, Math.min(100, score))}%`; } function renderScoringBars(dimensions: ScoringDimension[]): string { if (!dimensions.length) { return `

Keine Scoring-Dimensionen vorhanden.

`; } return `
${dimensions.map((dimension) => `

${escapeHtml(dimension.name)}

${escapeHtml(trafficLightLabel(dimension.farbe))}
${escapeHtml(String(dimension.weight))}%
`).join("")}
`; } function collectQuestionSummaryRows(data: SummaryData): QuestionSummaryRow[] { const rows: QuestionSummaryRow[] = []; const addRows = (entries: Record; antwort?: string; zusammenfassung?: string }> | undefined) => { for (const [key, entry] of Object.entries(entries ?? {})) { const label = entry.label?.trim() || key; const questions = entry.fragen?.length ? entry.fragen : [{ text: label }]; const hasMultipleQuestions = questions.length > 1; const originalAnswer = entry.antwort?.trim() || "Keine Originalantwort vorhanden."; for (const question of questions) { const text = question.text?.trim(); if (!text) continue; rows.push({ label, question: text, originalAnswer, aiAnswer: hasMultipleQuestions ? question.antwort?.trim() || "Keine KI-Zuordnung zu dieser Frage vorhanden." : entry.zusammenfassung?.trim() || question.antwort?.trim() || "Keine KI-Antwort vorhanden.", }); } } }; addRows(data.fragen); addRows(data.kriterium); return rows; } function estimateQuestionRowUnits(row: QuestionSummaryRow): number { const questionLines = Math.ceil(row.question.length / 38); const answerLines = Math.ceil(row.originalAnswer.length / 58); const aiAnswerLines = Math.ceil(row.aiAnswer.length / 58); return Math.max(5, Math.max(questionLines, answerLines, aiAnswerLines) + 2); } function paginateQuestionRows(rows: QuestionSummaryRow[]): QuestionSummaryRow[][] { const pages: QuestionSummaryRow[][] = []; let current: QuestionSummaryRow[] = []; let units = 0; const maxUnits = 38; for (const row of rows) { const rowUnits = estimateQuestionRowUnits(row); if (current.length && units + rowUnits > maxUnits) { pages.push(current); current = []; units = 0; } current.push(row); units += rowUnits; } if (current.length) pages.push(current); return pages; } function renderQuestionRows(rows: QuestionSummaryRow[]): string { return rows .map((row) => `
${escapeHtml(row.label)}

${escapeHtml(row.question)}

Originalantwort

${escapeHtml(row.originalAnswer)}

KI-Antwort

${escapeHtml(row.aiAnswer)}

`) .join(""); } export function renderQuestionSummaryReportHtml(data: SummaryData): string { const model = buildJuryReportModel(data); const rows = collectQuestionSummaryRows(data); const pages = paginateQuestionRows(rows); const questionPages = pages.length ? pages : [[]]; const title = `Fragen, Originalantworten & KI-Antworten - ${model.companyName}`; return ` ${escapeHtml(title)}
${questionPages.map((pageRows, index) => `
Fragen, Originalantworten & KI-Antworten · ${escapeHtml(model.generatedAt)}

${escapeHtml(model.companyName)}

Seite ${index + 1} / ${questionPages.length}
${pageRows.length ? renderQuestionRows(pageRows) : `

Keine Fragen vorhanden.

`}
${index === questionPages.length - 1 ? ` ` : ""}
`).join("")}
`; } export function renderJuryReportHtml(data: SummaryData): string { const model = buildJuryReportModel(data); const title = `Jury-Report - ${model.companyName}`; const logoSrc = brandLogoDataUri(); return ` ${escapeHtml(title)}
${logoSrc ? `` : ""}
Jury-Report · ${escapeHtml(model.generatedAt)}

${escapeHtml(model.companyName)}

${escapeHtml(model.verdict)}

${renderTrafficSignal(model.trafficLight)}
${model.companyFacts.map((fact) => `
${escapeHtml(fact.label)} ${escapeHtml(fact.value)}
`).join("")}

Differenzierungsprofil

${escapeHtml(model.differentiationProfile.uniqueSellingPoint)}

Kategorie ${escapeHtml(model.differentiationProfile.category)}
Belegstärke ${escapeHtml(model.differentiationProfile.evidenceStrength)}
Vergleichbarkeit ${escapeHtml(model.differentiationProfile.comparability)}

Ableitbare Signale

${renderList(model.differentiationProfile.signals, "Keine expliziten Differenzierungssignale gefunden.")}

Belegbarkeit / Glaubwürdigkeit

Anzahl harter Kennzahlen ${model.credibilityProfile.hardMetricCount}
Beispiele / Case Evidence ${escapeHtml(model.credibilityProfile.examplesOrCaseEvidence)}
Externe Validierbarkeit ${escapeHtml(model.credibilityProfile.externalVerifiability)}
Konsistenz über Antworten ${escapeHtml(model.credibilityProfile.answerConsistency)}

Ableitbare Signale

${renderList(model.credibilityProfile.signals, "Keine harten Kennzahlen oder externen Belege gefunden.")}

Stärkste Argumente

${renderFindings(model.strengths, "Keine belastbaren Stärken vorhanden.")}

Kritische Punkte

${renderFindings(model.concerns, "Keine kritischen Punkte vorhanden.")}

Scoring-Überblick

${renderScoringBars(model.dimensions)}

Verbesserungspotenziale

${renderList(model.improvementPotentials, "Keine Verbesserungspotenziale ableitbar.")}

SWOT: Stärken

${renderList(model.swot.staerken, "Keine Stärken vorhanden.")}

SWOT: Schwächen

${renderList(model.swot.schwaechen, "Keine Schwächen vorhanden.")}

SWOT: Chancen

${renderList(model.swot.chancen, "Keine Chancen vorhanden.")}

SWOT: Risiken

${renderList(model.swot.risiken, "Keine Risiken vorhanden.")}

Fehlende Daten

${renderList(model.missingDataWarnings, "Keine Warnungen zu fehlenden Daten vorhanden.")}
`; } function chromeCandidates(): string[] { return [ process.env.CHROME_BIN ?? "", "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/Applications/Chromium.app/Contents/MacOS/Chromium", "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", "google-chrome", "chromium", "chromium-browser", ].filter(Boolean); } async function commandExists(command: string): Promise { if (command.includes("/")) return Bun.file(command).exists(); const proc = Bun.spawn(["/bin/sh", "-lc", `command -v ${command}`], { stdout: "ignore", stderr: "ignore", }); return (await proc.exited) === 0; } async function findChrome(): Promise { for (const candidate of chromeCandidates()) { if (await commandExists(candidate)) return candidate; } throw new Error("Could not find Chrome/Chromium for PDF generation. Set CHROME_BIN to a Chrome executable."); } async function renderPdfWithPlaywright(htmlPath: string, pdfPath: string): Promise { await rm(pdfPath, { force: true }); const { chromium } = await import("playwright"); const browser = await chromium.launch({ headless: true }); try { const page = await browser.newPage(); await page.goto(pathToFileURL(htmlPath).toString(), { waitUntil: "load" }); await page.emulateMedia({ media: "print" }); await page.pdf({ path: pdfPath, format: "A4", printBackground: true, preferCSSPageSize: true, margin: { top: "0", right: "0", bottom: "0", left: "0", }, }); } finally { await browser.close(); } if (!(await hasNonEmptyFile(pdfPath))) { throw new Error("Playwright PDF generation finished without creating a PDF."); } } async function renderPdfWithSystemChrome(htmlPath: string, pdfPath: string): Promise { const chrome = await findChrome(); const userDataDir = await mkdtemp(join(tmpdir(), "bmp-report-chrome-")); await rm(pdfPath, { force: true }); const proc = Bun.spawn([ chrome, "--headless=new", "--disable-gpu", "--disable-background-networking", "--disable-component-update", "--disable-sync", "--no-sandbox", "--no-first-run", "--no-default-browser-check", "--allow-file-access-from-files", "--print-to-pdf-no-header", `--user-data-dir=${userDataDir}`, `--print-to-pdf=${pdfPath}`, pathToFileURL(htmlPath).toString(), ], { stdout: "pipe", stderr: "pipe", }); const stdoutPromise = new Response(proc.stdout).text(); const stderrPromise = new Response(proc.stderr).text(); let exitCode: number | "pdf-ready" | "timeout" = await Promise.race([ proc.exited, waitForPdf(pdfPath, 20_000), ]); if (exitCode === "pdf-ready" || exitCode === "timeout") { proc.kill("SIGKILL"); exitCode = await proc.exited.catch(() => 0); } const [stdout, stderr] = await Promise.all([stdoutPromise, stderrPromise]); await rm(userDataDir, { recursive: true, force: true }); if (exitCode !== 0 && !(await hasNonEmptyFile(pdfPath))) { throw new Error(`Chrome PDF generation failed: ${stderr || stdout || `exit code ${exitCode}`}`); } } async function renderPdfFromHtml(htmlPath: string, pdfPath: string): Promise { try { await renderPdfWithPlaywright(htmlPath, pdfPath); return; } catch (playwrightError) { try { await renderPdfWithSystemChrome(htmlPath, pdfPath); return; } catch (chromeError) { throw new Error([ "PDF generation failed with Playwright Chromium and system Chrome fallback.", "For VPS deployment, run `bun run playwright:install` after `bun install`.", "On minimal Linux servers, also run `bun run playwright:install-deps` or install the equivalent system packages.", `Playwright error: ${String(playwrightError)}`, `Chrome fallback error: ${String(chromeError)}`, ].join("\n")); } } } async function hasNonEmptyFile(path: string): Promise { const file = Bun.file(path); return (await file.exists()) && file.size > 0; } async function waitForPdf(path: string, timeoutMs: number): Promise<"pdf-ready" | "timeout"> { const started = Date.now(); while (Date.now() - started < timeoutMs) { if (await hasNonEmptyFile(path)) return "pdf-ready"; await Bun.sleep(250); } return "timeout"; } export async function writeJuryReport( stem: string, data: SummaryData, outDir: string, ): Promise { const htmlPath = join(outDir, `${stem}.report.html`); const pdfPath = join(outDir, `${stem}.pdf`); const questionsHtmlPath = join(outDir, `${stem}.questions.html`); const questionsPdfPath = join(outDir, `${stem}.questions.pdf`); await Bun.write(htmlPath, renderJuryReportHtml(data)); await renderPdfFromHtml(htmlPath, pdfPath); await Bun.write(questionsHtmlPath, renderQuestionSummaryReportHtml(data)); await renderPdfFromHtml(questionsHtmlPath, questionsPdfPath); return [htmlPath, pdfPath, questionsHtmlPath, questionsPdfPath]; }