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
|
||||
|
||||
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<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:** `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<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).
|
||||
|
||||
```mermaid
|
||||
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](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<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:
|
||||
|
||||
```mermaid
|
||||
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).
|
||||
|
||||
```mermaid
|
||||
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](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.
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -40,7 +40,7 @@ flowchart TB
|
||||
DB[("MariaDB<br/>icon_CRM")]
|
||||
end
|
||||
|
||||
OAI["OpenAI<br/>gpt-5.6-luna"]
|
||||
OAI["OpenAI<br/>gpt-6-luna"]
|
||||
PCRM["perinco CRM<br/>pcrm.perinco.de"]
|
||||
HF["Hugging-Face-CDN"]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user