Files
jobbi-bewerbung/views/jobsuche.ejs
T
thomasandClaude Opus 4.8 d86e40a6ff KI-Feinschliff, Volltextsuche über E-Mails, Prompt für die Antwort-Mail
Drei Erweiterungen, die sich server.js und lib/documents.js teilen und
deshalb zusammen liegen:

Jobsuche: Der Feinschliff (Zusatzbegriffe/Ausschluesse) laesst sich per KI
aus den eigenen Basis-Unterlagen vorschlagen. Der Rollen-Prompt ist wie die
uebrigen unter Vorlagen editierbar; gespeichert wird erst auf Klick.

Suche: Startseite und KI-Chat durchsuchen jetzt per SQLite-FTS5 auch die
E-Mail-Korrespondenz (Absender, Betreff, Text), Notizen und
Stellenbeschreibung - ein Name aus einer Mail findet die Bewerbung. Ranking
per bm25, Fundstellen mit Snippet, Feldfilter (firma:, von:), Phrasen,
Ausschluesse, Umlaut-Varianten (mueller/muller/mueller). Der Index wird von
SQL-Triggern gepflegt, weil Bewerbungen und Mails aus UI, REST-API, IMAP und
KI-Import geschrieben werden.

E-Mail: Vor dem Generieren laesst sich vorgeben, was in der Antwort stehen
soll; die Vorgabe geht als verbindliche Anweisung in den Prompt. Steht schon
ein Entwurf im Feld, ueberarbeitet die KI ihn, statt neu anzufangen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 10:56:41 +02:00

299 lines
17 KiB
Plaintext
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.
<!DOCTYPE html>
<html lang="de">
<head>
<%- include('partials/head') %>
</head>
<body class="min-h-screen flex flex-col transition-colors duration-300 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100" id="body">
<%- include('partials/header') %>
<main class="flex-1 container mx-auto px-4 py-8 max-w-4xl">
<div class="mb-6">
<h2 class="text-2xl font-bold text-gray-800 dark:text-white">Jobsuche</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
Du legst fest, <strong>wo</strong> gesucht wird. <strong>Was</strong> gesucht wird Rollen und
Technologien leitet die KI aus deinem hinterlegten Lebenslauf ab.
</p>
</div>
<% if (fremd) { %>
<div class="mb-6 rounded-lg border border-amber-300 dark:border-amber-700 bg-amber-50 dark:bg-amber-900/20 p-4">
<p class="text-sm text-amber-800 dark:text-amber-200">
Du bearbeitest als Administrator das Suchprofil von <strong><%= zielUser.username %></strong>.
<a href="/jobsuche" class="underline">Zum eigenen Profil</a>
</p>
</div>
<% } %>
<% if (fehler) { %>
<div class="mb-6 rounded-lg border border-red-300 dark:border-red-700 bg-red-50 dark:bg-red-900/20 p-4">
<p class="text-sm text-red-800 dark:text-red-200"><%= fehler %></p>
</div>
<% } %>
<% if (hinweis) { %>
<div class="mb-6 rounded-lg border border-green-300 dark:border-green-700 bg-green-50 dark:bg-green-900/20 p-4">
<p class="text-sm text-green-800 dark:text-green-200"><%= hinweis %></p>
</div>
<% } %>
<% if (!hatLebenslauf || !hatOllamaKey) { %>
<div class="mb-6 rounded-lg border border-amber-300 dark:border-amber-700 bg-amber-50 dark:bg-amber-900/20 p-4">
<p class="text-sm font-medium text-amber-800 dark:text-amber-200 mb-1">Vor dem ersten Suchlauf fehlt noch etwas:</p>
<ul class="text-sm text-amber-800 dark:text-amber-200 list-disc list-inside space-y-0.5">
<% if (!hatLebenslauf) { %>
<li>Kein Lebenslauf hinterlegt ohne ihn kann die KI dein Suchprofil nicht ableiten.
<a href="/vorlagen" class="underline">Unter Vorlagen anlegen</a></li>
<% } %>
<% if (!hatOllamaKey) { %>
<li>Kein Ollama-API-Schlüssel der Suchlauf läuft über dein eigenes KI-Kontingent.
<a href="/einstellungen" class="underline">In den Einstellungen eintragen</a></li>
<% } %>
</ul>
</div>
<% } %>
<form action="/jobsuche" method="POST" class="space-y-6">
<% if (fremd) { %><input type="hidden" name="ziel_user" value="<%= zielUser.id %>"><% } %>
<section class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<div class="flex items-start justify-between gap-4">
<div>
<h3 class="text-base font-semibold text-gray-800 dark:text-white">Automatische Suche</h3>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
Ist sie aktiv, sucht die KI nach Zeitplan und legt neue Treffer als Jobangebote an.
</p>
</div>
<label class="inline-flex items-center gap-2 shrink-0">
<input type="checkbox" name="aktiv" value="1" <%= profil.aktiv ? 'checked' : '' %>
class="rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500">
<span class="text-sm text-gray-700 dark:text-gray-300">aktiv</span>
</label>
</div>
</section>
<section class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<h3 class="text-base font-semibold text-gray-800 dark:text-white">Wo gesucht wird</h3>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1 mb-5">Modus und Orte.</p>
<div class="space-y-2 mb-5">
<% var modusText = {
regional: 'Nur Stellen mit Arbeitsort in den unten genannten Städten.',
remote: 'Nur Stellen, die zu 100 % im Homeoffice ausübbar sind deutschlandweit, Firmensitz egal.',
beides: 'Zwei Durchgänge: regional in deinen Städten und zusätzlich 100 % Remote deutschlandweit.'
}; %>
<% modi.forEach(function (m) { %>
<label class="flex items-start gap-3 p-3 rounded-md border border-gray-200 dark:border-gray-700 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700/40">
<input type="radio" name="modus" value="<%= m %>" <%= profil.modus === m ? 'checked' : '' %>
class="mt-0.5 border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500">
<span>
<span class="block text-sm font-medium text-gray-800 dark:text-white">
<%= m === 'regional' ? 'Regional' : (m === 'remote' ? '100 % Remote' : 'Regional + Remote') %>
</span>
<span class="block text-xs text-gray-500 dark:text-gray-400"><%= modusText[m] %></span>
</span>
</label>
<% }); %>
</div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" for="staedte">Städte</label>
<textarea id="staedte" name="staedte" rows="3" spellcheck="false"
class="w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm"
placeholder="Gladbeck, Bottrop, Gelsenkirchen, Essen"><%= profil.staedte.join(', ') %></textarea>
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">
Kommagetrennt, höchstens <%= maxStaedte %>. Die <strong>erste Stadt gilt als Wohnort</strong> und wird
am höchsten priorisiert. Bei „100 % Remote“ werden die Städte ignoriert.
</p>
</section>
<section class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<h3 class="text-base font-semibold text-gray-800 dark:text-white">Feinschliff</h3>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
Optional. Rollen und Technologien kommen aus deinem Lebenslauf hier ergänzt du nur Leitplanken.
</p>
</div>
<button type="button" id="feinschliffKi"
<%= (!hatUnterlagen || !hatOllamaKey) ? 'disabled' : '' %>
title="<%= !hatUnterlagen ? 'Dafür fehlen Basis-Unterlagen unter Vorlagen.' : (!hatOllamaKey ? 'Dafür fehlt der Ollama-API-Schlüssel in den Einstellungen.' : 'Leitet Vorschläge aus deinen Basis-Unterlagen ab.') %>"
class="inline-flex items-center gap-2 px-3 py-1.5 shrink-0 text-sm border border-blue-600 text-blue-600 dark:border-blue-400 dark:text-blue-400 rounded-md transition-colors hover:bg-blue-50 dark:hover:bg-blue-900/30 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent">
<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="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z"></path>
</svg>
<span id="feinschliffKiText">Per KI vorschlagen</span>
</button>
</div>
<p id="feinschliffHinweis" class="hidden text-sm mt-3"></p>
<p class="text-xs text-gray-400 dark:text-gray-500 mt-3 mb-5">
Der KI-Vorschlag wertet deine <a href="/vorlagen" class="underline">Basis-Unterlagen</a> aus und
überschreibt beide Felder. Gespeichert wird er erst, wenn du unten auf „Speichern“ klickst.
</p>
<div class="grid gap-4 sm:grid-cols-2">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" for="zusatz_begriffe">Zusätzlich berücksichtigen</label>
<textarea id="zusatz_begriffe" name="zusatz_begriffe" rows="3"
class="w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm"
placeholder="z. B. Teilzeit, Rechenzentrum, Systemhaus"><%= profil.zusatz_begriffe %></textarea>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" for="ausschluesse">Ausschließen</label>
<textarea id="ausschluesse" name="ausschluesse" rows="3"
class="w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm"
placeholder="z. B. Zeitarbeit, Headhunter, Schichtdienst"><%= profil.ausschluesse %></textarea>
</div>
</div>
</section>
<section class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<h3 class="text-base font-semibold text-gray-800 dark:text-white">Zeitplan</h3>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1 mb-5">Wann die automatische Suche laufen soll.</p>
<div class="flex flex-wrap gap-2 mb-5">
<% wochentage.forEach(function (t) { %>
<label class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md border border-gray-300 dark:border-gray-600 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700/40">
<input type="checkbox" name="tage" value="<%= t.nr %>"
<%= profil.zeitplan_tage.indexOf(t.nr) !== -1 ? 'checked' : '' %>
class="rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500">
<span class="text-gray-700 dark:text-gray-300"><%= t.kurz %></span>
</label>
<% }); %>
</div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" for="zeitplan_zeit">Uhrzeit</label>
<input type="time" id="zeitplan_zeit" name="zeitplan_zeit" value="<%= profil.zeitplan_zeit %>"
class="rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm">
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">
Der Lauf startet innerhalb weniger Minuten nach dieser Uhrzeit.
</p>
</section>
<div class="flex items-center justify-end gap-3">
<a href="/jobsuche<%= fremd ? '?user=' + zielUser.id : '' %>"
class="px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md transition-colors">
Verwerfen
</a>
<button type="submit"
class="inline-flex items-center gap-2 px-4 py-2 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors">
<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="M5 13l4 4L19 7"></path>
</svg>
Speichern
</button>
</div>
</form>
<section class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mt-6">
<div class="flex items-start justify-between gap-4 mb-5">
<div>
<h3 class="text-base font-semibold text-gray-800 dark:text-white">Suchläufe</h3>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
Ein Lauf durchsucht das Web und legt neue Treffer als
<a href="/jobangebote" class="text-blue-600 dark:text-blue-400 hover:underline">Jobangebote</a> an.
</p>
</div>
<form action="/jobsuche/start" method="POST" class="shrink-0">
<% if (fremd) { %><input type="hidden" name="ziel_user" value="<%= zielUser.id %>"><% } %>
<button type="submit"
class="inline-flex items-center gap-2 px-4 py-2 text-sm bg-green-600 hover:bg-green-700 text-white rounded-md transition-colors">
<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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg>
Jetzt suchen
</button>
</form>
</div>
<div id="laeufe">
<%- include('partials/suchlaeufe', { laeufe: laeufe }) %>
</div>
</section>
</main>
<%- include('partials/footer') %>
<script>
(function () {
const cy = document.getElementById('currentYear');
if (cy) cy.textContent = new Date().getFullYear();
// While a run is queued or in flight, refresh the list so the user sees it
// progress without reloading. Stops once nothing is pending any more.
const zielUser = <%- fremd ? zielUser.id : 'null' %>;
const url = '/jobsuche/laeufe' + (zielUser ? '?user=' + zielUser : '');
let timer = null;
function offen(laeufe) {
return laeufe.some(function (l) { return l.status === 'angefordert' || l.status === 'laeuft'; });
}
async function poll() {
try {
const res = await fetch(url, { headers: { Accept: 'application/json' } });
if (!res.ok) return;
const laeufe = await res.json();
if (!offen(laeufe)) {
clearInterval(timer);
location.reload(); // fertig -> Ergebnisse serverseitig rendern
}
} catch (e) { /* nächster Tick */ }
}
const initial = <%- JSON.stringify(laeufe.map(function (l) { return { status: l.status }; })) %>;
if (offen(initial)) timer = setInterval(poll, 5000);
})();
// "Per KI vorschlagen": lets the model derive the two guard-rail lists from the
// user's Basis-Unterlagen. Only fills the fields — saving stays a deliberate act.
(function () {
const btn = document.getElementById('feinschliffKi');
if (!btn) return;
const label = document.getElementById('feinschliffKiText');
const hinweis = document.getElementById('feinschliffHinweis');
const zusatz = document.getElementById('zusatz_begriffe');
const ausschluesse = document.getElementById('ausschluesse');
const zielUser = <%- fremd ? zielUser.id : 'null' %>;
const FARBE = {
info: 'text-gray-500 dark:text-gray-400',
ok: 'text-green-700 dark:text-green-400',
fehler: 'text-red-600 dark:text-red-400',
};
function melde(text, art) {
hinweis.textContent = text;
hinweis.className = 'text-sm mt-3 ' + FARBE[art];
}
btn.addEventListener('click', async function () {
btn.disabled = true;
label.textContent = 'KI denkt nach …';
melde('Die KI liest deine Basis-Unterlagen das dauert einen Moment.', 'info');
const modus = document.querySelector('input[name="modus"]:checked');
try {
const res = await fetch('/jobsuche/feinschliff', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
ziel_user: zielUser,
modus: modus ? modus.value : '',
staedte: document.getElementById('staedte').value,
}),
});
const data = await res.json().catch(function () { return {}; });
if (!res.ok) throw new Error(data.error || ('Fehler ' + res.status));
zusatz.value = data.zusatz_begriffe || '';
ausschluesse.value = data.ausschluesse || '';
melde('Vorschlag übernommen. Prüfe ihn und klicke auf „Speichern“.', 'ok');
} catch (e) {
melde(e.message || 'Der KI-Vorschlag ist fehlgeschlagen.', 'fehler');
} finally {
btn.disabled = false;
label.textContent = 'Per KI vorschlagen';
}
});
})();
</script>
</body>
</html>