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>
This commit is contained in:
@@ -0,0 +1,259 @@
|
||||
// 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 };
|
||||
Reference in New Issue
Block a user