Files
ICON-CRM/docs/05-ki.md
T
schakal00783andClaude Opus 5.5 6fe5b18c11 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>
2026-09-29 10:10:41 +02:00

13 KiB
Raw Blame History

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.

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<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
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:

flowchart LR
    CV["CV-Import<br/>Lebenslauf → Kontaktvorschlag"] --> AI["server/src/ai.js"]
    TX["Schreibhilfe<br/>Ausschreibungstext"] --> AI
    SU["Erweiterte Suche<br/>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).

flowchart LR
    U["Hochladen"] --> E["Eingang"]
    E -->|"Mensch: Ja, auslesen"| S["Screening"]
    E -->|"Mensch: Nein"| V["Verworfen"]
    S -->|"Formular bestaetigt"| A["Auswahl<br/>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).

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.

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.