Files
jobbi-bewerbung/lib/documents.js
T
thomasandClaude Opus 4.8 8281128adc Restyle the cover letter to match the CV; refine the résumé head
Cover letter: a big confident name + role in tracked caps, the shared
hairline-with-accent motif, and a centred contact footer strip (moved
out of the header) so it reads as one set with the monochrome résumé.

Résumé: drop the divider rule above the Profil heading — the section
headings carry the structure on their own.

Prompt: forbid the headline from using a protected professional title
the applicant has not verifiably earned (e.g. Fachinformatiker,
Ingenieur, Meister); fall back to a neutral activity label.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 12:33:32 +02:00

1274 lines
60 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 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. ' +
'Verwende AUSSCHLIESSLICH deutsche Sprache und das lateinische Alphabet. Keine englischen, ' +
'chinesischen, japanischen, koreanischen oder anderen fremdsprachigen Wörter oder Schriftzeichen - ' +
'auch nicht einzelne Zeichen oder Füllwörter.';
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": "Sehr kurzer Begleittext nur für die E-Mail: Anrede, ein Satz (Stelle + Unterlagen im Anhang), optional ein kurzer Schlusssatz, Grußformel, Name - jeweils 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, sachliche Berufsbezeichnung des Bewerbers, ggf. auf die Zielstelle zugeschnitten. ` +
`NIEMALS eine geschützte oder formal an eine Ausbildung/Prüfung/einen Studienabschluss gebundene ` +
`Berufsbezeichnung verwenden, die der Bewerber laut Unterlagen nicht nachweislich erworben hat ` +
`(z. B. "Fachinformatiker", "Ingenieur", "Techniker", "Meister", "Kaufmann/-frau", "Geselle", ` +
`"Facharzt", "Bachelor/Master", "Dipl.-..." u. Ä.). Im Zweifel eine neutrale, nicht geschützte ` +
`Tätigkeitsbezeichnung wählen (z. B. "Softwareentwickler", "IT-Fachkraft", "Anwendungsentwickler") ` +
`statt eines geschützten Titels.\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 <Nachname>," ` +
`bzw. "Sehr geehrter Herr <Nachname>," 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: 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. ` +
`Nenne den AKTUELLEN Arbeitgeber NIE beim Namen im Anschreiben (keine Firmenbezeichnung wie z. B. ` +
`"IT-Problemlöser GmbH") — beschreibe höchstens die Tätigkeit/Rolle allgemein, falls überhaupt relevant. ` +
`Der Lebenslauf nennt die Arbeitgeber wie gehabt; diese Regel gilt nur für das Anschreiben. ` +
`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 SEHR KURZER Begleittext für die E-Mail, mit der die Bewerbung samt Anhängen ` +
`(Lebenslauf, Anschreiben, ggf. Zeugnisse) versendet wird. Es ist NUR die E-Mail-Begleitnotiz, NICHT das ` +
`vollständige Anschreiben - dessen Inhalt NICHT wiederholen. Ein HR-Experte überfliegt diese E-Mail im ` +
`Vorbeigehen: max. 3 Sätze gesamt, auf das absolute Minimum reduziert. KEINE Motivationssätze, KEINE ` +
`Qualifikationsauflistungen, KEINE Wiederholung des Anschreibens. ` +
`email.betreff: "Bewerbung als <Stelle>" (ggf. Referenz-/Kennnummer anhängen). ` +
`email.text: sofort versendbar, exakt diese Struktur - (1) dieselbe Anrede wie im Anschreiben; ` +
`(2) ein Satz: auf welche Stelle man sich bewirbt UND dass die vollständigen Bewerbungsunterlagen im Anhang ` +
`beiliegen; (3) ein kurzer, freundlicher Schlusssatz mit Ausblick auf ein persönliches Gespräch; ` +
`(4) Grußformel "Mit freundlichen Grüßen"; (5) vollständiger Name in der letzten Zeile. ` +
`Trenne Anrede, Text, 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: 23 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 ` +
`23 jüngsten erhalten je 23 knappe "punkte", mittelalte 01 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 46 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.
// Backstop: strip non-Latin script characters (e.g. stray CJK like "包括")
// the model may emit despite the German-only instruction. Safe for German
// application text — no legitimate field contains these scripts. Preserves
// newlines (paragraph breaks) and common German umlauts/punctuation.
const NON_LATIN_SCRIPT = /[㐀-鿿豈-﫿぀-ヿ가-힯 -〿]/g;
const str = (v) => (typeof v === 'string' ? v.trim() : (v == null ? '' : String(v).trim()))
.replace(/[–—]/g, '-')
.replace(NON_LATIN_SCRIPT, '')
.replace(/[ \t]{2,}/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 <Nachname>" /
// "Sehr geehrter Herr <Nachname>" 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) — modern two-column layout
//
// A tinted left sidebar (photo, contact, competencies, languages, interests)
// and a wide main column (large name header, profile, timeline experience,
// education). This is the canonical premium German Lebenslauf: the recruiter
// reads the whole person in the first glance — face, role, core skills — and
// the career story reads top-to-bottom on a single, deliberate axis.
// ===========================================================================
const RV = { top: 20, bottom: 14 };
// --- Horizontal grid (fixed; only vertical rhythm scales to fit one page) ---
const SB_W = 68; // sidebar width
const SB_X = 10; // sidebar text left
const SB_R = SB_W - 10; // 58 sidebar text right
const SB_CW = SB_R - SB_X; // 48 sidebar content width
const MAIN_X = SB_W + 12; // 80 main column left (section labels)
const MAIN_R = PAGE.w - 15; // 195 main column right (right-aligned dates)
const BODY_X = MAIN_X + 8; // 88 body text left (hanging under labels)
const BODY_W = MAIN_R - BODY_X; // 107
const MAIN_W = MAIN_R - MAIN_X; // 115 full main width (name / heading rules)
// Strictly monochrome — no colour anywhere. Hierarchy comes from weight, size,
// tracking and a single neutral grey scale. The near-black "accent" carries the
// name, section labels, company names, markers and the photo frame; body text
// sits just below it; everything structural is grey.
const RC = {
ink: [33, 33, 33],
sub: [92, 96, 100],
hair: [208, 210, 214],
accent: [23, 23, 23],
// Neutral tint for the sidebar band and the experience timeline track.
sidebarBg: [242, 243, 245],
track: [199, 202, 208],
onSide: [38, 40, 44],
onSideSub: [96, 100, 106],
};
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
// Fit an applicant photo into a portrait frame, preserving aspect ratio. The
// frame size is fixed (does not scale with the page-fit S) so the sidebar
// keeps a stable, confident anchor regardless of how much text is below it.
function fitPhoto(doc, foto) {
const MAXW = 42, MAXH = 52;
if (!foto || !foto.dataUrl) return { ok: false, w: 0, h: 0 };
try {
const p = doc.getImageProperties(foto.dataUrl);
if (!(p.width > 0 && p.height > 0)) return { ok: false, w: 0, h: 0 };
let w = MAXW, h = w * p.height / p.width;
if (h > MAXH) { h = MAXH; w = h * p.width / p.height; }
return { ok: true, w, h };
} catch (e) { return { ok: false, w: 0, h: 0 }; }
}
function buildContactLines(header) {
const lines = [];
if (header.email) lines.push(header.email);
if (header.telefon) lines.push(header.telefon);
const ort = cityName(header) || header.ort;
if (ort) lines.push(ort);
if (header.webseite) lines.push(header.webseite);
if (header.geburtsdatum) lines.push(`Geb. ${header.geburtsdatum}`);
if (header.fuehrerschein) lines.push(`Führerschein ${header.fuehrerschein}`);
return lines;
}
// Sidebar section label: a compact tracked uppercase accent word underlined by
// a short accent segment — the same structural motif as the main column, sized
// down for the narrow column.
function sbHeading(doc, S, dry, cur, title) {
const pt = 9;
const cs = 1.0 * S;
cur.y += 5 * S;
doc.setFont('Lato', 'bold');
doc.setFontSize(pt * S);
const label = String(title).toUpperCase();
const lineY = cur.y + pt * S * PT * 1.24;
if (!dry) {
doc.setCharSpace(cs);
doc.setTextColor(RC.accent[0], RC.accent[1], RC.accent[2]);
doc.text(label, SB_X, cur.y + pt * S * PT * 0.76);
doc.setCharSpace(0);
doc.setDrawColor(RC.accent[0], RC.accent[1], RC.accent[2]);
doc.setLineWidth(0.9 * S);
doc.line(SB_X, lineY, SB_X + 9, lineY);
}
cur.y += pt * S * PT + 4.6 * S;
}
function sbBullets(doc, S, dry, cur, items, pt = 8.7) {
const tx = SB_X + 3.4;
const tw = SB_CW - 3.4;
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) {
const sz = 1.1 * S;
doc.setFillColor(RC.accent[0], RC.accent[1], RC.accent[2]);
doc.rect(SB_X + 0.2, cur.y + pt * S * PT * 0.42 - sz / 2, sz, sz, 'F');
}
doc.setTextColor(RC.onSide[0], RC.onSide[1], RC.onSide[2]);
doc.text(ln, tx, cur.y + pt * S * PT * 0.76);
}
cur.y += pt * S * PT * 1.32;
});
cur.y += 1.1 * S;
}
}
// Language row: name (left) + level (right-aligned in the sidebar column).
function sbLangRow(doc, S, dry, cur, s) {
const pt = 8.7;
doc.setFont('Lato', 'normal');
doc.setFontSize(pt * S);
if (!dry) {
doc.setTextColor(RC.onSide[0], RC.onSide[1], RC.onSide[2]);
doc.text(String(s.sprache), SB_X, cur.y + pt * S * PT * 0.76);
if (s.niveau) {
doc.setTextColor(RC.onSideSub[0], RC.onSideSub[1], RC.onSideSub[2]);
doc.text(String(s.niveau), SB_R, cur.y + pt * S * PT * 0.76, { align: 'right' });
}
}
cur.y += pt * S * PT * 1.62;
}
function composeSidebar(doc, S, dry, { cv, header, foto }) {
const cur = { y: RV.top };
const photo = fitPhoto(doc, foto);
if (photo.ok) {
const px = (SB_W - photo.w) / 2;
const py = RV.top - 2;
if (!dry) {
doc.addImage(foto.dataUrl, foto.format || 'PNG', px, py, photo.w, photo.h);
doc.setDrawColor(RC.hair[0], RC.hair[1], RC.hair[2]);
doc.setLineWidth(0.3 * S);
doc.rect(px, py, photo.w, photo.h);
}
cur.y = py + photo.h + 7 * S;
}
const contact = buildContactLines(header);
if (contact.length) {
sbHeading(doc, S, dry, cur, 'Kontakt');
contact.forEach((line) => write(doc, S, dry, cur, line, { pt: 8.6, color: RC.onSideSub, x: SB_X, width: SB_CW, factor: 1.5 }));
}
if (cv.kenntnisse.length) {
sbHeading(doc, S, dry, cur, 'Kernkompetenzen');
sbBullets(doc, S, dry, cur, cv.kenntnisse);
}
if (cv.sprachen.length) {
sbHeading(doc, S, dry, cur, 'Sprachen');
cv.sprachen.forEach((s) => sbLangRow(doc, S, dry, cur, s));
}
if (cv.hobbys.length) {
sbHeading(doc, S, dry, cur, 'Interessen');
write(doc, S, dry, cur, cv.hobbys.join(' · '), { pt: 8.6, color: RC.onSide, x: SB_X, width: SB_CW, factor: 1.55 });
}
return cur.y;
}
// ---------------------------------------------------------------------------
// Main column
// ---------------------------------------------------------------------------
// Main section heading: tracked uppercase accent label, outdented over a
// full-width hairline whose first stretch is a heavier accent segment as wide
// as the label word — a precise, repeating anchor down the page.
function mHeading(doc, S, dry, cur, title) {
const pt = 11;
const cs = 0.9 * S;
cur.y += 5.5 * S;
doc.setFont('Lato', 'bold');
doc.setFontSize(pt * S);
const label = String(title).toUpperCase();
const lineY = cur.y + pt * S * PT * 1.1;
if (!dry) {
doc.setCharSpace(cs);
doc.setTextColor(RC.accent[0], RC.accent[1], RC.accent[2]);
doc.text(label, MAIN_X, cur.y + pt * S * PT * 0.76);
doc.setCharSpace(0);
const wordW = doc.getTextWidth(label) + cs * label.length;
doc.setDrawColor(RC.hair[0], RC.hair[1], RC.hair[2]);
doc.setLineWidth(0.3 * S);
doc.line(MAIN_X, lineY, MAIN_R, lineY);
doc.setDrawColor(RC.accent[0], RC.accent[1], RC.accent[2]);
doc.setLineWidth(1.1 * S);
doc.line(MAIN_X, lineY, Math.min(MAIN_X + wordW, MAIN_R), lineY);
}
cur.y += pt * S * PT + 4.6 * S;
}
function cvBullets(doc, S, dry, cur, items, x, w, pt = 9.2) {
const tx = x + 3.8;
const tw = w - 3.8;
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) {
const sz = 1.15 * S;
doc.setFillColor(RC.accent[0], RC.accent[1], RC.accent[2]);
doc.rect(x + 0.2, 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.32;
});
cur.y += 1.0 * S;
}
}
// One experience entry: an accent node on the timeline, bold role title with a
// right-aligned period, the company in accent, then the achievement bullets.
function cvExperience(doc, S, dry, cur, e) {
const cx = MAIN_X + 1.4; // node / track centre x
const titlePt = 11, datePt = 9.3;
const dateW = 30;
doc.setFont('Lato', 'bold');
doc.setFontSize(titlePt * S);
const titleLines = doc.splitTextToSize(String(e.titel || ''), BODY_W - dateW);
const titleLineH = titlePt * S * PT * 1.16;
if (!dry) {
doc.setFillColor(RC.accent[0], RC.accent[1], RC.accent[2]);
doc.circle(cx, cur.y + titlePt * S * PT * 0.44, 1.5 * S, 'F');
doc.setTextColor(RC.ink[0], RC.ink[1], RC.ink[2]);
titleLines.forEach((ln, i) => doc.text(ln, BODY_X, cur.y + i * titleLineH + titlePt * S * PT * 0.76));
if (e.zeitraum) {
doc.setFont('Lato', 'normal');
doc.setFontSize(datePt * S);
doc.setTextColor(RC.sub[0], RC.sub[1], RC.sub[2]);
doc.text(String(e.zeitraum), MAIN_R, cur.y + datePt * S * PT * 0.76, { align: 'right' });
}
}
cur.y += titleLineH * Math.max(1, titleLines.length);
if (e.firma) write(doc, S, dry, cur, e.firma, { pt: 9.6, color: RC.accent, x: BODY_X, width: BODY_W, factor: 1.24 });
if (e.punkte && e.punkte.length) { cur.y += 1.4 * S; cvBullets(doc, S, dry, cur, e.punkte, BODY_X, BODY_W); }
cur.y += 5 * S;
}
// The full Berufserfahrung section, threaded on a subtle vertical timeline.
// The track is measured with a dry pass so it can be drawn behind the nodes in
// one clean stroke, then the entries render on top.
function composeTimeline(doc, S, dry, cur, entries) {
const startY = cur.y;
if (!dry) {
const probe = { y: cur.y };
entries.forEach((e) => cvExperience(doc, S, true, probe, e));
doc.setDrawColor(RC.track[0], RC.track[1], RC.track[2]);
doc.setLineWidth(0.5 * S);
doc.line(MAIN_X + 1.4, startY + 2 * S, MAIN_X + 1.4, probe.y - 5 * S);
}
entries.forEach((e) => cvExperience(doc, S, dry, cur, e));
}
function cvEducation(doc, S, dry, cur, e) {
const pt = 10.5, datePt = 9.3, dateW = 30;
doc.setFont('Lato', 'bold');
doc.setFontSize(pt * S);
const lines = doc.splitTextToSize(String(e.abschluss || ''), BODY_W - dateW);
const lineH = pt * S * PT * 1.16;
if (!dry) {
doc.setTextColor(RC.ink[0], RC.ink[1], RC.ink[2]);
lines.forEach((ln, i) => doc.text(ln, BODY_X, cur.y + i * lineH + pt * S * PT * 0.76));
if (e.zeitraum) {
doc.setFont('Lato', 'normal');
doc.setFontSize(datePt * S);
doc.setTextColor(RC.sub[0], RC.sub[1], RC.sub[2]);
doc.text(String(e.zeitraum), MAIN_R, cur.y + datePt * S * PT * 0.76, { align: 'right' });
}
}
cur.y += lineH * Math.max(1, lines.length);
if (e.institution) write(doc, S, dry, cur, e.institution, { pt: 9.5, color: RC.accent, x: BODY_X, width: BODY_W, factor: 1.2 });
if (e.zusatz) write(doc, S, dry, cur, e.zusatz, { pt: 9.3, color: RC.sub, x: BODY_X, width: BODY_W, factor: 1.24 });
cur.y += 4 * S;
}
// Big name + target role — the confident head the recruiter's eye lands on
// first. No divider rule here; the first section heading provides the structure.
function composeMainHeader(doc, S, dry, cur, header, titel) {
write(doc, S, dry, cur, header.name, { pt: 25, style: 'bold', color: RC.accent, x: MAIN_X, width: MAIN_W, factor: 1.06 });
if (titel) write(doc, S, dry, cur, titel, { pt: 12, color: RC.sub, x: MAIN_X, width: MAIN_W, factor: 1.36, charSpace: 0.3 });
cur.y += 2 * S;
}
function composeMain(doc, S, dry, { cv, header, titel }) {
const cur = { y: RV.top };
composeMainHeader(doc, S, dry, cur, header, titel);
if (cv.profil) {
mHeading(doc, S, dry, cur, 'Profil');
write(doc, S, dry, cur, cv.profil, { pt: 9.8, color: RC.ink, x: BODY_X, width: BODY_W, factor: 1.5 });
}
if (cv.berufserfahrung.length) {
mHeading(doc, S, dry, cur, 'Berufserfahrung');
composeTimeline(doc, S, dry, cur, cv.berufserfahrung);
}
[['Studium', cv.studium], ['Berufsausbildung', cv.berufsausbildung], ['Weiterbildungen', cv.weiterbildungen]].forEach(([t, entries]) => {
if (!entries || !entries.length) return;
mHeading(doc, S, dry, cur, t);
entries.forEach((e) => cvEducation(doc, S, dry, cur, e));
});
return cur.y;
}
// Draw the sidebar band first, then the main column, then the sidebar text —
// so the tint sits underneath, and the PDF text stream leads with the name and
// career story (better for résumé parsers) while positions stay absolute.
function composeCV(doc, S, dry, ctx) {
if (!dry) {
doc.setFillColor(RC.sidebarBg[0], RC.sidebarBg[1], RC.sidebarBg[2]);
doc.rect(0, 0, SB_W, PAGE.h, 'F');
}
const mainBottom = composeMain(doc, S, dry, ctx);
const sideBottom = composeSidebar(doc, S, dry, ctx);
return Math.max(mainBottom, sideBottom);
}
function renderCV(cv, header, titel, foto) {
const doc = makeDoc();
const ctx = { cv, header, titel, foto };
const need = composeCV(doc, 1, true, ctx) - 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, ctx);
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: 24 }; // DIN-ish margins (bottom reserves the footer strip)
const LET_R = PAGE.w - LET.mr; // 190
const LET_W = LET_R - LET.mx; // 165
// Strictly monochrome, matching the résumé, so the cover letter and CV read as
// one deliberately designed black-and-white application set.
const LC = {
ink: [26, 26, 26],
muted: [92, 96, 100],
hair: [206, 208, 212],
accent: [23, 23, 23],
};
// 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: a big, confident name + role in tracked caps, closed by
// the shared hairline-with-accent motif. Full contact details live in the
// footer strip, which keeps the head clean and echoes the résumé header. ---
write(doc, S, dry, cur, header.name, { pt: 20, style: 'bold', color: LC.accent, x, width, factor: 1.12 });
if (header.headline) write(doc, S, dry, cur, header.headline, { pt: 9.5, color: LC.muted, x, width, factor: 1.32, charSpace: 0.8, upper: true });
// Hairline across, with a short heavier accent segment at the start — the
// same motif that anchors every section of the résumé.
cur.y += 3.4 * 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.1 * S);
doc.line(x, cur.y, x + 16 * 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, no "Betreff:" label) — the letter's headline ---
cur.y += 7 * S;
write(doc, S, dry, cur, betreff, { pt: 11.5, 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 });
}
// --- Footer letterhead: a centred contact strip under a hairline, drawn in
// the reserved bottom margin so it never collides with the body. ---
if (!dry) {
const parts = [];
if (header.adresse) parts.push(String(header.adresse).replace(/\s*,\s*/g, ', '));
if (header.telefon) parts.push(header.telefon);
if (header.email) parts.push(header.email);
if (parts.length) {
const fy = PAGE.h - 15;
doc.setDrawColor(LC.hair[0], LC.hair[1], LC.hair[2]);
doc.setLineWidth(0.3 * S);
doc.line(x, fy, right, fy);
doc.setFont('Lato', 'normal');
doc.setFontSize(8 * S);
doc.setTextColor(LC.muted[0], LC.muted[1], LC.muted[2]);
doc.text(parts.join(' · '), (x + right) / 2, fy + 4 * S, { align: 'center' });
}
}
}
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 ...". Verwende ausschließlich ' +
'deutsche Sprache und das lateinische Alphabet - keine fremdsprachigen Wörter oder Schriftzeichen.';
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,
};