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>
This commit is contained in:
2026-07-14 12:27:06 +02:00
co-authored by Claude Opus 4.8
parent 67b8e5735f
commit 090228479a
5 changed files with 86 additions and 25 deletions
+13 -5
View File
@@ -2103,10 +2103,11 @@ async function generateEmailReply({
const FEINSCHLIFF_SCHEMA = { const FEINSCHLIFF_SCHEMA = {
type: 'object', type: 'object',
properties: { properties: {
berufsbezeichnungen: { type: 'array', items: { type: 'string' } },
zusatz_begriffe: { type: 'array', items: { type: 'string' } }, zusatz_begriffe: { type: 'array', items: { type: 'string' } },
ausschluesse: { type: 'array', items: { type: 'string' } }, ausschluesse: { type: 'array', items: { type: 'string' } },
}, },
required: ['zusatz_begriffe', 'ausschluesse'], required: ['berufsbezeichnungen', 'zusatz_begriffe', 'ausschluesse'],
}; };
// How many terms per field we keep. The two lists end up verbatim in the search // How many terms per field we keep. The two lists end up verbatim in the search
@@ -2139,10 +2140,16 @@ async function generateFeinschliff({ unterlagen, profil = {}, prompts = null })
`Suchmodus: ${modusText}\n` + `Suchmodus: ${modusText}\n` +
(staedte ? `Städte: ${staedte}\n` : '') + (staedte ? `Städte: ${staedte}\n` : '') +
`\n# Aufgabe\n` + `\n# Aufgabe\n` +
`Schlage die beiden Leitplanken der Stellensuche vor:\n` + `Schlage die drei Listen der Stellensuche vor:\n` +
`- "zusatz_begriffe": Begriffe, die die Suche zusätzlich berücksichtigen soll - Technologien, ` + `- "berufsbezeichnungen": die Stellentitel, unter denen der Bewerber suchen sollte - das sind die ` +
`Branchen, Arbeitsumfelder oder Arbeitszeitmodelle, die aus den Unterlagen belegbar sind ` + `Suchanfragen und damit das Wichtigste. Nimm die Titel, unter denen SEINE Tätigkeit real ` +
`(z. B. "Rechenzentrum", "Systemhaus"). KEINE Rollen-/Stellenbezeichnungen.\n` + `ausgeschrieben wird, und decke die üblichen Varianten derselben Rolle ab (z. B. ` +
`"Systemadministrator", "IT-Administrator", "Fachinformatiker Systemintegration"). Bleib bei dem, ` +
`was sein Erfahrungsniveau hergibt - keine Titel, für die er erkennbar zu wenig oder zu viel ` +
`Erfahrung hat, und keine reinen Technologie-Wörter.\n` +
`- "zusatz_begriffe": KEINE Stellentitel, sondern Kontext, der einen Treffer zusätzlich attraktiv ` +
`macht und aus den Unterlagen belegbar ist - Branchen, Arbeitsumfelder, Arbeitszeitmodelle ` +
`(z. B. "Rechenzentrum", "Systemhaus", "Teilzeit").\n` +
`- "ausschluesse": Kriterien, bei denen ein Treffer verworfen werden soll (z. B. Anstellungsformen ` + `- "ausschluesse": Kriterien, bei denen ein Treffer verworfen werden soll (z. B. Anstellungsformen ` +
`oder Rollen, die erkennbar nicht zum Profil passen).\n` + `oder Rollen, die erkennbar nicht zum Profil passen).\n` +
`Höchstens ${FEINSCHLIFF_MAX} Einträge je Liste, nach Wichtigkeit sortiert. Jeder Eintrag ist ein ` + `Höchstens ${FEINSCHLIFF_MAX} Einträge je Liste, nach Wichtigkeit sortiert. Jeder Eintrag ist ein ` +
@@ -2170,6 +2177,7 @@ async function generateFeinschliff({ unterlagen, profil = {}, prompts = null })
}; };
return { return {
berufsbezeichnungen: liste(['berufsbezeichnungen', 'berufsbezeichnung', 'stellentitel', 'rollen', 'jobtitel', 'titel']),
zusatz_begriffe: liste(['zusatz_begriffe', 'zusatzbegriffe', 'zusatz', 'begriffe', 'einschluesse']), zusatz_begriffe: liste(['zusatz_begriffe', 'zusatzbegriffe', 'zusatz', 'begriffe', 'einschluesse']),
ausschluesse: liste(['ausschluesse', 'ausschlüsse', 'ausschlusskriterien', 'exclude', 'ausschluss']), ausschluesse: liste(['ausschluesse', 'ausschlüsse', 'ausschlusskriterien', 'exclude', 'ausschluss']),
}; };
+12 -10
View File
@@ -92,20 +92,22 @@ const DEFAULTS = [
key: 'jobsuche_feinschliff', key: 'jobsuche_feinschliff',
titel: 'Jobsuche: Feinschliff vorschlagen', titel: 'Jobsuche: Feinschliff vorschlagen',
beschreibung: beschreibung:
'Rolle für den KI-Vorschlag der Leitplanken deiner Jobsuche (zusätzlich berücksichtigen / ' + 'Rolle für den KI-Vorschlag deiner Jobsuche (Berufsbezeichnungen / zusätzlich berücksichtigen / ' +
'ausschließen). Grundlage sind deine Basis-Unterlagen oben.', 'ausschließen). Grundlage sind deine Basis-Unterlagen oben.',
platzhalter: [], platzhalter: [],
inhalt: inhalt:
'Du bist ein erfahrener Recruiter und stellst die Leitplanken für die automatische ' + 'Du bist ein erfahrener Recruiter und stellst die Suchkriterien für die automatische ' +
'Stellensuche eines Bewerbers auf. Du liest seine Basis-Unterlagen (Lebenslauf, Kurzprofil, ' + 'Stellensuche eines Bewerbers auf. Du liest seine Basis-Unterlagen (Lebenslauf, Kurzprofil, ' +
'Anschreiben) und leitest daraus ab, welche Zusatzbegriffe eine Stellensuche treffsicherer ' + 'Anschreiben). Das Wichtigste sind die Berufsbezeichnungen: unter welchen Stellentiteln seine ' +
'machen und welche Treffer von vornherein verworfen werden sollten. Du erfindest nichts: ' + 'Tätigkeit real ausgeschrieben wird - denn danach wird gesucht, und was hier fehlt, findet die ' +
'Jeder Zusatzbegriff muss sich aus den Unterlagen belegen lassen (nachgewiesene Technologien, ' + 'Suche nie. Dieselbe Arbeit läuft je nach Firma unter verschiedenen Titeln; decke diese ' +
'Branchen, Umfelder, Arbeitszeitmodelle). Ausschlüsse leitest du aus dem ab, was erkennbar ' + 'Varianten ab, aber bleib bei dem, was sein Erfahrungsniveau hergibt. Zusatzbegriffe sind ' +
'nicht zum Bewerber passt - etwa Stellen weit unter oder weit über seinem Erfahrungsniveau, ' + 'KEINE Stellentitel, sondern belegbarer Kontext (Technologien, Branchen, Umfelder, ' +
'fachfremde Rollen oder Anstellungsformen, die er offensichtlich nicht sucht. Keine ' + 'Arbeitszeitmodelle). Ausschlüsse leitest du aus dem ab, was erkennbar nicht zum Bewerber ' +
'Rollenbezeichnungen als Zusatzbegriffe: die leitet die Suche ohnehin selbst aus dem ' + 'passt - etwa Stellen weit unter oder weit über seinem Erfahrungsniveau, fachfremde Rollen ' +
'Lebenslauf ab. Antworte auf Deutsch im lateinischen Alphabet.', 'oder Anstellungsformen, die er offensichtlich nicht sucht. Du erfindest nichts: Jeder ' +
'Vorschlag muss sich aus den Unterlagen belegen lassen. Antworte auf Deutsch im lateinischen ' +
'Alphabet.',
}, },
]; ];
+30 -3
View File
@@ -34,6 +34,7 @@ const DEFAULTS = {
aktiv: 0, aktiv: 0,
modus: 'regional', modus: 'regional',
staedte: [], staedte: [],
berufsbezeichnungen: '',
zusatz_begriffe: '', zusatz_begriffe: '',
ausschluesse: 'Zeitarbeit / Arbeitnehmerüberlassung, Personalvermittler / Headhunter', ausschluesse: 'Zeitarbeit / Arbeitnehmerüberlassung, Personalvermittler / Headhunter',
zeitplan_tage: '1,2,3,4,5', zeitplan_tage: '1,2,3,4,5',
@@ -91,6 +92,7 @@ function fromRow(row) {
aktiv: r.aktiv ? 1 : 0, aktiv: r.aktiv ? 1 : 0,
modus, modus,
staedte: parseStaedte(r.staedte), staedte: parseStaedte(r.staedte),
berufsbezeichnungen: r.berufsbezeichnungen || '',
zusatz_begriffe: r.zusatz_begriffe || '', zusatz_begriffe: r.zusatz_begriffe || '',
ausschluesse: r.ausschluesse != null ? r.ausschluesse : DEFAULTS.ausschluesse, ausschluesse: r.ausschluesse != null ? r.ausschluesse : DEFAULTS.ausschluesse,
zeitplan_tage: parseTage(r.zeitplan_tage != null ? r.zeitplan_tage : DEFAULTS.zeitplan_tage), zeitplan_tage: parseTage(r.zeitplan_tage != null ? r.zeitplan_tage : DEFAULTS.zeitplan_tage),
@@ -108,6 +110,7 @@ function fromForm(body, sanitize = (v) => v) {
aktiv: (b.aktiv === '1' || b.aktiv === 'on' || b.aktiv === true) ? 1 : 0, aktiv: (b.aktiv === '1' || b.aktiv === 'on' || b.aktiv === true) ? 1 : 0,
modus, modus,
staedte: parseStaedte(b.staedte).map((s) => sanitize(s)), staedte: parseStaedte(b.staedte).map((s) => sanitize(s)),
berufsbezeichnungen: sanitize(String(b.berufsbezeichnungen || '').trim()),
zusatz_begriffe: sanitize(String(b.zusatz_begriffe || '').trim()), zusatz_begriffe: sanitize(String(b.zusatz_begriffe || '').trim()),
ausschluesse: sanitize(String(b.ausschluesse || '').trim()), ausschluesse: sanitize(String(b.ausschluesse || '').trim()),
zeitplan_tage: parseTage(tage.join(',')), zeitplan_tage: parseTage(tage.join(',')),
@@ -121,6 +124,7 @@ function toRow(profil) {
aktiv: profil.aktiv ? 1 : 0, aktiv: profil.aktiv ? 1 : 0,
modus: profil.modus, modus: profil.modus,
staedte: JSON.stringify(profil.staedte || []), staedte: JSON.stringify(profil.staedte || []),
berufsbezeichnungen: profil.berufsbezeichnungen || '',
zusatz_begriffe: profil.zusatz_begriffe || '', zusatz_begriffe: profil.zusatz_begriffe || '',
ausschluesse: profil.ausschluesse || '', ausschluesse: profil.ausschluesse || '',
zeitplan_tage: (profil.zeitplan_tage || []).join(','), zeitplan_tage: (profil.zeitplan_tage || []).join(','),
@@ -203,14 +207,37 @@ function buildPrompt(profil, profilText) {
); );
} }
// 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()) { if (profilText && profilText.trim()) {
teile.push( teile.push(
'Leite Rollenbezeichnungen (Query-Varianten) und Technologie-Buzzwords AUS DEM FOLGENDEN PROFIL DES ' + (profil.berufsbezeichnungen
'BEWERBERS ab — fasse die Rolle breit (dieselbe Tätigkeit läuft je nach Firma unter vielen Titeln) und ' + ? 'Technologie-Buzzwords und die Gewichtung der Treffer leitest du AUS DEM FOLGENDEN PROFIL DES ' +
'gewichte Treffer höher, je mehr aus dem Profil passt:\n\n--- PROFIL ---\n' + '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) + profilText.trim().slice(0, 6000) +
'\n--- ENDE PROFIL ---' '\n--- ENDE PROFIL ---'
); );
} else if (profil.berufsbezeichnungen) {
teile.push(
'Es ist kein Lebenslauf/Profil hinterlegt — halte dich strikt an die oben genannten Berufsbezeichnungen.'
);
} else { } else {
teile.push( teile.push(
'ACHTUNG: Es ist kein Lebenslauf/Profil hinterlegt. Leite Rollen und Suchbegriffe aus den bereits ' + 'ACHTUNG: Es ist kein Lebenslauf/Profil hinterlegt. Leite Rollen und Suchbegriffe aus den bereits ' +
+15 -5
View File
@@ -916,14 +916,15 @@ async function loadSuchprofil(userId) {
async function saveSuchprofil(userId, profil) { async function saveSuchprofil(userId, profil) {
const r = suchprofil.toRow(profil); const r = suchprofil.toRow(profil);
await dbRun( await dbRun(
`INSERT INTO suchprofil (user_id, aktiv, modus, staedte, zusatz_begriffe, ausschluesse, zeitplan_tage, zeitplan_zeit, updated_at) `INSERT INTO suchprofil (user_id, aktiv, modus, staedte, berufsbezeichnungen, zusatz_begriffe, ausschluesse, zeitplan_tage, zeitplan_zeit, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(user_id) DO UPDATE SET ON CONFLICT(user_id) DO UPDATE SET
aktiv = excluded.aktiv, modus = excluded.modus, staedte = excluded.staedte, aktiv = excluded.aktiv, modus = excluded.modus, staedte = excluded.staedte,
berufsbezeichnungen = excluded.berufsbezeichnungen,
zusatz_begriffe = excluded.zusatz_begriffe, ausschluesse = excluded.ausschluesse, zusatz_begriffe = excluded.zusatz_begriffe, ausschluesse = excluded.ausschluesse,
zeitplan_tage = excluded.zeitplan_tage, zeitplan_zeit = excluded.zeitplan_zeit, zeitplan_tage = excluded.zeitplan_tage, zeitplan_zeit = excluded.zeitplan_zeit,
updated_at = CURRENT_TIMESTAMP`, updated_at = CURRENT_TIMESTAMP`,
[userId, r.aktiv, r.modus, r.staedte, r.zusatz_begriffe, r.ausschluesse, r.zeitplan_tage, r.zeitplan_zeit] [userId, r.aktiv, r.modus, r.staedte, r.berufsbezeichnungen, r.zusatz_begriffe, r.ausschluesse, r.zeitplan_tage, r.zeitplan_zeit]
); );
} }
@@ -1525,14 +1526,16 @@ async function initializeDatabase() {
`); `);
// Job-search profile (one row per user; a missing row = no search configured). // Job-search profile (one row per user; a missing row = no search configured).
// The user sets *where* (Städte/Modus) and the guard rails; the roles and // The user sets *where* (Städte/Modus), *unter welchen Titeln* gesucht wird
// buzzwords are derived from their Lebenslauf at run time — see lib/suchprofil. // (berufsbezeichnungen) und die Leitplanken; Technologien/Buzzwords leitet die
// Suche weiter aus dem Lebenslauf ab — siehe lib/suchprofil.
await exec(` await exec(`
CREATE TABLE IF NOT EXISTS suchprofil ( CREATE TABLE IF NOT EXISTS suchprofil (
user_id INTEGER PRIMARY KEY, user_id INTEGER PRIMARY KEY,
aktiv INTEGER NOT NULL DEFAULT 0, aktiv INTEGER NOT NULL DEFAULT 0,
modus TEXT NOT NULL DEFAULT 'regional', modus TEXT NOT NULL DEFAULT 'regional',
staedte TEXT NOT NULL DEFAULT '[]', staedte TEXT NOT NULL DEFAULT '[]',
berufsbezeichnungen TEXT DEFAULT '',
zusatz_begriffe TEXT DEFAULT '', zusatz_begriffe TEXT DEFAULT '',
ausschluesse TEXT DEFAULT '', ausschluesse TEXT DEFAULT '',
zeitplan_tage TEXT DEFAULT '1,2,3,4,5', zeitplan_tage TEXT DEFAULT '1,2,3,4,5',
@@ -1541,6 +1544,13 @@ async function initializeDatabase() {
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) )
`); `);
// Bestehende Installationen: die Spalte kam nach dem CREATE TABLE oben dazu.
// Leer heißt „wie bisher" — die Suche leitet die Rollen dann weiter selbst aus
// dem Lebenslauf ab, niemandes Profil ändert sich durch das Update.
const suchprofilSpalten = await dbAll('PRAGMA table_info(suchprofil)');
if (!suchprofilSpalten.some((s) => s.name === 'berufsbezeichnungen')) {
await exec("ALTER TABLE suchprofil ADD COLUMN berufsbezeichnungen TEXT DEFAULT ''");
}
// Queue + history of search runs. The app only ever *enqueues* (status // Queue + history of search runs. The app only ever *enqueues* (status
// 'angefordert'); the host-side runner (bin/jobsuche-runner.sh) picks runs up, // 'angefordert'); the host-side runner (bin/jobsuche-runner.sh) picks runs up,
+16 -2
View File
@@ -109,7 +109,7 @@
<div> <div>
<h3 class="text-base font-semibold text-gray-800 dark:text-white">Feinschliff</h3> <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"> <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. Unter welchen Titeln gesucht wird und was die Suche zusätzlich beachten oder verwerfen soll.
</p> </p>
</div> </div>
<button type="button" id="feinschliffKi" <button type="button" id="feinschliffKi"
@@ -125,9 +125,21 @@
<p id="feinschliffHinweis" class="hidden text-sm mt-3"></p> <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"> <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 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. überschreibt alle drei Felder. Gespeichert wird er erst, wenn du unten auf „Speichern“ klickst.
</p> </p>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" for="berufsbezeichnungen">Berufsbezeichnungen</label>
<textarea id="berufsbezeichnungen" name="berufsbezeichnungen" rows="2"
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. Systemadministrator, IT-Administrator, Fachinformatiker Systemintegration"><%= profil.berufsbezeichnungen %></textarea>
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">
<strong>Danach wird gesucht</strong> was hier fehlt, findet die Suche nicht. Kommagetrennt;
gängige Varianten desselben Titels ergänzt die Suche selbst. Leer = die Suche leitet die Titel
wie bisher aus deinem Lebenslauf ab.
</p>
</div>
<div class="grid gap-4 sm:grid-cols-2"> <div class="grid gap-4 sm:grid-cols-2">
<div> <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> <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" for="zusatz_begriffe">Zusätzlich berücksichtigen</label>
@@ -249,6 +261,7 @@
if (!btn) return; if (!btn) return;
const label = document.getElementById('feinschliffKiText'); const label = document.getElementById('feinschliffKiText');
const hinweis = document.getElementById('feinschliffHinweis'); const hinweis = document.getElementById('feinschliffHinweis');
const berufe = document.getElementById('berufsbezeichnungen');
const zusatz = document.getElementById('zusatz_begriffe'); const zusatz = document.getElementById('zusatz_begriffe');
const ausschluesse = document.getElementById('ausschluesse'); const ausschluesse = document.getElementById('ausschluesse');
const zielUser = <%- fremd ? zielUser.id : 'null' %>; const zielUser = <%- fremd ? zielUser.id : 'null' %>;
@@ -282,6 +295,7 @@
const data = await res.json().catch(function () { return {}; }); const data = await res.json().catch(function () { return {}; });
if (!res.ok) throw new Error(data.error || ('Fehler ' + res.status)); if (!res.ok) throw new Error(data.error || ('Fehler ' + res.status));
berufe.value = data.berufsbezeichnungen || '';
zusatz.value = data.zusatz_begriffe || ''; zusatz.value = data.zusatz_begriffe || '';
ausschluesse.value = data.ausschluesse || ''; ausschluesse.value = data.ausschluesse || '';
melde('Vorschlag übernommen. Prüfe ihn und klicke auf „Speichern“.', 'ok'); melde('Vorschlag übernommen. Prüfe ihn und klicke auf „Speichern“.', 'ok');