Files
jobbi-bewerbung/lib/suchprofil.js
T
thomasandClaude Opus 4.8 090228479a Jobsuche: Berufsbezeichnungen steuern die Suche, nicht mehr Faehigkeiten
Der Feinschliff schlug per KI Technologien vor ("Proxmox", "Rechenzentrum")
und verbot Rollenbezeichnungen ausdruecklich - die sollte die Suche selbst
aus dem Lebenslauf ableiten. Das war schief: Welche Anzeigen ueberhaupt
gefunden werden, entscheidet die Suchanfrage, also der Stellentitel.
Technologien gewichten nur, was schon da ist. Damit schlug die KI genau das
vor, was der Such-Agent ohnehin selbst ableitet, waehrend die eine Sache,
die der Nutzer weder sieht noch steuern kann - unter welchen Titeln gesucht
wird -, bei jedem Lauf neu improvisiert wurde. Faellt "Fachinformatiker
Systemintegration" weg, merkt das niemand; es kommt nur wenig zurueck.

- Neues Profilfeld berufsbezeichnungen: die KI schlaegt es vor, der Nutzer
  editiert es, der Such-Agent fragt jeden Titel einzeln ab.
- zusatz_begriffe bleibt, aber als Kontext (Branche, Umfeld, Arbeitszeit),
  nicht als Stellentitel; ausschluesse unveraendert.
- Leeres Feld = altes Verhalten (Titel aus dem Lebenslauf), niemandes
  Profil aendert sich durch das Update.
- Spalte wird fuer bestehende Installationen nachgezogen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 12:27:06 +02:00

291 lines
13 KiB
JavaScript

// Per-user job-search profile ("Suchprofil") + run queue.
//
// Background: the job search used to be two host-side cron scripts with the
// search criteria (seven cities, roles, buzzwords) hard-coded into the prompt —
// one user's profile, baked into the shell script. On a multi-user platform each
// user needs their own criteria, so the *what* now lives here, per user.
//
// Division of labour, forced by the deployment: the app runs in a container that
// has neither `claude` nor `ollama` (both live on the host), so the app cannot
// execute a search itself. Instead:
//
// app (container) -> writes a row into `suchlaeufe` (queued run)
// runner (host) -> picks queued/due runs up, executes the headless search,
// writes status + results back into the same table
//
// The runner talks to the tracker through the REST API using *that user's* API
// key, so an imported Jobangebot lands in the right account and goes through the
// same dedup/blacklist path as everything else.
//
// What the user configures (Städte, Modus, Zusatz-/Ausschlussbegriffe) and what
// is derived: the role titles and technology buzzwords still come from the user's
// own Lebenslauf (basis_dokumente, typ "Lebenslauf") — the profile steers *where*
// and sets guard rails, the CV supplies *what*.
const MODI = ['regional', 'remote', 'beides'];
// Weekdays as ISO numbers (1 = Monday … 7 = Sunday), matching what the UI shows.
const WOCHENTAGE = [
{ nr: 1, kurz: 'Mo' }, { nr: 2, kurz: 'Di' }, { nr: 3, kurz: 'Mi' },
{ nr: 4, kurz: 'Do' }, { nr: 5, kurz: 'Fr' }, { nr: 6, kurz: 'Sa' }, { nr: 7, kurz: 'So' },
];
const DEFAULTS = {
aktiv: 0,
modus: 'regional',
staedte: [],
berufsbezeichnungen: '',
zusatz_begriffe: '',
ausschluesse: 'Zeitarbeit / Arbeitnehmerüberlassung, Personalvermittler / Headhunter',
zeitplan_tage: '1,2,3,4,5',
zeitplan_zeit: '17:00',
};
// Cap the city list: each city multiplies the number of web queries a run makes,
// and an unbounded list would turn one run into an all-night job.
const MAX_STAEDTE = 12;
function parseStaedte(raw) {
if (Array.isArray(raw)) return normalizeStaedte(raw);
if (typeof raw !== 'string' || !raw.trim()) return [];
// Accept both the stored JSON array and free text from the form ("A, B\nC").
if (raw.trim().startsWith('[')) {
try { return normalizeStaedte(JSON.parse(raw)); } catch (e) { /* fall through */ }
}
return normalizeStaedte(raw.split(/[,;\n]/));
}
function normalizeStaedte(list) {
const out = [];
for (const s of list) {
const v = String(s == null ? '' : s).trim().replace(/\s+/g, ' ');
if (!v) continue;
if (out.some((x) => x.toLowerCase() === v.toLowerCase())) continue; // case-insensitive dedup
out.push(v);
if (out.length >= MAX_STAEDTE) break;
}
return out;
}
function parseTage(raw) {
const list = String(raw || '').split(',')
.map((n) => parseInt(n, 10))
.filter((n) => Number.isInteger(n) && n >= 1 && n <= 7);
return [...new Set(list)].sort((a, b) => a - b);
}
// "HH:MM", clamped to a valid time; anything unparseable falls back to the default.
function parseZeit(raw) {
const m = /^(\d{1,2}):(\d{2})$/.exec(String(raw || '').trim());
if (!m) return DEFAULTS.zeitplan_zeit;
const h = Math.min(23, Math.max(0, parseInt(m[1], 10)));
const min = Math.min(59, Math.max(0, parseInt(m[2], 10)));
return `${String(h).padStart(2, '0')}:${String(min).padStart(2, '0')}`;
}
// A DB row (or nothing) -> the profile object the app and the runner work with.
// A user without a row is a valid state: they simply have no search configured.
function fromRow(row) {
const r = row || {};
const modus = MODI.includes(r.modus) ? r.modus : DEFAULTS.modus;
return {
aktiv: r.aktiv ? 1 : 0,
modus,
staedte: parseStaedte(r.staedte),
berufsbezeichnungen: r.berufsbezeichnungen || '',
zusatz_begriffe: r.zusatz_begriffe || '',
ausschluesse: r.ausschluesse != null ? r.ausschluesse : DEFAULTS.ausschluesse,
zeitplan_tage: parseTage(r.zeitplan_tage != null ? r.zeitplan_tage : DEFAULTS.zeitplan_tage),
zeitplan_zeit: parseZeit(r.zeitplan_zeit != null ? r.zeitplan_zeit : DEFAULTS.zeitplan_zeit),
};
}
// Form body -> storable values. `sanitize` is the app's XSS sanitizer, injected so
// this module stays free of app dependencies.
function fromForm(body, sanitize = (v) => v) {
const b = body || {};
const modus = MODI.includes(b.modus) ? b.modus : DEFAULTS.modus;
const tage = Array.isArray(b.tage) ? b.tage : (b.tage ? [b.tage] : []);
return {
aktiv: (b.aktiv === '1' || b.aktiv === 'on' || b.aktiv === true) ? 1 : 0,
modus,
staedte: parseStaedte(b.staedte).map((s) => sanitize(s)),
berufsbezeichnungen: sanitize(String(b.berufsbezeichnungen || '').trim()),
zusatz_begriffe: sanitize(String(b.zusatz_begriffe || '').trim()),
ausschluesse: sanitize(String(b.ausschluesse || '').trim()),
zeitplan_tage: parseTage(tage.join(',')),
zeitplan_zeit: parseZeit(b.zeitplan_zeit),
};
}
// Values as they go into the DB (arrays serialized).
function toRow(profil) {
return {
aktiv: profil.aktiv ? 1 : 0,
modus: profil.modus,
staedte: JSON.stringify(profil.staedte || []),
berufsbezeichnungen: profil.berufsbezeichnungen || '',
zusatz_begriffe: profil.zusatz_begriffe || '',
ausschluesse: profil.ausschluesse || '',
zeitplan_tage: (profil.zeitplan_tage || []).join(','),
zeitplan_zeit: profil.zeitplan_zeit || DEFAULTS.zeitplan_zeit,
};
}
// Is the profile usable for a run? A regional search without a single city would
// otherwise send the model off to search "everywhere", which is exactly the
// unbounded run we want to avoid.
function validate(profil) {
const fehler = [];
if (!MODI.includes(profil.modus)) fehler.push('Ungültiger Modus.');
if (profil.modus !== 'remote' && !(profil.staedte || []).length) {
fehler.push('Für die regionale Suche muss mindestens eine Stadt angegeben sein.');
}
if (profil.aktiv && !(profil.zeitplan_tage || []).length) {
fehler.push('Für den Zeitplan muss mindestens ein Wochentag gewählt sein.');
}
return fehler;
}
// Is a scheduled run due? `letzterLauf` is the ISO timestamp of the last run that
// the schedule triggered (manual runs do not count, so a manual test does not
// swallow the day's scheduled run). Due means: today is one of the chosen days,
// the configured time has passed, and no scheduled run has happened since that
// time today.
function istFaellig(profil, jetzt, letzterLauf) {
if (!profil.aktiv) return false;
const tage = profil.zeitplan_tage || [];
if (!tage.length) return false;
const iso = jetzt.getDay() === 0 ? 7 : jetzt.getDay(); // JS: 0=So -> ISO 7
if (!tage.includes(iso)) return false;
const [h, m] = profil.zeitplan_zeit.split(':').map(Number);
const faelligAb = new Date(jetzt);
faelligAb.setHours(h, m, 0, 0);
if (jetzt < faelligAb) return false; // today's slot not reached yet
if (!letzterLauf) return true;
const letzte = new Date(letzterLauf);
if (Number.isNaN(letzte.getTime())) return true;
return letzte < faelligAb; // already ran for this slot?
}
// Build the instruction the headless search agent gets. Everything user-specific
// is interpolated here, so the skill itself no longer carries anyone's cities.
// `profilText` is the user's CV/profile text (from their Lebenslauf template) —
// the roles and technologies to search for are derived from it, not configured.
function buildPrompt(profil, profilText) {
const teile = [];
teile.push(
'Führe eine IT-/Fach-Stellensuche vollständig autonom im Hintergrund aus, ohne jede Rückfrage. ' +
'Nutze dafür den Stellensuche-Skill (Quellen-Politik, Dedup, Blacklist, Volltext-Beschreibung).'
);
if (profil.modus === 'regional') {
teile.push(
`Suche AUSSCHLIESSLICH Stellen mit Arbeitsort in diesen Städten: ${profil.staedte.join(', ')} ` +
`(Priorität in dieser Reihenfolge, die erste ist der Wohnort). Stellen außerhalb dieser Städte ` +
`verwerfen — auch das übrige Umland und reines Homeoffice/Remote ohne Sitz in einer dieser Städte. ` +
`Sitzt der Arbeitgeber in einer dieser Städte und bietet zusätzlich Homeoffice, ist die Stelle zulässig. ` +
`Setze IMMER labels=["Regional"].`
);
} else if (profil.modus === 'remote') {
teile.push(
'Suche AUSSCHLIESSLICH Stellen, die 100 % REMOTE / vollständig im Homeoffice und deutschlandweit ' +
'ortsunabhängig ausübbar sind (Anstellung in Deutschland). Firmensitz egal. Verwirf Präsenz-, Hybrid- ' +
'und ortsgebundene Stellen sowie vages „Homeoffice möglich" ohne klare 100-%-Remote-Zusage; bestätige ' +
'die Remote-Regelung live im Anzeigentext per WebFetch. Setze IMMER labels=["Remote"].'
);
} else {
teile.push(
`Suche in ZWEI Durchgängen: (1) regional mit Arbeitsort ausschließlich in ${profil.staedte.join(', ')} ` +
`(Priorität in dieser Reihenfolge) — diese Treffer mit labels=["Regional"]; (2) deutschlandweit Stellen, ` +
`die 100 % REMOTE / vollständig im Homeoffice ausübbar sind (live per WebFetch bestätigen) — diese ` +
`Treffer mit labels=["Remote"]. Alles, was in keinen der beiden Durchgänge fällt, verwerfen.`
);
}
// Die Berufsbezeichnungen sind die eigentlichen Suchanfragen — sie entscheiden,
// welche Anzeigen überhaupt gefunden werden. Sind sie gesetzt, sucht der Agent
// danach, statt sich die Titel bei jedem Lauf neu aus dem Lebenslauf zu
// improvisieren (was der Nutzer weder sieht noch steuern kann). Das Profil
// liefert dann nur noch die Technologien zur Gewichtung.
if (profil.berufsbezeichnungen) {
teile.push(
`Suche gezielt nach Stellen mit diesen Berufsbezeichnungen (das sind deine Suchanfragen, jede einzeln ` +
`abfragen): ${profil.berufsbezeichnungen}. Nahe Schreibweisen und gängige Varianten desselben Titels ` +
`(m/w/d, "IT-" davor, Singular/Plural, englische Entsprechung) darfst du ergänzen; auf fachfremde Rollen ` +
`ausweichen darfst du NICHT.`
);
}
if (profilText && profilText.trim()) {
teile.push(
(profil.berufsbezeichnungen
? 'Technologie-Buzzwords und die Gewichtung der Treffer leitest du AUS DEM FOLGENDEN PROFIL DES ' +
'BEWERBERS ab (die Suchtitel stehen oben und gelten) — gewichte Treffer höher, je mehr aus dem ' +
'Profil passt:'
: 'Leite Rollenbezeichnungen (Query-Varianten) und Technologie-Buzzwords AUS DEM FOLGENDEN PROFIL DES ' +
'BEWERBERS ab — fasse die Rolle breit (dieselbe Tätigkeit läuft je nach Firma unter vielen Titeln) und ' +
'gewichte Treffer höher, je mehr aus dem Profil passt:') +
'\n\n--- PROFIL ---\n' +
profilText.trim().slice(0, 6000) +
'\n--- ENDE PROFIL ---'
);
} else if (profil.berufsbezeichnungen) {
teile.push(
'Es ist kein Lebenslauf/Profil hinterlegt — halte dich strikt an die oben genannten Berufsbezeichnungen.'
);
} else {
teile.push(
'ACHTUNG: Es ist kein Lebenslauf/Profil hinterlegt. Leite Rollen und Suchbegriffe aus den bereits ' +
'erfassten Bewerbungen des Benutzers ab (GET /applications) und halte die Suche eng an deren Stellentiteln.'
);
}
if (profil.zusatz_begriffe) {
teile.push(`Zusätzlich ausdrücklich berücksichtigen: ${profil.zusatz_begriffe}.`);
}
if (profil.ausschluesse) {
teile.push(`Ausschlusskriterien — solche Treffer verwerfen: ${profil.ausschluesse}.`);
}
teile.push(
'Fokussiere auf Original-Stellenanzeigen echter Arbeitgeber, bevorzugt direkt auf deren Karriereseite ' +
'(Firmen recherchieren, Karriereseite per WebFetch prüfen) — nicht nur breit ausgeschriebene Board-Anzeigen.'
);
teile.push(
'Pflege JEDEN neuen, live per WebFetch verifizierten Treffer sofort selbst als Jobangebot ein ' +
'(POST /joboffers, external_id DETERMINISTISCH aus firma+stelle mit Präfix "stellensuche-", NICHT aus einer ' +
'flüchtigen Board-ID, damit der Server-Upsert statt einer Dublette greift; art=Firmenwebsite wo möglich; die ' +
'VOLLSTÄNDIGE Stellenausschreibung als beschreibung; kontakt_email nur wenn belegt). Beachte die Dedup gegen ' +
'bereits erfasste Bewerbungen/Jobangebote und die Blacklist. Sperre jeden echten Neuimport (action:created) ' +
'danach sofort per POST /joboffers/blacklist (typ=firma_stelle, ort falls bekannt); bei 409 oder bloßem ' +
'updated NICHT blacklisten und NICHT mit force erzwingen.'
);
teile.push(
'Stelle am Ende KEINE Frage. Gib als LETZTE Zeile exakt diese Bilanz aus (nur Zahlen):\n' +
'ERGEBNIS neu=<X> dubletten=<Y> verworfen=<Z>'
);
return teile.join('\n\n');
}
// The runner reports back "ERGEBNIS neu=1 dubletten=2 verworfen=3"; pull the
// numbers out of whatever the agent printed. Missing counts read as 0.
function parseErgebnis(text) {
const m = /ERGEBNIS\s+neu=(\d+)\s+dubletten=(\d+)\s+verworfen=(\d+)/i.exec(String(text || ''));
if (!m) return null;
return { neu: Number(m[1]), dubletten: Number(m[2]), verworfen: Number(m[3]) };
}
module.exports = {
MODI, WOCHENTAGE, DEFAULTS, MAX_STAEDTE,
fromRow, fromForm, toRow, validate, istFaellig, buildPrompt, parseErgebnis,
parseStaedte, parseTage, parseZeit,
};