From d1116d522d181acef1bc5c10893e24308da93a65 Mon Sep 17 00:00:00 2001 From: Thomas Hackner Date: Mon, 13 Jul 2026 02:18:02 +0200 Subject: [PATCH] Vor dem Generieren waehlen: Anschreiben, Lebenslauf oder beides Auf der Bewerbungsseite laesst sich jetzt auswaehlen, welche Unterlagen die KI erzeugt. Standard bleibt beides; die letzte Auswahl wird gemerkt und ist beim naechsten Generieren vorbelegt. Ohne Haken bricht das Formular ab statt still beides zu erzeugen. Nicht gewaehlte Dokumente werden gar nicht erst angefragt: JSON-Schema und Skelett im Prompt werden auf die gewuenschten Teile reduziert, das spart einen Gutteil der Generierungszeit. Gerendert wird ebenfalls nur das Gewaehlte - auch wenn das Modell sich nicht an das Schema haelt und trotzdem alles liefert. Fuer den E-Mail-Versand mitgedacht: - Wird kein Lebenslauf erzeugt, fuehrt ihn das Anschreiben nicht mehr unter "Anlagen" auf. Sonst kuendigt der Brief eine Anlage an, die nie mitgeht. - Der Prompt bekommt zusaetzlich die Liste der Dateien, die der Bewerbungsmail tatsaechlich anhaengen (inkl. Anschreiben selbst), damit der Begleittext keine Unterlagen nennt, die nicht dabei sind. - Die Anhang-Checkboxen im Mailformular ziehen sich aus den erzeugten Anhaengen, greifen also automatisch. Die REST-API kennt das Feld ebenfalls (POST .../generate, "dokumente"), inkl. OpenAPI-Doku. Co-Authored-By: Claude Opus 4.8 --- lib/api.js | 9 ++- lib/documents.js | 173 ++++++++++++++++++++++++++++++++------------ lib/openapi.js | 9 +++ server.js | 21 +++++- views/bewerbung.ejs | 62 +++++++++++++++- 5 files changed, 223 insertions(+), 51 deletions(-) diff --git a/lib/api.js b/lib/api.js index 802cd69..60b4a8d 100644 --- a/lib/api.js +++ b/lib/api.js @@ -10,6 +10,7 @@ const path = require('path'); const fs = require('fs'); const blacklist = require('./blacklist'); const { LABEL_OPTIONS, parseLabels, serializeLabels } = require('./labels'); +const { normalizeDokumente } = require('./documents'); // Replace the stored labels JSON string with a real array on outgoing rows. function withLabels(row) { @@ -410,8 +411,12 @@ function createExternalApi(deps) { const anlagenIds = Array.isArray((req.body || {}).anlagen) ? req.body.anlagen.map((v) => parseInt(v, 10)).filter((n) => !Number.isNaN(n)) : []; - runGeneration(id, { anlagenIds }); - res.status(202).json({ success: true }); + // Optional: which documents to produce (["anschreiben"], ["lebenslauf"] or + // both). Omitted / empty means both. + const dokumente = normalizeDokumente((req.body || {}).dokumente); + await dbRun('UPDATE bewerbungen SET generierung_dokumente = ? WHERE id = ?', [dokumente.join(','), id]); + runGeneration(id, { anlagenIds, dokumente }); + res.status(202).json({ success: true, dokumente }); } catch (error) { console.error('API generate error:', error); res.status(500).json({ error: 'Serverfehler' }); diff --git a/lib/documents.js b/lib/documents.js index d42d95b..d6eb681 100644 --- a/lib/documents.js +++ b/lib/documents.js @@ -148,7 +148,35 @@ const OUTPUT_SCHEMA = { required: ['headline', 'kontakt', 'anschreiben', 'email', 'lebenslauf'], }; -async function generateTailoredTexts({ job, basisDokumente, settings, llmNotizen = '', zusatzAnlagen = [], prompts = null }) { +// Which documents a generation run should produce. `email` (the covering note +// for the mail body) is always generated — it is what the application is sent +// with, whatever is attached to it. +const DOKUMENT_TYPEN = ['anschreiben', 'lebenslauf']; + +// Accept anything (string, array, undefined) and return a clean, ordered list. +// Empty / unknown input means "both" — the default everywhere. +function normalizeDokumente(v) { + const gewaehlt = [].concat(v == null ? [] : v).map((x) => String(x).trim().toLowerCase()); + const gefiltert = DOKUMENT_TYPEN.filter((d) => gewaehlt.includes(d)); + return gefiltert.length ? gefiltert : DOKUMENT_TYPEN.slice(); +} + +// Ask the model only for the documents we actually want. Dropping a section from +// the schema means the model never writes it — that saves a chunk of generation +// time and tokens when only one document is needed. +function buildOutputSchema(dokumente) { + const properties = { ...OUTPUT_SCHEMA.properties }; + if (!dokumente.includes('anschreiben')) delete properties.anschreiben; + if (!dokumente.includes('lebenslauf')) delete properties.lebenslauf; + return { + type: 'object', + properties, + required: OUTPUT_SCHEMA.required.filter((k) => !DOKUMENT_TYPEN.includes(k) || dokumente.includes(k)), + }; +} + +async function generateTailoredTexts({ job, basisDokumente, settings, llmNotizen = '', zusatzAnlagen = [], prompts = null, dokumente = null }) { + const doks = normalizeDokumente(dokumente); const apiKey = process.env.OLLAMA_API_KEY; if (!apiKey) { throw new Error( @@ -187,38 +215,50 @@ async function generateTailoredTexts({ job, basisDokumente, settings, llmNotizen // below stays in code because the response parsing depends on it. const system = promptStore.get(prompts, 'unterlagen'); - 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` + + // Built from blocks so it matches the (possibly reduced) schema exactly: a + // skeleton showing a section the schema forbids would only confuse the model. + const skelettBloecke = [ + ` "headline": "Kurze Berufsbezeichnung (max. 5 Wörter)"`, + ` "kontakt": { "email": "", "telefon": "", "ort": "", "webseite": "", "geburtsdatum": "" }`, + ]; + if (doks.includes('anschreiben')) { + skelettBloecke.push( + ` "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` + + ` }` + ); + } + skelettBloecke.push( ` "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` + - `}`; + ` }` + ); + if (doks.includes('lebenslauf')) { + skelettBloecke.push( + ` "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` + + ` }` + ); + } + const skeleton = `{\n${skelettBloecke.join(',\n')}\n}`; const notizenText = (llmNotizen && llmNotizen.trim()) ? `# Zusätzliche Notizen des Bewerbers (WICHTIG — nutze diese Angaben aktiv: z. B. vollständige ` + @@ -226,12 +266,46 @@ async function generateTailoredTexts({ job, basisDokumente, settings, llmNotizen `${llmNotizen.trim()}\n\n` : ''; - // Attachments actually enclosed with this application. The Lebenslauf is - // always part of the set; the extra items are the ones the user selected. - const anlagenListe = ['Lebenslauf', ...(zusatzAnlagen || []).filter(Boolean)]; - const anlagenText = - `# Beigefügte Anlagen (genau diese Dokumente liegen der Bewerbung bei)\n` + - anlagenListe.map((a) => `- ${a}`).join('\n') + `\n\n`; + // Attachments actually enclosed with this application. The Lebenslauf is only + // listed when it is actually being generated — otherwise the cover letter would + // promise an enclosure that never gets attached. + const anlagenListe = [ + ...(doks.includes('lebenslauf') ? ['Lebenslauf'] : []), + ...(zusatzAnlagen || []).filter(Boolean), + ]; + const anlagenText = anlagenListe.length + ? `# Beigefügte Anlagen (genau diese Dokumente liegen der Bewerbung bei)\n` + + anlagenListe.map((a) => `- ${a}`).join('\n') + `\n\n` + : `# Beigefügte Anlagen\n(keine — erwähne keine Anlagen und kündige keine an)\n\n`; + + // What the application e-mail will actually carry. Unlike the "Anlagen" list + // above (which is what the *letter* lists beneath itself), this includes the + // cover letter itself — so the mail's covering note never announces a document + // that isn't attached. + const mailAnhaenge = [ + ...(doks.includes('anschreiben') ? ['Anschreiben (PDF)'] : []), + ...(doks.includes('lebenslauf') ? ['Lebenslauf (PDF)'] : []), + ...(zusatzAnlagen || []).filter(Boolean), + ]; + const mailAnhangText = + `# Anhänge der Bewerbungs-E-Mail (genau diese Dateien werden mitgeschickt)\n` + + mailAnhaenge.map((a) => `- ${a}`).join('\n') + `\n` + + `Formuliere "email.text" passend zu genau diesen Anhängen — nenne keine Unterlagen, ` + + `die nicht dabei sind.\n\n`; + + const aufgabe = doks.length === 2 + ? `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.` + : doks[0] === 'anschreiben' + ? `Erzeuge NUR die TEXTE für ein Anschreiben, passgenau auf diese Stelle zugeschnitten. ` + + `Ein Lebenslauf wird diesmal NICHT erstellt und darf nicht im JSON auftauchen. ` + + `Layout/Design ist bereits vorgegeben — liefere nur die Inhalte. Das Anschreiben wird ` + + `auf EINER A4-Seite gedruckt, halte dich also kurz.` + : `Erzeuge NUR die TEXTE für einen Lebenslauf, passgenau auf diese Stelle zugeschnitten. ` + + `Ein Anschreiben wird diesmal NICHT erstellt und darf nicht im JSON auftauchen. ` + + `Layout/Design ist bereits vorgegeben — liefere nur die Inhalte. Der Lebenslauf wird ` + + `auf EINER A4-Seite gedruckt, halte dich also kurz.`; const userPrompt = `# Bewerberdaten\n${bewerber}\n\n` + @@ -239,10 +313,9 @@ async function generateTailoredTexts({ job, basisDokumente, settings, llmNotizen `# Zielstelle\n${stelleText}\n\n` + notizenText + anlagenText + + mailAnhangText + `# 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` + + aufgabe + `\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` + @@ -358,7 +431,7 @@ async function generateTailoredTexts({ job, basisDokumente, settings, llmNotizen body: JSON.stringify({ model: OLLAMA_MODEL, stream: false, - format: OUTPUT_SCHEMA, + format: buildOutputSchema(doks), options: { temperature: 0.4 }, messages: [ { role: 'system', content: system }, @@ -1797,8 +1870,12 @@ function hasLebenslauf(l) { || l.schulbildung.length || l.weiterbildungen.length || l.kenntnisse.length); } -async function generateApplicationDocuments({ job, basisDokumente, settings, zusatzAnlagen = [], llmNotizen = '', signatur = null, bewerbungsfoto = null, prompts = null, design = null }) { - const data = await generateTailoredTexts({ job, basisDokumente, settings, llmNotizen, zusatzAnlagen, prompts }); +// `dokumente` = which documents to produce, e.g. ['anschreiben'] or both +// (the default). Anything not requested is neither asked of the model nor +// rendered — and, crucially, is not announced as an enclosure in the letter. +async function generateApplicationDocuments({ job, basisDokumente, settings, zusatzAnlagen = [], llmNotizen = '', signatur = null, bewerbungsfoto = null, prompts = null, design = null, dokumente = null }) { + const doks = normalizeDokumente(dokumente); + const data = await generateTailoredTexts({ job, basisDokumente, settings, llmNotizen, zusatzAnlagen, prompts, dokumente: doks }); const headline = data.headline || job.stelle || ''; const header = buildHeader(settings, data.kontakt, headline); @@ -1807,14 +1884,18 @@ async function generateApplicationDocuments({ job, basisDokumente, settings, zus 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 cvGewuenscht = doks.includes('lebenslauf'); + const briefGewuenscht = doks.includes('anschreiben'); + const cvPresent = cvGewuenscht && hasLebenslauf(data.lebenslauf); + + // "Anlagen" list for the letter: the Lebenslauf only when one is actually + // produced, then any static extras. Never promise an enclosure we don't send. const anlagen = [ ...(cvPresent ? ['Lebenslauf'] : []), ...zusatzAnlagen.filter(Boolean), ]; - if (hasAnschreiben(data.anschreiben)) { + if (briefGewuenscht && hasAnschreiben(data.anschreiben)) { documents.push({ name: `Anschreiben - ${label}`.trim(), filename: `Anschreiben_${suffix}.pdf`, @@ -2021,4 +2102,6 @@ module.exports = { renderLebenslaufPdf, renderAnschreibenPdf, renderDesignVorschau, + DOKUMENT_TYPEN, + normalizeDokumente, }; diff --git a/lib/openapi.js b/lib/openapi.js index 28e39bd..d12e7b3 100644 --- a/lib/openapi.js +++ b/lib/openapi.js @@ -363,6 +363,15 @@ function buildOpenApiSpec(baseUrl = '') { 'IDs der zusätzlich beizulegenden statischen Anlagen (basis_anhaenge). ' + 'Standard: keine. Die Auswahl erscheint auch im Anschreiben unter "Anlagen".', }, + dokumente: { + type: 'array', + items: { type: 'string', enum: ['anschreiben', 'lebenslauf'] }, + description: + 'Welche Unterlagen erzeugt werden sollen. Weglassen oder leer = beide (Standard). ' + + 'Wird der Lebenslauf nicht erzeugt, führt ihn das Anschreiben auch nicht unter ' + + '"Anlagen" auf, und der E-Mail-Begleittext kündigt ihn nicht an.', + example: ['anschreiben'], + }, }, }, }, diff --git a/server.js b/server.js index e7a43c0..879b792 100644 --- a/server.js +++ b/server.js @@ -27,7 +27,10 @@ const multer = require('multer'); } })(); -const { generateApplicationDocuments, generateEmailReply, renderDesignVorschau } = require('./lib/documents'); +const { + generateApplicationDocuments, generateEmailReply, renderDesignVorschau, + DOKUMENT_TYPEN, normalizeDokumente, +} = require('./lib/documents'); const chat = require('./lib/chat'); const promptStore = require('./lib/prompts'); const designStore = require('./lib/design'); @@ -617,6 +620,9 @@ async function runGeneration(bewerbungId, options = {}) { const anlagenIds = Array.isArray(options.anlagenIds) ? options.anlagenIds.map(Number) : []; const selectedAnhaenge = basisAnhaenge.filter((a) => anlagenIds.includes(a.id)); + // Which documents to produce; unset means both (the default everywhere). + const dokumente = normalizeDokumente(options.dokumente); + const { documents, email } = await generateApplicationDocuments({ job: { firma: bewerbung.firma, @@ -629,6 +635,7 @@ async function runGeneration(bewerbungId, options = {}) { settings, prompts, design, + dokumente, // Names of the selected attachments so the cover letter (and the LLM) lists // exactly these under "Anlagen". zusatzAnlagen: selectedAnhaenge.map((a) => a.name || a.dateiname), @@ -851,6 +858,9 @@ function initializeDatabase() { `); // Migration: labels (JSON array of work-location labels) on both Stellen tables. db.run('ALTER TABLE bewerbungen ADD COLUMN labels TEXT', () => {}); + // Which documents the last generation run produced ("anschreiben,lebenslauf"). + // Remembered so the form comes back pre-ticked with the user's last choice. + db.run('ALTER TABLE bewerbungen ADD COLUMN generierung_dokumente TEXT', () => {}); db.run('ALTER TABLE jobangebote ADD COLUMN labels TEXT', () => {}); // Migration: add columns to pre-existing jobangebote tables. db.run('ALTER TABLE jobangebote ADD COLUMN anzeige_datum DATE', () => {}); @@ -1463,6 +1473,10 @@ initializeDatabase().then(() => { anhaenge, interneAnhaenge, emails, + // Last choice of documents (defaults to both), for pre-ticking the form. + dokumentAuswahl: normalizeDokumente( + application.generierung_dokumente ? String(application.generierung_dokumente).split(',') : null + ), mailConfigured: mailer.isConfigured(), mailFrom: mailer.isConfigured() ? mailer.fromField() : '', mailError: req.query.mailerror ? String(req.query.mailerror) : '', @@ -2355,7 +2369,10 @@ initializeDatabase().then(() => { // Selected extra attachments (checkbox values); none by default. const anlagenIds = [].concat(req.body.anlage || []).map((v) => parseInt(v, 10)).filter((n) => !Number.isNaN(n)); - runGeneration(id, { anlagenIds }); + // Which documents to generate; nothing ticked = both (the default). + const dokumente = normalizeDokumente(req.body.dokument); + await dbRun('UPDATE bewerbungen SET generierung_dokumente = ? WHERE id = ?', [dokumente.join(','), id]); + runGeneration(id, { anlagenIds, dokumente }); res.redirect('/bewerbung/' + id); } catch (error) { console.error('Error generating documents:', error); diff --git a/views/bewerbung.ejs b/views/bewerbung.ejs index d11ac11..966d144 100644 --- a/views/bewerbung.ejs +++ b/views/bewerbung.ejs @@ -326,8 +326,9 @@
onsubmit="return confirm('Unterlagen neu generieren? Vorhandene generierte Dateien werden ersetzt.');"<% } %>> + id="generierenForm" + data-bestaetigen="<%= genStatus === 'fertig' ? '1' : '' %>" + class="mt-6 border-t border-gray-200 dark:border-gray-700 pt-5"> @@ -339,6 +340,32 @@ class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-800 dark:text-white leading-relaxed" placeholder="z. B. Musterfirma GmbH Musterstraße 12 45128 Essen Ansprechpartner: Frau Müller"><%= application.llm_notizen || '' %> + +
+ +

+ Standardmäßig werden Anschreiben und Lebenslauf erstellt. Wird der Lebenslauf abgewählt, + führt ihn das Anschreiben auch nicht mehr unter „Anlagen“ auf. +

+
+ + +
+ +
+
@@ -913,6 +940,37 @@ }, 4000); } })(); + + // Generation form: require at least one document, and keep the + // "replace existing files" confirmation when regenerating. + (function () { + const form = document.getElementById('generierenForm'); + if (!form) return; + const boxes = Array.from(form.querySelectorAll('[data-dokument]')); + const hinweis = document.getElementById('dokumentHinweis'); + + form.addEventListener('submit', function (e) { + const gewaehlt = boxes.filter((b) => b.checked); + if (!gewaehlt.length) { + e.preventDefault(); + if (hinweis) hinweis.classList.remove('hidden'); + return; + } + if (form.dataset.bestaetigen === '1') { + const was = gewaehlt.length === 2 + ? 'Anschreiben und Lebenslauf' + : (gewaehlt[0].value === 'anschreiben' ? 'das Anschreiben' : 'der Lebenslauf'); + if (!confirm('Es ' + (gewaehlt.length === 2 ? 'werden ' : 'wird ') + was + + ' neu generiert. Vorhandene generierte Dateien werden ersetzt. Fortfahren?')) { + e.preventDefault(); + } + } + }); + + boxes.forEach((b) => b.addEventListener('change', function () { + if (hinweis && boxes.some((x) => x.checked)) hinweis.classList.add('hidden'); + })); + })();