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:
+108
-20
@@ -10,7 +10,7 @@ const { jsPDF } = require('jspdf');
|
|||||||
// Ollama Cloud API (https://ollama.com). Override host/model via env if needed.
|
// 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_HOST = (process.env.OLLAMA_HOST || 'https://ollama.com').replace(/\/+$/, '');
|
||||||
const OLLAMA_MODEL = process.env.OLLAMA_MODEL || 'gpt-oss:120b';
|
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
|
// 1. LLM call — produce tailored TEXT for the template
|
||||||
@@ -47,12 +47,22 @@ const OUTPUT_SCHEMA = {
|
|||||||
anschreiben: {
|
anschreiben: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
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' },
|
betreff: { type: 'string' },
|
||||||
anrede: { type: 'string' },
|
anrede: { type: 'string' },
|
||||||
absaetze: { type: 'array', items: { type: 'string' } },
|
absaetze: { type: 'array', items: { type: 'string' } },
|
||||||
gruss: { type: 'string' },
|
gruss: { type: 'string' },
|
||||||
},
|
},
|
||||||
required: ['betreff', 'anrede', 'absaetze', 'gruss'],
|
required: ['empfaenger', 'betreff', 'anrede', 'absaetze', 'gruss'],
|
||||||
},
|
},
|
||||||
lebenslauf: {
|
lebenslauf: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
@@ -91,7 +101,7 @@ const OUTPUT_SCHEMA = {
|
|||||||
required: ['headline', 'kontakt', 'anschreiben', 'lebenslauf'],
|
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;
|
const apiKey = process.env.OLLAMA_API_KEY;
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -140,6 +150,7 @@ async function generateTailoredTexts({ job, basisDokumente, settings }) {
|
|||||||
` "headline": "Kurze Berufsbezeichnung (max. 5 Wörter)",\n` +
|
` "headline": "Kurze Berufsbezeichnung (max. 5 Wörter)",\n` +
|
||||||
` "kontakt": { "email": "", "telefon": "", "ort": "", "webseite": "", "geburtsdatum": "", "fuehrerschein": "" },\n` +
|
` "kontakt": { "email": "", "telefon": "", "ort": "", "webseite": "", "geburtsdatum": "", "fuehrerschein": "" },\n` +
|
||||||
` "anschreiben": {\n` +
|
` "anschreiben": {\n` +
|
||||||
|
` "empfaenger": { "firma": "Unternehmen", "adresse": "Straße Hausnr. (falls bekannt, sonst leer)", "ort": "PLZ Ort", "ansprechpartner": "" },\n` +
|
||||||
` "betreff": "Bewerbung als …",\n` +
|
` "betreff": "Bewerbung als …",\n` +
|
||||||
` "anrede": "Sehr geehrte Damen und Herren,",\n` +
|
` "anrede": "Sehr geehrte Damen und Herren,",\n` +
|
||||||
` "absaetze": ["Absatz 1", "Absatz 2", "Absatz 3"],\n` +
|
` "absaetze": ["Absatz 1", "Absatz 2", "Absatz 3"],\n` +
|
||||||
@@ -165,10 +176,17 @@ async function generateTailoredTexts({ job, basisDokumente, settings }) {
|
|||||||
` }\n` +
|
` }\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 =
|
const userPrompt =
|
||||||
`# Bewerberdaten\n${bewerber}\n\n` +
|
`# Bewerberdaten\n${bewerber}\n\n` +
|
||||||
`# Basis-Unterlagen des Bewerbers (Faktengrundlage — NUR diese Fakten verwenden)\n${basisText}\n\n` +
|
`# Basis-Unterlagen des Bewerbers (Faktengrundlage — NUR diese Fakten verwenden)\n${basisText}\n\n` +
|
||||||
`# Zielstelle\n${stelleText}\n\n` +
|
`# Zielstelle\n${stelleText}\n\n` +
|
||||||
|
notizenText +
|
||||||
`# Aufgabe\n` +
|
`# Aufgabe\n` +
|
||||||
`Erzeuge die TEXTE für ein Anschreiben und einen Lebenslauf, passgenau auf diese ` +
|
`Erzeuge die TEXTE für ein Anschreiben und einen Lebenslauf, passgenau auf diese ` +
|
||||||
`Stelle zugeschnitten. Layout/Design ist bereits vorgegeben — liefere nur die Inhalte. ` +
|
`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` +
|
`- headline: kurze Berufsbezeichnung des Bewerbers, ggf. auf die Zielstelle zugeschnitten.\n` +
|
||||||
`- kontakt: E-Mail, Telefon, Ort, Webseite, Geburtsdatum, Führerschein NUR übernehmen, ` +
|
`- kontakt: E-Mail, Telefon, Ort, Webseite, Geburtsdatum, Führerschein NUR übernehmen, ` +
|
||||||
`wenn in den Basis-Unterlagen vorhanden; sonst leerer String. Nichts erfinden.\n` +
|
`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, ` +
|
`- 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 ` +
|
`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, ` +
|
`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". ` +
|
`- 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` +
|
`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 → ` +
|
`- Bildung korrekt einordnen: Hochschulstudium → studium; Berufsausbildung/Ausbildungsberuf → ` +
|
||||||
`berufsausbildung; schulische Abschlüsse (Realschule, Gymnasium, Fachoberschulreife/Abitur) → schulbildung; ` +
|
`berufsausbildung; schulische Abschlüsse (Mittlere Reife/Fachoberschulreife, Fachhochschulreife, Abitur) → schulbildung; ` +
|
||||||
`Zertifikate/Fortbildungen → weiterbildungen. Grundschule weglassen.\n` +
|
`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` +
|
`- kenntnisse: max. 12 prägnante Stichworte, relevanteste zuerst.\n` +
|
||||||
`- sprachen, hobbys, weiterbildungen: nur falls in den Unterlagen vorhanden.\n\n` +
|
`- sprachen, hobbys, weiterbildungen: nur falls in den Unterlagen vorhanden.\n\n` +
|
||||||
`WICHTIG — keine HR-Red-Flags:\n` +
|
`WICHTIG — keine HR-Red-Flags:\n` +
|
||||||
@@ -280,6 +309,34 @@ function toZeitraum(v) {
|
|||||||
return str(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
|
// Defensive: never let an HR red-flag phrase reach the PDF, even if the model
|
||||||
// ignores the instruction.
|
// ignores the instruction.
|
||||||
const RED_FLAG = /(nicht\s*beendet|abgebrochen|abbruch|ohne\s*abschluss|arbeits(los|suchend)|gek(ü|ue)ndigt|entlassen)/i;
|
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'])),
|
geburtsdatum: str(pick(k, ['geburtsdatum', 'geboren', 'birthdate', 'dob', 'geburt'])),
|
||||||
fuehrerschein: str(pick(k, ['fuehrerschein', 'führerschein', 'driving_license', 'license', 'licence', 'fuehrerscheinklasse'])),
|
fuehrerschein: str(pick(k, ['fuehrerschein', 'führerschein', 'driving_license', 'license', 'licence', 'fuehrerscheinklasse'])),
|
||||||
},
|
},
|
||||||
anschreiben: {
|
anschreiben: (() => {
|
||||||
betreff: str(pick(a, ['betreff', 'subject', 'titel'])),
|
const anrede = str(pick(a, ['anrede', 'salutation']));
|
||||||
anrede: str(pick(a, ['anrede', 'salutation'])),
|
const absaetze = pickArray(a, ['absaetze', 'absätze', 'paragraphs', 'text', 'inhalt']).map(str).filter(Boolean);
|
||||||
absaetze: pickArray(a, ['absaetze', 'absätze', 'paragraphs', 'text', 'inhalt']).map(str).filter(Boolean),
|
const e = a.empfaenger || a.recipient || a.empfänger || {};
|
||||||
gruss: str(pick(a, ['gruss', 'gruß', 'grussformel', 'closing', 'schluss'])),
|
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: {
|
lebenslauf: {
|
||||||
berufserfahrung: pickArray(l, ['berufserfahrung', 'erfahrung', 'work_experience', 'experience', 'stationen'])
|
berufserfahrung: pickArray(l, ['berufserfahrung', 'erfahrung', 'work_experience', 'experience', 'stationen'])
|
||||||
.map((e) => ({
|
.map((e) => ({
|
||||||
@@ -579,12 +647,22 @@ const LC = {
|
|||||||
hair: [206, 208, 212],
|
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 }) {
|
function composeLetter(doc, S, dry, cur, { letter, header, job, anlagen }) {
|
||||||
const x = LET.mx;
|
const x = LET.mx;
|
||||||
const right = LET_R;
|
const right = LET_R;
|
||||||
const width = LET_W;
|
const width = LET_W;
|
||||||
const today = new Date().toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
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 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 ---
|
// --- Letterhead: name (left) + contact (right), thin rule ---
|
||||||
const top = cur.y;
|
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);
|
rule(doc, S, dry, cur.y, x, right, LC.hair, 0.3);
|
||||||
cur.y += 13 * S;
|
cur.y += 13 * S;
|
||||||
|
|
||||||
// --- Recipient (Anschriftfeld) ---
|
// --- Recipient (Anschriftfeld) — from the LLM's empfaenger, falling back to job ---
|
||||||
if (job.firma) write(doc, S, dry, cur, job.firma, { pt: 10.5, color: LC.ink, x, width, factor: 1.32 });
|
const emp = letter.empfaenger || {};
|
||||||
if (job.ort) write(doc, S, dry, cur, job.ort, { pt: 10.5, color: LC.ink, x, width, factor: 1.32 });
|
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) ---
|
// --- Date (right-aligned) ---
|
||||||
cur.y += 7 * S;
|
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 });
|
write(doc, S, dry, cur, dateLine, { pt: 10, color: LC.ink, x, width, align: 'right', right, factor: 1.2 });
|
||||||
|
|
||||||
// --- Subject (bold, no "Betreff:" label) ---
|
// --- Subject (bold, no "Betreff:" label) ---
|
||||||
@@ -679,8 +762,8 @@ function hasLebenslauf(l) {
|
|||||||
|| l.schulbildung.length || l.weiterbildungen.length || l.kenntnisse.length);
|
|| l.schulbildung.length || l.weiterbildungen.length || l.kenntnisse.length);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function generateApplicationDocuments({ job, basisDokumente, settings }) {
|
async function generateApplicationDocuments({ job, basisDokumente, settings, zusatzAnlagen = [], llmNotizen = '' }) {
|
||||||
const data = await generateTailoredTexts({ job, basisDokumente, settings });
|
const data = await generateTailoredTexts({ job, basisDokumente, settings, llmNotizen });
|
||||||
const headline = data.headline || job.stelle || '';
|
const headline = data.headline || job.stelle || '';
|
||||||
const header = buildHeader(settings, data.kontakt, headline);
|
const header = buildHeader(settings, data.kontakt, headline);
|
||||||
|
|
||||||
@@ -690,13 +773,18 @@ async function generateApplicationDocuments({ job, basisDokumente, settings }) {
|
|||||||
|
|
||||||
const documents = [];
|
const documents = [];
|
||||||
const cvPresent = hasLebenslauf(data.lebenslauf);
|
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)) {
|
if (hasAnschreiben(data.anschreiben)) {
|
||||||
documents.push({
|
documents.push({
|
||||||
name: `Anschreiben – ${label}`.trim(),
|
name: `Anschreiben – ${label}`.trim(),
|
||||||
filename: `Anschreiben_${suffix}.pdf`,
|
filename: `Anschreiben_${suffix}.pdf`,
|
||||||
mime: 'application/pdf',
|
mime: 'application/pdf',
|
||||||
buffer: renderAnschreibenPdf(data.anschreiben, header, job, cvPresent ? ['Lebenslauf'] : []),
|
buffer: renderAnschreibenPdf(data.anschreiben, header, job, anlagen),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Generated
+72
@@ -13,6 +13,7 @@
|
|||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"jspdf": "^2.5.1",
|
"jspdf": "^2.5.1",
|
||||||
"jspdf-autotable": "^3.8.2",
|
"jspdf-autotable": "^3.8.2",
|
||||||
|
"multer": "^2.2.0",
|
||||||
"sqlite3": "^5.1.6"
|
"sqlite3": "^5.1.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -187,6 +188,12 @@
|
|||||||
"node": ">= 8"
|
"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": {
|
"node_modules/aproba": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz",
|
||||||
@@ -384,6 +391,23 @@
|
|||||||
"ieee754": "^1.1.13"
|
"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": {
|
"node_modules/bytes": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||||
@@ -533,6 +557,21 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true
|
"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": {
|
"node_modules/console-control-strings": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
|
||||||
@@ -1790,6 +1829,25 @@
|
|||||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/napi-build-utils": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
|
||||||
@@ -2652,6 +2710,14 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/string_decoder": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||||
@@ -2849,6 +2915,12 @@
|
|||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/undefsafe": {
|
||||||
"version": "2.0.5",
|
"version": "2.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"jspdf": "^2.5.1",
|
"jspdf": "^2.5.1",
|
||||||
"jspdf-autotable": "^3.8.2",
|
"jspdf-autotable": "^3.8.2",
|
||||||
|
"multer": "^2.2.0",
|
||||||
"sqlite3": "^5.1.6"
|
"sqlite3": "^5.1.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ const express = require('express');
|
|||||||
const sqlite3 = require('sqlite3').verbose();
|
const sqlite3 = require('sqlite3').verbose();
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
const multer = require('multer');
|
||||||
|
|
||||||
// Minimal, dependency-free .env loader: load KEY=VALUE lines from a local
|
// Minimal, dependency-free .env loader: load KEY=VALUE lines from a local
|
||||||
// (git-ignored) .env file into process.env without overwriting existing vars.
|
// (git-ignored) .env file into process.env without overwriting existing vars.
|
||||||
@@ -75,6 +76,25 @@ if (!fs.existsSync(anhaengeDir)) {
|
|||||||
fs.mkdirSync(anhaengeDir, { recursive: true });
|
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
|
// Database setup
|
||||||
const dbPath = path.join(dataDir, 'bewerbungen.db');
|
const dbPath = path.join(dataDir, 'bewerbungen.db');
|
||||||
const db = new sqlite3.Database(dbPath);
|
const db = new sqlite3.Database(dbPath);
|
||||||
@@ -147,6 +167,7 @@ async function runGeneration(bewerbungId) {
|
|||||||
const bewerbung = await dbGet('SELECT * FROM bewerbungen WHERE id = ?', [bewerbungId]);
|
const bewerbung = await dbGet('SELECT * FROM bewerbungen WHERE id = ?', [bewerbungId]);
|
||||||
if (!bewerbung) return;
|
if (!bewerbung) return;
|
||||||
const basisDokumente = await dbAll('SELECT * FROM basis_dokumente ORDER BY id ASC');
|
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 settings = await dbGet('SELECT * FROM settings WHERE id = 1');
|
||||||
|
|
||||||
const documents = await generateApplicationDocuments({
|
const documents = await generateApplicationDocuments({
|
||||||
@@ -159,15 +180,32 @@ async function runGeneration(bewerbungId) {
|
|||||||
},
|
},
|
||||||
basisDokumente,
|
basisDokumente,
|
||||||
settings,
|
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) {
|
let seq = 0;
|
||||||
const stored = `${bewerbungId}_${Date.now()}_${doc.filename}`;
|
const storeAnhang = async (name, filename, mime, buffer) => {
|
||||||
fs.writeFileSync(path.join(anhaengeDir, stored), doc.buffer);
|
const stored = `${bewerbungId}_${Date.now()}_${seq++}_${filename}`;
|
||||||
|
fs.writeFileSync(path.join(anhaengeDir, stored), buffer);
|
||||||
await dbRun(
|
await dbRun(
|
||||||
'INSERT INTO anhaenge (bewerbung_id, name, dateiname, mime, pfad) VALUES (?, ?, ?, ?, ?)',
|
'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(
|
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 quelle_url TEXT', () => {
|
||||||
db.run('ALTER TABLE bewerbungen ADD COLUMN generierung_status 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 generierung_fehler TEXT', () => {
|
||||||
|
db.run('ALTER TABLE bewerbungen ADD COLUMN llm_notizen TEXT', () => {
|
||||||
|
|
||||||
// Chronological status changes, each with an optional comment
|
// Chronological status changes, each with an optional comment
|
||||||
db.run(`
|
db.run(`
|
||||||
@@ -241,6 +280,19 @@ function initializeDatabase() {
|
|||||||
`, (err) => {
|
`, (err) => {
|
||||||
if (err) return reject(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
|
// Generated attachment files linked to an application
|
||||||
db.run(`
|
db.run(`
|
||||||
CREATE TABLE IF NOT EXISTS anhaenge (
|
CREATE TABLE IF NOT EXISTS anhaenge (
|
||||||
@@ -286,6 +338,8 @@ function initializeDatabase() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -431,7 +485,7 @@ initializeDatabase().then(() => {
|
|||||||
const result = await dbRun(
|
const result = await dbRun(
|
||||||
`INSERT INTO bewerbungen
|
`INSERT INTO bewerbungen
|
||||||
(datum, firma, stelle, art, status, notizen, ort, stellenbeschreibung, quelle_url, generierung_status)
|
(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 || '']
|
[datum, firma, stelle, notizen, ort || '', stellenbeschreibung || '', quelle_url || '']
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -441,14 +495,13 @@ initializeDatabase().then(() => {
|
|||||||
[result.lastID, datum, 'Entwurf', 'Automatisch über Indeed importiert']
|
[result.lastID, datum, 'Entwurf', 'Automatisch über Indeed importiert']
|
||||||
);
|
);
|
||||||
|
|
||||||
// Kick off AI document generation in the background — respond immediately.
|
// Note: generation is NOT started automatically — the user reviews the draft,
|
||||||
runGeneration(result.lastID);
|
// adds LLM notes if needed, and triggers generation on the application page.
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
id: result.lastID,
|
id: result.lastID,
|
||||||
url: `/bewerbung/${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) {
|
} catch (error) {
|
||||||
console.error('Error importing job:', error);
|
console.error('Error importing job:', error);
|
||||||
@@ -692,8 +745,10 @@ initializeDatabase().then(() => {
|
|||||||
app.get('/vorlagen', async (req, res) => {
|
app.get('/vorlagen', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const basisDokumente = await dbAll('SELECT * FROM basis_dokumente ORDER BY id ASC');
|
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', {
|
res.render('vorlagen', {
|
||||||
basisDokumente,
|
basisDokumente,
|
||||||
|
basisAnhaenge,
|
||||||
basisTypOptions: BASIS_TYP_OPTIONS,
|
basisTypOptions: BASIS_TYP_OPTIONS,
|
||||||
hasApiKey: Boolean(process.env.OLLAMA_API_KEY),
|
hasApiKey: Boolean(process.env.OLLAMA_API_KEY),
|
||||||
hideSettings: true,
|
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
|
// Download a generated attachment
|
||||||
app.get('/anhaenge/:id/download', async (req, res) => {
|
app.get('/anhaenge/:id/download', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -779,13 +891,19 @@ initializeDatabase().then(() => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Re-run the AI generation for an application (removes old generated files first)
|
// Start (or re-run) the AI generation for an application. Saves the LLM notes
|
||||||
app.post('/bewerbung/:id/regenerate', async (req, res) => {
|
// first, removes any previously generated attachments, then generates.
|
||||||
|
app.post('/bewerbung/:id/generieren', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const bewerbung = await dbGet('SELECT id FROM bewerbungen WHERE id = ?', [id]);
|
const bewerbung = await dbGet('SELECT id FROM bewerbungen WHERE id = ?', [id]);
|
||||||
if (!bewerbung) return res.status(404).send('Bewerbung nicht gefunden');
|
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]);
|
const alte = await dbAll('SELECT * FROM anhaenge WHERE bewerbung_id = ?', [id]);
|
||||||
for (const a of alte) {
|
for (const a of alte) {
|
||||||
fs.promises.unlink(path.join(anhaengeDir, a.pfad)).catch(() => {});
|
fs.promises.unlink(path.join(anhaengeDir, a.pfad)).catch(() => {});
|
||||||
@@ -796,7 +914,7 @@ initializeDatabase().then(() => {
|
|||||||
runGeneration(id);
|
runGeneration(id);
|
||||||
res.redirect('/bewerbung/' + id);
|
res.redirect('/bewerbung/' + id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error regenerating documents:', error);
|
console.error('Error generating documents:', error);
|
||||||
res.status(500).send('Serverfehler');
|
res.status(500).send('Serverfehler');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
+33
-11
@@ -101,21 +101,18 @@
|
|||||||
data-status="<%= genStatus || '' %>">
|
data-status="<%= genStatus || '' %>">
|
||||||
<div class="flex flex-wrap items-center justify-between gap-3 mb-4">
|
<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>
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Generation status banner -->
|
<!-- Generation status banner -->
|
||||||
<div id="genBanner">
|
<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">
|
<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">
|
<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>
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
@@ -183,6 +180,31 @@
|
|||||||
<% } %>
|
<% } %>
|
||||||
</div>
|
</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. Musterfirma GmbH Musterstraße 12 45128 Essen 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 -->
|
<!-- Job description from the import -->
|
||||||
<% if (application.stellenbeschreibung && application.stellenbeschreibung.trim()) { %>
|
<% if (application.stellenbeschreibung && application.stellenbeschreibung.trim()) { %>
|
||||||
<details class="mt-6 rounded-lg border border-gray-200 dark:border-gray-700">
|
<details class="mt-6 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||||
|
|||||||
@@ -122,6 +122,67 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</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>
|
</main>
|
||||||
|
|
||||||
<%- include('partials/footer') %>
|
<%- include('partials/footer') %>
|
||||||
|
|||||||
Reference in New Issue
Block a user