From cbdbbc07e316928f7cf109e85d904670ea900f0e Mon Sep 17 00:00:00 2001 From: Thomas Hackner Date: Fri, 3 Jul 2026 11:22:14 +0200 Subject: [PATCH] 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 --- lib/documents.js | 128 ++++++++++++++++++++++++++++++++------- package-lock.json | 72 ++++++++++++++++++++++ package.json | 1 + server.js | 142 ++++++++++++++++++++++++++++++++++++++++---- views/bewerbung.ejs | 44 ++++++++++---- views/vorlagen.ejs | 61 +++++++++++++++++++ 6 files changed, 405 insertions(+), 43 deletions(-) diff --git a/lib/documents.js b/lib/documents.js index 712c349..b09f715 100644 --- a/lib/documents.js +++ b/lib/documents.js @@ -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), }); } diff --git a/package-lock.json b/package-lock.json index 584b4fa..e43373c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "express": "^4.18.2", "jspdf": "^2.5.1", "jspdf-autotable": "^3.8.2", + "multer": "^2.2.0", "sqlite3": "^5.1.6" }, "devDependencies": { @@ -187,6 +188,12 @@ "node": ">= 8" } }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, "node_modules/aproba": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz", @@ -384,6 +391,23 @@ "ieee754": "^1.1.13" } }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -533,6 +557,21 @@ "license": "MIT", "optional": true }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, "node_modules/console-control-strings": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", @@ -1790,6 +1829,25 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/multer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz", + "integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/napi-build-utils": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", @@ -2652,6 +2710,14 @@ "node": ">= 0.8" } }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -2849,6 +2915,12 @@ "node": ">= 0.6" } }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, "node_modules/undefsafe": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", diff --git a/package.json b/package.json index 85c67ab..123f085 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "express": "^4.18.2", "jspdf": "^2.5.1", "jspdf-autotable": "^3.8.2", + "multer": "^2.2.0", "sqlite3": "^5.1.6" }, "devDependencies": { diff --git a/server.js b/server.js index 775af91..d8fb7c1 100644 --- a/server.js +++ b/server.js @@ -2,6 +2,7 @@ const express = require('express'); const sqlite3 = require('sqlite3').verbose(); const path = require('path'); const fs = require('fs'); +const multer = require('multer'); // Minimal, dependency-free .env loader: load KEY=VALUE lines from a local // (git-ignored) .env file into process.env without overwriting existing vars. @@ -75,6 +76,25 @@ if (!fs.existsSync(anhaengeDir)) { fs.mkdirSync(anhaengeDir, { recursive: true }); } +// Directory for static extra attachments (e.g. Zeugnisse) the user uploads once +// and that are sent along with every generated application. +const basisAnhaengeDir = path.join(dataDir, 'basis_anhaenge'); +if (!fs.existsSync(basisAnhaengeDir)) { + fs.mkdirSync(basisAnhaengeDir, { recursive: true }); +} + +// Multipart upload for those static attachments +const uploadBasisAnhang = multer({ + storage: multer.diskStorage({ + destination: (req, file, cb) => cb(null, basisAnhaengeDir), + filename: (req, file, cb) => { + const safe = String(file.originalname).replace(/[^a-zA-Z0-9._-]/g, '_'); + cb(null, `${Date.now()}_${safe}`); + }, + }), + limits: { fileSize: 15 * 1024 * 1024 }, // 15 MB +}).single('datei'); + // Database setup const dbPath = path.join(dataDir, 'bewerbungen.db'); const db = new sqlite3.Database(dbPath); @@ -147,6 +167,7 @@ async function runGeneration(bewerbungId) { const bewerbung = await dbGet('SELECT * FROM bewerbungen WHERE id = ?', [bewerbungId]); if (!bewerbung) return; const basisDokumente = await dbAll('SELECT * FROM basis_dokumente ORDER BY id ASC'); + const basisAnhaenge = await dbAll('SELECT * FROM basis_anhaenge ORDER BY id ASC'); const settings = await dbGet('SELECT * FROM settings WHERE id = 1'); const documents = await generateApplicationDocuments({ @@ -159,15 +180,32 @@ 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), + // Free-text notes (company address, contact person, extra context) for the LLM. + llmNotizen: bewerbung.llm_notizen || '', }); - for (const doc of documents) { - const stored = `${bewerbungId}_${Date.now()}_${doc.filename}`; - fs.writeFileSync(path.join(anhaengeDir, stored), doc.buffer); + let seq = 0; + const storeAnhang = async (name, filename, mime, buffer) => { + const stored = `${bewerbungId}_${Date.now()}_${seq++}_${filename}`; + fs.writeFileSync(path.join(anhaengeDir, stored), buffer); await dbRun( 'INSERT INTO anhaenge (bewerbung_id, name, dateiname, mime, pfad) VALUES (?, ?, ?, ?, ?)', - [bewerbungId, doc.name, doc.filename, doc.mime, stored] + [bewerbungId, name, filename, mime, stored] ); + }; + + // Generated (AI) documents + for (const doc of documents) { + await storeAnhang(doc.name, doc.filename, doc.mime, doc.buffer); + } + + // Static extra attachments (e.g. Zeugnisse) — copied as-is + for (const ba of basisAnhaenge) { + 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)); } await dbRun( @@ -214,6 +252,7 @@ function initializeDatabase() { db.run('ALTER TABLE bewerbungen ADD COLUMN quelle_url TEXT', () => { db.run('ALTER TABLE bewerbungen ADD COLUMN generierung_status TEXT', () => { db.run('ALTER TABLE bewerbungen ADD COLUMN generierung_fehler TEXT', () => { + db.run('ALTER TABLE bewerbungen ADD COLUMN llm_notizen TEXT', () => { // Chronological status changes, each with an optional comment db.run(` @@ -241,6 +280,19 @@ function initializeDatabase() { `, (err) => { if (err) return reject(err); + // Static extra attachments (e.g. Zeugnisse) attached to every application + db.run(` + CREATE TABLE IF NOT EXISTS basis_anhaenge ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT, + dateiname TEXT NOT NULL, + mime TEXT, + pfad TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `, (err) => { + if (err) return reject(err); + // Generated attachment files linked to an application db.run(` CREATE TABLE IF NOT EXISTS anhaenge ( @@ -286,6 +338,8 @@ function initializeDatabase() { }); }); }); + }); + }); }); }); }); @@ -431,7 +485,7 @@ initializeDatabase().then(() => { const result = await dbRun( `INSERT INTO bewerbungen (datum, firma, stelle, art, status, notizen, ort, stellenbeschreibung, quelle_url, generierung_status) - VALUES (?, ?, ?, 'Indeed', 'Entwurf', ?, ?, ?, ?, 'ausstehend')`, + VALUES (?, ?, ?, 'Indeed', 'Entwurf', ?, ?, ?, ?, 'nicht_gestartet')`, [datum, firma, stelle, notizen, ort || '', stellenbeschreibung || '', quelle_url || ''] ); @@ -441,14 +495,13 @@ initializeDatabase().then(() => { [result.lastID, datum, 'Entwurf', 'Automatisch über Indeed importiert'] ); - // Kick off AI document generation in the background — respond immediately. - runGeneration(result.lastID); - + // Note: generation is NOT started automatically — the user reviews the draft, + // adds LLM notes if needed, and triggers generation on the application page. res.json({ success: true, id: result.lastID, url: `/bewerbung/${result.lastID}`, - message: 'Bewerbung als Entwurf angelegt. Die Unterlagen werden nun generiert.', + message: 'Bewerbung als Entwurf angelegt. Unterlagen können auf der Bewerbungsseite generiert werden.', }); } catch (error) { console.error('Error importing job:', error); @@ -692,8 +745,10 @@ initializeDatabase().then(() => { app.get('/vorlagen', async (req, res) => { try { const basisDokumente = await dbAll('SELECT * FROM basis_dokumente ORDER BY id ASC'); + const basisAnhaenge = await dbAll('SELECT * FROM basis_anhaenge ORDER BY id ASC'); res.render('vorlagen', { basisDokumente, + basisAnhaenge, basisTypOptions: BASIS_TYP_OPTIONS, hasApiKey: Boolean(process.env.OLLAMA_API_KEY), hideSettings: true, @@ -748,6 +803,63 @@ initializeDatabase().then(() => { } }); + // ----- Static extra attachments (Zeugnisse etc.) ----- + + // Upload an attachment + app.post('/anlagen', (req, res) => { + uploadBasisAnhang(req, res, async (err) => { + try { + if (err) { + console.error('Upload error:', err.message); + return res.redirect('/vorlagen'); + } + if (req.file) { + const original = req.file.originalname || req.file.filename; + const name = (req.body.name && req.body.name.trim()) + ? sanitizeInput(req.body.name.trim()) + : sanitizeInput(original.replace(/\.[^.]+$/, '')); + await dbRun( + 'INSERT INTO basis_anhaenge (name, dateiname, mime, pfad) VALUES (?, ?, ?, ?)', + [name, sanitizeInput(original), req.file.mimetype || 'application/octet-stream', req.file.filename] + ); + } + res.redirect('/vorlagen'); + } catch (error) { + console.error('Error saving attachment:', error); + res.status(500).send('Serverfehler'); + } + }); + }); + + // Download a static attachment + app.get('/anlagen/:id/download', async (req, res) => { + try { + const a = await dbGet('SELECT * FROM basis_anhaenge WHERE id = ?', [req.params.id]); + if (!a) return res.status(404).send('Anlage nicht gefunden'); + const filePath = path.join(basisAnhaengeDir, a.pfad); + if (!fs.existsSync(filePath)) return res.status(404).send('Datei nicht gefunden'); + res.download(filePath, a.dateiname); + } catch (error) { + console.error('Error downloading attachment:', error); + res.status(500).send('Serverfehler'); + } + }); + + // Delete a static attachment + app.post('/anlagen/:id/delete', async (req, res) => { + try { + const a = await dbGet('SELECT * FROM basis_anhaenge WHERE id = ?', [req.params.id]); + if (a) { + fs.promises.unlink(path.join(basisAnhaengeDir, a.pfad)).catch(() => {}); + await dbRun('DELETE FROM basis_anhaenge WHERE id = ?', [req.params.id]); + } + res.redirect('/vorlagen'); + } catch (error) { + console.error('Error deleting attachment:', error); + res.status(500).send('Serverfehler'); + } + }); + // Download a generated attachment app.get('/anhaenge/:id/download', async (req, res) => { try { @@ -779,13 +891,19 @@ initializeDatabase().then(() => { } }); - // Re-run the AI generation for an application (removes old generated files first) - app.post('/bewerbung/:id/regenerate', async (req, res) => { + // Start (or re-run) the AI generation for an application. Saves the LLM notes + // first, removes any previously generated attachments, then generates. + app.post('/bewerbung/:id/generieren', async (req, res) => { try { const { id } = req.params; const bewerbung = await dbGet('SELECT id FROM bewerbungen WHERE id = ?', [id]); if (!bewerbung) return res.status(404).send('Bewerbung nicht gefunden'); + // Persist the LLM notes the user provided (used as context during generation) + if (typeof req.body.llm_notizen !== 'undefined') { + await dbRun('UPDATE bewerbungen SET llm_notizen = ? WHERE id = ?', [req.body.llm_notizen || '', id]); + } + const alte = await dbAll('SELECT * FROM anhaenge WHERE bewerbung_id = ?', [id]); for (const a of alte) { fs.promises.unlink(path.join(anhaengeDir, a.pfad)).catch(() => {}); @@ -796,7 +914,7 @@ initializeDatabase().then(() => { runGeneration(id); res.redirect('/bewerbung/' + id); } catch (error) { - console.error('Error regenerating documents:', error); + console.error('Error generating documents:', error); res.status(500).send('Serverfehler'); } }); diff --git a/views/bewerbung.ejs b/views/bewerbung.ejs index ed41c6b..11d7704 100644 --- a/views/bewerbung.ejs +++ b/views/bewerbung.ejs @@ -101,21 +101,18 @@ data-status="<%= genStatus || '' %>">

Bewerbungsunterlagen

-
- -
- <% if (genStatus === 'ausstehend') { %> + <% if (!genStatus || genStatus === 'nicht_gestartet') { %> +
+ + + + Noch keine Unterlagen generiert. Ergänze bei Bedarf unten Notizen (z. B. die vollständige Firmenanschrift) und starte die Generierung. +
+ <% } else if (genStatus === 'ausstehend') { %>
@@ -183,6 +180,31 @@ <% } %>
+ +
onsubmit="return confirm('Unterlagen neu generieren? Vorhandene generierte Dateien werden ersetzt.');"<% } %>> + +

+ Zusätzliche Infos für die Generierung – z. B. die vollständige Firmenanschrift (Straße, PLZ, Ort), + ein Ansprechpartner oder besondere Hinweise zur Bewerbung. Die KI berücksichtigt diese Angaben. +

+ +
+ +
+
+ <% if (application.stellenbeschreibung && application.stellenbeschreibung.trim()) { %>
diff --git a/views/vorlagen.ejs b/views/vorlagen.ejs index 3377d25..65c0540 100644 --- a/views/vorlagen.ejs +++ b/views/vorlagen.ejs @@ -122,6 +122,67 @@
+ + +

Zusätzliche Anlagen

+

+ Feste Dateien (z. B. Arbeitszeugnisse, Zertifikate), die jeder generierten Bewerbung + automatisch als Anhang beigelegt und im Anschreiben unter „Anlagen" aufgeführt werden. + Für einzelne Bewerbungen kannst du den Anhang später auf der Bewerbungsseite wieder entfernen. +

+ +
+ <% if (basisAnhaenge.length === 0) { %> +

Noch keine zusätzlichen Anlagen hinterlegt.

+ <% } else { %> +
    + <% basisAnhaenge.forEach(a => { %> +
  • +
    + + + +
    +

    <%= a.name || a.dateiname %>

    +

    <%= a.dateiname %>

    +
    +
    +
    + Download +
    + +
    +
    +
  • + <% }); %> +
+ <% } %> + +
+
+
+ + +
+
+ + +
+
+
+ +
+
+
<%- include('partials/footer') %>