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>
This commit is contained in:
+123
-4
@@ -1990,13 +1990,48 @@ const REPLY_SCHEMA = {
|
||||
|
||||
// Draft a reply to a recruiter/company e-mail in the context of an application.
|
||||
// `incoming` = { from, subject, text }; `job` = { firma, stelle };
|
||||
// `settings` = { name, ... }. `hinweise` = optional free-text steering.
|
||||
// `settings` = { name, ... }.
|
||||
// `typ` = 'antwort' (default) for a normal reply, or 'absage' for a polite, short
|
||||
// rejection because the applicant accepted another position.
|
||||
async function generateEmailReply({ incoming, job = {}, settings = {}, hinweise = '', typ = 'antwort', prompts = null }) {
|
||||
//
|
||||
// `hinweise` is what the user typed into the prompt field above the reply form
|
||||
// ("Termin am Dienstag zusagen, nach dem Gehaltsrahmen fragen"). It is the point
|
||||
// of the whole call: without it the model can only produce a generic reply, so it
|
||||
// is passed as a binding instruction, not as background colour.
|
||||
//
|
||||
// `entwurf` is the draft currently in the reply box. When present, the model
|
||||
// *revises* that text along the new instructions instead of starting over — that
|
||||
// is how the field is actually used ("gleiche Antwort, aber kürzer und förmlicher"),
|
||||
// and re-generating from scratch would throw the user's own edits away.
|
||||
async function generateEmailReply({
|
||||
incoming, job = {}, settings = {}, hinweise = '', entwurf = '', typ = 'antwort', prompts = null,
|
||||
}) {
|
||||
const name = (settings && settings.name) || 'der Bewerber';
|
||||
const isAbsage = typ === 'absage';
|
||||
|
||||
const anweisung = String(hinweise || '').trim().slice(0, 2000);
|
||||
const bisher = String(entwurf || '').trim().slice(0, 6000);
|
||||
|
||||
// Beide Blöcke stehen NACH der eingegangenen Mail und VOR der Aufgabe: das
|
||||
// Zuletztgelesene wiegt beim Modell am schwersten, und genau das soll die
|
||||
// Anweisung des Nutzers sein.
|
||||
const anweisungsBlock = anweisung
|
||||
? `# Anweisungen von ${name} für diese Antwort (verbindlich)\n` +
|
||||
`${anweisung}\n\n` +
|
||||
`Setze diese Anweisungen vollständig um - sie bestimmen den Inhalt der E-Mail und haben ` +
|
||||
`Vorrang vor allgemeinen Höflichkeitsformeln. Was dort nicht steht und sich nicht aus der ` +
|
||||
`eingegangenen Nachricht ergibt, gehört nicht in die Antwort: keine zusätzlichen Zusagen, ` +
|
||||
`Termine oder Angaben dazuerfinden. Ist eine Anweisung unklar oder fehlt eine konkrete ` +
|
||||
`Angabe, setze einen Platzhalter in eckigen Klammern, statt etwas anzunehmen.\n\n`
|
||||
: '';
|
||||
|
||||
const entwurfsBlock = bisher
|
||||
? `# Bisheriger Entwurf\n${bisher}\n\n` +
|
||||
`Überarbeite DIESEN Entwurf gemäß den Anweisungen oben. Behalte bei, was passt - schreibe ` +
|
||||
`nicht ohne Not alles neu, und verwirf keine inhaltlichen Punkte des Entwurfs, sofern die ` +
|
||||
`Anweisungen das nicht verlangen.\n\n`
|
||||
: '';
|
||||
|
||||
// Role prompt (per type) plus the shared style rules — both editable; the task
|
||||
// description below stays in code because it defines the expected JSON.
|
||||
const commonRules = promptStore.get(prompts, 'email_stil');
|
||||
@@ -2016,8 +2051,10 @@ async function generateEmailReply({ incoming, job = {}, settings = {}, hinweise
|
||||
`Von: ${incoming.from || '-'}\n` +
|
||||
`Betreff: ${incoming.subject || '-'}\n\n` +
|
||||
`${incoming.text || ''}\n\n` +
|
||||
(hinweise && hinweise.trim() ? `# Hinweise für die Absage (aktiv berücksichtigen)\n${hinweise.trim()}\n\n` : '') +
|
||||
anweisungsBlock +
|
||||
entwurfsBlock +
|
||||
`# Aufgabe\n` +
|
||||
(bisher ? 'Gib den überarbeiteten Entwurf als vollständige E-Mail zurück. ' : '') +
|
||||
`Formuliere eine kurze, höfliche Absage-E-Mail: Du hast eine andere Stelle angenommen ` +
|
||||
`und ziehst dich aus diesem Prozess zurück. Struktur des Feldes "text": Anrede (an den ` +
|
||||
`konkreten Absender, falls Name erkennbar, sonst "Sehr geehrte Damen und Herren,"), ein ` +
|
||||
@@ -2038,8 +2075,10 @@ async function generateEmailReply({ incoming, job = {}, settings = {}, hinweise
|
||||
`Von: ${incoming.from || '-'}\n` +
|
||||
`Betreff: ${incoming.subject || '-'}\n\n` +
|
||||
`${incoming.text || ''}\n\n` +
|
||||
(hinweise && hinweise.trim() ? `# Hinweise für die Antwort (aktiv berücksichtigen)\n${hinweise.trim()}\n\n` : '') +
|
||||
anweisungsBlock +
|
||||
entwurfsBlock +
|
||||
`# Aufgabe\n` +
|
||||
(bisher ? 'Gib den überarbeiteten Entwurf als vollständige E-Mail zurück. ' : '') +
|
||||
`Formuliere eine passende Antwort-E-Mail. Struktur des Feldes "text": Anrede (an den ` +
|
||||
`konkreten Absender, falls Name erkennbar, sonst "Sehr geehrte Damen und Herren,"), ` +
|
||||
`2-4 kurze Absätze, Grußformel "Mit freundlichen Grüßen" und in der letzten Zeile der ` +
|
||||
@@ -2057,6 +2096,85 @@ async function generateEmailReply({ incoming, job = {}, settings = {}, hinweise
|
||||
};
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// AI job-search guard rails ("Feinschliff")
|
||||
// ===========================================================================
|
||||
|
||||
const FEINSCHLIFF_SCHEMA = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
zusatz_begriffe: { type: 'array', items: { type: 'string' } },
|
||||
ausschluesse: { type: 'array', items: { type: 'string' } },
|
||||
},
|
||||
required: ['zusatz_begriffe', 'ausschluesse'],
|
||||
};
|
||||
|
||||
// How many terms per field we keep. The two lists end up verbatim in the search
|
||||
// agent's prompt, so a long one dilutes the search instead of sharpening it.
|
||||
const FEINSCHLIFF_MAX = 8;
|
||||
|
||||
// Suggest the two guard-rail lists of a Suchprofil ("Zusätzlich berücksichtigen"
|
||||
// / "Ausschließen") from the applicant's own base documents. `unterlagen` is the
|
||||
// concatenated text of their Vorlagen (Lebenslauf, Kurzprofil, ...), `profil` the
|
||||
// current Suchprofil (modus + cities give the model the search's frame).
|
||||
// Returns { zusatz_begriffe, ausschluesse } as comma-separated strings — exactly
|
||||
// the shape the two textareas and the DB hold.
|
||||
async function generateFeinschliff({ unterlagen, profil = {}, prompts = null }) {
|
||||
const text = String(unterlagen || '').trim();
|
||||
if (!text) {
|
||||
throw new Error('Keine Basis-Unterlagen hinterlegt - ohne sie kann die KI nichts ableiten.');
|
||||
}
|
||||
|
||||
const modusText = {
|
||||
regional: 'regional (nur Stellen mit Arbeitsort in den genannten Städten)',
|
||||
remote: '100 % Remote (deutschlandweit, ortsunabhängig)',
|
||||
beides: 'regional in den genannten Städten und zusätzlich 100 % Remote deutschlandweit',
|
||||
}[profil.modus] || 'regional';
|
||||
const staedte = (profil.staedte || []).join(', ');
|
||||
|
||||
const system = promptStore.get(prompts, 'jobsuche_feinschliff');
|
||||
const user =
|
||||
`# Basis-Unterlagen des Bewerbers\n${text.slice(0, 6000)}\n\n` +
|
||||
`# Rahmen der Suche\n` +
|
||||
`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` +
|
||||
`- "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 ` +
|
||||
`kurzer Begriff (1-4 Wörter), kein Satz. Verwende ausschließlich den einfachen Bindestrich "-". ` +
|
||||
`Antworte AUSSCHLIESSLICH mit dem JSON-Objekt, ohne Markdown, ohne Code-Fences.`;
|
||||
|
||||
const parsed = await ollamaChatJSON({ system, user, schema: FEINSCHLIFF_SCHEMA, temperature: 0.4 });
|
||||
|
||||
// The model honours the schema keys only loosely; accept the usual synonyms and
|
||||
// flatten each list into the comma-separated free text the profile stores. A
|
||||
// model that answers with one comma-separated string instead of an array is
|
||||
// split back apart rather than dropped.
|
||||
const liste = (keys) => {
|
||||
const roheListe = pickArray(parsed, keys);
|
||||
const werte = roheListe.length ? roheListe : String(pick(parsed, keys) || '').split(/[,;\n]/);
|
||||
const out = [];
|
||||
for (const roh of werte) {
|
||||
const v = str(roh).replace(/^[-•*\s]+/, '').replace(/[,;]+$/, '').trim();
|
||||
if (!v) continue;
|
||||
if (out.some((x) => x.toLowerCase() === v.toLowerCase())) continue;
|
||||
out.push(v);
|
||||
if (out.length >= FEINSCHLIFF_MAX) break;
|
||||
}
|
||||
return out.join(', ');
|
||||
};
|
||||
|
||||
return {
|
||||
zusatz_begriffe: liste(['zusatz_begriffe', 'zusatzbegriffe', 'zusatz', 'begriffe', 'einschluesse']),
|
||||
ausschluesse: liste(['ausschluesse', 'ausschlüsse', 'ausschlusskriterien', 'exclude', 'ausschluss']),
|
||||
};
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Design preview
|
||||
// ===========================================================================
|
||||
@@ -2112,6 +2230,7 @@ function renderDesignVorschau({ settings = {}, signatur = null, bewerbungsfoto =
|
||||
module.exports = {
|
||||
generateApplicationDocuments,
|
||||
generateEmailReply,
|
||||
generateFeinschliff,
|
||||
generateTailoredTexts,
|
||||
renderLebenslaufPdf,
|
||||
renderAnschreibenPdf,
|
||||
|
||||
@@ -88,6 +88,25 @@ const DEFAULTS = [
|
||||
'Buzzword-Paare und ohne Selbstetiketten wie "Als Muttersprachler ...". Verwende ausschließlich ' +
|
||||
'deutsche Sprache und das lateinische Alphabet - keine fremdsprachigen Wörter oder Schriftzeichen.',
|
||||
},
|
||||
{
|
||||
key: 'jobsuche_feinschliff',
|
||||
titel: 'Jobsuche: Feinschliff vorschlagen',
|
||||
beschreibung:
|
||||
'Rolle für den KI-Vorschlag der Leitplanken deiner Jobsuche (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 ' +
|
||||
'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.',
|
||||
},
|
||||
];
|
||||
|
||||
const BY_KEY = new Map(DEFAULTS.map((p) => [p.key, p]));
|
||||
|
||||
+344
@@ -0,0 +1,344 @@
|
||||
// Volltextsuche über Bewerbungen und ihre E-Mail-Korrespondenz (SQLite FTS5).
|
||||
//
|
||||
// Bisher filterte die Startseite nur clientseitig über Firma + Stelle der schon
|
||||
// gerenderten Zeilen. Damit war unauffindbar, was nur in einer E-Mail steht — der
|
||||
// Name der Ansprechpartnerin, ein Betreff, eine Zusage im Fließtext. Diese
|
||||
// Suche indiziert daher beides.
|
||||
//
|
||||
// Aufbau: ein FTS5-Index aus *Fragmenten*. Ein Fragment ist entweder eine
|
||||
// Bewerbung (Firma, Stelle, Notizen, Stellenbeschreibung …) oder eine einzelne
|
||||
// E-Mail (Absender/Empfänger, Betreff, Text). Jedes E-Mail-Fragment trägt Firma
|
||||
// und Stelle seiner Bewerbung mit — nur so findet eine Anfrage wie
|
||||
// "müller bosch" die Bewerbung, obwohl der Name allein in der E-Mail und die
|
||||
// Firma allein in der Bewerbung steht (FTS5 verknüpft Terme innerhalb *einer*
|
||||
// Zeile mit UND).
|
||||
//
|
||||
// Gepflegt wird der Index von SQL-Triggern, nicht von der Anwendung: Bewerbungen
|
||||
// und E-Mails werden an vielen Stellen geschrieben (Web-UI, REST-API, IMAP-Abruf,
|
||||
// KI-Import), und ein vergessener Aufruf würde den Index still veralten lassen.
|
||||
//
|
||||
// Die Ergebnisse werden nach Bewerbung gruppiert zurückgegeben: eine Bewerbung
|
||||
// ist ein Treffer, die Fragmente sagen, *wo* sie getroffen wurde ("in E-Mail von
|
||||
// Frau Müller"). E-Mails ohne zugeordnete Bewerbung (Postfach) sind eigene Treffer.
|
||||
|
||||
// Spaltengewichte für bm25: ein Firmentreffer wiegt schwerer als ein Wort
|
||||
// irgendwo im Mailtext. Reihenfolge = Spaltenreihenfolge im Index.
|
||||
const GEWICHTE = [12.0, 8.0, 6.0, 4.0, 1.0]; // firma, stelle, person, betreff, text
|
||||
|
||||
// Beschnitt pro Mailtext: eine lange Signatur/Zitatkette bläht den Index auf,
|
||||
// ohne die Suche besser zu machen.
|
||||
const MAX_TEXT = 20000;
|
||||
|
||||
// Wie viele Bewerbungen eine Suche höchstens zurückgibt.
|
||||
const LIMIT = 25;
|
||||
|
||||
// Markierungen um Fundstellen im Snippet. Bewusst keine HTML-Tags: die Rohtexte
|
||||
// werden erst im Client escaped und die Marker danach durch <mark> ersetzt, sonst
|
||||
// wäre der Snippet ein XSS-Vektor (Mailtexte sind Fremdinhalt).
|
||||
const MARK_START = '\u0002';
|
||||
const MARK_END = '\u0003';
|
||||
|
||||
// Feld-Filter, die der Nutzer tippen kann: "firma:bosch", "von:müller".
|
||||
const FELD_ALIASE = {
|
||||
firma: 'firma', unternehmen: 'firma',
|
||||
stelle: 'stelle', job: 'stelle', position: 'stelle',
|
||||
person: 'person', von: 'person', an: 'person', name: 'person', kontakt: 'person',
|
||||
betreff: 'betreff', subject: 'betreff',
|
||||
text: 'text', notiz: 'text', notizen: 'text', mail: 'text', inhalt: 'text',
|
||||
};
|
||||
|
||||
// Der Bewerbungs-Teil eines Fragments (Notizen, Beschreibung, Metadaten) — als
|
||||
// SQL-Ausdruck, weil ihn Trigger und Backfill gleichermaßen brauchen.
|
||||
const BEWERBUNG_TEXT = `
|
||||
trim(coalesce(%s.notizen, '') || ' ' || coalesce(%s.interne_notizen, '') || ' ' ||
|
||||
coalesce(%s.llm_notizen, '') || ' ' || coalesce(%s.ort, '') || ' ' ||
|
||||
coalesce(%s.art, '') || ' ' || coalesce(%s.status, '') || ' ' ||
|
||||
coalesce(%s.labels, '') || ' ' || coalesce(%s.stellenbeschreibung, '') || ' ' ||
|
||||
coalesce(%s.quelle_url, ''))
|
||||
`;
|
||||
const bewerbungText = (alias) => BEWERBUNG_TEXT.replace(/%s/g, alias);
|
||||
|
||||
// Schema + Trigger + einmaliger Backfill. Idempotent: läuft bei jedem Start.
|
||||
async function ensureSchema({ exec, dbGet }) {
|
||||
await exec(`
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS suche_index USING fts5(
|
||||
firma, stelle, person, betreff, text,
|
||||
user_id UNINDEXED,
|
||||
bewerbung_id UNINDEXED,
|
||||
typ UNINDEXED,
|
||||
ref_id UNINDEXED,
|
||||
meta UNINDEXED,
|
||||
tokenize = 'unicode61 remove_diacritics 2'
|
||||
)
|
||||
`);
|
||||
|
||||
// Trigger. "typ" trennt die beiden Fragmentarten, "ref_id" zeigt auf die
|
||||
// Quellzeile — beides brauchen die Trigger, um gezielt zu löschen.
|
||||
await exec(`
|
||||
CREATE TRIGGER IF NOT EXISTS bewerbungen_suche_ai AFTER INSERT ON bewerbungen BEGIN
|
||||
INSERT INTO suche_index (firma, stelle, person, betreff, text, user_id, bewerbung_id, typ, ref_id, meta)
|
||||
VALUES (coalesce(new.firma, ''), coalesce(new.stelle, ''), coalesce(new.email_empfaenger, ''), '',
|
||||
${bewerbungText('new')}, new.user_id, new.id, 'bewerbung', new.id, '');
|
||||
END
|
||||
`);
|
||||
|
||||
// Beim Ändern: eigenes Fragment neu aufbauen und Firma/Stelle in den
|
||||
// E-Mail-Fragmenten nachziehen (sie tragen sie denormalisiert mit).
|
||||
await exec(`
|
||||
CREATE TRIGGER IF NOT EXISTS bewerbungen_suche_au AFTER UPDATE ON bewerbungen BEGIN
|
||||
DELETE FROM suche_index WHERE typ = 'bewerbung' AND ref_id = old.id;
|
||||
INSERT INTO suche_index (firma, stelle, person, betreff, text, user_id, bewerbung_id, typ, ref_id, meta)
|
||||
VALUES (coalesce(new.firma, ''), coalesce(new.stelle, ''), coalesce(new.email_empfaenger, ''), '',
|
||||
${bewerbungText('new')}, new.user_id, new.id, 'bewerbung', new.id, '');
|
||||
UPDATE suche_index SET firma = coalesce(new.firma, ''), stelle = coalesce(new.stelle, '')
|
||||
WHERE typ = 'email' AND bewerbung_id = new.id;
|
||||
END
|
||||
`);
|
||||
|
||||
await exec(`
|
||||
CREATE TRIGGER IF NOT EXISTS bewerbungen_suche_ad AFTER DELETE ON bewerbungen BEGIN
|
||||
DELETE FROM suche_index WHERE bewerbung_id = old.id;
|
||||
END
|
||||
`);
|
||||
|
||||
// E-Mails: Firma/Stelle der zugeordneten Bewerbung wandern ins Fragment, damit
|
||||
// "müller bosch" trifft. meta trägt, was das Ergebnis anzeigt (Betreff, Absender,
|
||||
// Datum, Richtung) — als JSON, unindiziert.
|
||||
const emailFragment = () => `
|
||||
INSERT INTO suche_index (firma, stelle, person, betreff, text, user_id, bewerbung_id, typ, ref_id, meta)
|
||||
SELECT coalesce(b.firma, ''), coalesce(b.stelle, ''),
|
||||
trim(coalesce(new.from_addr, '') || ' ' || coalesce(new.to_addr, '')),
|
||||
coalesce(new.subject, ''), substr(coalesce(new.body_text, ''), 1, ${MAX_TEXT}),
|
||||
new.user_id, new.bewerbung_id, 'email', new.id,
|
||||
json_object('betreff', coalesce(new.subject, ''), 'von', coalesce(new.from_addr, ''),
|
||||
'an', coalesce(new.to_addr, ''), 'datum', coalesce(new.email_date, new.created_at),
|
||||
'richtung', coalesce(new.direction, ''))
|
||||
FROM (SELECT 1) LEFT JOIN bewerbungen b ON b.id = new.bewerbung_id;
|
||||
`;
|
||||
|
||||
await exec(`
|
||||
CREATE TRIGGER IF NOT EXISTS emails_suche_ai AFTER INSERT ON emails BEGIN
|
||||
${emailFragment()}
|
||||
END
|
||||
`);
|
||||
|
||||
// Eine E-Mail wird u. a. beim Zuordnen im Postfach aktualisiert (bewerbung_id) —
|
||||
// dann muss das Fragment mitsamt Firma/Stelle neu entstehen.
|
||||
await exec(`
|
||||
CREATE TRIGGER IF NOT EXISTS emails_suche_au AFTER UPDATE ON emails BEGIN
|
||||
DELETE FROM suche_index WHERE typ = 'email' AND ref_id = old.id;
|
||||
${emailFragment()}
|
||||
END
|
||||
`);
|
||||
|
||||
await exec(`
|
||||
CREATE TRIGGER IF NOT EXISTS emails_suche_ad AFTER DELETE ON emails BEGIN
|
||||
DELETE FROM suche_index WHERE typ = 'email' AND ref_id = old.id;
|
||||
END
|
||||
`);
|
||||
|
||||
// Backfill: nur beim allerersten Start nach der Einführung (Index noch leer,
|
||||
// Daten aber vorhanden). Danach halten die Trigger alles aktuell.
|
||||
const leer = await dbGet('SELECT count(*) AS n FROM suche_index');
|
||||
if (leer && leer.n === 0) {
|
||||
await exec(`
|
||||
INSERT INTO suche_index (firma, stelle, person, betreff, text, user_id, bewerbung_id, typ, ref_id, meta)
|
||||
SELECT coalesce(b.firma, ''), coalesce(b.stelle, ''), coalesce(b.email_empfaenger, ''), '',
|
||||
${bewerbungText('b')}, b.user_id, b.id, 'bewerbung', b.id, ''
|
||||
FROM bewerbungen b
|
||||
`);
|
||||
await exec(`
|
||||
INSERT INTO suche_index (firma, stelle, person, betreff, text, user_id, bewerbung_id, typ, ref_id, meta)
|
||||
SELECT coalesce(b.firma, ''), coalesce(b.stelle, ''),
|
||||
trim(coalesce(e.from_addr, '') || ' ' || coalesce(e.to_addr, '')),
|
||||
coalesce(e.subject, ''), substr(coalesce(e.body_text, ''), 1, ${MAX_TEXT}),
|
||||
e.user_id, e.bewerbung_id, 'email', e.id,
|
||||
json_object('betreff', coalesce(e.subject, ''), 'von', coalesce(e.from_addr, ''),
|
||||
'an', coalesce(e.to_addr, ''), 'datum', coalesce(e.email_date, e.created_at),
|
||||
'richtung', coalesce(e.direction, ''))
|
||||
FROM emails e LEFT JOIN bewerbungen b ON b.id = e.bewerbung_id
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Eingabe -> FTS5-MATCH-Ausdruck
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Was der Nutzer tippen darf:
|
||||
// müller -> Präfixsuche über alle Felder
|
||||
// "frau müller" -> Wortfolge
|
||||
// firma:bosch -> nur in diesem Feld
|
||||
// -zeitarbeit -> schließt Treffer aus
|
||||
// Alles andere (FTS-Sonderzeichen wie * : ^ NEAR) wird entschärft: jeder Term
|
||||
// geht in Anführungszeichen in die Query, damit eine Eingabe wie "c++" oder ein
|
||||
// Doppelpunkt keinen Syntaxfehler auslöst statt zu suchen.
|
||||
function buildMatch(roh) {
|
||||
const q = String(roh == null ? '' : roh).trim();
|
||||
if (!q) return null;
|
||||
|
||||
const TOKEN = /(-?)(?:(\w+):)?(?:"([^"]*)"|(\S+))/gu;
|
||||
const positiv = [];
|
||||
const negativ = [];
|
||||
|
||||
let m;
|
||||
while ((m = TOKEN.exec(q)) !== null) {
|
||||
const [, minus, feldRoh, phrase, wort] = m;
|
||||
const inhalt = (phrase != null ? phrase : wort || '').trim();
|
||||
// Ohne Buchstaben/Ziffern bleibt nichts Suchbares übrig (z. B. "--" oder ":").
|
||||
const sauber = inhalt.replace(/"/g, ' ').trim();
|
||||
if (!/[\p{L}\p{N}]/u.test(sauber)) continue;
|
||||
|
||||
const feld = feldRoh ? FELD_ALIASE[feldRoh.toLowerCase()] : null;
|
||||
// Unbekanntes Präfix ("foo:bar") ist kein Feldfilter, sondern Suchtext.
|
||||
const suchtext = feldRoh && !feld ? `${feldRoh} ${sauber}` : sauber;
|
||||
const term = umlautTerm(suchtext);
|
||||
// Ein Feldfilter darf auf einen geklammerten Ausdruck zeigen: {firma} : ("a"* OR "b"*)
|
||||
const mitFeld = feld ? `{${feld}} : ${term}` : term;
|
||||
(minus ? negativ : positiv).push(mitFeld);
|
||||
}
|
||||
|
||||
// Eine reine Ausschlussfrage ("-zeitarbeit") hat keinen Anker — FTS5 braucht
|
||||
// mindestens einen positiven Term, und "alles außer X" ist ohnehin keine Suche.
|
||||
if (!positiv.length) return null;
|
||||
const basis = positiv.join(' AND ');
|
||||
return negativ.length ? `${basis} NOT (${negativ.join(' OR ')})` : basis;
|
||||
}
|
||||
|
||||
// Ein Term als FTS5-Ausdruck, mit Präfixsuche ("bosch"* findet auch
|
||||
// "boschgruppe" — beim Tippen der Normalfall).
|
||||
//
|
||||
// Dazu die deutsche Umlaut-Frage: der Index faltet Diakritika (Müller -> muller),
|
||||
// deshalb findet "muller" bereits "Müller". Die ausgeschriebene Form "mueller"
|
||||
// aber nicht — und genau die tippt man bei Namen ständig. Also wird zusätzlich
|
||||
// die Variante mit aufgelöstem ue/oe/ae/ss gesucht, ODER-verknüpft. Falsche
|
||||
// Auflösungen ("Neuenkirchen" -> "nunkirchen") laufen dabei einfach ins Leere,
|
||||
// der Originalterm bleibt ja daneben stehen.
|
||||
function umlautTerm(text) {
|
||||
const roh = `"${text}"*`;
|
||||
const variante = text
|
||||
.replace(/ue/gi, 'u').replace(/oe/gi, 'o').replace(/ae/gi, 'a')
|
||||
.replace(/ß/g, 'ss');
|
||||
if (variante.toLowerCase() === text.toLowerCase()) return roh;
|
||||
return `(${roh} OR "${variante}"*)`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Suche
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SPALTEN = ['firma', 'stelle', 'person', 'betreff', 'text'];
|
||||
const FELD_LABEL = { firma: 'Firma', stelle: 'Stelle', person: 'Kontakt', betreff: 'Betreff', text: 'Text' };
|
||||
|
||||
// Findet Bewerbungen (und nicht zugeordnete E-Mails) zu einer Suchanfrage.
|
||||
// `dbAll` ist der Query-Helfer der App, `userId` grenzt hart auf den Besitzer ein
|
||||
// (der Index ist gemeinsam, die unindizierte user_id-Spalte trennt die Mandanten).
|
||||
// Rückgabe: nach Score sortierte Treffer, jeder mit Fundstellen + Snippets.
|
||||
async function suche(dbAll, userId, roh, limit = LIMIT) {
|
||||
const match = buildMatch(roh);
|
||||
if (!match || !userId) return [];
|
||||
|
||||
const gewichte = GEWICHTE.join(', ');
|
||||
const snippets = SPALTEN
|
||||
.map((_, i) => `snippet(suche_index, ${i}, '${MARK_START}', '${MARK_END}', '…', 12) AS s_${i}`)
|
||||
.join(', ');
|
||||
|
||||
// Fragmente laden (mehrere je Bewerbung möglich), das Gruppieren macht JS —
|
||||
// in SQL ginge es nur mit einem zweiten Durchlauf über die FTS-Tabelle.
|
||||
let rows;
|
||||
try {
|
||||
rows = await dbAll(
|
||||
`SELECT bewerbung_id, typ, ref_id, meta, ${snippets},
|
||||
bm25(suche_index, ${gewichte}) AS score
|
||||
FROM suche_index
|
||||
WHERE suche_index MATCH ? AND user_id = ?
|
||||
ORDER BY score
|
||||
LIMIT ?`,
|
||||
[match, userId, limit * 4] // Puffer: mehrere Fragmente fallen auf eine Bewerbung zusammen
|
||||
);
|
||||
} catch (e) {
|
||||
// Eine Eingabe, die FTS5 trotz Entschärfung nicht parst, ist kein Serverfehler,
|
||||
// sondern schlicht kein Treffer.
|
||||
if (/fts5|syntax/i.test(e.message)) return [];
|
||||
throw e;
|
||||
}
|
||||
if (!rows.length) return [];
|
||||
|
||||
// Fragmente -> Treffer je Bewerbung. Nicht zugeordnete E-Mails bleiben einzeln.
|
||||
const treffer = new Map();
|
||||
for (const r of rows) {
|
||||
const key = r.bewerbung_id ? `b${r.bewerbung_id}` : `e${r.ref_id}`;
|
||||
if (!treffer.has(key)) {
|
||||
treffer.set(key, {
|
||||
typ: r.bewerbung_id ? 'bewerbung' : 'email',
|
||||
bewerbung_id: r.bewerbung_id || null,
|
||||
email_id: r.bewerbung_id ? null : r.ref_id,
|
||||
score: r.score,
|
||||
fundstellen: [],
|
||||
});
|
||||
}
|
||||
const t = treffer.get(key);
|
||||
t.score = Math.min(t.score, r.score); // bm25: kleiner = besser
|
||||
const fund = fundstelle(r);
|
||||
if (fund) t.fundstellen.push(fund);
|
||||
}
|
||||
|
||||
const liste = [...treffer.values()].sort((a, b) => a.score - b.score).slice(0, limit);
|
||||
await ergaenzeBewerbungen(dbAll, userId, liste);
|
||||
for (const t of liste) t.fundstellen = t.fundstellen.slice(0, 3);
|
||||
return liste;
|
||||
}
|
||||
|
||||
// Aus einem Fragment die beste Fundstelle bauen: die Spalte mit Markierung im
|
||||
// Snippet (FTS5 liefert für Spalten ohne Treffer den Anfang des Textes zurück).
|
||||
function fundstelle(row) {
|
||||
for (let i = 0; i < SPALTEN.length; i++) {
|
||||
const s = row[`s_${i}`];
|
||||
if (!s || s.indexOf(MARK_START) === -1) continue;
|
||||
const meta = row.meta ? sicherJson(row.meta) : null;
|
||||
return {
|
||||
quelle: row.typ, // 'bewerbung' | 'email'
|
||||
feld: SPALTEN[i],
|
||||
feld_label: FELD_LABEL[SPALTEN[i]],
|
||||
snippet: s,
|
||||
email: meta && row.typ === 'email' ? meta : null,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function sicherJson(s) {
|
||||
try { return JSON.parse(s); } catch (e) { return null; }
|
||||
}
|
||||
|
||||
// Snippet ohne die Fundstellen-Marker — für Verbraucher, die nichts hervorheben
|
||||
// (der KI-Chat etwa: Steuerzeichen im Tool-Ergebnis würden das Modell nur irritieren).
|
||||
function ohneMarker(s) {
|
||||
return String(s == null ? '' : s).split(MARK_START).join('').split(MARK_END).join('');
|
||||
}
|
||||
|
||||
// Die Anzeigedaten der getroffenen Bewerbungen nachladen (der Index hält nur
|
||||
// Suchtext). Eine Abfrage für alle Treffer.
|
||||
async function ergaenzeBewerbungen(dbAll, userId, liste) {
|
||||
const ids = liste.filter((t) => t.bewerbung_id).map((t) => t.bewerbung_id);
|
||||
if (!ids.length) return;
|
||||
const rows = await dbAll(
|
||||
`SELECT id, firma, stelle, status, datum, art FROM bewerbungen
|
||||
WHERE user_id = ? AND id IN (${ids.map(() => '?').join(',')})`,
|
||||
[userId, ...ids]
|
||||
);
|
||||
const byId = new Map(rows.map((r) => [r.id, r]));
|
||||
for (const t of liste) {
|
||||
const b = t.bewerbung_id ? byId.get(t.bewerbung_id) : null;
|
||||
if (b) t.bewerbung = b;
|
||||
}
|
||||
// Eine Bewerbung, die es nicht mehr gibt (Index hinkt hinterher), fällt raus.
|
||||
for (let i = liste.length - 1; i >= 0; i--) {
|
||||
if (liste[i].typ === 'bewerbung' && !liste[i].bewerbung) liste.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ensureSchema, suche, buildMatch, ohneMarker,
|
||||
MARK_START, MARK_END, LIMIT, FELD_ALIASE,
|
||||
};
|
||||
@@ -30,7 +30,7 @@ const multer = require('multer');
|
||||
})();
|
||||
|
||||
const {
|
||||
generateApplicationDocuments, generateEmailReply, renderDesignVorschau,
|
||||
generateApplicationDocuments, generateEmailReply, generateFeinschliff, renderDesignVorschau,
|
||||
DOKUMENT_TYPEN, normalizeDokumente,
|
||||
} = require('./lib/documents');
|
||||
const chat = require('./lib/chat');
|
||||
@@ -48,6 +48,7 @@ const password = require('./lib/password');
|
||||
const migrate = require('./lib/migrate-multiuser');
|
||||
const { runMigration, importEnvIntoAdmin } = migrate;
|
||||
const suchprofil = require('./lib/suchprofil');
|
||||
const suche = require('./lib/suche');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
@@ -1555,6 +1556,11 @@ async function initializeDatabase() {
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
|
||||
// Volltext-Index über Bewerbungen + E-Mail-Korrespondenz. Muss nach den beiden
|
||||
// Quelltabellen entstehen, weil die Trigger auf ihnen sitzen; der Backfill
|
||||
// läuft nur beim ersten Start (siehe lib/suche.js).
|
||||
await suche.ensureSchema({ exec, dbGet });
|
||||
}
|
||||
// Initialize and start server
|
||||
initializeDatabase().then(async () => {
|
||||
@@ -1902,6 +1908,21 @@ initializeDatabase().then(async () => {
|
||||
}
|
||||
});
|
||||
|
||||
// Volltextsuche für die Startseite: Bewerbungen samt ihrer E-Mail-Korrespondenz
|
||||
// (Absender, Betreff, Text), Notizen und Stellenbeschreibung. Liefert je Treffer
|
||||
// die Fundstellen mit Snippet — die Marker darin ersetzt der Client durch <mark>.
|
||||
app.get('/api/suche', async (req, res) => {
|
||||
try {
|
||||
const q = String(req.query.q || '');
|
||||
if (q.trim().length < 2) return res.json({ q, treffer: [] });
|
||||
const treffer = await suche.suche(dbAll, uid(), q);
|
||||
res.json({ q, treffer });
|
||||
} catch (error) {
|
||||
console.error('Search error:', error);
|
||||
res.status(500).json({ error: 'Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
// Poll generation status + current attachments for one application
|
||||
app.get('/api/bewerbungen/:id/generierung', async (req, res) => {
|
||||
try {
|
||||
@@ -2312,7 +2333,11 @@ initializeDatabase().then(async () => {
|
||||
job: { firma: bewerbung.firma, stelle: bewerbung.stelle },
|
||||
settings,
|
||||
prompts: await loadPrompts(),
|
||||
// Was der Nutzer über dem Antwortfeld vorgegeben hat, plus (beim zweiten
|
||||
// Klick) der Entwurf, der gerade im Feld steht — dann überarbeitet die KI
|
||||
// ihn, statt neu anzufangen.
|
||||
hinweise: String(req.body.hinweise || ''),
|
||||
entwurf: String(req.body.entwurf || ''),
|
||||
typ: String(req.body.typ || 'antwort'),
|
||||
});
|
||||
// Include the quoted original so the reply reads like a mail-client thread.
|
||||
@@ -3421,6 +3446,12 @@ initializeDatabase().then(async () => {
|
||||
"SELECT id FROM basis_dokumente WHERE user_id = ? AND typ = 'Lebenslauf' AND inhalt IS NOT NULL AND inhalt != '' LIMIT 1",
|
||||
[ziel.id]
|
||||
);
|
||||
// Der KI-Feinschliff liest ALLE Basis-Unterlagen, nicht nur den Lebenslauf —
|
||||
// ein Kurzprofil allein reicht ihm schon.
|
||||
const unterlagen = await dbGet(
|
||||
"SELECT id FROM basis_dokumente WHERE user_id = ? AND inhalt IS NOT NULL AND inhalt != '' LIMIT 1",
|
||||
[ziel.id]
|
||||
);
|
||||
const ollamaKey = await dbGet(
|
||||
"SELECT value FROM app_state WHERE user_id = ? AND key = 'cfg:OLLAMA_API_KEY' AND value != ''",
|
||||
[ziel.id]
|
||||
@@ -3435,6 +3466,7 @@ initializeDatabase().then(async () => {
|
||||
wochentage: suchprofil.WOCHENTAGE,
|
||||
maxStaedte: suchprofil.MAX_STAEDTE,
|
||||
hatLebenslauf: Boolean(lebenslauf),
|
||||
hatUnterlagen: Boolean(unterlagen),
|
||||
hatOllamaKey: Boolean(ollamaKey),
|
||||
fehler: req.query.fehler ? String(req.query.fehler) : null,
|
||||
hinweis: req.query.hinweis ? String(req.query.hinweis) : null,
|
||||
@@ -3465,6 +3497,47 @@ initializeDatabase().then(async () => {
|
||||
}
|
||||
});
|
||||
|
||||
// KI-Vorschlag für den Feinschliff (Zusatzbegriffe / Ausschlüsse), abgeleitet
|
||||
// aus den Basis-Unterlagen des Zielbenutzers. Der Vorschlag wird nur
|
||||
// zurückgegeben, nicht gespeichert — der Nutzer prüft ihn im Formular und
|
||||
// speichert selbst. Anders als der Suchlauf läuft das direkt hier: es ist ein
|
||||
// einzelner Ollama-Aufruf, kein Web-Rechercheauftrag für den Host-Runner.
|
||||
app.post('/jobsuche/feinschliff', async (req, res) => {
|
||||
try {
|
||||
const ziel = resolveZielUser(req);
|
||||
if (!ziel) return res.status(403).json({ error: 'Zugriff verweigert – nur für Administratoren.' });
|
||||
|
||||
const unterlagen = await dbAll(
|
||||
`SELECT typ, name, inhalt FROM basis_dokumente
|
||||
WHERE user_id = ? AND inhalt IS NOT NULL AND inhalt != ''
|
||||
ORDER BY CASE typ WHEN 'Lebenslauf' THEN 0 WHEN 'Profil/Kurzprofil' THEN 1 WHEN 'Anschreiben' THEN 2 ELSE 3 END, id ASC`,
|
||||
[ziel.id]
|
||||
);
|
||||
if (!unterlagen.length) {
|
||||
return res.status(400).json({
|
||||
error: 'Keine Basis-Unterlagen hinterlegt – lege zuerst unter „Vorlagen“ einen Lebenslauf an.',
|
||||
});
|
||||
}
|
||||
|
||||
// Modus/Städte kommen aus dem Formular (ggf. ungespeichert), der Rest ist
|
||||
// für den Vorschlag irrelevant.
|
||||
const profil = suchprofil.fromForm({ ...req.body, aktiv: '0' }, sanitizeInput);
|
||||
const text = unterlagen
|
||||
.map((d) => `## ${d.typ}${d.name ? ` – ${d.name}` : ''}\n${String(d.inhalt).trim()}`)
|
||||
.join('\n\n');
|
||||
|
||||
const vorschlag = await generateFeinschliff({
|
||||
unterlagen: text,
|
||||
profil,
|
||||
prompts: await loadPrompts(),
|
||||
});
|
||||
res.json(vorschlag);
|
||||
} catch (error) {
|
||||
console.error('Error generating Feinschliff:', error);
|
||||
res.status(500).json({ error: error.message || 'Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
// "Jetzt suchen": queue a run. Picked up by the host runner within a few minutes.
|
||||
app.post('/jobsuche/start', async (req, res) => {
|
||||
try {
|
||||
@@ -3712,11 +3785,11 @@ initializeDatabase().then(async () => {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'suche_bewerbungen',
|
||||
description: 'Durchsucht Bewerbungen nach Firmen- oder Stellenname (Teiltreffer). Nutze dies, wenn der Nutzer eine konkrete Firma/Stelle nennt oder fragt, was zu einer Firma bekannt ist. Liefert eine kompakte Trefferliste (id, firma, stelle, status, datum, ort) — hole Details mit bewerbung_detail.',
|
||||
description: 'Volltextsuche über Bewerbungen UND ihre E-Mail-Korrespondenz: Firma, Stelle, Notizen, Stellenbeschreibung, Absender/Empfänger, Betreff und Mailtext. Nutze dies auch, wenn der Nutzer nur einen Namen einer Ansprechpartnerin, einen Betreff oder eine Formulierung aus einer E-Mail nennt. Liefert je Treffer die Bewerbung samt Fundstelle (wo der Begriff steht) — hole Details mit bewerbung_detail.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: { type: 'string', description: 'Suchbegriff, z. B. Firmen- oder Stellenname (mind. 2 Zeichen)' },
|
||||
query: { type: 'string', description: 'Suchbegriff (mind. 2 Zeichen). Mehrere Wörter werden UND-verknüpft; "in Anführungszeichen" sucht die Wortfolge, feld:wert grenzt ein (firma:, stelle:, von:, betreff:), -wort schließt aus.' },
|
||||
},
|
||||
required: ['query'],
|
||||
},
|
||||
@@ -3773,14 +3846,22 @@ initializeDatabase().then(async () => {
|
||||
if (name === 'suche_bewerbungen') {
|
||||
const q = String(a.query || '').trim();
|
||||
if (q.length < 2) return { treffer: [], hinweis: 'Suchbegriff zu kurz' };
|
||||
const like = `%${q.replace(/[%_]/g, (m) => '\\' + m)}%`;
|
||||
const rows = await dbAll(
|
||||
`SELECT id, firma, stelle, status, datum, ort FROM bewerbungen
|
||||
WHERE user_id = ? AND (firma LIKE ? ESCAPE '\\' OR stelle LIKE ? ESCAPE '\\')
|
||||
ORDER BY datum DESC, created_at DESC LIMIT 20`,
|
||||
[uid(), like, like]
|
||||
);
|
||||
return { treffer: rows };
|
||||
// Dieselbe Volltextsuche wie auf der Startseite (lib/suche.js) — der Assistent
|
||||
// findet eine Bewerbung dadurch auch über einen Namen aus der Korrespondenz.
|
||||
const treffer = await suche.suche(dbAll, uid(), q, 20);
|
||||
return {
|
||||
treffer: treffer.map((t) => ({
|
||||
id: t.bewerbung_id,
|
||||
...(t.bewerbung || {}),
|
||||
// Wo der Begriff steht, ohne Markierungszeichen — die versteht das Modell nicht.
|
||||
fundstellen: t.fundstellen.map((f) => ({
|
||||
wo: f.quelle === 'email'
|
||||
? `E-Mail${f.email && f.email.betreff ? ` „${f.email.betreff}"` : ''}${f.email && f.email.von ? ` von ${f.email.von}` : ''}`
|
||||
: f.feld_label,
|
||||
auszug: suche.ohneMarker(f.snippet),
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}
|
||||
if (name === 'list_bewerbungen') {
|
||||
const limit = Math.min(Math.max(Number(a.limit) || 20, 1), 40);
|
||||
|
||||
+89
-21
@@ -500,20 +500,34 @@
|
||||
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Betreff</label>
|
||||
<input type="text" name="subject" value="<%= /^\s*(re|aw)\s*:/i.test(e.subject || '') ? (e.subject || '') : ('Re: ' + (e.subject || '')) %>"
|
||||
class="reply-subject w-full px-3 py-2 mb-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-800 dark:text-white text-sm">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 mb-1">
|
||||
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400">Nachricht</label>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<!-- KI-Antwort: erst sagen, was drinstehen soll, dann generieren lassen.
|
||||
Ohne Vorgabe kommt nur eine allgemeine Höflichkeitsantwort heraus. -->
|
||||
<div class="mb-3 rounded-md border border-purple-200 dark:border-purple-800/60 bg-purple-50/60 dark:bg-purple-900/10 p-3">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 mb-1.5">
|
||||
<label for="ai-prompt-<%= e.id %>" class="flex items-center gap-1.5 text-xs font-medium text-purple-700 dark:text-purple-300">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path></svg>
|
||||
Was soll die KI antworten?
|
||||
</label>
|
||||
<select class="ai-reply-typ text-xs px-1.5 py-0.5 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-200">
|
||||
<option value="antwort">Antwort</option>
|
||||
<option value="absage">Absage (andere Stelle)</option>
|
||||
</select>
|
||||
<button type="button" class="ai-reply-btn inline-flex items-center gap-1 text-xs text-purple-600 hover:text-purple-800 dark:text-purple-400 hover:underline"
|
||||
</div>
|
||||
<textarea id="ai-prompt-<%= e.id %>" rows="2"
|
||||
class="ai-reply-prompt w-full px-3 py-2 border border-purple-200 dark:border-purple-800/60 rounded-md bg-white dark:bg-gray-700 text-gray-800 dark:text-white text-sm leading-relaxed"
|
||||
placeholder="z. B. Termin am Dienstag 14 Uhr zusagen, nach dem Ablauf des Gesprächs fragen – oder: kürzer und förmlicher"></textarea>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 mt-2">
|
||||
<p class="ai-reply-status text-xs text-gray-500 dark:text-gray-400">
|
||||
Ohne Vorgabe antwortet die KI nur allgemein auf die Nachricht.
|
||||
</p>
|
||||
<button type="button" class="ai-reply-btn inline-flex items-center gap-1.5 shrink-0 px-3 py-1.5 text-xs font-medium rounded-md bg-purple-600 hover:bg-purple-700 text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
data-email-id="<%= e.id %>" data-app-id="<%= application.id %>" data-target="reply-<%= e.id %>">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path></svg>
|
||||
<span class="ai-reply-label">KI-Antwort generieren</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Nachricht</label>
|
||||
<textarea name="body" rows="12" required
|
||||
class="reply-body w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-800 dark:text-white text-sm leading-relaxed"><%= e.reply_quote ? '\n\n' + e.reply_quote : '' %></textarea>
|
||||
<% if (basisAnhaenge && basisAnhaenge.length) { %>
|
||||
@@ -861,46 +875,100 @@
|
||||
});
|
||||
});
|
||||
|
||||
// Generate an AI draft reply and drop it into the reply form.
|
||||
// KI-Antwort: Der Nutzer gibt vor, was drinstehen soll; die KI schreibt
|
||||
// die Mail dazu. Steht schon ein Entwurf im Feld, geht er mit — dann
|
||||
// überarbeitet die KI ihn entlang der neuen Vorgabe, statt ihn (und die
|
||||
// eigenen Änderungen daran) wegzuwerfen.
|
||||
document.querySelectorAll('.ai-reply-btn').forEach((btn) => {
|
||||
const form = document.getElementById(btn.dataset.target);
|
||||
const typSelect = form ? form.querySelector('.ai-reply-typ') : null;
|
||||
if (!form) return;
|
||||
const typSelect = form.querySelector('.ai-reply-typ');
|
||||
const promptFeld = form.querySelector('.ai-reply-prompt');
|
||||
const status = form.querySelector('.ai-reply-status');
|
||||
const body = form.querySelector('.reply-body');
|
||||
const subject = form.querySelector('.reply-subject');
|
||||
const label = btn.querySelector('.ai-reply-label');
|
||||
const syncLabel = () => {
|
||||
if (!label || !typSelect) return;
|
||||
label.textContent = typSelect.value === 'absage' ? 'KI-Absage generieren' : 'KI-Antwort generieren';
|
||||
|
||||
// Das zitierte Original steht unter dem Entwurf und ist nicht Teil davon.
|
||||
let zitat = (body ? body.value : '').trim();
|
||||
|
||||
const entwurfText = () => {
|
||||
let t = body ? body.value : '';
|
||||
if (zitat && t.trimEnd().endsWith(zitat)) t = t.trimEnd().slice(0, -zitat.length);
|
||||
return t.trim();
|
||||
};
|
||||
if (typSelect) { typSelect.addEventListener('change', syncLabel); syncLabel(); }
|
||||
btn.addEventListener('click', async () => {
|
||||
if (!form) return;
|
||||
|
||||
const syncLabel = () => {
|
||||
if (!label) return;
|
||||
const absage = typSelect && typSelect.value === 'absage';
|
||||
const ueberarbeiten = entwurfText().length > 0;
|
||||
label.textContent = ueberarbeiten
|
||||
? 'Entwurf überarbeiten'
|
||||
: (absage ? 'KI-Absage generieren' : 'KI-Antwort generieren');
|
||||
};
|
||||
|
||||
const melde = (text, art) => {
|
||||
if (!status) return;
|
||||
status.textContent = text;
|
||||
status.className = 'ai-reply-status text-xs ' + (
|
||||
art === 'fehler' ? 'text-red-600 dark:text-red-400'
|
||||
: art === 'ok' ? 'text-green-700 dark:text-green-400'
|
||||
: 'text-gray-500 dark:text-gray-400');
|
||||
};
|
||||
|
||||
if (typSelect) typSelect.addEventListener('change', syncLabel);
|
||||
if (body) body.addEventListener('input', syncLabel);
|
||||
syncLabel();
|
||||
|
||||
const generieren = async () => {
|
||||
const typ = typSelect ? typSelect.value : 'antwort';
|
||||
const hinweise = promptFeld ? promptFeld.value.trim() : '';
|
||||
const entwurf = entwurfText();
|
||||
const prev = label ? label.textContent : '';
|
||||
if (label) label.textContent = 'Generiere …';
|
||||
if (label) label.textContent = entwurf ? 'Überarbeite …' : 'Generiere …';
|
||||
btn.disabled = true;
|
||||
melde(entwurf
|
||||
? 'Die KI überarbeitet den Entwurf nach deiner Vorgabe …'
|
||||
: 'Die KI schreibt die Antwort …', 'info');
|
||||
try {
|
||||
const res = await fetch('/bewerbung/' + btn.dataset.appId + '/email/ai-reply', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email_id: btn.dataset.emailId, typ }),
|
||||
body: JSON.stringify({ email_id: btn.dataset.emailId, typ, hinweise, entwurf }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Fehler');
|
||||
const body = form.querySelector('.reply-body');
|
||||
const subject = form.querySelector('.reply-subject');
|
||||
if (body && data.text) {
|
||||
// Generated reply on top, quoted original beneath — like a mail client.
|
||||
body.value = data.text + (data.quote ? '\n\n' + data.quote : '');
|
||||
if (data.quote) zitat = data.quote;
|
||||
// Entwurf oben, zitiertes Original darunter — wie im Mailprogramm.
|
||||
body.value = data.text + (zitat ? '\n\n' + zitat : '');
|
||||
body.focus();
|
||||
try { body.setSelectionRange(data.text.length, data.text.length); } catch (e) {}
|
||||
}
|
||||
if (subject && data.betreff) subject.value = data.betreff;
|
||||
melde(hinweise
|
||||
? 'Entwurf nach deiner Vorgabe erstellt – prüfen, anpassen, senden.'
|
||||
: 'Entwurf erstellt. Für Änderungen oben vorgeben, was anders sein soll.', 'ok');
|
||||
syncLabel();
|
||||
} catch (e) {
|
||||
alert('KI-Antwort fehlgeschlagen: ' + e.message);
|
||||
} finally {
|
||||
melde('KI-Antwort fehlgeschlagen: ' + (e.message || 'Unbekannter Fehler'), 'fehler');
|
||||
if (label) label.textContent = prev;
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
syncLabel();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
btn.addEventListener('click', generieren);
|
||||
// Strg/Cmd + Enter im Prompt-Feld generiert direkt.
|
||||
if (promptFeld) {
|
||||
promptFeld.addEventListener('keydown', (ev) => {
|
||||
if (ev.key === 'Enter' && (ev.ctrlKey || ev.metaKey)) {
|
||||
ev.preventDefault();
|
||||
if (!btn.disabled) generieren();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// While documents are being generated, poll for completion and
|
||||
|
||||
+244
-38
@@ -13,8 +13,15 @@
|
||||
<svg class="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 dark:text-gray-500 pointer-events-none" 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>
|
||||
<input id="firmaSearch" type="text" placeholder="Firma suchen …" autocomplete="off"
|
||||
class="w-full pl-12 pr-10 py-3 rounded-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-800 dark:text-white shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition">
|
||||
<input id="firmaSearch" type="search" role="searchbox" autocomplete="off" spellcheck="false"
|
||||
placeholder="Suchen: Firma, Stelle, Notizen, E-Mails …"
|
||||
aria-label="Bewerbungen und E-Mails durchsuchen"
|
||||
aria-controls="searchResults" aria-expanded="false"
|
||||
class="w-full pl-12 pr-20 py-3 rounded-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-800 dark:text-white shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition">
|
||||
<kbd id="firmaSearchHint"
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 hidden sm:inline-block px-1.5 py-0.5 rounded border border-gray-300 dark:border-gray-600 text-[11px] font-sans text-gray-400 dark:text-gray-500 pointer-events-none">/</kbd>
|
||||
<span id="firmaSearchSpinner"
|
||||
class="hidden absolute right-10 top-1/2 -translate-y-1/2 w-4 h-4 rounded-full border-2 border-gray-300 dark:border-gray-600 border-t-blue-500 animate-spin"></span>
|
||||
<button id="firmaSearchClear" type="button" aria-label="Suche zurücksetzen"
|
||||
class="hidden absolute right-3 top-1/2 -translate-y-1/2 p-1 rounded-full text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 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="M6 18L18 6M6 6l12 12"></path></svg>
|
||||
@@ -43,9 +50,26 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Suchergebnisse (Volltext über Bewerbungen + E-Mails, /api/suche) -->
|
||||
<div id="searchPanel" class="hidden bg-white dark:bg-gray-800 rounded-lg shadow-md mb-8">
|
||||
<div class="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 px-6 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||
<p id="searchSummary" class="text-sm text-gray-600 dark:text-gray-300"></p>
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500">
|
||||
Durchsucht Firma, Stelle, Notizen, Stellenbeschreibung und die E-Mail-Korrespondenz.
|
||||
<span class="hidden sm:inline">Verfeinern: <code>firma:bosch</code>, <code>von:müller</code>, <code>"genauer wortlaut"</code>, <code>-zeitarbeit</code></span>
|
||||
</p>
|
||||
</div>
|
||||
<ul id="searchResults" role="listbox" aria-label="Suchergebnisse" class="divide-y divide-gray-100 dark:divide-gray-700/60"></ul>
|
||||
<div id="searchEmpty" class="hidden px-6 py-12 text-center text-gray-500 dark:text-gray-400">
|
||||
<p>Nichts gefunden für „<span id="searchEmptyTerm"></span>".</p>
|
||||
<p class="mt-2 text-sm">Gesucht wird auch in E-Mails – prüfe die Schreibweise oder suche nach einem Teilwort.</p>
|
||||
</div>
|
||||
<div id="searchError" class="hidden px-6 py-8 text-center text-sm text-red-600 dark:text-red-400"></div>
|
||||
</div>
|
||||
|
||||
<!-- Upcoming appointments (from the application calendar) -->
|
||||
<% if (typeof kommendeTermine !== 'undefined' && kommendeTermine && kommendeTermine.length) { %>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-8">
|
||||
<div id="termineCard" class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-8">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<svg class="w-5 h-5 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path></svg>
|
||||
<h2 class="text-lg font-semibold text-gray-800 dark:text-white">Anstehende Termine</h2>
|
||||
@@ -106,7 +130,7 @@
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
</style>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-x-auto">
|
||||
<div id="listCard" class="bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-x-auto">
|
||||
<div class="md:min-w-[760px]">
|
||||
<!-- Column labels (desktop only — on phones the rows become stacked cards) -->
|
||||
<div class="app-head app-grid px-6 py-3 bg-gray-50 dark:bg-gray-700 text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
@@ -170,7 +194,7 @@
|
||||
const hatVerlauf = app.verlauf && app.verlauf.length > 0;
|
||||
const hatDetails = hatNotizen || hatInterne || hatVerlauf;
|
||||
%>
|
||||
<div class="app-row border-t border-gray-100 dark:border-gray-700/60 <%= hatDetails ? 'has-details' : '' %>" data-search="<%= ((app.firma || '') + ' ' + (app.stelle || '')).toLowerCase().replace(/"/g, '') %>">
|
||||
<div class="app-row border-t border-gray-100 dark:border-gray-700/60 <%= hatDetails ? 'has-details' : '' %>">
|
||||
<!-- Eine Zeile pro Bewerbung -->
|
||||
<div class="app-line app-grid px-6 py-3 text-sm transition-colors <%= hatDetails ? 'cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700/50' : '' %>">
|
||||
<span class="flex items-center justify-center">
|
||||
@@ -280,9 +304,6 @@
|
||||
</section>
|
||||
<% }); %>
|
||||
</div>
|
||||
<div id="searchNoResults" class="px-6 py-12 text-center text-gray-500 dark:text-gray-400 border-t border-gray-200 dark:border-gray-700" style="display:none">
|
||||
<p>Keine Firma gefunden für „<span id="searchNoResultsTerm"></span>".</p>
|
||||
</div>
|
||||
<% } else { %>
|
||||
<div class="px-6 py-12 text-center text-gray-500 dark:text-gray-400 border-t border-gray-200 dark:border-gray-700">
|
||||
<p>Keine Bewerbungen gefunden.</p>
|
||||
@@ -325,51 +346,236 @@
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Live-Firmensuche: filtert die Bewerbungsliste nach Firma/Stelle ----
|
||||
// ---- Volltextsuche (/api/suche): Bewerbungen + E-Mail-Korrespondenz ----
|
||||
// Die Liste unten bleibt der Normalzustand; sobald gesucht wird, tritt an
|
||||
// ihre Stelle die gerankte Trefferliste mit Fundstellen.
|
||||
var input = document.getElementById('firmaSearch');
|
||||
if (!input) return;
|
||||
var clear = document.getElementById('firmaSearchClear');
|
||||
var sections = qsa('.status-group');
|
||||
var noResults = document.getElementById('searchNoResults');
|
||||
var noResultsTerm = document.getElementById('searchNoResultsTerm');
|
||||
var hint = document.getElementById('firmaSearchHint');
|
||||
var spinner = document.getElementById('firmaSearchSpinner');
|
||||
var panel = document.getElementById('searchPanel');
|
||||
var results = document.getElementById('searchResults');
|
||||
var summary = document.getElementById('searchSummary');
|
||||
var empty = document.getElementById('searchEmpty');
|
||||
var emptyTerm = document.getElementById('searchEmptyTerm');
|
||||
var fehler = document.getElementById('searchError');
|
||||
var listCard = document.getElementById('listCard');
|
||||
var termineCard = document.getElementById('termineCard');
|
||||
|
||||
function filter() {
|
||||
var q = input.value.trim().toLowerCase();
|
||||
if (clear) clear.classList.toggle('hidden', !q);
|
||||
var anyVisible = false;
|
||||
var MIN = 2; // kürzere Eingaben liefern nur Rauschen
|
||||
var MARK_START = '\u0002'; // Fundstellen-Marker aus lib/suche.js
|
||||
var MARK_END = '\u0003';
|
||||
var timer = null;
|
||||
var laufend = null; // AbortController der offenen Anfrage
|
||||
var aktiv = -1; // per Tastatur ausgewählter Treffer
|
||||
|
||||
sections.forEach(function (sec) {
|
||||
var rows = qsa('.app-row', sec);
|
||||
var visible = 0;
|
||||
rows.forEach(function (row) {
|
||||
var match = !q || (row.dataset.search || '').indexOf(q) !== -1;
|
||||
row.style.display = match ? '' : 'none';
|
||||
if (match) visible++;
|
||||
});
|
||||
if (visible) anyVisible = true;
|
||||
function escapeHtml(s) {
|
||||
return String(s == null ? '' : s)
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
var countEl = sec.querySelector('[data-group-count]');
|
||||
if (countEl) countEl.textContent = visible;
|
||||
sec.style.display = (visible === 0 && q) ? 'none' : '';
|
||||
// Snippet -> HTML: erst escapen (Mailtext ist Fremdinhalt), dann die
|
||||
// Marker durch <mark> ersetzen. Andersherum wäre es eine XSS-Lücke.
|
||||
function snippetHtml(s) {
|
||||
return escapeHtml(s)
|
||||
.split(escapeHtml(MARK_START)).join('<mark class="bg-yellow-200 dark:bg-yellow-500/40 dark:text-white rounded-sm px-0.5">')
|
||||
.split(escapeHtml(MARK_END)).join('</mark>');
|
||||
}
|
||||
|
||||
// Während der Suche passende Gruppen aufklappen, nach dem Löschen alles wieder zu.
|
||||
var body = sec.querySelector('.group-body');
|
||||
var chev = sec.querySelector('.group-toggle .chevron');
|
||||
setOpen(body, chev, !!q && visible > 0);
|
||||
function datumDe(iso) {
|
||||
if (!iso) return '';
|
||||
var d = new Date(iso);
|
||||
return isNaN(d) ? '' : d.toLocaleDateString('de-DE');
|
||||
}
|
||||
|
||||
// "Frau Müller <m.mueller@bosch.de>" -> "Frau Müller"
|
||||
function absender(v) {
|
||||
var s = String(v || '').trim();
|
||||
var m = s.match(/^\s*"?([^"<]+?)"?\s*<[^>]+>\s*$/);
|
||||
return (m ? m[1].trim() : s) || '';
|
||||
}
|
||||
|
||||
// Woher der Treffer stammt — die Herkunft ist bei einer Mailsuche die
|
||||
// eigentliche Information ("steht in der Mail von Frau Müller").
|
||||
function herkunft(f) {
|
||||
if (f.quelle !== 'email' || !f.email) return f.feld_label || 'Bewerbung';
|
||||
var von = absender(f.email.richtung === 'out' ? f.email.an : f.email.von);
|
||||
var d = datumDe(f.email.datum);
|
||||
return 'E-Mail' + (von ? (f.email.richtung === 'out' ? ' an ' : ' von ') + von : '') + (d ? ', ' + d : '');
|
||||
}
|
||||
|
||||
function trefferZeile(t, i) {
|
||||
var b = t.bewerbung || {};
|
||||
var li = document.createElement('li');
|
||||
li.setAttribute('role', 'option');
|
||||
li.dataset.index = String(i);
|
||||
|
||||
var a = document.createElement('a');
|
||||
a.href = t.typ === 'email' && !t.bewerbung_id ? '/postfach' : '/bewerbung/' + t.bewerbung_id;
|
||||
a.className = 'block px-6 py-4 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors focus:outline-none focus:bg-gray-50 dark:focus:bg-gray-700/50';
|
||||
|
||||
var kopf = document.createElement('div');
|
||||
kopf.className = 'flex flex-wrap items-baseline gap-x-2 gap-y-1';
|
||||
var titel = document.createElement('span');
|
||||
titel.className = 'font-medium text-gray-900 dark:text-white';
|
||||
titel.textContent = t.bewerbung_id ? (b.firma || '(ohne Firma)') : 'E-Mail im Postfach';
|
||||
kopf.appendChild(titel);
|
||||
if (b.stelle) {
|
||||
var stelle = document.createElement('span');
|
||||
stelle.className = 'text-sm text-gray-600 dark:text-gray-300';
|
||||
stelle.textContent = '· ' + b.stelle;
|
||||
kopf.appendChild(stelle);
|
||||
}
|
||||
if (b.status) {
|
||||
var st = document.createElement('span');
|
||||
st.className = 'px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200';
|
||||
st.textContent = b.status;
|
||||
kopf.appendChild(st);
|
||||
}
|
||||
if (b.datum) {
|
||||
var dt = document.createElement('span');
|
||||
dt.className = 'ml-auto text-xs text-gray-400 dark:text-gray-500 whitespace-nowrap';
|
||||
dt.textContent = datumDe(b.datum);
|
||||
kopf.appendChild(dt);
|
||||
}
|
||||
a.appendChild(kopf);
|
||||
|
||||
(t.fundstellen || []).forEach(function (f) {
|
||||
var zeile = document.createElement('p');
|
||||
zeile.className = 'mt-1.5 text-sm text-gray-600 dark:text-gray-300 flex flex-wrap items-baseline gap-x-2';
|
||||
var wo = document.createElement('span');
|
||||
wo.className = 'shrink-0 text-xs uppercase tracking-wide ' +
|
||||
(f.quelle === 'email' ? 'text-indigo-600 dark:text-indigo-400' : 'text-gray-400 dark:text-gray-500');
|
||||
wo.textContent = herkunft(f);
|
||||
var text = document.createElement('span');
|
||||
text.className = 'min-w-0 break-words';
|
||||
text.innerHTML = snippetHtml(f.snippet); // vorher escaped, siehe snippetHtml
|
||||
zeile.appendChild(wo);
|
||||
zeile.appendChild(text);
|
||||
a.appendChild(zeile);
|
||||
});
|
||||
|
||||
if (noResults) {
|
||||
noResults.style.display = (!anyVisible && q) ? '' : 'none';
|
||||
if (noResultsTerm) noResultsTerm.textContent = input.value.trim();
|
||||
li.appendChild(a);
|
||||
return li;
|
||||
}
|
||||
|
||||
function markiere(i) {
|
||||
var zeilen = qsa('li', results);
|
||||
if (!zeilen.length) return;
|
||||
aktiv = (i + zeilen.length) % zeilen.length;
|
||||
zeilen.forEach(function (li, n) {
|
||||
li.classList.toggle('bg-blue-50', n === aktiv);
|
||||
li.classList.toggle('dark:bg-gray-700/60', n === aktiv);
|
||||
li.setAttribute('aria-selected', n === aktiv ? 'true' : 'false');
|
||||
});
|
||||
zeilen[aktiv].scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
|
||||
function zeigeListe() {
|
||||
panel.classList.add('hidden');
|
||||
if (listCard) listCard.classList.remove('hidden');
|
||||
if (termineCard) termineCard.classList.remove('hidden');
|
||||
input.setAttribute('aria-expanded', 'false');
|
||||
aktiv = -1;
|
||||
}
|
||||
|
||||
function zeigeTreffer(daten) {
|
||||
var treffer = daten.treffer || [];
|
||||
results.innerHTML = '';
|
||||
treffer.forEach(function (t, i) { results.appendChild(trefferZeile(t, i)); });
|
||||
|
||||
summary.textContent = treffer.length
|
||||
? treffer.length + (treffer.length === 1 ? ' Treffer' : ' Treffer') + ' für „' + daten.q.trim() + '"'
|
||||
: 'Keine Treffer';
|
||||
empty.classList.toggle('hidden', treffer.length > 0);
|
||||
if (emptyTerm) emptyTerm.textContent = daten.q.trim();
|
||||
fehler.classList.add('hidden');
|
||||
|
||||
panel.classList.remove('hidden');
|
||||
if (listCard) listCard.classList.add('hidden');
|
||||
if (termineCard) termineCard.classList.add('hidden');
|
||||
input.setAttribute('aria-expanded', 'true');
|
||||
aktiv = -1;
|
||||
}
|
||||
|
||||
function zeigeFehler(msg) {
|
||||
results.innerHTML = '';
|
||||
summary.textContent = '';
|
||||
empty.classList.add('hidden');
|
||||
fehler.textContent = msg;
|
||||
fehler.classList.remove('hidden');
|
||||
panel.classList.remove('hidden');
|
||||
if (listCard) listCard.classList.add('hidden');
|
||||
if (termineCard) termineCard.classList.add('hidden');
|
||||
}
|
||||
|
||||
async function suchen(q) {
|
||||
if (laufend) laufend.abort(); // veraltete Antwort darf die neue nicht überholen
|
||||
laufend = new AbortController();
|
||||
spinner.classList.remove('hidden');
|
||||
try {
|
||||
var res = await fetch('/api/suche?q=' + encodeURIComponent(q), {
|
||||
headers: { Accept: 'application/json' }, signal: laufend.signal,
|
||||
});
|
||||
if (!res.ok) throw new Error('Die Suche ist fehlgeschlagen (' + res.status + ').');
|
||||
zeigeTreffer(await res.json());
|
||||
} catch (e) {
|
||||
if (e.name === 'AbortError') return; // durch den nächsten Tastendruck ersetzt
|
||||
zeigeFehler(e.message || 'Die Suche ist fehlgeschlagen.');
|
||||
} finally {
|
||||
spinner.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
input.addEventListener('input', filter);
|
||||
if (clear) clear.addEventListener('click', function () {
|
||||
function getippt() {
|
||||
var q = input.value.trim();
|
||||
clear.classList.toggle('hidden', !q);
|
||||
if (hint) hint.classList.toggle('hidden', !!q);
|
||||
clearTimeout(timer);
|
||||
if (laufend) { laufend.abort(); laufend = null; }
|
||||
spinner.classList.add('hidden');
|
||||
if (q.length < MIN) { zeigeListe(); return; }
|
||||
// Entprellt: erst tippen lassen, dann eine Anfrage stellen.
|
||||
timer = setTimeout(function () { suchen(q); }, 180);
|
||||
}
|
||||
|
||||
input.addEventListener('input', getippt);
|
||||
|
||||
input.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape') {
|
||||
input.value = '';
|
||||
getippt();
|
||||
input.blur();
|
||||
return;
|
||||
}
|
||||
if (panel.classList.contains('hidden')) return;
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); markiere(aktiv + 1); }
|
||||
else if (e.key === 'ArrowUp') { e.preventDefault(); markiere(aktiv - 1); }
|
||||
else if (e.key === 'Enter') {
|
||||
var zeilen = qsa('li', results);
|
||||
var ziel = zeilen[aktiv >= 0 ? aktiv : 0];
|
||||
var link = ziel && ziel.querySelector('a');
|
||||
if (link) { e.preventDefault(); link.click(); }
|
||||
}
|
||||
});
|
||||
|
||||
clear.addEventListener('click', function () {
|
||||
input.value = '';
|
||||
filter();
|
||||
getippt();
|
||||
input.focus();
|
||||
});
|
||||
|
||||
// "/" springt ins Suchfeld — solange man nicht ohnehin schon tippt.
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key !== '/' || e.metaKey || e.ctrlKey || e.altKey) return;
|
||||
var t = e.target;
|
||||
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT' || t.isContentEditable)) return;
|
||||
e.preventDefault();
|
||||
input.focus();
|
||||
input.select();
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</main>
|
||||
|
||||
+73
-3
@@ -105,9 +105,27 @@
|
||||
</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">Feinschliff</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1 mb-5">
|
||||
Optional. Rollen und Technologien kommen aus deinem Lebenslauf – hier ergänzt du nur Leitplanken.
|
||||
<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">
|
||||
@@ -223,6 +241,58 @@
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user