Files
jobbi-bewerbung/lib/documents.js
T
thomasandClaude Opus 4.8 5f02413410 Norm-compliant CV education sections + redesigned monochrome cover letter
CV:
- Split education into proper German sections (Studium, Berufsausbildung,
  Schulbildung, Weiterbildungen) instead of one lumped "Ausbildung".
- Keep the timeline gap-free (lückenlos) and strip HR red-flag phrases
  ("nicht beendet", "abgebrochen", "arbeitslos", …) defensively.

Cover letter:
- Full refactor to a clean, single-column DIN-5008 business letter: letterhead
  (name left, contact right, hairline), recipient block, right-aligned date,
  bold subject, body, signature space, enclosure note ("Anlage: Lebenslauf").
- Monochrome (grayscale only), no accent colours; own single-page auto-fit.
- Prompt hardened against red flags (no salary, no reasons for leaving, no gaps).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 10:36:33 +02:00

725 lines
30 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Document generation.
//
// Architecture: the *design* lives here as a fixed, high-quality two-column
// template (dark sidebar + white main column). 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');
// 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 || 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: {
headline: { type: 'string' },
kontakt: {
type: 'object',
properties: {
email: { type: 'string' },
telefon: { type: 'string' },
ort: { type: 'string' },
webseite: { type: 'string' },
geburtsdatum: { type: 'string' },
fuehrerschein: { type: 'string' },
},
required: ['email', 'telefon', 'ort', 'webseite', 'geburtsdatum', 'fuehrerschein'],
},
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: {
berufserfahrung: {
type: 'array',
items: {
type: 'object',
properties: {
zeitraum: { type: 'string' },
titel: { type: 'string' },
firma: { type: 'string' },
beschreibung: { type: 'string' },
},
required: ['zeitraum', 'titel', 'firma', 'beschreibung'],
},
},
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',
items: {
type: 'object',
properties: { sprache: { type: 'string' }, niveau: { type: 'string' } },
required: ['sprache', 'niveau'],
},
},
hobbys: { type: 'array', items: { type: 'string' } },
},
required: ['berufserfahrung', 'studium', 'berufsausbildung', 'schulbildung', 'weiterbildungen', 'kenntnisse', 'sprachen', 'hobbys'],
},
},
required: ['headline', 'kontakt', 'anschreiben', 'lebenslauf'],
};
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 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.';
const skeleton =
`{\n` +
` "headline": "Kurze Berufsbezeichnung (max. 5 Wörter)",\n` +
` "kontakt": { "email": "", "telefon": "", "ort": "", "webseite": "", "geburtsdatum": "", "fuehrerschein": "" },\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` +
` "berufserfahrung": [\n` +
` { "zeitraum": "02.2025 heute", "titel": "Jobtitel", "firma": "Arbeitgeber, Ort", "beschreibung": "1 kurzer Satz (optional)" }\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` +
` "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` +
` }\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 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, Führerschein NUR übernehmen, ` +
`wenn in den Basis-Unterlagen vorhanden; sonst leerer String. Nichts erfinden.\n` +
`- anschreiben.absaetze: 34 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.\n` +
`- berufserfahrung: ALLE Stationen aus den Unterlagen, neueste zuerst. "firma" = "Arbeitgeber, Ort". ` +
`Nur die 23 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, 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.`;
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.
const str = (v) => (typeof v === 'string' ? v.trim() : (v == null ? '' : String(v).trim()));
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 [];
}
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);
}
// 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: {
betreff: str(pick(a, ['betreff', 'subject', 'titel'])),
anrede: str(pick(a, ['anrede', 'salutation'])),
absaetze: pickArray(a, ['absaetze', 'absätze', 'paragraphs', 'text', 'inhalt']).map(str).filter(Boolean),
gruss: str(pick(a, ['gruss', 'gruß', 'grussformel', 'closing', 'schluss'])),
},
lebenslauf: {
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'])),
beschreibung: safeNote(str(pick(e, ['beschreibung', 'description', 'details', 'text']))),
}))
.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. PDF template — fixed two-column design (sidebar + main column)
// ===========================================================================
const PT = 0.352778; // pt → mm
const PAGE = { w: 210, h: 297 };
const SIDEBAR_W = 68;
const SIDE_PAD = 8;
const SIDE_X = SIDE_PAD;
const SIDE_W = SIDEBAR_W - SIDE_PAD * 2;
const MAIN_X = SIDEBAR_W + 10;
const MAIN_R = PAGE.w - 14;
const MAIN_W = MAIN_R - MAIN_X;
const DATE_W = 25; // date gutter inside the main column
const ENTRY_X = MAIN_X + DATE_W + 4;
const ENTRY_W = MAIN_R - ENTRY_X;
const TOP = 20;
const BOTTOM = 16;
const MIN_SCALE = 0.6;
const C = {
navy: [29, 40, 60], // sidebar background
sideText: [231, 237, 244],
sideLabel: [143, 158, 181],
sideRole: [150, 178, 204],
sideRule: [58, 72, 98],
accent: [122, 197, 230], // cyan — headings, underlines, bullets
company: [77, 141, 201], // main-column company / institution
ink: [33, 43, 61],
gray: [110, 122, 140],
};
// 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('helvetica', 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);
}
// ----- sidebar -------------------------------------------------------------
function composeSidebar(doc, S, dry, cur, { header, cv }) {
// Name (split into two lines on the first space, like the reference)
const nameParts = (header.name || '').trim().split(/\s+/);
const nameLines = nameParts.length > 1
? [nameParts.slice(0, -1).join(' '), nameParts[nameParts.length - 1]]
: [header.name || ''];
for (const nl of nameLines) {
write(doc, S, dry, cur, nl, { pt: 25, style: 'bold', color: [255, 255, 255], x: SIDE_X, width: SIDE_W, factor: 1.02 });
}
if (header.headline) {
cur.y += 1.5 * S;
write(doc, S, dry, cur, header.headline, { pt: 11.5, color: C.sideRole, x: SIDE_X, width: SIDE_W, factor: 1.22 });
}
const section = (title) => {
cur.y += 6 * S;
write(doc, S, dry, cur, title, { pt: 10, style: 'bold', color: C.accent, x: SIDE_X, width: SIDE_W, charSpace: 0.6, upper: true, factor: 1.0 });
cur.y += 1.8 * S;
rule(doc, S, dry, cur.y, SIDE_X, SIDEBAR_W - SIDE_PAD, C.sideRule, 0.35);
cur.y += 4.2 * S;
};
// KONTAKT
const kontaktItems = [
['Adresse', header.adresse],
['Telefon', header.telefon],
['E-Mail', header.email],
['Geboren', header.geburtsdatum],
['Führerschein', header.fuehrerschein],
].filter(([, v]) => v);
if (kontaktItems.length) {
section('Kontakt');
kontaktItems.forEach(([label, value]) => {
write(doc, S, dry, cur, label, { pt: 7.5, color: C.sideLabel, x: SIDE_X, width: SIDE_W, charSpace: 0.3, upper: true, factor: 1.25 });
write(doc, S, dry, cur, value, { pt: 9.5, color: C.sideText, x: SIDE_X, width: SIDE_W, factor: 1.25 });
cur.y += 2.4 * S;
});
}
if (cv && cv.kenntnisse.length) {
section('Fähigkeiten');
cv.kenntnisse.forEach((skill) => {
const dotY = cur.y + 9 * S * PT * 0.42;
if (!dry) { doc.setFillColor(C.accent[0], C.accent[1], C.accent[2]); doc.circle(SIDE_X + 0.9, dotY, 0.7 * S, 'F'); }
write(doc, S, dry, cur, skill, { pt: 9, color: C.sideText, x: SIDE_X + 4, width: SIDE_W - 4, factor: 1.28 });
cur.y += 1.4 * S;
});
}
if (cv && cv.sprachen.length) {
section('Sprachen');
cv.sprachen.forEach((s) => {
// two-tone: language white, level muted, on one line
doc.setFont('helvetica', 'normal');
doc.setFontSize(9 * S);
if (!dry) {
doc.setTextColor(C.sideText[0], C.sideText[1], C.sideText[2]);
doc.text(s.sprache, SIDE_X, cur.y + 9 * S * PT * 0.76);
if (s.niveau) {
const w = doc.getTextWidth(s.sprache);
doc.setTextColor(C.sideLabel[0], C.sideLabel[1], C.sideLabel[2]);
doc.text(` ${s.niveau}`, SIDE_X + w, cur.y + 9 * S * PT * 0.76);
}
}
cur.y += 9 * S * PT * 1.4;
});
}
if (cv && cv.hobbys.length) {
section('Hobbys');
write(doc, S, dry, cur, cv.hobbys.join(' · '), { pt: 9, color: C.sideText, x: SIDE_X, width: SIDE_W, factor: 1.3 });
}
}
// ----- main column ---------------------------------------------------------
function mainSection(doc, S, dry, cur, title) {
cur.y += 3.5 * S;
write(doc, S, dry, cur, title, { pt: 13, style: 'bold', color: C.ink, x: MAIN_X, width: MAIN_W, charSpace: 0.5, upper: true, factor: 1.0 });
cur.y += 2.2 * S;
rule(doc, S, dry, cur.y, MAIN_X, MAIN_X + 15, C.accent, 1.1);
cur.y += 6 * S;
}
// One dated entry: date in the left gutter, content to the right.
function mainEntry(doc, S, dry, cur, { zeitraum, title, sub, note, desc }) {
const startY = cur.y;
// date (own baseline, doesn't drive the cursor)
if (!dry && zeitraum) {
doc.setFont('helvetica', 'bold');
doc.setFontSize(8.5 * S);
doc.setTextColor(C.gray[0], C.gray[1], C.gray[2]);
const dl = doc.splitTextToSize(zeitraum, DATE_W);
dl.forEach((ln, i) => doc.text(ln, MAIN_X, startY + 11.5 * S * PT * 0.76 + i * 8.5 * S * PT * 1.2));
}
if (title) write(doc, S, dry, cur, title, { pt: 11.5, style: 'bold', color: C.ink, x: ENTRY_X, width: ENTRY_W, factor: 1.22 });
if (sub) write(doc, S, dry, cur, sub, { pt: 10, style: 'bold', color: C.company, x: ENTRY_X, width: ENTRY_W, factor: 1.25 });
if (note) write(doc, S, dry, cur, note, { pt: 9, style: 'italic', color: C.gray, x: ENTRY_X, width: ENTRY_W, factor: 1.25 });
if (desc) { cur.y += 0.6 * S; write(doc, S, dry, cur, desc, { pt: 9.3, color: C.gray, x: ENTRY_X, width: ENTRY_W, factor: 1.35 }); }
cur.y += 5 * S;
}
function composeMainCV(doc, S, dry, cur, { cv }) {
if (cv.berufserfahrung.length) {
mainSection(doc, S, dry, cur, 'Berufserfahrung');
cv.berufserfahrung.forEach((e) => mainEntry(doc, S, dry, cur, {
zeitraum: e.zeitraum, title: e.titel, sub: e.firma, desc: e.beschreibung,
}));
}
// 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),
}));
});
}
// ----- two-column page assembly (measure → scale → draw) -------------------
function renderTwoColumn(composeSide, composeMain) {
const doc = new jsPDF({ unit: 'mm', format: 'a4' });
// measure both columns at scale 1
const sm = { y: TOP }; composeSide(doc, 1, true, sm);
const mm = { y: TOP }; composeMain(doc, 1, true, mm);
const need = Math.max(sm.y, mm.y) - TOP;
const avail = PAGE.h - TOP - BOTTOM;
let S = 1;
if (need > avail) S = Math.max(MIN_SCALE, (avail / need) * 0.99);
// draw
doc.setFillColor(C.navy[0], C.navy[1], C.navy[2]);
doc.rect(0, 0, SIDEBAR_W, PAGE.h, 'F');
composeSide(doc, S, false, { y: TOP });
composeMain(doc, S, false, { y: TOP });
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) || '',
adresse: (settings && settings.adresse) || '',
headline,
...kontakt,
};
}
function renderLebenslaufPdf(cv, header) {
return renderTwoColumn(
(doc, S, dry, cur) => composeSidebar(doc, S, dry, cur, { header, cv }),
(doc, S, dry, cur) => composeMainCV(doc, S, dry, cur, { cv })
);
}
function renderAnschreibenPdf(letter, header, job, anlagen) {
return renderSingleColumn((doc, S, dry, cur) => composeLetter(doc, S, dry, cur, { letter, header, job, anlagen }));
}
// ===========================================================================
// 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 }) {
const data = await generateTailoredTexts({ job, basisDokumente, settings });
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);
if (hasAnschreiben(data.anschreiben)) {
documents.push({
name: `Anschreiben ${label}`.trim(),
filename: `Anschreiben_${suffix}.pdf`,
mime: 'application/pdf',
buffer: renderAnschreibenPdf(data.anschreiben, header, job, cvPresent ? ['Lebenslauf'] : []),
});
}
if (cvPresent) {
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,
};