Files
jobbi-bewerbung/lib/documents.js
T
thomasandClaude Opus 4.8 fd20ca0daf Add AI application assistant: Indeed import + Ollama document generation
- Browser extension (Chromium MV3) injecting a "send to tracker" button next
  to the Indeed job description; scrapes job info and posts it to a new
  /api/indeed-import endpoint (CORS-enabled), configurable tracker URL via popup.
- New "Entwurf" status. Imports create a draft and trigger background AI
  generation of tailored Anschreiben + Lebenslauf (PDF attachments) via the
  Ollama Cloud API, grounded strictly in user-provided base documents.
- Vorlagen page to manage base documents; attachments UI, generation status
  polling, regenerate and download routes on the application page.
- Schema: ort/stellenbeschreibung/quelle_url/generierung_* columns, plus
  basis_dokumente and anhaenge tables (with migrations).
- Config via .env (OLLAMA_API_KEY/OLLAMA_MODEL/OLLAMA_HOST); dependency-free
  .env loader. Dockerfile copies lib/, .dockerignore added.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 22:21:28 +02:00

260 lines
9.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 };