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:
co-authored by
Claude Opus 5.5
parent
0d41d46a3c
commit
e35c7518db
+3
-1
@@ -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
@@ -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
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user