feat(ki): Einstellungen -> KI mit OpenAI-Schluessel und Modellwahl

Neuer Admin-Reiter "KI": zeigt die letzten vier Zeichen des hinterlegten
OpenAI-Schluessels samt Quelle, nimmt einen neuen an (vorher gegen
/v1/models geprueft) und laesst je Einsatzzweck das Modell per Dropdown
waehlen - Scanner-Bewertung, Abgleich "bereits beworben", erweiterte Suche.
Die Auswahl kommt live von OpenAI, Vorgaben sind die bisher fest
verdrahteten Modelle. Reasoning-Modelle bekommen reasoning_effort statt
temperature, damit ein Wechsel keinen 400er ausloest.

Schluesselfeld aus dem Scanner-Reiter entfernt; Doku in docs/14-ki.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sascha Wolf
2026-09-23 10:31:55 +02:00
co-authored by Claude Opus 5.5
parent 0d41d46a3c
commit e35c7518db
26 changed files with 930 additions and 144 deletions
+3 -1
View File
@@ -92,7 +92,9 @@ SCANNER_SCHEDULER=true
# 0 = sofort loslegen.
SCANNER_SCHEDULER_OFFSET=0
# Fallback fuer den OpenAI-Key; bevorzugt wird der Wert aus den Einstellungen.
# Fallback fuer den OpenAI-Key; bevorzugt wird der Wert aus Einstellungen > KI.
# Dort wird auch das Modell je Einsatzzweck gewaehlt.
# OPENAI_MODEL setzt nur die Vorgabe der erweiterten Suche.
# OPENAI_API_KEY=
# ---------------------------------------------------------------------------
+1 -1
View File
@@ -12,7 +12,7 @@
"start": "node src/server.js",
"dev": "node --watch src/server.js",
"migrate": "node src/migrate.js",
"test": "node test/mail.test.mjs && node test/acquisition.test.mjs && node test/userFields.test.mjs && node test/linkedinPhoto.test.mjs && node test/sessionUser.test.mjs && node test/sso.test.mjs && node test/msSecret.test.mjs"
"test": "node test/mail.test.mjs && node test/acquisition.test.mjs && node test/userFields.test.mjs && node test/linkedinPhoto.test.mjs && node test/sessionUser.test.mjs && node test/sso.test.mjs && node test/msSecret.test.mjs && node test/ai.test.mjs"
},
"dependencies": {
"bcryptjs": "^2.4.3",
+216 -20
View File
@@ -1,13 +1,22 @@
import Setting from './scanner/models/Setting.js';
import { getSettings, setSetting } from './settings.js';
/*
* Anbindung an OpenAI fuer das CRM (bisher nutzte nur der Scanner ein Modell).
* Anbindung an OpenAI - Schluessel, Modellwahl und der eine Aufruf, den das
* CRM selbst macht (erweiterte Suche).
*
* Der Schluessel wird NICHT zum zweiten Mal gepflegt: es ist derselbe, den der
* Scanner benutzt - Einstellungen -> Scanner, gespeichert als `openai_api_key`
* in der Scanner-Tabelle `settings`. Ein zweiter Schluessel an zweiter Stelle
* waere eine Quelle fuer den Fall "die eine Funktion geht, die andere nicht,
* und keiner weiss warum".
* Gepflegt wird alles in Einstellungen -> KI (nur Administratoren):
*
* Schluessel Es gibt genau einen. Er liegt weiterhin als `openai_api_key` in
* der Scanner-Tabelle `settings`, wo er schon immer lag - so
* muss nichts umziehen, und Scanner wie CRM lesen dieselbe
* Stelle. Ersatzweise gilt OPENAI_API_KEY aus der .env.
* Ausgeliefert werden nur die letzten vier Zeichen.
*
* Modelle Je Einsatzzweck eins (AI_PURPOSES), gespeichert in
* crm_settings als `ai_model_<zweck>`. Ohne Eintrag gilt die
* Vorgabe - das ist genau das Modell, das vor der Einstellung
* fest im Code stand.
*
* Bewusst ohne das `openai`-Paket: gebraucht wird ein einziger Aufruf, und
* `fetch` ist in Node eingebaut. Der Scanner behaelt sein SDK - dort haengt
@@ -16,11 +25,39 @@ import Setting from './scanner/models/Setting.js';
* Der Schluessel bleibt auf dem Server. Der Browser spricht nie mit OpenAI.
*/
const ENDPOINT = 'https://api.openai.com/v1/chat/completions';
const API_BASE = 'https://api.openai.com/v1';
const TIMEOUT_MS = 120_000;
const KEY_SETTING = 'openai_api_key';
/** Dasselbe Modell wie im Scanner - was dort ausreicht, reicht hier auch. */
export const AI_MODEL = process.env.OPENAI_MODEL || 'gpt-4o-mini';
/**
* Einsatzzwecke mit ihrer Vorgabe. Reihenfolge = Reihenfolge in der Oberflaeche.
* Die Vorgabe der Suche laesst sich weiter ueber OPENAI_MODEL setzen.
*/
export const AI_PURPOSES = {
matching: {
label: 'Scanner: Bewertung Lebenslauf gegen Projekt',
fallback: 'gpt-4o-mini',
},
applied: {
label: 'Scanner: Abgleich „bereits beworben“',
fallback: 'gpt-5.6-luna',
},
search: {
label: 'Erweiterte Suche',
fallback: process.env.OPENAI_MODEL || 'gpt-4o-mini',
},
};
/**
* Auswahl, falls OpenAI die Modellliste nicht liefert (kein Schluessel,
* nicht erreichbar). Die aktuell eingestellten Modelle kommen immer dazu.
*/
const FALLBACK_MODELS = ['gpt-5.6-luna', 'gpt-5', 'gpt-5-mini', 'gpt-4.1', 'gpt-4.1-mini', 'gpt-4o', 'gpt-4o-mini'];
const settingKey = (purpose) => `ai_model_${purpose}`;
/** Modellnamen von OpenAI: Buchstaben, Ziffern, Punkt, Bindestrich, Doppelpunkt. */
const MODEL_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,99}$/;
class AiError extends Error {
constructor(message, status = 502) {
@@ -29,16 +66,175 @@ class AiError extends Error {
}
}
/** Der Schluessel des Scanners, ersatzweise aus der Umgebung. */
/* ---------------------------------------------------------------------------
* Schluessel
* ------------------------------------------------------------------------- */
const envKey = () => (process.env.OPENAI_API_KEY || '').trim();
/** Der Schluessel samt Herkunft ('db', 'env' oder null). */
async function effectiveKey() {
const fromDb = ((await Setting.getVal(KEY_SETTING)) || '').trim();
if (fromDb) return { value: fromDb, source: 'db' };
const fromEnv = envKey();
return fromEnv ? { value: fromEnv, source: 'env' } : { value: '', source: null };
}
/** Der gueltige Schluessel - Datenbank vor .env. */
export async function aiKey() {
const fromDb = await Setting.getVal('openai_api_key');
return (fromDb || process.env.OPENAI_API_KEY || '').trim();
return (await effectiveKey()).value;
}
export async function aiConfigured() {
return Boolean(await aiKey());
}
/** Zustand fuer die Oberflaeche: nur die letzten vier Zeichen, nie mehr. */
export async function keyStatus() {
const { value, source } = await effectiveKey();
return {
configured: Boolean(value),
source,
last4: value.length >= 12 ? value.slice(-4) : null,
env_fallback: Boolean(envKey()),
};
}
/** Speichert einen neuen Schluessel. Pruefen muss der Aufrufer vorher (verifyKey). */
export async function saveKey(key) {
await Setting.setVal(KEY_SETTING, key);
return keyStatus();
}
/** Verwirft den hinterlegten Schluessel - ab dann gilt wieder die .env. */
export async function clearKey() {
await Setting.setVal(KEY_SETTING, '');
return keyStatus();
}
async function openAiGet(path, key) {
let response;
try {
response = await fetch(`${API_BASE}${path}`, {
headers: { Authorization: `Bearer ${key}` },
signal: AbortSignal.timeout(20_000),
});
} catch (err) {
return { ok: false, error: `OpenAI ist nicht erreichbar: ${err.message}` };
}
const data = await response.json().catch(() => null);
if (!response.ok) {
return { ok: false, error: data?.error?.message || `HTTP ${response.status}` };
}
return { ok: true, data };
}
/**
* Probiert einen Schluessel aus, bevor er gespeichert wird. Die Modellliste
* abzurufen kostet nichts und scheitert bei falschem Schluessel mit 401.
*/
export async function verifyKey(candidate) {
const result = await openAiGet('/models', candidate);
return result.ok ? { ok: true } : { ok: false, error: result.error };
}
/* ---------------------------------------------------------------------------
* Modelle
* ------------------------------------------------------------------------- */
export function isValidModel(value) {
return MODEL_PATTERN.test(String(value ?? ''));
}
/**
* Reasoning-Modelle (o1, o3, o4-mini, gpt-5 ...) nehmen kein `temperature`,
* dafuer `reasoning_effort`. Wer in den Einstellungen umstellt, soll keinen
* 400er von OpenAI bekommen - die Aufrufer fragen deshalb hier nach.
*/
export function isReasoningModel(model) {
return /^(o\d|gpt-5)/i.test(String(model ?? ''));
}
/** Eingestelltes Modell je Zweck, sonst die Vorgabe. */
export async function modelFor(purpose) {
const entry = AI_PURPOSES[purpose];
if (!entry) throw new Error(`Unbekannter KI-Zweck: ${purpose}`);
let stored = '';
try {
const values = await getSettings({ [settingKey(purpose)]: '' });
stored = values[settingKey(purpose)] || '';
} catch (err) {
console.warn('[KI] Modellwahl nicht lesbar, nutze Vorgabe:', err.message);
}
return isValidModel(stored) ? stored : entry.fallback;
}
/** Alle Zwecke mit aktuellem Modell und Vorgabe. */
export async function modelSettings() {
const result = {};
for (const [purpose, entry] of Object.entries(AI_PURPOSES)) {
result[purpose] = { label: entry.label, model: await modelFor(purpose), fallback: entry.fallback };
}
return result;
}
/** Speichert die Modellwahl; nur bekannte Zwecke, nur plausible Namen. */
export async function saveModels(input) {
for (const purpose of Object.keys(AI_PURPOSES)) {
if (input?.[purpose] === undefined) continue;
const value = String(input[purpose]).trim();
if (!isValidModel(value)) {
throw new AiError(`Ungültiger Modellname: ${value || '(leer)'}`, 400);
}
await setSetting(settingKey(purpose), value);
}
return modelSettings();
}
/**
* Textmodelle, die sich fuer Chat Completions eignen. OpenAI liefert auch
* Audio-, Bild-, Embedding- und Moderationsmodelle - die gehoeren nicht in die
* Auswahl.
*/
const EXCLUDED = /(audio|realtime|tts|transcribe|whisper|dall-e|image|embedding|moderation|search|instruct|codex|computer-use|babbage|davinci)/i;
const CHAT_MODEL = /^(gpt-|o\d|chatgpt-)/i;
/**
* Auswahl fuer das Dropdown. Ohne Schluessel oder bei Fehler die feste
* Liste - mit Hinweis, damit die Oberflaeche es sagen kann.
*/
export async function availableModels() {
const current = Object.values(await modelSettings()).map((entry) => entry.model);
const key = await aiKey();
let models = [];
let source = 'fallback';
let error = null;
if (key) {
const result = await openAiGet('/models', key);
if (result.ok && Array.isArray(result.data?.data)) {
models = result.data.data
.map((entry) => String(entry.id))
.filter((id) => CHAT_MODEL.test(id) && !EXCLUDED.test(id));
source = 'openai';
} else {
error = result.error || 'Modellliste nicht lesbar.';
}
}
if (models.length === 0) models = [...FALLBACK_MODELS];
return {
models: [...new Set([...models, ...current])].sort((a, b) => a.localeCompare(b, 'en', { numeric: true })),
source,
error,
};
}
/* ---------------------------------------------------------------------------
* Aufruf
* ------------------------------------------------------------------------- */
/**
* Ein Aufruf an das Modell, Antwort als JSON.
*
@@ -46,32 +242,32 @@ export async function aiConfigured() {
* @param {string} options.system
* @param {string} options.user
* @param {number} [options.maxTokens]
* @param {string} [options.model] - sonst das Modell der Suche
*/
export async function askModelJson({ system, user, maxTokens }) {
export async function askModelJson({ system, user, maxTokens, model }) {
const key = await aiKey();
if (!key) {
throw new AiError(
'Es ist kein OpenAI-Schlüssel hinterlegt (Einstellungen → Scanner).',
503
);
throw new AiError('Es ist kein OpenAI-Schlüssel hinterlegt (Einstellungen → KI).', 503);
}
const body = {
model: AI_MODEL,
model: model || (await modelFor('search')),
messages: [
...(system ? [{ role: 'system', content: system }] : []),
{ role: 'user', content: user },
],
response_format: { type: 'json_object' },
};
if (maxTokens) body.max_tokens = maxTokens;
// max_completion_tokens verstehen alle aktuellen Modelle, max_tokens die
// Reasoning-Modelle nicht.
if (maxTokens) body.max_completion_tokens = maxTokens;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
let response;
try {
response = await fetch(ENDPOINT, {
response = await fetch(`${API_BASE}/chat/completions`, {
method: 'POST',
headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
body: JSON.stringify(body),
+77
View File
@@ -0,0 +1,77 @@
import express from 'express';
import { requireAuth, requireAdmin } from '../middleware/auth.js';
import {
availableModels,
clearKey,
keyStatus,
modelSettings,
saveKey,
saveModels,
verifyKey,
} from '../ai.js';
/*
* Einstellungen -> KI. Nur Administratoren.
*
* Der Schluessel geht nur hinein; heraus kommen seine letzten vier Zeichen,
* damit man erkennt, welcher hinterlegt ist.
*/
const router = express.Router();
router.use(requireAuth, requireAdmin);
// GET /api/ai/settings
router.get('/settings', async (req, res, next) => {
try {
res.json({
key: await keyStatus(),
models: await modelSettings(),
available: await availableModels(),
});
} catch (err) {
next(err);
}
});
// PUT /api/ai/key { key }
router.put('/key', async (req, res, next) => {
const key = String(req.body?.key ?? '').trim();
if (!key) return res.status(400).json({ error: 'Kein Schlüssel übergeben.' });
if (key.length > 500 || /\s/.test(key)) {
return res.status(400).json({ error: 'Das sieht nicht nach einem OpenAI-Schlüssel aus.' });
}
try {
// Erst bei OpenAI ausprobieren, dann speichern: ein vertippter Schluessel
// legt sonst still Scanner und Suche lahm.
const check = await verifyKey(key);
if (!check.ok) {
return res.status(400).json({ error: `OpenAI hat diesen Schlüssel abgelehnt: ${check.error}` });
}
const status = await saveKey(key);
res.json({ ok: true, key: status, available: await availableModels() });
} catch (err) {
next(err);
}
});
// DELETE /api/ai/key - hinterlegten Schluessel verwerfen, .env gilt wieder
router.delete('/key', async (req, res, next) => {
try {
res.json({ ok: true, key: await clearKey(), available: await availableModels() });
} catch (err) {
next(err);
}
});
// PUT /api/ai/models { matching?, applied?, search? }
router.put('/models', async (req, res, next) => {
try {
res.json({ ok: true, models: await saveModels(req.body) });
} catch (err) {
if (err?.status) return res.status(err.status).json({ error: err.message });
next(err);
}
});
export default router;
+5 -3
View File
@@ -1,7 +1,7 @@
import express from 'express';
import { pool } from '../db.js';
import { requireAuth } from '../middleware/auth.js';
import { askModelJson, aiConfigured, AI_MODEL } from '../ai.js';
import { askModelJson, aiConfigured, modelFor } from '../ai.js';
import { fetchIconContacts } from './iconCrm.js';
const router = express.Router();
@@ -160,7 +160,7 @@ router.post('/ai', async (req, res, next) => {
if (!question) return res.status(400).json({ error: 'Keine Frage übergeben.' });
if (!(await aiConfigured())) {
return res.status(503).json({
error: 'Es ist kein OpenAI-Schlüssel hinterlegt (Einstellungen → Scanner).',
error: 'Es ist kein OpenAI-Schlüssel hinterlegt (Einstellungen → KI).',
});
}
@@ -202,7 +202,9 @@ router.post('/ai', async (req, res, next) => {
(list) => list.length >= AI_LIMIT
);
const model = await modelFor('search');
const result = await askModelJson({
model,
system: AI_SYSTEM,
user: `Frage: ${question}\n\nBestand:\n${JSON.stringify({
leads: leads.map((row) => ({ ...row, mail_pool: Boolean(row.mail_pool) })),
@@ -225,7 +227,7 @@ router.post('/ai', async (req, res, next) => {
res.json({
answer: String(result?.answer ?? '').trim(),
refs: Array.isArray(result?.refs) ? result.refs.slice(0, 30) : [],
model: AI_MODEL,
model,
truncated,
});
} catch (err) {
+6 -4
View File
@@ -1,6 +1,7 @@
import { OpenAI } from 'openai';
import { pool } from '../../db.js';
import Setting from '../models/Setting.js';
import { aiKey, isReasoningModel, modelFor } from '../../ai.js';
/*
* Abgleich "bereits beworben?".
@@ -19,7 +20,6 @@ import Setting from '../models/Setting.js';
* applied_check_days Zeitraum in Tagen, Vorgabe 14
*/
export const APPLIED_CHECK_MODEL = 'gpt-5.6-luna';
export const APPLIED_CHECK_DEFAULT_DAYS = 14;
const MAX_DAYS = 365;
@@ -101,8 +101,10 @@ const toRef = (candidate, reason) => ({
});
async function askModel(project, candidates) {
const apiKey = (await Setting.getVal('openai_api_key')) || process.env.OPENAI_API_KEY;
const apiKey = await aiKey();
if (!apiKey) throw new Error('OpenAI API Key is not configured.');
// Modell aus Einstellungen -> KI, Vorgabe gpt-5.6-luna.
const model = await modelFor('applied');
const openai = new OpenAI({ apiKey });
@@ -138,13 +140,13 @@ ${JSON.stringify(
)}`;
const response = await openai.chat.completions.create({
model: APPLIED_CHECK_MODEL,
model,
messages: [
{ role: 'system', content: system },
{ role: 'user', content: user },
],
response_format: { type: 'json_object' },
reasoning_effort: 'low',
...(isReasoningModel(model) ? { reasoning_effort: 'low' } : { temperature: 0 }),
});
const parsed = JSON.parse(response.choices[0]?.message?.content || '{}');
@@ -1,5 +1,5 @@
import { OpenAI } from 'openai';
import Setting from '../models/Setting.js';
import { aiKey, isReasoningModel, modelFor } from '../../ai.js';
/**
* Evaluates the match between an agent's CV and a scraped project description using OpenAI.
@@ -16,11 +16,12 @@ export const evaluateMatch = async (cvText, projectDescription) => {
};
}
// Fetch API key from DB, fallback to environment variable
const apiKey = await Setting.getVal('openai_api_key') || process.env.OPENAI_API_KEY;
// Schluessel und Modell kommen aus Einstellungen -> KI.
const apiKey = await aiKey();
if (!apiKey) {
throw new Error('OpenAI API Key is not configured. Please set it in Settings.');
}
const model = await modelFor('matching');
const openai = new OpenAI({ apiKey });
@@ -55,13 +56,14 @@ ${projectDescription}`;
try {
const response = await openai.chat.completions.create({
model: 'gpt-4o-mini',
model,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userContent }
],
response_format: { type: 'json_object' },
temperature: 0.1 // Low temperature for more deterministic analysis
// Reasoning models reject temperature; low temperature keeps classic models deterministic.
...(isReasoningModel(model) ? { reasoning_effort: 'low' } : { temperature: 0.1 })
});
const rawContent = response.choices[0].message.content.trim();
+3
View File
@@ -16,6 +16,7 @@ import mailRoutes from './routes/mail.js';
import integrationRoutes from './routes/integrations.js';
import iconCrmRoutes from './routes/iconCrm.js';
import searchRoutes from './routes/search.js';
import aiRoutes from './routes/ai.js';
import { requireAuth } from './middleware/auth.js';
import { initScannerDatabase } from './scanner/models/index.js';
import { startScheduler } from './scanner/services/scheduler.js';
@@ -61,6 +62,8 @@ app.use('/api/users', userRoutes);
app.use('/api/leads', leadRoutes);
// Globale Suche (Volltext) und die erweiterte Suche mit dem Modell des Scanners.
app.use('/api/search', searchRoutes);
// OpenAI-Schluessel und Modellwahl (Einstellungen -> KI, nur admin).
app.use('/api/ai', aiRoutes);
app.use('/api/connections', connectionRoutes);
app.use('/api/events', eventRoutes);
app.use('/api/opportunities', opportunityRoutes);
+43
View File
@@ -0,0 +1,43 @@
/*
* Prueft die Modellwahl, soweit es ohne Datenbank und OpenAI geht: welche
* Namen angenommen werden und welche Modelle als Reasoning-Modell gelten
* (die bekommen reasoning_effort statt temperature).
*/
const { AI_PURPOSES, isReasoningModel, isValidModel } = await import('../src/ai.js');
let failures = 0;
function check(name, condition) {
if (condition) {
console.log(` ok ${name}`);
} else {
failures += 1;
console.log(` FAIL ${name}`);
}
}
console.log('KI-Einstellungen');
check('Vorgabe Bewertung ist gpt-4o-mini', AI_PURPOSES.matching.fallback === 'gpt-4o-mini');
check('Vorgabe Abgleich ist gpt-5.6-luna', AI_PURPOSES.applied.fallback === 'gpt-5.6-luna');
for (const name of ['gpt-4o-mini', 'gpt-5.6-luna', 'o4-mini', 'gpt-4o-2024-08-06', 'ft:gpt-4o-mini:perinco:x:1']) {
check(`gueltig: ${name}`, isValidModel(name));
}
for (const name of ['', ' gpt-4o', 'gpt 4o', 'gpt-4o;drop', 'x'.repeat(101), null]) {
check(`ungueltig: ${JSON.stringify(name)?.slice(0, 20)}`, !isValidModel(name));
}
for (const name of ['o1', 'o3-mini', 'o4-mini', 'gpt-5', 'gpt-5.6-luna']) {
check(`reasoning: ${name}`, isReasoningModel(name));
}
for (const name of ['gpt-4o-mini', 'gpt-4.1', 'chatgpt-4o-latest']) {
check(`klassisch: ${name}`, !isReasoningModel(name));
}
if (failures) {
console.log(`\n${failures} Pruefung(en) fehlgeschlagen.`);
process.exit(1);
}
console.log('\nAlles gruen.');
process.exit(0);