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
+1 -1
View File
@@ -97,7 +97,7 @@ Einzelheiten: [13 Anmeldung über Microsoft](13-anmeldung-sso.md).
```mermaid ```mermaid
flowchart TD flowchart TD
L["Anmeldung"] --> R{"Rolle"} 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"] R -->|user| U["Dashboard, Akquise, Opportunities, Interviews, Kontakte, Mails, Scanner<br/>Einstellungen: Mein Konto, Akquise, Whisper"]
``` ```
+3
View File
@@ -382,6 +382,9 @@ erDiagram
- **`settings` ist ein Schlüssel-Wert-Speicher.** Geheimnisse (`openai_api_key`, - **`settings` ist ein Schlüssel-Wert-Speicher.** Geheimnisse (`openai_api_key`,
`smtp_pass`) liefert die API nur maskiert als `********` aus; ein leeres oder `smtp_pass`) liefert die API nur maskiert als `********` aus; ein leeres oder
maskiertes Feld beim Speichern lässt den gespeicherten Wert unberührt. 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 ## Statuswege
+4 -3
View File
@@ -90,8 +90,9 @@ flowchart TD
Entscheidungen dahinter: Entscheidungen dahinter:
- **Eigenes Modell.** Die Bewertung CV gegen Projekt bleibt bei `gpt-4o-mini`; - **Eigenes Modell.** Die Bewertung CV gegen Projekt nutzt als Vorgabe
der Abgleich nutzt `gpt-5.6-luna` (Konstante `APPLIED_CHECK_MODEL`). Er muss `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 umformulierte Ausschreibungen als dieselbe erkennen und ähnliche Rollen bei
anderem Kunden auseinanderhalten - das ist ein Urteil, kein Textvergleich. anderem Kunden auseinanderhalten - das ist ein Urteil, kein Textvergleich.
Der Schlüssel ist derselbe `openai_api_key`. 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` | | Unpassend | `ScannerRejected.tsx` | Treffer mit Status `rejected` |
| Suchagenten | `ScannerAgents.tsx` | Beraterprofile, CV-Upload | | Suchagenten | `ScannerAgents.tsx` | Beraterprofile, CV-Upload |
| Börsen-Konten | `ScannerPortals.tsx` | Verbindungsstatus, Zugangsdaten, Cookie-Import | | 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 **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 Menüpunkt wechselt, hört nichts - die Komponente wird ausgebaut und die
+2 -1
View File
@@ -129,7 +129,8 @@ entfallen.
| --- | --- | | --- | --- |
| Anmeldung schlägt fehl | `journalctl -u perinco-crm-backend -n 50`, `JWT_SECRET` gesetzt? | | Anmeldung schlägt fehl | `journalctl -u perinco-crm-backend -n 50`, `JWT_SECRET` gesetzt? |
| Scanner-Reiter leer, 500er | `[Scanner] Datenbank nicht erreichbar` im Journal | | 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) | | Portal `expired` | Cookies neu importieren (Cookie-Editor) |
| `Target page ... has been closed` | Dienst wurde mitten im Scrapen neu gestartet | | `Target page ... has been closed` | Dienst wurde mitten im Scrapen neu gestartet |
| Backend stirbt beim Scrapen | `MemoryMax` zu klein, `systemctl show ... -p MemoryCurrent` | | Backend stirbt beim Scrapen | `MemoryMax` zu klein, `systemctl show ... -p MemoryCurrent` |
+14 -1
View File
@@ -949,7 +949,8 @@ Sitzung kommt derzeit nur über den Cookie-Import.
| **Mein Konto** | alle | Foto, Terminfarbe, Passwort ändern | | **Mein Konto** | alle | Foto, Terminfarbe, Passwort ändern |
| **Akquise** | alle | Inhalte der Auswahllisten *Von*, *Kanal*, *Berater* | | **Akquise** | alle | Inhalte der Auswahllisten *Von*, *Kanal*, *Berater* |
| **Postfach** | Admin | Verbindung zu Microsoft 365, Abgleich, Kontaktübernahme | | **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 | | **Whisper** | alle | Spracherkennung für das Diktat einrichten |
Im Reiter **Akquise** stehen die drei Auswahllisten des Akquise-Formulars, jede 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 ein Akquise-Eintrag oder eine Opportunity höchstens sein darf, um verglichen zu
werden - in Tagen oder Wochen, Vorgabe zwei Wochen. 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.
![Einstellungen, Reiter KI](bilder/einstellungen-ki.png)
Im Reiter **Postfach** steht oben, ob die Verbindung zu Microsoft 365 steht. Im Reiter **Postfach** steht oben, ob die Verbindung zu Microsoft 365 steht.
**Verbindung testen** prüft beides: die Anmeldung *und* den tatsächlichen **Verbindung testen** prüft beides: die Anmeldung *und* den tatsächlichen
Zugriff auf `crm@perinco.de`. Schlägt etwas fehl, nennt die Meldung den Zugriff auf `crm@perinco.de`. Schlägt etwas fehl, nennt die Meldung den
+24
View File
@@ -231,6 +231,30 @@ Eigenheiten, die man kennen muss:
Funktionen `mailConfigured()` und `ssoConfigured()` - die Prüfung muss in die Funktionen `mailConfigured()` und `ssoConfigured()` - die Prüfung muss in die
Datenbank sehen. 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/` ### `server/src/sso/`
Anmeldung über Microsoft Entra, fachlich in Anmeldung über Microsoft Entra, fachlich in
+4 -3
View File
@@ -99,11 +99,11 @@ werden:
Die erweiterte Suche benutzt **denselben OpenAI-Schlüssel wie der Scanner** – Die erweiterte Suche benutzt **denselben OpenAI-Schlüssel wie der Scanner** –
`openai_api_key` in der Scanner-Tabelle `settings`, gepflegt unter `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 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". 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 Endpunkt mit `503` und dem Hinweis auf die Einstellungen; die Volltextsuche
bleibt davon unberührt. bleibt davon unberührt.
@@ -116,7 +116,8 @@ Scanner behält sein SDK, dort hängt mehr daran.
| Datei | Aufgabe | | Datei | Aufgabe |
| --- | --- | | --- | --- |
| `server/src/routes/search.js` | beide Endpunkte | | `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 | | `server/src/routes/iconCrm.js` | `fetchIconContacts()` samt Zwischenspeicher |
| `src/components/GlobalSearch.tsx` | das Feld, beide Modi, die Trefferliste | | `src/components/GlobalSearch.tsx` | das Feld, beide Modi, die Trefferliste |
| `src/components/Header.tsx` | bindet es mittig in die Kopfleiste ein | | `src/components/Header.tsx` | bindet es mittig in die Kopfleiste ein |
+102
View File
@@ -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.
![Einstellungen, Reiter KI: Schlüssel mit den letzten vier Zeichen, Modell je Einsatzzweck](bilder/einstellungen-ki.png)
## 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 |
+1
View File
@@ -23,6 +23,7 @@ keine Vorkenntnisse voraus.
| [11 Postfachanbindung](11-postfach.md) | crm@perinco.de über Microsoft Graph, Kontakte aus Mails | | [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 | | [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 | | [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 Die Designentscheidungen und die Messwerte zur Spracherkennung stehen im
[README des Projekts](../README.md). [README des Projekts](../README.md).
Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

+3 -1
View File
@@ -92,7 +92,9 @@ SCANNER_SCHEDULER=true
# 0 = sofort loslegen. # 0 = sofort loslegen.
SCANNER_SCHEDULER_OFFSET=0 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= # OPENAI_API_KEY=
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+1 -1
View File
@@ -12,7 +12,7 @@
"start": "node src/server.js", "start": "node src/server.js",
"dev": "node --watch src/server.js", "dev": "node --watch src/server.js",
"migrate": "node src/migrate.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": { "dependencies": {
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
+216 -20
View File
@@ -1,13 +1,22 @@
import Setting from './scanner/models/Setting.js'; 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 * Gepflegt wird alles in Einstellungen -> KI (nur Administratoren):
* Scanner benutzt - Einstellungen -> Scanner, gespeichert als `openai_api_key` *
* in der Scanner-Tabelle `settings`. Ein zweiter Schluessel an zweiter Stelle * Schluessel Es gibt genau einen. Er liegt weiterhin als `openai_api_key` in
* waere eine Quelle fuer den Fall "die eine Funktion geht, die andere nicht, * der Scanner-Tabelle `settings`, wo er schon immer lag - so
* und keiner weiss warum". * 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 * Bewusst ohne das `openai`-Paket: gebraucht wird ein einziger Aufruf, und
* `fetch` ist in Node eingebaut. Der Scanner behaelt sein SDK - dort haengt * `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. * 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 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 { class AiError extends Error {
constructor(message, status = 502) { 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() { export async function aiKey() {
const fromDb = await Setting.getVal('openai_api_key'); return (await effectiveKey()).value;
return (fromDb || process.env.OPENAI_API_KEY || '').trim();
} }
export async function aiConfigured() { export async function aiConfigured() {
return Boolean(await aiKey()); 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. * Ein Aufruf an das Modell, Antwort als JSON.
* *
@@ -46,32 +242,32 @@ export async function aiConfigured() {
* @param {string} options.system * @param {string} options.system
* @param {string} options.user * @param {string} options.user
* @param {number} [options.maxTokens] * @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(); const key = await aiKey();
if (!key) { if (!key) {
throw new AiError( throw new AiError('Es ist kein OpenAI-Schlüssel hinterlegt (Einstellungen → KI).', 503);
'Es ist kein OpenAI-Schlüssel hinterlegt (Einstellungen → Scanner).',
503
);
} }
const body = { const body = {
model: AI_MODEL, model: model || (await modelFor('search')),
messages: [ messages: [
...(system ? [{ role: 'system', content: system }] : []), ...(system ? [{ role: 'system', content: system }] : []),
{ role: 'user', content: user }, { role: 'user', content: user },
], ],
response_format: { type: 'json_object' }, 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 controller = new AbortController();
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS); const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
let response; let response;
try { try {
response = await fetch(ENDPOINT, { response = await fetch(`${API_BASE}/chat/completions`, {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' }, headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
body: JSON.stringify(body), 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 express from 'express';
import { pool } from '../db.js'; import { pool } from '../db.js';
import { requireAuth } from '../middleware/auth.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'; import { fetchIconContacts } from './iconCrm.js';
const router = express.Router(); 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 (!question) return res.status(400).json({ error: 'Keine Frage übergeben.' });
if (!(await aiConfigured())) { if (!(await aiConfigured())) {
return res.status(503).json({ 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 (list) => list.length >= AI_LIMIT
); );
const model = await modelFor('search');
const result = await askModelJson({ const result = await askModelJson({
model,
system: AI_SYSTEM, system: AI_SYSTEM,
user: `Frage: ${question}\n\nBestand:\n${JSON.stringify({ user: `Frage: ${question}\n\nBestand:\n${JSON.stringify({
leads: leads.map((row) => ({ ...row, mail_pool: Boolean(row.mail_pool) })), leads: leads.map((row) => ({ ...row, mail_pool: Boolean(row.mail_pool) })),
@@ -225,7 +227,7 @@ router.post('/ai', async (req, res, next) => {
res.json({ res.json({
answer: String(result?.answer ?? '').trim(), answer: String(result?.answer ?? '').trim(),
refs: Array.isArray(result?.refs) ? result.refs.slice(0, 30) : [], refs: Array.isArray(result?.refs) ? result.refs.slice(0, 30) : [],
model: AI_MODEL, model,
truncated, truncated,
}); });
} catch (err) { } catch (err) {
+6 -4
View File
@@ -1,6 +1,7 @@
import { OpenAI } from 'openai'; import { OpenAI } from 'openai';
import { pool } from '../../db.js'; import { pool } from '../../db.js';
import Setting from '../models/Setting.js'; import Setting from '../models/Setting.js';
import { aiKey, isReasoningModel, modelFor } from '../../ai.js';
/* /*
* Abgleich "bereits beworben?". * Abgleich "bereits beworben?".
@@ -19,7 +20,6 @@ import Setting from '../models/Setting.js';
* applied_check_days Zeitraum in Tagen, Vorgabe 14 * applied_check_days Zeitraum in Tagen, Vorgabe 14
*/ */
export const APPLIED_CHECK_MODEL = 'gpt-5.6-luna';
export const APPLIED_CHECK_DEFAULT_DAYS = 14; export const APPLIED_CHECK_DEFAULT_DAYS = 14;
const MAX_DAYS = 365; const MAX_DAYS = 365;
@@ -101,8 +101,10 @@ const toRef = (candidate, reason) => ({
}); });
async function askModel(project, candidates) { 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.'); 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 }); const openai = new OpenAI({ apiKey });
@@ -138,13 +140,13 @@ ${JSON.stringify(
)}`; )}`;
const response = await openai.chat.completions.create({ const response = await openai.chat.completions.create({
model: APPLIED_CHECK_MODEL, model,
messages: [ messages: [
{ role: 'system', content: system }, { role: 'system', content: system },
{ role: 'user', content: user }, { role: 'user', content: user },
], ],
response_format: { type: 'json_object' }, response_format: { type: 'json_object' },
reasoning_effort: 'low', ...(isReasoningModel(model) ? { reasoning_effort: 'low' } : { temperature: 0 }),
}); });
const parsed = JSON.parse(response.choices[0]?.message?.content || '{}'); const parsed = JSON.parse(response.choices[0]?.message?.content || '{}');
@@ -1,5 +1,5 @@
import { OpenAI } from 'openai'; 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. * 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 // Schluessel und Modell kommen aus Einstellungen -> KI.
const apiKey = await Setting.getVal('openai_api_key') || process.env.OPENAI_API_KEY; const apiKey = await aiKey();
if (!apiKey) { if (!apiKey) {
throw new Error('OpenAI API Key is not configured. Please set it in Settings.'); throw new Error('OpenAI API Key is not configured. Please set it in Settings.');
} }
const model = await modelFor('matching');
const openai = new OpenAI({ apiKey }); const openai = new OpenAI({ apiKey });
@@ -55,13 +56,14 @@ ${projectDescription}`;
try { try {
const response = await openai.chat.completions.create({ const response = await openai.chat.completions.create({
model: 'gpt-4o-mini', model,
messages: [ messages: [
{ role: 'system', content: systemPrompt }, { role: 'system', content: systemPrompt },
{ role: 'user', content: userContent } { role: 'user', content: userContent }
], ],
response_format: { type: 'json_object' }, 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(); 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 integrationRoutes from './routes/integrations.js';
import iconCrmRoutes from './routes/iconCrm.js'; import iconCrmRoutes from './routes/iconCrm.js';
import searchRoutes from './routes/search.js'; import searchRoutes from './routes/search.js';
import aiRoutes from './routes/ai.js';
import { requireAuth } from './middleware/auth.js'; import { requireAuth } from './middleware/auth.js';
import { initScannerDatabase } from './scanner/models/index.js'; import { initScannerDatabase } from './scanner/models/index.js';
import { startScheduler } from './scanner/services/scheduler.js'; import { startScheduler } from './scanner/services/scheduler.js';
@@ -61,6 +62,8 @@ app.use('/api/users', userRoutes);
app.use('/api/leads', leadRoutes); app.use('/api/leads', leadRoutes);
// Globale Suche (Volltext) und die erweiterte Suche mit dem Modell des Scanners. // Globale Suche (Volltext) und die erweiterte Suche mit dem Modell des Scanners.
app.use('/api/search', searchRoutes); 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/connections', connectionRoutes);
app.use('/api/events', eventRoutes); app.use('/api/events', eventRoutes);
app.use('/api/opportunities', opportunityRoutes); 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);
+1 -1
View File
@@ -181,7 +181,7 @@ const App: React.FC = () => {
Unpassend: 'Projekt Scanner - als „passt nicht" markierte Treffer', Unpassend: 'Projekt Scanner - als „passt nicht" markierte Treffer',
Suchagenten: 'Projekt Scanner - Beraterprofile mit CV und Suchbegriffen', Suchagenten: 'Projekt Scanner - Beraterprofile mit CV und Suchbegriffen',
'Börsen-Konten': 'Projekt Scanner - Verbindungen zu den Jobbörsen', '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 = const subtitle =
+287
View File
@@ -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>
);
};
+1 -28
View File
@@ -109,7 +109,6 @@ export const ScannerSettings: React.FC = () => {
const isScanningActive = values.scanning_active === 'true'; const isScanningActive = values.scanning_active === 'true';
const emailActive = values.email_notifications_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 appliedCheckActive = values.applied_check_active === 'true';
const periodDays = Math.max(1, Number.parseInt(values.applied_check_days, 10) || 14); const periodDays = Math.max(1, Number.parseInt(values.applied_check_days, 10) || 14);
const periodFactor = periodUnit === 'weeks' ? 7 : 1; const periodFactor = periodUnit === 'weeks' ? 7 : 1;
@@ -190,7 +189,7 @@ export const ScannerSettings: React.FC = () => {
<Section <Section
title="Abgleich mit Akquise und Opportunities" 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'} accent={appliedCheckActive ? 'border-l-brand-500' : 'border-l-ink-300'}
> >
<Checkbox <Checkbox
@@ -241,32 +240,6 @@ export const ScannerSettings: React.FC = () => {
</div> </div>
</Section> </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 <Section
title="Browser-Benachrichtigungen" 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.`} 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.`}
+3
View File
@@ -4,6 +4,7 @@ import { MailSettings } from './MailSettings';
import { SsoSettings } from './SsoSettings'; import { SsoSettings } from './SsoSettings';
import { AcquisitionSettings } from './AcquisitionSettings'; import { AcquisitionSettings } from './AcquisitionSettings';
import { ScannerSettings } from './ScannerSettings'; import { ScannerSettings } from './ScannerSettings';
import { AiSettings } from './AiSettings';
import { ColorPicker } from './ColorPicker'; import { ColorPicker } from './ColorPicker';
import { AvatarPicker } from '../ui/AvatarPicker'; import { AvatarPicker } from '../ui/AvatarPicker';
import { WhisperSettings } from '../whisper/WhisperSettings'; import { WhisperSettings } from '../whisper/WhisperSettings';
@@ -20,6 +21,7 @@ const TABS: { id: SettingsTab; label: string }[] = [
{ id: 'mail', label: 'Postfach' }, { id: 'mail', label: 'Postfach' },
{ id: 'sso', label: 'Anmeldung' }, { id: 'sso', label: 'Anmeldung' },
{ id: 'scanner', label: 'Scanner' }, { id: 'scanner', label: 'Scanner' },
{ id: 'ki', label: 'KI' },
{ id: 'whisper', label: 'Whisper' }, { id: 'whisper', label: 'Whisper' },
]; ];
@@ -227,6 +229,7 @@ export const Settings: React.FC<{ initialTab?: SettingsTab }> = ({ initialTab })
{tab === 'mail' && isAdmin && <MailSettings />} {tab === 'mail' && isAdmin && <MailSettings />}
{tab === 'sso' && isAdmin && <SsoSettings />} {tab === 'sso' && isAdmin && <SsoSettings />}
{tab === 'scanner' && isAdmin && <ScannerSettings />} {tab === 'scanner' && isAdmin && <ScannerSettings />}
{tab === 'ki' && isAdmin && <AiSettings />}
{tab === 'whisper' && <WhisperSettings />} {tab === 'whisper' && <WhisperSettings />}
</div> </div>
); );
+51
View File
@@ -166,6 +166,57 @@ export const msSecretApi = {
request<{ ok: true; secret: MsSecretStatus }>('/auth/microsoft-secret', { method: 'DELETE' }), 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) ---------- // ---------- Benutzerverwaltung (nur admin) ----------
export interface UserInput { export interface UserInput {
-2
View File
@@ -89,7 +89,6 @@ export interface ScannerMatch {
export interface ScannerSettings { export interface ScannerSettings {
scanning_active: string; scanning_active: string;
search_interval: string; search_interval: string;
openai_api_key: string;
match_threshold: string; match_threshold: string;
display_threshold: string; display_threshold: string;
sound_notifications_active: string; sound_notifications_active: string;
@@ -108,7 +107,6 @@ export interface ScannerSettings {
export const SCANNER_SETTINGS_DEFAULTS: ScannerSettings = { export const SCANNER_SETTINGS_DEFAULTS: ScannerSettings = {
scanning_active: 'true', scanning_active: 'true',
search_interval: '60', search_interval: '60',
openai_api_key: '',
match_threshold: '70', match_threshold: '70',
display_threshold: '50', display_threshold: '50',
sound_notifications_active: 'true', sound_notifications_active: 'true',
+2 -1
View File
@@ -31,9 +31,10 @@ export type SettingsTab =
| 'mail' | 'mail'
| 'sso' | 'sso'
| 'scanner' | 'scanner'
| 'ki'
| 'whisper'; | '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 * Reiter des Projekt Scanners. "Scanner" selbst ist das Dashboard und damit die