E-Mail: KI-Absage generieren (andere Stelle angenommen)

Im Antwort-Formular gibt es neben dem KI-Button jetzt ein Dropdown Antwort/Absage.
Für Absage formuliert die KI eine kurze, höfliche E-Mail: Bewerber hat eine andere
Stelle angenommen, bedankt sich, zieht sich aus dem Prozess zurück.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-07-10 20:51:37 +02:00
co-authored by Claude
parent 0c7502cfd7
commit 0a0b0b4724
3 changed files with 88 additions and 34 deletions
+49 -9
View File
@@ -1239,19 +1239,56 @@ const REPLY_SCHEMA = {
// Draft a reply to a recruiter/company e-mail in the context of an application. // Draft a reply to a recruiter/company e-mail in the context of an application.
// `incoming` = { from, subject, text }; `job` = { firma, stelle }; // `incoming` = { from, subject, text }; `job` = { firma, stelle };
// `settings` = { name, ... }. `hinweise` = optional free-text steering. // `settings` = { name, ... }. `hinweise` = optional free-text steering.
async function generateEmailReply({ incoming, job = {}, settings = {}, hinweise = '' }) { // `typ` = 'antwort' (default) for a normal reply, or 'absage' for a polite, short
// rejection because the applicant accepted another position.
async function generateEmailReply({ incoming, job = {}, settings = {}, hinweise = '', typ = 'antwort' }) {
const name = (settings && settings.name) || 'der Bewerber'; const name = (settings && settings.name) || 'der Bewerber';
const system = const isAbsage = typ === 'absage';
'Du bist ' + name + ' und schreibst als Bewerber eine höfliche, professionelle, ' +
'deutschsprachige Antwort-E-Mail an ein Unternehmen im laufenden Bewerbungsprozess. ' + const commonRules =
'Du antwortest konkret auf die eingegangene Nachricht. Wichtig: Du erfindest KEINE ' + ' Wichtig: Du erfindest KEINE Fakten (keine erfundenen Termine, Zahlen, Zusagen). ' +
'Fakten (keine erfundenen Termine, Zahlen, Zusagen). Wenn eine konkrete Angabe nötig ist, ' + 'Wenn eine konkrete Angabe nötig ist, die du nicht kennst, setze einen klar erkennbaren ' +
'die du nicht kennst (z. B. ein genauer Terminvorschlag), setze einen klar erkennbaren ' +
'Platzhalter in eckigen Klammern, z. B. "[Terminvorschlag einfügen]". Schreibe natürlich, ' + 'Platzhalter in eckigen Klammern, z. B. "[Terminvorschlag einfügen]". Schreibe natürlich, ' +
'knapp und verbindlich, wie ein deutscher Muttersprachler - ohne leere Floskeln, ohne ' + 'knapp und verbindlich, wie ein deutscher Muttersprachler - ohne leere Floskeln, ohne ' +
'Buzzword-Paare und ohne Selbstetiketten wie "Als Muttersprachler ...". Verwende ausschließlich ' + 'Buzzword-Paare und ohne Selbstetiketten wie "Als Muttersprachler ...". Verwende ausschließlich ' +
'deutsche Sprache und das lateinische Alphabet - keine fremdsprachigen Wörter oder Schriftzeichen.'; 'deutsche Sprache und das lateinische Alphabet - keine fremdsprachigen Wörter oder Schriftzeichen.';
const user =
let system, user, betreffDefault;
if (isAbsage) {
system =
'Du bist ' + name + ' und schreibst als Bewerber eine höfliche, professionelle, ' +
'deutschsprachige Absage-E-Mail an ein Unternehmen im laufenden Bewerbungsprozess. ' +
'Du teilst mit, dass du dich entschieden hast, eine andere Stelle anzunehmen, und ' +
'bedankst dich für die Zeit, das Gespräch und das Vertrauen. Halte die E-Mail bewusst ' +
'kurz: ein bis zwei kurze Absätze genügen - keine langen Erklärungen, keine Rechtfertigung. ' +
'Bleibe wertschätzend und bestimmt zugleich; nenne keine Gründe im Detail und erfinde keine ' +
'konkreten Namen anderer Unternehmen.' + commonRules;
user =
`# Kontext der Bewerbung\n` +
`Unternehmen: ${job.firma || '-'}\n` +
`Stelle: ${job.stelle || '-'}\n` +
`Bewerber: ${name}\n\n` +
`# Eingegangene E-Mail (Bezug)\n` +
`Von: ${incoming.from || '-'}\n` +
`Betreff: ${incoming.subject || '-'}\n\n` +
`${incoming.text || ''}\n\n` +
(hinweise && hinweise.trim() ? `# Hinweise für die Absage (aktiv berücksichtigen)\n${hinweise.trim()}\n\n` : '') +
`# Aufgabe\n` +
`Formuliere eine kurze, höfliche Absage-E-Mail: Du hast eine andere Stelle angenommen ` +
`und ziehst dich aus diesem Prozess zurück. Struktur des Feldes "text": Anrede (an den ` +
`konkreten Absender, falls Name erkennbar, sonst "Sehr geehrte Damen und Herren,"), ein ` +
`bis zwei kurze Absätze, Grußformel "Mit freundlichen Grüßen" und in der letzten Zeile der ` +
`Name "${name}". Trenne Anrede, Absätze, Gruß und Name durch je eine Leerzeile (\\n\\n). ` +
`"betreff": knappe Betreffzeile, i. d. R. "Re: ${incoming.subject || ''}". ` +
`Verwende ausschließlich den einfachen Bindestrich "-" (niemals oder —). ` +
`Antworte AUSSCHLIESSLICH mit dem JSON-Objekt, ohne Markdown, ohne Code-Fences.`;
betreffDefault = 'Re: ' + (incoming.subject || ('Absage zur Bewerbung als ' + (job.stelle || '')));
} else {
system =
'Du bist ' + name + ' und schreibst als Bewerber eine höfliche, professionelle, ' +
'deutschsprachige Antwort-E-Mail an ein Unternehmen im laufenden Bewerbungsprozess. ' +
'Du antwortest konkret auf die eingegangene Nachricht.' + commonRules;
user =
`# Kontext der Bewerbung\n` + `# Kontext der Bewerbung\n` +
`Unternehmen: ${job.firma || '-'}\n` + `Unternehmen: ${job.firma || '-'}\n` +
`Stelle: ${job.stelle || '-'}\n` + `Stelle: ${job.stelle || '-'}\n` +
@@ -1269,9 +1306,12 @@ async function generateEmailReply({ incoming, job = {}, settings = {}, hinweise
`"betreff": sinnvolle Betreffzeile, i. d. R. "Re: ${incoming.subject || ''}". ` + `"betreff": sinnvolle Betreffzeile, i. d. R. "Re: ${incoming.subject || ''}". ` +
`Verwende ausschließlich den einfachen Bindestrich "-" (niemals oder —). ` + `Verwende ausschließlich den einfachen Bindestrich "-" (niemals oder —). ` +
`Antworte AUSSCHLIESSLICH mit dem JSON-Objekt, ohne Markdown, ohne Code-Fences.`; `Antworte AUSSCHLIESSLICH mit dem JSON-Objekt, ohne Markdown, ohne Code-Fences.`;
betreffDefault = 'Re: ' + (incoming.subject || '');
}
const parsed = await ollamaChatJSON({ system, user, schema: REPLY_SCHEMA, temperature: 0.5 }); const parsed = await ollamaChatJSON({ system, user, schema: REPLY_SCHEMA, temperature: 0.5 });
return { return {
betreff: str(pick(parsed, ['betreff', 'subject', 'titel'])) || ('Re: ' + (incoming.subject || '')), betreff: str(pick(parsed, ['betreff', 'subject', 'titel'])) || betreffDefault,
text: str(pick(parsed, ['text', 'body', 'inhalt', 'nachricht'])), text: str(pick(parsed, ['text', 'body', 'inhalt', 'nachricht'])),
}; };
} }
+1
View File
@@ -1579,6 +1579,7 @@ initializeDatabase().then(() => {
job: { firma: bewerbung.firma, stelle: bewerbung.stelle }, job: { firma: bewerbung.firma, stelle: bewerbung.stelle },
settings, settings,
hinweise: String(req.body.hinweise || ''), hinweise: String(req.body.hinweise || ''),
typ: String(req.body.typ || 'antwort'),
}); });
// Include the quoted original so the reply reads like a mail-client thread. // Include the quoted original so the reply reads like a mail-client thread.
res.json({ ...draft, quote: buildReplyQuote(orig) }); res.json({ ...draft, quote: buildReplyQuote(orig) });
+16 -3
View File
@@ -474,12 +474,18 @@
class="reply-subject w-full px-3 py-2 mb-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-800 dark:text-white text-sm"> class="reply-subject w-full px-3 py-2 mb-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-800 dark:text-white text-sm">
<div class="flex items-center justify-between mb-1"> <div class="flex items-center justify-between mb-1">
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400">Nachricht</label> <label class="block text-xs font-medium text-gray-500 dark:text-gray-400">Nachricht</label>
<div class="flex items-center gap-2">
<select class="ai-reply-typ text-xs px-1.5 py-0.5 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-200">
<option value="antwort">Antwort</option>
<option value="absage">Absage (andere Stelle)</option>
</select>
<button type="button" class="ai-reply-btn inline-flex items-center gap-1 text-xs text-purple-600 hover:text-purple-800 dark:text-purple-400 hover:underline" <button type="button" class="ai-reply-btn inline-flex items-center gap-1 text-xs text-purple-600 hover:text-purple-800 dark:text-purple-400 hover:underline"
data-email-id="<%= e.id %>" data-app-id="<%= application.id %>" data-target="reply-<%= e.id %>"> data-email-id="<%= e.id %>" data-app-id="<%= application.id %>" data-target="reply-<%= e.id %>">
<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="M13 10V3L4 14h7v7l9-11h-7z"></path></svg> <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="M13 10V3L4 14h7v7l9-11h-7z"></path></svg>
<span class="ai-reply-label">KI-Antwort generieren</span> <span class="ai-reply-label">KI-Antwort generieren</span>
</button> </button>
</div> </div>
</div>
<textarea name="body" rows="12" required <textarea name="body" rows="12" required
class="reply-body 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 text-sm leading-relaxed"><%= e.reply_quote ? '\n\n' + e.reply_quote : '' %></textarea> class="reply-body 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 text-sm leading-relaxed"><%= e.reply_quote ? '\n\n' + e.reply_quote : '' %></textarea>
<% if (basisAnhaenge && basisAnhaenge.length) { %> <% if (basisAnhaenge && basisAnhaenge.length) { %>
@@ -849,10 +855,17 @@
// Generate an AI draft reply and drop it into the reply form. // Generate an AI draft reply and drop it into the reply form.
document.querySelectorAll('.ai-reply-btn').forEach((btn) => { document.querySelectorAll('.ai-reply-btn').forEach((btn) => {
btn.addEventListener('click', async () => {
const form = document.getElementById(btn.dataset.target); const form = document.getElementById(btn.dataset.target);
if (!form) return; const typSelect = form ? form.querySelector('.ai-reply-typ') : null;
const label = btn.querySelector('.ai-reply-label'); const label = btn.querySelector('.ai-reply-label');
const syncLabel = () => {
if (!label || !typSelect) return;
label.textContent = typSelect.value === 'absage' ? 'KI-Absage generieren' : 'KI-Antwort generieren';
};
if (typSelect) { typSelect.addEventListener('change', syncLabel); syncLabel(); }
btn.addEventListener('click', async () => {
if (!form) return;
const typ = typSelect ? typSelect.value : 'antwort';
const prev = label ? label.textContent : ''; const prev = label ? label.textContent : '';
if (label) label.textContent = 'Generiere …'; if (label) label.textContent = 'Generiere …';
btn.disabled = true; btn.disabled = true;
@@ -860,7 +873,7 @@
const res = await fetch('/bewerbung/' + btn.dataset.appId + '/email/ai-reply', { const res = await fetch('/bewerbung/' + btn.dataset.appId + '/email/ai-reply', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email_id: btn.dataset.emailId }), body: JSON.stringify({ email_id: btn.dataset.emailId, typ }),
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Fehler'); if (!res.ok) throw new Error(data.error || 'Fehler');