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
+155
View File
@@ -0,0 +1,155 @@
<!DOCTYPE html>
<html lang="de">
<head>
<%- include('partials/head') %>
</head>
<body class="min-h-screen transition-colors duration-300 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100" id="body">
<%- include('partials/header', { hideSettings: true }) %>
<main class="container mx-auto px-4 py-8 max-w-3xl">
<!-- Back link -->
<a href="/" class="inline-flex items-center gap-2 text-sm text-blue-600 dark:text-blue-400 hover:underline mb-6">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"></path>
</svg>
Zurück zur Übersicht
</a>
<h1 class="text-2xl font-bold text-gray-800 dark:text-white mb-2">Basis-Unterlagen (Vorlagen)</h1>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6">
Diese Unterlagen dienen der KI als Faktengrundlage. Beim Import einer Stelle über die Browser-Erweiterung
werden daraus automatisch zugeschnittene Bewerbungsunterlagen erstellt es werden ausschließlich die
hier hinterlegten Informationen verwendet, es wird nichts hinzuerfunden. Füge z. B. dein
Standard-Anschreiben und deinen Lebenslauf als Text ein.
</p>
<!-- API key warning -->
<% if (!hasApiKey) { %>
<div class="rounded-lg bg-amber-50 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-800 px-4 py-3 mb-6">
<div class="flex items-start gap-2">
<svg class="w-5 h-5 text-amber-600 dark:text-amber-400 shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
</svg>
<div class="text-sm text-amber-800 dark:text-amber-200">
<p class="font-semibold">Kein API-Schlüssel konfiguriert</p>
<p>Setze die Umgebungsvariable <code class="px-1 rounded bg-amber-100 dark:bg-amber-800">OLLAMA_API_KEY</code>
(z. B. in einer <code class="px-1 rounded bg-amber-100 dark:bg-amber-800">.env</code>-Datei),
damit Bewerbungsunterlagen automatisch generiert werden können.</p>
</div>
</div>
</div>
<% } %>
<!-- Existing base documents -->
<section class="space-y-4 mb-8">
<% if (basisDokumente.length === 0) { %>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 text-center text-gray-500 dark:text-gray-400">
Noch keine Basis-Unterlagen hinterlegt. Füge unten dein erstes Dokument hinzu.
</div>
<% } %>
<% basisDokumente.forEach(doc => { %>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<form action="/vorlagen/<%= doc.id %>" method="POST" class="space-y-3">
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div>
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Typ</label>
<select name="typ" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-800 dark:text-white">
<% basisTypOptions.forEach(opt => { %>
<option value="<%= opt %>" <%= doc.typ === opt ? 'selected' : '' %>><%= opt %></option>
<% }); %>
</select>
</div>
<div class="sm:col-span-2">
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Bezeichnung</label>
<input type="text" name="name" value="<%= doc.name || '' %>"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-800 dark:text-white"
placeholder="z. B. Standard-Anschreiben">
</div>
</div>
<div>
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Inhalt (Text)</label>
<textarea name="inhalt" rows="8"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-800 dark:text-white leading-relaxed font-mono text-sm"><%= doc.inhalt %></textarea>
</div>
<div class="flex justify-end gap-2">
<button type="submit"
class="px-3 py-1.5 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors">
Speichern
</button>
</div>
</form>
<form action="/vorlagen/<%= doc.id %>/delete" method="POST" class="mt-1 text-right"
onsubmit="return confirm('Dieses Basis-Dokument löschen?');">
<button type="submit"
class="text-xs text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 hover:underline">
Dokument löschen
</button>
</form>
</div>
<% }); %>
</section>
<!-- Add new base document -->
<section class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<h2 class="text-lg font-semibold text-gray-800 dark:text-white mb-4">Basis-Dokument hinzufügen</h2>
<form action="/vorlagen" method="POST" class="space-y-3">
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div>
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Typ</label>
<select name="typ" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-800 dark:text-white">
<% basisTypOptions.forEach(opt => { %>
<option value="<%= opt %>"><%= opt %></option>
<% }); %>
</select>
</div>
<div class="sm:col-span-2">
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Bezeichnung</label>
<input type="text" name="name"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-800 dark:text-white"
placeholder="z. B. Standard-Anschreiben">
</div>
</div>
<div>
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Inhalt (Text)</label>
<textarea name="inhalt" rows="10" required
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-800 dark:text-white leading-relaxed font-mono text-sm"
placeholder="Füge hier den vollständigen Text deines Anschreibens oder Lebenslaufs ein…"></textarea>
</div>
<div class="flex justify-end">
<button type="submit" class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-md transition-colors">
Hinzufügen
</button>
</div>
</form>
</section>
</main>
<%- include('partials/footer') %>
<script>
(function () {
const root = document.documentElement;
const dm = localStorage.getItem('darkMode');
if (dm === 'enabled' || (!dm && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
root.classList.add('dark');
}
const toggle = document.getElementById('darkModeToggle');
const sun = document.getElementById('sunIcon');
const moon = document.getElementById('moonIcon');
function sync() {
const d = root.classList.contains('dark');
if (sun) sun.classList.toggle('hidden', d);
if (moon) moon.classList.toggle('hidden', !d);
}
sync();
if (toggle) toggle.addEventListener('click', () => {
root.classList.toggle('dark');
localStorage.setItem('darkMode', root.classList.contains('dark') ? 'enabled' : 'disabled');
sync();
});
const cy = document.getElementById('currentYear');
if (cy) cy.textContent = new Date().getFullYear();
})();
</script>
</body>
</html>