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:
2026-07-02 22:21:28 +02:00
co-authored by Claude Opus 4.8
parent 6952309de9
commit fd20ca0daf
18 changed files with 1453 additions and 7 deletions
+259
View File
@@ -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 };