- Static extra attachments: upload files (e.g. Zeugnisse) once on the Vorlagen
page; they are attached to every generated application and listed under
"Anlagen" in the cover letter (via multer upload + basis_anhaenge table).
- Import no longer auto-generates: an imported job is saved as a draft
("nicht_gestartet"); generation is triggered manually on the application page.
- Per-application "LLM-Notizen" field: free text (company address, contact
person, extra context) that is fed to the model at generation time. Saving the
notes and (re)generating happens in one action.
- Cover letter recipient block is now a structured empfaenger (firma, address,
city, contact person) the model fills from the job + notes; the salutation
adapts to a named contact. Falls back to the imported company + city.
- Raise default OLLAMA_TIMEOUT_MS to 300s for slower cloud models.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
813 lines
35 KiB
JavaScript
813 lines
35 KiB
JavaScript
// 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 || 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' },
|
||
fuehrerschein: { type: 'string' },
|
||
},
|
||
required: ['email', 'telefon', 'ort', 'webseite', 'geburtsdatum', 'fuehrerschein'],
|
||
},
|
||
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'],
|
||
},
|
||
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, 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.';
|
||
|
||
const skeleton =
|
||
`{\n` +
|
||
` "headline": "Kurze Berufsbezeichnung (max. 5 Wörter)",\n` +
|
||
` "kontakt": { "email": "", "telefon": "", "ort": "", "webseite": "", "geburtsdatum": "", "fuehrerschein": "" },\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` +
|
||
` "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 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, Führerschein NUR übernehmen, ` +
|
||
`wenn in den Basis-Unterlagen vorhanden; sonst leerer String. Nichts erfinden.\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 auch die Anrede an (z. B. "Sehr geehrte Frau …,").\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. ` +
|
||
`WICHTIG: 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".\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/Ausbildungsberuf → ` +
|
||
`berufsausbildung; schulische Abschlüsse (Mittlere Reife/Fachoberschulreife, Fachhochschulreife, Abitur) → schulbildung; ` +
|
||
`Zertifikate/Fortbildungen → weiterbildungen. Grundschule weglassen. ` +
|
||
`WICHTIG: Wenn ein schulischer Abschluss (z. B. Fachhochschulreife) an einem Berufskolleg gemeinsam mit einer ` +
|
||
`Berufsausbildung erworben wurde, führe diesen schulischen Abschluss ZUSÄTZLICH als eigenen Eintrag unter ` +
|
||
`schulbildung auf (nicht nur als Zusatz der Berufsausbildung).\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);
|
||
}
|
||
|
||
// 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',
|
||
]);
|
||
|
||
function fixAnredeContinuation(anrede, absaetze) {
|
||
if (!absaetze.length || !anrede.trim().endsWith(',')) return absaetze;
|
||
const m = absaetze[0].match(/^(\s*)([A-Za-zÄÖÜäöüß]+)([\s\S]*)$/);
|
||
if (!m) return absaetze;
|
||
const [, lead, word, rest] = m;
|
||
const first = word[0];
|
||
const isUpper = (first >= 'A' && first <= 'Z') || 'ÄÖÜ'.includes(first);
|
||
if (isUpper && LOWER_OPENERS.has(word.toLowerCase())) {
|
||
const out = absaetze.slice();
|
||
out[0] = lead + first.toLowerCase() + word.slice(1) + rest;
|
||
return out;
|
||
}
|
||
return absaetze;
|
||
}
|
||
|
||
// 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 = 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: fixAnredeContinuation(anrede, absaetze),
|
||
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],
|
||
};
|
||
|
||
// 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 }) {
|
||
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), 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) — 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, 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, zusatzAnlagen = [], llmNotizen = '' }) {
|
||
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),
|
||
});
|
||
}
|
||
|
||
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,
|
||
};
|