Generate an editable norm-compliant cover email per application
Alongside the tailored PDFs, the LLM now produces a short, DIN-style Begleit-E-Mail (subject + body) grounded in the same data: same salutation as the cover letter, the target role, a line or two of core motivation, a pointer to the attached documents, and a friendly close. Persisted in new bewerbungen columns (email_betreff, email_anschreiben) and surfaced in an editable subject field + textarea on the detail page, so the applicant can tweak it before sending; a copy button puts subject and body on the clipboard. Values are stored raw and HTML-escaped on render to avoid double-escaping. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+33
-2
@@ -82,6 +82,14 @@ const OUTPUT_SCHEMA = {
|
|||||||
},
|
},
|
||||||
required: ['empfaenger', 'betreff', 'anrede', 'absaetze', 'gruss'],
|
required: ['empfaenger', 'betreff', 'anrede', 'absaetze', 'gruss'],
|
||||||
},
|
},
|
||||||
|
email: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
betreff: { type: 'string' },
|
||||||
|
text: { type: 'string' },
|
||||||
|
},
|
||||||
|
required: ['betreff', 'text'],
|
||||||
|
},
|
||||||
lebenslauf: {
|
lebenslauf: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
@@ -116,7 +124,7 @@ const OUTPUT_SCHEMA = {
|
|||||||
required: ['profil', 'berufserfahrung', 'studium', 'berufsausbildung', 'weiterbildungen', 'kenntnisse', 'sprachen', 'hobbys'],
|
required: ['profil', 'berufserfahrung', 'studium', 'berufsausbildung', 'weiterbildungen', 'kenntnisse', 'sprachen', 'hobbys'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
required: ['headline', 'kontakt', 'anschreiben', 'lebenslauf'],
|
required: ['headline', 'kontakt', 'anschreiben', 'email', 'lebenslauf'],
|
||||||
};
|
};
|
||||||
|
|
||||||
async function generateTailoredTexts({ job, basisDokumente, settings, llmNotizen = '' }) {
|
async function generateTailoredTexts({ job, basisDokumente, settings, llmNotizen = '' }) {
|
||||||
@@ -174,6 +182,10 @@ async function generateTailoredTexts({ job, basisDokumente, settings, llmNotizen
|
|||||||
` "absaetze": ["Absatz 1", "Absatz 2", "Absatz 3"],\n` +
|
` "absaetze": ["Absatz 1", "Absatz 2", "Absatz 3"],\n` +
|
||||||
` "gruss": "Mit freundlichen Grüßen"\n` +
|
` "gruss": "Mit freundlichen Grüßen"\n` +
|
||||||
` },\n` +
|
` },\n` +
|
||||||
|
` "email": {\n` +
|
||||||
|
` "betreff": "Bewerbung als … (ggf. mit Referenz-/Kennnummer)",\n` +
|
||||||
|
` "text": "Kurzer, sofort versendbarer E-Mail-Text: Anrede, 2-3 knappe Absätze, Grußformel, Name - Absätze durch Leerzeile getrennt"\n` +
|
||||||
|
` },\n` +
|
||||||
` "lebenslauf": {\n` +
|
` "lebenslauf": {\n` +
|
||||||
` "profil": "2-3 Sätze Kurzprofil, zugeschnitten auf die Zielstelle",\n` +
|
` "profil": "2-3 Sätze Kurzprofil, zugeschnitten auf die Zielstelle",\n` +
|
||||||
` "berufserfahrung": [\n` +
|
` "berufserfahrung": [\n` +
|
||||||
@@ -234,6 +246,17 @@ async function generateTailoredTexts({ job, basisDokumente, settings, llmNotizen
|
|||||||
`Nur der ERSTE Absatz beginnt mit einem KLEINbuchstaben (die Anrede endet mit Komma, der Satz wird ` +
|
`Nur der ERSTE Absatz beginnt mit einem KLEINbuchstaben (die Anrede endet mit Komma, der Satz wird ` +
|
||||||
`fortgesetzt), z. B. "mit …" oder "als …" — außer das erste Wort ist ein Substantiv, ein Eigenname oder die ` +
|
`fortgesetzt), z. B. "mit …" oder "als …" — außer das erste Wort ist ein Substantiv, ein Eigenname oder die ` +
|
||||||
`Höflichkeitsform "Ihre/Ihr/Ihnen". Alle WEITEREN Absätze sind neue Sätze und beginnen normal mit GROSSbuchstaben.\n` +
|
`Höflichkeitsform "Ihre/Ihr/Ihnen". Alle WEITEREN Absätze sind neue Sätze und beginnen normal mit GROSSbuchstaben.\n` +
|
||||||
|
`- email: ein KURZES, normgerechtes Begleit-E-Mail-Anschreiben, mit dem die Bewerbung samt Anhängen ` +
|
||||||
|
`(Lebenslauf, Anschreiben, ggf. Zeugnisse) per E-Mail versendet wird. Es ist NICHT das vollständige ` +
|
||||||
|
`Anschreiben - wiederhole dessen Inhalt nicht, fasse dich bewusst knapp (Faustregel: höchstens 6-8 Sätze). ` +
|
||||||
|
`email.betreff: prägnante Betreffzeile, i. d. R. "Bewerbung als <Stelle>" (eine in Anzeige/Notizen genannte ` +
|
||||||
|
`Referenz-/Kennnummer anhängen). email.text: vollständiger, sofort versendbarer E-Mail-Text mit exakt dieser ` +
|
||||||
|
`Struktur - (1) dieselbe Anrede wie im Anschreiben; (2) ein Satz, auf welche Stelle man sich bewirbt (falls ` +
|
||||||
|
`bekannt: wo die Stelle gefunden wurde); (3) ein bis zwei Sätze zur Kernmotivation/Kernqualifikation; ` +
|
||||||
|
`(4) ein Satz mit dem Hinweis, dass die vollständigen Bewerbungsunterlagen im Anhang beiliegen; (5) ein ` +
|
||||||
|
`freundlicher Ausblick auf ein persönliches Gespräch; (6) Grußformel "Mit freundlichen Grüßen"; (7) in der ` +
|
||||||
|
`letzten Zeile der vollständige Name des Bewerbers. Trenne Anrede, Absätze, Grußformel und Name jeweils durch ` +
|
||||||
|
`eine Leerzeile (\\n\\n). Höflich, sachlich, in Aktiv-Sätzen, ohne Floskeln und Weichspüler, keine Platzhalter.\n` +
|
||||||
`- profil: 2–3 Sätze Kurzprofil, konkret auf die Zielstelle zugeschnitten (der individuelle Text zur Stelle).\n` +
|
`- profil: 2–3 Sätze Kurzprofil, konkret auf die Zielstelle zugeschnitten (der individuelle Text zur Stelle).\n` +
|
||||||
`- berufserfahrung: ALLE Stationen aus den Unterlagen, neueste zuerst — lückenloser Verlauf, ` +
|
`- berufserfahrung: ALLE Stationen aus den Unterlagen, neueste zuerst — lückenloser Verlauf, ` +
|
||||||
`keine Station weglassen, die sonst eine Lücke hinterlässt (Überschneidungen sind ok). ` +
|
`keine Station weglassen, die sonst eine Lücke hinterlässt (Überschneidungen sind ok). ` +
|
||||||
@@ -486,6 +509,14 @@ function normalizeResult(parsed) {
|
|||||||
gruss: str(pick(a, ['gruss', 'gruß', 'grussformel', 'closing', 'schluss'])),
|
gruss: str(pick(a, ['gruss', 'gruß', 'grussformel', 'closing', 'schluss'])),
|
||||||
};
|
};
|
||||||
})(),
|
})(),
|
||||||
|
email: (() => {
|
||||||
|
const em = parsed.email || parsed.email_anschreiben || parsed.mail || parsed.begleitmail || {};
|
||||||
|
if (typeof em === 'string') return { betreff: '', text: str(em) };
|
||||||
|
return {
|
||||||
|
betreff: str(pick(em, ['betreff', 'subject', 'titel'])),
|
||||||
|
text: str(pick(em, ['text', 'body', 'inhalt', 'nachricht', 'mailtext', 'anschreiben'])),
|
||||||
|
};
|
||||||
|
})(),
|
||||||
lebenslauf: {
|
lebenslauf: {
|
||||||
profil: str(pick(l, ['profil', 'kurzprofil', 'zusammenfassung', 'summary', 'ueberblick', 'über_mich'])),
|
profil: str(pick(l, ['profil', 'kurzprofil', 'zusammenfassung', 'summary', 'ueberblick', 'über_mich'])),
|
||||||
berufserfahrung: pickArray(l, ['berufserfahrung', 'erfahrung', 'work_experience', 'experience', 'stationen'])
|
berufserfahrung: pickArray(l, ['berufserfahrung', 'erfahrung', 'work_experience', 'experience', 'stationen'])
|
||||||
@@ -979,7 +1010,7 @@ async function generateApplicationDocuments({ job, basisDokumente, settings, zus
|
|||||||
throw new Error('Die KI hat keine verwertbaren Unterlagen erzeugt.');
|
throw new Error('Die KI hat keine verwertbaren Unterlagen erzeugt.');
|
||||||
}
|
}
|
||||||
|
|
||||||
return documents;
|
return { documents, email: data.email || { betreff: '', text: '' } };
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
|||||||
@@ -254,7 +254,7 @@ async function runGeneration(bewerbungId) {
|
|||||||
const basisAnhaenge = await dbAll('SELECT * FROM basis_anhaenge 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, email } = await generateApplicationDocuments({
|
||||||
job: {
|
job: {
|
||||||
firma: bewerbung.firma,
|
firma: bewerbung.firma,
|
||||||
stelle: bewerbung.stelle,
|
stelle: bewerbung.stelle,
|
||||||
@@ -297,8 +297,9 @@ async function runGeneration(bewerbungId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await dbRun(
|
await dbRun(
|
||||||
"UPDATE bewerbungen SET generierung_status = 'fertig', generierung_fehler = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
"UPDATE bewerbungen SET generierung_status = 'fertig', generierung_fehler = NULL, " +
|
||||||
[bewerbungId]
|
"email_betreff = ?, email_anschreiben = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||||
|
[(email && email.betreff) || '', (email && email.text) || '', bewerbungId]
|
||||||
);
|
);
|
||||||
console.log(`Bewerbungsunterlagen für #${bewerbungId} generiert (${documents.length} Dokument(e)).`);
|
console.log(`Bewerbungsunterlagen für #${bewerbungId} generiert (${documents.length} Dokument(e)).`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -340,6 +341,8 @@ 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 email_betreff TEXT', () => {
|
||||||
|
db.run('ALTER TABLE bewerbungen ADD COLUMN email_anschreiben TEXT', () => {
|
||||||
db.run('ALTER TABLE bewerbungen ADD COLUMN llm_notizen 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
|
||||||
@@ -435,6 +438,8 @@ function initializeDatabase() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -774,6 +779,26 @@ initializeDatabase().then(() => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Save the editable e-mail cover text (Begleit-E-Mail) after the user tweaks it.
|
||||||
|
app.post('/bewerbung/:id/email', 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');
|
||||||
|
|
||||||
|
// Store raw; the value is HTML-escaped on render (EJS <%= %>), matching how
|
||||||
|
// the generated e-mail is stored. Sanitising here would double-escape.
|
||||||
|
await dbRun(
|
||||||
|
'UPDATE bewerbungen SET email_betreff = ?, email_anschreiben = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||||
|
[String(req.body.email_betreff || ''), String(req.body.email_anschreiben || ''), id]
|
||||||
|
);
|
||||||
|
res.redirect('/bewerbung/' + id + '#email');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving e-mail text:', error);
|
||||||
|
res.status(500).send('Serverfehler');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Add a timeline entry (status change with date + comment)
|
// Add a timeline entry (status change with date + comment)
|
||||||
app.post('/bewerbung/:id/verlauf', async (req, res) => {
|
app.post('/bewerbung/:id/verlauf', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -180,6 +180,45 @@
|
|||||||
<% } %>
|
<% } %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Editable e-mail cover text (Begleit-E-Mail) -->
|
||||||
|
<% if ((application.email_anschreiben && application.email_anschreiben.trim()) || genStatus === 'fertig') { %>
|
||||||
|
<div id="email" class="mt-6 border-t border-gray-200 dark:border-gray-700 pt-5">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-2 mb-1">
|
||||||
|
<h3 class="text-sm font-semibold text-gray-800 dark:text-white">Begleit-E-Mail</h3>
|
||||||
|
<button type="button" id="emailCopyBtn"
|
||||||
|
class="inline-flex items-center gap-1 text-xs text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 hover:underline">
|
||||||
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path>
|
||||||
|
</svg>
|
||||||
|
<span id="emailCopyLabel">E-Mail-Text kopieren</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
|
||||||
|
Kurzer, normgerechter Text zum Versand der Bewerbung per E-Mail (Unterlagen im Anhang).
|
||||||
|
Du kannst Betreff und Text vor dem Versand frei anpassen und speichern.
|
||||||
|
</p>
|
||||||
|
<form action="/bewerbung/<%= application.id %>/email" method="POST">
|
||||||
|
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Betreff</label>
|
||||||
|
<input type="text" name="email_betreff" id="emailBetreff" value="<%= application.email_betreff || '' %>"
|
||||||
|
class="w-full px-3 py-2 mb-3 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-800 dark:text-white"
|
||||||
|
placeholder="Bewerbung als …">
|
||||||
|
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">E-Mail-Text</label>
|
||||||
|
<textarea name="email_anschreiben" id="emailText" rows="12"
|
||||||
|
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 font-sans"
|
||||||
|
placeholder="Sehr geehrte Damen und Herren, …"><%= application.email_anschreiben || '' %></textarea>
|
||||||
|
<div class="flex justify-end mt-3">
|
||||||
|
<button type="submit"
|
||||||
|
class="inline-flex items-center gap-1.5 px-4 py-2 text-sm bg-green-600 hover:bg-green-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="M5 13l4 4L19 7"></path>
|
||||||
|
</svg>
|
||||||
|
E-Mail-Text speichern
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
<!-- LLM notes + generate/regenerate -->
|
<!-- LLM notes + generate/regenerate -->
|
||||||
<form action="/bewerbung/<%= application.id %>/generieren" method="POST"
|
<form action="/bewerbung/<%= application.id %>/generieren" method="POST"
|
||||||
class="mt-6 border-t border-gray-200 dark:border-gray-700 pt-5"
|
class="mt-6 border-t border-gray-200 dark:border-gray-700 pt-5"
|
||||||
@@ -345,6 +384,19 @@
|
|||||||
const cy = document.getElementById('currentYear');
|
const cy = document.getElementById('currentYear');
|
||||||
if (cy) cy.textContent = new Date().getFullYear();
|
if (cy) cy.textContent = new Date().getFullYear();
|
||||||
|
|
||||||
|
// Copy the e-mail cover text (subject + body) to the clipboard.
|
||||||
|
const copyBtn = document.getElementById('emailCopyBtn');
|
||||||
|
if (copyBtn) copyBtn.addEventListener('click', async () => {
|
||||||
|
const betreff = (document.getElementById('emailBetreff') || {}).value || '';
|
||||||
|
const body = (document.getElementById('emailText') || {}).value || '';
|
||||||
|
const label = document.getElementById('emailCopyLabel');
|
||||||
|
const payload = (betreff ? 'Betreff: ' + betreff + '\n\n' : '') + body;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(payload);
|
||||||
|
if (label) { const t = label.textContent; label.textContent = 'Kopiert!'; setTimeout(() => { label.textContent = t; }, 1500); }
|
||||||
|
} catch (e) { /* clipboard blocked — ignore */ }
|
||||||
|
});
|
||||||
|
|
||||||
// While documents are being generated, poll for completion and
|
// While documents are being generated, poll for completion and
|
||||||
// reload the page once the status changes.
|
// reload the page once the status changes.
|
||||||
const unterlagen = document.getElementById('unterlagen');
|
const unterlagen = document.getElementById('unterlagen');
|
||||||
|
|||||||
Reference in New Issue
Block a user