feat(scanner): Funde mit Akquise und Opportunities abgleichen

Neu gefundene Projekte mit Treffer werden einmalig per gpt-5.6-luna mit den
Akquise-Eintraegen und Opportunities der letzten N Tage (Vorgabe 14)
verglichen. Passt ein Eintrag, zeigt der Scanner "bereits beworben!" samt
Begruendung. Schalter und Zeitraum (Tage/Wochen) in Einstellungen -> Scanner,
Neupruefung von Hand im Detail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sascha Wolf
2026-09-17 17:12:17 +02:00
co-authored by Claude Opus 5
parent 87ca52a34b
commit af3ca33743
15 changed files with 549 additions and 10 deletions
+3
View File
@@ -332,6 +332,9 @@ erDiagram
varchar rate
varchar url
datetime published_at
tinyint already_applied "in Akquise/Opportunities gefunden"
text applied_refs "JSON: type, id, title, consultant, reason"
datetime applied_check_at "NULL = noch nicht abgeglichen"
timestamp created_at "gescrapt am, Aufbewahrung 7 Tage"
}
matches {
+71 -1
View File
@@ -40,12 +40,81 @@ sequenceDiagram
P->>M: E-Mail an Agent oder Standardempfaenger
end
end
opt Abgleich aktiv, Projekt hat Treffer, noch nicht geprüft
P->>P: Akquise und Opportunities der letzten N Tage laden
P->>AI: gpt-5.6-luna: Projekt vs. CRM-Einträge
AI-->>P: { matches: [{ type, id, reason }] }
P->>DB: already_applied, applied_refs, applied_check_at
end
```
Zwei Sparmassnahmen stecken bewusst **vor** dem OpenAI-Aufruf: der vorhandene
Treffer (Unique-Key `agent_id, project_id`) und die Ausschlussbegriffe. Ein
Projekt, das „Arbeitnehmerüberlassung" enthält, kostet damit keinen Cent.
## Abgleich „bereits beworben!"
Dieselbe Ausschreibung kommt oft mehrfach: über zwei Börsen, über mehrere
Vermittler, mit anderem Titel und anderem Link. Damit niemand ein Projekt ein
zweites Mal anbietet, gleicht `services/appliedCheck.js` jeden Fund mit der
**Akquise-Liste** (`crm_acquisitions`) und den **Opportunities**
(`crm_opportunities`) ab. Steht er dort schon, trägt der Treffer im Dashboard,
unter *Unpassend* und im Detail die Markierung **bereits beworben!**; das Detail
nennt die passenden Einträge samt Begründung.
```mermaid
flowchart TD
A["Projekt im Lauf verarbeitet"] --> B{"applied_check_active?"}
B -->|nein| X["nichts tun"]
B -->|ja| C{"schon geprüft?<br/>applied_check_at"}
C -->|ja| X
C -->|nein| D{"mindestens ein Treffer?"}
D -->|nein| X
D -->|ja| E["Kandidaten laden:<br/>Akquise found_at, Opportunities created_at<br/>der letzten N Tage, ohne Papierkorb"]
E --> F{"Kandidaten?"}
F -->|nein| N["nicht beworben"]
F -->|ja| G{"gleicher Link?"}
G -->|ja| Y["bereits beworben"]
G -->|nein| H["gpt-5.6-luna, reasoning_effort low"]
H -->|Treffer mit bekannter Id| Y
H -->|keine| N
N --> S["am Projekt speichern"]
Y --> S
```
| Einstellung | Vorgabe | Bedeutung |
| --- | --- | --- |
| `applied_check_active` | `true` | Schalter in *Einstellungen → Scanner* |
| `applied_check_days` | `14` | Zeitraum zurück, in Tagen (Oberfläche: Tage oder Wochen), höchstens 365 |
Entscheidungen dahinter:
- **Eigenes Modell.** Die Bewertung CV gegen Projekt bleibt bei `gpt-4o-mini`;
der Abgleich nutzt `gpt-5.6-luna` (Konstante `APPLIED_CHECK_MODEL`). Er muss
umformulierte Ausschreibungen als dieselbe erkennen und ähnliche Rollen bei
anderem Kunden auseinanderhalten - das ist ein Urteil, kein Textvergleich.
Der Schlüssel ist derselbe `openai_api_key`.
- **Einmal je Projekt, nur mit Treffer.** Die Börsen liefern bei jedem Lauf
dieselben Projekte erneut; `applied_check_at` verhindert, dass sie jedes Mal
bezahlt werden. Projekte ohne Treffer zeigt die Oberfläche nie an.
- **Gleicher Link braucht keine KI.** Verglichen wird ohne Protokoll, `www`,
Query und Schrägstrich am Ende.
- **Nur Ids, die wir geschickt haben.** Nennt das Modell eine unbekannte Id,
zählt sie nicht.
- **Fehler setzen aus, nicht ab.** Scheitert der Aufruf (Schlüssel, Quote,
Netz), bleibt der Abgleich für den Rest des Laufs aus und das Projekt
ungeprüft - der nächste Lauf versucht es wieder. Die Bewertung läuft davon
unberührt weiter.
- **Momentaufnahme.** Geprüft wird beim Fund. Wird die Bewerbung erst danach
in der Akquise erfasst, holt *Auf Bewerbung prüfen* im Detail den Abgleich
von Hand nach (`POST /projects/:id/applied-check`, auch bei abgeschaltetem
Automatismus).
Die drei Spalten an `projects` legt `initScannerDatabase()` per
`ALTER TABLE ... ADD COLUMN IF NOT EXISTS` an - `sync()` ohne `alter` tut das in
bestehenden Tabellen nicht.
## Wann läuft er
```mermaid
@@ -215,7 +284,7 @@ Zertifikatswarnung. Der DNS-Eintrag muss beim Anbieter gelöscht werden.
| Unpassend | `ScannerRejected.tsx` | Treffer mit Status `rejected` |
| Suchagenten | `ScannerAgents.tsx` | Beraterprofile, CV-Upload |
| Börsen-Konten | `ScannerPortals.tsx` | Verbindungsstatus, Zugangsdaten, Cookie-Import |
| Einstellungen | `settings/ScannerSettings.tsx` | Intervall, Schwellenwerte, OpenAI, Ton, E-Mail |
| Einstellungen | `settings/ScannerSettings.tsx` | Intervall, Schwellenwerte, Abgleich mit Akquise/Opportunities, OpenAI, Ton, E-Mail |
**Wichtig zum Ton:** Er hängt an der Dashboard-Komponente. Wer auf einen anderen
Menüpunkt wechselt, hört nichts - die Komponente wird ausgebaut und die
@@ -240,6 +309,7 @@ Alle unter `/api/scanner`, alle hinter `requireAuth`:
| POST | `/portals/test-connection/:id` | Mini-Scrape bzw. Session-Prüfung |
| GET | `/matches` | Filter: `status`, `agentId`, `minPercentage`, `limit` |
| PUT/DELETE | `/matches/:id` | Status setzen, löschen |
| POST | `/projects/:id/applied-check` | Abgleich „bereits beworben!" von Hand wiederholen |
| GET/POST | `/settings` | Geheimnisse maskiert |
| POST | `/scan` | Lauf starten, `409` wenn schon einer läuft |
| GET | `/status` | läuft ein Lauf, Scheduler an, Datenbankname |
+16
View File
@@ -819,6 +819,17 @@ wissen:
3. **Ist der Tab geschlossen, passiert nichts.** Wer unabhängig vom Browser
benachrichtigt werden will, schaltet die **E-Mail** in den Einstellungen ein.
### „bereits beworben!"
Jeder neue Treffer wird mit der **Akquise-Liste** und den **Opportunities**
abgeglichen. Steht das Projekt dort schon - auch unter anderem Titel oder von
einer anderen Börse -, trägt es in der Liste die rote Markierung **bereits
beworben!**. Im Detail steht, welcher Eintrag passt, für welchen Berater und
warum die KI ihn für dasselbe Projekt hält.
Geprüft wird einmal, beim Fund. Wer die Bewerbung erst danach in der Akquise
einträgt, klickt im Detail auf **Auf Bewerbung prüfen**.
### Unpassend
Alles, was über *Passt nicht* aussortiert wurde. Von hier bringt *Zurück ins
@@ -900,6 +911,11 @@ nicht mehr: kein Lauf im Hintergrund, und *Jetzt scannen* antwortet mit einem
Hinweis statt zu starten. Der Zustand steht auch oben im Scanner-Dashboard, damit
man nicht rätselt, warum keine neuen Treffer kommen.
Unter **Abgleich mit Akquise und Opportunities** lässt sich die Markierung
*bereits beworben!* ein- und ausschalten. **Zeitraum zurück** bestimmt, wie alt
ein Akquise-Eintrag oder eine Opportunity höchstens sein darf, um verglichen zu
werden - in Tagen oder Wochen, Vorgabe zwei Wochen.
Im Reiter **Postfach** steht oben, ob die Verbindung zu Microsoft 365 steht.
**Verbindung testen** prüft beides: die Anmeldung *und* den tatsächlichen
Zugriff auf `crm@perinco.de`. Schlägt etwas fehl, nennt die Meldung den
+1
View File
@@ -229,6 +229,7 @@ Die portierte Scanner-API unter `/api/scanner`, vollständig in
| `models/Setting.js` | Schlüssel-Wert mit `getVal`/`setVal` |
| `services/scraper.js` | Playwright je Portal; unverändert aus der alten App |
| `services/matchingEngine.js` | OpenAI `gpt-4o-mini`, `response_format: json_object`, `temperature: 0.1` |
| `services/appliedCheck.js` | Abgleich mit Akquise/Opportunities, OpenAI `gpt-5.6-luna`, Ergebnis an `projects` |
| `services/pipeline.js` | ein Lauf: scrapen, speichern, bewerten, benachrichtigen |
| `services/scheduler.js` | node-cron jede Minute, Zeitfenster, Sperrfrist, `last_scan_at` |
| `services/cleanup.js` | Projekte älter als 7 Tage löschen (Treffer kaskadieren) |