KI: Modellauswahl als Dropdown + Abgleich mit Produktionsstand

Einstellungen -> KI: statt freiem Modellnamen ein Dropdown mit den
Chat-Modellen, die der hinterlegte OpenAI-Schluessel nutzen darf.

- Backend: listAiModels() fragt OpenAI GET /v1/models, filtert auf
  Chat-Modelle (gpt-*, o*, chatgpt-*; ohne Embedding/Audio/Bild/Instruct
  usw.), neueste zuerst, 10 min im Speicher (Schluesselwechsel laedt neu)
- Neuer Endpunkt GET /api/ai/models (admin)
- Frontend: aktives Modell bleibt waehlbar, auch wenn nicht mehr gelistet;
  Rueckfall auf Textfeld, wenn die Liste nicht abrufbar ist
- Doku: 05-ki (Modellauswahl mit Ablaufdiagramm), 10-api, 08-anleitung

Zieht ausserdem den bereits live laufenden, bisher nicht eingecheckten
Stand nach (perinco-Kontaktabgleich/MergeDialog, Projektmargen,
Whisper-Anpassungen u. a.). Repo entspricht damit icrm.icon-experts.de.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
schakal00783
2026-09-23 10:26:30 +02:00
co-authored by Claude Opus 5.5
parent 3f25355a6d
commit cbac02a96a
40 changed files with 1476 additions and 80 deletions
+18
View File
@@ -152,6 +152,24 @@ Projekt).
(Aktiv / Nicht aktiv / Abgeschlossen), `rate`, `notes` (HTML). Im Menü heißt die
Ansicht **Laufende Projekte**.
`rate` ist ein **Tagessatz** in Euro – das, was ICON dem Kunden berechnet. Beim
Berater stehen dagegen **Stundensätze** (`rate_onsite`, `rate_remote` in
`icon_contacts`). Die Marge rechnet mit **1 Tag = 8 Stunden**:
```
Marge = rate − Stundensatz des Beraters × 8
```
Die Marge wird **nicht gespeichert**. Sie ist eine Rechnung aus zwei Feldern,
die sich beide ändern können – ein gespeicherter Wert wäre nach der ersten
Satzänderung falsch, ohne dass es jemand merkt. `GET /api/projects` liefert die
beiden Sätze des Beraters deshalb als `contact_rate_onsite` /
`contact_rate_remote` mit; gerechnet wird in `src/components/projects/margin.ts`.
Es gibt am Projekt kein Feld für die Einsatzart. **Vorrang hat der
Remote-Satz**; nur wenn er fehlt, wird der Vor-Ort-Satz genommen. Welcher es
war, steht in der Oberfläche dabei.
Im übernommenen Bestand steht im Kopf von `notes` ein Textblock aus dem alten
CRM (`KUNDE:`, `KONTAKT:`, `EXPERTE:`, `START:`, `ENDE:`), darunter der
übernommene Ausschreibungstext als HTML. `src/lib/richText.ts` macht daraus im
+7 -1
View File
@@ -38,7 +38,7 @@ flowchart TD
V --> KON["contacts/<br/>Liste, Kacheln, Netzwerk, CV-Import"]
V --> UNT["companies/<br/>Liste, Kacheln, Group, Netzwerk"]
V --> AUS["tenders/<br/>Tabelle, Live!, Archiv"]
V --> PRJ["projects/"]
V --> PRJ["projects/<br/>Kacheln, Karte, Marge"]
V --> HIS["history/"]
V --> SET["settings/<br/>Nutzer, Stammdaten, Konto, Diktat"]
KON --> NW["network/Network.tsx<br/>d3-force + SVG"]
@@ -46,6 +46,12 @@ flowchart TD
NW --> CT["network/CompanyTree.tsx"]
```
`projects/margin.ts` rechnet die Marge: Tagessatz des Projekts minus
Stundensatz des Beraters mal acht. Die Rechnung liegt neben der Ansicht und
nicht im Backend, weil sie in der Projektkarte **live** mitlaufen muss – dort
lassen sich Tagessatz und Berater ändern, bevor gespeichert wird. Siehe
`docs/02-datenmodell.md`, Abschnitt `icon_projects`.
Zwei Dinge liegen bewusst in `App.tsx`:
* Die Listen der Unternehmen und Benutzer werden **einmal** geladen und
+34 -1
View File
@@ -22,12 +22,45 @@ Dienst neu starten müssen.
| 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` |
| 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.
+28 -4
View File
@@ -159,9 +159,33 @@ Zwei Untermenüs:
## Laufende Projekte
Titel, Kunde, Experte, Zeitraum, Status und Notizen. Die Notizen sind ein
WYSIWYG-Feld – im übernommenen Bestand steht dort ein Kopfblock aus dem alten
CRM und darunter der Ausschreibungstext.
Titel, Kunde, Experte, Zeitraum, Status, Tagessatz und Notizen. Die Notizen
sind ein WYSIWYG-Feld – im übernommenen Bestand steht dort ein Kopfblock aus dem
alten CRM und darunter der Ausschreibungstext.
### Marge
Jede Projektkachel weist die Marge aus, und in der Projektkarte steht die
Rechnung aufgeschlüsselt:
| Zeile | Woher |
| --- | --- |
| Tagessatz Kunde | Feld **Tagessatz** am Projekt |
| Stundensatz Berater | Kontaktkarte des Experten |
| Kosten (8 h) | Stundensatz × 8 |
| Marge | Tagessatz − Kosten, dazu der Prozentsatz vom Tagessatz |
**Ein Tag sind 8 Stunden.** Hat der Berater einen Remote- *und* einen
Vor-Ort-Satz, wird mit dem **Remote-Satz** gerechnet; fehlt der, mit dem
Vor-Ort-Satz. Welcher benutzt wurde, steht daneben.
Die Farbe ist eine schnelle Ampel: grün ab 25 %, gelb darüber bis 0, rot bei
negativer Marge.
Fehlt der Tagessatz oder hat der Berater keinen Stundensatz, steht statt einer
Zahl der Grund dort – eine leere Zeile ließe offen, ob die Marge null oder
unbekannt ist. Der Tagessatz wird am Projekt gepflegt, die Stundensätze beim
Kontakt (*Kontakt → Stundensatz vor Ort / remote*).
## Historie
@@ -191,7 +215,7 @@ nichts gespeichert. Details: [04 Diktat](04-diktat.md).
| --- | --- | --- |
| Nutzerverwaltung | Administratoren | Benutzer anlegen, Rolle, aktiv/inaktiv |
| Stammdaten | Administratoren | Auswahllisten: Branchen, Arten der Kontakte |
| KI | Administratoren | OpenAI-Schlüssel, Modell, Verbindungstest |
| KI | Administratoren | OpenAI-Schlüssel, Modell (Auswahlliste der für den Schlüssel verfügbaren Modelle), Verbindungstest |
| Mein Konto | alle | Foto, Farbe, Passwort, Design |
| Diktat | alle | Spracherkennung einrichten |
+6
View File
@@ -71,6 +71,11 @@ auch Phasenwechsel, der Schalter `online` und die Zuweisung von Kontakten.
`GET /`, `POST /`, `PATCH /:id`, `DELETE /:id`.
`GET /` liefert zu jedem Projekt zusätzlich `contact_rate_onsite` und
`contact_rate_remote` – die Stundensätze des zugeordneten Beraters. Sie sind
nur für die Margen-Anzeige da und **nicht** über `PATCH` änderbar; gepflegt
werden sie am Kontakt.
## Leads – `/api/leads`
| Endpunkt | Zweck |
@@ -102,6 +107,7 @@ auch Phasenwechsel, der Schalter `online` und die Zuweisung von Kontakten.
| `GET /status` | `{ configured, model }` – steuert, ob die Knöpfe erscheinen |
| `POST /text` | Schreibhilfe: `{ task, text, prompt }` |
| `GET /config` | *admin* – Zustand der Einrichtung; der Schlüssel nur verkürzt |
| `GET /models` | *admin* – `{ models: [{ id, created }] }`, Chat-Modelle des hinterlegten Schlüssels, neueste zuerst (10 min zwischengespeichert) |
| `PATCH /config` | *admin* – `{ key?, model? }`; `key: ''` entfernt ihn wieder |
| `POST /test` | *admin* – Miniaufruf gegen das Modell |