# 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
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.