// Document generation: tailors application documents to a job description with // the help of an LLM (Ollama Cloud), then renders them to PDF. // // The AI *rewrites* the user's own, previously provided base documents // (Basis-Dokumente) so the result stays grounded in real facts — it must not // invent experience the applicant doesn't have. const { jsPDF } = require('jspdf'); // Ollama Cloud API (https://ollama.com). Override host/model via env if needed // (e.g. point OLLAMA_HOST at a local Ollama instance). const OLLAMA_HOST = (process.env.OLLAMA_HOST || 'https://ollama.com').replace(/\/+$/, ''); const OLLAMA_MODEL = process.env.OLLAMA_MODEL || 'gpt-oss:120b'; const OLLAMA_TIMEOUT_MS = Number(process.env.OLLAMA_TIMEOUT_MS || 180000); // ----- Ollama call --------------------------------------------------------- // Returns { anschreiben: string, lebenslauf: string }. // Throws if no API key is configured or the API call fails. async function generateTailoredTexts({ job, basisDokumente, settings }) { const apiKey = process.env.OLLAMA_API_KEY; if (!apiKey) { throw new Error( 'OLLAMA_API_KEY ist nicht gesetzt. Bitte den API-Schlüssel als ' + 'Umgebungsvariable (z. B. in einer .env-Datei) hinterlegen, damit ' + 'Bewerbungsunterlagen generiert werden können.' ); } if (!basisDokumente || basisDokumente.length === 0) { throw new Error( 'Es sind keine Basis-Unterlagen hinterlegt. Bitte zuerst unter "Vorlagen" ' + 'mindestens ein Basis-Dokument (z. B. Anschreiben und Lebenslauf) bereitstellen.' ); } const bewerber = [ settings && settings.name ? `Name: ${settings.name}` : null, settings && settings.adresse ? `Adresse: ${settings.adresse}` : null, ].filter(Boolean).join('\n') || 'Keine Angaben'; const basisText = basisDokumente .map((d, i) => `### Basis-Dokument ${i + 1} — Typ: ${d.typ || 'Sonstiges'} (${d.name || 'ohne Titel'})\n${d.inhalt}`) .join('\n\n'); const stelleText = [ job.stelle ? `Stellenbezeichnung: ${job.stelle}` : null, job.firma ? `Unternehmen: ${job.firma}` : null, job.ort ? `Ort: ${job.ort}` : null, job.gehalt ? `Gehalt/Konditionen: ${job.gehalt}` : null, job.quelle_url ? `Quelle: ${job.quelle_url}` : null, '', 'Stellenbeschreibung:', job.stellenbeschreibung || '(keine Beschreibung übermittelt)', ].filter((l) => l !== null).join('\n'); const system = 'Du bist ein erfahrener Bewerbungscoach und schreibst professionelle, ' + 'deutschsprachige Bewerbungsunterlagen. Du passt die BEREITGESTELLTEN ' + 'Basis-Unterlagen des Bewerbers auf eine konkrete Stellenausschreibung an. ' + 'Wichtigste Regel: Du erfindest KEINE Fakten, Qualifikationen, Abschlüsse ' + 'oder Berufserfahrungen. Verwende ausschließlich Informationen, die in den ' + 'Basis-Unterlagen des Bewerbers stehen. Du darfst umformulieren, ' + 'gewichten, relevante Punkte hervorheben und auf die Stelle zuschneiden — ' + 'aber nichts hinzudichten. Schreibe natürlich, überzeugend und ohne Floskeln.'; const userPrompt = `# Bewerberdaten\n${bewerber}\n\n` + `# Basis-Unterlagen des Bewerbers (Faktengrundlage — NUR diese Fakten verwenden)\n${basisText}\n\n` + `# Zielstelle\n${stelleText}\n\n` + `# Aufgabe\n` + `Erstelle zwei zugeschnittene Dokumente:\n` + `1. "anschreiben": Ein vollständiges, individuelles Anschreiben für genau diese Stelle. ` + `Beginne mit einer passenden Anrede und schließe mit einer Grußformel und dem Namen des Bewerbers. ` + `Betone die zur Stellenbeschreibung passenden Stärken aus den Basis-Unterlagen. ` + `Keine Platzhalter wie [Firma] — nutze die echten Angaben. Kein Briefkopf/Adressblock, nur der Brieftext.\n` + `2. "lebenslauf": Eine auf die Stelle zugeschnittene, gut strukturierte Fassung des Lebenslaufs ` + `(relevante Stationen und Kompetenzen zuerst), rein auf Basis der vorhandenen Angaben. ` + `Falls keine Lebenslauf-Informationen in den Basis-Unterlagen vorhanden sind, gib einen leeren String zurück.\n` + `Antworte ausschließlich im geforderten JSON-Format.`; // Structured output: ask Ollama to return exactly this JSON shape. const format = { type: 'object', properties: { anschreiben: { type: 'string' }, lebenslauf: { type: 'string' }, }, required: ['anschreiben', 'lebenslauf'], }; const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), OLLAMA_TIMEOUT_MS); let res; try { res = await fetch(`${OLLAMA_HOST}/api/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}`, }, body: JSON.stringify({ model: OLLAMA_MODEL, stream: false, format, options: { temperature: 0.4 }, messages: [ { role: 'system', content: system }, { role: 'user', content: userPrompt }, ], }), signal: controller.signal, }); } catch (err) { if (err.name === 'AbortError') { throw new Error(`Zeitüberschreitung bei der KI-Anfrage (> ${Math.round(OLLAMA_TIMEOUT_MS / 1000)}s).`); } throw new Error(`Verbindung zur Ollama-API fehlgeschlagen: ${err.message}`); } finally { clearTimeout(timeout); } if (!res.ok) { const body = await res.text().catch(() => ''); throw new Error(`Ollama-API antwortete mit ${res.status}: ${body.slice(0, 300)}`); } const data = await res.json(); const text = ((data && data.message && data.message.content) || '').trim(); if (!text) { throw new Error('Die KI hat keine Antwort geliefert (leerer Inhalt).'); } let parsed; try { parsed = JSON.parse(text); } catch (e) { // Fallback: try to extract the first JSON object; otherwise treat the // whole answer as the cover letter so nothing is lost. const match = text.match(/\{[\s\S]*\}/); if (match) { try { parsed = JSON.parse(match[0]); } catch (_) { /* ignore */ } } if (!parsed) parsed = { anschreiben: text, lebenslauf: '' }; } return { anschreiben: (parsed.anschreiben || '').trim(), lebenslauf: (parsed.lebenslauf || '').trim(), }; } // ----- PDF rendering ------------------------------------------------------- // Renders a text document to a PDF Buffer with a simple, clean A4 layout. function textToPdf({ title, headerLines = [], bodyText = '' }) { const doc = new jsPDF({ unit: 'mm', format: 'a4' }); const pageWidth = doc.internal.pageSize.getWidth(); const pageHeight = doc.internal.pageSize.getHeight(); const marginX = 20; const marginTop = 20; const marginBottom = 20; const maxWidth = pageWidth - marginX * 2; let y = marginTop; function ensureSpace(lineHeight) { if (y + lineHeight > pageHeight - marginBottom) { doc.addPage(); y = marginTop; } } function writeBlock(text, { fontSize = 11, fontStyle = 'normal', gapAfter = 4, lineHeight } = {}) { doc.setFont('helvetica', fontStyle); doc.setFontSize(fontSize); const lh = lineHeight || fontSize * 0.5; // mm per line // Preserve intentional blank lines from the source text. const paragraphs = String(text).split('\n'); paragraphs.forEach((para) => { if (para.trim() === '') { ensureSpace(lh); y += lh; return; } const lines = doc.splitTextToSize(para, maxWidth); lines.forEach((line) => { ensureSpace(lh); doc.text(line, marginX, y); y += lh; }); }); y += gapAfter; } if (title) { writeBlock(title, { fontSize: 16, fontStyle: 'bold', gapAfter: 6 }); } if (headerLines.length) { writeBlock(headerLines.join('\n'), { fontSize: 10, fontStyle: 'normal', gapAfter: 8 }); } if (bodyText) { writeBlock(bodyText, { fontSize: 11, fontStyle: 'normal', gapAfter: 4, lineHeight: 6 }); } const arrayBuffer = doc.output('arraybuffer'); return Buffer.from(arrayBuffer); } // Build the list of attachment documents (PDF buffers) for a job. // Returns [{ name, filename, mime, buffer }]. async function generateApplicationDocuments({ job, basisDokumente, settings }) { const { anschreiben, lebenslauf } = await generateTailoredTexts({ job, basisDokumente, settings }); const today = new Date().toLocaleDateString('de-DE'); const bewerberName = (settings && settings.name) || ''; const bewerberAdresse = (settings && settings.adresse) || ''; const safe = (s) => String(s || '').replace(/[^a-zA-Z0-9äöüÄÖÜß _-]/g, '').replace(/\s+/g, '_').slice(0, 60) || 'Bewerbung'; const suffix = safe(job.firma || job.stelle || 'Bewerbung'); const documents = []; if (anschreiben) { const headerLines = [ bewerberName, bewerberAdresse, '', job.firma || '', job.ort || '', '', today, '', `Bewerbung als ${job.stelle || ''}`.trim(), ].filter((l) => l !== null); documents.push({ name: `Anschreiben – ${job.stelle || job.firma || ''}`.trim(), filename: `Anschreiben_${suffix}.pdf`, mime: 'application/pdf', buffer: textToPdf({ title: '', headerLines, bodyText: anschreiben }), }); } if (lebenslauf) { const headerLines = [bewerberName, bewerberAdresse].filter(Boolean); documents.push({ name: `Lebenslauf – ${job.stelle || job.firma || ''}`.trim(), filename: `Lebenslauf_${suffix}.pdf`, mime: 'application/pdf', buffer: textToPdf({ title: 'Lebenslauf', headerLines, bodyText: lebenslauf }), }); } if (documents.length === 0) { throw new Error('Die KI hat keine verwertbaren Unterlagen erzeugt.'); } return documents; } module.exports = { generateApplicationDocuments, generateTailoredTexts, textToPdf };