Vor dem Generieren waehlen: Anschreiben, Lebenslauf oder beides
Auf der Bewerbungsseite laesst sich jetzt auswaehlen, welche Unterlagen die KI erzeugt. Standard bleibt beides; die letzte Auswahl wird gemerkt und ist beim naechsten Generieren vorbelegt. Ohne Haken bricht das Formular ab statt still beides zu erzeugen. Nicht gewaehlte Dokumente werden gar nicht erst angefragt: JSON-Schema und Skelett im Prompt werden auf die gewuenschten Teile reduziert, das spart einen Gutteil der Generierungszeit. Gerendert wird ebenfalls nur das Gewaehlte - auch wenn das Modell sich nicht an das Schema haelt und trotzdem alles liefert. Fuer den E-Mail-Versand mitgedacht: - Wird kein Lebenslauf erzeugt, fuehrt ihn das Anschreiben nicht mehr unter "Anlagen" auf. Sonst kuendigt der Brief eine Anlage an, die nie mitgeht. - Der Prompt bekommt zusaetzlich die Liste der Dateien, die der Bewerbungsmail tatsaechlich anhaengen (inkl. Anschreiben selbst), damit der Begleittext keine Unterlagen nennt, die nicht dabei sind. - Die Anhang-Checkboxen im Mailformular ziehen sich aus den erzeugten Anhaengen, greifen also automatisch. Die REST-API kennt das Feld ebenfalls (POST .../generate, "dokumente"), inkl. OpenAPI-Doku. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+7
-2
@@ -10,6 +10,7 @@ const path = require('path');
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const blacklist = require('./blacklist');
|
const blacklist = require('./blacklist');
|
||||||
const { LABEL_OPTIONS, parseLabels, serializeLabels } = require('./labels');
|
const { LABEL_OPTIONS, parseLabels, serializeLabels } = require('./labels');
|
||||||
|
const { normalizeDokumente } = require('./documents');
|
||||||
|
|
||||||
// Replace the stored labels JSON string with a real array on outgoing rows.
|
// Replace the stored labels JSON string with a real array on outgoing rows.
|
||||||
function withLabels(row) {
|
function withLabels(row) {
|
||||||
@@ -410,8 +411,12 @@ function createExternalApi(deps) {
|
|||||||
const anlagenIds = Array.isArray((req.body || {}).anlagen)
|
const anlagenIds = Array.isArray((req.body || {}).anlagen)
|
||||||
? req.body.anlagen.map((v) => parseInt(v, 10)).filter((n) => !Number.isNaN(n))
|
? req.body.anlagen.map((v) => parseInt(v, 10)).filter((n) => !Number.isNaN(n))
|
||||||
: [];
|
: [];
|
||||||
runGeneration(id, { anlagenIds });
|
// Optional: which documents to produce (["anschreiben"], ["lebenslauf"] or
|
||||||
res.status(202).json({ success: true });
|
// both). Omitted / empty means both.
|
||||||
|
const dokumente = normalizeDokumente((req.body || {}).dokumente);
|
||||||
|
await dbRun('UPDATE bewerbungen SET generierung_dokumente = ? WHERE id = ?', [dokumente.join(','), id]);
|
||||||
|
runGeneration(id, { anlagenIds, dokumente });
|
||||||
|
res.status(202).json({ success: true, dokumente });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('API generate error:', error);
|
console.error('API generate error:', error);
|
||||||
res.status(500).json({ error: 'Serverfehler' });
|
res.status(500).json({ error: 'Serverfehler' });
|
||||||
|
|||||||
+107
-24
@@ -148,7 +148,35 @@ const OUTPUT_SCHEMA = {
|
|||||||
required: ['headline', 'kontakt', 'anschreiben', 'email', 'lebenslauf'],
|
required: ['headline', 'kontakt', 'anschreiben', 'email', 'lebenslauf'],
|
||||||
};
|
};
|
||||||
|
|
||||||
async function generateTailoredTexts({ job, basisDokumente, settings, llmNotizen = '', zusatzAnlagen = [], prompts = null }) {
|
// Which documents a generation run should produce. `email` (the covering note
|
||||||
|
// for the mail body) is always generated — it is what the application is sent
|
||||||
|
// with, whatever is attached to it.
|
||||||
|
const DOKUMENT_TYPEN = ['anschreiben', 'lebenslauf'];
|
||||||
|
|
||||||
|
// Accept anything (string, array, undefined) and return a clean, ordered list.
|
||||||
|
// Empty / unknown input means "both" — the default everywhere.
|
||||||
|
function normalizeDokumente(v) {
|
||||||
|
const gewaehlt = [].concat(v == null ? [] : v).map((x) => String(x).trim().toLowerCase());
|
||||||
|
const gefiltert = DOKUMENT_TYPEN.filter((d) => gewaehlt.includes(d));
|
||||||
|
return gefiltert.length ? gefiltert : DOKUMENT_TYPEN.slice();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ask the model only for the documents we actually want. Dropping a section from
|
||||||
|
// the schema means the model never writes it — that saves a chunk of generation
|
||||||
|
// time and tokens when only one document is needed.
|
||||||
|
function buildOutputSchema(dokumente) {
|
||||||
|
const properties = { ...OUTPUT_SCHEMA.properties };
|
||||||
|
if (!dokumente.includes('anschreiben')) delete properties.anschreiben;
|
||||||
|
if (!dokumente.includes('lebenslauf')) delete properties.lebenslauf;
|
||||||
|
return {
|
||||||
|
type: 'object',
|
||||||
|
properties,
|
||||||
|
required: OUTPUT_SCHEMA.required.filter((k) => !DOKUMENT_TYPEN.includes(k) || dokumente.includes(k)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateTailoredTexts({ job, basisDokumente, settings, llmNotizen = '', zusatzAnlagen = [], prompts = null, dokumente = null }) {
|
||||||
|
const doks = normalizeDokumente(dokumente);
|
||||||
const apiKey = process.env.OLLAMA_API_KEY;
|
const apiKey = process.env.OLLAMA_API_KEY;
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -187,21 +215,31 @@ async function generateTailoredTexts({ job, basisDokumente, settings, llmNotizen
|
|||||||
// below stays in code because the response parsing depends on it.
|
// below stays in code because the response parsing depends on it.
|
||||||
const system = promptStore.get(prompts, 'unterlagen');
|
const system = promptStore.get(prompts, 'unterlagen');
|
||||||
|
|
||||||
const skeleton =
|
// Built from blocks so it matches the (possibly reduced) schema exactly: a
|
||||||
`{\n` +
|
// skeleton showing a section the schema forbids would only confuse the model.
|
||||||
` "headline": "Kurze Berufsbezeichnung (max. 5 Wörter)",\n` +
|
const skelettBloecke = [
|
||||||
` "kontakt": { "email": "", "telefon": "", "ort": "", "webseite": "", "geburtsdatum": "" },\n` +
|
` "headline": "Kurze Berufsbezeichnung (max. 5 Wörter)"`,
|
||||||
|
` "kontakt": { "email": "", "telefon": "", "ort": "", "webseite": "", "geburtsdatum": "" }`,
|
||||||
|
];
|
||||||
|
if (doks.includes('anschreiben')) {
|
||||||
|
skelettBloecke.push(
|
||||||
` "anschreiben": {\n` +
|
` "anschreiben": {\n` +
|
||||||
` "empfaenger": { "firma": "Unternehmen", "adresse": "Straße Hausnr. (falls bekannt, sonst leer)", "ort": "PLZ Ort", "ansprechpartner": "" },\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` +
|
||||||
` "gruss": "Mit freundlichen Grüßen"\n` +
|
` "gruss": "Mit freundlichen Grüßen"\n` +
|
||||||
` },\n` +
|
` }`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
skelettBloecke.push(
|
||||||
` "email": {\n` +
|
` "email": {\n` +
|
||||||
` "betreff": "Bewerbung als … (ggf. mit Referenz-/Kennnummer)",\n` +
|
` "betreff": "Bewerbung als … (ggf. mit Referenz-/Kennnummer)",\n` +
|
||||||
` "text": "Sehr kurzer Begleittext nur für die E-Mail: Anrede, ein Satz (Stelle + Unterlagen im Anhang), optional ein kurzer Schlusssatz, Grußformel, Name - jeweils durch Leerzeile getrennt"\n` +
|
` "text": "Sehr kurzer Begleittext nur für die E-Mail: Anrede, ein Satz (Stelle + Unterlagen im Anhang), optional ein kurzer Schlusssatz, Grußformel, Name - jeweils durch Leerzeile getrennt"\n` +
|
||||||
` },\n` +
|
` }`
|
||||||
|
);
|
||||||
|
if (doks.includes('lebenslauf')) {
|
||||||
|
skelettBloecke.push(
|
||||||
` "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` +
|
||||||
@@ -217,8 +255,10 @@ async function generateTailoredTexts({ job, basisDokumente, settings, llmNotizen
|
|||||||
` "kenntnisse": ["Fähigkeit 1", "Fähigkeit 2"],\n` +
|
` "kenntnisse": ["Fähigkeit 1", "Fähigkeit 2"],\n` +
|
||||||
` "sprachen": [ { "sprache": "Deutsch", "niveau": "Muttersprache" }, { "sprache": "Englisch", "niveau": "verhandlungssicher" } ],\n` +
|
` "sprachen": [ { "sprache": "Deutsch", "niveau": "Muttersprache" }, { "sprache": "Englisch", "niveau": "verhandlungssicher" } ],\n` +
|
||||||
` "hobbys": ["Hobby 1", "Hobby 2"]\n` +
|
` "hobbys": ["Hobby 1", "Hobby 2"]\n` +
|
||||||
` }\n` +
|
` }`
|
||||||
`}`;
|
);
|
||||||
|
}
|
||||||
|
const skeleton = `{\n${skelettBloecke.join(',\n')}\n}`;
|
||||||
|
|
||||||
const notizenText = (llmNotizen && llmNotizen.trim())
|
const notizenText = (llmNotizen && llmNotizen.trim())
|
||||||
? `# Zusätzliche Notizen des Bewerbers (WICHTIG — nutze diese Angaben aktiv: z. B. vollständige ` +
|
? `# Zusätzliche Notizen des Bewerbers (WICHTIG — nutze diese Angaben aktiv: z. B. vollständige ` +
|
||||||
@@ -226,12 +266,46 @@ async function generateTailoredTexts({ job, basisDokumente, settings, llmNotizen
|
|||||||
`${llmNotizen.trim()}\n\n`
|
`${llmNotizen.trim()}\n\n`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
// Attachments actually enclosed with this application. The Lebenslauf is
|
// Attachments actually enclosed with this application. The Lebenslauf is only
|
||||||
// always part of the set; the extra items are the ones the user selected.
|
// listed when it is actually being generated — otherwise the cover letter would
|
||||||
const anlagenListe = ['Lebenslauf', ...(zusatzAnlagen || []).filter(Boolean)];
|
// promise an enclosure that never gets attached.
|
||||||
const anlagenText =
|
const anlagenListe = [
|
||||||
`# Beigefügte Anlagen (genau diese Dokumente liegen der Bewerbung bei)\n` +
|
...(doks.includes('lebenslauf') ? ['Lebenslauf'] : []),
|
||||||
anlagenListe.map((a) => `- ${a}`).join('\n') + `\n\n`;
|
...(zusatzAnlagen || []).filter(Boolean),
|
||||||
|
];
|
||||||
|
const anlagenText = anlagenListe.length
|
||||||
|
? `# Beigefügte Anlagen (genau diese Dokumente liegen der Bewerbung bei)\n` +
|
||||||
|
anlagenListe.map((a) => `- ${a}`).join('\n') + `\n\n`
|
||||||
|
: `# Beigefügte Anlagen\n(keine — erwähne keine Anlagen und kündige keine an)\n\n`;
|
||||||
|
|
||||||
|
// What the application e-mail will actually carry. Unlike the "Anlagen" list
|
||||||
|
// above (which is what the *letter* lists beneath itself), this includes the
|
||||||
|
// cover letter itself — so the mail's covering note never announces a document
|
||||||
|
// that isn't attached.
|
||||||
|
const mailAnhaenge = [
|
||||||
|
...(doks.includes('anschreiben') ? ['Anschreiben (PDF)'] : []),
|
||||||
|
...(doks.includes('lebenslauf') ? ['Lebenslauf (PDF)'] : []),
|
||||||
|
...(zusatzAnlagen || []).filter(Boolean),
|
||||||
|
];
|
||||||
|
const mailAnhangText =
|
||||||
|
`# Anhänge der Bewerbungs-E-Mail (genau diese Dateien werden mitgeschickt)\n` +
|
||||||
|
mailAnhaenge.map((a) => `- ${a}`).join('\n') + `\n` +
|
||||||
|
`Formuliere "email.text" passend zu genau diesen Anhängen — nenne keine Unterlagen, ` +
|
||||||
|
`die nicht dabei sind.\n\n`;
|
||||||
|
|
||||||
|
const aufgabe = doks.length === 2
|
||||||
|
? `Erzeuge die TEXTE für ein Anschreiben und einen Lebenslauf, passgenau auf diese ` +
|
||||||
|
`Stelle zugeschnitten. Layout/Design ist bereits vorgegeben — liefere nur die Inhalte. ` +
|
||||||
|
`Beide Dokumente werden auf JE EINER A4-Seite gedruckt, halte dich also kurz.`
|
||||||
|
: doks[0] === 'anschreiben'
|
||||||
|
? `Erzeuge NUR die TEXTE für ein Anschreiben, passgenau auf diese Stelle zugeschnitten. ` +
|
||||||
|
`Ein Lebenslauf wird diesmal NICHT erstellt und darf nicht im JSON auftauchen. ` +
|
||||||
|
`Layout/Design ist bereits vorgegeben — liefere nur die Inhalte. Das Anschreiben wird ` +
|
||||||
|
`auf EINER A4-Seite gedruckt, halte dich also kurz.`
|
||||||
|
: `Erzeuge NUR die TEXTE für einen Lebenslauf, passgenau auf diese Stelle zugeschnitten. ` +
|
||||||
|
`Ein Anschreiben wird diesmal NICHT erstellt und darf nicht im JSON auftauchen. ` +
|
||||||
|
`Layout/Design ist bereits vorgegeben — liefere nur die Inhalte. Der Lebenslauf wird ` +
|
||||||
|
`auf EINER A4-Seite gedruckt, halte dich also kurz.`;
|
||||||
|
|
||||||
const userPrompt =
|
const userPrompt =
|
||||||
`# Bewerberdaten\n${bewerber}\n\n` +
|
`# Bewerberdaten\n${bewerber}\n\n` +
|
||||||
@@ -239,10 +313,9 @@ async function generateTailoredTexts({ job, basisDokumente, settings, llmNotizen
|
|||||||
`# Zielstelle\n${stelleText}\n\n` +
|
`# Zielstelle\n${stelleText}\n\n` +
|
||||||
notizenText +
|
notizenText +
|
||||||
anlagenText +
|
anlagenText +
|
||||||
|
mailAnhangText +
|
||||||
`# Aufgabe\n` +
|
`# Aufgabe\n` +
|
||||||
`Erzeuge die TEXTE für ein Anschreiben und einen Lebenslauf, passgenau auf diese ` +
|
aufgabe + `\n\n` +
|
||||||
`Stelle zugeschnitten. Layout/Design ist bereits vorgegeben — liefere nur die Inhalte. ` +
|
|
||||||
`Beide Dokumente werden auf JE EINER A4-Seite gedruckt, halte dich also kurz.\n\n` +
|
|
||||||
`Verwende EXAKT diese JSON-Struktur und exakt diese Schlüsselnamen ` +
|
`Verwende EXAKT diese JSON-Struktur und exakt diese Schlüsselnamen ` +
|
||||||
`(keine anderen, keine zusätzlichen Felder, "zeitraum" immer als einzelner String):\n\n` +
|
`(keine anderen, keine zusätzlichen Felder, "zeitraum" immer als einzelner String):\n\n` +
|
||||||
`${skeleton}\n\n` +
|
`${skeleton}\n\n` +
|
||||||
@@ -358,7 +431,7 @@ async function generateTailoredTexts({ job, basisDokumente, settings, llmNotizen
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
model: OLLAMA_MODEL,
|
model: OLLAMA_MODEL,
|
||||||
stream: false,
|
stream: false,
|
||||||
format: OUTPUT_SCHEMA,
|
format: buildOutputSchema(doks),
|
||||||
options: { temperature: 0.4 },
|
options: { temperature: 0.4 },
|
||||||
messages: [
|
messages: [
|
||||||
{ role: 'system', content: system },
|
{ role: 'system', content: system },
|
||||||
@@ -1797,8 +1870,12 @@ 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, zusatzAnlagen = [], llmNotizen = '', signatur = null, bewerbungsfoto = null, prompts = null, design = null }) {
|
// `dokumente` = which documents to produce, e.g. ['anschreiben'] or both
|
||||||
const data = await generateTailoredTexts({ job, basisDokumente, settings, llmNotizen, zusatzAnlagen, prompts });
|
// (the default). Anything not requested is neither asked of the model nor
|
||||||
|
// rendered — and, crucially, is not announced as an enclosure in the letter.
|
||||||
|
async function generateApplicationDocuments({ job, basisDokumente, settings, zusatzAnlagen = [], llmNotizen = '', signatur = null, bewerbungsfoto = null, prompts = null, design = null, dokumente = null }) {
|
||||||
|
const doks = normalizeDokumente(dokumente);
|
||||||
|
const data = await generateTailoredTexts({ job, basisDokumente, settings, llmNotizen, zusatzAnlagen, prompts, dokumente: doks });
|
||||||
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);
|
||||||
|
|
||||||
@@ -1807,14 +1884,18 @@ async function generateApplicationDocuments({ job, basisDokumente, settings, zus
|
|||||||
const label = job.stelle || job.firma || '';
|
const label = job.stelle || job.firma || '';
|
||||||
|
|
||||||
const documents = [];
|
const documents = [];
|
||||||
const cvPresent = hasLebenslauf(data.lebenslauf);
|
const cvGewuenscht = doks.includes('lebenslauf');
|
||||||
// "Anlagen" list for the letter: Lebenslauf first, then any static extras.
|
const briefGewuenscht = doks.includes('anschreiben');
|
||||||
|
const cvPresent = cvGewuenscht && hasLebenslauf(data.lebenslauf);
|
||||||
|
|
||||||
|
// "Anlagen" list for the letter: the Lebenslauf only when one is actually
|
||||||
|
// produced, then any static extras. Never promise an enclosure we don't send.
|
||||||
const anlagen = [
|
const anlagen = [
|
||||||
...(cvPresent ? ['Lebenslauf'] : []),
|
...(cvPresent ? ['Lebenslauf'] : []),
|
||||||
...zusatzAnlagen.filter(Boolean),
|
...zusatzAnlagen.filter(Boolean),
|
||||||
];
|
];
|
||||||
|
|
||||||
if (hasAnschreiben(data.anschreiben)) {
|
if (briefGewuenscht && hasAnschreiben(data.anschreiben)) {
|
||||||
documents.push({
|
documents.push({
|
||||||
name: `Anschreiben - ${label}`.trim(),
|
name: `Anschreiben - ${label}`.trim(),
|
||||||
filename: `Anschreiben_${suffix}.pdf`,
|
filename: `Anschreiben_${suffix}.pdf`,
|
||||||
@@ -2021,4 +2102,6 @@ module.exports = {
|
|||||||
renderLebenslaufPdf,
|
renderLebenslaufPdf,
|
||||||
renderAnschreibenPdf,
|
renderAnschreibenPdf,
|
||||||
renderDesignVorschau,
|
renderDesignVorschau,
|
||||||
|
DOKUMENT_TYPEN,
|
||||||
|
normalizeDokumente,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -363,6 +363,15 @@ function buildOpenApiSpec(baseUrl = '') {
|
|||||||
'IDs der zusätzlich beizulegenden statischen Anlagen (basis_anhaenge). ' +
|
'IDs der zusätzlich beizulegenden statischen Anlagen (basis_anhaenge). ' +
|
||||||
'Standard: keine. Die Auswahl erscheint auch im Anschreiben unter "Anlagen".',
|
'Standard: keine. Die Auswahl erscheint auch im Anschreiben unter "Anlagen".',
|
||||||
},
|
},
|
||||||
|
dokumente: {
|
||||||
|
type: 'array',
|
||||||
|
items: { type: 'string', enum: ['anschreiben', 'lebenslauf'] },
|
||||||
|
description:
|
||||||
|
'Welche Unterlagen erzeugt werden sollen. Weglassen oder leer = beide (Standard). ' +
|
||||||
|
'Wird der Lebenslauf nicht erzeugt, führt ihn das Anschreiben auch nicht unter ' +
|
||||||
|
'"Anlagen" auf, und der E-Mail-Begleittext kündigt ihn nicht an.',
|
||||||
|
example: ['anschreiben'],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -27,7 +27,10 @@ const multer = require('multer');
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const { generateApplicationDocuments, generateEmailReply, renderDesignVorschau } = require('./lib/documents');
|
const {
|
||||||
|
generateApplicationDocuments, generateEmailReply, renderDesignVorschau,
|
||||||
|
DOKUMENT_TYPEN, normalizeDokumente,
|
||||||
|
} = require('./lib/documents');
|
||||||
const chat = require('./lib/chat');
|
const chat = require('./lib/chat');
|
||||||
const promptStore = require('./lib/prompts');
|
const promptStore = require('./lib/prompts');
|
||||||
const designStore = require('./lib/design');
|
const designStore = require('./lib/design');
|
||||||
@@ -617,6 +620,9 @@ async function runGeneration(bewerbungId, options = {}) {
|
|||||||
const anlagenIds = Array.isArray(options.anlagenIds) ? options.anlagenIds.map(Number) : [];
|
const anlagenIds = Array.isArray(options.anlagenIds) ? options.anlagenIds.map(Number) : [];
|
||||||
const selectedAnhaenge = basisAnhaenge.filter((a) => anlagenIds.includes(a.id));
|
const selectedAnhaenge = basisAnhaenge.filter((a) => anlagenIds.includes(a.id));
|
||||||
|
|
||||||
|
// Which documents to produce; unset means both (the default everywhere).
|
||||||
|
const dokumente = normalizeDokumente(options.dokumente);
|
||||||
|
|
||||||
const { documents, email } = await generateApplicationDocuments({
|
const { documents, email } = await generateApplicationDocuments({
|
||||||
job: {
|
job: {
|
||||||
firma: bewerbung.firma,
|
firma: bewerbung.firma,
|
||||||
@@ -629,6 +635,7 @@ async function runGeneration(bewerbungId, options = {}) {
|
|||||||
settings,
|
settings,
|
||||||
prompts,
|
prompts,
|
||||||
design,
|
design,
|
||||||
|
dokumente,
|
||||||
// Names of the selected attachments so the cover letter (and the LLM) lists
|
// Names of the selected attachments so the cover letter (and the LLM) lists
|
||||||
// exactly these under "Anlagen".
|
// exactly these under "Anlagen".
|
||||||
zusatzAnlagen: selectedAnhaenge.map((a) => a.name || a.dateiname),
|
zusatzAnlagen: selectedAnhaenge.map((a) => a.name || a.dateiname),
|
||||||
@@ -851,6 +858,9 @@ function initializeDatabase() {
|
|||||||
`);
|
`);
|
||||||
// Migration: labels (JSON array of work-location labels) on both Stellen tables.
|
// Migration: labels (JSON array of work-location labels) on both Stellen tables.
|
||||||
db.run('ALTER TABLE bewerbungen ADD COLUMN labels TEXT', () => {});
|
db.run('ALTER TABLE bewerbungen ADD COLUMN labels TEXT', () => {});
|
||||||
|
// Which documents the last generation run produced ("anschreiben,lebenslauf").
|
||||||
|
// Remembered so the form comes back pre-ticked with the user's last choice.
|
||||||
|
db.run('ALTER TABLE bewerbungen ADD COLUMN generierung_dokumente TEXT', () => {});
|
||||||
db.run('ALTER TABLE jobangebote ADD COLUMN labels TEXT', () => {});
|
db.run('ALTER TABLE jobangebote ADD COLUMN labels TEXT', () => {});
|
||||||
// Migration: add columns to pre-existing jobangebote tables.
|
// Migration: add columns to pre-existing jobangebote tables.
|
||||||
db.run('ALTER TABLE jobangebote ADD COLUMN anzeige_datum DATE', () => {});
|
db.run('ALTER TABLE jobangebote ADD COLUMN anzeige_datum DATE', () => {});
|
||||||
@@ -1463,6 +1473,10 @@ initializeDatabase().then(() => {
|
|||||||
anhaenge,
|
anhaenge,
|
||||||
interneAnhaenge,
|
interneAnhaenge,
|
||||||
emails,
|
emails,
|
||||||
|
// Last choice of documents (defaults to both), for pre-ticking the form.
|
||||||
|
dokumentAuswahl: normalizeDokumente(
|
||||||
|
application.generierung_dokumente ? String(application.generierung_dokumente).split(',') : null
|
||||||
|
),
|
||||||
mailConfigured: mailer.isConfigured(),
|
mailConfigured: mailer.isConfigured(),
|
||||||
mailFrom: mailer.isConfigured() ? mailer.fromField() : '',
|
mailFrom: mailer.isConfigured() ? mailer.fromField() : '',
|
||||||
mailError: req.query.mailerror ? String(req.query.mailerror) : '',
|
mailError: req.query.mailerror ? String(req.query.mailerror) : '',
|
||||||
@@ -2355,7 +2369,10 @@ initializeDatabase().then(() => {
|
|||||||
|
|
||||||
// Selected extra attachments (checkbox values); none by default.
|
// Selected extra attachments (checkbox values); none by default.
|
||||||
const anlagenIds = [].concat(req.body.anlage || []).map((v) => parseInt(v, 10)).filter((n) => !Number.isNaN(n));
|
const anlagenIds = [].concat(req.body.anlage || []).map((v) => parseInt(v, 10)).filter((n) => !Number.isNaN(n));
|
||||||
runGeneration(id, { anlagenIds });
|
// Which documents to generate; nothing ticked = both (the default).
|
||||||
|
const dokumente = normalizeDokumente(req.body.dokument);
|
||||||
|
await dbRun('UPDATE bewerbungen SET generierung_dokumente = ? WHERE id = ?', [dokumente.join(','), id]);
|
||||||
|
runGeneration(id, { anlagenIds, dokumente });
|
||||||
res.redirect('/bewerbung/' + id);
|
res.redirect('/bewerbung/' + id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error generating documents:', error);
|
console.error('Error generating documents:', error);
|
||||||
|
|||||||
+60
-2
@@ -326,8 +326,9 @@
|
|||||||
|
|
||||||
<!-- 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"
|
id="generierenForm"
|
||||||
<% if (genStatus === 'fertig') { %>onsubmit="return confirm('Unterlagen neu generieren? Vorhandene generierte Dateien werden ersetzt.');"<% } %>>
|
data-bestaetigen="<%= genStatus === 'fertig' ? '1' : '' %>"
|
||||||
|
class="mt-6 border-t border-gray-200 dark:border-gray-700 pt-5">
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
Notizen für die KI
|
Notizen für die KI
|
||||||
</label>
|
</label>
|
||||||
@@ -339,6 +340,32 @@
|
|||||||
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"
|
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>
|
placeholder="z. B. Musterfirma GmbH Musterstraße 12 45128 Essen Ansprechpartner: Frau Müller"><%= application.llm_notizen || '' %></textarea>
|
||||||
|
|
||||||
|
<!-- Which documents to generate (both by default). -->
|
||||||
|
<div class="mt-4" id="dokumentAuswahl">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Was soll generiert werden?</label>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mb-2">
|
||||||
|
Standardmäßig werden Anschreiben und Lebenslauf erstellt. Wird der Lebenslauf abgewählt,
|
||||||
|
führt ihn das Anschreiben auch nicht mehr unter „Anlagen“ auf.
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-wrap gap-4">
|
||||||
|
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
<input type="checkbox" name="dokument" value="anschreiben" data-dokument
|
||||||
|
<%= dokumentAuswahl.includes('anschreiben') ? 'checked' : '' %>
|
||||||
|
class="rounded border-gray-300 dark:border-gray-600">
|
||||||
|
Anschreiben
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
<input type="checkbox" name="dokument" value="lebenslauf" data-dokument
|
||||||
|
<%= dokumentAuswahl.includes('lebenslauf') ? 'checked' : '' %>
|
||||||
|
class="rounded border-gray-300 dark:border-gray-600">
|
||||||
|
Lebenslauf
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-red-600 dark:text-red-400 mt-2 hidden" id="dokumentHinweis">
|
||||||
|
Bitte mindestens ein Dokument auswählen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Optional extra attachments to enclose (none by default). -->
|
<!-- Optional extra attachments to enclose (none by default). -->
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Zusätzliche Anlagen</label>
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Zusätzliche Anlagen</label>
|
||||||
@@ -913,6 +940,37 @@
|
|||||||
}, 4000);
|
}, 4000);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
// Generation form: require at least one document, and keep the
|
||||||
|
// "replace existing files" confirmation when regenerating.
|
||||||
|
(function () {
|
||||||
|
const form = document.getElementById('generierenForm');
|
||||||
|
if (!form) return;
|
||||||
|
const boxes = Array.from(form.querySelectorAll('[data-dokument]'));
|
||||||
|
const hinweis = document.getElementById('dokumentHinweis');
|
||||||
|
|
||||||
|
form.addEventListener('submit', function (e) {
|
||||||
|
const gewaehlt = boxes.filter((b) => b.checked);
|
||||||
|
if (!gewaehlt.length) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (hinweis) hinweis.classList.remove('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (form.dataset.bestaetigen === '1') {
|
||||||
|
const was = gewaehlt.length === 2
|
||||||
|
? 'Anschreiben und Lebenslauf'
|
||||||
|
: (gewaehlt[0].value === 'anschreiben' ? 'das Anschreiben' : 'der Lebenslauf');
|
||||||
|
if (!confirm('Es ' + (gewaehlt.length === 2 ? 'werden ' : 'wird ') + was
|
||||||
|
+ ' neu generiert. Vorhandene generierte Dateien werden ersetzt. Fortfahren?')) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
boxes.forEach((b) => b.addEventListener('change', function () {
|
||||||
|
if (hinweis && boxes.some((x) => x.checked)) hinweis.classList.add('hidden');
|
||||||
|
}));
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user