Files
Sascha WolfandClaude Opus 5.5 69001e9687 feat(scanner): Schwellen im Dashboard pro Benutzer
"Anzeige ab %" und "Hinweis ab %" liegen jetzt am CRM-Benutzer
(crm_users.scanner_display_threshold/scanner_match_threshold) statt global
in den Scanner-Einstellungen. NULL = globaler Standard. Die Mail-Schwelle
bleibt global, die KI-Suche nimmt die Anzeigeschwelle des Suchenden.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 08:50:40 +01:00

152 lines
6.5 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 12 Globale Suche
Ein Feld oben in der Kopfleiste, zwei Modi. Übernommen aus dem ICON CRM –
gleiche Gesten, gleicher Aufbau, damit niemand zweimal lernen muss, wo er
hinfassen soll.
`Strg+K` (bzw. `Cmd+K`) springt von überall dorthin, `Esc` leert das Feld.
| Modus | Symbol | Verhalten |
| --- | --- | --- |
| Volltext | Lupe | läuft beim Tippen mit (220 ms Verzögerung), Treffer nach Bereichen gruppiert |
| Erweitert | Funke | eine Frage in Worten, beantwortet vom Modell – erst auf Enter |
## Was durchsucht wird
```mermaid
flowchart LR
Q["Suchfeld"] --> API["GET /api/search?q="]
API --> L[("crm_leads")]
API --> O[("crm_opportunities")]
API --> A[("crm_acquisitions")]
API --> E[("crm_events")]
API --> IC["ICON CRM<br/>fetchIconContacts()"]
```
| Gruppe | Quelle | Felder |
| --- | --- | --- |
| Kontakte | `crm_leads` | Name, Firma, E-Mail, Telefon, Mobil, Position, Tags, Notizen, Ort |
| Kontakte (ICON CRM) | Schnittstelle | Name, Position, Firma, E-Mail, Telefon, Tags |
| Opportunities | `crm_opportunities` | Titel, Firma, Beschreibung, Notizen, Tags, nächster Schritt |
| Akquise | `crm_acquisitions` | Titel, Firma, Beschreibung, Notizen, Tags, Absender, Anbieter, Berater |
| Termine | `crm_events` | Titel, Beschreibung, Ort |
Gelöschtes (`deleted_at`) bleibt außen vor. Kontakte aus dem Vorbereich „Aus
Mails" (`mail_pool = 1`) erscheinen mit, aber hinter den gepflegten und mit dem
Hinweis „aus dem Postfach" – sie sind oft unvollständig.
Bewusst `LIKE` statt Volltextindex: die Datenmengen sind klein, und ein
`FULLTEXT`-Index hülfe bei Teilwortsuche („informat" soll „Finanz Informatik"
finden) nicht.
Die Kontakte des ICON CRM liegen nicht in dieser Datenbank; sie kommen über die
Schnittstelle (`fetchIconContacts()`, eine Minute Zwischenspeicher) und werden
im Speicher gefiltert. Fällt das andere Haus aus, fehlt nur diese Gruppe – die
übrige Suche bleibt benutzbar. Ihre Treffer sind **gold umrandet** und öffnen
nichts: gepflegt werden sie drüben.
## Was ein Treffer öffnet
| Gruppe | Klick führt zu |
| --- | --- |
| Kontakte | die Kontaktkarte (`goTo('Kontakte', { leadId })`) |
| Opportunities | die Karte der Opportunity (`goTo('Opportunities', { opportunityId })`) |
| Akquise | die Akquise-Ansicht |
| Termine | die Interviews-Ansicht |
| Kontakte (ICON CRM) | nichts – nur Anzeige |
Für Kontakt und Opportunity gibt es einen Direktaufruf: `navigation.goTo()`
nimmt dafür `leadId` bzw. `opportunityId` entgegen. Akquise und Termine kennen
keinen – dort wechselt der Treffer in die Ansicht.
## Erweiterte Suche
```mermaid
sequenceDiagram
actor B as Benutzer
participant E as Express
participant M as MariaDB
participant S as perinco_scanner
participant O as OpenAI
B->>E: POST /api/search/ai { question }
E->>M: Bestand lesen (Kontakte, Opportunities, Akquise, Termine)
E->>S: Scanner-Treffer lesen (ab Anzeigeschwelle)
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.
Die genannten Datensätze kommen als `refs` zurück und stehen unter der Antwort
als anklickbare Schaltflächen. Ein Scanner-Treffer (`type: "scanner"`) führt in
die Scanner-Ansicht.
### Scanner-Treffer
Der Scanner hat eine **eigene Datenbank** (`perinco_scanner`, siehe
`server/src/scanner/db/config.js`). Bis Oktober 2026 las die erweiterte Suche
nur die `crm_*`-Tabellen und antwortete auf Fragen zum Scanner deshalb mit
„Dazu steht nichts im Bestand." Seitdem holt `loadScannerMatches()` die
Treffer dazu und gibt sie als `scanner_matches` mit:
| Feld | Herkunft |
| --- | --- |
| `id`, `match_percentage`, `status`, `found_at` | `matches` |
| `agent` | `agents.name` |
| `project_title`, `company`, `portal`, `location`, `rate`, `published_at`, `already_applied`, `url` | `projects` |
Mitgegeben wird nur, was auch die Scanner-Ansicht zeigt: Treffer ab der
Anzeigeschwelle des suchenden Benutzers (`crm_users.scanner_display_threshold`,
sonst der globale `display_threshold`), die neuesten zuerst, höchstens 400. Die
Projektbeschreibung bleibt draußen – sie ist lang und würde den Kontext
sprengen. Ist die Scanner-Datenbank nicht erreichbar, fehlt nur dieser Teil.
Vier Regeln stehen ausdrücklich im Systemprompt, weil die Antworten sonst falsch
werden:
* `daily_rate` ist ein **Tagessatz**; `null` heißt *nicht hinterlegt*, nicht
*null Euro*. Verglichen wird nur, wo ein Wert steht.
* Kontakte mit `mail_pool` sind noch nicht gepflegt – sie zählen mit, werden
aber als aus dem Postfach stammend benannt.
* `scanner_matches` sind Treffer des Projekt-Scanners; Bezug in `refs` mit
`type: "scanner"`.
* `icon_contacts` sind Kontakte des ICON CRM. Ihre Kennung ist eine Zeichenkette
(`icon-31`) und gehört **nicht** in `refs`.
## Schlüssel
Die erweiterte Suche benutzt **denselben OpenAI-Schlüssel wie der Scanner** –
`openai_api_key` in der Scanner-Tabelle `settings`, gepflegt unter
*Einstellungen → KI* (siehe [14 KI](14-ki.md)), ersatzweise `OPENAI_API_KEY` aus der Umgebung. Ein
zweiter Schlüssel an zweiter Stelle wäre eine Quelle für den Fall „die eine
Funktion geht, die andere nicht, und keiner weiß warum".
Modell: einstellbar unter *Einstellungen → KI*, Vorgabe `gpt-4o-mini`. Fehlt der Schlüssel, antwortet der
Endpunkt mit `503` und dem Hinweis auf die Einstellungen; die Volltextsuche
bleibt davon unberührt.
Der Aufruf liegt in `server/src/ai.js` – bewusst ohne das `openai`-Paket:
gebraucht wird ein einziger Aufruf, und `fetch` ist in Node eingebaut. Der
Scanner behält sein SDK, dort hängt mehr daran.
## Dateien
| Datei | Aufgabe |
| --- | --- |
| `server/src/routes/search.js` | beide Endpunkte |
| `server/src/ai.js` | OpenAI-Aufruf, Schlüssel, Modellwahl |
| `server/src/routes/ai.js` | Einstellungen → KI (siehe [14 KI](14-ki.md)) |
| `server/src/routes/iconCrm.js` | `fetchIconContacts()` samt Zwischenspeicher |
| `src/components/GlobalSearch.tsx` | das Feld, beide Modi, die Trefferliste |
| `src/components/Header.tsx` | bindet es mittig in die Kopfleiste ein |
Auf schmalen Fenstern (< 768 px) wird das Feld ausgeblendet – dort ist der Titel
der Ansicht wichtiger.