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:
+128
-45
@@ -148,7 +148,35 @@ const OUTPUT_SCHEMA = {
|
||||
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;
|
||||
if (!apiKey) {
|
||||
throw new Error(
|
||||
@@ -187,38 +215,50 @@ async function generateTailoredTexts({ job, basisDokumente, settings, llmNotizen
|
||||
// below stays in code because the response parsing depends on it.
|
||||
const system = promptStore.get(prompts, 'unterlagen');
|
||||
|
||||
const skeleton =
|
||||
`{\n` +
|
||||
` "headline": "Kurze Berufsbezeichnung (max. 5 Wörter)",\n` +
|
||||
` "kontakt": { "email": "", "telefon": "", "ort": "", "webseite": "", "geburtsdatum": "" },\n` +
|
||||
` "anschreiben": {\n` +
|
||||
` "empfaenger": { "firma": "Unternehmen", "adresse": "Straße Hausnr. (falls bekannt, sonst leer)", "ort": "PLZ Ort", "ansprechpartner": "" },\n` +
|
||||
` "betreff": "Bewerbung als …",\n` +
|
||||
` "anrede": "Sehr geehrte Damen und Herren,",\n` +
|
||||
` "absaetze": ["Absatz 1", "Absatz 2", "Absatz 3"],\n` +
|
||||
` "gruss": "Mit freundlichen Grüßen"\n` +
|
||||
` },\n` +
|
||||
// Built from blocks so it matches the (possibly reduced) schema exactly: a
|
||||
// skeleton showing a section the schema forbids would only confuse the model.
|
||||
const skelettBloecke = [
|
||||
` "headline": "Kurze Berufsbezeichnung (max. 5 Wörter)"`,
|
||||
` "kontakt": { "email": "", "telefon": "", "ort": "", "webseite": "", "geburtsdatum": "" }`,
|
||||
];
|
||||
if (doks.includes('anschreiben')) {
|
||||
skelettBloecke.push(
|
||||
` "anschreiben": {\n` +
|
||||
` "empfaenger": { "firma": "Unternehmen", "adresse": "Straße Hausnr. (falls bekannt, sonst leer)", "ort": "PLZ Ort", "ansprechpartner": "" },\n` +
|
||||
` "betreff": "Bewerbung als …",\n` +
|
||||
` "anrede": "Sehr geehrte Damen und Herren,",\n` +
|
||||
` "absaetze": ["Absatz 1", "Absatz 2", "Absatz 3"],\n` +
|
||||
` "gruss": "Mit freundlichen Grüßen"\n` +
|
||||
` }`
|
||||
);
|
||||
}
|
||||
skelettBloecke.push(
|
||||
` "email": {\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` +
|
||||
` },\n` +
|
||||
` "lebenslauf": {\n` +
|
||||
` "profil": "2-3 Sätze Kurzprofil, zugeschnitten auf die Zielstelle",\n` +
|
||||
` "berufserfahrung": [\n` +
|
||||
` { "zeitraum": "02/2025 - heute", "titel": "Jobtitel", "firma": "Arbeitgeber, Ort", "punkte": ["Aufgabe/Erfolg 1", "Aufgabe/Erfolg 2"] }\n` +
|
||||
` ],\n` +
|
||||
` "studium": [\n` +
|
||||
` { "zeitraum": "10/2010 - 03/2017", "abschluss": "Studiengang", "institution": "Hochschule", "zusatz": "" }\n` +
|
||||
` ],\n` +
|
||||
` "berufsausbildung": [\n` +
|
||||
` { "zeitraum": "08/2006 - 07/2009", "abschluss": "Ausbildungsberuf / Abschluss", "institution": "Betrieb/Berufskolleg", "zusatz": "" }\n` +
|
||||
` ],\n` +
|
||||
` "weiterbildungen": [],\n` +
|
||||
` "kenntnisse": ["Fähigkeit 1", "Fähigkeit 2"],\n` +
|
||||
` "sprachen": [ { "sprache": "Deutsch", "niveau": "Muttersprache" }, { "sprache": "Englisch", "niveau": "verhandlungssicher" } ],\n` +
|
||||
` "hobbys": ["Hobby 1", "Hobby 2"]\n` +
|
||||
` }\n` +
|
||||
`}`;
|
||||
` }`
|
||||
);
|
||||
if (doks.includes('lebenslauf')) {
|
||||
skelettBloecke.push(
|
||||
` "lebenslauf": {\n` +
|
||||
` "profil": "2-3 Sätze Kurzprofil, zugeschnitten auf die Zielstelle",\n` +
|
||||
` "berufserfahrung": [\n` +
|
||||
` { "zeitraum": "02/2025 - heute", "titel": "Jobtitel", "firma": "Arbeitgeber, Ort", "punkte": ["Aufgabe/Erfolg 1", "Aufgabe/Erfolg 2"] }\n` +
|
||||
` ],\n` +
|
||||
` "studium": [\n` +
|
||||
` { "zeitraum": "10/2010 - 03/2017", "abschluss": "Studiengang", "institution": "Hochschule", "zusatz": "" }\n` +
|
||||
` ],\n` +
|
||||
` "berufsausbildung": [\n` +
|
||||
` { "zeitraum": "08/2006 - 07/2009", "abschluss": "Ausbildungsberuf / Abschluss", "institution": "Betrieb/Berufskolleg", "zusatz": "" }\n` +
|
||||
` ],\n` +
|
||||
` "weiterbildungen": [],\n` +
|
||||
` "kenntnisse": ["Fähigkeit 1", "Fähigkeit 2"],\n` +
|
||||
` "sprachen": [ { "sprache": "Deutsch", "niveau": "Muttersprache" }, { "sprache": "Englisch", "niveau": "verhandlungssicher" } ],\n` +
|
||||
` "hobbys": ["Hobby 1", "Hobby 2"]\n` +
|
||||
` }`
|
||||
);
|
||||
}
|
||||
const skeleton = `{\n${skelettBloecke.join(',\n')}\n}`;
|
||||
|
||||
const notizenText = (llmNotizen && llmNotizen.trim())
|
||||
? `# 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`
|
||||
: '';
|
||||
|
||||
// Attachments actually enclosed with this application. The Lebenslauf is
|
||||
// always part of the set; the extra items are the ones the user selected.
|
||||
const anlagenListe = ['Lebenslauf', ...(zusatzAnlagen || []).filter(Boolean)];
|
||||
const anlagenText =
|
||||
`# Beigefügte Anlagen (genau diese Dokumente liegen der Bewerbung bei)\n` +
|
||||
anlagenListe.map((a) => `- ${a}`).join('\n') + `\n\n`;
|
||||
// Attachments actually enclosed with this application. The Lebenslauf is only
|
||||
// listed when it is actually being generated — otherwise the cover letter would
|
||||
// promise an enclosure that never gets attached.
|
||||
const anlagenListe = [
|
||||
...(doks.includes('lebenslauf') ? ['Lebenslauf'] : []),
|
||||
...(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 =
|
||||
`# Bewerberdaten\n${bewerber}\n\n` +
|
||||
@@ -239,10 +313,9 @@ async function generateTailoredTexts({ job, basisDokumente, settings, llmNotizen
|
||||
`# Zielstelle\n${stelleText}\n\n` +
|
||||
notizenText +
|
||||
anlagenText +
|
||||
mailAnhangText +
|
||||
`# Aufgabe\n` +
|
||||
`Erzeuge die TEXTE für ein Anschreiben und einen Lebenslauf, passgenau auf diese ` +
|
||||
`Stelle zugeschnitten. Layout/Design ist bereits vorgegeben — liefere nur die Inhalte. ` +
|
||||
`Beide Dokumente werden auf JE EINER A4-Seite gedruckt, halte dich also kurz.\n\n` +
|
||||
aufgabe + `\n\n` +
|
||||
`Verwende EXAKT diese JSON-Struktur und exakt diese Schlüsselnamen ` +
|
||||
`(keine anderen, keine zusätzlichen Felder, "zeitraum" immer als einzelner String):\n\n` +
|
||||
`${skeleton}\n\n` +
|
||||
@@ -358,7 +431,7 @@ async function generateTailoredTexts({ job, basisDokumente, settings, llmNotizen
|
||||
body: JSON.stringify({
|
||||
model: OLLAMA_MODEL,
|
||||
stream: false,
|
||||
format: OUTPUT_SCHEMA,
|
||||
format: buildOutputSchema(doks),
|
||||
options: { temperature: 0.4 },
|
||||
messages: [
|
||||
{ role: 'system', content: system },
|
||||
@@ -1797,8 +1870,12 @@ function hasLebenslauf(l) {
|
||||
|| 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 }) {
|
||||
const data = await generateTailoredTexts({ job, basisDokumente, settings, llmNotizen, zusatzAnlagen, prompts });
|
||||
// `dokumente` = which documents to produce, e.g. ['anschreiben'] or both
|
||||
// (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 header = buildHeader(settings, data.kontakt, headline);
|
||||
|
||||
@@ -1807,14 +1884,18 @@ async function generateApplicationDocuments({ job, basisDokumente, settings, zus
|
||||
const label = job.stelle || job.firma || '';
|
||||
|
||||
const documents = [];
|
||||
const cvPresent = hasLebenslauf(data.lebenslauf);
|
||||
// "Anlagen" list for the letter: Lebenslauf first, then any static extras.
|
||||
const cvGewuenscht = doks.includes('lebenslauf');
|
||||
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 = [
|
||||
...(cvPresent ? ['Lebenslauf'] : []),
|
||||
...zusatzAnlagen.filter(Boolean),
|
||||
];
|
||||
|
||||
if (hasAnschreiben(data.anschreiben)) {
|
||||
if (briefGewuenscht && hasAnschreiben(data.anschreiben)) {
|
||||
documents.push({
|
||||
name: `Anschreiben - ${label}`.trim(),
|
||||
filename: `Anschreiben_${suffix}.pdf`,
|
||||
@@ -2021,4 +2102,6 @@ module.exports = {
|
||||
renderLebenslaufPdf,
|
||||
renderAnschreibenPdf,
|
||||
renderDesignVorschau,
|
||||
DOKUMENT_TYPEN,
|
||||
normalizeDokumente,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user