"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>
152 lines
6.5 KiB
Markdown
152 lines
6.5 KiB
Markdown
# 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.
|