// Document generation. // // Architecture: the *design* lives here as a fixed, high-quality template // (clean monochrome single-column layout for both cover letter and résumé, // reverse-chronological, DIN-ish margins). The LLM only supplies the TEXT // content (tailored to the job) as structured data — it never touches layout. // The text stays grounded in the user's own base documents; nothing is invented. const { jsPDF } = require('jspdf'); const fs = require('fs'); const path = require('path'); // Embedded professional typeface for both cover letter and résumé: Lato // (SIL Open Font License, see lib/fonts/OFL.txt). The base64 TTFs are read // once at module load and registered per jsPDF instance. const LATO_REGULAR_B64 = fs.readFileSync(path.join(__dirname, 'fonts', 'Lato-Regular.ttf')).toString('base64'); const LATO_BOLD_B64 = fs.readFileSync(path.join(__dirname, 'fonts', 'Lato-Bold.ttf')).toString('base64'); function makeDoc() { const doc = new jsPDF({ unit: 'mm', format: 'a4' }); doc.addFileToVFS('Lato-Regular.ttf', LATO_REGULAR_B64); doc.addFileToVFS('Lato-Bold.ttf', LATO_BOLD_B64); doc.addFont('Lato-Regular.ttf', 'Lato', 'normal'); doc.addFont('Lato-Bold.ttf', 'Lato', 'bold'); doc.setFont('Lato'); return doc; } // Ollama Cloud API (https://ollama.com). Override host/model via env if needed. 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 || 300000); // =========================================================================== // 1. LLM call — produce tailored TEXT for the template // =========================================================================== // Reused shape for every education-type section (Studium, Berufsausbildung, …). const EDU_ITEM = { type: 'object', properties: { zeitraum: { type: 'string' }, abschluss: { type: 'string' }, institution: { type: 'string' }, zusatz: { type: 'string' }, }, required: ['zeitraum', 'abschluss', 'institution', 'zusatz'], }; const OUTPUT_SCHEMA = { type: 'object', properties: { headline: { type: 'string' }, kontakt: { type: 'object', properties: { email: { type: 'string' }, telefon: { type: 'string' }, ort: { type: 'string' }, webseite: { type: 'string' }, geburtsdatum: { type: 'string' }, }, required: ['email', 'telefon', 'ort', 'webseite', 'geburtsdatum'], }, anschreiben: { type: 'object', properties: { empfaenger: { type: 'object', properties: { firma: { type: 'string' }, adresse: { type: 'string' }, ort: { type: 'string' }, ansprechpartner: { type: 'string' }, }, required: ['firma', 'adresse', 'ort', 'ansprechpartner'], }, betreff: { type: 'string' }, anrede: { type: 'string' }, absaetze: { type: 'array', items: { type: 'string' } }, gruss: { type: 'string' }, }, required: ['empfaenger', 'betreff', 'anrede', 'absaetze', 'gruss'], }, email: { type: 'object', properties: { betreff: { type: 'string' }, text: { type: 'string' }, }, required: ['betreff', 'text'], }, 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'], }, }, studium: { type: 'array', items: EDU_ITEM }, berufsausbildung: { type: 'array', items: EDU_ITEM }, weiterbildungen: { type: 'array', items: EDU_ITEM }, kenntnisse: { type: 'array', items: { type: 'string' } }, sprachen: { type: 'array', items: { type: 'object', properties: { sprache: { type: 'string' }, niveau: { type: 'string' } }, required: ['sprache', 'niveau'], }, }, hobbys: { type: 'array', items: { type: 'string' } }, }, required: ['profil', 'berufserfahrung', 'studium', 'berufsausbildung', 'weiterbildungen', 'kenntnisse', 'sprachen', 'hobbys'], }, }, required: ['headline', 'kontakt', 'anschreiben', 'email', 'lebenslauf'], }; async function generateTailoredTexts({ job, basisDokumente, settings, llmNotizen = '' }) { 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 Bewerbungstexte. 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 aus den Basis-Unterlagen. ' + 'Du darfst umformulieren, gewichten, relevante Punkte hervorheben und auf die Stelle ' + 'zuschneiden — aber nichts hinzudichten. Schreibe natürlich, konkret und ohne Floskeln, so wie ein ' + 'deutscher Muttersprachler schreibt: keine Buzzword-Paare, keine leeren Eigenschaftswörter und keine ' + 'Selbstetiketten wie "Als Muttersprachler ..."; jede Aussage aus einer konkreten Tätigkeit heraus.'; const skeleton = `{\n` + ` "headline": "Kurze Berufsbezeichnung (max. 5 Wörter)",\n` + ` "kontakt": { "email": "", "telefon": "", "ort": "", "webseite": "", "geburtsdatum": "" },\n` + ` "anschreiben": {\n` + ` "empfaenger": { "firma": "Unternehmen", "adresse": "Straße Hausnr. (falls bekannt, sonst leer)", "ort": "PLZ Ort", "ansprechpartner": "" },\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` + ` "email": {\n` + ` "betreff": "Bewerbung als … (ggf. mit Referenz-/Kennnummer)",\n` + ` "text": "Kurzer, sofort versendbarer E-Mail-Text: Anrede, 2-3 knappe Absätze, Grußformel, Name - Absätze durch Leerzeile getrennt"\n` + ` },\n` + ` "lebenslauf": {\n` + ` "profil": "2-3 Sätze Kurzprofil, zugeschnitten auf die Zielstelle",\n` + ` "berufserfahrung": [\n` + ` { "zeitraum": "02/2025 - heute", "titel": "Jobtitel", "firma": "Arbeitgeber, Ort", "punkte": ["Aufgabe/Erfolg 1", "Aufgabe/Erfolg 2"] }\n` + ` ],\n` + ` "studium": [\n` + ` { "zeitraum": "10/2010 - 03/2017", "abschluss": "Studiengang", "institution": "Hochschule", "zusatz": "" }\n` + ` ],\n` + ` "berufsausbildung": [\n` + ` { "zeitraum": "08/2006 - 07/2009", "abschluss": "Ausbildungsberuf / Abschluss", "institution": "Betrieb/Berufskolleg", "zusatz": "" }\n` + ` ],\n` + ` "weiterbildungen": [],\n` + ` "kenntnisse": ["Fähigkeit 1", "Fähigkeit 2"],\n` + ` "sprachen": [ { "sprache": "Deutsch", "niveau": "Muttersprache" }, { "sprache": "Englisch", "niveau": "verhandlungssicher" } ],\n` + ` "hobbys": ["Hobby 1", "Hobby 2"]\n` + ` }\n` + `}`; const notizenText = (llmNotizen && llmNotizen.trim()) ? `# Zusätzliche Notizen des Bewerbers (WICHTIG — nutze diese Angaben aktiv: z. B. vollständige ` + `Firmenanschrift, Ansprechpartner, besondere Hinweise zur Bewerbung oder zum Unternehmen)\n` + `${llmNotizen.trim()}\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` + notizenText + `# Aufgabe\n` + `Erzeuge die TEXTE für ein Anschreiben und einen Lebenslauf, passgenau auf diese ` + `Stelle zugeschnitten. Layout/Design ist bereits vorgegeben — liefere nur die Inhalte. ` + `Beide Dokumente werden auf JE EINER A4-Seite gedruckt, halte dich also kurz.\n\n` + `Verwende EXAKT diese JSON-Struktur und exakt diese Schlüsselnamen ` + `(keine anderen, keine zusätzlichen Felder, "zeitraum" immer als einzelner String):\n\n` + `${skeleton}\n\n` + `Vorgaben (deutscher Lebenslauf nach aktueller Norm/Best Practice):\n` + `- headline: kurze Berufsbezeichnung des Bewerbers, ggf. auf die Zielstelle zugeschnitten.\n` + `- kontakt: E-Mail, Telefon, Ort, Webseite, Geburtsdatum NUR übernehmen, wenn in den ` + `Basis-Unterlagen vorhanden; sonst leerer String. Nichts erfinden. Geburtsdatum als ` + `DD.MM.YYYY (z. B. "03.04.1990"). Keine anderen persönlichen Angaben (Führerschein, ` + `Familienstand, Nationalität o. Ä.) - diese Felder bleiben leer.\n` + `- anschreiben.empfaenger: Empfängerblock. firma = Unternehmen; adresse = Straße + Hausnr. ` + `(nur falls aus Stellenanzeige oder Notizen bekannt, sonst leer); ort = "PLZ Ort"; ` + `ansprechpartner = konkrete Kontaktperson (nur falls bekannt, sonst leer). Nutze eine in den ` + `Notizen angegebene vollständige Firmenanschrift und einen genannten Ansprechpartner. ` + `Ist ein Ansprechpartner bekannt, passe die Anrede an - aber NUR als "Sehr geehrte Frau ," ` + `bzw. "Sehr geehrter Herr ," mit ausschließlichem NACHNAMEN, niemals mit Vorname oder ` + `vollem Namen (also "Sehr geehrte Frau Müller," nicht "Sehr geehrte Frau Anna Müller,"). Verwende ` + `den VOLLSTÄNDIGEN Nachnamen inklusive aller Namensbestandteile wie "Al", "El", "van", "von", "de" ` + `(z. B. "Sehr geehrte Frau Al Haruni," nicht "Sehr geehrte Frau Haruni,"). Steht in den Unterlagen ` + `ein voller Name (z. B. "Omaima Arrami Al Haruni"), verwende in der Anrede nur den Nachnamen ` + `("Frau Al Haruni"). Der ansprechpartner-Wert im JSON darf der volle Name sein (für "z. Hd. …").\n` + `- anschreiben.absaetze: 3–4 kurze, überzeugende Absätze (kein Adressblock, kein Datum, ` + `keine Grußformel hier). Sachlich und positiv, in Aktiv-Sätzen, ohne Floskeln und ` + `Weichspüler ("Ich würde gerne …"). KEINE Gehaltsvorstellungen, KEINE Kündigungsgründe, ` + `KEINE Erwähnung von Lücken/Arbeitslosigkeit, keine Selbstzweifel. Keine Platzhalter — echte Angaben nutzen. ` + `Nur der ERSTE Absatz beginnt mit einem KLEINbuchstaben (die Anrede endet mit Komma, der Satz wird ` + `fortgesetzt), z. B. "mit …" oder "als …" — außer das erste Wort ist ein Substantiv, ein Eigenname oder die ` + `Höflichkeitsform "Ihre/Ihr/Ihnen". Alle WEITEREN Absätze sind neue Sätze und beginnen normal mit GROSSbuchstaben.\n` + `- email: ein KURZES, normgerechtes Begleit-E-Mail-Anschreiben, mit dem die Bewerbung samt Anhängen ` + `(Lebenslauf, Anschreiben, ggf. Zeugnisse) per E-Mail versendet wird. Es ist NICHT das vollständige ` + `Anschreiben - wiederhole dessen Inhalt nicht, fasse dich bewusst knapp (Faustregel: höchstens 6-8 Sätze). ` + `email.betreff: prägnante Betreffzeile, i. d. R. "Bewerbung als " (eine in Anzeige/Notizen genannte ` + `Referenz-/Kennnummer anhängen). email.text: vollständiger, sofort versendbarer E-Mail-Text mit exakt dieser ` + `Struktur - (1) dieselbe Anrede wie im Anschreiben; (2) ein Satz, auf welche Stelle man sich bewirbt (falls ` + `bekannt: wo die Stelle gefunden wurde); (3) ein bis zwei Sätze zur Kernmotivation/Kernqualifikation; ` + `(4) ein Satz mit dem Hinweis, dass die vollständigen Bewerbungsunterlagen im Anhang beiliegen; (5) ein ` + `freundlicher Ausblick auf ein persönliches Gespräch; (6) Grußformel "Mit freundlichen Grüßen"; (7) in der ` + `letzten Zeile der vollständige Name des Bewerbers. Trenne Anrede, Absätze, Grußformel und Name jeweils durch ` + `eine Leerzeile (\\n\\n). Höflich, sachlich, in Aktiv-Sätzen, ohne Floskeln und Weichspüler, keine Platzhalter.\n` + `- profil: 2–3 Sätze Kurzprofil, konkret auf die Zielstelle zugeschnitten (der individuelle Text zur Stelle).\n` + `- Stil/Sprache (gilt für Anschreiben, Profil UND E-Mail): Schreibe so natürlich, wie ein deutscher ` + `Muttersprachler tatsächlich schreibt - konkrete Verben, kurze klare Sätze. KEINE leeren Floskeln oder ` + `Buzzword-Paare (also NICHT "serviceorientiert und klar", "motiviert und zuverlässig", "teamfähig und ` + `belastbar", "kommuniziere ich serviceorientiert und klar - sowohl mit Anwendern als auch im Team"). Jede ` + `genannte Eigenschaft MUSS aus einer konkreten Tätigkeit oder einem Ergebnis hervorgehen, sonst weglassen. ` + `KEINE Meta-Aussagen über die eigene Person oder Sprache wie "Als Muttersprachler ..." - Sprachkenntnisse ` + `gehören ausschließlich in die Sprachen-Sektion des Lebenslaufs, niemals in den Fließtext. Formuliere aus ` + `der konkreten Aufgabe heraus statt über abstrakte Selbstetiketten.\n` + `- berufserfahrung: ALLE Stationen aus den Unterlagen, neueste zuerst — lückenloser Verlauf, ` + `keine Station weglassen, die sonst eine Lücke hinterlässt (Überschneidungen sind ok). ` + `"firma" = "Arbeitgeber, Ort". Je jünger/relevanter eine Station, desto mehr Stichpunkte: die ` + `2–3 jüngsten erhalten je 2–3 knappe "punkte", mittelalte 0–1 Stichpunkt, ältere ein leeres ` + `Array (nur noch Zeile mit Zeitraum, Titel, Firma). Irrelevante alte Stationen dürfen als ` + `letzte knappe Zeile stehenbleiben, wenn sie für einen lückenlosen Verlauf nötig sind — nicht ausschmücken.\n` + `- Bildung: Hochschulstudium → studium; Berufsausbildung/Ausbildungsberuf → berufsausbildung; ` + `Zertifikate/Fortbildungen → weiterbildungen. Schulabschlüsse (Mittlere Reife, Fachoberschulreife, ` + `Fachhochschulreife, Abitur u. ä.) NICHT in den Lebenslauf aufnehmen — komplett weglassen. ` + `Grundschule ebenfalls weglassen.\n` + `- Studium ohne Abschluss: ehrlich, aber neutral als Eintrag in "studium" führen — NIE einen ` + `akademischen Grad voransetzen (kein "B.Sc.", "M.Sc.", "Diplom", "Dr." o. Ä.), der nicht erworben ` + `wurde. "abschluss" = reiner Studiengangname (z. B. "Praktische Informatik"), "zusatz" bleibt leer ` + `(kein "ohne Abschluss"/"nicht beendet"/"abgebrochen"). Ein kurzer, sachlicher Eintrag reicht; ` + `bei vorhandener Berufserfahrung trägt diese den Lebenslauf, das Studium ist eine Nebenzeile.\n` + `- Nur auf die Stelle bezogene Inhalte: alles weglassen, was nicht zur Zielstelle passt.\n` + `- kenntnisse: NUR Fähigkeiten, die für DIESE Stelle wirklich NOTWENDIG sind — d. h. die in der ` + `Stellenanzeige gefordert werden oder ohne die die Position faktisch nicht auszufüllen ist. Keine ` + `"nice-to-have"- oder Allgemein-Plätze (z. B. keine "Teamfähigkeit", "MS Office", "Zuverlässigkeit"), ` + `kein Auffüllen. Lieber 4–6 konkrete, harte Pflichtfähigkeiten als 12 weiche. Jedes Stichwort muss ` + `konkret benennbar sein (Technologie, Methode, Werkzeug, Zertifizierung) und aus den Basis-Unterlagen ` + `belegbar bleiben — nichts erfinden. Reihenfolge: geforderte Pflichtfähigkeiten zuerst.\n` + `- sprachen: IMMER aus den Basis-Unterlagen übernehmen (Sprachkenntnisse gehören in jeden ` + `deutschen Lebenslauf, unabhängig davon, ob die Stelle sie explizit fordert). Deutsch als ` + `Muttersprache sowie alle weiteren dort genannten Sprachen — insbesondere Englisch — ` + `vollständig aufführen, mit Niveau laut Unterlagen. Nichts erfinden, nichts weglassen.\n` + `- weiterbildungen: NUR falls in den Unterlagen vorhanden UND als Anforderung in der ` + `Stellenanzeige genannt bzw. für die Stelle zwingend. Sonst weglassen.\n` + `- hobbys: NUR aufnehmen, wenn sie direkt für den Beruf nützlich sind (z. B. Open-Source-Engagement, ` + `vereinsinterne IT/Verwaltung, ehrenamtliche Projektarbeit mit Bezug zur Stelle); sonst komplett weglassen.\n\n` + `WICHTIG — keine HR-Red-Flags:\n` + `- Lückenloser Zeitverlauf: übernimm ALLE Zeiträume aus den Unterlagen, sodass keine unerklärten ` + `Lücken entstehen. Lasse keine Stationen weg, die sonst eine Lücke hinterlassen (Überschneidungen sind ok).\n` + `- Keine negativen/entwertenden Vermerke: NIEMALS "nicht beendet", "abgebrochen", "ohne Abschluss", ` + `"arbeitslos", "arbeitssuchend", "gekündigt" o. Ä. Lasse "zusatz" in solchen Fällen leer und nenne nur ` + `Zeitraum, Bezeichnung und Institution neutral.\n` + `- Sachlich, positiv, konsistente Datumsformate (MM/YYYY), z. B. "02/2025 - heute".\n` + `- Trennzeichen: in ALLEN Texten (Anschreiben UND Lebenslauf) ausschließlich den einfachen Bindestrich ` + `"-" verwenden - niemals den Halbgeviertstrich (–) oder Geviertstrich (—). Gilt auch für Datumsangaben, ` + `Wertebereiche (z. B. "2-3 Jahre") und Aufzählungen.\n\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).'); 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 honour the schema keys only loosely, so accept common German synonyms. // Normalize one content string: trim, and force a plain hyphen "-" instead of // en/em dash ("–"/"—") everywhere — applies to every field of both documents // so the requirement holds even if the model still emits dashes. const str = (v) => (typeof v === 'string' ? v.trim() : (v == null ? '' : String(v).trim())).replace(/[–—]/g, '-'); 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 []; } // Render CV date ranges with a slash separator (MM/YYYY) regardless of how // the model formatted them, so the timeline reads consistently. Only the // month.year pattern is rewritten; year-only entries and "heute" stay as-is. function normalizeDate(s) { return String(s || '').replace(/\b(\d{1,2})\.(\d{4})\b/g, (_, m, y) => String(m).padStart(2, '0') + '/' + y); } function toZeitraum(v) { if (!v) return ''; let out; if (typeof v === 'string') out = v.trim(); else if (typeof v === 'object') { const von = str(pick(v, ['von', 'from', 'start', 'beginn'])); const bis = str(pick(v, ['bis', 'to', 'ende', 'end'])); out = [von, bis].filter(Boolean).join(' - '); } else out = str(v); // Use a plain hyphen "-" (never en/em dash "–"/"—") for the date range. return normalizeDate(out).replace(/[–—]/g, '-'); } // After an Anrede ending in a comma, the body continues the sentence, so its // first word is lowercase — unless it's a noun, proper name or the polite // "Ihr/Ihre/…". We only downcase clearly-safe sentence openers (prepositions, // conjunctions, adverbs, lowercase pronouns) to avoid breaking those cases. const LOWER_OPENERS = new Set([ 'mit', 'für', 'über', 'auf', 'in', 'bei', 'durch', 'seit', 'nach', 'aus', 'von', 'zu', 'im', 'am', 'zum', 'zur', 'vor', 'gegen', 'um', 'ohne', 'trotz', 'wegen', 'aufgrund', 'dank', 'während', 'per', 'laut', 'als', 'wie', 'da', 'weil', 'nachdem', 'hiermit', 'anbei', 'beiliegend', 'gerne', 'gern', 'sehr', 'bereits', 'schon', 'momentan', 'derzeit', 'aktuell', 'nun', 'mittlerweile', 'inzwischen', 'ich', 'mein', 'meine', 'meinen', 'meiner', 'meinem', 'meines', ]); // Capitalize the first letter of a paragraph (every paragraph is a new sentence). function capitalizeFirstLetter(p) { return String(p).replace(/^(\s*)([a-zäöüß])/, (_, lead, ch) => lead + ch.toUpperCase()); } // Lowercase the first word only if it's a clearly-safe sentence opener. function lowercaseFirstIfOpener(p) { const m = String(p).match(/^(\s*)([A-Za-zÄÖÜäöüß]+)/); if (!m) return p; const word = m[2]; const first = word[0]; const isUpper = (first >= 'A' && first <= 'Z') || 'ÄÖÜ'.includes(first); if (isUpper && LOWER_OPENERS.has(word.toLowerCase())) { return m[1] + first.toLowerCase() + p.slice(m[1].length + 1); } return p; } // Fix paragraph casing: all paragraphs start capitalized (new sentences); the // first paragraph continues the sentence after the Anrede comma, so a safe // opener there is lowercased. function fixParagraphCasing(anrede, absaetze) { if (!absaetze.length) return absaetze; const out = absaetze.map(capitalizeFirstLetter); if (anrede.trim().endsWith(',')) out[0] = lowercaseFirstIfOpener(out[0]); return out; } // Defensive: the salutation must be "Sehr geehrte Frau " / // "Sehr geehrter Herr " with ONLY the last name — never a first // name or full name. Strip any leading given names the model may have added, // but keep academic titles (Dr., Prof., …) and name particles (van, von, …). const SAL_TITLES = new Set(['dr', 'prof', 'dipl', 'ing', 'rer', 'nat', 'mag', 'phd', 'habil', 'dr.habil']); const SAL_PARTICLES = new Set(['van', 'de', 'von', 'zu', 'der', 'den', 'ten', 'da', 'di', 'del', 'della', 'la', 'le', 'vom', 'zur', 'mc', 'mac', 'al', 'el']); function cleanAnrede(anrede) { return String(anrede).replace( /^(Sehr geehrte\s+Frau|Sehr geehrter\s+Herr)\s+(.+?)(,?\s*)$/i, (m, sal, name, tail) => { const words = String(name).trim().split(/\s+/); if (words.length <= 1) return m; // already a single name const kept = words.filter((w, i) => { if (i === words.length - 1) return true; // last word = surname const lw = w.toLowerCase().replace(/[.­]/g, ''); return SAL_TITLES.has(lw) || SAL_PARTICLES.has(lw); // keep titles / particles }); if (kept.length === words.length) return m; // nothing to strip return `${sal} ${kept.join(' ')}${tail}`; }, ); } // Defensive: never let an HR red-flag phrase reach the PDF, even if the model // ignores the instruction. const RED_FLAG = /(nicht\s*beendet|abgebrochen|abbruch|ohne\s*abschluss|arbeits(los|suchend)|gek(ü|ue)ndigt|entlassen)/i; const safeNote = (s) => (RED_FLAG.test(s) ? '' : s); function mapEdu(e) { return { zeitraum: toZeitraum(pick(e, ['zeitraum', 'zeit', 'dauer', 'period', 'jahr']) || e.zeitraum), abschluss: str(pick(e, ['abschluss', 'titel', 'grad', 'degree', 'qualifikation', 'studiengang', 'fach', 'name'])), institution: str(pick(e, ['institution', 'schule', 'hochschule', 'universitaet', 'universität', 'einrichtung', 'betrieb', 'organisation', 'ort'])), zusatz: safeNote(str(pick(e, ['zusatz', 'note', 'hinweis', 'schwerpunkt', 'details']))), }; } const eduList = (obj, keys) => pickArray(obj, keys).map(mapEdu).filter((e) => e.abschluss || e.institution); function normalizeResult(parsed) { const k = parsed.kontakt || parsed.contact || {}; const a = parsed.anschreiben || parsed.cover_letter || {}; const l = parsed.lebenslauf || parsed.cv || parsed.resume || {}; // Typed education sections; fall back to a lumped "ausbildung"/"bildung" list. let studium = eduList(l, ['studium', 'hochschulstudium', 'studies']); let berufsausbildung = eduList(l, ['berufsausbildung', 'ausbildung', 'berufsabschluss', 'apprenticeship']); let schulbildung = eduList(l, ['schulbildung', 'schulausbildung', 'schule', 'schooling']); const weiterbildungen = eduList(l, ['weiterbildungen', 'weiterbildung', 'zertifikate', 'certifications', 'fortbildungen']); if (!studium.length && !berufsausbildung.length && !schulbildung.length) { const lumped = eduList(l, ['ausbildung', 'bildung', 'education', 'qualifikationen']); berufsausbildung = lumped; } return { headline: str(pick(parsed, ['headline', 'berufsbezeichnung', 'titel', 'position', 'beruf', 'rolle'])), 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'])), geburtsdatum: str(pick(k, ['geburtsdatum', 'geboren', 'birthdate', 'dob', 'geburt'])), fuehrerschein: str(pick(k, ['fuehrerschein', 'führerschein', 'driving_license', 'license', 'licence', 'fuehrerscheinklasse'])), }, anschreiben: (() => { const anrede = cleanAnrede(str(pick(a, ['anrede', 'salutation']))); const absaetze = pickArray(a, ['absaetze', 'absätze', 'paragraphs', 'text', 'inhalt']).map(str).filter(Boolean); const e = a.empfaenger || a.recipient || a.empfänger || {}; return { empfaenger: { firma: str(pick(e, ['firma', 'unternehmen', 'company', 'name'])), adresse: str(pick(e, ['adresse', 'strasse', 'straße', 'street', 'anschrift'])), ort: str(pick(e, ['ort', 'plz_ort', 'plzort', 'stadt', 'city', 'plz'])), ansprechpartner: str(pick(e, ['ansprechpartner', 'ansprechperson', 'kontaktperson', 'contact'])), }, betreff: str(pick(a, ['betreff', 'subject', 'titel'])), anrede, absaetze: fixParagraphCasing(anrede, absaetze), gruss: str(pick(a, ['gruss', 'gruß', 'grussformel', 'closing', 'schluss'])), }; })(), email: (() => { const em = parsed.email || parsed.email_anschreiben || parsed.mail || parsed.begleitmail || {}; if (typeof em === 'string') return { betreff: '', text: str(em) }; return { betreff: str(pick(em, ['betreff', 'subject', 'titel'])), text: str(pick(em, ['text', 'body', 'inhalt', 'nachricht', 'mailtext', 'anschreiben'])), }; })(), lebenslauf: { profil: str(pick(l, ['profil', 'kurzprofil', 'zusammenfassung', 'summary', 'ueberblick', 'über_mich'])), 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', 'bullets', 'details']) .map((p) => safeNote(str(p))).filter(Boolean), })) .filter((e) => e.titel || e.firma), studium, berufsausbildung, schulbildung, weiterbildungen, 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), hobbys: pickArray(l, ['hobbys', 'hobbies', 'interessen', 'interests']) .map((s) => (typeof s === 'object' ? str(pick(s, ['name', 'bezeichnung'])) : str(s))) .filter(Boolean), }, }; } // =========================================================================== // 2. Shared PDF primitives // =========================================================================== const PT = 0.352778; // pt -> mm const PAGE = { w: 210, h: 297 }; const MIN_SCALE = 0.6; // Low-level text writer. `cur` is a {y} cursor; advances it. `dry` = measure only. function write(doc, S, dry, cur, text, o) { const pt = o.pt; const factor = o.factor == null ? 1.3 : o.factor; if (text == null || text === '') return; doc.setFont('Lato', o.style || 'normal'); doc.setFontSize(pt * S); if (o.charSpace) doc.setCharSpace(o.charSpace * S); const content = o.upper ? String(text).toUpperCase() : String(text); const lines = doc.splitTextToSize(content, o.width); for (const ln of lines) { if (!dry) { doc.setTextColor(o.color[0], o.color[1], o.color[2]); const drawX = o.align === 'right' ? o.right : o.x; doc.text(ln, drawX, cur.y + pt * S * PT * 0.76, { align: o.align || 'left' }); } cur.y += pt * S * PT * factor; } if (o.charSpace) doc.setCharSpace(0); } function rule(doc, S, dry, y, x1, x2, color, weight) { if (dry) return; doc.setDrawColor(color[0], color[1], color[2]); doc.setLineWidth(weight * S); doc.line(x1, y, x2, y); } // =========================================================================== // Resume (Lebenslauf) — monochrome single column, reverse-chronological // =========================================================================== const RV = { mx: 20, top: 18, bottom: 14 }; const RV_W = PAGE.w - RV.mx * 2; // 170 content width const RV_R = PAGE.w - RV.mx; // 190 right edge (right-aligned dates) const RV_DATE_W = 34; // reserved right zone for the date column const RC = { ink: [26, 26, 26], sub: [92, 98, 106], hair: [206, 208, 212], // One deep, professional navy-slate accent — used sparingly for structure // (name, section labels, the short header rule, bullet markers). It signals // care and confidence without ever reading as flashy; body text stays near // black so the résumé remains crisp and ATS-safe. accent: [31, 64, 104], }; // Section heading: an accent-coloured, tracked uppercase label over a full-width // hairline, with a short heavier accent segment at the start — a small, precise // motif that gives every section a confident, consistent anchor. function cvHeading(doc, S, dry, cur, title, ruleRight) { const pt = 10.5; cur.y += 3.6 * S; doc.setFont('Lato', 'bold'); doc.setFontSize(pt * S); const lineY = cur.y + pt * S * PT * 1.08; if (!dry) { doc.setCharSpace(0.9 * S); doc.setTextColor(RC.accent[0], RC.accent[1], RC.accent[2]); doc.text(String(title).toUpperCase(), RV.mx, cur.y + pt * S * PT * 0.76); doc.setCharSpace(0); const right = ruleRight || RV_R; doc.setDrawColor(RC.hair[0], RC.hair[1], RC.hair[2]); doc.setLineWidth(0.3 * S); doc.line(RV.mx, lineY, right, lineY); doc.setDrawColor(RC.accent[0], RC.accent[1], RC.accent[2]); doc.setLineWidth(1.0 * S); doc.line(RV.mx, lineY, Math.min(RV.mx + 15 * S, right), lineY); } cur.y += pt * S * PT + 4.0 * S; } // One row: title (left) + muted date (right-aligned) on a single baseline. // `leftStyle` controls the title weight ('bold' for experience/education, // 'normal' for the language list). function cvRow(doc, S, dry, cur, leftText, rightText, leftPt, leftStyle) { leftPt = leftPt || 11; leftStyle = leftStyle || 'bold'; const rightPt = 9.5; const leftW = RV_W - RV_DATE_W; doc.setFont('Lato', leftStyle); doc.setFontSize(leftPt * S); const lines = doc.splitTextToSize(String(leftText || ''), leftW); const lineH = leftPt * S * PT * 1.18; if (!dry) { doc.setTextColor(RC.ink[0], RC.ink[1], RC.ink[2]); lines.forEach((ln, i) => doc.text(ln, RV.mx, cur.y + i * lineH + leftPt * S * PT * 0.76)); if (rightText) { doc.setFont('Lato', 'normal'); doc.setFontSize(rightPt * S); doc.setTextColor(RC.sub[0], RC.sub[1], RC.sub[2]); doc.text(String(rightText), RV_R, cur.y + rightPt * S * PT * 0.76, { align: 'right' }); } } cur.y += lineH * Math.max(1, lines.length); } function cvBullets(doc, S, dry, cur, items, x, w, pt = 9.3) { const tx = x + 3.6 * S; const tw = w - 3.6 * S; for (const it of items) { doc.setFont('Lato', 'normal'); doc.setFontSize(pt * S); const lines = doc.splitTextToSize(String(it), tw); lines.forEach((ln, i) => { if (!dry) { if (i === 0) { // Small accent square marker (echoes the section-label motif). const sz = 1.15 * S; doc.setFillColor(RC.accent[0], RC.accent[1], RC.accent[2]); doc.rect(x + 0.2 * S, cur.y + pt * S * PT * 0.42 - sz / 2, sz, sz, 'F'); } doc.setTextColor(RC.ink[0], RC.ink[1], RC.ink[2]); doc.text(ln, tx, cur.y + pt * S * PT * 0.76); } cur.y += pt * S * PT * 1.3; }); cur.y += 0.9 * S; } } // Two-column bullet grid — used for the skills list so a dozen short items // stay compact and scannable instead of one long vertical column. function cvTwoColBullets(doc, S, dry, cur, items, x, w, pt = 9.3) { const gap = 8 * S; const colW = (w - gap) / 2; const half = Math.ceil(items.length / 2); const lc = { y: cur.y }, rc = { y: cur.y }; cvBullets(doc, S, dry, lc, items.slice(0, half), x, colW, pt); cvBullets(doc, S, dry, rc, items.slice(half), x + colW + gap, colW, pt); cur.y = Math.max(lc.y, rc.y); } function cvExperience(doc, S, dry, cur, e) { cvRow(doc, S, dry, cur, e.titel, e.zeitraum, 11, 'bold'); if (e.firma) write(doc, S, dry, cur, e.firma, { pt: 9.7, color: RC.sub, x: RV.mx, width: RV_W, factor: 1.18 }); if (e.punkte && e.punkte.length) { cur.y += 1 * S; cvBullets(doc, S, dry, cur, e.punkte, RV.mx, RV_W); } cur.y += 3 * S; } function cvEducation(doc, S, dry, cur, e) { cvRow(doc, S, dry, cur, e.abschluss, e.zeitraum, 11, 'bold'); if (e.institution) write(doc, S, dry, cur, e.institution, { pt: 9.7, color: RC.sub, x: RV.mx, width: RV_W, factor: 1.18 }); if (e.zusatz) write(doc, S, dry, cur, e.zusatz, { pt: 9.5, color: RC.sub, x: RV.mx, width: RV_W, factor: 1.2 }); cur.y += 2.6 * S; } // Header: name + position (top-left) and a contact block, separated by a // confident rule — the canonical modern CV head. With an applicant photo // (`foto`), the photo sits top-right and the contact details move to the // left column under the name; without one, the contact block is right-aligned // top-right as before. function composeCvHeader(doc, S, dry, cur, header, titel, foto) { const top = cur.y; const contactLines = [header.email, header.telefon, cityName(header) || header.ort, header.webseite].filter(Boolean); // Optional applicant photo (top-right). Fit a portrait frame preserving // aspect ratio; a thin hairline border gives it a clean edge on white. const PHOTO_W = 36, PHOTO_H = 40; // mm (frame cap; 20% larger than 30x40) let photoOk = false, photoW = 0, photoH = 0; if (foto && foto.dataUrl) { try { const props = doc.getImageProperties(foto.dataUrl); const maxW = PHOTO_W * S, maxH = PHOTO_H * S; photoW = maxW; photoH = photoW * props.height / props.width; if (photoH > maxH) { photoH = maxH; photoW = photoH * props.width / props.height; } photoOk = props.width > 0 && props.height > 0; } catch (e) { photoOk = false; } } const gapPhoto = 6 * S; const leftW = photoOk ? (RV_W - photoW - gapPhoto) : RV_W * 0.62; write(doc, S, dry, cur, header.name, { pt: 22, style: 'bold', color: RC.accent, x: RV.mx, width: leftW, factor: 1.08 }); if (titel) write(doc, S, dry, cur, titel, { pt: 11.5, color: RC.sub, x: RV.mx, width: leftW, factor: 1.32, charSpace: 0.2 }); if (header.geburtsdatum) write(doc, S, dry, cur, `Geburtsdatum: ${header.geburtsdatum}`, { pt: 9, color: RC.sub, x: RV.mx, width: leftW, factor: 1.3 }); if (photoOk) { // contact details left-aligned under the name/title (photo takes the right) contactLines.forEach((line) => write(doc, S, dry, cur, line, { pt: 9, color: RC.sub, x: RV.mx, width: leftW, factor: 1.34 })); } const leftBottom = cur.y; let photoBottom = leftBottom, photoLeft = RV_R; if (photoOk) { const px = RV_R - photoW; const py = top; photoLeft = px; photoBottom = py + photoH; if (!dry) { doc.addImage(foto.dataUrl, foto.format || 'PNG', px, py, photoW, photoH); doc.setDrawColor(RC.hair[0], RC.hair[1], RC.hair[2]); doc.setLineWidth(0.3 * S); doc.rect(px, py, photoW, photoH); } // Tuck the first section up right under the contact block; the photo's // lower flank is filled by the Profil (handled in cvProfil), not by a gap. cur.y = leftBottom + 1.5 * S; } else { const rc = { y: top }; contactLines.forEach((line) => write(doc, S, dry, rc, line, { pt: 9, color: RC.sub, x: RV.mx, width: RV_W, align: 'right', right: RV_R, factor: 1.34 })); cur.y = Math.max(leftBottom, rc.y) + 3.2 * S; cur.y += 2.5 * S; } return { photoOk, photoBottom, photoLeft, gapPhoto }; } // Profil section that flows around an optional photo: the heading and first // lines tuck into the empty space beside the photo's lower flank (with the // hairline stopping short of the photo), then the text spills to full width // once it clears the photo's bottom edge. function cvProfil(doc, S, dry, cur, text, geo) { const beside = geo.photoOk && (cur.y + 3 * S) < geo.photoBottom; const ruleRight = beside ? (geo.photoLeft - geo.gapPhoto) : RV_R; cvHeading(doc, S, dry, cur, 'Profil', ruleRight); const pt = 10, factor = 1.45; const lineH = pt * S * PT * factor; const narrowW = geo.photoOk ? (geo.photoLeft - geo.gapPhoto - RV.mx) : RV_W; doc.setFont('Lato', 'normal'); doc.setFontSize(pt * S); const drawLine = (ln) => { if (!dry) { doc.setTextColor(RC.ink[0], RC.ink[1], RC.ink[2]); doc.text(ln, RV.mx, cur.y + pt * S * PT * 0.76); } cur.y += lineH; }; let remaining = String(text); if (geo.photoOk && cur.y < geo.photoBottom) { const nBeside = Math.floor((geo.photoBottom - cur.y) / lineH); if (nBeside > 0) { const narrow = doc.splitTextToSize(remaining, narrowW); const take = Math.min(nBeside, narrow.length); for (let i = 0; i < take; i++) drawLine(narrow[i]); remaining = narrow.slice(take).join(' '); } } if (remaining) doc.splitTextToSize(remaining, RV_W).forEach(drawLine); // Ensure the next section starts below the photo. cur.y = Math.max(cur.y, geo.photoBottom); } function composeCV(doc, S, dry, cur, { cv, header, titel, foto }) { const geo = composeCvHeader(doc, S, dry, cur, header, titel, foto); if (cv.profil) { cvProfil(doc, S, dry, cur, cv.profil, geo); } else { cur.y = Math.max(cur.y, geo.photoBottom); } // Kernkompetenzen right after the Profil: the recruiter sees the role-match // in the first seconds, before diving into the experience below. if (cv.kenntnisse.length) { cvHeading(doc, S, dry, cur, 'Kernkompetenzen'); cvTwoColBullets(doc, S, dry, cur, cv.kenntnisse, RV.mx, RV_W); } if (cv.berufserfahrung.length) { cvHeading(doc, S, dry, cur, 'Berufserfahrung'); cv.berufserfahrung.forEach((e) => cvExperience(doc, S, dry, cur, e)); } [['Studium', cv.studium], ['Berufsausbildung', cv.berufsausbildung], ['Weiterbildungen', cv.weiterbildungen]].forEach(([t, entries]) => { if (!entries || !entries.length) return; cvHeading(doc, S, dry, cur, t); entries.forEach((e) => cvEducation(doc, S, dry, cur, e)); }); if (cv.sprachen.length) { cvHeading(doc, S, dry, cur, 'Sprachen'); cv.sprachen.forEach((s) => cvRow(doc, S, dry, cur, s.sprache, s.niveau, 9.7, 'normal')); } if (cv.hobbys.length) { cvHeading(doc, S, dry, cur, 'Interessen'); write(doc, S, dry, cur, cv.hobbys.join(', '), { pt: 9.7, color: RC.ink, x: RV.mx, width: RV_W, factor: 1.3 }); } } function renderCV(cv, header, titel, foto) { const doc = makeDoc(); const m = { y: RV.top }; composeCV(doc, 1, true, m, { cv, header, titel, foto }); const need = m.y - RV.top; const avail = PAGE.h - RV.top - RV.bottom; let S = 1; if (need > avail) S = Math.max(MIN_SCALE, (avail / need) * 0.99); composeCV(doc, S, false, { y: RV.top }, { cv, header, titel, foto }); return Buffer.from(doc.output('arraybuffer')); } // =========================================================================== // Cover letter — clean, single-column DIN-5008 business letter (monochrome) // =========================================================================== const LET = { mx: 25, mr: 20, top: 24, bottom: 18 }; // DIN-ish margins const LET_R = PAGE.w - LET.mr; // 190 const LET_W = LET_R - LET.mx; // 165 // Grayscale body with the same deep navy-slate accent as the résumé, so the // cover letter and CV read as one deliberately designed application set. const LC = { ink: [26, 26, 26], muted: [92, 98, 106], hair: [206, 208, 212], accent: [31, 64, 104], }; // Extract just the town from a possibly-full address ("Feldstraße 76, 45968 // Gladbeck" / "45968 Gladbeck" / "Gladbeck" → "Gladbeck"). function cityName(header) { let s = header.ort || header.adresse || ''; const parts = String(s).split(',').map((p) => p.trim()).filter(Boolean); if (parts.length) s = parts[parts.length - 1]; return s.replace(/\b\d{5}\b/g, '').replace(/\s+/g, ' ').trim(); } function composeLetter(doc, S, dry, cur, { letter, header, job, anlagen, signatur }) { const x = LET.mx; const right = LET_R; const width = LET_W; 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'); const stadt = cityName(header); // just the town for the date line ("Ort, den …") // --- Letterhead: name (left) + contact (right), accented rule --- const top = cur.y; write(doc, S, dry, cur, header.name, { pt: 17, style: 'bold', color: LC.accent, x, width: width * 0.55, factor: 1.15 }); if (header.headline) write(doc, S, dry, cur, header.headline, { pt: 10, color: LC.muted, x, width: width * 0.55, factor: 1.25, charSpace: 0.2 }); const leftBottom = cur.y; const contactLines = []; if (header.adresse) String(header.adresse).split(',').forEach((s) => { if (s.trim()) contactLines.push(s.trim()); }); if (header.telefon) contactLines.push(header.telefon); if (header.email) contactLines.push(header.email); const rc = { y: top }; contactLines.forEach((line) => write(doc, S, dry, rc, line, { pt: 9, color: LC.muted, x, width, align: 'right', right, factor: 1.32 })); // Hairline across, with a short heavier accent segment at the start — the // same motif that anchors every section of the résumé. cur.y = Math.max(leftBottom, rc.y) + 3 * S; if (!dry) { doc.setDrawColor(LC.hair[0], LC.hair[1], LC.hair[2]); doc.setLineWidth(0.3 * S); doc.line(x, cur.y, right, cur.y); doc.setDrawColor(LC.accent[0], LC.accent[1], LC.accent[2]); doc.setLineWidth(1.0 * S); doc.line(x, cur.y, x + 15 * S, cur.y); } cur.y += 13 * S; // --- Recipient (Anschriftfeld) — from the LLM's empfaenger, falling back to job --- const emp = letter.empfaenger || {}; const empFirma = emp.firma || job.firma || ''; const empOrt = emp.ort || job.ort || ''; if (empFirma) write(doc, S, dry, cur, empFirma, { pt: 10.5, color: LC.ink, x, width, factor: 1.32 }); if (emp.ansprechpartner) write(doc, S, dry, cur, `z. Hd. ${emp.ansprechpartner}`, { pt: 10.5, color: LC.ink, x, width, factor: 1.32 }); if (emp.adresse) write(doc, S, dry, cur, emp.adresse, { pt: 10.5, color: LC.ink, x, width, factor: 1.32 }); if (empOrt) write(doc, S, dry, cur, empOrt, { pt: 10.5, color: LC.ink, x, width, factor: 1.32 }); // --- Date (right-aligned) --- cur.y += 7 * S; const dateLine = stadt ? `${stadt}, den ${today}` : today; write(doc, S, dry, cur, dateLine, { pt: 10, color: LC.ink, x, width, align: 'right', right, factor: 1.2 }); // --- Subject (bold navy, no "Betreff:" label) — the letter's headline --- cur.y += 7 * S; write(doc, S, dry, cur, betreff, { pt: 11, style: 'bold', color: LC.accent, x, width, factor: 1.3 }); // --- Salutation + body --- cur.y += 6 * S; if (letter.anrede) { write(doc, S, dry, cur, letter.anrede, { pt: 10.5, color: LC.ink, x, width, factor: 1.4 }); cur.y += 3 * S; } letter.absaetze.forEach((p, i) => { if (i > 0) cur.y += 3.2 * S; write(doc, S, dry, cur, p, { pt: 10.5, color: LC.ink, x, width, factor: 1.52 }); }); // --- Closing + signature --- cur.y += 6 * S; if (letter.gruss) write(doc, S, dry, cur, letter.gruss, { pt: 10.5, color: LC.ink, x, width, factor: 1.3 }); // Signature image (if provided) directly under the closing, replacing the // typed name; otherwise leave room and print the name. let sigOk = false, sigW = 0, sigH = 0; if (signatur && signatur.dataUrl) { try { const props = doc.getImageProperties(signatur.dataUrl); const maxW = 57.6 * S, maxH = 24 * S; // 20% larger than before (48 / 20 mm) sigW = maxW; sigH = sigW * props.height / props.width; if (sigH > maxH) { sigH = maxH; sigW = sigH * props.width / props.height; } sigOk = props.width > 0 && props.height > 0; } catch (e) { sigOk = false; } } if (sigOk) { cur.y += 3 * S; if (!dry) doc.addImage(signatur.dataUrl, signatur.format || 'PNG', x, cur.y, sigW, sigH); cur.y += sigH; } else { cur.y += 13 * S; // room for a handwritten signature write(doc, S, dry, cur, header.name, { pt: 10.5, style: 'bold', color: LC.ink, x, width, factor: 1.2 }); } // --- Enclosures --- if (anlagen && anlagen.length) { cur.y += 8 * S; write(doc, S, dry, cur, (anlagen.length > 1 ? 'Anlagen: ' : 'Anlage: ') + anlagen.join(', '), { pt: 9, color: LC.muted, x, width, factor: 1.2 }); } } function renderSingleColumn(compose) { const doc = makeDoc(); const m = { y: LET.top }; compose(doc, 1, true, m); const need = m.y - LET.top; const avail = PAGE.h - LET.top - LET.bottom; let S = 1; if (need > avail) S = Math.max(MIN_SCALE, (avail / need) * 0.99); compose(doc, S, false, { y: LET.top }); return Buffer.from(doc.output('arraybuffer')); } function buildHeader(settings, kontakt, headline) { return { name: (settings && settings.name) || '', adresse: (settings && settings.adresse) || '', headline, ...kontakt, }; } function renderLebenslaufPdf(cv, header, foto) { // Title at the top = the position (headline), falling back to the name. return renderCV(cv, header, header.headline || '', foto); } function renderAnschreibenPdf(letter, header, job, anlagen, signatur) { return renderSingleColumn((doc, S, dry, cur) => composeLetter(doc, S, dry, cur, { letter, header, job, anlagen, signatur })); } // =========================================================================== // 3. Public entry point // =========================================================================== function hasAnschreiben(a) { return a && a.absaetze && a.absaetze.length > 0; } function hasLebenslauf(l) { return l && (l.berufserfahrung.length || l.studium.length || l.berufsausbildung.length || l.schulbildung.length || l.weiterbildungen.length || l.kenntnisse.length); } async function generateApplicationDocuments({ job, basisDokumente, settings, zusatzAnlagen = [], llmNotizen = '', signatur = null, bewerbungsfoto = null }) { const data = await generateTailoredTexts({ job, basisDokumente, settings, llmNotizen }); const headline = data.headline || job.stelle || ''; const header = buildHeader(settings, data.kontakt, headline); 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 = []; const cvPresent = hasLebenslauf(data.lebenslauf); // "Anlagen" list for the letter: Lebenslauf first, then any static extras. const anlagen = [ ...(cvPresent ? ['Lebenslauf'] : []), ...zusatzAnlagen.filter(Boolean), ]; if (hasAnschreiben(data.anschreiben)) { documents.push({ name: `Anschreiben - ${label}`.trim(), filename: `Anschreiben_${suffix}.pdf`, mime: 'application/pdf', buffer: renderAnschreibenPdf(data.anschreiben, header, job, anlagen, signatur), }); } if (cvPresent) { documents.push({ name: `Lebenslauf - ${label}`.trim(), filename: `Lebenslauf_${suffix}.pdf`, mime: 'application/pdf', buffer: renderLebenslaufPdf(data.lebenslauf, header, bewerbungsfoto), }); } if (documents.length === 0) { throw new Error('Die KI hat keine verwertbaren Unterlagen erzeugt.'); } return { documents, email: data.email || { betreff: '', text: '' } }; } // =========================================================================== // AI reply drafting — draft a professional German reply to an incoming email // =========================================================================== // Small shared Ollama JSON call (used by the reply drafter). async function ollamaChatJSON({ system, user, schema, temperature = 0.5 }) { const apiKey = process.env.OLLAMA_API_KEY; if (!apiKey) throw new Error('OLLAMA_API_KEY ist nicht gesetzt.'); 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: schema, options: { temperature }, messages: [ { role: 'system', content: system }, { role: 'user', content: user }, ], }), 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 textOut = ((data && data.message && data.message.content) || '').trim(); if (!textOut) throw new Error('Die KI hat keine Antwort geliefert (leerer Inhalt).'); const cleaned = textOut.replace(/^\s*```(?:json)?\s*/i, '').replace(/\s*```\s*$/i, '').trim(); try { return JSON.parse(cleaned); } catch (e) { const m = cleaned.match(/\{[\s\S]*\}/); if (m) { try { return JSON.parse(m[0]); } catch (_) { /* ignore */ } } } throw new Error('Die KI-Antwort konnte nicht als JSON gelesen werden.'); } const REPLY_SCHEMA = { type: 'object', properties: { betreff: { type: 'string' }, text: { type: 'string' } }, required: ['betreff', 'text'], }; // Draft a reply to a recruiter/company e-mail in the context of an application. // `incoming` = { from, subject, text }; `job` = { firma, stelle }; // `settings` = { name, ... }. `hinweise` = optional free-text steering. async function generateEmailReply({ incoming, job = {}, settings = {}, hinweise = '' }) { const name = (settings && settings.name) || 'der Bewerber'; const system = 'Du bist ' + name + ' und schreibst als Bewerber eine höfliche, professionelle, ' + 'deutschsprachige Antwort-E-Mail an ein Unternehmen im laufenden Bewerbungsprozess. ' + 'Du antwortest konkret auf die eingegangene Nachricht. Wichtig: Du erfindest KEINE ' + 'Fakten (keine erfundenen Termine, Zahlen, Zusagen). Wenn eine konkrete Angabe nötig ist, ' + 'die du nicht kennst (z. B. ein genauer Terminvorschlag), setze einen klar erkennbaren ' + 'Platzhalter in eckigen Klammern, z. B. "[Terminvorschlag einfügen]". Schreibe natürlich, ' + 'knapp und verbindlich, wie ein deutscher Muttersprachler - ohne leere Floskeln, ohne ' + 'Buzzword-Paare und ohne Selbstetiketten wie "Als Muttersprachler ...".'; const user = `# Kontext der Bewerbung\n` + `Unternehmen: ${job.firma || '-'}\n` + `Stelle: ${job.stelle || '-'}\n` + `Bewerber: ${name}\n\n` + `# Eingegangene E-Mail\n` + `Von: ${incoming.from || '-'}\n` + `Betreff: ${incoming.subject || '-'}\n\n` + `${incoming.text || ''}\n\n` + (hinweise && hinweise.trim() ? `# Hinweise für die Antwort (aktiv berücksichtigen)\n${hinweise.trim()}\n\n` : '') + `# Aufgabe\n` + `Formuliere eine passende Antwort-E-Mail. Struktur des Feldes "text": Anrede (an den ` + `konkreten Absender, falls Name erkennbar, sonst "Sehr geehrte Damen und Herren,"), ` + `2-4 kurze Absätze, Grußformel "Mit freundlichen Grüßen" und in der letzten Zeile der ` + `Name "${name}". Trenne Anrede, Absätze, Gruß und Name durch je eine Leerzeile (\\n\\n). ` + `"betreff": sinnvolle Betreffzeile, i. d. R. "Re: ${incoming.subject || ''}". ` + `Verwende ausschließlich den einfachen Bindestrich "-" (niemals – oder —). ` + `Antworte AUSSCHLIESSLICH mit dem JSON-Objekt, ohne Markdown, ohne Code-Fences.`; const parsed = await ollamaChatJSON({ system, user, schema: REPLY_SCHEMA, temperature: 0.5 }); return { betreff: str(pick(parsed, ['betreff', 'subject', 'titel'])) || ('Re: ' + (incoming.subject || '')), text: str(pick(parsed, ['text', 'body', 'inhalt', 'nachricht'])), }; } module.exports = { generateApplicationDocuments, generateEmailReply, generateTailoredTexts, renderLebenslaufPdf, renderAnschreibenPdf, };