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:
2026-07-14 10:56:41 +02:00
co-authored by Claude Opus 4.8
parent 3c7f5d7c74
commit d86e40a6ff
7 changed files with 984 additions and 77 deletions
+92 -11
View File
@@ -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);