From 6fe5b18c118ce03cecc8a233f7b7a3d11749e627 Mon Sep 17 00:00:00 2001 From: schakal00783 Date: Tue, 29 Sep 2026 10:10:41 +0200 Subject: [PATCH] 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 --- docs/05-ki.md | 562 ++++++++++++------------- docs/09-anleitung-entwickler.md | 2 +- docs/README.md | 2 +- server/.env.example | 4 +- server/src/ai.js | 476 ++++++++++----------- src/components/settings/AiSettings.tsx | 2 +- 6 files changed, 524 insertions(+), 524 deletions(-) diff --git a/docs/05-ki.md b/docs/05-ki.md index 554dc26..64b9ecd 100644 --- a/docs/05-ki.md +++ b/docs/05-ki.md @@ -1,281 +1,281 @@ -# 05 KI - -Modell: **`gpt-5.6-luna`** (OpenAI), angesprochen über `chat/completions`. Der -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* -gepflegt und liegen in `icon_settings`; `server/.env` (chmod 600) bleibt -Startwert und Rückfall. - -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. - -## Schlüssel pflegen - -*Einstellungen → KI* (nur Administratoren). Der Schlüssel steht in -`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, -und dann soll niemand dafür eine Datei auf dem Server bearbeiten und einen -Dienst neu starten müssen. - -| Was | Verhalten | -| --- | --- | -| 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 | -| 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` | -| 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 -Wert. Ein stiller Wechsel wäre später nicht mehr nachvollziehbar. - -### Modellauswahl - -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 -hinterlegten Schlüssel (`listAiModels()` in `ai.js`) – angezeigt wird also genau -das, was dieser Schlüssel darf. - -```mermaid -sequenceDiagram - participant B as Browser (AiSettings) - participant S as Backend /api/ai/models - participant O as OpenAI /v1/models - B->>S: GET (admin) - alt Liste im Speicher und < 10 min alt, gleicher Schlüssel - S-->>B: { models } - else - S->>O: GET mit Bearer-Schlüssel - O-->>S: alle Modelle - S->>S: nur Chat-Modelle, neueste zuerst - S-->>B: { models } - end -``` - -| Regel | Umsetzung | -| --- | --- | -| Nur Chat-Modelle | ID beginnt mit `gpt-`, `o` oder `chatgpt-`; raus fallen Embedding, Whisper, TTS, Bild, Audio, Realtime, Transcribe, Moderation, Search, Instruct, Codex, Computer-Use, Sora | -| Reihenfolge | nach `created` absteigend – neueste oben | -| 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" | -| Vorgabemodell | markiert „(Vorgabe)" | -| 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 | - -> **Fallstrick:** `gpt-5.6-luna` akzeptiert **kein eigenes `temperature`** und -> weist jeden Wert außer dem Standard mit HTTP 400 ab. `ai.js` schickt deshalb -> keines. `response_format: {type:'json_object'}` funktioniert dagegen. - -Drei Stellen nutzen das Modell: - -```mermaid -flowchart LR - CV["CV-Import
Lebenslauf → Kontaktvorschlag"] --> AI["server/src/ai.js"] - TX["Schreibhilfe
Ausschreibungstext"] --> AI - SU["Erweiterte Suche
Frage in Worten"] --> AI - AI -->|"HTTPS, Schluessel bleibt am Server"| OAI["api.openai.com"] -``` - -## CV-Import - -*Kontakte → CV-Import.* Lebensläufe als PDF, DOCX oder Text, per Dateiauswahl -oder Ziehen, auch mehrere auf einmal (bis 20, je 15 MB). - -```mermaid -flowchart LR - U["Hochladen"] --> E["Eingang"] - E -->|"Mensch: Ja, auslesen"| S["Screening"] - E -->|"Mensch: Nein"| V["Verworfen"] - S -->|"Formular bestaetigt"| A["Auswahl
Kontakt angelegt"] - S -->|"abgelehnt"| V -``` - -| Ordner | Bedeutung | -| --- | --- | -| Eingang | hochgeladen; ein Mensch entscheidet je Datei „Ja, auslesen" oder „Nein" | -| Screening | ausgelesen; der Vorschlag steht im Formular und wird bestätigt | -| Auswahl | übernommen – Kontakt angelegt, Lebenslauf hängt daran | - -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 -korrigiert wird, gewinnt gegen die KI. - -Übernommen werden Name, Position, Kontaktdaten, LinkedIn, aktueller Arbeitgeber, -Schlagworte, eine Zusammenfassung und – nur wenn sie ausdrücklich im Text stehen -– die **Stundensätze**. Steht im Lebenslauf ein Tagessatz, wird er *nicht* -umgerechnet, sondern weggelassen. - -Frühere Stationen werden als **beendete** Anstellungen angelegt; dadurch -erscheint der Kontakt beim jeweiligen Unternehmen sofort im Zweig „Verlassen". -Unbekannte Unternehmen legt der Import mit an. - -### Der Werdegang bekommt einen eigenen Durchgang - -Der große Prompt muss Name, Kontaktdaten, Schlagworte, Zusammenfassung und Sätze -zugleich liefern – die Stationen kamen dabei **unzuverlässig** heraus: derselbe -Lebenslauf ergab einmal drei, einmal zwei, ein Beraterprofil mit fünf Kunden nur -einen. Deshalb läuft ein **zweiter Aufruf mit einer einzigen Aufgabe** -(`STATIONS_PROMPT` in `server/src/routes/cvs.js`), und beide Listen werden -zusammengeführt. - -Was dieser Durchgang ausdrücklich verlangt: - -* Vollständigkeit vor Kürze – auch kurze und nebenberufliche Stationen. -* Alle Abschnitte, die Stationen tragen können: *Berufserfahrung*, *Beruflicher - Werdegang*, *Projekte*, *Projekterfahrung*, *Referenzen*, *Kunden*. -* Bei freiberuflicher Tätigkeit ist der **Kunde** das Unternehmen, nicht die - eigene Firma. -* Derselbe Kunde zu verschiedenen Zeiten gehört mehrfach in die Liste. -* Schule, Studium, Zertifikate gehören **nicht** hinein. -* Eine fehlende Zeitangabe ist kein Grund, eine Station wegzulassen. -* 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 - zugeordnet werden. - -Zusammengeführt wird über Firmenname und Anfangsjahr, wobei der Name vorher auf -das Vergleichbare heruntergebrochen wird (Rechtsformen wie GmbH, AG, SE fliegen -raus). So erscheint „Bayerische Versorgungskammer" nicht zweimal, weil ein -Durchgang sie mit Klammerzusatz gelesen hat. - -Die Obergrenze beim Übernehmen liegt bei **60** Stationen statt bei 20: -Beraterprofile mit Projektliste kommen leicht über zwanzig Kunden, und alles -darüber verschwand vorher stillschweigend. - -> Gemessen an den vorhandenen Lebensläufen: 1 → 5 Stationen, 3 → 3, und ein -> reines Projektportfolio ohne einen einzigen Arbeitgeber bleibt bei 0 – dort -> stehen nur Produktnamen im Fließtext, und daraus Stationen zu erfinden wäre -> schlechter als keine. - -### Die laufende Station - -Der aktuelle Arbeitgeber wird beim Übernehmen als laufende Station angelegt. -Zwei Fehler hingen daran, und beide sahen in der Karte nach einem Lesefehler -aus: - -* Sie begann am **Tag des Imports**. Im Lebenslauf stand „seit 03/2023", in der - Karte das heutige Datum. -* Und sie stand **zweimal** da: einmal als laufende Station ohne Datum, einmal - als frühere mit Datum. - -Beides kam daher, dass die Liste der Stationen und der Arbeitgeber getrennt -verarbeitet wurden. Jetzt wird zuerst gesucht, welche Station den Arbeitgeber -beschreibt – über den Firmennamen ohne Rechtsform und daran, dass sie laufend -ist (als aktuell gekennzeichnet oder ohne Enddatum). Ihr Startdatum eröffnet die -laufende Station, und sie wird nicht noch einmal als frühere angelegt. - -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 -den Werdegang. - -Der Text wird vor der Anfrage lokal aus der Datei geholt -(`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; -der Download läuft über `fetch` mit Bearer-Kopf, ein normaler Link schickt ihn -nicht mit. - -### Das Bewerbungsfoto - -Trägt der Lebenslauf ein Portrait, wird es beim Auslesen mit herausgeholt und im -Screening-Formular oben angezeigt – mit Haken, standardmäßig gesetzt. Beim -Übernehmen landet es als Foto am Kontakt. - -Gefunden wird es **ohne zusätzliches Paket** (`server/src/cvPhoto.js`): - -| Format | Weg | -| --- | --- | -| 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 | - -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 -auf dem Server. - -**Welches Bild das Portrait ist**, entscheidet die Form – die Reihenfolge in der -Datei hilft nicht, in DOCX steht das Firmenlogo oft vor dem Foto: - -* kleiner als 100 × 100 px → Symbol, Trennlinie, Aufzählungszeichen -* Seitenverhältnis außerhalb 0,4–1,6 → Kopfzeile, Banner, Diagramm -* vom Rest gewinnt **Hochformat** vor Querformat, dann das größere - -Verkleinert wird im Browser, über denselben Weg wie jedes andere Foto -(`src/lib/imageCompress.ts`): mittiger Ausschnitt, 400 px im Quadrat, WebP – und -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 -ist die Zusicherung, nicht die Regel. - -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 -der Liste der Importe steht es **nicht**: sie trägt nur `has_photo`, das Bild -holt das Formular einzeln (`GET /api/cvs/:id/photo`). - -## Schreibhilfe für Ausschreibungen - -Das Feld *Ausschreibungstext* (`icon_tenders.notes`) ist ein WYSIWYG-Editor mit -fünf Knöpfen: *Text erzeugen*, *Verbessern*, *Kürzen*, *Stichpunkte*, -*Anforderungsprofil*. Darunter ein Feld für die Vorgabe („Interim PL SAP -S/4HANA, Bank, 12 Monate, 80 % remote"). - -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 -gibt keinen Speichern-Knopf. - -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)). - -## Erweiterte Suche - -Ein Feld, zwei Modi – umgeschaltet über die Symbole rechts im Feld: - -**Lupe – die normale Suche** (`Strg+K`). Über Kontakte, Unternehmen, -Ausschreibungen, Projekte, Leads und die Historie; läuft beim Tippen mit, ein -Treffer öffnet direkt die Karte. Bewusst `LIKE` statt Volltextindex: die -Datenmengen sind klein, und ein `FULLTEXT`-Index hülfe bei Teilwortsuche -(„informat" soll „Finanz Informatik" finden) nicht. - -**Funke – die erweiterte Suche.** Eine Frage in Worten, beantwortet vom Modell. -Die genannten Datensätze kommen als anklickbare Chips zurück. - -```mermaid -sequenceDiagram - actor B as Benutzer - participant E as Express - participant M as MariaDB - participant O as OpenAI - - B->>E: POST /api/search/ai { question } - E->>M: Bestand lesen (Kontakte, Unternehmen, Ausschreibungen, Projekte, Leads) - E->>E: zusammenfassen, ab 400 Zeilen je Liste kuerzen - E->>O: Systemprompt + Bestand + Frage - O-->>E: { answer, refs } - E-->>B: Antwort + anklickbare Chips -``` - -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, -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 -Antwort das dazu. - -### Was im Bestand steht - -Je Kontakt: Name, Position, Art, Status, E-Mail, Telefon, Quelle, Schlagworte, -freiberuflich, Kontaktstand, **`rate_onsite` und `rate_remote`**, LinkedIn, -Unternehmen und dessen Branche. - -Zwei Regeln stehen ausdrücklich im Systemprompt, weil die Antworten sonst falsch -werden: - -* Ein fehlender Satz ist **nicht** null Euro. Verglichen wird nur, wo ein Wert - steht; die Antwort sagt dazu, bei wie vielen keiner hinterlegt ist. -* Es sind **Stundensätze**, keine Tagessätze. Fragt jemand nach dem Tagessatz, - antwortet die Suche mit dem Stundensatz und sagt das dazu. - -Beispiel: - -> **Frage:** Wer hat den höchsten Tagessatz? -> **Antwort:** Oliver Schaible hat mit 165,00 € pro Stunde den höchsten -> hinterlegten Satz vor Ort. Im CRM sind Stundensätze, keine Tagessätze, -> gepflegt. Bei 8 Kontakten ist kein Satz hinterlegt. - -Die Kontakte des perinco CRM stehen dem Bestand ebenfalls zur Verfügung, solange -die Schnittstelle eingerichtet ist – gelesen über denselben Zwischenspeicher wie -die Ansicht (`fetchPerincoContacts()`), und ausdrücklich als fremd gekennzeichnet. +# 05 KI + +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 +(`server/src/ai.js`). Schlüssel und Modell werden unter *Einstellungen → KI* +gepflegt und liegen in `icon_settings`; `server/.env` (chmod 600) bleibt +Startwert und Rückfall. + +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. + +## Schlüssel pflegen + +*Einstellungen → KI* (nur Administratoren). Der Schlüssel steht in +`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, +und dann soll niemand dafür eine Datei auf dem Server bearbeiten und einen +Dienst neu starten müssen. + +| Was | Verhalten | +| --- | --- | +| 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 | +| 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-6-luna` | +| 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 +Wert. Ein stiller Wechsel wäre später nicht mehr nachvollziehbar. + +### Modellauswahl + +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 +hinterlegten Schlüssel (`listAiModels()` in `ai.js`) – angezeigt wird also genau +das, was dieser Schlüssel darf. + +```mermaid +sequenceDiagram + participant B as Browser (AiSettings) + participant S as Backend /api/ai/models + participant O as OpenAI /v1/models + B->>S: GET (admin) + alt Liste im Speicher und < 10 min alt, gleicher Schlüssel + S-->>B: { models } + else + S->>O: GET mit Bearer-Schlüssel + O-->>S: alle Modelle + S->>S: nur Chat-Modelle, neueste zuerst + S-->>B: { models } + end +``` + +| Regel | Umsetzung | +| --- | --- | +| Nur Chat-Modelle | ID beginnt mit `gpt-`, `o` oder `chatgpt-`; raus fallen Embedding, Whisper, TTS, Bild, Audio, Realtime, Transcribe, Moderation, Search, Instruct, Codex, Computer-Use, Sora | +| Reihenfolge | nach `created` absteigend – neueste oben | +| 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" | +| Vorgabemodell | markiert „(Vorgabe)" | +| 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 | + +> **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 +> keines. `response_format: {type:'json_object'}` funktioniert dagegen. + +Drei Stellen nutzen das Modell: + +```mermaid +flowchart LR + CV["CV-Import
Lebenslauf → Kontaktvorschlag"] --> AI["server/src/ai.js"] + TX["Schreibhilfe
Ausschreibungstext"] --> AI + SU["Erweiterte Suche
Frage in Worten"] --> AI + AI -->|"HTTPS, Schluessel bleibt am Server"| OAI["api.openai.com"] +``` + +## CV-Import + +*Kontakte → CV-Import.* Lebensläufe als PDF, DOCX oder Text, per Dateiauswahl +oder Ziehen, auch mehrere auf einmal (bis 20, je 15 MB). + +```mermaid +flowchart LR + U["Hochladen"] --> E["Eingang"] + E -->|"Mensch: Ja, auslesen"| S["Screening"] + E -->|"Mensch: Nein"| V["Verworfen"] + S -->|"Formular bestaetigt"| A["Auswahl
Kontakt angelegt"] + S -->|"abgelehnt"| V +``` + +| Ordner | Bedeutung | +| --- | --- | +| Eingang | hochgeladen; ein Mensch entscheidet je Datei „Ja, auslesen" oder „Nein" | +| Screening | ausgelesen; der Vorschlag steht im Formular und wird bestätigt | +| Auswahl | übernommen – Kontakt angelegt, Lebenslauf hängt daran | + +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 +korrigiert wird, gewinnt gegen die KI. + +Übernommen werden Name, Position, Kontaktdaten, LinkedIn, aktueller Arbeitgeber, +Schlagworte, eine Zusammenfassung und – nur wenn sie ausdrücklich im Text stehen +– die **Stundensätze**. Steht im Lebenslauf ein Tagessatz, wird er *nicht* +umgerechnet, sondern weggelassen. + +Frühere Stationen werden als **beendete** Anstellungen angelegt; dadurch +erscheint der Kontakt beim jeweiligen Unternehmen sofort im Zweig „Verlassen". +Unbekannte Unternehmen legt der Import mit an. + +### Der Werdegang bekommt einen eigenen Durchgang + +Der große Prompt muss Name, Kontaktdaten, Schlagworte, Zusammenfassung und Sätze +zugleich liefern – die Stationen kamen dabei **unzuverlässig** heraus: derselbe +Lebenslauf ergab einmal drei, einmal zwei, ein Beraterprofil mit fünf Kunden nur +einen. Deshalb läuft ein **zweiter Aufruf mit einer einzigen Aufgabe** +(`STATIONS_PROMPT` in `server/src/routes/cvs.js`), und beide Listen werden +zusammengeführt. + +Was dieser Durchgang ausdrücklich verlangt: + +* Vollständigkeit vor Kürze – auch kurze und nebenberufliche Stationen. +* Alle Abschnitte, die Stationen tragen können: *Berufserfahrung*, *Beruflicher + Werdegang*, *Projekte*, *Projekterfahrung*, *Referenzen*, *Kunden*. +* Bei freiberuflicher Tätigkeit ist der **Kunde** das Unternehmen, nicht die + eigene Firma. +* Derselbe Kunde zu verschiedenen Zeiten gehört mehrfach in die Liste. +* Schule, Studium, Zertifikate gehören **nicht** hinein. +* Eine fehlende Zeitangabe ist kein Grund, eine Station wegzulassen. +* 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 + zugeordnet werden. + +Zusammengeführt wird über Firmenname und Anfangsjahr, wobei der Name vorher auf +das Vergleichbare heruntergebrochen wird (Rechtsformen wie GmbH, AG, SE fliegen +raus). So erscheint „Bayerische Versorgungskammer" nicht zweimal, weil ein +Durchgang sie mit Klammerzusatz gelesen hat. + +Die Obergrenze beim Übernehmen liegt bei **60** Stationen statt bei 20: +Beraterprofile mit Projektliste kommen leicht über zwanzig Kunden, und alles +darüber verschwand vorher stillschweigend. + +> Gemessen an den vorhandenen Lebensläufen: 1 → 5 Stationen, 3 → 3, und ein +> reines Projektportfolio ohne einen einzigen Arbeitgeber bleibt bei 0 – dort +> stehen nur Produktnamen im Fließtext, und daraus Stationen zu erfinden wäre +> schlechter als keine. + +### Die laufende Station + +Der aktuelle Arbeitgeber wird beim Übernehmen als laufende Station angelegt. +Zwei Fehler hingen daran, und beide sahen in der Karte nach einem Lesefehler +aus: + +* Sie begann am **Tag des Imports**. Im Lebenslauf stand „seit 03/2023", in der + Karte das heutige Datum. +* Und sie stand **zweimal** da: einmal als laufende Station ohne Datum, einmal + als frühere mit Datum. + +Beides kam daher, dass die Liste der Stationen und der Arbeitgeber getrennt +verarbeitet wurden. Jetzt wird zuerst gesucht, welche Station den Arbeitgeber +beschreibt – über den Firmennamen ohne Rechtsform und daran, dass sie laufend +ist (als aktuell gekennzeichnet oder ohne Enddatum). Ihr Startdatum eröffnet die +laufende Station, und sie wird nicht noch einmal als frühere angelegt. + +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 +den Werdegang. + +Der Text wird vor der Anfrage lokal aus der Datei geholt +(`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; +der Download läuft über `fetch` mit Bearer-Kopf, ein normaler Link schickt ihn +nicht mit. + +### Das Bewerbungsfoto + +Trägt der Lebenslauf ein Portrait, wird es beim Auslesen mit herausgeholt und im +Screening-Formular oben angezeigt – mit Haken, standardmäßig gesetzt. Beim +Übernehmen landet es als Foto am Kontakt. + +Gefunden wird es **ohne zusätzliches Paket** (`server/src/cvPhoto.js`): + +| Format | Weg | +| --- | --- | +| 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 | + +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 +auf dem Server. + +**Welches Bild das Portrait ist**, entscheidet die Form – die Reihenfolge in der +Datei hilft nicht, in DOCX steht das Firmenlogo oft vor dem Foto: + +* kleiner als 100 × 100 px → Symbol, Trennlinie, Aufzählungszeichen +* Seitenverhältnis außerhalb 0,4–1,6 → Kopfzeile, Banner, Diagramm +* vom Rest gewinnt **Hochformat** vor Querformat, dann das größere + +Verkleinert wird im Browser, über denselben Weg wie jedes andere Foto +(`src/lib/imageCompress.ts`): mittiger Ausschnitt, 400 px im Quadrat, WebP – und +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 +ist die Zusicherung, nicht die Regel. + +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 +der Liste der Importe steht es **nicht**: sie trägt nur `has_photo`, das Bild +holt das Formular einzeln (`GET /api/cvs/:id/photo`). + +## Schreibhilfe für Ausschreibungen + +Das Feld *Ausschreibungstext* (`icon_tenders.notes`) ist ein WYSIWYG-Editor mit +fünf Knöpfen: *Text erzeugen*, *Verbessern*, *Kürzen*, *Stichpunkte*, +*Anforderungsprofil*. Darunter ein Feld für die Vorgabe („Interim PL SAP +S/4HANA, Bank, 12 Monate, 80 % remote"). + +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 +gibt keinen Speichern-Knopf. + +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)). + +## Erweiterte Suche + +Ein Feld, zwei Modi – umgeschaltet über die Symbole rechts im Feld: + +**Lupe – die normale Suche** (`Strg+K`). Über Kontakte, Unternehmen, +Ausschreibungen, Projekte, Leads und die Historie; läuft beim Tippen mit, ein +Treffer öffnet direkt die Karte. Bewusst `LIKE` statt Volltextindex: die +Datenmengen sind klein, und ein `FULLTEXT`-Index hülfe bei Teilwortsuche +(„informat" soll „Finanz Informatik" finden) nicht. + +**Funke – die erweiterte Suche.** Eine Frage in Worten, beantwortet vom Modell. +Die genannten Datensätze kommen als anklickbare Chips zurück. + +```mermaid +sequenceDiagram + actor B as Benutzer + participant E as Express + participant M as MariaDB + participant O as OpenAI + + B->>E: POST /api/search/ai { question } + E->>M: Bestand lesen (Kontakte, Unternehmen, Ausschreibungen, Projekte, Leads) + E->>E: zusammenfassen, ab 400 Zeilen je Liste kuerzen + E->>O: Systemprompt + Bestand + Frage + O-->>E: { answer, refs } + E-->>B: Antwort + anklickbare Chips +``` + +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, +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 +Antwort das dazu. + +### Was im Bestand steht + +Je Kontakt: Name, Position, Art, Status, E-Mail, Telefon, Quelle, Schlagworte, +freiberuflich, Kontaktstand, **`rate_onsite` und `rate_remote`**, LinkedIn, +Unternehmen und dessen Branche. + +Zwei Regeln stehen ausdrücklich im Systemprompt, weil die Antworten sonst falsch +werden: + +* Ein fehlender Satz ist **nicht** null Euro. Verglichen wird nur, wo ein Wert + steht; die Antwort sagt dazu, bei wie vielen keiner hinterlegt ist. +* Es sind **Stundensätze**, keine Tagessätze. Fragt jemand nach dem Tagessatz, + antwortet die Suche mit dem Stundensatz und sagt das dazu. + +Beispiel: + +> **Frage:** Wer hat den höchsten Tagessatz? +> **Antwort:** Oliver Schaible hat mit 165,00 € pro Stunde den höchsten +> hinterlegten Satz vor Ort. Im CRM sind Stundensätze, keine Tagessätze, +> gepflegt. Bei 8 Kontakten ist kein Satz hinterlegt. + +Die Kontakte des perinco CRM stehen dem Bestand ebenfalls zur Verfügung, solange +die Schnittstelle eingerichtet ist – gelesen über denselben Zwischenspeicher wie +die Ansicht (`fetchPerincoContacts()`), und ausdrücklich als fremd gekennzeichnet. diff --git a/docs/09-anleitung-entwickler.md b/docs/09-anleitung-entwickler.md index 1c93d3c..9bddf88 100644 --- a/docs/09-anleitung-entwickler.md +++ b/docs/09-anleitung-entwickler.md @@ -88,7 +88,7 @@ Zuordnung in `navigation.open()` und der Filter in der Historie. ## 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. **Instabile Arrays in der Netzwerkansicht.** Ein `= []` als Vorgabewert erzeugt diff --git a/docs/README.md b/docs/README.md index 3f7f437..8edb106 100644 --- a/docs/README.md +++ b/docs/README.md @@ -40,7 +40,7 @@ flowchart TB DB[("MariaDB
icon_CRM")] end - OAI["OpenAI
gpt-5.6-luna"] + OAI["OpenAI
gpt-6-luna"] PCRM["perinco CRM
pcrm.perinco.de"] HF["Hugging-Face-CDN"] diff --git a/server/.env.example b/server/.env.example index 13cf4db..bfc972b 100644 --- a/server/.env.example +++ b/server/.env.example @@ -36,10 +36,10 @@ UPLOAD_DIR=/var/www/icon_CRM/uploads # 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. # -# 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. OPENAI_API_KEY= -OPENAI_MODEL=gpt-5.6-luna +OPENAI_MODEL=gpt-6-luna # --------------------------------------------------------------------------- # Lesender Zugriff auf die Kontakte des perinco CRM diff --git a/server/src/ai.js b/server/src/ai.js index bb29a64..0be74ad 100644 --- a/server/src/ai.js +++ b/server/src/ai.js @@ -1,238 +1,238 @@ -import { getSetting, setSetting } from './db.js'; - -/* - * Anbindung an OpenAI. - * - * 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. - * - * Der Schluessel verlaesst den Server nie in Richtung Browser - jede KI-Anfrage - * laeuft ueber das Backend, und nach aussen gibt es ihn nur verkuerzt - * ("sk-proj-...a1b2"). - * - * Er kann an zwei Stellen stehen: - * - * 1. in der Datenbank (icon_settings), gepflegt unter Einstellungen -> KI - * 2. in server/.env als OPENAI_API_KEY - * - * Die Datenbank gewinnt. Grund: ein Schluessel laeuft ab oder wird gewechselt, - * 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 - * System ohne Oberflaeche in Gang zu bringen. - */ - -const ENDPOINT = 'https://api.openai.com/v1/chat/completions'; - -/** Nach dieser Zeit wird abgebrochen - die Oberflaeche soll nicht haengen. */ -const TIMEOUT_MS = 120_000; - -export const DEFAULT_MODEL = 'gpt-5.6-luna'; - -const KEY_SETTING = 'openai_api_key'; -const MODEL_SETTING = 'openai_model'; - -/* - * Im Speicher gehalten, damit nicht jede Anfrage die Datenbank fragt. Gefuellt - * beim Start (loadAiConfig) und bei jeder Aenderung. - */ -const config = { - key: process.env.OPENAI_API_KEY || '', - model: process.env.OPENAI_MODEL || DEFAULT_MODEL, - /** Woher der gueltige Schluessel stammt - fuer die Anzeige. */ - source: process.env.OPENAI_API_KEY ? 'env' : 'none', -}; - -/** Beim Start aufrufen, sobald die Datenbank steht. */ -export async function loadAiConfig() { - try { - const [key, model] = await Promise.all([getSetting(KEY_SETTING), getSetting(MODEL_SETTING)]); - if (key) { - config.key = key; - config.source = 'db'; - } - if (model) config.model = model; - } catch (err) { - // Ohne Datenbank bleibt es bei der .env - das Backend soll trotzdem starten. - console.warn('[ICON CRM] KI-Einstellungen nicht lesbar:', err.message); - } -} - -export function aiConfigured() { - return Boolean(config.key); -} - -export function aiModel() { - return config.model; -} - -/** - * Zustand fuer die Oberflaeche. Der Schluessel wird nur verkuerzt gezeigt - - * genug, um zwei Schluessel auseinanderzuhalten, zu wenig, um ihn zu benutzen. - */ -export function aiStatus() { - return { - configured: aiConfigured(), - model: config.model, - defaultModel: DEFAULT_MODEL, - source: config.source, - masked: maskKey(config.key), - /** Steht in der .env noch einer? Dann greift er, wenn der aus der DB faellt. */ - envAvailable: Boolean(process.env.OPENAI_API_KEY), - }; -} - -function maskKey(key) { - if (!key) return null; - return key.length <= 12 ? '****' : `${key.slice(0, 7)}…${key.slice(-4)}`; -} - -/** - * Schluessel setzen oder entfernen. - * - * Entfernen heisst: der Wert in der Datenbank faellt weg. Steht in der .env - * einer, gilt wieder dieser - das ist kein Versehen, sondern der Rueckfall. - */ -export async function saveAiKey(key) { - const value = String(key ?? '').trim(); - await setSetting(KEY_SETTING, value || null); - if (value) { - config.key = value; - config.source = 'db'; - } else { - config.key = process.env.OPENAI_API_KEY || ''; - config.source = config.key ? 'env' : 'none'; - } -} - -/** Modell setzen; leer heisst zurueck zur Vorgabe. */ -export async function saveAiModel(model) { - const value = String(model ?? '').trim(); - await setSetting(MODEL_SETTING, value || null); - config.model = value || process.env.OPENAI_MODEL || DEFAULT_MODEL; -} - -/* - * Die Modelle, die der hinterlegte Schluessel nutzen darf. - * - * /v1/models liefert alles - auch Bild-, Audio-, Embedding- und Moderations- - * modelle. Fuer das CRM zaehlen nur Chat-Modelle, also werden die anderen - * ausgefiltert. Kurz im Speicher gehalten, damit das Aufklappen der Liste nicht - * jedes Mal OpenAI fragt; ein Schluesselwechsel leert den Speicher. - */ -const MODELS_ENDPOINT = 'https://api.openai.com/v1/models'; -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; -let modelCache = { key: '', at: 0, list: [] }; - -export async function listAiModels() { - if (!aiConfigured()) throw new AiError('Es ist kein Schlüssel hinterlegt.', 503); - if (modelCache.key === config.key && Date.now() - modelCache.at < MODELS_TTL_MS) { - return modelCache.list; - } - - let response; - try { - response = await fetch(MODELS_ENDPOINT, { - headers: { Authorization: `Bearer ${config.key}` }, - signal: AbortSignal.timeout(15_000), - }); - } catch { - throw new AiError('OpenAI ist gerade nicht erreichbar.', 502); - } - const payload = await response.json().catch(() => null); - if (!response.ok) { - throw new AiError(payload?.error?.message || `OpenAI antwortete mit HTTP ${response.status}.`, 502); - } - - const list = (payload?.data ?? []) - .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)) - // Neueste zuerst - die will man meistens. - .sort((a, b) => b.created - a.created || a.id.localeCompare(b.id)); - - modelCache = { key: config.key, at: Date.now(), list }; - return list; -} - -class AiError extends Error { - constructor(message, status = 502) { - super(message); - this.status = status; - } -} - -/** - * Ein Aufruf an das Modell. - * - * `temperature` wird bewusst NICHT mitgeschickt: gpt-5.6-luna laesst nur den - * Standardwert zu und weist jeden anderen mit 400 ab. - * - * @param {object} options - * @param {string} options.system Rollenanweisung - * @param {string} options.user Eigentliche Anfrage - * @param {boolean} [options.json] true erzwingt eine JSON-Antwort - * @param {number} [options.maxTokens] - * @returns {Promise} der Antworttext - */ -export async function askModel({ system, user, json = false, maxTokens }) { - if (!aiConfigured()) { - throw new AiError( - 'Es ist kein OpenAI-Schlüssel hinterlegt (Einstellungen → KI, oder OPENAI_API_KEY in server/.env).', - 503 - ); - } - - const body = { - model: config.model, - messages: [ - ...(system ? [{ role: 'system', content: system }] : []), - { role: 'user', content: user }, - ], - }; - if (json) body.response_format = { type: 'json_object' }; - 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, { - method: 'POST', - headers: { - Authorization: `Bearer ${config.key}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(body), - signal: controller.signal, - }); - } catch (err) { - if (err?.name === 'AbortError') throw new AiError('Die KI hat nicht rechtzeitig geantwortet.', 504); - throw new AiError('OpenAI ist gerade nicht erreichbar.', 502); - } finally { - clearTimeout(timer); - } - - const payload = await response.json().catch(() => null); - if (!response.ok) { - // Die Meldung von OpenAI ist meist praezise ("model not found", "quota") - - // 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); - } - - const text = payload?.choices?.[0]?.message?.content; - if (!text) throw new AiError('Die KI hat keine Antwort geliefert.', 502); - return text; -} - -/** Wie askModel, gibt aber das geparste JSON zurueck. */ -export async function askModelJson(options) { - const text = await askModel({ ...options, json: true }); - try { - return JSON.parse(text); - } catch { - throw new AiError('Die KI hat kein gültiges JSON geliefert.', 502); - } -} - -export { AiError }; +import { getSetting, setSetting } from './db.js'; + +/* + * Anbindung an OpenAI. + * + * 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. + * + * Der Schluessel verlaesst den Server nie in Richtung Browser - jede KI-Anfrage + * laeuft ueber das Backend, und nach aussen gibt es ihn nur verkuerzt + * ("sk-proj-...a1b2"). + * + * Er kann an zwei Stellen stehen: + * + * 1. in der Datenbank (icon_settings), gepflegt unter Einstellungen -> KI + * 2. in server/.env als OPENAI_API_KEY + * + * Die Datenbank gewinnt. Grund: ein Schluessel laeuft ab oder wird gewechselt, + * 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 + * System ohne Oberflaeche in Gang zu bringen. + */ + +const ENDPOINT = 'https://api.openai.com/v1/chat/completions'; + +/** Nach dieser Zeit wird abgebrochen - die Oberflaeche soll nicht haengen. */ +const TIMEOUT_MS = 120_000; + +export const DEFAULT_MODEL = 'gpt-6-luna'; + +const KEY_SETTING = 'openai_api_key'; +const MODEL_SETTING = 'openai_model'; + +/* + * Im Speicher gehalten, damit nicht jede Anfrage die Datenbank fragt. Gefuellt + * beim Start (loadAiConfig) und bei jeder Aenderung. + */ +const config = { + key: process.env.OPENAI_API_KEY || '', + model: process.env.OPENAI_MODEL || DEFAULT_MODEL, + /** Woher der gueltige Schluessel stammt - fuer die Anzeige. */ + source: process.env.OPENAI_API_KEY ? 'env' : 'none', +}; + +/** Beim Start aufrufen, sobald die Datenbank steht. */ +export async function loadAiConfig() { + try { + const [key, model] = await Promise.all([getSetting(KEY_SETTING), getSetting(MODEL_SETTING)]); + if (key) { + config.key = key; + config.source = 'db'; + } + if (model) config.model = model; + } catch (err) { + // Ohne Datenbank bleibt es bei der .env - das Backend soll trotzdem starten. + console.warn('[ICON CRM] KI-Einstellungen nicht lesbar:', err.message); + } +} + +export function aiConfigured() { + return Boolean(config.key); +} + +export function aiModel() { + return config.model; +} + +/** + * Zustand fuer die Oberflaeche. Der Schluessel wird nur verkuerzt gezeigt - + * genug, um zwei Schluessel auseinanderzuhalten, zu wenig, um ihn zu benutzen. + */ +export function aiStatus() { + return { + configured: aiConfigured(), + model: config.model, + defaultModel: DEFAULT_MODEL, + source: config.source, + masked: maskKey(config.key), + /** Steht in der .env noch einer? Dann greift er, wenn der aus der DB faellt. */ + envAvailable: Boolean(process.env.OPENAI_API_KEY), + }; +} + +function maskKey(key) { + if (!key) return null; + return key.length <= 12 ? '****' : `${key.slice(0, 7)}…${key.slice(-4)}`; +} + +/** + * Schluessel setzen oder entfernen. + * + * Entfernen heisst: der Wert in der Datenbank faellt weg. Steht in der .env + * einer, gilt wieder dieser - das ist kein Versehen, sondern der Rueckfall. + */ +export async function saveAiKey(key) { + const value = String(key ?? '').trim(); + await setSetting(KEY_SETTING, value || null); + if (value) { + config.key = value; + config.source = 'db'; + } else { + config.key = process.env.OPENAI_API_KEY || ''; + config.source = config.key ? 'env' : 'none'; + } +} + +/** Modell setzen; leer heisst zurueck zur Vorgabe. */ +export async function saveAiModel(model) { + const value = String(model ?? '').trim(); + await setSetting(MODEL_SETTING, value || null); + config.model = value || process.env.OPENAI_MODEL || DEFAULT_MODEL; +} + +/* + * Die Modelle, die der hinterlegte Schluessel nutzen darf. + * + * /v1/models liefert alles - auch Bild-, Audio-, Embedding- und Moderations- + * modelle. Fuer das CRM zaehlen nur Chat-Modelle, also werden die anderen + * ausgefiltert. Kurz im Speicher gehalten, damit das Aufklappen der Liste nicht + * jedes Mal OpenAI fragt; ein Schluesselwechsel leert den Speicher. + */ +const MODELS_ENDPOINT = 'https://api.openai.com/v1/models'; +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; +let modelCache = { key: '', at: 0, list: [] }; + +export async function listAiModels() { + if (!aiConfigured()) throw new AiError('Es ist kein Schlüssel hinterlegt.', 503); + if (modelCache.key === config.key && Date.now() - modelCache.at < MODELS_TTL_MS) { + return modelCache.list; + } + + let response; + try { + response = await fetch(MODELS_ENDPOINT, { + headers: { Authorization: `Bearer ${config.key}` }, + signal: AbortSignal.timeout(15_000), + }); + } catch { + throw new AiError('OpenAI ist gerade nicht erreichbar.', 502); + } + const payload = await response.json().catch(() => null); + if (!response.ok) { + throw new AiError(payload?.error?.message || `OpenAI antwortete mit HTTP ${response.status}.`, 502); + } + + const list = (payload?.data ?? []) + .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)) + // Neueste zuerst - die will man meistens. + .sort((a, b) => b.created - a.created || a.id.localeCompare(b.id)); + + modelCache = { key: config.key, at: Date.now(), list }; + return list; +} + +class AiError extends Error { + constructor(message, status = 502) { + super(message); + this.status = status; + } +} + +/** + * Ein Aufruf an das Modell. + * + * `temperature` wird bewusst NICHT mitgeschickt: die Luna-Modelle lassen nur den + * Standardwert zu und weisen jeden anderen mit 400 ab. + * + * @param {object} options + * @param {string} options.system Rollenanweisung + * @param {string} options.user Eigentliche Anfrage + * @param {boolean} [options.json] true erzwingt eine JSON-Antwort + * @param {number} [options.maxTokens] + * @returns {Promise} der Antworttext + */ +export async function askModel({ system, user, json = false, maxTokens }) { + if (!aiConfigured()) { + throw new AiError( + 'Es ist kein OpenAI-Schlüssel hinterlegt (Einstellungen → KI, oder OPENAI_API_KEY in server/.env).', + 503 + ); + } + + const body = { + model: config.model, + messages: [ + ...(system ? [{ role: 'system', content: system }] : []), + { role: 'user', content: user }, + ], + }; + if (json) body.response_format = { type: 'json_object' }; + 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, { + method: 'POST', + headers: { + Authorization: `Bearer ${config.key}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + signal: controller.signal, + }); + } catch (err) { + if (err?.name === 'AbortError') throw new AiError('Die KI hat nicht rechtzeitig geantwortet.', 504); + throw new AiError('OpenAI ist gerade nicht erreichbar.', 502); + } finally { + clearTimeout(timer); + } + + const payload = await response.json().catch(() => null); + if (!response.ok) { + // Die Meldung von OpenAI ist meist praezise ("model not found", "quota") - + // 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); + } + + const text = payload?.choices?.[0]?.message?.content; + if (!text) throw new AiError('Die KI hat keine Antwort geliefert.', 502); + return text; +} + +/** Wie askModel, gibt aber das geparste JSON zurueck. */ +export async function askModelJson(options) { + const text = await askModel({ ...options, json: true }); + try { + return JSON.parse(text); + } catch { + throw new AiError('Die KI hat kein gültiges JSON geliefert.', 502); + } +} + +export { AiError }; diff --git a/src/components/settings/AiSettings.tsx b/src/components/settings/AiSettings.tsx index 2e7f668..adafe5f 100644 --- a/src/components/settings/AiSettings.tsx +++ b/src/components/settings/AiSettings.tsx @@ -174,7 +174,7 @@ export const AiSettings: React.FC = () => { hint={ models ? `${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 ? (