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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user