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. +

+ <% } %> +
+ +