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
@@ -97,7 +97,7 @@ Einzelheiten: [13 Anmeldung über Microsoft](13-anmeldung-sso.md).
|
||||
```mermaid
|
||||
flowchart TD
|
||||
L["Anmeldung"] --> R{"Rolle"}
|
||||
R -->|admin| A["Dashboard, Akquise, Opportunities, Interviews, Kontakte, Mails, Scanner<br/>Einstellungen: Nutzerverwaltung, Mein Konto, Akquise, Postfach, Scanner, Whisper"]
|
||||
R -->|admin| A["Dashboard, Akquise, Opportunities, Interviews, Kontakte, Mails, Scanner<br/>Einstellungen: Nutzerverwaltung, Mein Konto, Akquise, Postfach, Anmeldung, Scanner, KI, Whisper"]
|
||||
R -->|user| U["Dashboard, Akquise, Opportunities, Interviews, Kontakte, Mails, Scanner<br/>Einstellungen: Mein Konto, Akquise, Whisper"]
|
||||
```
|
||||
|
||||
|
||||
@@ -382,6 +382,9 @@ erDiagram
|
||||
- **`settings` ist ein Schlüssel-Wert-Speicher.** Geheimnisse (`openai_api_key`,
|
||||
`smtp_pass`) liefert die API nur maskiert als `********` aus; ein leeres oder
|
||||
maskiertes Feld beim Speichern lässt den gespeicherten Wert unberührt.
|
||||
`openai_api_key` wird seit dem 23.09.2026 unter *Einstellungen → KI* gepflegt
|
||||
(siehe [14 KI](14-ki.md)); die Modellwahl dazu steht in `crm_settings` als
|
||||
`ai_model_matching`, `ai_model_applied` und `ai_model_search`.
|
||||
|
||||
## Statuswege
|
||||
|
||||
|
||||
+4
-3
@@ -90,8 +90,9 @@ flowchart TD
|
||||
|
||||
Entscheidungen dahinter:
|
||||
|
||||
- **Eigenes Modell.** Die Bewertung CV gegen Projekt bleibt bei `gpt-4o-mini`;
|
||||
der Abgleich nutzt `gpt-5.6-luna` (Konstante `APPLIED_CHECK_MODEL`). Er muss
|
||||
- **Eigenes Modell.** Die Bewertung CV gegen Projekt nutzt als Vorgabe
|
||||
`gpt-4o-mini`, der Abgleich `gpt-5.6-luna` - beide umstellbar unter
|
||||
*Einstellungen → KI* (siehe [14 KI](14-ki.md)). Der Abgleich muss
|
||||
umformulierte Ausschreibungen als dieselbe erkennen und ähnliche Rollen bei
|
||||
anderem Kunden auseinanderhalten - das ist ein Urteil, kein Textvergleich.
|
||||
Der Schlüssel ist derselbe `openai_api_key`.
|
||||
@@ -284,7 +285,7 @@ Zertifikatswarnung. Der DNS-Eintrag muss beim Anbieter gelöscht werden.
|
||||
| Unpassend | `ScannerRejected.tsx` | Treffer mit Status `rejected` |
|
||||
| Suchagenten | `ScannerAgents.tsx` | Beraterprofile, CV-Upload |
|
||||
| Börsen-Konten | `ScannerPortals.tsx` | Verbindungsstatus, Zugangsdaten, Cookie-Import |
|
||||
| Einstellungen | `settings/ScannerSettings.tsx` | Intervall, Schwellenwerte, Abgleich mit Akquise/Opportunities, OpenAI, Ton, E-Mail |
|
||||
| Einstellungen | `settings/ScannerSettings.tsx` | Intervall, Schwellenwerte, Abgleich mit Akquise/Opportunities, Ton, E-Mail (Schlüssel und Modelle: `settings/AiSettings.tsx`) |
|
||||
|
||||
**Wichtig zum Ton:** Er hängt an der Dashboard-Komponente. Wer auf einen anderen
|
||||
Menüpunkt wechselt, hört nichts - die Komponente wird ausgebaut und die
|
||||
|
||||
+2
-1
@@ -129,7 +129,8 @@ entfallen.
|
||||
| --- | --- |
|
||||
| Anmeldung schlägt fehl | `journalctl -u perinco-crm-backend -n 50`, `JWT_SECRET` gesetzt? |
|
||||
| Scanner-Reiter leer, 500er | `[Scanner] Datenbank nicht erreichbar` im Journal |
|
||||
| Kein Treffer trotz Scan | `scanning_active`, aktive Agenten mit CV, OpenAI-Key gesetzt |
|
||||
| Kein Treffer trotz Scan | `scanning_active`, aktive Agenten mit CV, OpenAI-Key gesetzt (*Einstellungen → KI*) |
|
||||
| KI-Fehler „model not found“ | Modell unter *Einstellungen → KI* auf eines aus der Liste stellen |
|
||||
| Portal `expired` | Cookies neu importieren (Cookie-Editor) |
|
||||
| `Target page ... has been closed` | Dienst wurde mitten im Scrapen neu gestartet |
|
||||
| Backend stirbt beim Scrapen | `MemoryMax` zu klein, `systemctl show ... -p MemoryCurrent` |
|
||||
|
||||
@@ -949,7 +949,8 @@ Sitzung kommt derzeit nur über den Cookie-Import.
|
||||
| **Mein Konto** | alle | Foto, Terminfarbe, Passwort ändern |
|
||||
| **Akquise** | alle | Inhalte der Auswahllisten *Von*, *Kanal*, *Berater* |
|
||||
| **Postfach** | Admin | Verbindung zu Microsoft 365, Abgleich, Kontaktübernahme |
|
||||
| **Scanner** | Admin | Suchintervall, Schwellenwerte, KI-Schlüssel, Ton, E-Mail |
|
||||
| **Scanner** | Admin | Suchintervall, Schwellenwerte, Ton, E-Mail |
|
||||
| **KI** | Admin | OpenAI-Schlüssel, Modell je Einsatzzweck |
|
||||
| **Whisper** | alle | Spracherkennung für das Diktat einrichten |
|
||||
|
||||
Im Reiter **Akquise** stehen die drei Auswahllisten des Akquise-Formulars, jede
|
||||
@@ -976,6 +977,18 @@ Unter **Abgleich mit Akquise und Opportunities** lässt sich die Markierung
|
||||
ein Akquise-Eintrag oder eine Opportunity höchstens sein darf, um verglichen zu
|
||||
werden - in Tagen oder Wochen, Vorgabe zwei Wochen.
|
||||
|
||||
Im Reiter **KI** steht links der **OpenAI-Schlüssel**. Zu sehen ist nur sein
|
||||
Ende (etwa `sk-…x7Qa`) und woher er stammt. Einen neuen trägst du unter *Neuer
|
||||
Schlüssel* ein und klickst **Speichern** - das CRM fragt damit kurz bei OpenAI an
|
||||
und übernimmt ihn nur, wenn OpenAI ihn annimmt. Bis dahin bleibt der alte gültig.
|
||||
|
||||
Rechts wählst du je Einsatzzweck das **Modell**: für die Bewertung im Scanner,
|
||||
für den Abgleich „bereits beworben!“ und für die erweiterte Suche. Die Liste
|
||||
kommt direkt von OpenAI; *(Vorgabe)* markiert das ursprüngliche Modell. Nach
|
||||
**Modellwahl speichern** gilt die Wahl ab dem nächsten Aufruf.
|
||||
|
||||

|
||||
|
||||
Im Reiter **Postfach** steht oben, ob die Verbindung zu Microsoft 365 steht.
|
||||
**Verbindung testen** prüft beides: die Anmeldung *und* den tatsächlichen
|
||||
Zugriff auf `crm@perinco.de`. Schlägt etwas fehl, nennt die Meldung den
|
||||
|
||||
@@ -231,6 +231,30 @@ Eigenheiten, die man kennen muss:
|
||||
Funktionen `mailConfigured()` und `ssoConfigured()` - die Prüfung muss in die
|
||||
Datenbank sehen.
|
||||
|
||||
### `server/src/ai.js`
|
||||
|
||||
OpenAI-Schlüssel, Modellwahl je Einsatzzweck und der Aufruf der erweiterten
|
||||
Suche. Gepflegt unter *Einstellungen → KI*, Details in [14 KI](14-ki.md).
|
||||
|
||||
| Export | Zweck |
|
||||
| --- | --- |
|
||||
| `aiKey()` / `aiConfigured()` | gültiger Schlüssel, Datenbank vor `.env` |
|
||||
| `keyStatus()` | Quelle und letzte vier Zeichen - nie der ganze Schlüssel |
|
||||
| `verifyKey()` / `saveKey()` / `clearKey()` | Probe bei OpenAI, schreiben, zurücknehmen |
|
||||
| `AI_PURPOSES`, `modelFor(zweck)` | Einsatzzwecke mit Vorgabe; eingestelltes Modell oder Vorgabe |
|
||||
| `modelSettings()` / `saveModels()` / `availableModels()` | Modellwahl lesen, schreiben, Auswahlliste von OpenAI |
|
||||
| `isReasoningModel()` | `reasoning_effort` statt `temperature`, in `test/ai.test.mjs` geprüft |
|
||||
| `askModelJson()` | ein Chat-Completions-Aufruf mit JSON-Antwort |
|
||||
|
||||
Eigenheiten, die man kennen muss:
|
||||
|
||||
* Der Schlüssel liegt in der **Scanner**-Datenbank (`settings.openai_api_key`),
|
||||
die Modellwahl in der **CRM**-Datenbank (`crm_settings.ai_model_*`). Der
|
||||
Schlüssel stand dort schon immer; die Modellwahl ist CRM-weit.
|
||||
* Scanner-Dienste (`matchingEngine.js`, `appliedCheck.js`) holen Schlüssel und
|
||||
Modell ebenfalls hier - keiner liest `openai_api_key` mehr selbst.
|
||||
* Kein Zwischenspeicher: jede Änderung wirkt beim nächsten Aufruf.
|
||||
|
||||
### `server/src/sso/`
|
||||
|
||||
Anmeldung über Microsoft Entra, fachlich in
|
||||
|
||||
+4
-3
@@ -99,11 +99,11 @@ werden:
|
||||
|
||||
Die erweiterte Suche benutzt **denselben OpenAI-Schlüssel wie der Scanner** –
|
||||
`openai_api_key` in der Scanner-Tabelle `settings`, gepflegt unter
|
||||
*Einstellungen → Scanner*, ersatzweise `OPENAI_API_KEY` aus der Umgebung. Ein
|
||||
*Einstellungen → KI* (siehe [14 KI](14-ki.md)), ersatzweise `OPENAI_API_KEY` aus der Umgebung. Ein
|
||||
zweiter Schlüssel an zweiter Stelle wäre eine Quelle für den Fall „die eine
|
||||
Funktion geht, die andere nicht, und keiner weiß warum".
|
||||
|
||||
Modell: `gpt-4o-mini`, wie im Scanner. Fehlt der Schlüssel, antwortet der
|
||||
Modell: einstellbar unter *Einstellungen → KI*, Vorgabe `gpt-4o-mini`. Fehlt der Schlüssel, antwortet der
|
||||
Endpunkt mit `503` und dem Hinweis auf die Einstellungen; die Volltextsuche
|
||||
bleibt davon unberührt.
|
||||
|
||||
@@ -116,7 +116,8 @@ Scanner behält sein SDK, dort hängt mehr daran.
|
||||
| Datei | Aufgabe |
|
||||
| --- | --- |
|
||||
| `server/src/routes/search.js` | beide Endpunkte |
|
||||
| `server/src/ai.js` | OpenAI-Aufruf, Schlüssel des Scanners |
|
||||
| `server/src/ai.js` | OpenAI-Aufruf, Schlüssel, Modellwahl |
|
||||
| `server/src/routes/ai.js` | Einstellungen → KI (siehe [14 KI](14-ki.md)) |
|
||||
| `server/src/routes/iconCrm.js` | `fetchIconContacts()` samt Zwischenspeicher |
|
||||
| `src/components/GlobalSearch.tsx` | das Feld, beide Modi, die Trefferliste |
|
||||
| `src/components/Header.tsx` | bindet es mittig in die Kopfleiste ein |
|
||||
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
# 14 KI: Schlüssel und Modelle
|
||||
|
||||
Das CRM ruft OpenAI an drei Stellen auf. Schlüssel und Modell pflegen
|
||||
Administratoren unter **Einstellungen → KI** - ohne SSH, ohne Neustart.
|
||||
|
||||

|
||||
|
||||
## Wo die KI arbeitet
|
||||
|
||||
| Einsatzzweck | Code | Schlüssel in `crm_settings` | Vorgabe |
|
||||
| --- | --- | --- | --- |
|
||||
| Scanner: Bewertung Lebenslauf gegen Projekt | `scanner/services/matchingEngine.js` | `ai_model_matching` | `gpt-4o-mini` |
|
||||
| Scanner: Abgleich „bereits beworben" | `scanner/services/appliedCheck.js` | `ai_model_applied` | `gpt-5.6-luna` |
|
||||
| Erweiterte Suche | `routes/search.js` | `ai_model_search` | `gpt-4o-mini` (bzw. `OPENAI_MODEL`) |
|
||||
|
||||
Die Vorgaben sind genau die Modelle, die vor der Einstellung fest im Code
|
||||
standen. Wer nichts umstellt, merkt also nichts. Steht in `crm_settings` kein
|
||||
oder ein ungültiger Eintrag, gilt die Vorgabe.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
UI["Einstellungen → KI"] -->|"PUT /api/ai/key"| V{"GET /v1/models<br/>mit neuem Schlüssel"}
|
||||
V -->|"abgelehnt"| X["Fehlermeldung,<br/>alter Schlüssel bleibt"]
|
||||
V -->|"ok"| K[("perinco_scanner.settings<br/>openai_api_key")]
|
||||
UI -->|"PUT /api/ai/models"| M[("perinco_CRM.crm_settings<br/>ai_model_*")]
|
||||
K --> AI["server/src/ai.js<br/>aiKey() / modelFor()"]
|
||||
E["OPENAI_API_KEY<br/>server/.env"] -.->|"nur wenn DB leer"| AI
|
||||
M --> AI
|
||||
AI --> S1["Scanner-Bewertung"]
|
||||
AI --> S2["Abgleich bereits beworben"]
|
||||
AI --> S3["Erweiterte Suche"]
|
||||
```
|
||||
|
||||
## Schlüssel
|
||||
|
||||
Es gibt **genau einen** OpenAI-Schlüssel für Scanner und CRM. Er liegt
|
||||
unverändert als `openai_api_key` in der Scanner-Tabelle `settings` - dort, wo er
|
||||
schon immer lag. Nichts musste umziehen. Ersatzweise gilt `OPENAI_API_KEY` aus
|
||||
`server/.env`.
|
||||
|
||||
* **Anzeige nur mit Ende.** Die Oberfläche zeigt `sk-…` und die letzten vier
|
||||
Zeichen, dazu die Quelle (*hier hinterlegt* oder *server/.env*). Mehr gibt der
|
||||
Server nie heraus, auch nicht an Administratoren. Bei Schlüsseln unter zwölf
|
||||
Zeichen steht nur *ja*.
|
||||
* **Vor dem Speichern geprüft.** Ein neuer Schlüssel wird erst gegen
|
||||
`GET https://api.openai.com/v1/models` ausprobiert. Lehnt OpenAI ab, bleibt der
|
||||
alte gültig, und die Meldung von OpenAI steht im Fehlertext. Der Abruf kostet
|
||||
nichts.
|
||||
* **Verwerfen** leert den hinterlegten Wert. Danach gilt wieder die `.env` -
|
||||
oder, wenn dort nichts steht, gibt es keinen Schlüssel mehr.
|
||||
* Der Schlüssel liegt **unverschlüsselt** in der Scanner-Datenbank, wie bisher.
|
||||
Ein Dump von `perinco_scanner` enthält ihn also.
|
||||
|
||||
Im Reiter *Scanner* gibt es kein Schlüsselfeld mehr. `POST /api/scanner/settings`
|
||||
nimmt `openai_api_key` aus Kompatibilität weiter an.
|
||||
|
||||
## Modelle
|
||||
|
||||
Das Dropdown füllt sich **live von OpenAI**: alle Modelle, die der Schlüssel
|
||||
nutzen darf, gefiltert auf Textmodelle (`gpt-*`, `o1`/`o3`/`o4…`, `chatgpt-*`,
|
||||
ohne Audio, Bild, Embedding, Realtime, TTS, Suche, Moderation). Die aktuell
|
||||
eingestellten Modelle stehen immer mit drin, die Vorgabe ist markiert.
|
||||
|
||||
Ist die Liste nicht abrufbar (kein Schlüssel, OpenAI nicht erreichbar), zeigt
|
||||
das Dropdown eine feste Auswahl, und die Seite sagt, warum.
|
||||
|
||||
Die Wahl gilt ab dem nächsten Aufruf - es gibt keinen Zwischenspeicher.
|
||||
|
||||
### Reasoning- und klassische Modelle
|
||||
|
||||
Die beiden Modellfamilien nehmen unterschiedliche Parameter. Damit ein Wechsel
|
||||
im Dropdown keinen `400` von OpenAI auslöst, entscheidet `isReasoningModel()`
|
||||
(Namen `o1…`, `o3…`, `gpt-5…`) je Aufruf:
|
||||
|
||||
| | Reasoning (`o*`, `gpt-5*`) | klassisch (`gpt-4o*`, `gpt-4.1*`) |
|
||||
| --- | --- | --- |
|
||||
| Scanner-Bewertung | `reasoning_effort: low` | `temperature: 0.1` |
|
||||
| Abgleich „bereits beworben" | `reasoning_effort: low` | `temperature: 0` |
|
||||
| Erweiterte Suche | `max_completion_tokens` | `max_completion_tokens` |
|
||||
|
||||
`max_completion_tokens` verstehen beide Familien, das alte `max_tokens` die
|
||||
Reasoning-Modelle nicht.
|
||||
|
||||
## Endpunkte
|
||||
|
||||
Alle unter `/api/ai`, nur für Administratoren.
|
||||
|
||||
| Methode | Pfad | Zweck |
|
||||
| --- | --- | --- |
|
||||
| GET | `/settings` | Schlüsselzustand, Modell je Zweck, Auswahlliste |
|
||||
| PUT | `/key` | `{ key }` - prüfen, dann speichern |
|
||||
| DELETE | `/key` | hinterlegten Schlüssel verwerfen |
|
||||
| PUT | `/models` | `{ matching?, applied?, search? }` - Namen geprüft gegen `^[A-Za-z0-9][A-Za-z0-9._:-]{0,99}$` |
|
||||
|
||||
## Dateien
|
||||
|
||||
| Datei | Aufgabe |
|
||||
| --- | --- |
|
||||
| `server/src/ai.js` | Schlüssel, Modellwahl, Modellliste, Aufruf der Suche |
|
||||
| `server/src/routes/ai.js` | die vier Endpunkte |
|
||||
| `src/components/settings/AiSettings.tsx` | Reiter *KI* |
|
||||
| `server/test/ai.test.mjs` | Modellnamen und Reasoning-Erkennung |
|
||||
@@ -23,6 +23,7 @@ keine Vorkenntnisse voraus.
|
||||
| [11 Postfachanbindung](11-postfach.md) | crm@perinco.de über Microsoft Graph, Kontakte aus Mails |
|
||||
| [12 Globale Suche](12-suche.md) | Volltext und erweiterte Suche im Kopf der Anwendung |
|
||||
| [13 Anmeldung über Microsoft](13-anmeldung-sso.md) | SSO mit Entra, Zuordnung der Konten, Einrichtung |
|
||||
| [14 KI](14-ki.md) | OpenAI-Schlüssel und Modellwahl je Einsatzzweck |
|
||||
|
||||
Die Designentscheidungen und die Messwerte zur Spracherkennung stehen im
|
||||
[README des Projekts](../README.md).
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 170 KiB |
+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);
|
||||
+1
-1
@@ -181,7 +181,7 @@ const App: React.FC = () => {
|
||||
Unpassend: 'Projekt Scanner - als „passt nicht" markierte Treffer',
|
||||
Suchagenten: 'Projekt Scanner - Beraterprofile mit CV und Suchbegriffen',
|
||||
'Börsen-Konten': 'Projekt Scanner - Verbindungen zu den Jobbörsen',
|
||||
Einstellungen: 'Konto, Diktat, Benutzer, Postfach und Scanner',
|
||||
Einstellungen: 'Konto, Diktat, Benutzer, Postfach, Scanner und KI',
|
||||
};
|
||||
|
||||
const subtitle =
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
aiApi,
|
||||
type AiAvailableModels,
|
||||
type AiKeyStatus,
|
||||
type AiPurpose,
|
||||
type AiSettings as AiSettingsData,
|
||||
} from '../../lib/api';
|
||||
import { inputClass, labelClass } from '../leads/leadUi';
|
||||
import { useToast } from '../Toast';
|
||||
|
||||
/*
|
||||
* KI - Adminsicht: OpenAI-Schlüssel und das Modell je Einsatzzweck.
|
||||
*
|
||||
* Der Schlüssel geht nur hinein. Angezeigt werden seine letzten vier Zeichen,
|
||||
* damit man erkennt, welcher hinterlegt ist - mehr gibt der Server nicht her.
|
||||
* Es gibt genau einen Schlüssel für Scanner und CRM.
|
||||
*/
|
||||
|
||||
const Fact: React.FC<{ label: string; value: React.ReactNode }> = ({ label, value }) => (
|
||||
<div className="flex justify-between gap-3 text-sm">
|
||||
<dt className="shrink-0 text-ink-400">{label}</dt>
|
||||
<dd className="truncate text-right font-semibold text-ink-800">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
|
||||
const KeySection: React.FC<{
|
||||
keyStatus: AiKeyStatus;
|
||||
onChange: (key: AiKeyStatus, available: AiAvailableModels) => void;
|
||||
}> = ({ keyStatus, onChange }) => {
|
||||
const { addToast } = useToast();
|
||||
const [value, setValue] = useState('');
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isClearing, setIsClearing] = useState(false);
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!value.trim()) return;
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const result = await aiApi.saveKey(value.trim());
|
||||
onChange(result.key, result.available);
|
||||
setValue('');
|
||||
addToast('Schlüssel gespeichert - OpenAI hat ihn akzeptiert.', 'success');
|
||||
} catch (err) {
|
||||
addToast(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.', 'error');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClear = async () => {
|
||||
setIsClearing(true);
|
||||
try {
|
||||
const result = await aiApi.clearKey();
|
||||
onChange(result.key, result.available);
|
||||
addToast(
|
||||
result.key.configured
|
||||
? 'Hinterlegter Schlüssel verworfen - es gilt wieder der Wert aus server/.env.'
|
||||
: 'Hinterlegter Schlüssel verworfen.',
|
||||
'success'
|
||||
);
|
||||
} catch (err) {
|
||||
addToast(err instanceof Error ? err.message : 'Verwerfen fehlgeschlagen.', 'error');
|
||||
} finally {
|
||||
setIsClearing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
className={`rounded-2xl border border-l-4 border-ink-200 bg-surface p-5 ${
|
||||
keyStatus.configured ? 'border-l-green-500' : 'border-l-amber-500'
|
||||
}`}
|
||||
>
|
||||
<h3 className="text-base text-ink-900">OpenAI-Schlüssel</h3>
|
||||
<p className="mt-1 text-xs text-ink-400">
|
||||
Ein Schlüssel für alles: Scanner-Bewertung, Abgleich „bereits beworben“ und erweiterte Suche.
|
||||
Angezeigt wird nur das Ende.
|
||||
</p>
|
||||
|
||||
<dl className="mt-4 space-y-2">
|
||||
<Fact
|
||||
label="Hinterlegt"
|
||||
value={
|
||||
keyStatus.configured ? (
|
||||
<code className="font-mono">
|
||||
{keyStatus.last4 ? `sk-…${keyStatus.last4}` : 'ja'}
|
||||
</code>
|
||||
) : (
|
||||
<span className="text-amber-700">kein Schlüssel</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Fact
|
||||
label="Quelle"
|
||||
value={
|
||||
keyStatus.source === 'db'
|
||||
? 'hier hinterlegt'
|
||||
: keyStatus.source === 'env'
|
||||
? 'server/.env'
|
||||
: 'fehlt'
|
||||
}
|
||||
/>
|
||||
</dl>
|
||||
|
||||
{!keyStatus.configured && (
|
||||
<p className="mt-4 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm font-semibold text-amber-800">
|
||||
Ohne Schlüssel bewertet der Scanner keine Projekte und die erweiterte Suche bleibt aus.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="mt-5 space-y-4">
|
||||
<div>
|
||||
<label className={labelClass} htmlFor="ai-key">
|
||||
Neuer Schlüssel
|
||||
</label>
|
||||
<input
|
||||
id="ai-key"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
value={value}
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
placeholder="sk-proj-..."
|
||||
className={inputClass}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-ink-400">
|
||||
Beim Speichern fragt das CRM testweise bei OpenAI an - ein vertippter Schlüssel wird gar
|
||||
nicht erst übernommen. Der alte bleibt bis dahin gültig.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!value.trim() || isSaving}
|
||||
className="rounded-xl bg-brand-500 px-4 py-2.5 text-sm font-bold text-white transition hover:bg-brand-600 disabled:opacity-50"
|
||||
>
|
||||
{isSaving ? 'Prüfe...' : 'Speichern'}
|
||||
</button>
|
||||
|
||||
{keyStatus.source === 'db' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
disabled={isClearing}
|
||||
className="rounded-xl border border-ink-200 px-4 py-2.5 text-sm font-bold text-ink-600 transition hover:border-red-300 hover:text-red-700 disabled:opacity-50"
|
||||
>
|
||||
{isClearing
|
||||
? 'Verwerfe...'
|
||||
: keyStatus.env_fallback
|
||||
? 'Verwerfen, .env nutzen'
|
||||
: 'Schlüssel entfernen'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
const PURPOSE_ORDER: AiPurpose[] = ['matching', 'applied', 'search'];
|
||||
|
||||
const ModelSection: React.FC<{
|
||||
models: AiSettingsData['models'];
|
||||
available: AiAvailableModels;
|
||||
onChange: (models: AiSettingsData['models']) => void;
|
||||
}> = ({ models, available, onChange }) => {
|
||||
const { addToast } = useToast();
|
||||
const [draft, setDraft] = useState<Record<AiPurpose, string>>(() => ({
|
||||
matching: models.matching.model,
|
||||
applied: models.applied.model,
|
||||
search: models.search.model,
|
||||
}));
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const changed = PURPOSE_ORDER.filter((purpose) => draft[purpose] !== models[purpose].model);
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (changed.length === 0) return;
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const payload: Partial<Record<AiPurpose, string>> = {};
|
||||
for (const purpose of changed) payload[purpose] = draft[purpose];
|
||||
const result = await aiApi.saveModels(payload);
|
||||
onChange(result.models);
|
||||
addToast('Modellwahl gespeichert. Gilt ab dem nächsten Aufruf.', 'success');
|
||||
} catch (err) {
|
||||
addToast(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.', 'error');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="rounded-2xl border border-ink-200 bg-surface p-5">
|
||||
<h3 className="text-base text-ink-900">Modell</h3>
|
||||
<p className="mt-1 text-xs text-ink-400">
|
||||
{available.source === 'openai'
|
||||
? 'Die Auswahl stammt live von OpenAI - alle Textmodelle, die dieser Schlüssel nutzen darf.'
|
||||
: 'Feste Auswahl - die Liste von OpenAI war nicht abrufbar.'}
|
||||
</p>
|
||||
{available.error && (
|
||||
<p className="mt-3 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-xs font-semibold text-amber-800">
|
||||
OpenAI: {available.error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="mt-4 space-y-4">
|
||||
{PURPOSE_ORDER.map((purpose) => (
|
||||
<div key={purpose}>
|
||||
<label className={labelClass} htmlFor={`ai-model-${purpose}`}>
|
||||
{models[purpose].label}
|
||||
</label>
|
||||
<select
|
||||
id={`ai-model-${purpose}`}
|
||||
value={draft[purpose]}
|
||||
onChange={(event) => setDraft((prev) => ({ ...prev, [purpose]: event.target.value }))}
|
||||
className={inputClass}
|
||||
>
|
||||
{available.models.map((model) => (
|
||||
<option key={model} value={model}>
|
||||
{model}
|
||||
{model === models[purpose].fallback ? ' (Vorgabe)' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={changed.length === 0 || isSaving}
|
||||
className="rounded-xl bg-brand-500 px-4 py-2.5 text-sm font-bold text-white transition hover:bg-brand-600 disabled:opacity-50"
|
||||
>
|
||||
{isSaving ? 'Speichere...' : 'Modellwahl speichern'}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export const AiSettings: React.FC = () => {
|
||||
const { addToast } = useToast();
|
||||
const [data, setData] = useState<AiSettingsData | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
aiApi
|
||||
.settings()
|
||||
.then((result) => {
|
||||
if (!cancelled) setData(result);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) {
|
||||
addToast(err instanceof Error ? err.message : 'KI-Einstellungen nicht erreichbar.', 'error');
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setIsLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
if (isLoading) return <p className="text-sm text-ink-400">Wird geladen...</p>;
|
||||
if (!data) return null;
|
||||
|
||||
return (
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<KeySection
|
||||
keyStatus={data.key}
|
||||
onChange={(key, available) => setData((prev) => (prev ? { ...prev, key, available } : prev))}
|
||||
/>
|
||||
<ModelSection
|
||||
models={data.models}
|
||||
available={data.available}
|
||||
onChange={(models) => setData((prev) => (prev ? { ...prev, models } : prev))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -109,7 +109,6 @@ export const ScannerSettings: React.FC = () => {
|
||||
|
||||
const isScanningActive = values.scanning_active === 'true';
|
||||
const emailActive = values.email_notifications_active === 'true';
|
||||
const keyStored = values.openai_api_key === SECRET_MASK;
|
||||
const appliedCheckActive = values.applied_check_active === 'true';
|
||||
const periodDays = Math.max(1, Number.parseInt(values.applied_check_days, 10) || 14);
|
||||
const periodFactor = periodUnit === 'weeks' ? 7 : 1;
|
||||
@@ -190,7 +189,7 @@ export const ScannerSettings: React.FC = () => {
|
||||
|
||||
<Section
|
||||
title="Abgleich mit Akquise und Opportunities"
|
||||
description="Jedes neu gefundene Projekt mit Treffer wird mit den Einträgen der Akquise-Liste und den Opportunities verglichen. Ist es dort schon erfasst – auch unter anderem Titel oder von einer anderen Börse –, erscheint es im Dashboard mit „bereits beworben!“. Die KI (gpt-5.6-luna) nutzt den OpenAI-Key unten."
|
||||
description="Jedes neu gefundene Projekt mit Treffer wird mit den Einträgen der Akquise-Liste und den Opportunities verglichen. Ist es dort schon erfasst – auch unter anderem Titel oder von einer anderen Börse –, erscheint es im Dashboard mit „bereits beworben!“. KI-Modell und OpenAI-Schlüssel stehen unter Einstellungen → KI."
|
||||
accent={appliedCheckActive ? 'border-l-brand-500' : 'border-l-ink-300'}
|
||||
>
|
||||
<Checkbox
|
||||
@@ -241,32 +240,6 @@ export const ScannerSettings: React.FC = () => {
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title="OpenAI"
|
||||
description="Wird für die semantische Bewertung des Lebenslaufs gegen die Projektbeschreibung gebraucht (gpt-4o-mini)."
|
||||
>
|
||||
<label className={labelClass}>
|
||||
API-Key
|
||||
{keyStored && (
|
||||
<span className="ml-2 rounded-md border border-brand-200 bg-brand-50 px-2 py-0.5 text-[10px] font-extrabold uppercase tracking-wider text-brand-700">
|
||||
gespeichert
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={keyStored ? '' : values.openai_api_key}
|
||||
onChange={(event) => set('openai_api_key', event.target.value)}
|
||||
placeholder={
|
||||
keyStored
|
||||
? 'Leer lassen, um den gespeicherten Key zu behalten'
|
||||
: 'sk-proj-...'
|
||||
}
|
||||
className={inputClass}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title="Browser-Benachrichtigungen"
|
||||
description={`Bei einem neuen Treffer über ${values.match_threshold}% spielt das Scanner-Dashboard einen Signalton. Voraussetzung: Der Reiter ist im Browser geöffnet. Browser lassen Töne erst nach dem ersten Klick auf der Seite zu.`}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { MailSettings } from './MailSettings';
|
||||
import { SsoSettings } from './SsoSettings';
|
||||
import { AcquisitionSettings } from './AcquisitionSettings';
|
||||
import { ScannerSettings } from './ScannerSettings';
|
||||
import { AiSettings } from './AiSettings';
|
||||
import { ColorPicker } from './ColorPicker';
|
||||
import { AvatarPicker } from '../ui/AvatarPicker';
|
||||
import { WhisperSettings } from '../whisper/WhisperSettings';
|
||||
@@ -20,6 +21,7 @@ const TABS: { id: SettingsTab; label: string }[] = [
|
||||
{ id: 'mail', label: 'Postfach' },
|
||||
{ id: 'sso', label: 'Anmeldung' },
|
||||
{ id: 'scanner', label: 'Scanner' },
|
||||
{ id: 'ki', label: 'KI' },
|
||||
{ id: 'whisper', label: 'Whisper' },
|
||||
];
|
||||
|
||||
@@ -227,6 +229,7 @@ export const Settings: React.FC<{ initialTab?: SettingsTab }> = ({ initialTab })
|
||||
{tab === 'mail' && isAdmin && <MailSettings />}
|
||||
{tab === 'sso' && isAdmin && <SsoSettings />}
|
||||
{tab === 'scanner' && isAdmin && <ScannerSettings />}
|
||||
{tab === 'ki' && isAdmin && <AiSettings />}
|
||||
{tab === 'whisper' && <WhisperSettings />}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -166,6 +166,57 @@ export const msSecretApi = {
|
||||
request<{ ok: true; secret: MsSecretStatus }>('/auth/microsoft-secret', { method: 'DELETE' }),
|
||||
};
|
||||
|
||||
// ---------- KI: OpenAI-Schluessel und Modellwahl (nur admin) ----------
|
||||
|
||||
export interface AiKeyStatus {
|
||||
configured: boolean;
|
||||
/** 'db' = in den Einstellungen hinterlegt, 'env' = aus server/.env */
|
||||
source: 'db' | 'env' | null;
|
||||
/** Letzte vier Zeichen - mehr gibt der Server nie heraus. */
|
||||
last4: string | null;
|
||||
env_fallback: boolean;
|
||||
}
|
||||
|
||||
export type AiPurpose = 'matching' | 'applied' | 'search';
|
||||
|
||||
export interface AiModelSetting {
|
||||
label: string;
|
||||
model: string;
|
||||
fallback: string;
|
||||
}
|
||||
|
||||
export interface AiAvailableModels {
|
||||
models: string[];
|
||||
/** 'openai' = live abgefragt, 'fallback' = feste Liste */
|
||||
source: 'openai' | 'fallback';
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface AiSettings {
|
||||
key: AiKeyStatus;
|
||||
models: Record<AiPurpose, AiModelSetting>;
|
||||
available: AiAvailableModels;
|
||||
}
|
||||
|
||||
export const aiApi = {
|
||||
settings: () => request<AiSettings>('/ai/settings'),
|
||||
saveKey: (key: string) =>
|
||||
request<{ ok: true; key: AiKeyStatus; available: AiAvailableModels }>('/ai/key', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ key }),
|
||||
}),
|
||||
/** Verwirft den hinterlegten Schluessel; ab dann gilt wieder server/.env. */
|
||||
clearKey: () =>
|
||||
request<{ ok: true; key: AiKeyStatus; available: AiAvailableModels }>('/ai/key', {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
saveModels: (models: Partial<Record<AiPurpose, string>>) =>
|
||||
request<{ ok: true; models: Record<AiPurpose, AiModelSetting> }>('/ai/models', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(models),
|
||||
}),
|
||||
};
|
||||
|
||||
// ---------- Benutzerverwaltung (nur admin) ----------
|
||||
|
||||
export interface UserInput {
|
||||
|
||||
@@ -89,7 +89,6 @@ export interface ScannerMatch {
|
||||
export interface ScannerSettings {
|
||||
scanning_active: string;
|
||||
search_interval: string;
|
||||
openai_api_key: string;
|
||||
match_threshold: string;
|
||||
display_threshold: string;
|
||||
sound_notifications_active: string;
|
||||
@@ -108,7 +107,6 @@ export interface ScannerSettings {
|
||||
export const SCANNER_SETTINGS_DEFAULTS: ScannerSettings = {
|
||||
scanning_active: 'true',
|
||||
search_interval: '60',
|
||||
openai_api_key: '',
|
||||
match_threshold: '70',
|
||||
display_threshold: '50',
|
||||
sound_notifications_active: 'true',
|
||||
|
||||
+2
-1
@@ -31,9 +31,10 @@ export type SettingsTab =
|
||||
| 'mail'
|
||||
| 'sso'
|
||||
| 'scanner'
|
||||
| 'ki'
|
||||
| 'whisper';
|
||||
|
||||
export const ADMIN_SETTINGS_TABS: SettingsTab[] = ['users', 'mail', 'sso', 'scanner'];
|
||||
export const ADMIN_SETTINGS_TABS: SettingsTab[] = ['users', 'mail', 'sso', 'scanner', 'ki'];
|
||||
|
||||
/**
|
||||
* Reiter des Projekt Scanners. "Scanner" selbst ist das Dashboard und damit die
|
||||
|
||||
Reference in New Issue
Block a user