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),
});
}
+72
View File
@@ -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",
+1
View File
@@ -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": {
+130 -12
View File
@@ -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');
}
});
+33 -11
View File
@@ -101,21 +101,18 @@
data-status="<%= genStatus || '' %>">
<div class="flex flex-wrap items-center justify-between gap-3 mb-4">
<h2 class="text-lg font-semibold text-gray-800 dark:text-white">Bewerbungsunterlagen</h2>
<form action="/bewerbung/<%= application.id %>/regenerate" method="POST"
onsubmit="return confirm('Unterlagen neu generieren? Vorhandene generierte Dateien werden ersetzt.');">
<button type="submit"
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
</svg>
Neu generieren
</button>
</form>
</div>
<!-- Generation status banner -->
<div id="genBanner">
<% if (genStatus === 'ausstehend') { %>
<% if (!genStatus || genStatus === 'nicht_gestartet') { %>
<div class="flex items-center gap-3 rounded-lg bg-gray-50 dark:bg-gray-700/40 border border-gray-200 dark:border-gray-700 px-4 py-3 mb-4">
<svg class="w-5 h-5 text-gray-500 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<span class="text-sm text-gray-700 dark:text-gray-300">Noch keine Unterlagen generiert. Ergänze bei Bedarf unten Notizen (z. B. die vollständige Firmenanschrift) und starte die Generierung.</span>
</div>
<% } else if (genStatus === 'ausstehend') { %>
<div class="flex items-center gap-3 rounded-lg bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-800 px-4 py-3 mb-4">
<svg class="w-5 h-5 text-blue-600 dark:text-blue-400 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
@@ -183,6 +180,31 @@
<% } %>
</div>
<!-- LLM notes + generate/regenerate -->
<form action="/bewerbung/<%= application.id %>/generieren" method="POST"
class="mt-6 border-t border-gray-200 dark:border-gray-700 pt-5"
<% if (genStatus === 'fertig') { %>onsubmit="return confirm('Unterlagen neu generieren? Vorhandene generierte Dateien werden ersetzt.');"<% } %>>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Notizen für die KI
</label>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-2">
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.
</p>
<textarea name="llm_notizen" rows="4"
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.&#10;Musterfirma GmbH&#10;Musterstraße 12&#10;45128 Essen&#10;Ansprechpartner: Frau Müller"><%= application.llm_notizen || '' %></textarea>
<div class="flex justify-end mt-3">
<button type="submit" <%= genStatus === 'ausstehend' ? 'disabled' : '' %>
class="inline-flex items-center gap-1.5 px-4 py-2 text-sm bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white rounded-md transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
</svg>
<%= genStatus === 'ausstehend' ? 'Wird generiert…' : (genStatus === 'fertig' ? 'Notizen speichern & neu generieren' : 'Unterlagen generieren') %>
</button>
</div>
</form>
<!-- Job description from the import -->
<% if (application.stellenbeschreibung && application.stellenbeschreibung.trim()) { %>
<details class="mt-6 rounded-lg border border-gray-200 dark:border-gray-700">
+61
View File
@@ -122,6 +122,67 @@
</div>
</form>
</section>
<!-- Static extra attachments (Zeugnisse etc.) -->
<h2 class="text-xl font-bold text-gray-800 dark:text-white mt-10 mb-2">Zusätzliche Anlagen</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6">
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.
</p>
<section class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-6">
<% if (basisAnhaenge.length === 0) { %>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">Noch keine zusätzlichen Anlagen hinterlegt.</p>
<% } else { %>
<ul class="space-y-2 mb-2">
<% basisAnhaenge.forEach(a => { %>
<li class="flex items-center justify-between gap-3 rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700/40 px-4 py-3">
<div class="flex items-center gap-3 min-w-0">
<svg class="w-6 h-6 text-red-500 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"></path>
</svg>
<div class="min-w-0">
<p class="text-sm font-medium text-gray-800 dark:text-gray-100 truncate"><%= a.name || a.dateiname %></p>
<p class="text-xs text-gray-500 dark:text-gray-400 truncate"><%= a.dateiname %></p>
</div>
</div>
<div class="flex items-center gap-3 shrink-0">
<a href="/anlagen/<%= a.id %>/download" class="text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400 hover:underline">Download</a>
<form action="/anlagen/<%= a.id %>/delete" method="POST" onsubmit="return confirm('Diese Anlage löschen?');">
<button type="submit" class="text-red-500 hover:text-red-700 dark:text-red-400" aria-label="Anlage löschen">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
</svg>
</button>
</form>
</div>
</li>
<% }); %>
</ul>
<% } %>
<form action="/anlagen" method="POST" enctype="multipart/form-data" class="border-t border-gray-200 dark:border-gray-700 pt-4 mt-4 space-y-3">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Datei (PDF)</label>
<input type="file" name="datei" accept="application/pdf,image/*" required
class="w-full text-sm text-gray-700 dark:text-gray-300 file:mr-3 file:py-2 file:px-3 file:rounded-md file:border-0 file:bg-blue-600 file:text-white hover:file:bg-blue-700">
</div>
<div>
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Bezeichnung (optional)</label>
<input type="text" name="name"
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"
placeholder="z. B. Arbeitszeugnis">
</div>
</div>
<div class="flex justify-end">
<button type="submit" class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-md transition-colors">
Anlage hochladen
</button>
</div>
</form>
</section>
</main>
<%- include('partials/footer') %>