diff --git a/lib/documents.js b/lib/documents.js index a666353..712c349 100644 --- a/lib/documents.js +++ b/lib/documents.js @@ -16,6 +16,18 @@ const OLLAMA_TIMEOUT_MS = Number(process.env.OLLAMA_TIMEOUT_MS || 180000); // 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: { @@ -58,19 +70,10 @@ const OUTPUT_SCHEMA = { required: ['zeitraum', 'titel', 'firma', 'beschreibung'], }, }, - ausbildung: { - type: 'array', - items: { - type: 'object', - properties: { - zeitraum: { type: 'string' }, - abschluss: { type: 'string' }, - institution: { type: 'string' }, - zusatz: { type: 'string' }, - }, - required: ['zeitraum', 'abschluss', 'institution', 'zusatz'], - }, - }, + studium: { type: 'array', items: EDU_ITEM }, + berufsausbildung: { type: 'array', items: EDU_ITEM }, + schulbildung: { type: 'array', items: EDU_ITEM }, + weiterbildungen: { type: 'array', items: EDU_ITEM }, kenntnisse: { type: 'array', items: { type: 'string' } }, sprachen: { type: 'array', @@ -82,7 +85,7 @@ const OUTPUT_SCHEMA = { }, hobbys: { type: 'array', items: { type: 'string' } }, }, - required: ['berufserfahrung', 'ausbildung', 'kenntnisse', 'sprachen', 'hobbys'], + required: ['berufserfahrung', 'studium', 'berufsausbildung', 'schulbildung', 'weiterbildungen', 'kenntnisse', 'sprachen', 'hobbys'], }, }, required: ['headline', 'kontakt', 'anschreiben', 'lebenslauf'], @@ -146,9 +149,16 @@ async function generateTailoredTexts({ job, basisDokumente, settings }) { ` "berufserfahrung": [\n` + ` { "zeitraum": "02.2025 – heute", "titel": "Jobtitel", "firma": "Arbeitgeber, Ort", "beschreibung": "1 kurzer Satz (optional)" }\n` + ` ],\n` + - ` "ausbildung": [\n` + - ` { "zeitraum": "2012 – 2015", "abschluss": "Abschluss", "institution": "Schule/Hochschule", "zusatz": "" }\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` + + ` "schulbildung": [\n` + + ` { "zeitraum": "08.2000 – 07.2006", "abschluss": "Schulabschluss (z. B. Fachoberschulreife)", "institution": "Schule, Ort", "zusatz": "" }\n` + + ` ],\n` + + ` "weiterbildungen": [],\n` + ` "kenntnisse": ["Fähigkeit 1", "Fähigkeit 2"],\n` + ` "sprachen": [ { "sprache": "Deutsch", "niveau": "Muttersprache" } ],\n` + ` "hobbys": ["Hobby 1", "Hobby 2"]\n` + @@ -166,17 +176,28 @@ async function generateTailoredTexts({ job, basisDokumente, settings }) { `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:\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, Führerschein NUR übernehmen, ` + `wenn in den Basis-Unterlagen vorhanden; 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: relevanteste Stationen (neueste zuerst). "firma" = "Arbeitgeber, Ort". ` + - `"beschreibung" = optional EIN kurzer Satz (sonst leer).\n` + - `- ausbildung: relevante Abschlüsse/Stationen. "zusatz" optional (z. B. "Abschluss: …" oder "nicht beendet").\n` + + `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.\n` + + `- berufserfahrung: ALLE Stationen aus den Unterlagen, neueste zuerst. "firma" = "Arbeitgeber, Ort". ` + + `Nur die 2–3 jüngsten/relevantesten erhalten EINEN kurzen "beschreibung"-Satz, ältere lässt du leer.\n` + + `- Bildung korrekt einordnen: Hochschulstudium → studium; Berufsausbildung/Berufskolleg-Abschluss → ` + + `berufsausbildung; schulische Abschlüsse (Realschule, Gymnasium, Fachoberschulreife/Abitur) → schulbildung; ` + + `Zertifikate/Fortbildungen → weiterbildungen. Grundschule weglassen.\n` + `- kenntnisse: max. 12 prägnante Stichworte, relevanteste zuerst.\n` + - `- sprachen & hobbys: nur falls in den Unterlagen vorhanden.\n` + + `- sprachen, hobbys, weiterbildungen: nur falls in den Unterlagen vorhanden.\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.JJJJ).\n\n` + `Leere Felder als leerer String bzw. leeres Array. Antworte AUSSCHLIESSLICH mit dem ` + `JSON-Objekt, ohne Markdown, ohne Code-Fences, ohne weiteren Text.`; @@ -259,11 +280,36 @@ function toZeitraum(v) { return str(v); } +// 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: { @@ -286,17 +332,13 @@ function normalizeResult(parsed) { 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'])), - beschreibung: str(pick(e, ['beschreibung', 'description', 'details', 'text', 'zusatz'])), + beschreibung: safeNote(str(pick(e, ['beschreibung', 'description', 'details', 'text']))), })) .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', 'organisation'])), - zusatz: str(pick(e, ['zusatz', 'note', 'hinweis', 'details'])), - })) - .filter((e) => e.abschluss || e.institution), + 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), @@ -484,40 +526,20 @@ function composeMainCV(doc, S, dry, cur, { cv }) { zeitraum: e.zeitraum, title: e.titel, sub: e.firma, desc: e.beschreibung, })); } - if (cv.ausbildung.length) { - mainSection(doc, S, dry, cur, 'Ausbildung'); - cv.ausbildung.forEach((e) => mainEntry(doc, S, dry, cur, { - zeitraum: e.zeitraum, title: e.abschluss, sub: e.institution, note: e.zusatz, + // Education, split into norm-compliant German sections (reverse-chronological). + const eduSections = [ + ['Studium', cv.studium], + ['Berufsausbildung', cv.berufsausbildung], + ['Schulbildung', cv.schulbildung], + ['Weiterbildungen', cv.weiterbildungen], + ]; + eduSections.forEach(([title, entries]) => { + if (!entries || !entries.length) return; + mainSection(doc, S, dry, cur, title); + entries.forEach((e) => mainEntry(doc, S, dry, cur, { + zeitraum: e.zeitraum, title: e.abschluss, sub: e.institution, note: safeNote(e.zusatz), })); - } -} - -function composeMainLetter(doc, S, dry, cur, { 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'); - - cur.y += 4 * S; - if (job.firma) write(doc, S, dry, cur, job.firma, { pt: 10.5, style: 'bold', color: C.ink, x: MAIN_X, width: MAIN_W, factor: 1.25 }); - if (job.ort) write(doc, S, dry, cur, job.ort, { pt: 10, color: C.gray, x: MAIN_X, width: MAIN_W, factor: 1.25 }); - - cur.y += 4 * S; - const dateLine = header.ort ? `${header.ort}, den ${today}` : today; - write(doc, S, dry, cur, dateLine, { pt: 10, color: C.gray, x: MAIN_X, width: MAIN_W, align: 'right', right: MAIN_R, factor: 1.2 }); - - cur.y += 4 * S; - write(doc, S, dry, cur, betreff, { pt: 12, style: 'bold', color: C.ink, x: MAIN_X, width: MAIN_W, factor: 1.25 }); - - cur.y += 3.5 * S; - if (letter.anrede) { write(doc, S, dry, cur, letter.anrede, { pt: 10.5, color: C.ink, x: MAIN_X, width: MAIN_W, factor: 1.35 }); cur.y += 2 * S; } - letter.absaetze.forEach((p, i) => { - if (i > 0) cur.y += 2.6 * S; - write(doc, S, dry, cur, p, { pt: 10.5, color: C.ink, x: MAIN_X, width: MAIN_W, factor: 1.45 }); }); - - cur.y += 4.5 * S; - if (letter.gruss) write(doc, S, dry, cur, letter.gruss, { pt: 10.5, color: C.ink, x: MAIN_X, width: MAIN_W, factor: 1.3 }); - cur.y += 8 * S; - write(doc, S, dry, cur, header.name, { pt: 10.5, style: 'bold', color: C.ink, x: MAIN_X, width: MAIN_W, factor: 1.2 }); } // ----- two-column page assembly (measure → scale → draw) ------------------- @@ -542,6 +564,91 @@ function renderTwoColumn(composeSide, composeMain) { 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 + +// Restrained grayscale palette — no accent colours. +const LC = { + ink: [26, 26, 26], + muted: [92, 98, 106], + hair: [206, 208, 212], +}; + +function composeLetter(doc, S, dry, cur, { letter, header, job, anlagen }) { + 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'); + + // --- Letterhead: name (left) + contact (right), thin rule --- + const top = cur.y; + write(doc, S, dry, cur, header.name, { pt: 16, style: 'bold', color: LC.ink, x, width: width * 0.55, factor: 1.15 }); + if (header.headline) write(doc, S, dry, cur, header.headline, { pt: 9.5, color: LC.muted, x, width: width * 0.55, factor: 1.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 })); + + cur.y = Math.max(leftBottom, rc.y) + 3 * S; + rule(doc, S, dry, cur.y, x, right, LC.hair, 0.3); + cur.y += 13 * S; + + // --- Recipient (Anschriftfeld) --- + if (job.firma) write(doc, S, dry, cur, job.firma, { pt: 10.5, color: LC.ink, x, width, factor: 1.32 }); + if (job.ort) write(doc, S, dry, cur, job.ort, { pt: 10.5, color: LC.ink, x, width, factor: 1.32 }); + + // --- Date (right-aligned) --- + cur.y += 7 * S; + const dateLine = header.ort ? `${header.ort}, den ${today}` : today; + write(doc, S, dry, cur, dateLine, { pt: 10, color: LC.ink, x, width, align: 'right', right, factor: 1.2 }); + + // --- Subject (bold, no "Betreff:" label) --- + cur.y += 7 * S; + write(doc, S, dry, cur, betreff, { pt: 11, style: 'bold', color: LC.ink, 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 space + name --- + 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 }); + cur.y += 13 * S; // room for a 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 = new jsPDF({ unit: 'mm', format: 'a4' }); + 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) || '', @@ -558,11 +665,8 @@ function renderLebenslaufPdf(cv, header) { ); } -function renderAnschreibenPdf(letter, header, job) { - return renderTwoColumn( - (doc, S, dry, cur) => composeSidebar(doc, S, dry, cur, { header, cv: null }), - (doc, S, dry, cur) => composeMainLetter(doc, S, dry, cur, { letter, header, job }) - ); +function renderAnschreibenPdf(letter, header, job, anlagen) { + return renderSingleColumn((doc, S, dry, cur) => composeLetter(doc, S, dry, cur, { letter, header, job, anlagen })); } // =========================================================================== @@ -571,7 +675,8 @@ function renderAnschreibenPdf(letter, header, job) { 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); + 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 }) { @@ -584,17 +689,18 @@ async function generateApplicationDocuments({ job, basisDokumente, settings }) { const label = job.stelle || job.firma || ''; const documents = []; + const cvPresent = hasLebenslauf(data.lebenslauf); if (hasAnschreiben(data.anschreiben)) { documents.push({ name: `Anschreiben – ${label}`.trim(), filename: `Anschreiben_${suffix}.pdf`, mime: 'application/pdf', - buffer: renderAnschreibenPdf(data.anschreiben, header, job), + buffer: renderAnschreibenPdf(data.anschreiben, header, job, cvPresent ? ['Lebenslauf'] : []), }); } - if (hasLebenslauf(data.lebenslauf)) { + if (cvPresent) { documents.push({ name: `Lebenslauf – ${label}`.trim(), filename: `Lebenslauf_${suffix}.pdf`,