docs: echte Umlaute in der Dokumentation

893 Stellen in Fliesstext, Tabellen und Mermaid-Beschriftungen. Dazu die
sichtbaren ss-Fälle (ausschließlich, groß, außen, schließt, heißt,
draußen, weiße, anstoßen, Straße).

Nicht angetastet, damit die Doku weiter beschreibt, was wirklich in der
Datenbank steht:

- Codeblöcke und Code-Spans - dort stehen Bezeichner, SQL, Enum-Werte
  und Dateinamen: `mail_pool`, `'uebernommen'`, `extract.js`
- in Mermaid nur der Text in Anführungszeichen; Knotennamen, Pfeile und
  Werte wie source = 'Mail' bleiben, wie sie sind
- `empfaenger` im ER-Diagramm von crm_mail_contact_candidates: die
  Aufzählung dort nennt die Datenbankwerte, nicht die Anzeige

Die Menü-Skizze in der Benutzeranleitung zeigt jetzt die aufgeklappten
Kontakte mit "Aus Mails" samt Zähler.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sascha Wolf
2026-08-11 09:45:58 +02:00
co-authored by Claude Opus 5
parent 8b7ce448a6
commit 7973dc07b1
13 changed files with 688 additions and 684 deletions
+145 -145
View File
@@ -1,14 +1,14 @@
# perinco CRM
CRM im perinco Corporate Design. Vite/React-Frontend, Express-Backend, MariaDB.
Enthaelt den portierten *Projekt Scanner* und die lesende Anbindung des
Vertriebspostfachs `crm@perinco.de` ueber Microsoft Graph.
Enthält den portierten *Projekt Scanner* und die lesende Anbindung des
Vertriebspostfachs `crm@perinco.de` über Microsoft Graph.
> **Dokumentation mit Funktionszeichnungen:** [`docs/`](docs/README.md)
> — Architektur, Datenmodell, Scanner-Ablauf, Diktat, Frontend, Betrieb,
> [Postfachanbindung](docs/11-postfach.md).
> Zum Einstieg: [Anleitung fuer Benutzer](docs/07-anleitung-benutzer.md) ·
> [Anleitung fuer Entwickler](docs/08-anleitung-entwickler.md) ·
> Zum Einstieg: [Anleitung für Benutzer](docs/07-anleitung-benutzer.md) ·
> [Anleitung für Entwickler](docs/08-anleitung-entwickler.md) ·
> [Quellcode-Referenz](docs/09-quellcode.md)
## Corporate Identity
@@ -17,12 +17,12 @@ Direkt aus dem perinco Logo gesampelt:
| Rolle | Wert |
| --- | --- |
| Primaerfarbe (Gruen) | `#6BA04A`, Akzent `#68A33F` |
| Sekundaerfarbe (Grau) | `#6E6E6E` |
| Primärfarbe (Grün) | `#6BA04A`, Akzent `#68A33F` |
| Sekundärfarbe (Grau) | `#6E6E6E` |
| Schrift | Avenir LT (wie perinco.de); Fallback Nunito Sans |
Die Farbskalen liegen als Tailwind-Theme in `src/index.css` (`brand-*`, `ink-*`)
sowie die Flaechen `canvas` (Seite) und `surface` (Karten, Leisten, Dialoge).
sowie die Flächen `canvas` (Seite) und `surface` (Karten, Leisten, Dialoge).
## Dark Mode
@@ -32,21 +32,21 @@ Doppelklick auf den Umschalter stellt das wieder her.
Umgesetzt ist der Dark Mode **ohne `dark:`-Varianten im Markup**: die Klasse
`dark` auf `<html>` tauscht in `src/index.css` nur die Werte der Variablen
`canvas`, `surface`, `ink-*`, `brand-*` und der getoenten Statusfarben. Die
Komponenten benutzen durchgaengig diese Skalen und folgen damit automatisch.
`canvas`, `surface`, `ink-*`, `brand-*` und der getönten Statusfarben. Die
Komponenten benutzen durchgängig diese Skalen und folgen damit automatisch.
Drei Dinge bleiben in beiden Themes gleich und sind deshalb bewusst so gewaehlt:
Drei Dinge bleiben in beiden Themes gleich und sind deshalb bewusst so gewählt:
| Was | Warum |
| --- | --- |
| `ink-950` | liegt nur unter Dialog-Overlays, muss dunkel bleiben |
| `text-white` | steht immer auf farbigen Flaechen (Buttons, Termin-Chips) |
| `brand-500` | das CI-Gruen der Buttons; nur die Tints darum kippen |
| `text-white` | steht immer auf farbigen Flächen (Buttons, Termin-Chips) |
| `brand-500` | das CI-Grün der Buttons; nur die Tints darum kippen |
Das Netzwerkdiagramm kann im SVG keine Tailwind-Klassen stapeln und benutzt
deshalb `var(--color-*)` direkt. Das Logo ist fuer helle Hintergruende gemacht -
deshalb `var(--color-*)` direkt. Das Logo ist für helle Hintergründe gemacht -
die Utility `.logo-adaptive` dreht im Dark Mode die Helligkeit und holt per
`hue-rotate` das Gruen zurueck.
`hue-rotate` das Grün zurück.
Ein Inline-Skript in `index.html` setzt die Klasse vor dem ersten Frame, sonst
blitzt beim Laden die helle Variante auf.
@@ -62,11 +62,11 @@ Browser ──► Apache (:443/:80, pcrm.perinco.de)
└──► Microsoft Graph (crm@perinco.de, nur lesend)
```
Der Scanner haengt unter `/api/scanner`, die Postfachanbindung unter `/api/mail` -
Der Scanner hängt unter `/api/scanner`, die Postfachanbindung unter `/api/mail` -
beide brauchen damit **keine** eigene Apache-Regel.
Der Browser spricht **nie** direkt mit MariaDB - deshalb das Express-Backend.
Das Backend bindet ausschliesslich auf `127.0.0.1` und ist von aussen nur ueber
Das Backend bindet ausschließlich auf `127.0.0.1` und ist von außen nur über
den Apache-Proxy erreichbar.
### Datenmodell
@@ -74,25 +74,25 @@ den Apache-Proxy erreichbar.
| Tabelle | Inhalt |
| --- | --- |
| `crm_users` | Benutzer, Rolle `admin` oder `user`, bcrypt-Hash, `color` (Kalenderfarbe), `avatar` (Foto) |
| `crm_leads` | Kontakte inkl. `created_by` (Ersteller) und `owner_id` (zustaendig); `mail_pool = 1` ist der Vorbereich „Aus Mails" aus dem Postfachabgleich |
| `crm_leads` | Kontakte inkl. `created_by` (Ersteller) und `owner_id` (zuständig); `mail_pool = 1` ist der Vorbereich „Aus Mails" aus dem Postfachabgleich |
| `crm_opportunities` | Vertriebschancen: Phase, Tagessatz, Laufzeit, letzte Interaktion, Wahrscheinlichkeit |
| `crm_opportunity_contacts` | Ansprechpartner je Opportunity (n:m auf `crm_leads`) mit Rolle |
| `crm_lead_connections` | Wer steht mit wem in Kontakt; Paar aufsteigend gespeichert, Unique-Key verhindert Duplikate |
| `crm_events` | Termine des gemeinsamen Teamkalenders; `description` kann Mention-Tokens enthalten |
| `crm_event_participants` | Teilnehmer je Termin (n:m), Primaerschluessel verhindert Doppeleintraege |
| `crm_event_participants` | Teilnehmer je Termin (n:m), Primärschlüssel verhindert Doppeleinträge |
### Rollen
* `admin` - sieht den Menuepunkt Einstellungen inkl. Nutzerverwaltung
* `user` - sieht Einstellungen nicht; die API weist Zugriffe zusaetzlich mit 403 ab
* `admin` - sieht den Menüpunkt Einstellungen inkl. Nutzerverwaltung
* `user` - sieht Einstellungen nicht; die API weist Zugriffe zusätzlich mit 403 ab
Ein Sicherheitsnetz im Backend verhindert, dass der letzte aktive Administrator
degradiert, deaktiviert oder geloescht wird.
degradiert, deaktiviert oder gelöscht wird.
Ausnahme von der Admin-Regel ist `GET /api/users/directory`: die Benutzerliste
brauchen alle Rollen fuer Netzwerkdiagramm, Zustaendigkeiten, @-Mentions,
brauchen alle Rollen für Netzwerkdiagramm, Zuständigkeiten, @-Mentions,
Teilnehmerauswahl und Farben. Passwort-Hashes liefert der Endpunkt nie.
Termine darf jeder anlegen; aendern und loeschen nur der Ersteller oder ein
Termine darf jeder anlegen; ändern und löschen nur der Ersteller oder ein
Administrator.
## Struktur
@@ -173,40 +173,40 @@ deploy/
## Opportunities
Erster Reiter im Menue und Startansicht. Drei Ansichten wie bei den Kontakten,
Erster Reiter im Menü und Startansicht. Drei Ansichten wie bei den Kontakten,
umgeschaltet oben rechts:
| Ansicht | Inhalt |
| --- | --- |
| **Kachel** | Karten mit Volumen, Tagessatz, Laufzeit, Zeitraum, letzter Interaktion und naechstem Schritt |
| **Kachel** | Karten mit Volumen, Tagessatz, Laufzeit, Zeitraum, letzter Interaktion und nächstem Schritt |
| **Liste** | sortierbare Tabelle, Spaltenkopf klicken kehrt die Richtung um |
| **Pipeline** | Kanban nach Phase, Karten per Drag & Drop verschiebbar |
Die dritte Ansicht ist bewusst eine Pipeline statt eines Netzwerks: bei einer
Vertriebschance interessiert der Fortschritt durch die Phasen. Wer nicht ziehen
mag oder auf dem Tablet arbeitet, aendert die Phase ueber das Auswahlfeld auf der
mag oder auf dem Tablet arbeitet, ändert die Phase über das Auswahlfeld auf der
Karte.
**Felder:** Titel, Firma, Projektbeschreibung (in Kachel und Liste
aufklappbar), Phase, Abschlusswahrscheinlichkeit, Tagessatz, Laufzeit in
Personentagen (halbe Tage moeglich), Projektstart und -ende, letzte Interaktion
mit Notiz, naechster Schritt, Tags, interne Notizen, Zustaendigkeit sowie
Personentagen (halbe Tage möglich), Projektstart und -ende, letzte Interaktion
mit Notiz, nächster Schritt, Tags, interne Notizen, Zuständigkeit sowie
beliebig viele Ansprechpartner aus den Kontakten - jeder mit eigener Rolle.
**Volumen** rechnet die App aus Tagessatz x Personentagen, **gewichtet** zusaetzlich
**Volumen** rechnet die App aus Tagessatz x Personentagen, **gewichtet** zusätzlich
mit der Wahrscheinlichkeit. Beides steht live im Formular, pro Karte, als Summe
ueber der Liste, je Pipeline-Spalte und in der Kopfzeile.
über der Liste, je Pipeline-Spalte und in der Kopfzeile.
**Sortieren** geht doppelt: ueber das Auswahlfeld in der Steuerleiste (wirkt in
allen Ansichten) und in der Liste ueber die Spaltenkoepfe. Geld- und Datumsfelder
**Sortieren** geht doppelt: über das Auswahlfeld in der Steuerleiste (wirkt in
allen Ansichten) und in der Liste über die Spaltenköpfe. Geld- und Datumsfelder
starten absteigend, Text aufsteigend; leere Werte landen immer hinten.
Aendern und loeschen darf der Ersteller, der Zustaendige und jeder Administrator.
Ändern und löschen darf der Ersteller, der Zuständige und jeder Administrator.
## Projekt Scanner
Der eigenstaendige *Projekt Scanner* ist vollstaendig hier eingezogen und soll
die alte App ersetzen. Die laeuft weiter unter `scanner.perinco.de`:
Der eigenständige *Projekt Scanner* ist vollständig hier eingezogen und soll
die alte App ersetzen. Die läuft weiter unter `scanner.perinco.de`:
```
/var/www/Projekt Scanner/backend Express auf 0.0.0.0:5005
@@ -215,24 +215,24 @@ systemd-Unit: project-scanner-backend (nicht "project-scanner")
```
Bis zur Ablosung laufen **beide parallel und beide voll funktional**: eigene
Datenbank, eigener Verschluesselungsschluessel, eigenes Upload-Verzeichnis,
eigener Scheduler. Die alte App bleibt unveraendert - weder ihre Dateien noch
Datenbank, eigener Verschlüsselungsschlüssel, eigenes Upload-Verzeichnis,
eigener Scheduler. Die alte App bleibt unverändert - weder ihre Dateien noch
ihre Datenbank werden angefasst.
Menuepunkt **Scanner** links; ein Klick klappt die Reiter der alten App darunter
Menüpunkt **Scanner** links; ein Klick klappt die Reiter der alten App darunter
auf:
| Reiter | Inhalt |
| --- | --- |
| **Dashboard** | Kennzahlen, Filter (Zeitraum, Agent, Schwellenwerte), sortierbare Match-Tabelle, *Jetzt scannen* |
| **Unpassend** | als „passt nicht" markierte Treffer; zurueckholen oder loeschen |
| **Unpassend** | als „passt nicht" markierte Treffer; zurückholen oder löschen |
| **Suchagenten** | Beraterprofile: Name, E-Mail, Keywords, Ausschlussbegriffe, CV-PDF, aktiv/inaktiv |
| **Boersen-Konten** | Verbindungsstatus je Portal, Zugangsdaten, Cookie-Import, Verbindungstest |
| **Börsen-Konten** | Verbindungsstatus je Portal, Zugangsdaten, Cookie-Import, Verbindungstest |
Die **Einstellungen** des Scanners stehen nicht im Untermenue, sondern als Reiter
*Scanner* unter *Einstellungen* - also nur fuer Rolle `admin`. Die Reiter selbst
Die **Einstellungen** des Scanners stehen nicht im Untermenü, sondern als Reiter
*Scanner* unter *Einstellungen* - also nur für Rolle `admin`. Die Reiter selbst
sieht jede Rolle. Anzeige- und Benachrichtigungs-Schwellenwert lassen sich an
beiden Stellen aendern; es ist derselbe Wert in der Datenbank.
beiden Stellen ändern; es ist derselbe Wert in der Datenbank.
### Was portiert wurde
@@ -241,60 +241,60 @@ beiden Stellen aendern; es ist derselbe Wert in der Datenbank.
| Sequelize-Modelle (`agents`, `portals`, `projects`, `matches`, `settings`) | `server/src/scanner/models/` |
| Scraper (Playwright), Matching (OpenAI `gpt-4o-mini`), Pipeline, Scheduler, Cleanup, PDF-Parser | `server/src/scanner/services/` |
| REST-API | `server/src/routes/scanner.js`, gemountet als `/api/scanner` |
| Oberflaeche | `src/components/scanner/`, in Tailwind und CI-Farben neu gebaut |
| Uebernahme aus der alten DB | `server/src/scanner/importLegacy.js` (`npm run scanner:import`) |
| Oberfläche | `src/components/scanner/`, in Tailwind und CI-Farben neu gebaut |
| Übernahme aus der alten DB | `server/src/scanner/importLegacy.js` (`npm run scanner:import`) |
Drei bewusste Unterschiede zum Original:
1. **Anmeldung** Die HTTP-Basic-Auth des Scanners entfaellt - alle Endpunkte
1. **Anmeldung** Die HTTP-Basic-Auth des Scanners entfällt - alle Endpunkte
liegen hinter `requireAuth`, also der CRM-Sitzung.
2. **CV-PDFs** kommen ueber `GET /api/scanner/agents/:id/cv` statt aus einem
2. **CV-PDFs** kommen über `GET /api/scanner/agents/:id/cv` statt aus einem
statischen Verzeichnis: ein Link im neuen Tab kann kein Bearer-Token
mitschicken, deshalb holt das Frontend die Datei und oeffnet sie als Blob.
3. **Meldungen** laufen als Toast statt `alert()`, Loeschabfragen als Dialog.
mitschicken, deshalb holt das Frontend die Datei und öffnet sie als Blob.
3. **Meldungen** laufen als Toast statt `alert()`, Löschabfragen als Dialog.
### Parallelbetrieb - alles doppelt
Beide Scanner laufen **gleichzeitig und funktional**, ohne sich zu beruehren.
Dafuer ist hier jede geteilte Ressource verdoppelt:
Beide Scanner laufen **gleichzeitig und funktional**, ohne sich zu berühren.
Dafür ist hier jede geteilte Ressource verdoppelt:
| | alte App | diese Instanz |
| --- | --- | --- |
| Datenbank | `project_scanner` | `perinco_scanner` (eigene Tabellen, gleiche Namen) |
| Verschluesselung | `ENCRYPTION_KEY` | `SCANNER_ENCRYPTION_KEY` - eigener Wert |
| Verschlüsselung | `ENCRYPTION_KEY` | `SCANNER_ENCRYPTION_KEY` - eigener Wert |
| CV-PDFs | `/var/www/Projekt Scanner/backend/uploads` | `SCANNER_UPLOAD_DIR` = `/var/www/perinco_CRM/server/uploads` |
| Scheduler | eigener Cron im alten Dienst | `SCANNER_SCHEDULER`, Standard `true` |
| Aufraeumen | 03:00 | 03:30 |
| Aufräumen | 03:00 | 03:30 |
Die Scanner-Datenbank legt das Backend beim Start **selbst an**
(`CREATE DATABASE IF NOT EXISTS`), die Tabellen kommen aus `sequelize.sync()`,
Portale und Standardeinstellungen werden geseedet. Es genuegt also, das Backend
Portale und Standardeinstellungen werden geseedet. Es genügt also, das Backend
zu starten.
| Variable | Bedeutung |
| --- | --- |
| `SCANNER_DB_NAME` | eigene Datenbank, Standard `perinco_scanner` - **nicht** auf `project_scanner` zeigen lassen |
| `SCANNER_DB_HOST/_PORT/_USER/_PASSWORD` | leer = CRM-Zugangsdaten |
| `SCANNER_ENCRYPTION_KEY` | eigener Schluessel fuer die Portal-Passwoerter; ohne ihn wird ein bekannter Ersatzschluessel benutzt und beim Start gewarnt |
| `SCANNER_ENCRYPTION_KEY` | eigener Schlüssel für die Portal-Passwörter; ohne ihn wird ein bekannter Ersatzschlüssel benutzt und beim Start gewarnt |
| `SCANNER_UPLOAD_DIR` | eigenes CV-Verzeichnis; leer = `<server>/uploads` |
| `SCANNER_SCHEDULER` | Hintergrund-Scraper; Standard an, nur `false` schaltet ab |
| `SCANNER_SCHEDULER_OFFSET` | Minuten Versatz fuer den **allerersten** Lauf, Standard `30`. Danach traegt `settings.last_scan_at` den Zeitplan ueber Neustarts hinweg |
| `SCANNER_SCHEDULER_OFFSET` | Minuten Versatz für den **allerersten** Lauf, Standard `30`. Danach trägt `settings.last_scan_at` den Zeitplan über Neustarts hinweg |
> **Kosten:** Beide Instanzen bewerten dieselben Projekte unabhaengig
> **Kosten:** Beide Instanzen bewerten dieselben Projekte unabhängig
> voneinander - die OpenAI-Kosten fallen also doppelt an, solange die alte App
> laeuft. Das ist der Preis des Parallelbetriebs, kein Fehler. Wer nur zuschauen
> läuft. Das ist der Preis des Parallelbetriebs, kein Fehler. Wer nur zuschauen
> will, setzt `SCANNER_SCHEDULER=false`; der Knopf *Jetzt scannen* funktioniert
> dann weiter.
Der **Zeitversatz** ist nicht Kosmetik: beide Instanzen benutzen dieselben
Portal-Sessions (aus derselben Cookie-Quelle) und wuerden beim ersten Start
Portal-Sessions (aus derselben Cookie-Quelle) und würden beim ersten Start
gleichzeitig losziehen - das provoziert Rate-Limits und CAPTCHAs bei
Freelancermap und Freelance.de. `SCANNER_SCHEDULER_OFFSET=30` verschiebt den
ersten Lauf um 30 Minuten, danach laufen beide im eigenen Intervall verschraenkt.
ersten Lauf um 30 Minuten, danach laufen beide im eigenen Intervall verschränkt.
Ab dem zweiten Start traegt `settings.last_scan_at` den Zeitplan - die Frist
Ab dem zweiten Start trägt `settings.last_scan_at` den Zeitplan - die Frist
greift dann nicht mehr. Sonst begann sie bei jedem Deployment von vorn und ein
faelliger Lauf konnte beliebig lange ausfallen (siehe
fälliger Lauf konnte beliebig lange ausfallen (siehe
[docs/03-scanner.md](docs/03-scanner.md#wann-laeuft-er)).
Innerhalb dieser Instanz kann **nur ein Lauf** gleichzeitig arbeiten: Scheduler
@@ -303,11 +303,11 @@ Aufruf bekommt 409 und den Hinweis *„Ein Suchlauf läuft bereits"*. Das Dashbo
zeigt oben links den Zustand - automatische Suche aktiv, nur manuelle Suche,
pausiert, Suchlauf läuft - samt Namen der Datenbank.
### Daten aus der alten App uebernehmen
### Daten aus der alten App übernehmen
Damit die neue Instanz nicht bei Null anfaengt, holt ein Skript Agenten, Portale
und Einstellungen herueber. Es liest die alte Datenbank nur, geschrieben wird
ausschliesslich in `perinco_scanner`:
Damit die neue Instanz nicht bei Null anfängt, holt ein Skript Agenten, Portale
und Einstellungen herüber. Es liest die alte Datenbank nur, geschrieben wird
ausschließlich in `perinco_scanner`:
```bash
cd /var/www/perinco_CRM/server
@@ -319,42 +319,42 @@ npm run scanner:import -- --force # vorhandene Zieldaten vorher loesche
Quelle kommt aus `LEGACY_*` in `server/.env`:
| Variable | Wofuer |
| Variable | Wofür |
| --- | --- |
| `LEGACY_DB_NAME` | Standard `project_scanner` |
| `LEGACY_DB_HOST/_PORT/_USER/_PASSWORD` | leer = CRM-Zugangsdaten |
| `LEGACY_UPLOAD_DIR` | Verzeichnis der alten CV-PDFs (`/var/www/Projekt Scanner/backend/uploads`); die Dateien werden **kopiert**. Wegen des Leerzeichens im Pfad in der `.env` in Anfuehrungszeichen setzen - systemd liest die Datei als `EnvironmentFile` und trennt sonst am Leerzeichen |
| `LEGACY_ENCRYPTION_KEY` | nur damit lassen sich die Portal-Passwoerter uebernehmen: entschluesseln mit dem alten, neu verschluesseln mit dem eigenen Schluessel |
| `LEGACY_UPLOAD_DIR` | Verzeichnis der alten CV-PDFs (`/var/www/Projekt Scanner/backend/uploads`); die Dateien werden **kopiert**. Wegen des Leerzeichens im Pfad in der `.env` in Anführungszeichen setzen - systemd liest die Datei als `EnvironmentFile` und trennt sonst am Leerzeichen |
| `LEGACY_ENCRYPTION_KEY` | nur damit lassen sich die Portal-Passwörter übernehmen: entschlüsseln mit dem alten, neu verschlüsseln mit dem eigenen Schlüssel |
Uebernommen werden auch die Session-Cookies der Portale (Klartext-JSON in der
Übernommen werden auch die Session-Cookies der Portale (Klartext-JSON in der
DB) - der neue Scanner ist damit sofort scharf.
Agenten, Projekte und Treffer behalten ihre Ids, damit uebernommene Treffer
Agenten, Projekte und Treffer behalten ihre Ids, damit übernommene Treffer
weiter auf die richtigen Agenten und Projekte zeigen. Deshalb bricht das Skript
ab, wenn im Ziel schon Agenten stehen - `--force` raeumt vorher auf.
ab, wenn im Ziel schon Agenten stehen - `--force` räumt vorher auf.
> **Beim Rollout am 06.08.2026 festgestellt:** Die in der alten Datenbank
> gespeicherten Portal-Passwoerter lassen sich mit dem dortigen
> `ENCRYPTION_KEY` **nicht** mehr entschluesseln - der Schluessel wurde nach dem
> gespeicherten Portal-Passwörter lassen sich mit dem dortigen
> `ENCRYPTION_KEY` **nicht** mehr entschlüsseln - der Schlüssel wurde nach dem
> Speichern getauscht. Die alte App kann sie damit ebenso wenig lesen; es ging
> also nichts verloren. Wer den Notfall-Login des Scrapers will, gibt die
> Passwoerter unter *Boersen-Konten* neu ein. Die Sessions (Cookies) sind davon
> unberuehrt und wurden uebernommen.
> Passwörter unter *Börsen-Konten* neu ein. Die Sessions (Cookies) sind davon
> unberührt und wurden übernommen.
### Playwright
`playwright` ist auf **exakt 1.60.0** gepinnt - dieselbe Version wie die laufende
Scanner-App. Deren Chromium-Build (`chromium-1223`) liegt auf dem Server bereits
in `/root/.cache/ms-playwright`, also faellt kein zweiter Browser-Download an und
in `/root/.cache/ms-playwright`, also fällt kein zweiter Browser-Download an und
gescrapt wird mit der Version, die dort produktiv funktioniert. Ein `^` in der
Version wuerde beides aufgeben, deshalb steht in `server/package.json` ein
`comments`-Feld mit dieser Begruendung.
Version würde beides aufgeben, deshalb steht in `server/package.json` ein
`comments`-Feld mit dieser Begründung.
## Diktat (Whisper im Browser)
Ueberall, wo laengerer Text entsteht - Projektbeschreibung, Interaktionsnotiz,
naechster Schritt, interne Notizen - sitzt ein Mikrofon-Button. Die Erkennung
laeuft **vollstaendig im Browser des Benutzers**: Whisper als ONNX-Modell ueber
Überall, wo längerer Text entsteht - Projektbeschreibung, Interaktionsnotiz,
nächster Schritt, interne Notizen - sitzt ein Mikrofon-Button. Die Erkennung
läuft **vollständig im Browser des Benutzers**: Whisper als ONNX-Modell über
`@huggingface/transformers`, gerechnet auf der Grafikkarte (WebGPU) oder
ersatzweise auf der CPU (WASM).
@@ -366,8 +366,8 @@ Das Backend ist daran **nicht beteiligt** und sieht keine Audiodaten - es gibt
keinen API-Key, keine Kosten pro Minute und keinen Datenabfluss. Aufnahmen
werden nirgends gespeichert.
* Die Erkennung laeuft in einem **Web Worker** (`src/lib/whisper.worker.ts`),
damit die Oberflaeche waehrenddessen nicht einfriert.
* Die Erkennung läuft in einem **Web Worker** (`src/lib/whisper.worker.ts`),
damit die Oberfläche währenddessen nicht einfriert.
* Die **ONNX-Laufzeit** wird mit ausgeliefert (Vite bundelt die `.wasm`), die
**Modelldateien** kommen beim ersten Laden vom Hugging-Face-CDN und bleiben
danach im Browser-Cache. Wer auch das vermeiden will, kann die Modelle auf den
@@ -375,42 +375,42 @@ werden nirgends gespeichert.
### Reiter "Whisper"
Unten im Menue, ueber den Einstellungen, fuer **alle Rollen** sichtbar. Es gibt
Unten im Menü, über den Einstellungen, für **alle Rollen** sichtbar. Es gibt
dort **nichts zu konfigurieren** - genau ein Knopf:
1. Reiter oeffnen, dort steht *"Einmalig werden 299 MB Sprachmodell geladen."*
1. Reiter öffnen, dort steht *"Einmalig werden 299 MB Sprachmodell geladen."*
2. **Sprachmodell laden (299 MB)** klicken, Fortschrittsbalken abwarten
3. Fertig - ab jetzt sind die Mikrofon-Buttons in den Formularen aktiv, auch
nach jeder weiteren Anmeldung
Dazu gibt es eine Probeaufnahme und **Sprachmodell loeschen**, falls man den
Platz zurueckhaben will. Sonst nichts: keine Modellauswahl, keine Genauigkeit,
kein Rechenweg, keine Geraetetabelle.
Dazu gibt es eine Probeaufnahme und **Sprachmodell löschen**, falls man den
Platz zurückhaben will. Sonst nichts: keine Modellauswahl, keine Genauigkeit,
kein Rechenweg, keine Gerätetabelle.
Das ist Absicht. Modell, Genauigkeit, Rechenweg und Sprache stehen als
Konstanten in `src/lib/whisper.tsx` - `small`, `q4`, Grafikkarte wenn vorhanden,
Deutsch. Jede andere Kombination war in den Messungen entweder schlechter oder
kaputt (siehe unten); eine Auswahl haette also nur Gelegenheit geboten, etwas
kaputt (siehe unten); eine Auswahl hätte also nur Gelegenheit geboten, etwas
falsch zu machen.
Beim Anmelden wird das Modell automatisch bereitgestellt, **wenn** es schon in
diesem Browser liegt - ein Download von 759 MB startet nie von allein. Der
Automatismus greift bewusst nur einmal pro Seitenaufruf: wer das Modell loescht,
Automatismus greift bewusst nur einmal pro Seitenaufruf: wer das Modell löscht,
bekommt es nicht sofort wieder heruntergeladen.
### Wo das Modell liegt und wie lange
Nicht in Cookies - dort passen nur wenige Kilobyte. Die Dateien liegen im
**Cache-Speicher der Seite** (`caches`, Name `transformers-cache`), der
Sitzungen und Browserneustarts ueberlebt. Beim Laden bittet die App zusaetzlich
Sitzungen und Browserneustarts überlebt. Beim Laden bittet die App zusätzlich
per `navigator.storage.persist()` um dauerhaften Speicher; damit darf der Browser
die Dateien bei Platznot nicht mehr selbst wegraeumen. Chrome entscheidet darueber
nach eigenen Kriterien und lehnt haeufig ab - dann funktioniert alles genauso, nur
die Dateien bei Platznot nicht mehr selbst wegräumen. Chrome entscheidet darüber
nach eigenen Kriterien und lehnt häufig ab - dann funktioniert alles genauso, nur
ohne diese Zusicherung.
Geraet oder Browserprofil gewechselt? Dann faellt der Download erneut an; der
Speicher gehoert zum Profil, nicht zum Benutzerkonto. Findet die App das Modell
nach dem Laden nicht im Cache, sagt sie es ausdruecklich.
Gerät oder Browserprofil gewechselt? Dann fällt der Download erneut an; der
Speicher gehört zum Profil, nicht zum Benutzerkonto. Findet die App das Modell
nach dem Laden nicht im Cache, sagt sie es ausdrücklich.
### Warum small und q4 - die Messungen
@@ -424,17 +424,17 @@ eine echte Stimme) auf einem Rechner mit NVIDIA-GPU, jeweils in q4:
| small | "auf Esfihana", "Der Tagessatz liegt bei 1100 Euro" | 299 MB | 10 s |
| **large-v3-turbo** | **"auf S4 HANA"**, "Der Tagessatz liegt bei 1100 Euro" | 759 MB | 13 s |
Alle drei erkennen etwa 4x schneller als Echtzeit; auf Geraeten ohne WebGPU
Alle drei erkennen etwa 4x schneller als Echtzeit; auf Geräten ohne WebGPU
verschiebt sich das Tempo erheblich.
Festgelegt ist **large-v3-turbo**: nur dieses Modell bekommt Fachbegriffe wie
"S4 HANA" richtig, und genau daran scheitern die kleineren im Alltag. Die 759 MB
fallen einmal pro Browser an - danach liegt das Modell im Cache-Speicher der
Seite und ueberlebt Browserneustarts (gemessen: 762 MB, zweite Sitzung ohne
Seite und überlebt Browserneustarts (gemessen: 762 MB, zweite Sitzung ohne
jeden Download bereit).
`small` ist der Kompromiss: turbo erkennt besser, wird vom Browser aber nicht
zwischengespeichert (siehe Groessengrenzen unten) und laedt darum bei jedem Start
zwischengespeichert (siehe Größengrenzen unten) und lädt darum bei jedem Start
erneut 759 MB.
### Welche Genauigkeit auf welchem Rechenweg
@@ -443,41 +443,41 @@ Deshalb ist `q4` festgelegt:
| | q4 | q8 | fp16 | fp32 |
| --- | --- | --- | --- | --- |
| **WebGPU** | tiny 1,4 s · small 1,9 s · turbo 3,8 s | Kauderwelsch oder keine Antwort | tiny laeuft, small blockiert den Tab | Einzeldatei zu gross |
| **WASM** | laeuft | small 10,3 s | ungetestet | Einzeldatei zu gross |
| **WebGPU** | tiny 1,4 s · small 1,9 s · turbo 3,8 s | Kauderwelsch oder keine Antwort | tiny läuft, small blockiert den Tab | Einzeldatei zu groß |
| **WASM** | läuft | small 10,3 s | ungetestet | Einzeldatei zu groß |
Der wichtigste Grund fuer diese Beschraenkung: bei `small` in fp16 auf WebGPU
Der wichtigste Grund für diese Beschränkung: bei `small` in fp16 auf WebGPU
reagiert der ganze Browser-Tab nicht mehr - **kein Timeout in der Seite kann das
abfangen**, weil das Skript selbst nicht mehr laeuft.
abfangen**, weil das Skript selbst nicht mehr läuft.
Genau deshalb ist die Auswahl verschwunden: `q4` steht fest, und damit kann diese
Klasse von Fehlern nicht mehr auftreten. Als Netz bleibt trotzdem eine Frist pro
Erkennung und eine Haenger-Erkennung beim Laden - falls ein Geraet trotzdem etwas
Erkennung und eine Hänger-Erkennung beim Laden - falls ein Gerät trotzdem etwas
Unerwartetes tut.
Zusaetzlich hat jede Erkennung eine Frist (15x Aufnahmedauer, mindestens 45 s,
hoechstens 5 min). Laeuft sie ab, wird der Worker verworfen und der Fehler
Zusätzlich hat jede Erkennung eine Frist (15x Aufnahmedauer, mindestens 45 s,
höchstens 5 min). Läuft sie ab, wird der Worker verworfen und der Fehler
gemeldet, statt dauerhaft "Erkenne..." zu zeigen.
### Die Groessengrenze des Browsers
### Die Größengrenze des Browsers
Nicht die Gesamtgroesse entscheidet, sondern die **groesste Einzeldatei** - der
Browser muss sie in einem zusammenhaengenden Speicherblock halten. Ueber etwa
Nicht die Gesamtgröße entscheidet, sondern die **größte Einzeldatei** - der
Browser muss sie in einem zusammenhängenden Speicherblock halten. Über etwa
500 MB bricht das Laden mit "Array buffer allocation failed" ab. Belege: 425 MB
(turbo in q4) laden und laufen, 688 MB (turbo in fp32) scheitern.
Genau daran scheitern fuer turbo alle Genauigkeiten ausser q4 - ein weiterer
Genau daran scheitern für turbo alle Genauigkeiten ausser q4 - ein weiterer
Grund, warum es keine Auswahl gibt.
Die Downloadgroesse im Reiter kommt live von Hugging Face
Die Downloadgröße im Reiter kommt live von Hugging Face
(`src/lib/whisperModels.ts`), damit dort eine echte Zahl steht.
> **Korrektur:** In einer frueheren Fassung stand hier eine zweite Grenze von
> **Korrektur:** In einer früheren Fassung stand hier eine zweite Grenze von
> etwa 300 MB pro Cache-Eintrag, oberhalb derer das Modell angeblich nicht
> zwischengespeichert wird. Das war falsch - ein Artefakt der Testumgebung.
> Von Playwright gestartete Chrome-Instanzen schreiben den Cache-Speicher nicht,
> ein normal gestartetes Chrome dagegen sehr wohl: turbo/q4 liegt dort mit
> 762 MB im Cache und ueberlebt einen Browserneustart. Deshalb prueft
> 762 MB im Cache und überlebt einen Browserneustart. Deshalb prüft
> `scratchpad/realchrome.mjs` die Persistenz gegen ein normal gestartetes
> Chrome und nicht gegen den Automatisierungskontext.
@@ -490,59 +490,59 @@ Die Downloadgroesse im Reiter kommt live von Hugging Face
Solange nichts geladen ist, erscheint im Opportunity-Formular ein Hinweis mit
Knopf **Zu Whisper**; die einzelnen Mikrofon-Buttons werden zu *Diktat
einrichten*. Beides schliesst den Dialog und springt in den Reiter. Auf Geraeten
einrichten*. Beides schließt den Dialog und springt in den Reiter. Auf Geräten
ohne Mikrofon oder ohne HTTPS erscheint gar kein Diktat.
## Kalender
Ein gemeinsamer Teamkalender: alle Benutzer sehen dieselben Termine, jeder darf
Eintraege anlegen.
Einträge anlegen.
* **Ansichten** Monat, Woche, Tag; `Heute` und die Pfeile blaettern. Klick auf
* **Ansichten** Monat, Woche, Tag; `Heute` und die Pfeile blättern. Klick auf
einen Monatstag oder eine Stundenzelle legt direkt einen Termin an, Klick auf
einen Wochentagskopf springt in die Tagesansicht.
* **Farben** Jeder Termin hat die Farbe seines Erstellers. Die Farbe kommt aus
`crm_users.color`; ist sie leer, wird sie stabil aus der Benutzer-ID
abgeleitet (Palette in `src/types.ts`). Aenderbar unter *Einstellungen → Mein
Konto* und - fuer Admins fuer alle - in der Nutzerverwaltung. Die Legende
unter dem Kalender zeigt, welche Farbe zu wem gehoert.
abgeleitet (Palette in `src/types.ts`). Änderbar unter *Einstellungen → Mein
Konto* und - für Admins für alle - in der Nutzerverwaltung. Die Legende
unter dem Kalender zeigt, welche Farbe zu wem gehört.
* **Teilnehmer** Ein Termin kann beliebig viele Benutzer haben; sie erscheinen
mit Foto - oder, wenn keines hinterlegt ist, als Initiale auf ihrer
Kalenderfarbe - und sind anklickbar.
* **@-Mentions** In der Beschreibung `@` tippen, um Kontakte *und* Benutzer zu
markieren (Pfeiltasten, Enter/Tab zum Uebernehmen). Gespeichert wird ein Token
markieren (Pfeiltasten, Enter/Tab zum Übernehmen). Gespeichert wird ein Token
`@[Anzeigename](lead:12)` bzw. `@[Anzeigename](user:3)`. In der Leseansicht
sind Mentions klickbar: Kontakte oeffnen das Kontaktfenster, Benutzer ein
Detailfenster mit Farbe, naechsten Terminen und zugeordneten Kontakten.
sind Mentions klickbar: Kontakte öffnen das Kontaktfenster, Benutzer ein
Detailfenster mit Farbe, nächsten Terminen und zugeordneten Kontakten.
Existiert die Person nicht mehr, bleibt der Name als grauer Text stehen.
* **Naechste Termine** Rechts neben dem Kalender, nach Tagen gruppiert und
zeitlich sortiert - der naechste anstehende Termin steht oben. Vergangene
* **Nächste Termine** Rechts neben dem Kalender, nach Tagen gruppiert und
zeitlich sortiert - der nächste anstehende Termin steht oben. Vergangene
Termine fallen aus der Liste, bleiben im Kalender aber erhalten.
## Fotos
Fotos von Kontakten und Benutzern liegen als Data-URL in der Datenbank
(`crm_leads.avatar`, `crm_users.avatar`). Damit das die Nutzlast nicht aufblaeht,
(`crm_leads.avatar`, `crm_users.avatar`). Damit das die Nutzlast nicht aufbläht,
verkleinert der Browser jedes Bild **vor** dem Upload
(`src/lib/imageCompress.ts`):
* mittiger Ausschnitt auf ein Quadrat von 256 px - mehr braucht keine Ansicht,
die groesste ist 80 px
* WebP mit Qualitaet 0,82, Fallback JPEG, dann PNG
die größte ist 80 px
* WebP mit Qualität 0,82, Fallback JPEG, dann PNG
* aus einem 2,9-MB-Foto werden so einige Kilobyte
Das Backend akzeptiert nur Bild-Data-URLs und http(s)-Adressen und begrenzt die
Laenge; die Quelldatei darf bis 25 MB gross sein, weil sie ohnehin
Länge; die Quelldatei darf bis 25 MB groß sein, weil sie ohnehin
komprimiert wird.
Benutzerfotos pflegt jeder fuer sich unter *Einstellungen → Mein
Konto*, Admins fuer alle in der Nutzerverwaltung. Angezeigt werden sie in der
Benutzerfotos pflegt jeder für sich unter *Einstellungen → Mein
Konto*, Admins für alle in der Nutzerverwaltung. Angezeigt werden sie in der
Sidebar, bei Terminteilnehmern und Erstellern, im Benutzer-Detailfenster, in den
@-Vorschlaegen, in der Nutzerverwaltung und als Benutzerknoten im
Netzwerkdiagramm. Ohne Foto erscheint ueberall die Initiale auf der
@-Vorschlägen, in der Nutzerverwaltung und als Benutzerknoten im
Netzwerkdiagramm. Ohne Foto erscheint überall die Initiale auf der
Kalenderfarbe (`UserAvatar`).
Ueberlappende Termine teilen sich im Zeitraster die Spaltenbreite, mehrtaegige
Überlappende Termine teilen sich im Zeitraster die Spaltenbreite, mehrtägige
Termine erscheinen an jedem betroffenen Tag, und eine rote Linie markiert die
aktuelle Uhrzeit.
@@ -550,14 +550,14 @@ aktuelle Uhrzeit.
Kraftgerichtetes Layout (`d3-force`), gerendert als SVG.
* **Kreise** = Kontakte, Farbe nach Status, Groesse nach Anzahl Verbindungen
* **Kreise** = Kontakte, Farbe nach Status, Größe nach Anzahl Verbindungen
* **Quadrate** = Benutzer, die Kontakte angelegt haben
* **Gruene Linie** = Kontakt &harr; Kontakt (Dicke nach Verbindungsstaerke)
* **Grüne Linie** = Kontakt &harr; Kontakt (Dicke nach Verbindungsstärke)
* **Graue gestrichelte Linie** = Ersteller &rarr; Kontakt
Bedienung: Hintergrund ziehen verschiebt, Mausrad zoomt auf den Cursor,
Knoten ziehen fixiert ihn (gruener Punkt), Doppelklick loest die Fixierung.
Ueber *Verbindung erstellen* zwei Kontakte anklicken, um eine Kante anzulegen;
Knoten ziehen fixiert ihn (grüner Punkt), Doppelklick löst die Fixierung.
Über *Verbindung erstellen* zwei Kontakte anklicken, um eine Kante anzulegen;
Esc bricht ab. Die Ansicht respektiert Suche und Statusfilter.
## Lokale Entwicklung
@@ -569,7 +569,7 @@ npm start # Backend :5006
cd .. && npm run dev # Frontend :3100, /api wird geproxied
```
Fuer lokale Entwicklung braucht es eine erreichbare MariaDB mit der Datenbank
Für lokale Entwicklung braucht es eine erreichbare MariaDB mit der Datenbank
`perinco_CRM`; das Schema legt das Backend beim Start selbst an. Die
Scanner-Datenbank `perinco_scanner` legt es komplett selbst an, inklusive
Tabellen, Portalen und Standardeinstellungen.
@@ -611,19 +611,19 @@ Abschnitt *Playwright* oben.
> **Reihenfolge beachten:** Der Import schreibt `search_interval` aus der alten
> App (15 Minuten) in die Einstellungen. Der laufende Scheduler bemerkt die
> Aenderung binnen einer Minute und plant sich neu - beim Rollout hat er deshalb
> Änderung binnen einer Minute und plant sich neu - beim Rollout hat er deshalb
> direkt nach dem Import einen Scan gestartet, den der folgende Restart mitten im
> Scrapen abgeschossen hat (`Target page ... has been closed` im Log, harmlos).
> Wer das vermeiden will, macht Schritt 3 vor Schritt 2.
Das hoehere `MemoryMax` (1536M statt 512M) ist kein Luxus: ein Scan-Lauf startet
Chromium als Kindprozess in derselben cgroup, mit 512M wuerde der OOM-Killer das
Backend mitten im Scrapen beenden. `deploy.ps1` laedt die Unit nicht mit hoch -
Das höhere `MemoryMax` (1536M statt 512M) ist kein Luxus: ein Scan-Lauf startet
Chromium als Kindprozess in derselben cgroup, mit 512M würde der OOM-Killer das
Backend mitten im Scrapen beenden. `deploy.ps1` lädt die Unit nicht mit hoch -
daher der `cp` oben.
> **RAM im Parallelbetrieb:** Im schlechtesten Fall scrapen beide Scanner
> gleichzeitig, also laufen zwei Chromium-Instanzen. `SCANNER_SCHEDULER_OFFSET`
> verschiebt die Laeufe gegeneinander, garantiert aber nichts - der alte Dienst
> verschiebt die Läufe gegeneinander, garantiert aber nichts - der alte Dienst
> hat seinen eigenen Zeitplan. Auf einem Server mit knappem RAM und vollem Swap
> ist das der Punkt, den man nach dem ersten Tag im Journal nachsieht.
@@ -665,4 +665,4 @@ in `deploy/pcrm.perinco.de.conf` und den certbot-Aufruf anpassen.
Beim ersten Backend-Start wird ein Administrator aus den `ADMIN_*`-Variablen in
`server/.env` angelegt - aber nur, wenn `crm_users` leer ist. Das Passwort ist
danach ausschliesslich ueber die Nutzerverwaltung bzw. *Mein Konto* aenderbar.
danach ausschließlich über die Nutzerverwaltung bzw. *Mein Konto* änderbar.