diff --git a/lib/documents.js b/lib/documents.js index 9f61132..563902b 100644 --- a/lib/documents.js +++ b/lib/documents.js @@ -2103,10 +2103,11 @@ async function generateEmailReply({ const FEINSCHLIFF_SCHEMA = { type: 'object', properties: { + berufsbezeichnungen: { type: 'array', items: { type: 'string' } }, zusatz_begriffe: { 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 @@ -2139,10 +2140,16 @@ async function generateFeinschliff({ unterlagen, profil = {}, prompts = null }) `Suchmodus: ${modusText}\n` + (staedte ? `Städte: ${staedte}\n` : '') + `\n# Aufgabe\n` + - `Schlage die beiden Leitplanken der Stellensuche vor:\n` + - `- "zusatz_begriffe": Begriffe, die die Suche zusätzlich berücksichtigen soll - Technologien, ` + - `Branchen, Arbeitsumfelder oder Arbeitszeitmodelle, die aus den Unterlagen belegbar sind ` + - `(z. B. "Rechenzentrum", "Systemhaus"). KEINE Rollen-/Stellenbezeichnungen.\n` + + `Schlage die drei Listen der Stellensuche vor:\n` + + `- "berufsbezeichnungen": die Stellentitel, unter denen der Bewerber suchen sollte - das sind die ` + + `Suchanfragen und damit das Wichtigste. Nimm die Titel, unter denen SEINE Tätigkeit real ` + + `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 ` + `oder Rollen, die erkennbar nicht zum Profil passen).\n` + `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 { + berufsbezeichnungen: liste(['berufsbezeichnungen', 'berufsbezeichnung', 'stellentitel', 'rollen', 'jobtitel', 'titel']), zusatz_begriffe: liste(['zusatz_begriffe', 'zusatzbegriffe', 'zusatz', 'begriffe', 'einschluesse']), ausschluesse: liste(['ausschluesse', 'ausschlüsse', 'ausschlusskriterien', 'exclude', 'ausschluss']), }; diff --git a/lib/prompts.js b/lib/prompts.js index 92472d6..f6dc4b6 100644 --- a/lib/prompts.js +++ b/lib/prompts.js @@ -92,20 +92,22 @@ const DEFAULTS = [ key: 'jobsuche_feinschliff', titel: 'Jobsuche: Feinschliff vorschlagen', 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.', platzhalter: [], 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, ' + - 'Anschreiben) und leitest daraus ab, welche Zusatzbegriffe eine Stellensuche treffsicherer ' + - 'machen und welche Treffer von vornherein verworfen werden sollten. Du erfindest nichts: ' + - 'Jeder Zusatzbegriff muss sich aus den Unterlagen belegen lassen (nachgewiesene Technologien, ' + - 'Branchen, Umfelder, Arbeitszeitmodelle). Ausschlüsse leitest du aus dem ab, was erkennbar ' + - 'nicht zum Bewerber passt - etwa Stellen weit unter oder weit über seinem Erfahrungsniveau, ' + - 'fachfremde Rollen oder Anstellungsformen, die er offensichtlich nicht sucht. Keine ' + - 'Rollenbezeichnungen als Zusatzbegriffe: die leitet die Suche ohnehin selbst aus dem ' + - 'Lebenslauf ab. Antworte auf Deutsch im lateinischen Alphabet.', + 'Anschreiben). Das Wichtigste sind die Berufsbezeichnungen: unter welchen Stellentiteln seine ' + + 'Tätigkeit real ausgeschrieben wird - denn danach wird gesucht, und was hier fehlt, findet die ' + + 'Suche nie. Dieselbe Arbeit läuft je nach Firma unter verschiedenen Titeln; decke diese ' + + 'Varianten ab, aber bleib bei dem, was sein Erfahrungsniveau hergibt. Zusatzbegriffe sind ' + + 'KEINE Stellentitel, sondern belegbarer Kontext (Technologien, Branchen, Umfelder, ' + + 'Arbeitszeitmodelle). Ausschlüsse leitest du aus dem ab, was erkennbar nicht zum Bewerber ' + + 'passt - etwa Stellen weit unter oder weit über seinem Erfahrungsniveau, fachfremde Rollen ' + + '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.', }, ]; diff --git a/lib/suchprofil.js b/lib/suchprofil.js index 5eeb5de..dbbe136 100644 --- a/lib/suchprofil.js +++ b/lib/suchprofil.js @@ -34,6 +34,7 @@ const DEFAULTS = { aktiv: 0, modus: 'regional', staedte: [], + berufsbezeichnungen: '', zusatz_begriffe: '', ausschluesse: 'Zeitarbeit / Arbeitnehmerüberlassung, Personalvermittler / Headhunter', zeitplan_tage: '1,2,3,4,5', @@ -91,6 +92,7 @@ function fromRow(row) { 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), @@ -108,6 +110,7 @@ function fromForm(body, sanitize = (v) => v) { 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(',')), @@ -121,6 +124,7 @@ function toRow(profil) { 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(','), @@ -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()) { teile.push( - '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' + + (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 ' + diff --git a/server.js b/server.js index bdf11c1..cfee9dc 100644 --- a/server.js +++ b/server.js @@ -916,14 +916,15 @@ async function loadSuchprofil(userId) { async function saveSuchprofil(userId, profil) { const r = suchprofil.toRow(profil); await dbRun( - `INSERT INTO suchprofil (user_id, aktiv, modus, staedte, zusatz_begriffe, ausschluesse, zeitplan_tage, zeitplan_zeit, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + `INSERT INTO suchprofil (user_id, aktiv, modus, staedte, berufsbezeichnungen, zusatz_begriffe, ausschluesse, zeitplan_tage, zeitplan_zeit, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT(user_id) DO UPDATE SET aktiv = excluded.aktiv, modus = excluded.modus, staedte = excluded.staedte, + berufsbezeichnungen = excluded.berufsbezeichnungen, zusatz_begriffe = excluded.zusatz_begriffe, ausschluesse = excluded.ausschluesse, zeitplan_tage = excluded.zeitplan_tage, zeitplan_zeit = excluded.zeitplan_zeit, 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). - // The user sets *where* (Städte/Modus) and the guard rails; the roles and - // buzzwords are derived from their Lebenslauf at run time — see lib/suchprofil. + // The user sets *where* (Städte/Modus), *unter welchen Titeln* gesucht wird + // (berufsbezeichnungen) und die Leitplanken; Technologien/Buzzwords leitet die + // Suche weiter aus dem Lebenslauf ab — siehe lib/suchprofil. await exec(` CREATE TABLE IF NOT EXISTS suchprofil ( user_id INTEGER PRIMARY KEY, aktiv INTEGER NOT NULL DEFAULT 0, modus TEXT NOT NULL DEFAULT 'regional', staedte TEXT NOT NULL DEFAULT '[]', + berufsbezeichnungen TEXT DEFAULT '', zusatz_begriffe TEXT DEFAULT '', ausschluesse TEXT DEFAULT '', 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 ) `); + // 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 // 'angefordert'); the host-side runner (bin/jobsuche-runner.sh) picks runs up, diff --git a/views/jobsuche.ejs b/views/jobsuche.ejs index 9cb5a71..18250a5 100644 --- a/views/jobsuche.ejs +++ b/views/jobsuche.ejs @@ -109,7 +109,7 @@

Feinschliff

- 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.