From 26872ece08aad7fed290539eefec2f0a7ffeaa4b Mon Sep 17 00:00:00 2001 From: Thomas Hackner Date: Sat, 4 Jul 2026 17:36:14 +0200 Subject: [PATCH] Choose enclosed attachments before generating; drop instant-generate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generate form now lets the user pick which extra attachments (basis_anhaenge) to enclose — none selected by default. Only the chosen ones are attached, and their names are passed to the LLM so the cover letter's "Anlagen" list and wording reflect exactly what is enclosed. Job offers keep only "Als Bewerbung übernehmen" (draft first); the "Übernehmen & KI-Unterlagen" button and its route are removed. REST API: POST /applications/:id/generate accepts an optional `anlagen` array of attachment IDs (Swagger updated). Co-Authored-By: Claude Opus 4.8 --- lib/api.js | 7 ++++++- lib/documents.js | 16 ++++++++++++++-- lib/openapi.js | 7 +++++++ server.js | 44 ++++++++++++++++--------------------------- views/bewerbung.ejs | 32 ++++++++++++++++++++++++++++++- views/jobangebote.ejs | 14 +++----------- 6 files changed, 77 insertions(+), 43 deletions(-) diff --git a/lib/api.js b/lib/api.js index 6525b4b..6d65ed7 100644 --- a/lib/api.js +++ b/lib/api.js @@ -387,7 +387,12 @@ function createExternalApi(deps) { [id] ); - runGeneration(id); + // Optional: IDs of static attachments (basis_anhaenge) to enclose; none + // by default. Also reflected in the cover letter's "Anlagen" list. + 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 }); } catch (error) { console.error('API generate error:', error); diff --git a/lib/documents.js b/lib/documents.js index 0be04ad..4fac96d 100644 --- a/lib/documents.js +++ b/lib/documents.js @@ -127,7 +127,7 @@ const OUTPUT_SCHEMA = { required: ['headline', 'kontakt', 'anschreiben', 'email', 'lebenslauf'], }; -async function generateTailoredTexts({ job, basisDokumente, settings, llmNotizen = '' }) { +async function generateTailoredTexts({ job, basisDokumente, settings, llmNotizen = '', zusatzAnlagen = [] }) { const apiKey = process.env.OLLAMA_API_KEY; if (!apiKey) { throw new Error( @@ -215,11 +215,19 @@ 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`; + 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 + + anlagenText + `# 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. ` + @@ -257,6 +265,10 @@ async function generateTailoredTexts({ job, basisDokumente, settings, llmNotizen `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. ` + + `Berücksichtige die beigefügten Anlagen (siehe Abschnitt "Beigefügte Anlagen"): Du darfst im Text ` + + `natürlich auf relevante Anlagen verweisen (z. B. "wie meine beigefügten Arbeitszeugnisse zeigen"), ` + + `aber erfinde KEINE Anlagen, die dort nicht aufgeführt sind, und stütze keine Aussage auf einen Nachweis, ` + + `der nicht beiliegt. Erstelle KEINE eigene "Anlagen:"-Auflistung im Text — die Anlagenliste wird separat erzeugt. ` + `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` + @@ -1129,7 +1141,7 @@ function hasLebenslauf(l) { } async function generateApplicationDocuments({ job, basisDokumente, settings, zusatzAnlagen = [], llmNotizen = '', signatur = null, bewerbungsfoto = null }) { - const data = await generateTailoredTexts({ job, basisDokumente, settings, llmNotizen }); + const data = await generateTailoredTexts({ job, basisDokumente, settings, llmNotizen, zusatzAnlagen }); const headline = data.headline || job.stelle || ''; const header = buildHeader(settings, data.kontakt, headline); diff --git a/lib/openapi.js b/lib/openapi.js index c638020..67300f3 100644 --- a/lib/openapi.js +++ b/lib/openapi.js @@ -346,6 +346,13 @@ function buildOpenApiSpec(baseUrl = '') { type: 'object', properties: { llm_notizen: { type: 'string', description: 'Freitext-Kontext für die KI (optional)' }, + anlagen: { + type: 'array', + items: { type: 'integer' }, + description: + 'IDs der zusätzlich beizulegenden statischen Anlagen (basis_anhaenge). ' + + 'Standard: keine. Die Auswahl erscheint auch im Anschreiben unter "Anlagen".', + }, }, }, }, diff --git a/server.js b/server.js index f847df4..fd9854f 100644 --- a/server.js +++ b/server.js @@ -435,7 +435,7 @@ async function attachVerlauf(applications) { // Run the AI document generation for one application (async, fire-and-forget). // Loads the base documents + user settings, asks the LLM to tailor them to the // job, writes the resulting PDFs to disk and links them as attachments. -async function runGeneration(bewerbungId) { +async function runGeneration(bewerbungId, options = {}) { try { const bewerbung = await dbGet('SELECT * FROM bewerbungen WHERE id = ?', [bewerbungId]); if (!bewerbung) return; @@ -443,6 +443,10 @@ async function runGeneration(bewerbungId) { const basisAnhaenge = await dbAll('SELECT * FROM basis_anhaenge ORDER BY id ASC'); const settings = await dbGet('SELECT * FROM settings WHERE id = 1'); + // Only the explicitly selected extra attachments are enclosed (default: none). + const anlagenIds = Array.isArray(options.anlagenIds) ? options.anlagenIds.map(Number) : []; + const selectedAnhaenge = basisAnhaenge.filter((a) => anlagenIds.includes(a.id)); + const { documents, email } = await generateApplicationDocuments({ job: { firma: bewerbung.firma, @@ -453,8 +457,9 @@ async function runGeneration(bewerbungId) { }, basisDokumente, settings, - // Names of the static attachments so the cover letter can list them under "Anlagen". - zusatzAnlagen: basisAnhaenge.map((a) => a.name || a.dateiname), + // 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), // Free-text notes (company address, contact person, extra context) for the LLM. llmNotizen: bewerbung.llm_notizen || '', // Signature image placed under the closing salutation (instead of the typed name). @@ -478,8 +483,8 @@ async function runGeneration(bewerbungId) { await storeAnhang(doc.name, doc.filename, doc.mime, doc.buffer); } - // Static extra attachments (e.g. Zeugnisse) — copied as-is - for (const ba of basisAnhaenge) { + // Selected extra attachments (e.g. Zeugnisse) — copied as-is + for (const ba of selectedAnhaenge) { const src = path.join(basisAnhaengeDir, ba.pfad); if (!fs.existsSync(src)) continue; await storeAnhang(ba.name || ba.dateiname, ba.dateiname, ba.mime || 'application/octet-stream', fs.readFileSync(src)); @@ -1078,6 +1083,8 @@ initializeDatabase().then(() => { [id] ); const basisCountRow = await dbGet('SELECT COUNT(*) as count FROM basis_dokumente'); + // Available static attachments (Zeugnisse etc.) to optionally enclose. + const basisAnhaenge = await dbAll('SELECT id, name, dateiname FROM basis_anhaenge ORDER BY id ASC'); // E-Mail correspondence (sent + received), oldest first, with attachments. const emails = await dbAll( @@ -1112,6 +1119,7 @@ initializeDatabase().then(() => { mailError: req.query.mailerror ? String(req.query.mailerror) : '', mailOk: req.query.mailok ? String(req.query.mailok) : '', basisCount: basisCountRow ? basisCountRow.count : 0, + basisAnhaenge, artOptions: ART_OPTIONS, statusOptions: STATUS_OPTIONS, hideSettings: true @@ -1642,7 +1650,9 @@ initializeDatabase().then(() => { await dbRun('DELETE FROM anhaenge WHERE bewerbung_id = ?', [id]); await dbRun("UPDATE bewerbungen SET generierung_status = 'ausstehend', generierung_fehler = NULL WHERE id = ?", [id]); - runGeneration(id); + // 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 }); res.redirect('/bewerbung/' + id); } catch (error) { console.error('Error generating documents:', error); @@ -1784,28 +1794,6 @@ initializeDatabase().then(() => { } }); - // Übernehmen AND immediately kick off the AI generation, then jump to the - // application page where the progress is shown. - app.post('/jobangebote/:id/uebernehmen-generieren', async (req, res) => { - try { - const angebot = await dbGet('SELECT * FROM jobangebote WHERE id = ?', [req.params.id]); - if (!angebot) return res.status(404).send('Jobangebot nicht gefunden'); - const bewerbungId = await uebernehmeAngebot(angebot); - - // Clear any previously generated files, then (re)start generation. - const alte = await dbAll('SELECT * FROM anhaenge WHERE bewerbung_id = ?', [bewerbungId]); - for (const a of alte) fs.promises.unlink(path.join(anhaengeDir, a.pfad)).catch(() => {}); - await dbRun('DELETE FROM anhaenge WHERE bewerbung_id = ?', [bewerbungId]); - await dbRun("UPDATE bewerbungen SET generierung_status = 'ausstehend', generierung_fehler = NULL WHERE id = ?", [bewerbungId]); - runGeneration(bewerbungId); - - res.redirect('/bewerbung/' + bewerbungId); - } catch (error) { - console.error('Error converting + generating from job offer:', error); - res.status(500).send('Serverfehler'); - } - }); - // Edit an offer's fields — mainly to paste/adjust the full job description // (any length) before turning it into an application. app.post('/jobangebote/:id/bearbeiten', async (req, res) => { diff --git a/views/bewerbung.ejs b/views/bewerbung.ejs index 93102fc..4b4a5bd 100644 --- a/views/bewerbung.ejs +++ b/views/bewerbung.ejs @@ -233,7 +233,37 @@ -
+ + +
+ + <% if (basisAnhaenge && basisAnhaenge.length) { %> +

+ Wähle aus, welche Anlagen (z. B. Zeugnisse) mitgeschickt werden. Standardmäßig + ist nichts ausgewählt; die Auswahl wird auch im Anschreiben unter „Anlagen“ berücksichtigt. +

+
+ <% basisAnhaenge.forEach(function(ba){ %> + + <% }); %> +
+ <% } else { %> +

+ Keine zusätzlichen Anlagen hinterlegt. Unter + Vorlagen + kannst du statische Anlagen (z. B. Zeugnisse) hochladen. +

+ <% } %> +
+ +