KI: Vorgabemodell auf gpt-6-luna umgestellt
gpt-6-luna kostet rund 50 % weniger als gpt-5.6-luna. Das Modell in icon_settings (Einstellungen -> KI) hat weiter Vorrang vor der Vorgabe. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
4adf48f336
commit
6fe5b18c11
+281
-281
@@ -1,281 +1,281 @@
|
|||||||
# 05 KI
|
# 05 KI
|
||||||
|
|
||||||
Modell: **`gpt-5.6-luna`** (OpenAI), angesprochen über `chat/completions`. Der
|
Modell: **`gpt-6-luna`** (OpenAI, seit 29.09.2026; vorher `gpt-5.6-luna`, rund 50 % teurer), angesprochen über `chat/completions`. Der
|
||||||
Browser spricht nie direkt mit OpenAI, jede Anfrage läuft über das Backend
|
Browser spricht nie direkt mit OpenAI, jede Anfrage läuft über das Backend
|
||||||
(`server/src/ai.js`). Schlüssel und Modell werden unter *Einstellungen → KI*
|
(`server/src/ai.js`). Schlüssel und Modell werden unter *Einstellungen → KI*
|
||||||
gepflegt und liegen in `icon_settings`; `server/.env` (chmod 600) bleibt
|
gepflegt und liegen in `icon_settings`; `server/.env` (chmod 600) bleibt
|
||||||
Startwert und Rückfall.
|
Startwert und Rückfall.
|
||||||
|
|
||||||
Fehlt der Schlüssel, läuft das CRM normal weiter und blendet die KI-Knöpfe aus.
|
Fehlt der Schlüssel, läuft das CRM normal weiter und blendet die KI-Knöpfe aus.
|
||||||
`GET /api/ai/status` sagt der Oberfläche, ob etwas eingerichtet ist.
|
`GET /api/ai/status` sagt der Oberfläche, ob etwas eingerichtet ist.
|
||||||
|
|
||||||
## Schlüssel pflegen
|
## Schlüssel pflegen
|
||||||
|
|
||||||
*Einstellungen → KI* (nur Administratoren). Der Schlüssel steht in
|
*Einstellungen → KI* (nur Administratoren). Der Schlüssel steht in
|
||||||
`icon_settings` (`openai_api_key`), die `.env` bleibt Startwert und Rückfall –
|
`icon_settings` (`openai_api_key`), die `.env` bleibt Startwert und Rückfall –
|
||||||
**die Datenbank gewinnt**. Grund: ein Schlüssel läuft ab oder wird gewechselt,
|
**die Datenbank gewinnt**. Grund: ein Schlüssel läuft ab oder wird gewechselt,
|
||||||
und dann soll niemand dafür eine Datei auf dem Server bearbeiten und einen
|
und dann soll niemand dafür eine Datei auf dem Server bearbeiten und einen
|
||||||
Dienst neu starten müssen.
|
Dienst neu starten müssen.
|
||||||
|
|
||||||
| Was | Verhalten |
|
| Was | Verhalten |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Anzeige | nur verkürzt (`sk-proj…a1b2`) – vollständig verlässt der Schlüssel den Server nie |
|
| Anzeige | nur verkürzt (`sk-proj…a1b2`) – vollständig verlässt der Schlüssel den Server nie |
|
||||||
| Eingabe | Passwortfeld, schreibt nur; ein bestehender Wert wird ersetzt |
|
| Eingabe | Passwortfeld, schreibt nur; ein bestehender Wert wird ersetzt |
|
||||||
| Entfernen | löscht den Wert in der Datenbank; steht in der `.env` einer, gilt wieder dieser |
|
| Entfernen | löscht den Wert in der Datenbank; steht in der `.env` einer, gilt wieder dieser |
|
||||||
| Modell | Auswahlliste der Chat-Modelle, die der Schlüssel nutzen darf (siehe unten); Vorgabe `gpt-5.6-luna` |
|
| Modell | Auswahlliste der Chat-Modelle, die der Schlüssel nutzen darf (siehe unten); Vorgabe `gpt-6-luna` |
|
||||||
| Test | ein Miniaufruf gegen das Modell – sagt, ob Schlüssel, Modellname und Kontingent zusammenpassen |
|
| Test | ein Miniaufruf gegen das Modell – sagt, ob Schlüssel, Modellname und Kontingent zusammenpassen |
|
||||||
|
|
||||||
In der Historie landet, **dass** jemand den Schlüssel gewechselt hat, nie der
|
In der Historie landet, **dass** jemand den Schlüssel gewechselt hat, nie der
|
||||||
Wert. Ein stiller Wechsel wäre später nicht mehr nachvollziehbar.
|
Wert. Ein stiller Wechsel wäre später nicht mehr nachvollziehbar.
|
||||||
|
|
||||||
### Modellauswahl
|
### Modellauswahl
|
||||||
|
|
||||||
Statt eines freien Textfelds zeigt der Reiter ein Dropdown. Die Liste kommt von
|
Statt eines freien Textfelds zeigt der Reiter ein Dropdown. Die Liste kommt von
|
||||||
`GET /api/ai/models`, das Backend fragt dafür OpenAI `GET /v1/models` mit dem
|
`GET /api/ai/models`, das Backend fragt dafür OpenAI `GET /v1/models` mit dem
|
||||||
hinterlegten Schlüssel (`listAiModels()` in `ai.js`) – angezeigt wird also genau
|
hinterlegten Schlüssel (`listAiModels()` in `ai.js`) – angezeigt wird also genau
|
||||||
das, was dieser Schlüssel darf.
|
das, was dieser Schlüssel darf.
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
sequenceDiagram
|
sequenceDiagram
|
||||||
participant B as Browser (AiSettings)
|
participant B as Browser (AiSettings)
|
||||||
participant S as Backend /api/ai/models
|
participant S as Backend /api/ai/models
|
||||||
participant O as OpenAI /v1/models
|
participant O as OpenAI /v1/models
|
||||||
B->>S: GET (admin)
|
B->>S: GET (admin)
|
||||||
alt Liste im Speicher und < 10 min alt, gleicher Schlüssel
|
alt Liste im Speicher und < 10 min alt, gleicher Schlüssel
|
||||||
S-->>B: { models }
|
S-->>B: { models }
|
||||||
else
|
else
|
||||||
S->>O: GET mit Bearer-Schlüssel
|
S->>O: GET mit Bearer-Schlüssel
|
||||||
O-->>S: alle Modelle
|
O-->>S: alle Modelle
|
||||||
S->>S: nur Chat-Modelle, neueste zuerst
|
S->>S: nur Chat-Modelle, neueste zuerst
|
||||||
S-->>B: { models }
|
S-->>B: { models }
|
||||||
end
|
end
|
||||||
```
|
```
|
||||||
|
|
||||||
| Regel | Umsetzung |
|
| Regel | Umsetzung |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Nur Chat-Modelle | ID beginnt mit `gpt-`, `o<Ziffer>` oder `chatgpt-`; raus fallen Embedding, Whisper, TTS, Bild, Audio, Realtime, Transcribe, Moderation, Search, Instruct, Codex, Computer-Use, Sora |
|
| Nur Chat-Modelle | ID beginnt mit `gpt-`, `o<Ziffer>` oder `chatgpt-`; raus fallen Embedding, Whisper, TTS, Bild, Audio, Realtime, Transcribe, Moderation, Search, Instruct, Codex, Computer-Use, Sora |
|
||||||
| Reihenfolge | nach `created` absteigend – neueste oben |
|
| Reihenfolge | nach `created` absteigend – neueste oben |
|
||||||
| Zwischenspeicher | 10 Minuten im Speicher; ein anderer Schlüssel lädt sofort neu |
|
| Zwischenspeicher | 10 Minuten im Speicher; ein anderer Schlüssel lädt sofort neu |
|
||||||
| Aktives Modell nicht mehr gelistet | bleibt im Dropdown, markiert „– nicht verfügbar" |
|
| Aktives Modell nicht mehr gelistet | bleibt im Dropdown, markiert „– nicht verfügbar" |
|
||||||
| Vorgabemodell | markiert „(Vorgabe)" |
|
| Vorgabemodell | markiert „(Vorgabe)" |
|
||||||
| Liste nicht abrufbar (kein Schlüssel, OpenAI down) | Rückfall auf das Textfeld mit Hinweis und *Erneut versuchen* |
|
| Liste nicht abrufbar (kein Schlüssel, OpenAI down) | Rückfall auf das Textfeld mit Hinweis und *Erneut versuchen* |
|
||||||
| Schlüssel gespeichert/entfernt | Liste wird neu geladen bzw. verworfen |
|
| Schlüssel gespeichert/entfernt | Liste wird neu geladen bzw. verworfen |
|
||||||
|
|
||||||
> **Fallstrick:** `gpt-5.6-luna` akzeptiert **kein eigenes `temperature`** und
|
> **Fallstrick:** die Luna-Modelle (`gpt-5.6-luna`, `gpt-6-luna`) akzeptieren **kein eigenes `temperature`** und
|
||||||
> weist jeden Wert außer dem Standard mit HTTP 400 ab. `ai.js` schickt deshalb
|
> weist jeden Wert außer dem Standard mit HTTP 400 ab. `ai.js` schickt deshalb
|
||||||
> keines. `response_format: {type:'json_object'}` funktioniert dagegen.
|
> keines. `response_format: {type:'json_object'}` funktioniert dagegen.
|
||||||
|
|
||||||
Drei Stellen nutzen das Modell:
|
Drei Stellen nutzen das Modell:
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart LR
|
flowchart LR
|
||||||
CV["CV-Import<br/>Lebenslauf → Kontaktvorschlag"] --> AI["server/src/ai.js"]
|
CV["CV-Import<br/>Lebenslauf → Kontaktvorschlag"] --> AI["server/src/ai.js"]
|
||||||
TX["Schreibhilfe<br/>Ausschreibungstext"] --> AI
|
TX["Schreibhilfe<br/>Ausschreibungstext"] --> AI
|
||||||
SU["Erweiterte Suche<br/>Frage in Worten"] --> AI
|
SU["Erweiterte Suche<br/>Frage in Worten"] --> AI
|
||||||
AI -->|"HTTPS, Schluessel bleibt am Server"| OAI["api.openai.com"]
|
AI -->|"HTTPS, Schluessel bleibt am Server"| OAI["api.openai.com"]
|
||||||
```
|
```
|
||||||
|
|
||||||
## CV-Import
|
## CV-Import
|
||||||
|
|
||||||
*Kontakte → CV-Import.* Lebensläufe als PDF, DOCX oder Text, per Dateiauswahl
|
*Kontakte → CV-Import.* Lebensläufe als PDF, DOCX oder Text, per Dateiauswahl
|
||||||
oder Ziehen, auch mehrere auf einmal (bis 20, je 15 MB).
|
oder Ziehen, auch mehrere auf einmal (bis 20, je 15 MB).
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart LR
|
flowchart LR
|
||||||
U["Hochladen"] --> E["Eingang"]
|
U["Hochladen"] --> E["Eingang"]
|
||||||
E -->|"Mensch: Ja, auslesen"| S["Screening"]
|
E -->|"Mensch: Ja, auslesen"| S["Screening"]
|
||||||
E -->|"Mensch: Nein"| V["Verworfen"]
|
E -->|"Mensch: Nein"| V["Verworfen"]
|
||||||
S -->|"Formular bestaetigt"| A["Auswahl<br/>Kontakt angelegt"]
|
S -->|"Formular bestaetigt"| A["Auswahl<br/>Kontakt angelegt"]
|
||||||
S -->|"abgelehnt"| V
|
S -->|"abgelehnt"| V
|
||||||
```
|
```
|
||||||
|
|
||||||
| Ordner | Bedeutung |
|
| Ordner | Bedeutung |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Eingang | hochgeladen; ein Mensch entscheidet je Datei „Ja, auslesen" oder „Nein" |
|
| Eingang | hochgeladen; ein Mensch entscheidet je Datei „Ja, auslesen" oder „Nein" |
|
||||||
| Screening | ausgelesen; der Vorschlag steht im Formular und wird bestätigt |
|
| Screening | ausgelesen; der Vorschlag steht im Formular und wird bestätigt |
|
||||||
| Auswahl | übernommen – Kontakt angelegt, Lebenslauf hängt daran |
|
| Auswahl | übernommen – Kontakt angelegt, Lebenslauf hängt daran |
|
||||||
|
|
||||||
Es wird bewusst **nichts automatisch angelegt**: ein falsch gelesener Name oder
|
Es wird bewusst **nichts automatisch angelegt**: ein falsch gelesener Name oder
|
||||||
Stundensatz wäre in der Datenbank schwerer zu finden als im Formular. Was dort
|
Stundensatz wäre in der Datenbank schwerer zu finden als im Formular. Was dort
|
||||||
korrigiert wird, gewinnt gegen die KI.
|
korrigiert wird, gewinnt gegen die KI.
|
||||||
|
|
||||||
Übernommen werden Name, Position, Kontaktdaten, LinkedIn, aktueller Arbeitgeber,
|
Übernommen werden Name, Position, Kontaktdaten, LinkedIn, aktueller Arbeitgeber,
|
||||||
Schlagworte, eine Zusammenfassung und – nur wenn sie ausdrücklich im Text stehen
|
Schlagworte, eine Zusammenfassung und – nur wenn sie ausdrücklich im Text stehen
|
||||||
– die **Stundensätze**. Steht im Lebenslauf ein Tagessatz, wird er *nicht*
|
– die **Stundensätze**. Steht im Lebenslauf ein Tagessatz, wird er *nicht*
|
||||||
umgerechnet, sondern weggelassen.
|
umgerechnet, sondern weggelassen.
|
||||||
|
|
||||||
Frühere Stationen werden als **beendete** Anstellungen angelegt; dadurch
|
Frühere Stationen werden als **beendete** Anstellungen angelegt; dadurch
|
||||||
erscheint der Kontakt beim jeweiligen Unternehmen sofort im Zweig „Verlassen".
|
erscheint der Kontakt beim jeweiligen Unternehmen sofort im Zweig „Verlassen".
|
||||||
Unbekannte Unternehmen legt der Import mit an.
|
Unbekannte Unternehmen legt der Import mit an.
|
||||||
|
|
||||||
### Der Werdegang bekommt einen eigenen Durchgang
|
### Der Werdegang bekommt einen eigenen Durchgang
|
||||||
|
|
||||||
Der große Prompt muss Name, Kontaktdaten, Schlagworte, Zusammenfassung und Sätze
|
Der große Prompt muss Name, Kontaktdaten, Schlagworte, Zusammenfassung und Sätze
|
||||||
zugleich liefern – die Stationen kamen dabei **unzuverlässig** heraus: derselbe
|
zugleich liefern – die Stationen kamen dabei **unzuverlässig** heraus: derselbe
|
||||||
Lebenslauf ergab einmal drei, einmal zwei, ein Beraterprofil mit fünf Kunden nur
|
Lebenslauf ergab einmal drei, einmal zwei, ein Beraterprofil mit fünf Kunden nur
|
||||||
einen. Deshalb läuft ein **zweiter Aufruf mit einer einzigen Aufgabe**
|
einen. Deshalb läuft ein **zweiter Aufruf mit einer einzigen Aufgabe**
|
||||||
(`STATIONS_PROMPT` in `server/src/routes/cvs.js`), und beide Listen werden
|
(`STATIONS_PROMPT` in `server/src/routes/cvs.js`), und beide Listen werden
|
||||||
zusammengeführt.
|
zusammengeführt.
|
||||||
|
|
||||||
Was dieser Durchgang ausdrücklich verlangt:
|
Was dieser Durchgang ausdrücklich verlangt:
|
||||||
|
|
||||||
* Vollständigkeit vor Kürze – auch kurze und nebenberufliche Stationen.
|
* Vollständigkeit vor Kürze – auch kurze und nebenberufliche Stationen.
|
||||||
* Alle Abschnitte, die Stationen tragen können: *Berufserfahrung*, *Beruflicher
|
* Alle Abschnitte, die Stationen tragen können: *Berufserfahrung*, *Beruflicher
|
||||||
Werdegang*, *Projekte*, *Projekterfahrung*, *Referenzen*, *Kunden*.
|
Werdegang*, *Projekte*, *Projekterfahrung*, *Referenzen*, *Kunden*.
|
||||||
* Bei freiberuflicher Tätigkeit ist der **Kunde** das Unternehmen, nicht die
|
* Bei freiberuflicher Tätigkeit ist der **Kunde** das Unternehmen, nicht die
|
||||||
eigene Firma.
|
eigene Firma.
|
||||||
* Derselbe Kunde zu verschiedenen Zeiten gehört mehrfach in die Liste.
|
* Derselbe Kunde zu verschiedenen Zeiten gehört mehrfach in die Liste.
|
||||||
* Schule, Studium, Zertifikate gehören **nicht** hinein.
|
* Schule, Studium, Zertifikate gehören **nicht** hinein.
|
||||||
* Eine fehlende Zeitangabe ist kein Grund, eine Station wegzulassen.
|
* Eine fehlende Zeitangabe ist kein Grund, eine Station wegzulassen.
|
||||||
* Der Text aus PDF und DOCX ist bei mehrspaltigem Layout durcheinander – Daten
|
* Der Text aus PDF und DOCX ist bei mehrspaltigem Layout durcheinander – Daten
|
||||||
stehen dann als Block neben den Firmennamen und müssen der Reihenfolge nach
|
stehen dann als Block neben den Firmennamen und müssen der Reihenfolge nach
|
||||||
zugeordnet werden.
|
zugeordnet werden.
|
||||||
|
|
||||||
Zusammengeführt wird über Firmenname und Anfangsjahr, wobei der Name vorher auf
|
Zusammengeführt wird über Firmenname und Anfangsjahr, wobei der Name vorher auf
|
||||||
das Vergleichbare heruntergebrochen wird (Rechtsformen wie GmbH, AG, SE fliegen
|
das Vergleichbare heruntergebrochen wird (Rechtsformen wie GmbH, AG, SE fliegen
|
||||||
raus). So erscheint „Bayerische Versorgungskammer" nicht zweimal, weil ein
|
raus). So erscheint „Bayerische Versorgungskammer" nicht zweimal, weil ein
|
||||||
Durchgang sie mit Klammerzusatz gelesen hat.
|
Durchgang sie mit Klammerzusatz gelesen hat.
|
||||||
|
|
||||||
Die Obergrenze beim Übernehmen liegt bei **60** Stationen statt bei 20:
|
Die Obergrenze beim Übernehmen liegt bei **60** Stationen statt bei 20:
|
||||||
Beraterprofile mit Projektliste kommen leicht über zwanzig Kunden, und alles
|
Beraterprofile mit Projektliste kommen leicht über zwanzig Kunden, und alles
|
||||||
darüber verschwand vorher stillschweigend.
|
darüber verschwand vorher stillschweigend.
|
||||||
|
|
||||||
> Gemessen an den vorhandenen Lebensläufen: 1 → 5 Stationen, 3 → 3, und ein
|
> Gemessen an den vorhandenen Lebensläufen: 1 → 5 Stationen, 3 → 3, und ein
|
||||||
> reines Projektportfolio ohne einen einzigen Arbeitgeber bleibt bei 0 – dort
|
> reines Projektportfolio ohne einen einzigen Arbeitgeber bleibt bei 0 – dort
|
||||||
> stehen nur Produktnamen im Fließtext, und daraus Stationen zu erfinden wäre
|
> stehen nur Produktnamen im Fließtext, und daraus Stationen zu erfinden wäre
|
||||||
> schlechter als keine.
|
> schlechter als keine.
|
||||||
|
|
||||||
### Die laufende Station
|
### Die laufende Station
|
||||||
|
|
||||||
Der aktuelle Arbeitgeber wird beim Übernehmen als laufende Station angelegt.
|
Der aktuelle Arbeitgeber wird beim Übernehmen als laufende Station angelegt.
|
||||||
Zwei Fehler hingen daran, und beide sahen in der Karte nach einem Lesefehler
|
Zwei Fehler hingen daran, und beide sahen in der Karte nach einem Lesefehler
|
||||||
aus:
|
aus:
|
||||||
|
|
||||||
* Sie begann am **Tag des Imports**. Im Lebenslauf stand „seit 03/2023", in der
|
* Sie begann am **Tag des Imports**. Im Lebenslauf stand „seit 03/2023", in der
|
||||||
Karte das heutige Datum.
|
Karte das heutige Datum.
|
||||||
* Und sie stand **zweimal** da: einmal als laufende Station ohne Datum, einmal
|
* Und sie stand **zweimal** da: einmal als laufende Station ohne Datum, einmal
|
||||||
als frühere mit Datum.
|
als frühere mit Datum.
|
||||||
|
|
||||||
Beides kam daher, dass die Liste der Stationen und der Arbeitgeber getrennt
|
Beides kam daher, dass die Liste der Stationen und der Arbeitgeber getrennt
|
||||||
verarbeitet wurden. Jetzt wird zuerst gesucht, welche Station den Arbeitgeber
|
verarbeitet wurden. Jetzt wird zuerst gesucht, welche Station den Arbeitgeber
|
||||||
beschreibt – über den Firmennamen ohne Rechtsform und daran, dass sie laufend
|
beschreibt – über den Firmennamen ohne Rechtsform und daran, dass sie laufend
|
||||||
ist (als aktuell gekennzeichnet oder ohne Enddatum). Ihr Startdatum eröffnet die
|
ist (als aktuell gekennzeichnet oder ohne Enddatum). Ihr Startdatum eröffnet die
|
||||||
laufende Station, und sie wird nicht noch einmal als frühere angelegt.
|
laufende Station, und sie wird nicht noch einmal als frühere angelegt.
|
||||||
|
|
||||||
Eine **früher datierte** Station beim selben Unternehmen bleibt davon unberührt:
|
Eine **früher datierte** Station beim selben Unternehmen bleibt davon unberührt:
|
||||||
ein Berater ist oft zweimal beim selben Kunden, und beide Einsätze gehören in
|
ein Berater ist oft zweimal beim selben Kunden, und beide Einsätze gehören in
|
||||||
den Werdegang.
|
den Werdegang.
|
||||||
|
|
||||||
Der Text wird vor der Anfrage lokal aus der Datei geholt
|
Der Text wird vor der Anfrage lokal aus der Datei geholt
|
||||||
(`server/src/cvText.js`) – PDF und DOCX ohne Fremddienst. Die Datei bleibt am
|
(`server/src/cvText.js`) – PDF und DOCX ohne Fremddienst. Die Datei bleibt am
|
||||||
Kontakt (`icon_contacts.cv_file`) und lässt sich aus dessen Karte herunterladen;
|
Kontakt (`icon_contacts.cv_file`) und lässt sich aus dessen Karte herunterladen;
|
||||||
der Download läuft über `fetch` mit Bearer-Kopf, ein normaler Link schickt ihn
|
der Download läuft über `fetch` mit Bearer-Kopf, ein normaler Link schickt ihn
|
||||||
nicht mit.
|
nicht mit.
|
||||||
|
|
||||||
### Das Bewerbungsfoto
|
### Das Bewerbungsfoto
|
||||||
|
|
||||||
Trägt der Lebenslauf ein Portrait, wird es beim Auslesen mit herausgeholt und im
|
Trägt der Lebenslauf ein Portrait, wird es beim Auslesen mit herausgeholt und im
|
||||||
Screening-Formular oben angezeigt – mit Haken, standardmäßig gesetzt. Beim
|
Screening-Formular oben angezeigt – mit Haken, standardmäßig gesetzt. Beim
|
||||||
Übernehmen landet es als Foto am Kontakt.
|
Übernehmen landet es als Foto am Kontakt.
|
||||||
|
|
||||||
Gefunden wird es **ohne zusätzliches Paket** (`server/src/cvPhoto.js`):
|
Gefunden wird es **ohne zusätzliches Paket** (`server/src/cvPhoto.js`):
|
||||||
|
|
||||||
| Format | Weg |
|
| Format | Weg |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| DOCX | ist ein ZIP – die Dateien unter `word/media/` werden mit dem eingebauten `zlib` herausgelöst |
|
| DOCX | ist ein ZIP – die Dateien unter `word/media/` werden mit dem eingebauten `zlib` herausgelöst |
|
||||||
| PDF | mit `/DCTDecode` abgelegte JPEGs stehen unverändert in der Datei und lassen sich an ihren eigenen Markierungen (FFD8 … FFD9) ausschneiden |
|
| PDF | mit `/DCTDecode` abgelegte JPEGs stehen unverändert in der Datei und lassen sich an ihren eigenen Markierungen (FFD8 … FFD9) ausschneiden |
|
||||||
|
|
||||||
Bilder in anderen PDF-Kodierungen (FlateDecode, also rohe Bitmaps) bleiben außen
|
Bilder in anderen PDF-Kodierungen (FlateDecode, also rohe Bitmaps) bleiben außen
|
||||||
vor: sie müssten neu kodiert werden, und dafür bräuchte es eine Bildbibliothek
|
vor: sie müssten neu kodiert werden, und dafür bräuchte es eine Bildbibliothek
|
||||||
auf dem Server.
|
auf dem Server.
|
||||||
|
|
||||||
**Welches Bild das Portrait ist**, entscheidet die Form – die Reihenfolge in der
|
**Welches Bild das Portrait ist**, entscheidet die Form – die Reihenfolge in der
|
||||||
Datei hilft nicht, in DOCX steht das Firmenlogo oft vor dem Foto:
|
Datei hilft nicht, in DOCX steht das Firmenlogo oft vor dem Foto:
|
||||||
|
|
||||||
* kleiner als 100 × 100 px → Symbol, Trennlinie, Aufzählungszeichen
|
* kleiner als 100 × 100 px → Symbol, Trennlinie, Aufzählungszeichen
|
||||||
* Seitenverhältnis außerhalb 0,4–1,6 → Kopfzeile, Banner, Diagramm
|
* Seitenverhältnis außerhalb 0,4–1,6 → Kopfzeile, Banner, Diagramm
|
||||||
* vom Rest gewinnt **Hochformat** vor Querformat, dann das größere
|
* vom Rest gewinnt **Hochformat** vor Querformat, dann das größere
|
||||||
|
|
||||||
Verkleinert wird im Browser, über denselben Weg wie jedes andere Foto
|
Verkleinert wird im Browser, über denselben Weg wie jedes andere Foto
|
||||||
(`src/lib/imageCompress.ts`): mittiger Ausschnitt, 400 px im Quadrat, WebP – und
|
(`src/lib/imageCompress.ts`): mittiger Ausschnitt, 400 px im Quadrat, WebP – und
|
||||||
eine harte Obergrenze von **512 kB**, unter die notfalls die Kantenlänge
|
eine harte Obergrenze von **512 kB**, unter die notfalls die Kantenlänge
|
||||||
schrittweise fällt. In der Praxis liegt ein Ergebnis bei 20–60 kB; die Grenze
|
schrittweise fällt. In der Praxis liegt ein Ergebnis bei 20–60 kB; die Grenze
|
||||||
ist die Zusicherung, nicht die Regel.
|
ist die Zusicherung, nicht die Regel.
|
||||||
|
|
||||||
Das gefundene Bild liegt bis zur Übernahme in `icon_cv_imports.photo` und wird
|
Das gefundene Bild liegt bis zur Übernahme in `icon_cv_imports.photo` und wird
|
||||||
danach dort geleert – sonst stünde dasselbe Foto zweimal in der Datenbank. In
|
danach dort geleert – sonst stünde dasselbe Foto zweimal in der Datenbank. In
|
||||||
der Liste der Importe steht es **nicht**: sie trägt nur `has_photo`, das Bild
|
der Liste der Importe steht es **nicht**: sie trägt nur `has_photo`, das Bild
|
||||||
holt das Formular einzeln (`GET /api/cvs/:id/photo`).
|
holt das Formular einzeln (`GET /api/cvs/:id/photo`).
|
||||||
|
|
||||||
## Schreibhilfe für Ausschreibungen
|
## Schreibhilfe für Ausschreibungen
|
||||||
|
|
||||||
Das Feld *Ausschreibungstext* (`icon_tenders.notes`) ist ein WYSIWYG-Editor mit
|
Das Feld *Ausschreibungstext* (`icon_tenders.notes`) ist ein WYSIWYG-Editor mit
|
||||||
fünf Knöpfen: *Text erzeugen*, *Verbessern*, *Kürzen*, *Stichpunkte*,
|
fünf Knöpfen: *Text erzeugen*, *Verbessern*, *Kürzen*, *Stichpunkte*,
|
||||||
*Anforderungsprofil*. Darunter ein Feld für die Vorgabe („Interim PL SAP
|
*Anforderungsprofil*. Darunter ein Feld für die Vorgabe („Interim PL SAP
|
||||||
S/4HANA, Bank, 12 Monate, 80 % remote").
|
S/4HANA, Bank, 12 Monate, 80 % remote").
|
||||||
|
|
||||||
Der vorherige Stand lässt sich mit *Zurück* zurückholen – die KI überschreibt
|
Der vorherige Stand lässt sich mit *Zurück* zurückholen – die KI überschreibt
|
||||||
nichts unwiderruflich. Gespeichert wird 800 ms nach dem letzten Tastendruck, es
|
nichts unwiderruflich. Gespeichert wird 800 ms nach dem letzten Tastendruck, es
|
||||||
gibt keinen Speichern-Knopf.
|
gibt keinen Speichern-Knopf.
|
||||||
|
|
||||||
Der Diktat-Knopf steht in derselben Leiste und ist **unabhängig** von der KI: er
|
Der Diktat-Knopf steht in derselben Leiste und ist **unabhängig** von der KI: er
|
||||||
rechnet im Browser und braucht keinen Schlüssel (siehe [04 Diktat](04-diktat.md)).
|
rechnet im Browser und braucht keinen Schlüssel (siehe [04 Diktat](04-diktat.md)).
|
||||||
|
|
||||||
## Erweiterte Suche
|
## Erweiterte Suche
|
||||||
|
|
||||||
Ein Feld, zwei Modi – umgeschaltet über die Symbole rechts im Feld:
|
Ein Feld, zwei Modi – umgeschaltet über die Symbole rechts im Feld:
|
||||||
|
|
||||||
**Lupe – die normale Suche** (`Strg+K`). Über Kontakte, Unternehmen,
|
**Lupe – die normale Suche** (`Strg+K`). Über Kontakte, Unternehmen,
|
||||||
Ausschreibungen, Projekte, Leads und die Historie; läuft beim Tippen mit, ein
|
Ausschreibungen, Projekte, Leads und die Historie; läuft beim Tippen mit, ein
|
||||||
Treffer öffnet direkt die Karte. Bewusst `LIKE` statt Volltextindex: die
|
Treffer öffnet direkt die Karte. Bewusst `LIKE` statt Volltextindex: die
|
||||||
Datenmengen sind klein, und ein `FULLTEXT`-Index hülfe bei Teilwortsuche
|
Datenmengen sind klein, und ein `FULLTEXT`-Index hülfe bei Teilwortsuche
|
||||||
(„informat" soll „Finanz Informatik" finden) nicht.
|
(„informat" soll „Finanz Informatik" finden) nicht.
|
||||||
|
|
||||||
**Funke – die erweiterte Suche.** Eine Frage in Worten, beantwortet vom Modell.
|
**Funke – die erweiterte Suche.** Eine Frage in Worten, beantwortet vom Modell.
|
||||||
Die genannten Datensätze kommen als anklickbare Chips zurück.
|
Die genannten Datensätze kommen als anklickbare Chips zurück.
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
sequenceDiagram
|
sequenceDiagram
|
||||||
actor B as Benutzer
|
actor B as Benutzer
|
||||||
participant E as Express
|
participant E as Express
|
||||||
participant M as MariaDB
|
participant M as MariaDB
|
||||||
participant O as OpenAI
|
participant O as OpenAI
|
||||||
|
|
||||||
B->>E: POST /api/search/ai { question }
|
B->>E: POST /api/search/ai { question }
|
||||||
E->>M: Bestand lesen (Kontakte, Unternehmen, Ausschreibungen, Projekte, Leads)
|
E->>M: Bestand lesen (Kontakte, Unternehmen, Ausschreibungen, Projekte, Leads)
|
||||||
E->>E: zusammenfassen, ab 400 Zeilen je Liste kuerzen
|
E->>E: zusammenfassen, ab 400 Zeilen je Liste kuerzen
|
||||||
E->>O: Systemprompt + Bestand + Frage
|
E->>O: Systemprompt + Bestand + Frage
|
||||||
O-->>E: { answer, refs }
|
O-->>E: { answer, refs }
|
||||||
E-->>B: Antwort + anklickbare Chips
|
E-->>B: Antwort + anklickbare Chips
|
||||||
```
|
```
|
||||||
|
|
||||||
Sie läuft **nicht** beim Tippen mit, sondern erst auf Enter – jede Anfrage
|
Sie läuft **nicht** beim Tippen mit, sondern erst auf Enter – jede Anfrage
|
||||||
kostet. Und sie erzeugt **kein SQL**: der Bestand wird mitgegeben. Ein Modell,
|
kostet. Und sie erzeugt **kein SQL**: der Bestand wird mitgegeben. Ein Modell,
|
||||||
das Abfragen auf die Datenbank schreiben darf, wäre eine Angriffsfläche; der
|
das Abfragen auf die Datenbank schreiben darf, wäre eine Angriffsfläche; der
|
||||||
Bestand ist klein genug, um ihn vollständig mitzuliefern. Wird gekürzt, sagt die
|
Bestand ist klein genug, um ihn vollständig mitzuliefern. Wird gekürzt, sagt die
|
||||||
Antwort das dazu.
|
Antwort das dazu.
|
||||||
|
|
||||||
### Was im Bestand steht
|
### Was im Bestand steht
|
||||||
|
|
||||||
Je Kontakt: Name, Position, Art, Status, E-Mail, Telefon, Quelle, Schlagworte,
|
Je Kontakt: Name, Position, Art, Status, E-Mail, Telefon, Quelle, Schlagworte,
|
||||||
freiberuflich, Kontaktstand, **`rate_onsite` und `rate_remote`**, LinkedIn,
|
freiberuflich, Kontaktstand, **`rate_onsite` und `rate_remote`**, LinkedIn,
|
||||||
Unternehmen und dessen Branche.
|
Unternehmen und dessen Branche.
|
||||||
|
|
||||||
Zwei Regeln stehen ausdrücklich im Systemprompt, weil die Antworten sonst falsch
|
Zwei Regeln stehen ausdrücklich im Systemprompt, weil die Antworten sonst falsch
|
||||||
werden:
|
werden:
|
||||||
|
|
||||||
* Ein fehlender Satz ist **nicht** null Euro. Verglichen wird nur, wo ein Wert
|
* Ein fehlender Satz ist **nicht** null Euro. Verglichen wird nur, wo ein Wert
|
||||||
steht; die Antwort sagt dazu, bei wie vielen keiner hinterlegt ist.
|
steht; die Antwort sagt dazu, bei wie vielen keiner hinterlegt ist.
|
||||||
* Es sind **Stundensätze**, keine Tagessätze. Fragt jemand nach dem Tagessatz,
|
* Es sind **Stundensätze**, keine Tagessätze. Fragt jemand nach dem Tagessatz,
|
||||||
antwortet die Suche mit dem Stundensatz und sagt das dazu.
|
antwortet die Suche mit dem Stundensatz und sagt das dazu.
|
||||||
|
|
||||||
Beispiel:
|
Beispiel:
|
||||||
|
|
||||||
> **Frage:** Wer hat den höchsten Tagessatz?
|
> **Frage:** Wer hat den höchsten Tagessatz?
|
||||||
> **Antwort:** Oliver Schaible hat mit 165,00 € pro Stunde den höchsten
|
> **Antwort:** Oliver Schaible hat mit 165,00 € pro Stunde den höchsten
|
||||||
> hinterlegten Satz vor Ort. Im CRM sind Stundensätze, keine Tagessätze,
|
> hinterlegten Satz vor Ort. Im CRM sind Stundensätze, keine Tagessätze,
|
||||||
> gepflegt. Bei 8 Kontakten ist kein Satz hinterlegt.
|
> gepflegt. Bei 8 Kontakten ist kein Satz hinterlegt.
|
||||||
|
|
||||||
Die Kontakte des perinco CRM stehen dem Bestand ebenfalls zur Verfügung, solange
|
Die Kontakte des perinco CRM stehen dem Bestand ebenfalls zur Verfügung, solange
|
||||||
die Schnittstelle eingerichtet ist – gelesen über denselben Zwischenspeicher wie
|
die Schnittstelle eingerichtet ist – gelesen über denselben Zwischenspeicher wie
|
||||||
die Ansicht (`fetchPerincoContacts()`), und ausdrücklich als fremd gekennzeichnet.
|
die Ansicht (`fetchPerincoContacts()`), und ausdrücklich als fremd gekennzeichnet.
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ Zuordnung in `navigation.open()` und der Filter in der Historie.
|
|||||||
|
|
||||||
## Bekannte Fallen
|
## Bekannte Fallen
|
||||||
|
|
||||||
**`gpt-5.6-luna` und `temperature`.** Jeder eigene Wert wird mit HTTP 400
|
**Luna-Modelle und `temperature`.** Jeder eigene Wert wird mit HTTP 400
|
||||||
abgewiesen. `ai.js` schickt keinen.
|
abgewiesen. `ai.js` schickt keinen.
|
||||||
|
|
||||||
**Instabile Arrays in der Netzwerkansicht.** Ein `= []` als Vorgabewert erzeugt
|
**Instabile Arrays in der Netzwerkansicht.** Ein `= []` als Vorgabewert erzeugt
|
||||||
|
|||||||
+1
-1
@@ -40,7 +40,7 @@ flowchart TB
|
|||||||
DB[("MariaDB<br/>icon_CRM")]
|
DB[("MariaDB<br/>icon_CRM")]
|
||||||
end
|
end
|
||||||
|
|
||||||
OAI["OpenAI<br/>gpt-5.6-luna"]
|
OAI["OpenAI<br/>gpt-6-luna"]
|
||||||
PCRM["perinco CRM<br/>pcrm.perinco.de"]
|
PCRM["perinco CRM<br/>pcrm.perinco.de"]
|
||||||
HF["Hugging-Face-CDN"]
|
HF["Hugging-Face-CDN"]
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -36,10 +36,10 @@ UPLOAD_DIR=/var/www/icon_CRM/uploads
|
|||||||
# Der Schluessel bleibt auf dem Server - der Browser spricht nie direkt mit
|
# Der Schluessel bleibt auf dem Server - der Browser spricht nie direkt mit
|
||||||
# OpenAI. Fehlt er, laeuft das CRM normal weiter und blendet die KI-Knoepfe aus.
|
# OpenAI. Fehlt er, laeuft das CRM normal weiter und blendet die KI-Knoepfe aus.
|
||||||
#
|
#
|
||||||
# Hinweis zum Modell: gpt-5.6-luna akzeptiert KEIN eigenes "temperature";
|
# Hinweis zum Modell: die Luna-Modelle akzeptieren KEIN eigenes "temperature";
|
||||||
# ai.js schickt deshalb nur den Standardwert.
|
# ai.js schickt deshalb nur den Standardwert.
|
||||||
OPENAI_API_KEY=
|
OPENAI_API_KEY=
|
||||||
OPENAI_MODEL=gpt-5.6-luna
|
OPENAI_MODEL=gpt-6-luna
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Lesender Zugriff auf die Kontakte des perinco CRM
|
# Lesender Zugriff auf die Kontakte des perinco CRM
|
||||||
|
|||||||
+238
-238
@@ -1,238 +1,238 @@
|
|||||||
import { getSetting, setSetting } from './db.js';
|
import { getSetting, setSetting } from './db.js';
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Anbindung an OpenAI.
|
* Anbindung an OpenAI.
|
||||||
*
|
*
|
||||||
* Bewusst ohne SDK: gebraucht werden genau zwei Aufrufe (Text und JSON), und
|
* Bewusst ohne SDK: gebraucht werden genau zwei Aufrufe (Text und JSON), und
|
||||||
* `fetch` ist in Node 22 eingebaut. Ein Paket weniger, das gepflegt werden muss.
|
* `fetch` ist in Node 22 eingebaut. Ein Paket weniger, das gepflegt werden muss.
|
||||||
*
|
*
|
||||||
* Der Schluessel verlaesst den Server nie in Richtung Browser - jede KI-Anfrage
|
* Der Schluessel verlaesst den Server nie in Richtung Browser - jede KI-Anfrage
|
||||||
* laeuft ueber das Backend, und nach aussen gibt es ihn nur verkuerzt
|
* laeuft ueber das Backend, und nach aussen gibt es ihn nur verkuerzt
|
||||||
* ("sk-proj-...a1b2").
|
* ("sk-proj-...a1b2").
|
||||||
*
|
*
|
||||||
* Er kann an zwei Stellen stehen:
|
* Er kann an zwei Stellen stehen:
|
||||||
*
|
*
|
||||||
* 1. in der Datenbank (icon_settings), gepflegt unter Einstellungen -> KI
|
* 1. in der Datenbank (icon_settings), gepflegt unter Einstellungen -> KI
|
||||||
* 2. in server/.env als OPENAI_API_KEY
|
* 2. in server/.env als OPENAI_API_KEY
|
||||||
*
|
*
|
||||||
* Die Datenbank gewinnt. Grund: ein Schluessel laeuft ab oder wird gewechselt,
|
* Die Datenbank gewinnt. Grund: ein Schluessel laeuft ab oder wird gewechselt,
|
||||||
* und dann soll niemand dafuer eine Datei auf dem Server bearbeiten und einen
|
* und dann soll niemand dafuer eine Datei auf dem Server bearbeiten und einen
|
||||||
* Dienst neu starten muessen. Die .env bleibt als Startwert und als Weg, das
|
* Dienst neu starten muessen. Die .env bleibt als Startwert und als Weg, das
|
||||||
* System ohne Oberflaeche in Gang zu bringen.
|
* System ohne Oberflaeche in Gang zu bringen.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const ENDPOINT = 'https://api.openai.com/v1/chat/completions';
|
const ENDPOINT = 'https://api.openai.com/v1/chat/completions';
|
||||||
|
|
||||||
/** Nach dieser Zeit wird abgebrochen - die Oberflaeche soll nicht haengen. */
|
/** Nach dieser Zeit wird abgebrochen - die Oberflaeche soll nicht haengen. */
|
||||||
const TIMEOUT_MS = 120_000;
|
const TIMEOUT_MS = 120_000;
|
||||||
|
|
||||||
export const DEFAULT_MODEL = 'gpt-5.6-luna';
|
export const DEFAULT_MODEL = 'gpt-6-luna';
|
||||||
|
|
||||||
const KEY_SETTING = 'openai_api_key';
|
const KEY_SETTING = 'openai_api_key';
|
||||||
const MODEL_SETTING = 'openai_model';
|
const MODEL_SETTING = 'openai_model';
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Im Speicher gehalten, damit nicht jede Anfrage die Datenbank fragt. Gefuellt
|
* Im Speicher gehalten, damit nicht jede Anfrage die Datenbank fragt. Gefuellt
|
||||||
* beim Start (loadAiConfig) und bei jeder Aenderung.
|
* beim Start (loadAiConfig) und bei jeder Aenderung.
|
||||||
*/
|
*/
|
||||||
const config = {
|
const config = {
|
||||||
key: process.env.OPENAI_API_KEY || '',
|
key: process.env.OPENAI_API_KEY || '',
|
||||||
model: process.env.OPENAI_MODEL || DEFAULT_MODEL,
|
model: process.env.OPENAI_MODEL || DEFAULT_MODEL,
|
||||||
/** Woher der gueltige Schluessel stammt - fuer die Anzeige. */
|
/** Woher der gueltige Schluessel stammt - fuer die Anzeige. */
|
||||||
source: process.env.OPENAI_API_KEY ? 'env' : 'none',
|
source: process.env.OPENAI_API_KEY ? 'env' : 'none',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Beim Start aufrufen, sobald die Datenbank steht. */
|
/** Beim Start aufrufen, sobald die Datenbank steht. */
|
||||||
export async function loadAiConfig() {
|
export async function loadAiConfig() {
|
||||||
try {
|
try {
|
||||||
const [key, model] = await Promise.all([getSetting(KEY_SETTING), getSetting(MODEL_SETTING)]);
|
const [key, model] = await Promise.all([getSetting(KEY_SETTING), getSetting(MODEL_SETTING)]);
|
||||||
if (key) {
|
if (key) {
|
||||||
config.key = key;
|
config.key = key;
|
||||||
config.source = 'db';
|
config.source = 'db';
|
||||||
}
|
}
|
||||||
if (model) config.model = model;
|
if (model) config.model = model;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Ohne Datenbank bleibt es bei der .env - das Backend soll trotzdem starten.
|
// Ohne Datenbank bleibt es bei der .env - das Backend soll trotzdem starten.
|
||||||
console.warn('[ICON CRM] KI-Einstellungen nicht lesbar:', err.message);
|
console.warn('[ICON CRM] KI-Einstellungen nicht lesbar:', err.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function aiConfigured() {
|
export function aiConfigured() {
|
||||||
return Boolean(config.key);
|
return Boolean(config.key);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function aiModel() {
|
export function aiModel() {
|
||||||
return config.model;
|
return config.model;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Zustand fuer die Oberflaeche. Der Schluessel wird nur verkuerzt gezeigt -
|
* Zustand fuer die Oberflaeche. Der Schluessel wird nur verkuerzt gezeigt -
|
||||||
* genug, um zwei Schluessel auseinanderzuhalten, zu wenig, um ihn zu benutzen.
|
* genug, um zwei Schluessel auseinanderzuhalten, zu wenig, um ihn zu benutzen.
|
||||||
*/
|
*/
|
||||||
export function aiStatus() {
|
export function aiStatus() {
|
||||||
return {
|
return {
|
||||||
configured: aiConfigured(),
|
configured: aiConfigured(),
|
||||||
model: config.model,
|
model: config.model,
|
||||||
defaultModel: DEFAULT_MODEL,
|
defaultModel: DEFAULT_MODEL,
|
||||||
source: config.source,
|
source: config.source,
|
||||||
masked: maskKey(config.key),
|
masked: maskKey(config.key),
|
||||||
/** Steht in der .env noch einer? Dann greift er, wenn der aus der DB faellt. */
|
/** Steht in der .env noch einer? Dann greift er, wenn der aus der DB faellt. */
|
||||||
envAvailable: Boolean(process.env.OPENAI_API_KEY),
|
envAvailable: Boolean(process.env.OPENAI_API_KEY),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function maskKey(key) {
|
function maskKey(key) {
|
||||||
if (!key) return null;
|
if (!key) return null;
|
||||||
return key.length <= 12 ? '****' : `${key.slice(0, 7)}…${key.slice(-4)}`;
|
return key.length <= 12 ? '****' : `${key.slice(0, 7)}…${key.slice(-4)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Schluessel setzen oder entfernen.
|
* Schluessel setzen oder entfernen.
|
||||||
*
|
*
|
||||||
* Entfernen heisst: der Wert in der Datenbank faellt weg. Steht in der .env
|
* Entfernen heisst: der Wert in der Datenbank faellt weg. Steht in der .env
|
||||||
* einer, gilt wieder dieser - das ist kein Versehen, sondern der Rueckfall.
|
* einer, gilt wieder dieser - das ist kein Versehen, sondern der Rueckfall.
|
||||||
*/
|
*/
|
||||||
export async function saveAiKey(key) {
|
export async function saveAiKey(key) {
|
||||||
const value = String(key ?? '').trim();
|
const value = String(key ?? '').trim();
|
||||||
await setSetting(KEY_SETTING, value || null);
|
await setSetting(KEY_SETTING, value || null);
|
||||||
if (value) {
|
if (value) {
|
||||||
config.key = value;
|
config.key = value;
|
||||||
config.source = 'db';
|
config.source = 'db';
|
||||||
} else {
|
} else {
|
||||||
config.key = process.env.OPENAI_API_KEY || '';
|
config.key = process.env.OPENAI_API_KEY || '';
|
||||||
config.source = config.key ? 'env' : 'none';
|
config.source = config.key ? 'env' : 'none';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Modell setzen; leer heisst zurueck zur Vorgabe. */
|
/** Modell setzen; leer heisst zurueck zur Vorgabe. */
|
||||||
export async function saveAiModel(model) {
|
export async function saveAiModel(model) {
|
||||||
const value = String(model ?? '').trim();
|
const value = String(model ?? '').trim();
|
||||||
await setSetting(MODEL_SETTING, value || null);
|
await setSetting(MODEL_SETTING, value || null);
|
||||||
config.model = value || process.env.OPENAI_MODEL || DEFAULT_MODEL;
|
config.model = value || process.env.OPENAI_MODEL || DEFAULT_MODEL;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Die Modelle, die der hinterlegte Schluessel nutzen darf.
|
* Die Modelle, die der hinterlegte Schluessel nutzen darf.
|
||||||
*
|
*
|
||||||
* /v1/models liefert alles - auch Bild-, Audio-, Embedding- und Moderations-
|
* /v1/models liefert alles - auch Bild-, Audio-, Embedding- und Moderations-
|
||||||
* modelle. Fuer das CRM zaehlen nur Chat-Modelle, also werden die anderen
|
* modelle. Fuer das CRM zaehlen nur Chat-Modelle, also werden die anderen
|
||||||
* ausgefiltert. Kurz im Speicher gehalten, damit das Aufklappen der Liste nicht
|
* ausgefiltert. Kurz im Speicher gehalten, damit das Aufklappen der Liste nicht
|
||||||
* jedes Mal OpenAI fragt; ein Schluesselwechsel leert den Speicher.
|
* jedes Mal OpenAI fragt; ein Schluesselwechsel leert den Speicher.
|
||||||
*/
|
*/
|
||||||
const MODELS_ENDPOINT = 'https://api.openai.com/v1/models';
|
const MODELS_ENDPOINT = 'https://api.openai.com/v1/models';
|
||||||
const MODELS_TTL_MS = 10 * 60_000;
|
const MODELS_TTL_MS = 10 * 60_000;
|
||||||
const NON_CHAT = /(embedding|whisper|tts|dall-e|image|audio|realtime|transcribe|moderation|search|instruct|davinci|babbage|computer-use|codex|sora)/i;
|
const NON_CHAT = /(embedding|whisper|tts|dall-e|image|audio|realtime|transcribe|moderation|search|instruct|davinci|babbage|computer-use|codex|sora)/i;
|
||||||
let modelCache = { key: '', at: 0, list: [] };
|
let modelCache = { key: '', at: 0, list: [] };
|
||||||
|
|
||||||
export async function listAiModels() {
|
export async function listAiModels() {
|
||||||
if (!aiConfigured()) throw new AiError('Es ist kein Schlüssel hinterlegt.', 503);
|
if (!aiConfigured()) throw new AiError('Es ist kein Schlüssel hinterlegt.', 503);
|
||||||
if (modelCache.key === config.key && Date.now() - modelCache.at < MODELS_TTL_MS) {
|
if (modelCache.key === config.key && Date.now() - modelCache.at < MODELS_TTL_MS) {
|
||||||
return modelCache.list;
|
return modelCache.list;
|
||||||
}
|
}
|
||||||
|
|
||||||
let response;
|
let response;
|
||||||
try {
|
try {
|
||||||
response = await fetch(MODELS_ENDPOINT, {
|
response = await fetch(MODELS_ENDPOINT, {
|
||||||
headers: { Authorization: `Bearer ${config.key}` },
|
headers: { Authorization: `Bearer ${config.key}` },
|
||||||
signal: AbortSignal.timeout(15_000),
|
signal: AbortSignal.timeout(15_000),
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
throw new AiError('OpenAI ist gerade nicht erreichbar.', 502);
|
throw new AiError('OpenAI ist gerade nicht erreichbar.', 502);
|
||||||
}
|
}
|
||||||
const payload = await response.json().catch(() => null);
|
const payload = await response.json().catch(() => null);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new AiError(payload?.error?.message || `OpenAI antwortete mit HTTP ${response.status}.`, 502);
|
throw new AiError(payload?.error?.message || `OpenAI antwortete mit HTTP ${response.status}.`, 502);
|
||||||
}
|
}
|
||||||
|
|
||||||
const list = (payload?.data ?? [])
|
const list = (payload?.data ?? [])
|
||||||
.map((m) => ({ id: String(m.id), created: Number(m.created) || 0 }))
|
.map((m) => ({ id: String(m.id), created: Number(m.created) || 0 }))
|
||||||
.filter((m) => /^(gpt-|o\d|chatgpt-)/i.test(m.id) && !NON_CHAT.test(m.id))
|
.filter((m) => /^(gpt-|o\d|chatgpt-)/i.test(m.id) && !NON_CHAT.test(m.id))
|
||||||
// Neueste zuerst - die will man meistens.
|
// Neueste zuerst - die will man meistens.
|
||||||
.sort((a, b) => b.created - a.created || a.id.localeCompare(b.id));
|
.sort((a, b) => b.created - a.created || a.id.localeCompare(b.id));
|
||||||
|
|
||||||
modelCache = { key: config.key, at: Date.now(), list };
|
modelCache = { key: config.key, at: Date.now(), list };
|
||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
|
|
||||||
class AiError extends Error {
|
class AiError extends Error {
|
||||||
constructor(message, status = 502) {
|
constructor(message, status = 502) {
|
||||||
super(message);
|
super(message);
|
||||||
this.status = status;
|
this.status = status;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ein Aufruf an das Modell.
|
* Ein Aufruf an das Modell.
|
||||||
*
|
*
|
||||||
* `temperature` wird bewusst NICHT mitgeschickt: gpt-5.6-luna laesst nur den
|
* `temperature` wird bewusst NICHT mitgeschickt: die Luna-Modelle lassen nur den
|
||||||
* Standardwert zu und weist jeden anderen mit 400 ab.
|
* Standardwert zu und weisen jeden anderen mit 400 ab.
|
||||||
*
|
*
|
||||||
* @param {object} options
|
* @param {object} options
|
||||||
* @param {string} options.system Rollenanweisung
|
* @param {string} options.system Rollenanweisung
|
||||||
* @param {string} options.user Eigentliche Anfrage
|
* @param {string} options.user Eigentliche Anfrage
|
||||||
* @param {boolean} [options.json] true erzwingt eine JSON-Antwort
|
* @param {boolean} [options.json] true erzwingt eine JSON-Antwort
|
||||||
* @param {number} [options.maxTokens]
|
* @param {number} [options.maxTokens]
|
||||||
* @returns {Promise<string>} der Antworttext
|
* @returns {Promise<string>} der Antworttext
|
||||||
*/
|
*/
|
||||||
export async function askModel({ system, user, json = false, maxTokens }) {
|
export async function askModel({ system, user, json = false, maxTokens }) {
|
||||||
if (!aiConfigured()) {
|
if (!aiConfigured()) {
|
||||||
throw new AiError(
|
throw new AiError(
|
||||||
'Es ist kein OpenAI-Schlüssel hinterlegt (Einstellungen → KI, oder OPENAI_API_KEY in server/.env).',
|
'Es ist kein OpenAI-Schlüssel hinterlegt (Einstellungen → KI, oder OPENAI_API_KEY in server/.env).',
|
||||||
503
|
503
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = {
|
const body = {
|
||||||
model: config.model,
|
model: config.model,
|
||||||
messages: [
|
messages: [
|
||||||
...(system ? [{ role: 'system', content: system }] : []),
|
...(system ? [{ role: 'system', content: system }] : []),
|
||||||
{ role: 'user', content: user },
|
{ role: 'user', content: user },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
if (json) body.response_format = { type: 'json_object' };
|
if (json) body.response_format = { type: 'json_object' };
|
||||||
if (maxTokens) body.max_completion_tokens = maxTokens;
|
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(ENDPOINT, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${config.key}`,
|
Authorization: `Bearer ${config.key}`,
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err?.name === 'AbortError') throw new AiError('Die KI hat nicht rechtzeitig geantwortet.', 504);
|
if (err?.name === 'AbortError') throw new AiError('Die KI hat nicht rechtzeitig geantwortet.', 504);
|
||||||
throw new AiError('OpenAI ist gerade nicht erreichbar.', 502);
|
throw new AiError('OpenAI ist gerade nicht erreichbar.', 502);
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = await response.json().catch(() => null);
|
const payload = await response.json().catch(() => null);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
// Die Meldung von OpenAI ist meist praezise ("model not found", "quota") -
|
// Die Meldung von OpenAI ist meist praezise ("model not found", "quota") -
|
||||||
// sie hilft mehr als ein eigener Text, enthaelt aber nie den Schluessel.
|
// sie hilft mehr als ein eigener Text, enthaelt aber nie den Schluessel.
|
||||||
throw new AiError(payload?.error?.message || `OpenAI antwortete mit HTTP ${response.status}.`, 502);
|
throw new AiError(payload?.error?.message || `OpenAI antwortete mit HTTP ${response.status}.`, 502);
|
||||||
}
|
}
|
||||||
|
|
||||||
const text = payload?.choices?.[0]?.message?.content;
|
const text = payload?.choices?.[0]?.message?.content;
|
||||||
if (!text) throw new AiError('Die KI hat keine Antwort geliefert.', 502);
|
if (!text) throw new AiError('Die KI hat keine Antwort geliefert.', 502);
|
||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Wie askModel, gibt aber das geparste JSON zurueck. */
|
/** Wie askModel, gibt aber das geparste JSON zurueck. */
|
||||||
export async function askModelJson(options) {
|
export async function askModelJson(options) {
|
||||||
const text = await askModel({ ...options, json: true });
|
const text = await askModel({ ...options, json: true });
|
||||||
try {
|
try {
|
||||||
return JSON.parse(text);
|
return JSON.parse(text);
|
||||||
} catch {
|
} catch {
|
||||||
throw new AiError('Die KI hat kein gültiges JSON geliefert.', 502);
|
throw new AiError('Die KI hat kein gültiges JSON geliefert.', 502);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export { AiError };
|
export { AiError };
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ export const AiSettings: React.FC = () => {
|
|||||||
hint={
|
hint={
|
||||||
models
|
models
|
||||||
? `${models.length} Modelle für diesen Schlüssel verfügbar. Vorgabe ist ${config.defaultModel}.`
|
? `${models.length} Modelle für diesen Schlüssel verfügbar. Vorgabe ist ${config.defaultModel}.`
|
||||||
: `Vorgabe ist ${config.defaultModel}. Achtung: gpt-5.6-luna erlaubt keinen eigenen „temperature"-Wert – das Backend schickt deshalb keinen.`
|
: `Vorgabe ist ${config.defaultModel}. Achtung: die Luna-Modelle erlauben keinen eigenen „temperature"-Wert – das Backend schickt deshalb keinen.`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{models ? (
|
{models ? (
|
||||||
|
|||||||
Reference in New Issue
Block a user