Manual generation, LLM notes with company address, and static attachments
- 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>
This commit is contained in:
+108
-20
@@ -10,7 +10,7 @@ const { jsPDF } = require('jspdf');
|
||||
// Ollama Cloud API (https://ollama.com). Override host/model via env if needed.
|
||||
const OLLAMA_HOST = (process.env.OLLAMA_HOST || 'https://ollama.com').replace(/\/+$/, '');
|
||||
const OLLAMA_MODEL = process.env.OLLAMA_MODEL || 'gpt-oss:120b';
|
||||
const OLLAMA_TIMEOUT_MS = Number(process.env.OLLAMA_TIMEOUT_MS || 180000);
|
||||
const OLLAMA_TIMEOUT_MS = Number(process.env.OLLAMA_TIMEOUT_MS || 300000);
|
||||
|
||||
// ===========================================================================
|
||||
// 1. LLM call — produce tailored TEXT for the template
|
||||
@@ -47,12 +47,22 @@ const OUTPUT_SCHEMA = {
|
||||
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: ['betreff', 'anrede', 'absaetze', 'gruss'],
|
||||
required: ['empfaenger', 'betreff', 'anrede', 'absaetze', 'gruss'],
|
||||
},
|
||||
lebenslauf: {
|
||||
type: 'object',
|
||||
@@ -91,7 +101,7 @@ const OUTPUT_SCHEMA = {
|
||||
required: ['headline', 'kontakt', 'anschreiben', 'lebenslauf'],
|
||||
};
|
||||
|
||||
async function generateTailoredTexts({ job, basisDokumente, settings }) {
|
||||
async function generateTailoredTexts({ job, basisDokumente, settings, llmNotizen = '' }) {
|
||||
const apiKey = process.env.OLLAMA_API_KEY;
|
||||
if (!apiKey) {
|
||||
throw new Error(
|
||||
@@ -140,6 +150,7 @@ async function generateTailoredTexts({ job, basisDokumente, settings }) {
|
||||
` "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` +
|
||||
@@ -165,10 +176,17 @@ async function generateTailoredTexts({ job, basisDokumente, settings }) {
|
||||
` }\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. ` +
|
||||
@@ -180,15 +198,26 @@ async function generateTailoredTexts({ job, basisDokumente, settings }) {
|
||||
`- 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.\n` +
|
||||
`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/Berufskolleg-Abschluss → ` +
|
||||
`berufsausbildung; schulische Abschlüsse (Realschule, Gymnasium, Fachoberschulreife/Abitur) → schulbildung; ` +
|
||||
`Zertifikate/Fortbildungen → weiterbildungen. Grundschule weglassen.\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` +
|
||||
@@ -280,6 +309,34 @@ function toZeitraum(v) {
|
||||
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;
|
||||
@@ -320,12 +377,23 @@ function normalizeResult(parsed) {
|
||||
geburtsdatum: str(pick(k, ['geburtsdatum', 'geboren', 'birthdate', 'dob', 'geburt'])),
|
||||
fuehrerschein: str(pick(k, ['fuehrerschein', 'führerschein', 'driving_license', 'license', 'licence', 'fuehrerscheinklasse'])),
|
||||
},
|
||||
anschreiben: {
|
||||
betreff: str(pick(a, ['betreff', 'subject', 'titel'])),
|
||||
anrede: str(pick(a, ['anrede', 'salutation'])),
|
||||
absaetze: pickArray(a, ['absaetze', 'absätze', 'paragraphs', 'text', 'inhalt']).map(str).filter(Boolean),
|
||||
gruss: str(pick(a, ['gruss', 'gruß', 'grussformel', 'closing', 'schluss'])),
|
||||
},
|
||||
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) => ({
|
||||
@@ -579,12 +647,22 @@ const LC = {
|
||||
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;
|
||||
@@ -603,13 +681,18 @@ function composeLetter(doc, S, dry, cur, { letter, header, job, anlagen }) {
|
||||
rule(doc, S, dry, cur.y, x, right, LC.hair, 0.3);
|
||||
cur.y += 13 * S;
|
||||
|
||||
// --- Recipient (Anschriftfeld) ---
|
||||
if (job.firma) write(doc, S, dry, cur, job.firma, { pt: 10.5, color: LC.ink, x, width, factor: 1.32 });
|
||||
if (job.ort) write(doc, S, dry, cur, job.ort, { pt: 10.5, color: LC.ink, x, width, factor: 1.32 });
|
||||
// --- 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 = header.ort ? `${header.ort}, den ${today}` : today;
|
||||
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) ---
|
||||
@@ -679,8 +762,8 @@ function hasLebenslauf(l) {
|
||||
|| l.schulbildung.length || l.weiterbildungen.length || l.kenntnisse.length);
|
||||
}
|
||||
|
||||
async function generateApplicationDocuments({ job, basisDokumente, settings }) {
|
||||
const data = await generateTailoredTexts({ job, basisDokumente, settings });
|
||||
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);
|
||||
|
||||
@@ -690,13 +773,18 @@ async function generateApplicationDocuments({ job, basisDokumente, settings }) {
|
||||
|
||||
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, cvPresent ? ['Lebenslauf'] : []),
|
||||
buffer: renderAnschreibenPdf(data.anschreiben, header, job, anlagen),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user