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:
2026-07-03 11:22:14 +02:00
co-authored by Claude Opus 4.8
parent 5f02413410
commit cbdbbc07e3
6 changed files with 405 additions and 43 deletions
+108 -20
View File
@@ -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: 34 kurze, überzeugende Absätze (kein Adressblock, kein Datum, ` +
`keine Grußformel hier). Sachlich und positiv, in Aktiv-Sätzen, ohne Floskeln und ` +
`Weichspüler ("Ich würde gerne …"). KEINE Gehaltsvorstellungen, KEINE Kündigungsgründe, ` +
`KEINE Erwähnung von Lücken/Arbeitslosigkeit, keine Selbstzweifel. Keine Platzhalter — echte Angaben nutzen.\n` +
`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 23 jüngsten/relevantesten erhalten EINEN kurzen "beschreibung"-Satz, ältere lässt du leer.\n` +
`- Bildung korrekt einordnen: Hochschulstudium → studium; Berufsausbildung/Berufskolleg-Abschluss` +
`berufsausbildung; schulische Abschlüsse (Realschule, Gymnasium, Fachoberschulreife/Abitur) → schulbildung; ` +
`Zertifikate/Fortbildungen → weiterbildungen. Grundschule weglassen.\n` +
`- 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),
});
}