// Document generation: tailors application documents to a job description with // the help of an LLM (Ollama Cloud), then renders them as polished, single-page // A4 PDFs (a modern CV and a clean DIN-style cover letter). // // The AI *rewrites* the user's own, previously provided base documents // (Basis-Dokumente) so the result stays grounded in real facts — it must not // invent experience the applicant doesn't have. const { jsPDF } = require('jspdf'); // Ollama Cloud API (https://ollama.com). Override host/model via env if needed // (e.g. point OLLAMA_HOST at a local Ollama instance). const OLLAMA_HOST = (process.env.OLLAMA_HOST || 'https://ollama.com').replace(/\/+$/, ''); const OLLAMA_MODEL = process.env.OLLAMA_MODEL || 'gpt-oss:120b'; const OLLAMA_TIMEOUT_MS = Number(process.env.OLLAMA_TIMEOUT_MS || 180000); // ----- Ollama call --------------------------------------------------------- // Structured schema the model must return. Everything is required (arrays may // be empty) so the JSON shape is predictable. const OUTPUT_SCHEMA = { type: 'object', properties: { kontakt: { type: 'object', properties: { email: { type: 'string' }, telefon: { type: 'string' }, ort: { type: 'string' }, webseite: { type: 'string' }, }, required: ['email', 'telefon', 'ort', 'webseite'], }, anschreiben: { type: 'object', properties: { betreff: { type: 'string' }, anrede: { type: 'string' }, absaetze: { type: 'array', items: { type: 'string' } }, gruss: { type: 'string' }, }, required: ['betreff', 'anrede', 'absaetze', 'gruss'], }, lebenslauf: { type: 'object', properties: { profil: { type: 'string' }, berufserfahrung: { type: 'array', items: { type: 'object', properties: { zeitraum: { type: 'string' }, titel: { type: 'string' }, firma: { type: 'string' }, punkte: { type: 'array', items: { type: 'string' } }, }, required: ['zeitraum', 'titel', 'firma', 'punkte'], }, }, ausbildung: { type: 'array', items: { type: 'object', properties: { zeitraum: { type: 'string' }, abschluss: { type: 'string' }, institution: { type: 'string' }, }, required: ['zeitraum', 'abschluss', 'institution'], }, }, kenntnisse: { type: 'array', items: { type: 'string' } }, sprachen: { type: 'array', items: { type: 'object', properties: { sprache: { type: 'string' }, niveau: { type: 'string' } }, required: ['sprache', 'niveau'], }, }, }, required: ['profil', 'berufserfahrung', 'ausbildung', 'kenntnisse', 'sprachen'], }, }, required: ['kontakt', 'anschreiben', 'lebenslauf'], }; // Returns the structured object { kontakt, anschreiben, lebenslauf }. // Throws if no API key is configured or the API call fails. async function generateTailoredTexts({ job, basisDokumente, settings }) { const apiKey = process.env.OLLAMA_API_KEY; if (!apiKey) { throw new Error( 'OLLAMA_API_KEY ist nicht gesetzt. Bitte den API-Schlüssel als ' + 'Umgebungsvariable (z. B. in einer .env-Datei) hinterlegen, damit ' + 'Bewerbungsunterlagen generiert werden können.' ); } if (!basisDokumente || basisDokumente.length === 0) { throw new Error( 'Es sind keine Basis-Unterlagen hinterlegt. Bitte zuerst unter "Vorlagen" ' + 'mindestens ein Basis-Dokument (z. B. Anschreiben und Lebenslauf) bereitstellen.' ); } const bewerber = [ settings && settings.name ? `Name: ${settings.name}` : null, settings && settings.adresse ? `Adresse: ${settings.adresse}` : null, ].filter(Boolean).join('\n') || 'Keine Angaben'; const basisText = basisDokumente .map((d, i) => `### Basis-Dokument ${i + 1} — Typ: ${d.typ || 'Sonstiges'} (${d.name || 'ohne Titel'})\n${d.inhalt}`) .join('\n\n'); const stelleText = [ job.stelle ? `Stellenbezeichnung: ${job.stelle}` : null, job.firma ? `Unternehmen: ${job.firma}` : null, job.ort ? `Ort: ${job.ort}` : null, job.gehalt ? `Gehalt/Konditionen: ${job.gehalt}` : null, '', 'Stellenbeschreibung:', job.stellenbeschreibung || '(keine Beschreibung übermittelt)', ].filter((l) => l !== null).join('\n'); const system = 'Du bist ein erfahrener Bewerbungscoach und erstellst professionelle, ' + 'deutschsprachige Bewerbungsunterlagen. Du passt die BEREITGESTELLTEN ' + 'Basis-Unterlagen des Bewerbers auf eine konkrete Stellenausschreibung an. ' + 'Wichtigste Regel: Du erfindest KEINE Fakten, Qualifikationen, Abschlüsse, ' + 'Kontaktdaten oder Berufserfahrungen. Verwende ausschließlich Informationen, ' + 'die in den Basis-Unterlagen des Bewerbers stehen. Du darfst umformulieren, ' + 'gewichten, relevante Punkte hervorheben und auf die Stelle zuschneiden — ' + 'aber nichts hinzudichten. Schreibe natürlich, konkret und ohne Floskeln.'; // An explicit skeleton with the EXACT keys — models that treat the schema as a // hint still copy the key names reliably from a concrete example. const skeleton = `{\n` + ` "kontakt": { "email": "", "telefon": "", "ort": "", "webseite": "" },\n` + ` "anschreiben": {\n` + ` "betreff": "Bewerbung als …",\n` + ` "anrede": "Sehr geehrte Damen und Herren,",\n` + ` "absaetze": ["Absatz 1", "Absatz 2", "Absatz 3"],\n` + ` "gruss": "Mit freundlichen Grüßen"\n` + ` },\n` + ` "lebenslauf": {\n` + ` "profil": "",\n` + ` "berufserfahrung": [\n` + ` { "zeitraum": "2018 – heute", "titel": "Jobtitel", "firma": "Arbeitgeber", "punkte": ["Aufgabe/Erfolg 1", "Aufgabe/Erfolg 2"] }\n` + ` ],\n` + ` "ausbildung": [\n` + ` { "zeitraum": "2012 – 2015", "abschluss": "Abschluss", "institution": "Schule/Hochschule" }\n` + ` ],\n` + ` "kenntnisse": ["Skill 1", "Skill 2"],\n` + ` "sprachen": [ { "sprache": "Deutsch", "niveau": "Muttersprache" } ]\n` + ` }\n` + `}`; const userPrompt = `# Bewerberdaten\n${bewerber}\n\n` + `# Basis-Unterlagen des Bewerbers (Faktengrundlage — NUR diese Fakten verwenden)\n${basisText}\n\n` + `# Zielstelle\n${stelleText}\n\n` + `# Aufgabe\n` + `Erzeuge strukturierte Daten für ein Anschreiben und einen Lebenslauf, jeweils ` + `passgenau auf diese Stelle zugeschnitten. Beide Dokumente werden auf JE EINER ` + `A4-Seite gedruckt — halte dich daher kurz und relevant.\n\n` + `Verwende EXAKT die folgende JSON-Struktur und exakt diese Schlüsselnamen ` + `(keine anderen, keine zusätzlichen Felder, "zeitraum" immer als einzelner String):\n\n` + `${skeleton}\n\n` + `Vorgaben:\n` + `- kontakt: E-Mail, Telefon, Ort und Webseite NUR übernehmen, wenn sie in den ` + `Basis-Unterlagen stehen; sonst leerer String. Nichts erfinden.\n` + `- anschreiben.absaetze: 3–4 kurze, überzeugende Absätze (kein Adressblock, ` + `kein Datum, keine Grußformel hier). Keine Platzhalter — echte Angaben nutzen.\n` + `- berufserfahrung: max. 4 relevanteste Stationen (neueste zuerst), je max. 3 knappe Stichpunkte.\n` + `- kenntnisse: max. 12 prägnante Schlagworte, relevanteste zuerst.\n` + `- profil: 1–2 Sätze (optional, sonst leer). sprachen nur falls vorhanden.\n` + `Leere Felder als leerer String bzw. leeres Array. Antworte AUSSCHLIESSLICH mit dem ` + `JSON-Objekt, ohne Markdown, ohne Code-Fences, ohne weiteren Text.`; const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), OLLAMA_TIMEOUT_MS); let res; try { res = await fetch(`${OLLAMA_HOST}/api/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}`, }, body: JSON.stringify({ model: OLLAMA_MODEL, stream: false, format: OUTPUT_SCHEMA, options: { temperature: 0.4 }, messages: [ { role: 'system', content: system }, { role: 'user', content: userPrompt }, ], }), signal: controller.signal, }); } catch (err) { if (err.name === 'AbortError') { throw new Error(`Zeitüberschreitung bei der KI-Anfrage (> ${Math.round(OLLAMA_TIMEOUT_MS / 1000)}s).`); } throw new Error(`Verbindung zur Ollama-API fehlgeschlagen: ${err.message}`); } finally { clearTimeout(timeout); } if (!res.ok) { const body = await res.text().catch(() => ''); throw new Error(`Ollama-API antwortete mit ${res.status}: ${body.slice(0, 300)}`); } const data = await res.json(); const text = ((data && data.message && data.message.content) || '').trim(); if (!text) { throw new Error('Die KI hat keine Antwort geliefert (leerer Inhalt).'); } // Strip Markdown code fences some models wrap around JSON, then parse. const cleaned = text.replace(/^\s*```(?:json)?\s*/i, '').replace(/\s*```\s*$/i, '').trim(); let parsed; try { parsed = JSON.parse(cleaned); } catch (e) { const match = cleaned.match(/\{[\s\S]*\}/); if (match) { try { parsed = JSON.parse(match[0]); } catch (_) { /* ignore */ } } } if (!parsed || typeof parsed !== 'object') { throw new Error('Die KI-Antwort konnte nicht als JSON gelesen werden.'); } return normalizeResult(parsed); } // ----- tolerant normalisation ---------------------------------------------- // Models don't always honour the exact schema keys, so accept common German // synonyms and shapes and coerce everything into our predictable structure. const str = (v) => (typeof v === 'string' ? v.trim() : (v == null ? '' : String(v).trim())); const asArray = (v) => (Array.isArray(v) ? v : []); function pick(obj, keys) { if (!obj) return ''; for (const k of keys) { if (obj[k] != null && String(obj[k]).trim() !== '') return obj[k]; } return ''; } function pickArray(obj, keys) { if (!obj) return []; for (const k of keys) { if (Array.isArray(obj[k]) && obj[k].length) return obj[k]; } return []; } // Accept "2018 – heute" or { von, bis } style objects. function toZeitraum(v) { if (!v) return ''; if (typeof v === 'string') return v.trim(); if (typeof v === 'object') { const von = str(pick(v, ['von', 'from', 'start', 'beginn'])); const bis = str(pick(v, ['bis', 'to', 'ende', 'end'])); return [von, bis].filter(Boolean).join(' – '); } return str(v); } function normalizeResult(parsed) { const k = parsed.kontakt || parsed.contact || {}; const a = parsed.anschreiben || parsed.cover_letter || parsed.anschreiben_daten || {}; const l = parsed.lebenslauf || parsed.cv || parsed.resume || {}; return { kontakt: { email: str(pick(k, ['email', 'e_mail', 'mail'])), telefon: str(pick(k, ['telefon', 'phone', 'tel', 'mobil'])), ort: str(pick(k, ['ort', 'stadt', 'wohnort', 'city'])), webseite: str(pick(k, ['webseite', 'website', 'web', 'url', 'linkedin'])), }, anschreiben: { betreff: str(pick(a, ['betreff', 'subject', 'titel'])), anrede: str(pick(a, ['anrede', 'salutation', 'gruss_anfang'])), absaetze: pickArray(a, ['absaetze', 'absätze', 'paragraphs', 'text', 'inhalt', 'absaetze_text']) .map(str).filter(Boolean), gruss: str(pick(a, ['gruss', 'gruß', 'grussformel', 'closing', 'schluss'])), }, lebenslauf: { profil: str(pick(l, ['profil', 'kurzprofil', 'zusammenfassung', 'summary', 'ueberblick'])), berufserfahrung: pickArray(l, ['berufserfahrung', 'erfahrung', 'work_experience', 'experience', 'stationen']) .map((e) => ({ zeitraum: toZeitraum(pick(e, ['zeitraum', 'zeit', 'dauer', 'period']) || e.zeitraum), titel: str(pick(e, ['titel', 'position', 'rolle', 'taetigkeit', 'jobtitel', 'bezeichnung', 'job'])), firma: str(pick(e, ['firma', 'arbeitgeber', 'unternehmen', 'company', 'organisation'])), punkte: pickArray(e, ['punkte', 'aufgaben', 'taetigkeiten', 'highlights', 'schwerpunkte', 'technologien', 'details', 'beschreibung']) .map(str).filter(Boolean), })) .filter((e) => e.titel || e.firma), ausbildung: pickArray(l, ['ausbildung', 'bildung', 'education', 'qualifikationen']) .map((e) => ({ zeitraum: toZeitraum(pick(e, ['zeitraum', 'zeit', 'dauer', 'period', 'jahr']) || e.zeitraum), abschluss: str(pick(e, ['abschluss', 'titel', 'grad', 'degree', 'qualifikation', 'name'])), institution: str(pick(e, ['institution', 'schule', 'hochschule', 'einrichtung', 'ort', 'organisation'])), })) .filter((e) => e.abschluss || e.institution), kenntnisse: pickArray(l, ['kenntnisse', 'skills', 'faehigkeiten', 'fähigkeiten', 'kompetenzen']) .map((s) => (typeof s === 'object' ? str(pick(s, ['name', 'skill', 'bezeichnung'])) : str(s))) .filter(Boolean), sprachen: pickArray(l, ['sprachen', 'languages']) .map((e) => (typeof e === 'string' ? { sprache: str(e), niveau: '' } : { sprache: str(pick(e, ['sprache', 'name', 'language'])), niveau: str(pick(e, ['niveau', 'level', 'stufe'])) })) .filter((e) => e.sprache), }, }; } // =========================================================================== // PDF rendering — modern, single-page A4 layout // =========================================================================== const PT_TO_MM = 0.352778; // 1 pt in millimetres // Restrained, professional palette (RGB). const COLORS = { ink: [30, 41, 59], // slate-800 — body text sub: [71, 85, 105], // slate-600 — secondary muted: [100, 116, 139], // slate-500 — meta accent: [37, 99, 235], // blue-600 — name, section titles, accents hair: [226, 232, 240], // slate-200 — hairlines chipBg: [239, 246, 255],// blue-50 — skill chips }; const PAGE = { w: 210, h: 297, marginX: 20, marginTop: 20, marginBottom: 18 }; const CONTENT_W = PAGE.w - PAGE.marginX * 2; const RIGHT = PAGE.w - PAGE.marginX; const MIN_SCALE = 0.68; function oneLine(v) { return String(v || '').replace(/\s*\n\s*/g, ', ').replace(/\s+/g, ' ').trim(); } // A tiny layout helper bound to one jsPDF document + a vertical cursor. // `scale` shrinks every font size and gap so the content fits one page; // `dryRun` measures without drawing. function makeWriter(doc, scale, dryRun) { const st = { y: PAGE.marginTop }; const S = scale; function font(style, pt) { doc.setFont('helvetica', style); doc.setFontSize(pt * S); } function color(c) { doc.setTextColor(c[0], c[1], c[2]); } const lh = (pt, factor = 1.3) => pt * S * PT_TO_MM * factor; const baseline = (pt) => pt * S * PT_TO_MM * 0.76; // approx ascent from top // Multi-line text block; returns nothing, advances the cursor. function textBlock(text, { pt, style = 'normal', col = COLORS.ink, factor = 1.3, x = PAGE.marginX, width = CONTENT_W, align = 'left' } = {}) { if (!text) return; font(style, pt); const lines = doc.splitTextToSize(String(text), width); for (const line of lines) { if (!dryRun) { color(col); const drawX = align === 'right' ? RIGHT : x; doc.text(line, drawX, st.y + baseline(pt), { align }); } st.y += lh(pt, factor); } } function gap(mm) { st.y += mm * S; } function rule(col = COLORS.hair, weight = 0.3) { if (!dryRun) { doc.setDrawColor(col[0], col[1], col[2]); doc.setLineWidth(weight * S); doc.line(PAGE.marginX, st.y, RIGHT, st.y); } } // Header shared by both documents (name + contact + accent rule). function header(name, contactParts) { textBlock(name || '', { pt: 23, style: 'bold', col: COLORS.ink, factor: 1.05 }); const contact = contactParts.map(oneLine).filter(Boolean).join(' · '); if (contact) { gap(1.3); textBlock(contact, { pt: 9.5, col: COLORS.muted, factor: 1.25 }); } gap(2.6); rule(COLORS.accent, 0.8); gap(4.5); } // Section heading: uppercase, tracked, accent, with a hairline beneath. function section(title) { gap(3.2); doc.setFont('helvetica', 'bold'); doc.setFontSize(9.5 * S); if (!dryRun) { doc.setCharSpace(0.45 * S); color(COLORS.accent); doc.text(String(title).toUpperCase(), PAGE.marginX, st.y + baseline(9.5)); doc.setCharSpace(0); } st.y += lh(9.5, 1.0); gap(1.4); rule(COLORS.hair, 0.3); gap(3.2); } // A title (bold) on the left and meta (muted) right-aligned on the same line. function entryHead(title, meta) { font('normal', 9.5); const metaW = meta ? doc.getTextWidth(meta) : 0; font('bold', 11); const titleLines = doc.splitTextToSize(title || '', CONTENT_W - metaW - 4 * S); const first = titleLines[0] || ''; if (!dryRun) { color(COLORS.ink); doc.text(first, PAGE.marginX, st.y + baseline(11)); if (meta) { font('normal', 9.5); color(COLORS.muted); doc.text(meta, RIGHT, st.y + baseline(11), { align: 'right' }); } } st.y += lh(11, 1.2); } function bullets(items) { const textX = PAGE.marginX + 4.5 * S; const width = RIGHT - textX; for (const item of items) { font('normal', 9.7); const lines = doc.splitTextToSize(item, width); lines.forEach((line, idx) => { if (!dryRun) { if (idx === 0) { color(COLORS.accent); doc.text('•', PAGE.marginX + 1.2 * S, st.y + baseline(9.7)); } color(COLORS.ink); doc.text(line, textX, st.y + baseline(9.7)); } st.y += lh(9.7, 1.3); }); } } // Skill "chips": rounded pills that wrap to the content width. function chips(items) { const pt = 9; font('normal', pt); const padX = 2.6 * S; const chipH = pt * S * PT_TO_MM + 2.8 * S; const gapX = 2.2 * S; const gapY = 2.2 * S; let x = PAGE.marginX; let rows = 1; for (const item of items) { const w = doc.getTextWidth(item) + padX * 2; if (x + w > RIGHT && x > PAGE.marginX) { x = PAGE.marginX; st.y += chipH + gapY; rows++; } if (!dryRun) { doc.setFillColor(COLORS.chipBg[0], COLORS.chipBg[1], COLORS.chipBg[2]); doc.roundedRect(x, st.y, w, chipH, chipH / 2, chipH / 2, 'F'); color(COLORS.accent); doc.text(item, x + padX, st.y + chipH / 2 + pt * S * PT_TO_MM * 0.34); } x += w + gapX; } st.y += chipH; return rows; } return { st, textBlock, gap, rule, header, section, entryHead, bullets, chips }; } // Two-pass render: measure at scale 1, then draw scaled to fit one page. function renderSinglePage(compose) { const doc = new jsPDF({ unit: 'mm', format: 'a4' }); const measured = makeWriter(doc, 1, true); compose(measured); const contentH = measured.st.y - PAGE.marginTop; const availH = PAGE.h - PAGE.marginTop - PAGE.marginBottom; let scale = 1; if (contentH > availH) scale = Math.max(MIN_SCALE, (availH / contentH) * 0.99); const drawer = makeWriter(doc, scale, false); compose(drawer); return Buffer.from(doc.output('arraybuffer')); } function buildHeader(settings, kontakt) { const name = (settings && settings.name) || ''; const adresse = (settings && settings.adresse) || ''; return { name, adresse, ...kontakt }; } function contactParts(header) { return [header.adresse, header.email, header.telefon, header.webseite].filter(Boolean); } function renderLebenslaufPdf(cv, header) { return renderSinglePage((w) => { w.header(header.name, contactParts(header)); if (cv.profil) { w.textBlock(cv.profil, { pt: 10, col: COLORS.sub, factor: 1.35 }); w.gap(1.5); } if (cv.berufserfahrung.length) { w.section('Berufserfahrung'); cv.berufserfahrung.forEach((e, i) => { if (i > 0) w.gap(2.6); w.entryHead(e.titel, e.zeitraum); if (e.firma) w.textBlock(e.firma, { pt: 9.7, style: 'bold', col: COLORS.accent, factor: 1.2 }); if (e.punkte.length) { w.gap(0.6); w.bullets(e.punkte); } }); } if (cv.ausbildung.length) { w.section('Ausbildung'); cv.ausbildung.forEach((e, i) => { if (i > 0) w.gap(1.8); w.entryHead(e.abschluss, e.zeitraum); if (e.institution) w.textBlock(e.institution, { pt: 9.7, col: COLORS.muted, factor: 1.2 }); }); } if (cv.kenntnisse.length) { w.section('Kenntnisse'); w.chips(cv.kenntnisse); w.gap(1); } if (cv.sprachen.length) { w.section('Sprachen'); const line = cv.sprachen.map((s) => (s.niveau ? `${s.sprache} (${s.niveau})` : s.sprache)).join(' · '); w.textBlock(line, { pt: 10, col: COLORS.ink, factor: 1.25 }); } }); } function renderAnschreibenPdf(letter, header, job) { const today = new Date().toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }); const betreff = letter.betreff || (job.stelle ? `Bewerbung als ${job.stelle}` : 'Bewerbung'); return renderSinglePage((w) => { w.header(header.name, contactParts(header)); // Recipient block if (job.firma) w.textBlock(job.firma, { pt: 10, style: 'bold', col: COLORS.ink, factor: 1.25 }); if (job.ort) w.textBlock(job.ort, { pt: 10, col: COLORS.sub, factor: 1.25 }); // Date (right-aligned) w.gap(3); const dateLine = header.ort ? `${header.ort}, den ${today}` : today; w.textBlock(dateLine, { pt: 10, col: COLORS.muted, align: 'right', factor: 1.2 }); // Subject w.gap(4); w.textBlock(betreff, { pt: 11.5, style: 'bold', col: COLORS.ink, factor: 1.25 }); // Salutation + body w.gap(3.5); if (letter.anrede) { w.textBlock(letter.anrede, { pt: 10.5, factor: 1.35 }); w.gap(2); } letter.absaetze.forEach((p, i) => { if (i > 0) w.gap(2.6); w.textBlock(p, { pt: 10.5, col: COLORS.ink, factor: 1.42 }); }); // Closing + name w.gap(4); if (letter.gruss) w.textBlock(letter.gruss, { pt: 10.5, factor: 1.3 }); w.gap(7); w.textBlock(header.name, { pt: 10.5, style: 'bold', col: COLORS.ink, factor: 1.2 }); }); } // ----- Public entry point -------------------------------------------------- function hasAnschreiben(a) { return a && a.absaetze && a.absaetze.length > 0; } function hasLebenslauf(l) { return l && (l.berufserfahrung.length || l.ausbildung.length || l.kenntnisse.length || l.profil); } // Build the list of attachment documents (PDF buffers) for a job. // Returns [{ name, filename, mime, buffer }]. async function generateApplicationDocuments({ job, basisDokumente, settings }) { const data = await generateTailoredTexts({ job, basisDokumente, settings }); const header = buildHeader(settings, data.kontakt); const safe = (s) => String(s || '').replace(/[^a-zA-Z0-9äöüÄÖÜß _-]/g, '').replace(/\s+/g, '_').slice(0, 60) || 'Bewerbung'; const suffix = safe(job.firma || job.stelle || 'Bewerbung'); const label = job.stelle || job.firma || ''; const documents = []; if (hasAnschreiben(data.anschreiben)) { documents.push({ name: `Anschreiben – ${label}`.trim(), filename: `Anschreiben_${suffix}.pdf`, mime: 'application/pdf', buffer: renderAnschreibenPdf(data.anschreiben, header, job), }); } if (hasLebenslauf(data.lebenslauf)) { documents.push({ name: `Lebenslauf – ${label}`.trim(), filename: `Lebenslauf_${suffix}.pdf`, mime: 'application/pdf', buffer: renderLebenslaufPdf(data.lebenslauf, header), }); } if (documents.length === 0) { throw new Error('Die KI hat keine verwertbaren Unterlagen erzeugt.'); } return documents; } module.exports = { generateApplicationDocuments, generateTailoredTexts, renderLebenslaufPdf, renderAnschreibenPdf, };