Globale Suche (Volltext + erweiterte Suche)

This commit is contained in:
2026-09-14 16:06:45 +02:00
parent a50f5cb4a6
commit 18f35657c0
+125
View File
@@ -0,0 +1,125 @@
# 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 O as OpenAI
B->>E: POST /api/search/ai { question }
E->>M: Bestand lesen (Kontakte, Opportunities, Akquise, Termine)
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.
Drei 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.
* `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 → Scanner*, 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: `gpt-4o-mini`, wie im Scanner. 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 des Scanners |
| `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.