Files
syntaxbullet 99569d2cf3 initial commit
2026-05-13 17:26:13 +02:00

1467 lines
43 KiB
TypeScript

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<string, unknown>)[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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
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 `
<div class="traffic-signal${color ? ` traffic-signal--${escapeHtml(color)}` : ""}" aria-label="Ampelbewertung: ${escapeHtml(trafficLightLabel(value))}">
<div class="traffic-signal__body" aria-hidden="true">
<span class="traffic-signal__lamp traffic-signal__lamp--rot"></span>
<span class="traffic-signal__lamp traffic-signal__lamp--gelb"></span>
<span class="traffic-signal__lamp traffic-signal__lamp--gruen"></span>
</div>
<div>
<span>Ampelbewertung</span>
<strong>${escapeHtml(trafficLightLabel(value))}</strong>
</div>
</div>
`;
}
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<string>();
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 `<p class="muted">${escapeHtml(emptyText)}</p>`;
return `<ul>${items.map((item) => `<li>${escapeHtml(item)}</li>`).join("")}</ul>`;
}
function renderFindings(items: ReportFinding[], emptyText: string): string {
if (!items.length) return `<p class="muted">${escapeHtml(emptyText)}</p>`;
return items
.map((item) => `
<article class="finding">
<div class="finding__meta">
<span>${escapeHtml(item.dimension)}</span>
<span class="pill pill--${escapeHtml(item.color)}">${escapeHtml(trafficLightLabel(item.color))}</span>
</div>
<h3>${escapeHtml(item.name)}</h3>
<p>${escapeHtml(item.text)}</p>
</article>
`)
.join("");
}
function normalizedScoreWidth(score: number): string {
return `${Math.max(0, Math.min(100, score))}%`;
}
function renderScoringBars(dimensions: ScoringDimension[]): string {
if (!dimensions.length) {
return `<p class="muted">Keine Scoring-Dimensionen vorhanden.</p>`;
}
return `
<div class="score-bars">
${dimensions.map((dimension) => `
<article class="score-bar score-bar--${escapeHtml(dimension.farbe)}">
<h3>${escapeHtml(dimension.name)}</h3>
<span class="pill pill--${escapeHtml(dimension.farbe)}">${escapeHtml(trafficLightLabel(dimension.farbe))}</span>
<div class="score-bar__visual">
<div class="score-bar__track" aria-label="${escapeHtml(`${dimension.name}: ${dimension.score} von 100`)}">
<div class="score-bar__fill" style="width: ${normalizedScoreWidth(dimension.score)}"></div>
</div>
</div>
<span class="score-bar__weight">${escapeHtml(String(dimension.weight))}%</span>
</article>
`).join("")}
</div>
`;
}
function collectQuestionSummaryRows(data: SummaryData): QuestionSummaryRow[] {
const rows: QuestionSummaryRow[] = [];
const addRows = (entries: Record<string, { label?: string; fragen?: Array<{ text?: string; antwort?: string }>; 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) => `
<article class="qa-row">
<div class="qa-question">
<span>${escapeHtml(row.label)}</span>
<p>${escapeHtml(row.question)}</p>
</div>
<div class="qa-answer">
<span>Originalantwort</span>
<p>${escapeHtml(row.originalAnswer)}</p>
</div>
<div class="qa-summary">
<span>KI-Antwort</span>
<p>${escapeHtml(row.aiAnswer)}</p>
</div>
</article>
`)
.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 `<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${escapeHtml(title)}</title>
<style>
@page { size: A4; margin: 0; }
* { box-sizing: border-box; }
body {
margin: 0;
color: #1d2430;
background: #e8edf5;
font-family: Arial, Helvetica, sans-serif;
line-height: 1.42;
}
main {
width: 210mm;
max-width: 100%;
margin: 0 auto;
display: grid;
gap: 10mm;
}
.page {
width: 210mm;
height: 297mm;
max-width: 100%;
padding: 16mm 16mm;
overflow: hidden;
background: #ffffff;
box-shadow: 0 8px 30px rgba(15, 23, 42, .14);
break-after: page;
page-break-after: always;
break-inside: avoid;
page-break-inside: avoid;
}
.page:last-child {
break-after: auto;
page-break-after: auto;
}
.report-head {
display: flex;
align-items: end;
justify-content: space-between;
gap: 12mm;
padding-bottom: 7mm;
margin-bottom: 7mm;
border-bottom: 2px solid #273b86;
}
.eyebrow {
color: #273b86;
font-size: 8.4px;
font-weight: 700;
letter-spacing: .14em;
text-transform: uppercase;
margin-bottom: 5px;
}
h1 {
margin: 0;
color: #101828;
font-size: 18px;
line-height: 1.14;
letter-spacing: .05em;
text-transform: uppercase;
}
.page-number {
color: #5e6875;
font-size: 9px;
font-weight: 700;
white-space: nowrap;
}
.qa-list {
display: grid;
border-top: 1px solid rgba(39, 59, 134, .18);
}
.qa-row {
display: grid;
grid-template-columns: minmax(0, .75fr) minmax(0, 1.25fr) minmax(0, 1.25fr);
gap: 6mm;
padding: 3.8mm 0;
border-bottom: 1px solid rgba(39, 59, 134, .18);
break-inside: avoid;
page-break-inside: avoid;
}
.qa-question span,
.qa-answer span,
.qa-summary span {
display: block;
color: #273b86;
font-size: 8.1px;
font-weight: 700;
letter-spacing: .04em;
text-transform: uppercase;
margin-bottom: 1.8mm;
}
.qa-question p,
.qa-answer p,
.qa-summary p {
margin: 0;
color: #1d2430;
font-size: 8.6px;
line-height: 1.42;
white-space: pre-wrap;
}
.qa-answer p {
color: #364253;
}
.qa-summary p {
color: #364253;
}
.empty {
color: #5e6875;
font-size: 10px;
line-height: 1.55;
}
.disclaimer {
margin-top: 8mm;
padding-top: 6mm;
border-top: 2px solid #273b86;
break-inside: avoid;
page-break-inside: avoid;
}
.disclaimer h2 {
margin: 0 0 3mm;
color: #273b86;
font-size: 10px;
letter-spacing: .12em;
text-transform: uppercase;
}
.disclaimer p {
margin: 0;
color: #364253;
font-size: 9px;
line-height: 1.5;
}
@media print {
body { background: #ffffff; print-color-adjust: exact; -webkit-print-color-adjust: exact; }
main { width: 210mm; max-width: none; display: block; }
.page {
width: 210mm;
height: 297mm;
margin: 0;
box-shadow: none;
break-after: page;
page-break-after: always;
}
.page:last-child {
break-after: auto;
page-break-after: auto;
}
}
</style>
</head>
<body>
<main>
${questionPages.map((pageRows, index) => `
<section class="page page--questions">
<header class="report-head">
<div>
<div class="eyebrow">Fragen, Originalantworten & KI-Antworten · ${escapeHtml(model.generatedAt)}</div>
<h1>${escapeHtml(model.companyName)}</h1>
</div>
<div class="page-number">Seite ${index + 1} / ${questionPages.length}</div>
</header>
<div class="qa-list">
${pageRows.length ? renderQuestionRows(pageRows) : `<p class="empty">Keine Fragen vorhanden.</p>`}
</div>
${index === questionPages.length - 1 ? `
<aside class="disclaimer">
<h2>${escapeHtml(AI_DISCLAIMER_TITLE)}</h2>
<p>${escapeHtml(AI_DISCLAIMER_TEXT)}</p>
</aside>
` : ""}
</section>
`).join("")}
</main>
</body>
</html>`;
}
export function renderJuryReportHtml(data: SummaryData): string {
const model = buildJuryReportModel(data);
const title = `Jury-Report - ${model.companyName}`;
const logoSrc = brandLogoDataUri();
return `<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${escapeHtml(title)}</title>
<style>
@page { size: A4; margin: 0; }
* { box-sizing: border-box; }
body {
margin: 0;
color: #1d2430;
background: #e8edf5;
font-family: Arial, Helvetica, sans-serif;
line-height: 1.42;
}
main {
width: 210mm;
max-width: 100%;
margin: 0 auto;
padding: 0;
display: grid;
gap: 10mm;
}
.page {
position: relative;
width: 210mm;
height: 297mm;
max-width: 100%;
margin-top: 0;
padding: 17mm 18mm;
border-top: 0;
overflow: hidden;
background: #ffffff;
box-shadow: 0 8px 30px rgba(15, 23, 42, .14);
break-after: page;
page-break-after: always;
break-inside: avoid;
page-break-inside: avoid;
}
.page:last-child {
break-after: auto;
page-break-after: auto;
}
h1, h2, h3, p { margin: 0; }
h1 {
font-family: Arial, Helvetica, sans-serif;
font-size: 24px;
line-height: 1.12;
letter-spacing: .08em;
font-weight: 700;
text-align: center;
color: #ffd21f;
}
h2 {
font-family: Arial, Helvetica, sans-serif;
font-size: 14px;
margin-bottom: 15px;
color: #273b86;
letter-spacing: .14em;
text-transform: uppercase;
text-align: center;
}
h2::after {
content: "";
display: block;
width: 30mm;
height: 2px;
margin: 9px auto 0;
background: #273b86;
}
h3 {
font-family: Arial, Helvetica, sans-serif;
font-size: 10.5px;
margin-top: 5px;
color: #101828;
}
p, li, td, th { font-size: 10.3px; }
p {
line-height: 1.58;
orphans: 3;
widows: 3;
}
ul { margin: 0; padding-left: 18px; }
li {
line-height: 1.5;
break-inside: avoid;
orphans: 3;
widows: 3;
}
li + li { margin-top: 5px; }
.page--cover {
padding: 18mm 13mm 12mm;
color: #ffffff;
background:
linear-gradient(120deg, rgba(0, 10, 45, .94), rgba(7, 35, 77, .9)),
linear-gradient(30deg, transparent 0 44%, rgba(255,255,255,.1) 45% 47%, transparent 48% 100%),
#071225;
}
.page--cover::before,
.page--cover::after {
content: "";
position: absolute;
inset: auto;
pointer-events: none;
opacity: .22;
border: 1px solid rgba(255,255,255,.26);
transform: rotate(-16deg);
}
.page--cover::before {
width: 78mm;
height: 58mm;
right: -22mm;
top: 4mm;
}
.page--cover::after {
width: 55mm;
height: 42mm;
left: -16mm;
bottom: 8mm;
}
.brand-logo {
position: relative;
z-index: 1;
display: block;
width: 128mm;
max-height: 34mm;
object-fit: contain;
margin: 0 auto 14mm;
border-radius: 2mm;
box-shadow: 0 9mm 22mm rgba(0,0,0,.28);
}
.eyebrow {
position: relative;
z-index: 1;
color: #dfe8f2;
font-size: 8.5px;
font-weight: 700;
letter-spacing: .16em;
margin-bottom: 11px;
text-transform: uppercase;
text-align: center;
}
.hero {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: 1fr;
gap: 10mm;
align-items: center;
}
.verdict {
max-width: 130mm;
margin: 11px auto 0;
color: #eef4f8;
font-size: 10.2px;
line-height: 1.62;
text-align: center;
}
.traffic-signal {
display: inline-grid;
grid-template-columns: auto auto;
gap: 4mm;
align-items: center;
justify-content: center;
justify-self: center;
margin-top: 3mm;
color: #eef4f8;
font-family: Arial, Helvetica, sans-serif;
text-align: left;
}
.traffic-signal__body {
display: grid;
gap: 1.4mm;
width: 9.6mm;
padding: 1.7mm;
border-radius: 999px;
background: #111827;
box-shadow: inset 0 0 0 .35mm rgba(255,255,255,.12), 0 2.6mm 8mm rgba(0,0,0,.32);
}
.traffic-signal__lamp {
width: 6.2mm;
height: 6.2mm;
border-radius: 999px;
background: #64748b;
opacity: .32;
box-shadow: inset 0 .4mm 1mm rgba(0,0,0,.35);
}
.traffic-signal span {
display: block;
color: #cbd8e5;
font-size: 7.5px;
font-weight: 700;
letter-spacing: .12em;
text-transform: uppercase;
}
.traffic-signal strong {
display: block;
margin-top: 1mm;
color: #ffffff;
font-size: 13px;
line-height: 1;
}
.traffic-signal--gruen .traffic-signal__lamp--gruen {
background: #16a34a;
opacity: 1;
box-shadow: 0 0 0 .9mm rgba(22,163,74,.18), 0 0 4mm rgba(22,163,74,.48);
}
.traffic-signal--gelb .traffic-signal__lamp--gelb {
background: #f59e0b;
opacity: 1;
box-shadow: 0 0 0 .9mm rgba(245,158,11,.2), 0 0 4mm rgba(245,158,11,.54);
}
.traffic-signal--rot .traffic-signal__lamp--rot {
background: #dc2626;
opacity: 1;
box-shadow: 0 0 0 .9mm rgba(220,38,38,.18), 0 0 4mm rgba(220,38,38,.5);
}
.facts {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 7px 13px;
margin-top: 13mm;
padding-top: 8mm;
border-top: 1px solid rgba(255,255,255,.24);
}
.fact { border-top: 1px solid rgba(255,255,255,.26); padding-top: 5px; }
.fact span {
display: block;
color: #cbd8e5;
font-family: Arial, Helvetica, sans-serif;
font-size: 9px;
}
.fact strong {
display: block;
font-family: Arial, Helvetica, sans-serif;
font-size: 10.5px;
margin-top: 1px;
color: #ffffff;
}
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10mm;
}
.profile-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10mm;
align-items: start;
background: #ffffff;
}
.panel {
background: transparent;
border: 0;
padding: 0;
break-inside: avoid;
page-break-inside: avoid;
}
.profile-table {
width: 100%;
margin-top: 8px;
border: 0;
border-radius: 0;
background: transparent;
}
.profile-table th,
.profile-table td {
border-bottom: 1px solid rgba(39, 59, 134, .18);
padding: 5px 0;
}
.profile-table th {
width: 42%;
color: #273b86;
font-family: Arial, Helvetica, sans-serif;
text-transform: none;
font-size: 9.5px;
}
.profile-table td {
font-family: Arial, Helvetica, sans-serif;
font-weight: 700;
}
.signal-list { margin-top: 10px; }
.signal-list h3 {
color: #273b86;
font-size: 9px;
text-transform: uppercase;
margin-bottom: 6px;
letter-spacing: .08em;
}
.finding + .finding {
margin-top: 11px;
border-top: 1px solid rgba(39, 59, 134, .16);
padding-top: 11px;
}
.finding {
break-inside: avoid;
page-break-inside: avoid;
}
.finding__meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
color: #273b86;
font-family: Arial, Helvetica, sans-serif;
font-size: 8.8px;
font-weight: 700;
text-transform: uppercase;
}
.finding p {
color: #364253;
margin-top: 5px;
line-height: 1.56;
}
.pill {
display: inline-block;
border-radius: 0;
padding: 1px 0 1px 6px;
border-left: 3px solid #8a95a8;
background: transparent;
color: #364253;
font-family: Arial, Helvetica, sans-serif;
font-size: 9.5px;
font-weight: 700;
white-space: nowrap;
}
.pill--gruen { border-left-color: #1f9d55; color: #176b3a; }
.pill--gelb { border-left-color: #d99a08; color: #805600; }
.pill--rot { border-left-color: #d43f3a; color: #8f2722; }
.pill--unbewertbar { border-left-color: #8a95a8; color: #364253; }
table {
width: 100%;
border-collapse: collapse;
background: transparent;
border-top: 2px solid #273b86;
border-bottom: 2px solid #273b86;
overflow: hidden;
}
th, td {
padding: 6px 5px;
border-bottom: 1px solid rgba(39, 59, 134, .16);
text-align: left;
vertical-align: top;
line-height: 1.45;
}
th {
color: #273b86;
font-family: Arial, Helvetica, sans-serif;
font-size: 9px;
text-transform: uppercase;
letter-spacing: .04em;
}
tr:last-child td { border-bottom: 0; }
tr {
break-inside: avoid;
page-break-inside: avoid;
}
.muted { color: #5e6875; }
.score-bars {
display: grid;
border-top: 2px solid #273b86;
border-bottom: 2px solid #273b86;
}
.score-bar {
break-inside: avoid;
page-break-inside: avoid;
display: grid;
grid-template-columns: minmax(36mm, 1.25fr) 20mm minmax(48mm, 2fr) 15mm;
align-items: center;
gap: 5mm;
padding: 3px 0;
border-bottom: 1px solid rgba(39, 59, 134, .16);
}
.score-bar:last-child { border-bottom: 0; }
.score-bar h3 {
margin: 0;
color: #101828;
font-size: 9.2px;
line-height: 1.15;
}
.score-bar .pill {
justify-self: start;
font-size: 8.4px;
padding-top: 0;
padding-bottom: 0;
}
.score-bar__visual {
padding-top: 1px;
}
.score-bar__weight {
color: #5e6875;
font-size: 8.8px;
font-weight: 700;
text-align: right;
}
.score-bar__track {
height: 5px;
border-radius: 999px;
overflow: hidden;
background: #e8edf5;
border: 1px solid rgba(39, 59, 134, .12);
}
.score-bar__fill {
height: 100%;
border-radius: 999px;
background: #8a95a8;
}
.score-bar--gruen .score-bar__fill { background: #1f9d55; }
.score-bar--gelb .score-bar__fill { background: #d99a08; }
.score-bar--rot .score-bar__fill { background: #d43f3a; }
.score-bar--unbewertbar .score-bar__fill { background: #8a95a8; }
.scoring-section,
.swot {
break-inside: avoid;
page-break-inside: avoid;
}
.swot .panel { min-height: 120px; }
.missing-data-section {
padding: 0;
}
.page--opportunities {
background: #ffffff;
}
.page--opportunities .scoring-section {
margin-bottom: 18mm;
}
.page--swot .swot {
align-items: start;
}
.disclaimer {
margin-top: 15mm;
padding-top: 6mm;
border-top: 2px solid #273b86;
break-inside: avoid;
page-break-inside: avoid;
}
.disclaimer h2 {
margin: 0 0 3mm;
color: #273b86;
font-size: 10px;
letter-spacing: .12em;
text-transform: uppercase;
}
.disclaimer p {
margin: 0;
color: #364253;
font-size: 9.4px;
line-height: 1.5;
}
@media print {
body {
background: #ffffff;
print-color-adjust: exact;
-webkit-print-color-adjust: exact;
}
main {
width: 210mm;
max-width: none;
padding: 0;
display: block;
}
.page {
width: 210mm;
height: 297mm;
margin: 0;
box-shadow: none;
break-after: page;
page-break-after: always;
break-inside: avoid;
page-break-inside: avoid;
}
.page:last-child {
break-after: auto;
page-break-after: auto;
}
.score-bars {
break-inside: avoid;
page-break-inside: avoid;
}
}
</style>
</head>
<body>
<main>
<section class="page page--cover">
${logoSrc ? `<img class="brand-logo" src="${logoSrc}" alt="Der Bayerische Mittelstandspreis 2026">` : ""}
<div class="eyebrow">Jury-Report · ${escapeHtml(model.generatedAt)}</div>
<div class="hero">
<div>
<h1>${escapeHtml(model.companyName)}</h1>
<p class="verdict">${escapeHtml(model.verdict)}</p>
</div>
${renderTrafficSignal(model.trafficLight)}
</div>
<div class="facts">
${model.companyFacts.map((fact) => `
<div class="fact">
<span>${escapeHtml(fact.label)}</span>
<strong>${escapeHtml(fact.value)}</strong>
</div>
`).join("")}
</div>
</section>
<section class="page page--profile">
<div class="profile-grid">
<div class="panel">
<h2>Differenzierungsprofil</h2>
<p>${escapeHtml(model.differentiationProfile.uniqueSellingPoint)}</p>
<table class="profile-table">
<tbody>
<tr>
<th>Kategorie</th>
<td>${escapeHtml(model.differentiationProfile.category)}</td>
</tr>
<tr>
<th>Belegstärke</th>
<td>${escapeHtml(model.differentiationProfile.evidenceStrength)}</td>
</tr>
<tr>
<th>Vergleichbarkeit</th>
<td>${escapeHtml(model.differentiationProfile.comparability)}</td>
</tr>
</tbody>
</table>
<div class="signal-list">
<h3>Ableitbare Signale</h3>
${renderList(model.differentiationProfile.signals, "Keine expliziten Differenzierungssignale gefunden.")}
</div>
</div>
<div class="panel">
<h2>Belegbarkeit / Glaubwürdigkeit</h2>
<table class="profile-table">
<tbody>
<tr>
<th>Anzahl harter Kennzahlen</th>
<td>${model.credibilityProfile.hardMetricCount}</td>
</tr>
<tr>
<th>Beispiele / Case Evidence</th>
<td>${escapeHtml(model.credibilityProfile.examplesOrCaseEvidence)}</td>
</tr>
<tr>
<th>Externe Validierbarkeit</th>
<td>${escapeHtml(model.credibilityProfile.externalVerifiability)}</td>
</tr>
<tr>
<th>Konsistenz über Antworten</th>
<td>${escapeHtml(model.credibilityProfile.answerConsistency)}</td>
</tr>
</tbody>
</table>
<div class="signal-list">
<h3>Ableitbare Signale</h3>
${renderList(model.credibilityProfile.signals, "Keine harten Kennzahlen oder externen Belege gefunden.")}
</div>
</div>
</div>
</section>
<section class="page page--findings">
<div class="grid">
<div class="panel">
<h2>Stärkste Argumente</h2>
${renderFindings(model.strengths, "Keine belastbaren Stärken vorhanden.")}
</div>
<div class="panel">
<h2>Kritische Punkte</h2>
${renderFindings(model.concerns, "Keine kritischen Punkte vorhanden.")}
</div>
</div>
</section>
<section class="page page--opportunities">
<div class="scoring-section">
<h2>Scoring-Überblick</h2>
${renderScoringBars(model.dimensions)}
</div>
<div class="panel">
<h2>Verbesserungspotenziale</h2>
${renderList(model.improvementPotentials, "Keine Verbesserungspotenziale ableitbar.")}
</div>
</section>
<section class="page page--swot">
<div class="grid swot">
<div class="panel">
<h2>SWOT: Stärken</h2>
${renderList(model.swot.staerken, "Keine Stärken vorhanden.")}
</div>
<div class="panel">
<h2>SWOT: Schwächen</h2>
${renderList(model.swot.schwaechen, "Keine Schwächen vorhanden.")}
</div>
<div class="panel">
<h2>SWOT: Chancen</h2>
${renderList(model.swot.chancen, "Keine Chancen vorhanden.")}
</div>
<div class="panel">
<h2>SWOT: Risiken</h2>
${renderList(model.swot.risiken, "Keine Risiken vorhanden.")}
</div>
</div>
</section>
<section class="page page--missing">
<div class="panel missing-data-section">
<h2>Fehlende Daten</h2>
${renderList(model.missingDataWarnings, "Keine Warnungen zu fehlenden Daten vorhanden.")}
<aside class="disclaimer">
<h2>${escapeHtml(AI_DISCLAIMER_TITLE)}</h2>
<p>${escapeHtml(AI_DISCLAIMER_TEXT)}</p>
</aside>
</div>
</section>
</main>
</body>
</html>`;
}
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<boolean> {
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<string> {
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<void> {
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<void> {
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<void> {
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<boolean> {
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<string[]> {
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];
}