# 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 | frei setzbar, 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.
> **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.
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.