diff --git a/docs/01-architektur.md b/docs/01-architektur.md index 44b93db..28014fd 100644 --- a/docs/01-architektur.md +++ b/docs/01-architektur.md @@ -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
Einstellungen: Nutzerverwaltung, Mein Konto, Akquise, Postfach, Scanner, Whisper"] + R -->|admin| A["Dashboard, Akquise, Opportunities, Interviews, Kontakte, Mails, Scanner
Einstellungen: Nutzerverwaltung, Mein Konto, Akquise, Postfach, Anmeldung, Scanner, KI, Whisper"] R -->|user| U["Dashboard, Akquise, Opportunities, Interviews, Kontakte, Mails, Scanner
Einstellungen: Mein Konto, Akquise, Whisper"] ``` diff --git a/docs/02-datenmodell.md b/docs/02-datenmodell.md index cc93862..0e99f47 100644 --- a/docs/02-datenmodell.md +++ b/docs/02-datenmodell.md @@ -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 diff --git a/docs/03-scanner.md b/docs/03-scanner.md index e3f034f..91c0697 100644 --- a/docs/03-scanner.md +++ b/docs/03-scanner.md @@ -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 diff --git a/docs/06-betrieb.md b/docs/06-betrieb.md index 08f3639..5b2d74f 100644 --- a/docs/06-betrieb.md +++ b/docs/06-betrieb.md @@ -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` | diff --git a/docs/07-anleitung-benutzer.md b/docs/07-anleitung-benutzer.md index cff1417..1f3330a 100644 --- a/docs/07-anleitung-benutzer.md +++ b/docs/07-anleitung-benutzer.md @@ -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. + +![Einstellungen, Reiter KI](bilder/einstellungen-ki.png) + 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 diff --git a/docs/09-quellcode.md b/docs/09-quellcode.md index c1bc3e5..9eeca4f 100644 --- a/docs/09-quellcode.md +++ b/docs/09-quellcode.md @@ -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 diff --git a/docs/12-suche.md b/docs/12-suche.md index 91e7c2e..6f720f0 100644 --- a/docs/12-suche.md +++ b/docs/12-suche.md @@ -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 | diff --git a/docs/14-ki.md b/docs/14-ki.md new file mode 100644 index 0000000..345a469 --- /dev/null +++ b/docs/14-ki.md @@ -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
mit neuem Schlüssel"} + V -->|"abgelehnt"| X["Fehlermeldung,
alter Schlüssel bleibt"] + V -->|"ok"| K[("perinco_scanner.settings
openai_api_key")] + UI -->|"PUT /api/ai/models"| M[("perinco_CRM.crm_settings
ai_model_*")] + K --> AI["server/src/ai.js
aiKey() / modelFor()"] + E["OPENAI_API_KEY
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 | diff --git a/docs/README.md b/docs/README.md index bb5d0a0..343081e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,69 +1,70 @@ -# Dokumentation perinco CRM - -Technische Dokumentation mit Funktionszeichnungen. Die Diagramme sind Mermaid und -werden von Gitea direkt gerendert. - -**Neu hier?** Wer die Anwendung *benutzen* will, fängt bei -[07 Anleitung für Benutzer](07-anleitung-benutzer.md) an. Wer daran *entwickeln* -will, bei [08 Anleitung für Entwickler](08-anleitung-entwickler.md). Beide setzen -keine Vorkenntnisse voraus. - -| Kapitel | Inhalt | -| --- | --- | -| [01 Architektur](01-architektur.md) | Topologie, Prozesse, Ports, Anfrageweg, Rollen | -| [02 Datenmodell](02-datenmodell.md) | Tabellen beider Datenbanken als ER-Diagramme | -| [03 Projekt Scanner](03-scanner.md) | Suchlauf von der Börse bis zum Treffer, Rückbau der alten App | -| [04 Diktat](04-diktat.md) | Spracherkennung im Browser, Modell-Lebenszyklus | -| [05 Frontend](05-frontend.md) | Ansichten, Menü, Komponentenkarte, Zustand | -| [06 Betrieb](06-betrieb.md) | Deployment, Umgebungsvariablen, Dienste, Störungssuche | -| [07 Anleitung für Benutzer](07-anleitung-benutzer.md) | Bedienung aller Bereiche, Schritt für Schritt | -| [08 Anleitung für Entwickler](08-anleitung-entwickler.md) | Einrichten, erweitern, bekannte Fallen | -| [09 Quellcode-Referenz](09-quellcode.md) | jede Datei, ihre Exporte und Zusagen | -| [10 Glossar](10-glossar.md) | Agent, Match, Lead, Opportunity, Status- und Portalwerte | -| [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 | - -Die Designentscheidungen und die Messwerte zur Spracherkennung stehen im -[README des Projekts](../README.md). - -## Das Ganze in einem Bild - -```mermaid -flowchart TB - subgraph Browser - SPA["React-SPA
Vite-Build"] - WSP["Whisper im Web Worker
WebGPU / WASM"] - end - - subgraph Server["Server 212.227.135.41"] - AP["Apache
:443"] - API["Express-Backend
127.0.0.1:5006"] - SCH["Scanner-Scheduler
node-cron"] - MSCH["Mail-Scheduler
node-cron"] - PW["Playwright
Chromium"] - DB1[("MariaDB
perinco_CRM")] - DB2[("MariaDB
perinco_scanner")] - end - - OAI["OpenAI
gpt-4o-mini"] - BOERSE["Projektbörsen
freelancermap, hays, ..."] - GRAPH["Microsoft Graph
crm@perinco.de"] - - SPA -->|"HTTPS /api"| AP - AP --> API - API --> DB1 - API --> DB2 - SCH --> PW - PW --> BOERSE - SCH --> OAI - SCH --> DB2 - API -.-> SCH - API -.-> MSCH - MSCH -->|"Delta Query, nur lesend"| GRAPH - MSCH --> DB1 - SPA -.->|"Audio bleibt im Browser"| WSP -``` - -Die ursprüngliche Scanner-App unter `scanner.perinco.de` ist am 09.09.2026 -zurückgebaut worden; das CRM ist seither die einzige Instanz. +# Dokumentation perinco CRM + +Technische Dokumentation mit Funktionszeichnungen. Die Diagramme sind Mermaid und +werden von Gitea direkt gerendert. + +**Neu hier?** Wer die Anwendung *benutzen* will, fängt bei +[07 Anleitung für Benutzer](07-anleitung-benutzer.md) an. Wer daran *entwickeln* +will, bei [08 Anleitung für Entwickler](08-anleitung-entwickler.md). Beide setzen +keine Vorkenntnisse voraus. + +| Kapitel | Inhalt | +| --- | --- | +| [01 Architektur](01-architektur.md) | Topologie, Prozesse, Ports, Anfrageweg, Rollen | +| [02 Datenmodell](02-datenmodell.md) | Tabellen beider Datenbanken als ER-Diagramme | +| [03 Projekt Scanner](03-scanner.md) | Suchlauf von der Börse bis zum Treffer, Rückbau der alten App | +| [04 Diktat](04-diktat.md) | Spracherkennung im Browser, Modell-Lebenszyklus | +| [05 Frontend](05-frontend.md) | Ansichten, Menü, Komponentenkarte, Zustand | +| [06 Betrieb](06-betrieb.md) | Deployment, Umgebungsvariablen, Dienste, Störungssuche | +| [07 Anleitung für Benutzer](07-anleitung-benutzer.md) | Bedienung aller Bereiche, Schritt für Schritt | +| [08 Anleitung für Entwickler](08-anleitung-entwickler.md) | Einrichten, erweitern, bekannte Fallen | +| [09 Quellcode-Referenz](09-quellcode.md) | jede Datei, ihre Exporte und Zusagen | +| [10 Glossar](10-glossar.md) | Agent, Match, Lead, Opportunity, Status- und Portalwerte | +| [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). + +## Das Ganze in einem Bild + +```mermaid +flowchart TB + subgraph Browser + SPA["React-SPA
Vite-Build"] + WSP["Whisper im Web Worker
WebGPU / WASM"] + end + + subgraph Server["Server 212.227.135.41"] + AP["Apache
:443"] + API["Express-Backend
127.0.0.1:5006"] + SCH["Scanner-Scheduler
node-cron"] + MSCH["Mail-Scheduler
node-cron"] + PW["Playwright
Chromium"] + DB1[("MariaDB
perinco_CRM")] + DB2[("MariaDB
perinco_scanner")] + end + + OAI["OpenAI
gpt-4o-mini"] + BOERSE["Projektbörsen
freelancermap, hays, ..."] + GRAPH["Microsoft Graph
crm@perinco.de"] + + SPA -->|"HTTPS /api"| AP + AP --> API + API --> DB1 + API --> DB2 + SCH --> PW + PW --> BOERSE + SCH --> OAI + SCH --> DB2 + API -.-> SCH + API -.-> MSCH + MSCH -->|"Delta Query, nur lesend"| GRAPH + MSCH --> DB1 + SPA -.->|"Audio bleibt im Browser"| WSP +``` + +Die ursprüngliche Scanner-App unter `scanner.perinco.de` ist am 09.09.2026 +zurückgebaut worden; das CRM ist seither die einzige Instanz. diff --git a/docs/bilder/einstellungen-ki.png b/docs/bilder/einstellungen-ki.png new file mode 100644 index 0000000..5278e95 Binary files /dev/null and b/docs/bilder/einstellungen-ki.png differ diff --git a/server/.env.example b/server/.env.example index 40590ef..339a85d 100644 --- a/server/.env.example +++ b/server/.env.example @@ -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= # --------------------------------------------------------------------------- diff --git a/server/package.json b/server/package.json index c235604..ad1dd14 100644 --- a/server/package.json +++ b/server/package.json @@ -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", diff --git a/server/src/ai.js b/server/src/ai.js index b086e28..7064a50 100644 --- a/server/src/ai.js +++ b/server/src/ai.js @@ -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_`. 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), diff --git a/server/src/routes/ai.js b/server/src/routes/ai.js new file mode 100644 index 0000000..6b9999a --- /dev/null +++ b/server/src/routes/ai.js @@ -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; diff --git a/server/src/routes/search.js b/server/src/routes/search.js index eeb690e..42ebb29 100644 --- a/server/src/routes/search.js +++ b/server/src/routes/search.js @@ -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) { diff --git a/server/src/scanner/services/appliedCheck.js b/server/src/scanner/services/appliedCheck.js index 4e62a4e..a6c9067 100644 --- a/server/src/scanner/services/appliedCheck.js +++ b/server/src/scanner/services/appliedCheck.js @@ -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 || '{}'); diff --git a/server/src/scanner/services/matchingEngine.js b/server/src/scanner/services/matchingEngine.js index 94bf7f8..9179aa1 100644 --- a/server/src/scanner/services/matchingEngine.js +++ b/server/src/scanner/services/matchingEngine.js @@ -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(); diff --git a/server/src/server.js b/server/src/server.js index 745cdb4..70ad5fa 100644 --- a/server/src/server.js +++ b/server/src/server.js @@ -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); diff --git a/server/test/ai.test.mjs b/server/test/ai.test.mjs new file mode 100644 index 0000000..912d0b3 --- /dev/null +++ b/server/test/ai.test.mjs @@ -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); diff --git a/src/App.tsx b/src/App.tsx index 047ba7c..d0a8a65 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 = diff --git a/src/components/settings/AiSettings.tsx b/src/components/settings/AiSettings.tsx new file mode 100644 index 0000000..272ab4d --- /dev/null +++ b/src/components/settings/AiSettings.tsx @@ -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 }) => ( +
+
{label}
+
{value}
+
+); + +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 ( +
+

OpenAI-Schlüssel

+

+ Ein Schlüssel für alles: Scanner-Bewertung, Abgleich „bereits beworben“ und erweiterte Suche. + Angezeigt wird nur das Ende. +

+ +
+ + {keyStatus.last4 ? `sk-…${keyStatus.last4}` : 'ja'} + + ) : ( + kein Schlüssel + ) + } + /> + +
+ + {!keyStatus.configured && ( +

+ Ohne Schlüssel bewertet der Scanner keine Projekte und die erweiterte Suche bleibt aus. +

+ )} + +
+
+ + setValue(event.target.value)} + placeholder="sk-proj-..." + className={inputClass} + /> +

+ 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. +

+
+ +
+ + + {keyStatus.source === 'db' && ( + + )} +
+
+
+ ); +}; + +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>(() => ({ + 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> = {}; + 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 ( +
+

Modell

+

+ {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.'} +

+ {available.error && ( +

+ OpenAI: {available.error} +

+ )} + +
+ {PURPOSE_ORDER.map((purpose) => ( +
+ + +
+ ))} + + +
+
+ ); +}; + +export const AiSettings: React.FC = () => { + const { addToast } = useToast(); + const [data, setData] = useState(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

Wird geladen...

; + if (!data) return null; + + return ( +
+ setData((prev) => (prev ? { ...prev, key, available } : prev))} + /> + setData((prev) => (prev ? { ...prev, models } : prev))} + /> +
+ ); +}; diff --git a/src/components/settings/ScannerSettings.tsx b/src/components/settings/ScannerSettings.tsx index 1680d34..b1eb9d4 100644 --- a/src/components/settings/ScannerSettings.tsx +++ b/src/components/settings/ScannerSettings.tsx @@ -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 = () => {
{
-
- - set('openai_api_key', event.target.value)} - placeholder={ - keyStored - ? 'Leer lassen, um den gespeicherten Key zu behalten' - : 'sk-proj-...' - } - className={inputClass} - /> -
-
= ({ initialTab }) {tab === 'mail' && isAdmin && } {tab === 'sso' && isAdmin && } {tab === 'scanner' && isAdmin && } + {tab === 'ki' && isAdmin && } {tab === 'whisper' && } ); diff --git a/src/lib/api.ts b/src/lib/api.ts index 4bab665..e0861dc 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -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; + available: AiAvailableModels; +} + +export const aiApi = { + settings: () => request('/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>) => + request<{ ok: true; models: Record }>('/ai/models', { + method: 'PUT', + body: JSON.stringify(models), + }), +}; + // ---------- Benutzerverwaltung (nur admin) ---------- export interface UserInput { diff --git a/src/lib/scannerApi.ts b/src/lib/scannerApi.ts index 7311df1..7d96cd7 100644 --- a/src/lib/scannerApi.ts +++ b/src/lib/scannerApi.ts @@ -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', diff --git a/src/types.ts b/src/types.ts index a8e2d94..02a956e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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