Jobsuche als Feature der App: Suchprofil pro Benutzer statt Cron-Prompt

Die Jobsuche lag in zwei Host-Skripten (jobsuche-cron.sh, jobsuche-remote-cron.sh)
aus der Ein-Benutzer-Zeit: Die Suchkriterien (sieben Staedte, Rollen, Buzzwords)
standen fest im Prompt, und importiert wurde mit EINEM globalen API-Token aus der
.env - der zufaellig dem Admin gehoerte. Auf der Multi-User-Plattform ist beides
hinfaellig.

Jetzt legt jeder Benutzer sein Suchprofil selbst fest (/jobsuche):
- Modus: regional / 100 % Remote / beides
- Staedte (die erste gilt als Wohnort und wird hoechstpriorisiert, max. 12)
- Feinschliff: zusaetzliche Begriffe, Ausschluesse
- Zeitplan: Wochentage + Uhrzeit, plus Button "Jetzt suchen"
Rollen und Technologien bleiben abgeleitet - aus dem Lebenslauf des Benutzers
(basis_dokumente), nicht aus einer gepflegten Liste. Admins koennen Profil und
Zeitplan eines Benutzers ueber /jobsuche?user=<id> mitpflegen (Link im Admin-Panel).

Aufteilung App/Host: Der Container hat weder claude noch ollama. Die App reiht
Laeufe daher nur in die Warteschlange ein (Tabelle suchlaeufe); der neue Runner
auf dem Host (scripts/jobsuche-runner.js, Cron alle 5 Min) arbeitet sie ab, baut
den Prompt je Benutzer aus dessen Profil + Lebenslauf und laeuft mit DESSEN
Zugangsdaten:
- BEWERBUNG_API_KEY = eigener API-Token des Benutzers (wird beim ersten Lauf
  automatisch erzeugt), damit Treffer im richtigen Konto landen und durch dieselbe
  Dedup-/Blacklist-Logik gehen,
- ANTHROPIC_BASE_URL/AUTH_TOKEN = eigener Ollama-Key des Benutzers ueber die
  Anthropic-kompatible Schnittstelle von Ollama Cloud (https://ollama.com/v1/messages,
  verifiziert: gueltiger Key -> 200, ungueltiger -> 401). Damit zahlt jeder seine
  eigene Suche, statt alles ueber die Host-Subscription zu buchen
  (JOBSUCHE_KI_AUTH=host stellt das alte Verhalten wieder her).

Ohne eigenen Ollama-Key oder ohne Lebenslauf bricht der Lauf mit klarer Meldung ab
statt still nichts zu tun; die Oberflaeche warnt vorab. Kein Stapeln: solange ein
Lauf offen ist, erzeugt ein weiterer Klick keinen zweiten. Verwaiste Laeufe
(Prozess weg) werden nach Zeitlimit als Fehler freigegeben.

Verifiziert mit zwei Benutzern: Zugriffsschutz (fremdes Profil -> 403), Speichern,
Warteschlange, automatische Key-Erzeugung, Prompt-Aufbau aus Profil + CV,
Ergebnis-Ruecklauf in die Oberflaeche, Fehlerpfade und die Faelligkeitslogik des
Zeitplans (Tag/Uhrzeit/bereits gelaufen).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-13 23:28:56 +02:00
co-authored by Claude Opus 4.8
parent 1e14738258
commit 9418750061
8 changed files with 1141 additions and 0 deletions
+327
View File
@@ -0,0 +1,327 @@
#!/usr/bin/env node
// Host-side runner for the per-user job search.
//
// Why it lives on the host and not in the app: the app container has neither
// `claude` nor `ollama`. The app therefore only *queues* runs (table suchlaeufe);
// this script — started by cron every few minutes — picks them up and executes
// them. It replaces the two old single-user scripts (jobsuche-cron.sh and
// jobsuche-remote-cron.sh), whose search criteria were hard-coded into the prompt.
//
// Per run it:
// 1. enqueues scheduled runs that are due (per user's Zeitplan),
// 2. claims one queued run at a time (status angefordert -> laeuft),
// 3. builds the prompt from that user's Suchprofil + their Lebenslauf,
// 4. runs the headless agent with THAT user's credentials:
// - BEWERBUNG_API_KEY = the user's API token (created if missing), so every
// imported Jobangebot lands in their account and passes the same
// dedup/blacklist path as the rest of the app,
// - the model is driven by the user's own Ollama key (see KI_AUTH below),
// 5. parses the agent's final "ERGEBNIS neu=… dubletten=… verworfen=…" line and
// writes status + counts back, so the UI can show what happened.
//
// Everything is per user; nothing here reads a global .env any more.
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { spawn } = require('child_process');
const sqlite3 = require('sqlite3');
const suchprofil = require('../lib/suchprofil');
const DB_PATH = process.env.JOBSUCHE_DB || '/opt/jobbi-bewerbung/data/bewerbungen.db';
const LOG_DIR = process.env.JOBSUCHE_LOG_DIR || '/opt/jobbi-bewerbung/logs';
const API_URL = process.env.JOBSUCHE_API_URL || 'http://localhost:4327/api/v1';
// Swappable so the pipeline can be exercised end-to-end without burning tokens.
const CLAUDE_BIN = process.env.JOBSUCHE_CLAUDE_BIN || 'claude';
// 'benutzer' = each run uses that user's own Ollama key (they pay for their own
// search). 'host' = fall back to the machine's ollama login via `ollama launch`.
const KI_AUTH = process.env.JOBSUCHE_KI_AUTH || 'benutzer';
const RUN_TIMEOUT_MS = (Number(process.env.JOBSUCHE_TIMEOUT) || 2700) * 1000;
// A run that has been 'laeuft' for longer than this lost its process (reboot,
// OOM); it would otherwise block the user's queue forever.
const STALE_MS = RUN_TIMEOUT_MS + 10 * 60 * 1000;
const LOG_RETENTION_DAYS = Number(process.env.JOBSUCHE_LOG_RETENTION_DAYS) || 30;
const CFG = 'cfg:';
const db = new sqlite3.Database(DB_PATH);
const all = (sql, p = []) => new Promise((res, rej) => db.all(sql, p, (e, r) => (e ? rej(e) : res(r))));
const get = (sql, p = []) => new Promise((res, rej) => db.get(sql, p, (e, r) => (e ? rej(e) : res(r))));
const run = (sql, p = []) => new Promise((res, rej) => db.run(sql, p, function (e) { e ? rej(e) : res(this); }));
function log(msg) {
console.log(`${new Date().toISOString()} ${msg}`);
}
async function cfgWert(userId, key) {
const row = await get('SELECT value FROM app_state WHERE user_id = ? AND key = ?', [userId, CFG + key]);
return row && row.value ? row.value : '';
}
// The user's API token, created on first use. Without it the agent could not
// import anything; making the user generate one by hand first would just be a
// trap ("profile active, nothing happens").
async function ensureApiKey(userId) {
const vorhanden = await cfgWert(userId, 'API_TOKEN');
if (vorhanden) return vorhanden;
const token = crypto.randomBytes(32).toString('hex');
await run(
'INSERT INTO app_state (user_id, key, value) VALUES (?, ?, ?) ' +
'ON CONFLICT(user_id, key) DO UPDATE SET value = excluded.value',
[userId, CFG + 'API_TOKEN', token]
);
log(` API-Key für Benutzer ${userId} automatisch erzeugt.`);
return token;
}
// The CV text the search profile is derived from. Prefer the Lebenslauf, fall
// back to a Profil/Kurzprofil — the point is to give the model the user's actual
// roles and technologies rather than a hard-coded list.
async function profilText(userId) {
const rows = await all(
`SELECT typ, inhalt FROM basis_dokumente
WHERE user_id = ? AND typ IN ('Lebenslauf', 'Profil/Kurzprofil')
AND inhalt IS NOT NULL AND inhalt != ''
ORDER BY CASE typ WHEN 'Lebenslauf' THEN 0 ELSE 1 END, id ASC`,
[userId]
);
return rows.map((r) => (r.inhalt || '').trim()).filter(Boolean).join('\n\n---\n\n');
}
// 1. Queue scheduled runs that are due.
async function faelligeEinreihen() {
const jetzt = new Date();
const users = await all('SELECT user_id FROM suchprofil WHERE aktiv = 1');
for (const { user_id: userId } of users) {
const profil = suchprofil.fromRow(await get('SELECT * FROM suchprofil WHERE user_id = ?', [userId]));
if (suchprofil.validate(profil).length) continue; // unbrauchbares Profil -> kein Zeitplan-Lauf
// Only scheduled runs count here: a manual test run must not swallow the
// day's automatic run.
const letzter = await get(
"SELECT angefordert_at FROM suchlaeufe WHERE user_id = ? AND ausloeser = 'zeitplan' ORDER BY id DESC LIMIT 1",
[userId]
);
const letzterAt = letzter ? `${letzter.angefordert_at}Z`.replace(' ', 'T') : null;
if (!suchprofil.istFaellig(profil, jetzt, letzterAt)) continue;
const offen = await get(
"SELECT id FROM suchlaeufe WHERE user_id = ? AND status IN ('angefordert','laeuft') LIMIT 1",
[userId]
);
if (offen) continue; // läuft schon etwas für diesen Benutzer
await run("INSERT INTO suchlaeufe (user_id, status, ausloeser) VALUES (?, 'angefordert', 'zeitplan')", [userId]);
log(`Zeitplan: Lauf für Benutzer ${userId} eingereiht.`);
}
}
// Runs whose process died: release them so the user is not stuck.
async function verwaisteAufraeumen() {
const res = await run(
`UPDATE suchlaeufe SET status = 'fehler', beendet_at = CURRENT_TIMESTAMP,
fehler = 'Lauf abgebrochen (Prozess nicht mehr vorhanden).'
WHERE status = 'laeuft'
AND gestartet_at IS NOT NULL
AND (julianday('now') - julianday(gestartet_at)) * 86400000 > ?`,
[STALE_MS]
);
if (res.changes) log(`${res.changes} verwaiste(n) Lauf/Läufe als Fehler markiert.`);
}
// Claim the oldest queued run. The conditional UPDATE is the claim: a second
// runner starting concurrently gets changes = 0 and moves on.
async function naechstenLaufClaimen() {
const kandidat = await get("SELECT * FROM suchlaeufe WHERE status = 'angefordert' ORDER BY id ASC LIMIT 1");
if (!kandidat) return null;
const res = await run(
"UPDATE suchlaeufe SET status = 'laeuft', gestartet_at = CURRENT_TIMESTAMP WHERE id = ? AND status = 'angefordert'",
[kandidat.id]
);
if (!res.changes) return null;
return get('SELECT * FROM suchlaeufe WHERE id = ?', [kandidat.id]);
}
async function laufAbschliessen(id, status, felder = {}) {
await run(
`UPDATE suchlaeufe SET status = ?, beendet_at = CURRENT_TIMESTAMP,
neu = ?, dubletten = ?, verworfen = ?, fehler = ?, log_datei = ?
WHERE id = ?`,
[status, felder.neu ?? null, felder.dubletten ?? null, felder.verworfen ?? null,
felder.fehler || null, felder.log || null, id]
);
}
// Spawn the headless agent and collect its output.
function agentStarten(prompt, env, logStream) {
return new Promise((resolve) => {
let argv;
if (KI_AUTH === 'host') {
// Legacy path: the machine's ollama login pays for the run.
argv = ['launch', 'claude', '--model', env.JOBSUCHE_MODEL, '--',
'--dangerously-skip-permissions', '-p', prompt];
argv = { cmd: 'ollama', args: argv };
} else {
// Per-user path: Claude Code talks to Ollama's Anthropic-compatible API with
// *this user's* key, so the search is billed to whoever requested it.
argv = { cmd: CLAUDE_BIN, args: ['--dangerously-skip-permissions', '-p', prompt] };
}
const kind = spawn(argv.cmd, argv.args, {
env,
cwd: '/opt/jobbi-bewerbung',
stdio: ['ignore', 'pipe', 'pipe'],
});
let ausgabe = '';
const sammeln = (buf) => {
const s = buf.toString();
ausgabe += s;
logStream.write(s);
};
kind.stdout.on('data', sammeln);
kind.stderr.on('data', sammeln);
const timer = setTimeout(() => {
kind.kill('SIGTERM');
setTimeout(() => kind.kill('SIGKILL'), 60000);
}, RUN_TIMEOUT_MS);
kind.on('error', (err) => {
clearTimeout(timer);
resolve({ code: -1, ausgabe, fehler: `Agent nicht startbar: ${err.message}` });
});
kind.on('close', (code, signal) => {
clearTimeout(timer);
const abgebrochen = signal === 'SIGTERM' || signal === 'SIGKILL';
resolve({
code,
ausgabe,
fehler: abgebrochen ? `Zeitlimit von ${RUN_TIMEOUT_MS / 1000}s überschritten.` : null,
});
});
});
}
async function laufAusfuehren(lauf) {
const userId = lauf.user_id;
const user = await get('SELECT username FROM users WHERE id = ?', [userId]);
const name = user ? user.username : `#${userId}`;
log(`Lauf ${lauf.id} (Benutzer ${name}, ${lauf.ausloeser}) startet.`);
const profil = suchprofil.fromRow(await get('SELECT * FROM suchprofil WHERE user_id = ?', [userId]));
const fehler = suchprofil.validate(profil);
if (fehler.length) {
await laufAbschliessen(lauf.id, 'fehler', { fehler: fehler.join(' ') });
log(` abgebrochen: ${fehler.join(' ')}`);
return;
}
const ollamaKey = await cfgWert(userId, 'OLLAMA_API_KEY');
if (KI_AUTH !== 'host' && !ollamaKey) {
const msg = 'Kein eigener Ollama-API-Schlüssel hinterlegt — der Suchlauf läuft über das KI-Kontingent des Benutzers. Bitte in den Einstellungen eintragen.';
await laufAbschliessen(lauf.id, 'fehler', { fehler: msg });
log(` abgebrochen: kein Ollama-Key für ${name}.`);
return;
}
const apiKey = await ensureApiKey(userId);
const modell = (await cfgWert(userId, 'OLLAMA_MODEL')) || 'glm-5.2:cloud';
const prompt = suchprofil.buildPrompt(profil, await profilText(userId));
fs.mkdirSync(LOG_DIR, { recursive: true });
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
const logDatei = path.join(LOG_DIR, `jobsuche_${name}_${stamp}.log`);
const logStream = fs.createWriteStream(logDatei, { flags: 'a' });
logStream.write(`=== Suchlauf ${lauf.id} — Benutzer ${name} (${lauf.ausloeser}) ===\n`);
logStream.write(`Modus: ${profil.modus} | Städte: ${profil.staedte.join(', ') || ''}\n`);
logStream.write(`Modell: ${modell} | KI-Auth: ${KI_AUTH}\n${'-'.repeat(50)}\n`);
const env = {
...process.env,
HOME: process.env.HOME || '/root',
PATH: `/root/.local/bin:/usr/local/bin:/usr/bin:/bin:${process.env.PATH || ''}`,
IS_SANDBOX: '1',
// The tracker API — the agent imports Jobangebote as THIS user.
BEWERBUNG_API_URL: API_URL,
BEWERBUNG_API_KEY: apiKey,
JOBSUCHE_MODEL: modell,
};
if (KI_AUTH !== 'host') {
// Point Claude Code at Ollama's Anthropic-compatible endpoint with the user's key.
env.ANTHROPIC_BASE_URL = (await cfgWert(userId, 'OLLAMA_HOST')) || 'https://ollama.com';
env.ANTHROPIC_AUTH_TOKEN = ollamaKey;
env.ANTHROPIC_MODEL = modell;
env.ANTHROPIC_SMALL_FAST_MODEL = modell;
}
const { code, ausgabe, fehler: laufFehler } = await agentStarten(prompt, env, logStream);
const ergebnis = suchprofil.parseErgebnis(ausgabe);
logStream.write(`\n${'-'.repeat(50)}\nExit-Code: ${code}\n`);
logStream.end();
if (laufFehler) {
await laufAbschliessen(lauf.id, 'fehler', { fehler: laufFehler, log: logDatei });
log(` Lauf ${lauf.id} fehlgeschlagen: ${laufFehler}`);
return;
}
if (code !== 0 && !ergebnis) {
await laufAbschliessen(lauf.id, 'fehler', {
fehler: `Suchlauf mit Exit-Code ${code} beendet (Details im Log).`,
log: logDatei,
});
log(` Lauf ${lauf.id} fehlgeschlagen (Exit ${code}).`);
return;
}
if (!ergebnis) {
// The agent finished but did not report its tally — treat as done with unknown
// numbers rather than as a failure; the import itself already happened.
await laufAbschliessen(lauf.id, 'fertig', { neu: 0, dubletten: 0, verworfen: 0, log: logDatei });
log(` Lauf ${lauf.id} beendet, aber ohne ERGEBNIS-Zeile.`);
return;
}
await laufAbschliessen(lauf.id, 'fertig', { ...ergebnis, log: logDatei });
log(` Lauf ${lauf.id} fertig: ${ergebnis.neu} neu, ${ergebnis.dubletten} Dubletten, ${ergebnis.verworfen} verworfen.`);
}
function alteLogsAufraeumen() {
try {
const grenze = Date.now() - LOG_RETENTION_DAYS * 86400000;
for (const f of fs.readdirSync(LOG_DIR)) {
if (!f.startsWith('jobsuche_') || !f.endsWith('.log')) continue;
const p = path.join(LOG_DIR, f);
if (fs.statSync(p).mtimeMs < grenze) fs.unlinkSync(p);
}
} catch (e) { /* Aufräumen ist best effort */ }
}
async function main() {
if (!fs.existsSync(DB_PATH)) {
console.error(`Datenbank nicht gefunden: ${DB_PATH}`);
process.exit(1);
}
await run('PRAGMA busy_timeout = 10000'); // die App schreibt parallel
await verwaisteAufraeumen();
await faelligeEinreihen();
// Work the queue until it is empty. Runs are serialised on purpose: a search is
// web- and token-heavy, and several in parallel would trip rate limits.
let lauf;
let anzahl = 0;
while ((lauf = await naechstenLaufClaimen())) {
await laufAusfuehren(lauf);
anzahl += 1;
}
if (!anzahl) log('Nichts zu tun.');
alteLogsAufraeumen();
db.close();
}
main().catch((e) => {
console.error('Runner-Fehler:', e);
process.exit(1);
});
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/env bash
# Cron-Einstiegspunkt für die Jobsuche (Multi-User).
#
# Ersetzt jobsuche-cron.sh + jobsuche-remote-cron.sh: Diese beiden hatten das
# Suchprofil (sieben Städte, Rollen, Buzzwords) fest im Prompt stehen und liefen
# mit EINEM globalen API-Token aus der .env — beides ist mit der Multi-User-
# Plattform hinfällig.
#
# Jetzt: Jeder Benutzer pflegt sein Suchprofil und seinen Zeitplan in der App
# (/jobsuche). Dieses Skript ruft nur noch den Runner auf, der fällige und manuell
# angeforderte Läufe abarbeitet — je Benutzer mit dessen eigenem Ollama- und
# API-Schlüssel.
#
# Cron (alle 5 Minuten — die Uhrzeit steuert der Benutzer, nicht der Cron):
# */5 * * * * /opt/jobbi-bewerbung/bin/jobsuche-runner.sh
#
# Manueller Lauf: /opt/jobbi-bewerbung/bin/jobsuche-runner.sh
set -uo pipefail
export HOME="${HOME:-/root}"
export PATH="/root/.local/bin:/usr/local/bin:/usr/bin:/bin:$PATH"
# Claude Code verweigert --dangerously-skip-permissions als root; in dieser
# Container-/Sandbox-Umgebung mit IS_SANDBOX=1 erlaubt.
export IS_SANDBOX=1
PROJECT_DIR="/opt/jobbi-bewerbung"
LOG_DIR="$PROJECT_DIR/logs"
RUNNER="$PROJECT_DIR/source/scripts/jobsuche-runner.js"
mkdir -p "$LOG_DIR"
LOG="$LOG_DIR/jobsuche-runner.log"
# Einzelinstanz: ein noch laufender Suchlauf darf vom nächsten Cron-Tick nicht
# ein zweites Mal gestartet werden.
exec 9>"$LOG_DIR/jobsuche-runner.lock"
if ! flock -n 9; then
exit 0
fi
cd "$PROJECT_DIR" || exit 1
# API-Erreichbarkeit prüfen — ohne den Tracker kann der Agent nichts importieren.
if ! curl -fsS --max-time 10 http://localhost:4327/api/v1/health >/dev/null 2>&1; then
echo "$(date -Is) FEHLER: Bewerbungs-Tracker (localhost:4327) nicht erreichbar Lauf übersprungen." >> "$LOG"
exit 1
fi
node "$RUNNER" >> "$LOG" 2>&1
STATUS=$?
# Log der Runner-Steuerung kurz halten (die eigentlichen Suchlauf-Logs liegen
# je Lauf separat und werden vom Runner selbst rotiert).
if [ -f "$LOG" ] && [ "$(wc -l < "$LOG")" -gt 5000 ]; then
tail -n 2000 "$LOG" > "$LOG.tmp" && mv "$LOG.tmp" "$LOG"
fi
exit $STATUS