feat(projekt-matching): auto-tag created opportunities, add German docs (S68)

Every opportunity created by the workflow now carries the CTag
"Auto: Durch Suchagent-Treffer erstellt" (id 6a83292a186dce7b2), set via
cTagsIds directly in the create POST. The tag id is resolved at runtime
by name (EspoClient.ensure_ctag, same pattern as team_id; self-healing
after a CRM rebuild); the read-back verification now also checks the tag.
No backfill: existing opportunities and the saved "Via cowork-api
erstellt" list filter are untouched.

Live-verified end to end: synthetic trigger mail, opportunity
6a832b8f29cc86bdf created with cTagsNames containing the tag (test
artifact removed afterwards). 65/65 pytest.

Also adds the complete German technical documentation of the workflow
(docs/projekt-matching-dokumentation.md, sections 1-13 + S68 appendix).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
tlg
2026-08-17 18:09:06 +02:00
parent 58b64c6d5f
commit 86bd7f6a07
5 changed files with 436 additions and 2 deletions

View File

@@ -0,0 +1,382 @@
# Projekt-Matching, technische Dokumentation
Stand: 2026-08-17. Erhoben aus der laufenden Konfiguration (Langflow-API, EspoCRM-API, Langfuse-API, Podman, systemd, Quellcode in `/home/lwc/bin`). Diese Datei dokumentiert den automatisierten Langflow-Workflow, der aus Suchagenten-E-Mails von freelancermap.de bewertete Verkaufschancen (`Opportunity`) im EspoCRM anlegt, einschließlich der am 2026-08-17 ergänzten Tag-Markierung (Abschnitt "Erweiterung um den Tag (S68)").
Ergänzende Unterlagen: `docs/projekt-matching-user-manual.md` (Bedienung), `docs/projekt-matching-design.md` (Designentscheidungen und Vorfallhistorie), Spezifikation und Plan unter `docs/superpowers/`.
---
## 1. Einordnung und Zweck
Der Workflow ersetzt das frühere interaktive Vorgehen mit dem Claude-Skill `projekt-anlegen`: Projektausschreibungen aus freelancermap-Suchagenten-Mails manuell sichten, Anforderungen gegen den Lebenslauf von Dr.-Ing. Thomas Langer prüfen und passende Projekte als Verkaufschance im EspoCRM erfassen. Das erledigt jetzt eine vollautomatische Pipeline: Postfach abrufen, Projekte aus der Mail extrahieren, Projektseite laden, Anforderungen per lokalem LLM extrahieren und gegen den Lebenslauf bewerten, Match-Werte deterministisch in Python berechnen, bei Must-have-Match über der Schwelle (85 Prozent) Verkaufschance samt Firma und Kontakt anlegen und eine Benachrichtigungsmail versenden. Kein Inhalt verlässt den Rechner, das LLM läuft lokal.
- **In Betrieb seit:** 2026-07-09 (Produktiv-Abnahme Gate 3, Merge-Commit `72a3b17`). Robustheits-Fixes am 2026-07-14 (Commit `36eac66`).
- **Auslösefrequenz:** Der Timer feuert alle 5 Minuten (288 Ticks pro Tag), die meisten Ticks sind Leerläufe.
- **Mittlere Last** (Zeitraum 2026-07-09 bis 2026-08-17, rund 39 Tage, Quellen: Langfuse-Traces `projekt-match`, IMAP-Trash, EspoCRM):
- verarbeitete Trigger-Mails: 1709 Mails im Trash, im Mittel rund 44 pro Tag
- bewertete Projekte: 2878 Traces gesamt, im Mittel rund 74 pro Tag
- davon `created` (Verkaufschance angelegt): 270, im Mittel rund 7 pro Tag
- davon `rejected` (Match unter Schwelle): 2559, `failed`: 49
- Im CRM existieren aktuell 66 vom API-Benutzer `cowork-api` angelegte Verkaufschancen (inklusive Skill-Ära ab 2026-06-06). Die Differenz zu den 270 `created`-Traces erklärt sich dadurch, dass gelöschte Verkaufschancen aus der Dublettenprüfung herausfallen und dasselbe Projekt bei erneutem Suchagenten-Treffer wieder angelegt wird (siehe Abschnitte 10 und 12), sowie durch manuelle Bereinigung im CRM. Eine genauere Aufschlüsselung ist aus den vorhandenen Daten nicht ermittelbar.
## 2. Betriebsumgebung
| Punkt | Wert (verifiziert am 2026-08-17) |
|---|---|
| Host | `destengssv006.biberg` (Linux 6.1, Debian) |
| System-User | `lwc` (uid 1007, `Linger=yes`, alles läuft rootless) |
| Laufzeitform | Rootless-Podman 4.3.1, Pod `langflow_pod`, Container `langflow_ctr` |
| Image / Version | `docker.io/langflowai/langflow:1.10.0`, Langflow 1.10.0 (per `/api/v1/version` bestätigt) |
| Ports | Pod publiziert `127.0.0.1:8090` auf Langflow (Container-Port 7860) und `127.0.0.1:8091` auf Langfuse (Container-Port 3000) |
| Reverse-Proxy / URL | Kein Reverse-Proxy. Langflow ist nur lokal unter `http://127.0.0.1:8090` erreichbar, es gibt keine externe URL. |
| Start / Autostart | systemd-User-Units, generiert mit `podman generate systemd --new`: `pod-langflow_pod.service` (enabled) zieht die `container-*.service`-Units nach. Durch `Linger=yes` startet alles beim Boot ohne Login. Neustart immer über `systemctl --user restart container-langflow_ctr.service`, nie über `podman restart` (die `--new`-Unit kann den Container sonst im Zustand "removed" zurücklassen). |
| Persistente Daten | `~/.local/share/langflow_pod/`: `langflow-data/` (Bind-Mount auf `/app/langflow` im Container: Paket `projektmatch/`, `vorgaben/` mit Lebenslauf und Rahmenbedingungen, Langflow-Config und -Log, `flow-exports/`), `postgres-data/` (PostgreSQL 17.10 mit der Langflow-Datenbank, darin Flows, Variablen, API-Keys, sowie der Langfuse-Datenbank), dazu ClickHouse-, MinIO- und Redis-Verzeichnisse für Langfuse |
| Flow-Definitionen | Liegen in der Langflow-Datenbank (Postgres). Quelle der Wahrheit ist aber der programmatische Builder `projekt-matching/deploy/build_flows.py` im Git-Repo, die Langflow-Oberfläche ist ausdrücklich nicht die Quelle der Wahrheit. |
| Weitere Container im Pod | `langfuse-web_ctr` und `langfuse-worker_ctr` (Langfuse 3.195.0), `postgres_ctr` (17.10), `clickhouse_ctr` (25.11.9.34), `redis_ctr` (7.4.9), `minio_ctr` |
| LLM-Dienst | vLLM auf dem Host, Port 8081, aus dem Container erreichbar als `http://host.containers.internal:8081/v1` |
| Pod-Aufbau | `/home/lwc/bin/create_pod_langflow.sh` (idempotent, erzeugt Pod, Container, systemd-Units) |
| Backup | Kein automatisiertes Backup gefunden: keine Crontabs für `lwc`, keine Backup-Units, keine Backup-Skripte im Repo. Die Wiederherstellung stützt sich auf den reproduzierbaren Aufbau aus Git plus Secrets (Abschnitt 13). Historische Daten (Langfuse-Traces, Flow-Laufhistorie) sind damit nicht gesichert. |
## 3. Ablageort und Identität des Flows
Der Workflow besteht aus zwei Flows:
| | Flow 1 | Flow 2 |
|---|---|---|
| Name in Langflow | `PM Ingest` | `PM Projekt bewerten` |
| Flow-ID | `b553b5f1-6fba-47dc-961e-020ca3a54486` | `0bc8e0d9-8c7d-42fb-ba1a-db4e98187268` |
| Endpoint-Name | `pm-ingest` | `pm-flow2` |
| Projekt/Ordner in Langflow | `Starter Project` (Standardordner, ID `b3cec7b1-696f-429b-9770-da773059e895`) | ebenso |
- Die Flow-IDs stehen zusätzlich in `~/.config/projekt-matching/env` (chmod 600), die Flow-2-ID auch in der Langflow-Variable `PM_FLOW2_ID`.
- Im Dateisystem und in Git liegt keine statische Flow-JSON-Definition, die Flows werden von `projekt-matching/deploy/build_flows.py` per API aus den Komponenten-Quelldateien `projekt-matching/components/pm_*.py` erzeugt. Die eigentliche Logik liegt im Python-Paket `projekt-matching/projektmatch/`, das nach `~/.local/share/langflow_pod/langflow-data/projektmatch/` deployt wird (im Container via `PYTHONPATH=/app/langflow` importierbar).
- JSON-Exporte beider Flows (vorher/nachher zur Tag-Erweiterung) liegen unter `~/.local/share/langflow_pod/langflow-data/flow-exports/`, siehe Abschnitt "Erweiterung um den Tag (S68)".
## 4. Auslöser
Die Auslösung ist zweistufig: ein externer systemd-Timer stößt per Webhook den Langflow-Flow an, der Flow pollt dann selbst das Postfach per IMAP. Der Langflow-eigene Scheduler wird nicht verwendet.
- **Timer:** systemd-User-Unit `projekt-matching.timer` (enabled, aktiv), `OnCalendar=*:00/5`, `Persistent=false`. Startet `projekt-matching.service` (Type=oneshot), diese ruft `~/bin/projekt-matching/deploy/trigger_webhook.sh` auf.
- **Trigger-Definition (wörtlich):** `curl -sS -m 15 -X POST "$FLOW1_WEBHOOK" -H "x-api-key: $LANGFLOW_API_KEY" -H 'Content-Type: application/json' -d '{"source": "systemd-timer"}'`, mit `FLOW1_WEBHOOK=http://127.0.0.1:8090/api/v1/webhook/pm-ingest` aus `~/.config/projekt-matching/env`. Fehler des Curl-Aufrufs sind folgenlos, der Zustand liegt im IMAP-Postfach, der nächste Tick holt alles nach.
- **Postfach:** `chancen@destengs.com` auf dem eigenen Mailserver `mail.destengs.com` (Dovecot), IMAP mit SSL auf Port 993, SMTP mit STARTTLS auf Port 587. Zugangsdaten: Passwort in der Langflow-Credential-Variable `PM_IMAP_PASSWORD` und in `~/.config/projekt-matching/secrets.env` (chmod 600).
- **Abfrageintervall:** alle 5 Minuten (Timer-Takt). Ein `flock`-Lock (`/app/langflow/projekt-matching.lock`) verhindert überlappende Läufe, ein zweiter Tick während eines laufenden Polls beendet sich sofort mit `{"skipped": "locked"}`.
- **Filterkriterien:** Es wird die INBOX per `UID SEARCH UNKEYWORD $ProjektChecked` abgefragt, also alle Mails ohne das benutzerdefinierte IMAP-Keyword. Kein Absender- oder Betrefffilter für die Auswahl; eigene Benachrichtigungen werden am Betreffpräfix `[Projekt-Match]` beziehungsweise `[Projekt-Match-Fehler]` erkannt und nur markiert. Projekt-Mails werden am Inhalt erkannt: Links auf `freelancermap.de/nproj/...` oder `.../projekt/...`.
- **Umgang mit verarbeiteten Nachrichten:** Trigger-Mails mit Projekten landen nach der Verarbeitung immer im Server-Ordner `Trash` (UID MOVE, Fallback Copy+Delete). Nicht-Projekt-Mails und unlesbare Mails erhalten einmalig das Keyword `$ProjektChecked` und bleiben unangetastet in der INBOX. Es gibt keine State-Datei und keine eigene Datenbank, der Zustand lebt vollständig im Postfach. Schlägt das Verschieben in den Trash fehl, wird ersatzweise das Keyword gesetzt, damit die Mail nie erneut verarbeitet wird (Terminal-Zustands-Regel).
## 5. Knotenkette
Beide Flows sind lineare Ketten ohne Verzweigungen auf Flow-Ebene. Bedingte Pfade existieren nur innerhalb der Komponenten über die Zustandsmaschine im `ctx`-Dictionary: `status` ist `ok`, `failed`, `rejected` oder `created`. `run_stage()` fängt Ausnahmen in `status=failed`, alle Folgestufen außer Notify reichen `failed` nur noch durch, `rejected` überspringt CRM und Mailversand, Notify läuft immer (postet in jedem Fall den Langfuse-Trace).
```mermaid
flowchart LR
subgraph Flow1 ["Flow 1: PM Ingest (b553b5f1)"]
W[Webhook] --> I["PM Ingest (PMIngest)"] --> O1[Text Output]
end
subgraph Flow2 ["Flow 2: PM Projekt bewerten (0bc8e0d9)"]
T[Text Input] --> F["PM 1 Fetch"] --> E["PM 2 Extract"] --> M["PM 3 Match CV"] --> R["PM 4 Rules+Gate"] --> C["PM 5 CRM"] --> N["PM 6 Notify"] --> O2[Text Output]
end
I -. "POST /api/v1/run/<flow2-id> je neuem Projekt" .-> T
```
**Flow 1 `PM Ingest`:**
| Knoten-ID | Anzeigename | Typ | Zweck und Parameter | Verbindung |
|---|---|---|---|---|
| `Webhook-pm1w` | Webhook | Langflow-Standardknoten (Kategorie input_output) | Empfängt den POST des systemd-Timers auf `/api/v1/webhook/pm-ingest`, Payload-Inhalt ist egal | `output_data` an `PMIngest-pm1a.trigger` |
| `PMIngest-pm1a` | PM Ingest | Custom Component (`components/pm_ingest.py`) | Ruft `ingest.run_ingest()` auf: Lock, IMAP-Poll, Eigen-Mail-Filter, Projekt-Splitting, kanonische URL, CRM-Dedup, je neuem Projekt HTTP-Dispatch an Flow 2 (`POST /api/v1/run/<PM_FLOW2_ID>?stream=false`, Header `x-api-key`, Timeout 1800 s), Aggregation, eine Alert-Mail je Trigger-Mail bei Fehlern, Mail in den Trash. Eingaben über Langflow-Variablen: `PM_IMAP_PASSWORD`, `PM_ESPO_BASE`, `PM_ESPO_API_KEY`, `PM_ALERT_TO`, `PM_FLOW2_ID`, `PM_LANGFLOW_API_KEY` (alle `load_from_db`, Auflösung zur Laufzeit) | `out` an `TextOutput-pm1o.input_value` |
| `TextOutput-pm1o` | Text Output | Langflow-Standardknoten | Gibt das Summary-JSON aus, z. B. `{"mails": 1, "projects": 2, "created": 1, "rejected": 1, "duplicate": 0, "failed": 0}` | Endknoten |
**Flow 2 `PM Projekt bewerten`** (ein Lauf je Projekt, Eingabe ist ein JSON-String `{"canonical": <URL>, "title": <Titel>}`):
| Knoten-ID | Anzeigename | Typ | Zweck und wesentliche Parameter | Verbindung |
|---|---|---|---|---|
| `TextInput-pm2i` | Text Input | Standardknoten | Nimmt das Eingabe-JSON von Flow 1 entgegen | `text` an `PMFetch-pm2a.payload` |
| `PMFetch-pm2a` | PM 1 Fetch | Custom (`pm_fetch.py`) | `stage_fetch`: Projektseite per GET laden (Browser-User-Agent, 3 Versuche mit Backoff), `page_text()` extrahiert `div.content` ohne die Skill-Tag-Wolke (`project-body-badges`), ersetzt alle Anführungszeichen durch `'`, verlangt mindestens 200 Zeichen (sonst Fehler "Login-Wall oder leere Seite"), kappt bei 24000 Zeichen | `out` an `PMExtract-pm2b.ctx_in` |
| `PMExtract-pm2b` | PM 2 Extract | Custom (`pm_extract.py`) | `stage_extract`: LLM-Aufruf 1 (`llm.extract_project`), strukturierte Extraktion von Projektname, Angebots- und Auftraggebertyp, Firma, Kontaktperson und Anforderungsliste nach `EXTRACT_SCHEMA` (Abschnitt 7). Variablen `PM_VLLM_BASE`, `PM_VLLM_MODEL` | `out` an `PMMatch-pm2c.ctx_in` |
| `PMMatch-pm2c` | PM 3 Match CV | Custom (`pm_match.py`) | `stage_match`: LLM-Aufruf 2 (`llm.match_cv`), bewertet jede Must- und Nice-Anforderung gegen den Lebenslauf (`/app/langflow/vorgaben/Lebenslauf_Dr-Ing_Thomas_Langer.md`) mit `yes`, `no` oder `unknown`, Vollständigkeits-Retry falls Nummern fehlen | `out` an `PMRules-pm2d.ctx_in` |
| `PMRules-pm2d` | PM 4 Rules+Gate | Custom (`pm_rules.py`) | `stage_rules`, rein deterministisch: Misc-Regeln (Startdatum-Fenster 8 Wochen, Auslastung, Entfernung zum Standort Sauerlach per Nominatim und Haversine mit 50/60-km-Grenzen, Sicherheitsüberprüfung als K.-o.), Match-Berechnung (yes=100, unknown=25, no=0, kaufmännisch gerundet), Beschreibungs-Markdown, Gate `mustMatch > PM_THRESHOLD` (85), sonst `status=rejected` | `out` an `PMCrm-pm2e.ctx_in` |
| `PMCrm-pm2e` | PM 5 CRM | Custom (`pm_crm.py`) | `stage_crm`: nur bei `decision=consider`. Team-Lookup nach Angebotstyp, Firma und Kontakt deduplizierend anlegen, eindeutigen Namen bestimmen, Tag-ID auflösen (`ensure_ctag`, seit S68), `POST Opportunity`, danach Verifikations-GET mit Gleichheitsprüfung (Name, Link, Beschreibung, Team, Firma, Tag). Variablen `PM_ESPO_BASE`, `PM_ESPO_API_KEY` | `out` an `PMNotify-pm2f.ctx_in` |
| `PMNotify-pm2f` | PM 6 Notify | Custom (`pm_notify.py`) | `stage_notify`: bei `status=created` Benachrichtigungsmail an `PM_NOTIFY_TO`, in jedem Fall ein konsolidierter Langfuse-Trace `projekt-match` (Tag = Endstatus). Läuft absichtlich auch bei `failed` und `rejected` | `out` an `TextOutput-pm2o.input_value` |
| `TextOutput-pm2o` | Text Output | Standardknoten | Gibt das Stage-Summary-JSON zurück (`status`, `error`, `decision`, `mustMatch`, `niceMatch`, `opportunityId`, `crmUrl`, `projectName`, `canonical`), Flow 1 sucht dieses JSON per Tiefensuche in der Run-Antwort | Endknoten |
## 6. E-Mail-Zerlegung
- Eine Suchagenten-Mail enthält typischerweise mehrere Treffer. Die Zerlegung erfolgt ohne LLM, rein durch HTML-Parsing (BeautifulSoup) in `mailparse.split_projects()`: alle `<a>`-Anker, deren `href` auf `https?://(www.)?freelancermap.de/(nproj/|projekt/)...` passt. Pro Projekt (Schlüssel ist der URL-Pfad) gewinnt der längste Ankertext als Titel, damit "Zum Projekt"-Buttons nicht den Titel stellen. Fallback für reine Text-Mails: URL-Zeile plus die nächste vorangehende nicht-leere Zeile als Titel.
- **Pro Treffer entsteht eine eigene Verkaufschance** (beziehungsweise Bewertung), nicht pro E-Mail: Flow 1 dispatcht jedes neue Projekt einzeln an Flow 2.
- Der Projektlink wird kanonisiert (`mailparse.canonical_url`): Redirects folgen, Query-String und Fragment abschneiden. Diese kanonische URL ist zugleich der Dubletten-Schlüssel.
- **Die Projektseite wird zusätzlich abgerufen.** Der Mailinhalt dient nur der Link- und Titel-Extraktion, die inhaltliche Bewertung arbeitet ausschließlich mit dem Text der abgerufenen Projektseite (`stage_fetch`, siehe Abschnitt 5). Hinter Login-Wänden liegende Ausschreibungen schlagen bewusst fehl (Alert), ein Mail-Snippet-Fallback existiert nicht.
## 7. Modell und Prompts
- **LLM:** vLLM (OpenAI-kompatibel, Version 0.22.1), lokal auf diesem Host, Endpunkt `http://host.containers.internal:8081/v1` (Langflow-Variable `PM_VLLM_BASE`), Modell `AxionML/Qwen3.5-9B-NVFP4` (`PM_VLLM_MODEL`). Kein Cloud-Zugriff.
- **Parameter** (beide Aufrufe identisch, `llm._post`): `temperature: 0.1`, `max_tokens: 8192`, `chat_template_kwargs: {"enable_thinking": false}` (Reasoning aus, sonst degeneriert die schemageführte Dekodierung), `response_format: {"type": "json_schema", "json_schema": {"name": "out", "schema": <Schema>}}` (das ältere `guided_json` wird von vLLM 0.22.1 stillschweigend ignoriert und wird deshalb nicht verwendet), HTTP-Timeout 1500 s. Leere oder nicht parsebare Antworten werden bis zu 3-mal mit Backoff wiederholt, HTTP-Fehler brechen sofort ab.
**System-Prompt Extraktion** (`llm.EXTRACT_SYSTEM`, wörtlich):
```text
Du extrahierst Anforderungen aus deutschen Freiberufler-Projektausschreibungen. Antworte NUR mit JSON nach Schema.
Regeln:
- projectName: Titel der Ausschreibung OHNE Portal-Zusatz (z. B. ohne "auf www.freelancermap.de").
- requirements: jede Anforderung einzeln, im Originalwortlaut (behutsames Kürzen erlaubt, Bedeutung nie verändern). Rahmenbedingungen (Start, Einsatzort/Remote-Anteil, Auslastung, Laufzeit) sind Anforderungen der Kategorie Misc.
- kat: Must = zwingend (Abschnitt "Must-haves"/"Anforderungen"; "zwingend", "erforderlich", "vorausgesetzt", "sehr gute Kenntnisse"). Nice = optional (Abschnitt "Nice-to-haves"; "von Vorteil", "wünschenswert", "idealerweise", "plus"). Misc = Rahmenbedingungen und alles, was weder Muss noch Wunsch-Qualifikation ist. Explizite Abschnittsüberschriften haben Vorrang vor Signalwörtern; "idealerweise" INNERHALB einer Must-Zeile lässt die Zeile Must bleiben. Nice-Signalwörter gelten NUR für die Zeile, in der sie selbst stehen — sie färben NIE auf folgende Zeilen ab: Jede Zeile eines Anforderungs-Abschnitts OHNE eigenes Nice-Signalwort ist Must, auch wenn direkt davor Nice-Zeilen stehen — AUSNAHME: Zeilen unter einer expliziten Nice-to-haves-Überschrift bleiben Nice. Beispiel: Auf "Idealerweise zusätzlich Erfahrung mit X" (Nice) folgt "Erfahrung mit Y" ohne Signalwort → Y ist Must.
- miscType nur für Misc-Zeilen relevant (sonst "other"): start = Projektstart/Verfügbarkeit (startDate als ISO-Datum YYYY-MM-DD, wenn ein konkretes Datum genannt ist, sonst null); workload = Auslastung (workloadPercent 0-100 oder null); duration = Laufzeit; location = Einsatzort/Remote (remotePercent 0-100 oder null; onsiteLocation = Ortsname oder null); security = Sicherheitsüberprüfung (SÜ, SÜ1/SÜ2/SÜ3, Ü2, Geheimschutz).
- offerType: "Projekt" = Freiberufler-/Werkauftrag (auch über Agentur). "Arbeitnehmer-Angebot" bei Festanstellung ("Festanstellung", "unbefristet", "Gehalt", "Arbeitsvertrag"). "ANÜ" bei Arbeitnehmerüberlassung ("ANÜ", "AÜG", "Überlassung", "Zeitarbeit"). Im Zweifel "Projekt".
- buyerType: "agency" bei Personaldienstleistern/Vermittlern (Hays, GULP/Randstad, SThree, Computer Futures, Aristo, freelancermap-Vermittler, "im Auftrag unseres Kunden", "für unseren Kunden"), sonst "direct". Im Zweifel "agency".
- companyName: Name der Agentur bzw. des Endkunden, sonst null. contactPerson: vollständiger Name der Ansprechperson, sonst null.
```
**Benutzer-Prompt Extraktion** (wörtlich, `<page_text>` ist der bereinigte Seitentext, auf 24000 Zeichen gekappt):
```text
Ausschreibungstext:
<page_text>
```
**Ausgabeschema Extraktion** (`llm.EXTRACT_SCHEMA`, vollständig):
```json
{
"type": "object",
"properties": {
"projectName": {"type": "string"},
"offerType": {"enum": ["Projekt", "Arbeitnehmer-Angebot", "ANÜ"]},
"buyerType": {"enum": ["agency", "direct"]},
"companyName": {"type": ["string", "null"]},
"contactPerson": {"type": ["string", "null"]},
"requirements": {
"type": "array",
"maxItems": 40,
"items": {
"type": "object",
"properties": {
"text": {"type": "string"},
"kat": {"enum": ["Must", "Nice", "Misc"]},
"miscType": {"enum": ["start", "workload", "duration", "location", "security", "other"]},
"startDate": {"type": ["string", "null"]},
"workloadPercent": {"type": ["integer", "null"]},
"remotePercent": {"type": ["integer", "null"]},
"onsiteLocation": {"type": ["string", "null"]}
},
"required": ["text", "kat", "miscType", "startDate", "workloadPercent", "remotePercent", "onsiteLocation"]
}
}
},
"required": ["projectName", "offerType", "buyerType", "companyName", "contactPerson", "requirements"]
}
```
**System-Prompt CV-Matching** (`llm.MATCH_SYSTEM`, wörtlich):
```text
Du bewertest nüchtern und streng, ob ein Lebenslauf einzelne Projekt-Anforderungen abdeckt. Antworte NUR mit JSON nach Schema: für JEDE übergebene Nr. genau ein Eintrag in ratings.
Bewertung:
- "yes" NUR bei klarer Evidenz im Lebenslauf.
- "no" wenn der Lebenslauf nichts Belastbares hergibt. Streng bleiben — eine geschönte Bewertung macht die Match-Zahlen wertlos. Kalibrierung: Proof-of-Concept-Erfahrung deckt "produktiven Betrieb" NICHT ab; "mehrjährig" wörtlich nehmen; ein Produktname (z. B. "Azure DevOps Server") belegt KEINE Cloud-Plattform-Erfahrung.
- "unknown" NUR bei echter Teilevidenz, wenn die Entscheidung von Wissen abhängt, das nur der Kandidat selbst hat. Zusammengesetzte Anforderungen ("X sowie Y", "X und Y"): klare Evidenz für einen Teil und keine Gegen-Evidenz für den anderen → "unknown", nicht "no".
- "wie z. B."-Aufzählungen: gleichwertige Alternativen zählen als Abdeckung (Beispiel: Ollama/llama.cpp/Transformers decken "LLM-Inference-Stacks wie z. B. vLLM, TGI, Triton" ab).
- reason: EIN kurzer deutscher Satz mit der Begründung.
```
**Benutzer-Prompt CV-Matching** (wörtlich, `<cv_text>` ist der vollständige Lebenslauf, `<listing>` die nummerierte Liste der Must- und Nice-Anforderungen im Format `1. <Text>`):
```text
Lebenslauf:
<cv_text>
Anforderungen:
<listing>
```
Fehlen in der Antwort Bewertungen zu einzelnen Nummern, folgt genau ein Korrektur-Turn (wörtlich): `Es fehlen Bewertungen für Nr. [<Liste>]. Antworte erneut mit ratings für ALLE Nummern.`
**Ausgabeschema CV-Matching** (`llm.MATCH_SCHEMA`, vollständig):
```json
{
"type": "object",
"properties": {
"ratings": {
"type": "array",
"items": {
"type": "object",
"properties": {
"nr": {"type": "integer"},
"rating": {"enum": ["yes", "no", "unknown"]},
"reason": {"type": "string"}
},
"required": ["nr", "rating", "reason"]
}
}
},
"required": ["ratings"]
}
```
Die Anführungszeichen im Prompt-Wortlaut sind unkritisch, problematisch waren Anführungszeichen im Eingabetext, sie werden seit `36eac66` vor dem Prompting durch `'` ersetzt (Schutz der schemageführten Dekodierung, Abschnitt 4.1 und 4.6 der Design-Doku).
## 8. Feldbelegung im CRM
Vom Workflow beim `POST Opportunity` gesetzte Felder (Quelle: `stages.stage_crm`, live verifiziert an angelegten Datensätzen):
| CRM-Feld | Technischer Name | Quelle des Werts | Beispiel |
|---|---|---|---|
| Name | `name` | `projectName` aus der LLM-Extraktion, bei Namenskollision Suffix ` (2)`, ` (3)` über `unique_opportunity_name` | `AWS Data Engineer (m/w/d) DWH/BI & ETL` |
| Beschreibung | `description` | Deterministisch aus `rules.build_description` (Format siehe unten) | siehe unten |
| Projektlink | `cProjektlink` | Kanonische Projekt-URL (Redirects aufgelöst, ohne Query), zugleich Dubletten-Schlüssel | `https://www.freelancermap.de/projekt/anue-projekt-aws-data-engineer-m-w-d-dwh-bi-und-etl` |
| Teams | `teamsIds` | Team-Lookup nach Angebotstyp: `Projekt` → Team `DesTEngS`, `Arbeitnehmer-Angebot``Arbeitnehmer`, `ANÜ``ANÜ`. Die Team-ID wird zur Laufzeit über den Namen aufgelöst, fehlt das Team, schlägt das Projekt fehl | `["6a31087b204535f4d"]` (Team `ANÜ`) |
| Über Agentur | `cAccount1Id` | Nur bei `buyerType=agency`: ID der per Namen deduplizierten Firma (Typ `Reseller`) | `6a460796cf70734e3` (`1st solution consulting gmbh`) |
| Firma | `accountId` | Nur bei `buyerType=direct`: ID der Firma (Typ `Customer`). Agentur- und Direktfall schließen sich aus | leer im Agenturfall |
| Kontakte | `contactsIds` | Nur wenn die Ausschreibung Person und Firma nennt: per Namen deduplizierter Kontakt, an die Firma gehängt | `["6a46079cc3db42617"]` |
| Tags | `cTagsIds` | Seit S68 (2026-08-17): fester Tag `Auto: Durch Suchagent-Treffer erstellt`, ID zur Laufzeit per Namen aufgelöst (`ensure_ctag`) | `["6a83292a186dce7b2"]` |
Bewusst **nicht** gesetzt werden (die EspoCRM-Vorgaben greifen, live am Datensatz verifiziert):
| Feld | Verhalten ohne Belegung |
|---|---|
| `stage` | CRM-Default `Interessant` |
| `amount` | bleibt leer (`null`). Die Dynamic Logic des CRM erzwingt `amount` erst bei bestimmten späteren Stages, beim Anlegen mit Default-Stage tritt daher kein 400 auf |
| `cLeadQuelle` | CRM-Default `Inbound Agentur` |
| `cVerguetungsmodell` | CRM-Default `Agentur Honorar` |
| `assignedUser` | bleibt leer |
| `closeDate`, Wahrscheinlichkeit u. a. | CRM-Vorgaben beziehungsweise leer |
**Format des Beschreibungs-Felds** (Markdown, exakt das Format des früheren Claude-Skills): erste Zeile `Must-have-Match: <X> % · Nice-to-have-Match: <Y> %` (fehlende Werte als ``), Leerzeile, dann die Tabelle `| Nr. | Kat. | ❔ | Anforderung |` mit einer Zeile je Anforderung in der Reihenfolge Must, Nice, Misc, Bewertungssymbol ✅ (yes), ❌ (no) oder ❔ (unknown). Die Zahlen stammen ausschließlich aus der deterministischen Berechnung, nie vom LLM.
## 9. API-Aufruf
Alle CRM-Zugriffe laufen über `https://crm.creature-go.com/api/v1` (Langflow-Variable `PM_ESPO_BASE`) mit dem API-Benutzer `cowork-api` (EspoCRM-Benutzertyp API, Rolle "CRM und Tags Vollzugriff").
- **Auth-Methode:** HTTP-Header `X-Api-Key: <Key>` bei jedem Request. Der Key steht in der Langflow-Credential-Variable `PM_ESPO_API_KEY` (verschlüsselt in der Langflow-Postgres-Datenbank), Ursprungsablage ist `/home/tlg/mkt/bewerb/.secrets/espocrm-api.md` (chmod 600, Besitzer `tlg`, für `lwc` per ACL lesbar). Der Key steht bewusst weder im Git-Repo noch in dieser Dokumentation.
- **Client:** `projektmatch/espocrm.py`, `requests.Session`, Timeout 30 s je Request, bei HTTP 5xx und Netzwerkfehlern bis zu 2 Wiederholungen mit Backoff (2 s, 4 s), HTTP 4xx bricht sofort ab und hebt den Antwort-Header `X-Status-Reason` in die Fehlermeldung (so werden auch Dynamic-Logic-Ablehnungen des CRM sichtbar).
- **Aufrufe im Anlegepfad** (Reihenfolge in `stage_crm`):
1. `GET /Team?where[0][type]=equals&where[0][attribute]=name&where[0][value]=<Teamname>` (Team-ID)
2. `GET /Account?where[0][type]=contains&where[0][attribute]=name&...` und bei Bedarf `POST /Account` mit `{"name": ..., "type": "Reseller"|"Customer"}`
3. optional `GET /Contact?...` und `POST /Contact` mit `{"firstName": ..., "lastName": ..., "accountId": ...}`
4. `GET /Opportunity?where[0][type]=startsWith&where[0][attribute]=name&...` (eindeutiger Name)
5. `GET /CTag?where[0][type]=equals&where[0][attribute]=name&where[0][value]=Auto: Durch Suchagent-Treffer erstellt`, bei 0 Treffern `POST /CTag {"name": ...}` (seit S68)
6. `POST /Opportunity` mit `Content-Type: application/json`, Payload: `{"name", "description", "cProjektlink", "teamsIds": [...], "cTagsIds": [<Tag-ID>], "cAccount1Id"|"accountId", "contactsIds"}` (die letzten beiden nur wenn vorhanden)
7. `GET /Opportunity/<id>` als Verifikation: Gleichheitsprüfung von `name`, `cProjektlink`, `description`, `teamsIds` enthält Team-ID, `cAccount1Id` beziehungsweise `accountId`, `cTagsIds` enthält Tag-ID. Jede Abweichung wirft `CRM-Verifikation fehlgeschlagen` und setzt das Projekt auf `failed` (der Datensatz bleibt dann bestehen, der Fehler landet im Alert).
- **Festhalten der neuen ID:** `ctx["opportunityId"]` und `ctx["crmUrl"]` (`https://crm.creature-go.com/#Opportunity/view/<id>`) gehen in den Langfuse-Trace (`metadata.opportunityId`, `metadata.crmUrl`) und in die Benachrichtigungsmail. Eine eigene Persistenz außerhalb von Trace und Mail gibt es nicht.
- Die Dublettenprüfung in Flow 1 nutzt denselben Client: `GET /Opportunity?where[0][type]=equals&where[0][attribute]=cProjektlink&where[0][value]=<kanonische URL>`.
## 10. Dublettenbehandlung
Ja, vor jedem Flow-2-Dispatch prüft Flow 1, ob zu dem Projekt bereits eine Verkaufschance existiert.
- **Kriterium:** exakte Gleichheit des Felds `cProjektlink` mit der kanonischen Projekt-URL (Redirects aufgelöst, Query und Fragment entfernt). Kein Namens- oder Hash-Vergleich. Dasselbe Projekt bei zwei verschiedenen Agenturen hat zwei verschiedene URLs und bleibt absichtlich zwei Verkaufschancen.
- **Bei Treffer:** Status `duplicate`, kein Flow-2-Lauf, kein LLM-Aufruf, kein CRM-Schreiben, keine Benachrichtigung, kein Langfuse-Trace. Der Treffer wird nur im Summary von Flow 1 gezählt, die Trigger-Mail wandert normal in den Trash.
- Namenskollisionen unabhängig von der URL werden beim Anlegen über das Suffix-Schema ` (2)`, ` (3)` aufgelöst.
- Grenze des Verfahrens: wird eine Verkaufschance im CRM gelöscht, greift die Prüfung nicht mehr, der nächste Suchagenten-Treffer zum selben Projekt legt sie neu an (siehe Abschnitt 12).
## 11. Fehlerbehandlung und Beobachtbarkeit
Grundprinzip (Terminal-Zustands-Regel): jedes Projekt endet im ersten Anlauf in genau einem der Zustände `created`, `rejected`, `duplicate` oder `failed`. Es gibt keine Wiederholung über Zyklen hinweg, nur begrenzte Retries innerhalb eines Laufs. Die Trigger-Mail landet in jedem Fall im Trash (Fallback: Keyword). Das schützt vor Endlosschleifen, der Preis ist, dass ein fehlgeschlagenes Projekt manuell nachbewertet werden muss (die Alert-Mail enthält dafür die URL).
| Störung | Verhalten |
|---|---|
| CRM-API nicht erreichbar / 5xx | 2 Wiederholungen mit Backoff im `EspoClient`, dann `EspoError`, Projekt `failed`, Eintrag in der Alert-Mail |
| HTTP 400 vom CRM (z. B. Dynamic Logic, die bei bestimmten Stages `amount` erzwingt) | Sofortiger Abbruch, die Ursache aus dem Antwort-Header `X-Status-Reason` steht wörtlich in Fehlermeldung, Alert und Trace. Im Normalbetrieb tritt der Fall nicht auf, weil der Workflow mit Default-Stage anlegt und `amount` nicht setzt |
| vLLM nicht erreichbar / HTTP-Fehler | Sofortiger Fehler `vLLM HTTP <code>`, Projekt `failed`, Alert |
| LLM liefert leeres/kaputtes JSON | Bis zu 3 Versuche mit Backoff (10 s, 20 s, 30 s), dann `LLM lieferte kein JSON`, `failed`, Alert |
| LLM lässt Bewertungen aus | Ein Korrektur-Turn, dann `Matching unvollständig`, `failed`, Alert |
| Projektseite nicht ladbar / Login-Wall / zu kurz | 3 Abrufversuche, dann `Abruf fehlgeschlagen` beziehungsweise `Seitentext zu kurz`, `failed`, Alert |
| Unerwartetes Mail-Format, Mail nicht lesbar | Mail wird mit `$ProjektChecked` markiert und übersprungen, Zähler `unreadable` im Summary, Pipeline läuft weiter |
| IMAP-Verbindung stirbt während langer Läufe | `ensure_alive()` (NOOP-Probe plus Reconnect) vor der Finalisierung, `move_to_trash`-Fallback auf `flag_checked` (`trashFailed` im Summary) |
| CRM-Schreiben ok, Benachrichtigungsmail scheitert | Verkaufschance existiert, Status `failed` mit `notify: <Fehler>` im Trace, es wird nicht erneut versendet (akzeptierter Randfall) |
| Alert-Mail selbst scheitert | Zähler `alertFailed` im Summary, keine weitere Eskalation |
- **Benachrichtigung:** eine aggregierte Alert-Mail je Trigger-Mail mit Fehlern, Betreff `[Projekt-Match-Fehler] Freelancermap — <n> Projekt(e)`, an `PM_ALERT_TO` (aktuell `Thomas.Langer@destengs.com`). Erfolgsfall: `[Projekt-Match] Freelancermap — <Must> % — <Projektname>` an `PM_NOTIFY_TO` (aktuell ebenfalls Thomas). Ausgehende Mails werden zusätzlich in den IMAP-Ordner Sent kopiert (seit `f40a5bb`).
- **Dead-Letter-Verhalten:** die Alert-Mail ist der Dead-Letter-Kanal, fehlgeschlagene Projekte werden nirgends zur automatischen Wiedervorlage gespeichert.
- **Logs und Traces:**
- Primär: Langfuse unter `http://127.0.0.1:8091`, Projekt `projekt-matching`. Je bewertetem Projekt ein Trace `projekt-match` mit Tag `created`, `rejected` oder `failed`, samt kanonischer URL, Anforderungs-Tabelle, Match-Werten, Fehlertext und Opportunity-ID. Zusätzlich landen Langflows eigene Ausführungs-Traces (Komponenten, LLM-Ein-/Ausgaben) im selben Projekt. Aufbewahrung: keine Löschregel konfiguriert, die Traces bleiben unbegrenzt (begrenzt nur durch Plattenplatz).
- Timer-Ebene: `journalctl --user -u projekt-matching.service` zeigt je Tick das Summary-JSON der Webhook-Antwort. Aufbewahrung nach journald-Standardrotation, eine explizite Vorhaltedauer ist nicht konfiguriert.
- Langflow-Läufe: Monitor- und Builds-API von Langflow. `podman logs langflow_ctr` ist unbrauchbar (bekannte structlog-Endlosschleife flutet das Log) und `~/.local/share/langflow_pod/langflow-data/langflow.log` ist aus demselben Grund ohne Aussagekraft.
## 12. Bekannte Schwächen
Ehrliche Liste des aktuellen Zustands:
1. **Gelöschte Verkaufschancen kommen wieder.** Die Dublettenprüfung vergleicht nur gegen existierende Datensätze. Löscht man eine automatisch angelegte Verkaufschance, legt der nächste Suchagenten-Treffer zum selben Projekt sie erneut an. Das erklärt einen großen Teil der Differenz zwischen 270 `created`-Traces und 66 vorhandenen cowork-api-Datensätzen.
2. **Nur freelancermap.** Andere Portale werden ignoriert (Erweiterungspunkt: `mailparse.PROJECT_URL_RE` und das Splitting).
3. **Login-geschützte Ausschreibungen schlagen fehl** (bewusst, kein Snippet-Fallback), erzeugen aber jedes Mal einen Alert.
4. **LLM-Varianz:** der Nice-to-have-Match schwankt um einige Punkte zwischen Läufen desselben Projekts, Misc-Zeilen fehlen gelegentlich in der Extraktion. Der Must-Match (das Gate) war in allen Verifikationsläufen stabil. Überwachung über die Langfuse-Annotation-Queue `projekt-matching-review`, nichts davon ist automatisiert.
5. **Kein Backup.** Weder `postgres-data` (Flows, Variablen, Langfuse) noch `langflow-data` werden gesichert. Die Flows sind aus Git reproduzierbar, Traces und Laufhistorie nicht.
6. **`build_flows.py` setzt beim erneuten Ausführen Live-Werte zurück:** `PM_NOTIFY_TO` und `PM_ALERT_TO` stehen im Skript auf `chancen@destengs.com`, live wurden sie nachträglich auf `Thomas.Langer@destengs.com` umgestellt. Ein Re-Deploy der Flows rotiert außerdem den Langflow-API-Key und beide Flow-IDs (das Skript zieht `~/.config/projekt-matching/env` und die Variablen nach, externe Verweise auf alte IDs brechen trotzdem).
7. **Variablen-Änderungen per API zeigen Lesenachlauf:** direkt nach einem `PATCH /api/v1/variables/<id>` kann ein `GET` noch den alten Wert liefern (in dieser Sitzung live beobachtet). Nach Variablenänderungen den Zielwert mit kurzem Abstand gegenlesen.
8. **`parse_run_result` ankert auf "irgendein eingebettetes JSON mit `status`-Schlüssel"** in der Flow-2-Antwort. Robust in Langflow 1.10.0, ein Upgrade, das Zwischenobjekte serialisiert, bräuchte einen strikteren Anker.
9. **Berechtigungs-Klippen beim Deploy:** Lebenslauf und CRM-Key-Datei gehören `tlg` und sind für `lwc` nur über gesetzte ACLs lesbar. Fehlen die ACLs, deployt `deploy_files.sh` mit Warnungen unvollständig und `build_flows.py` setzt Platzhalter-Credentials.
10. **Tag-Namensbindung:** der Tag wird zur Laufzeit über seinen exakten Namen aufgelöst und bei Fehlen neu angelegt. Wird der Tag im CRM umbenannt, entsteht beim nächsten Treffer ein neuer Tag mit dem alten Namen (statt eines Fehlers). Der Wortlaut in `stages.AUTO_TAG_NAME` ist der Vertrag.
11. **Langfuse-Einrichtung ist teilweise fragil beziehungsweise manuell:** Organisation und Projekt entstehen über interne tRPC-Aufrufe (die offizielle Admin-API ist in der OSS-Version gesperrt), die Annotation-Queue ist ein manueller UI-Schritt.
12. **Ein Provisorium im Repo-Zustand:** die Code-Änderungen der Tag-Erweiterung (S68) sind deployt und getestet, aber noch nicht committet (siehe Abschnitt "Erweiterung um den Tag (S68)", Punkt "Dateien").
## 13. Wiederherstellung
Nach Totalverlust des Hosts genügt der folgende Ablauf (Details in `docs/projekt-matching-design.md`, Abschnitt 7), sofern die unten genannten Artefakte gesichert sind:
1. `bash /home/lwc/bin/create_pod_langflow.sh` (Pod, Datenbanken, Langflow, Langfuse).
2. ACLs setzen (als `tlg`): `setfacl -m u:lwc:r` auf den Lebenslauf und auf `/home/tlg/mkt/bewerb/.secrets/espocrm-api.md`; `~/.config/projekt-matching/secrets.env` (0600) mit `PM_IMAP_PASSWORD` anlegen.
3. `projekt-matching/deploy/deploy_files.sh` (Paket und Vorgaben in das Datenverzeichnis).
4. `set -a; source projekt-matching/deploy/secrets.local.env; set +a` und dann `.venv/bin/python projekt-matching/deploy/build_flows.py` (Variablen, API-Key, beide Flows, Env-Datei). Danach `PM_NOTIFY_TO` und `PM_ALERT_TO` auf die Produktivempfänger stellen (Schwäche 6).
5. `.venv/bin/python projekt-matching/deploy/setup_langfuse.py`, gedruckte Keys in das Pod-Skript übernehmen, Skript erneut ausführen, Annotation-Queue manuell in der UI anlegen.
6. systemd-Units kopieren und aktivieren: `cp projekt-matching/deploy/projekt-matching.{service,timer} ~/.config/systemd/user/ && systemctl --user daemon-reload && systemctl --user enable --now projekt-matching.timer`.
7. Optional Postfach baselinen: `tests/e2e/flag_inbox.py`.
Zwingend zu sichernde Artefakte:
- das Git-Repo `/home/lwc/bin` (Code, Komponenten, Deploy-Skripte, Pod-Skript, Doku),
- `/home/tlg/mkt/bewerb/.secrets/espocrm-api.md` (CRM-Key) und `~/.config/projekt-matching/secrets.env` (IMAP-Passwort),
- `/home/tlg/mkt/bewerb/vorgaben/Lebenslauf_Dr-Ing_Thomas_Langer.md` und `rahmenbedingungen.md`,
- das EspoCRM selbst läuft extern (`crm.creature-go.com`) und ist von diesem Host aus nicht zu sichern.
Nicht wiederherstellbar ohne zusätzliches Backup: Langfuse-Traces und Scores, Langflow-Laufhistorie, die IMAP-Historie liegt auf dem Mailserver. Der Tag aus S68 braucht keine Sicherung, `ensure_ctag` legt ihn bei Bedarf selbst neu an; nach einer CRM-Wiederherstellung mit neuen IDs funktioniert die Namensauflösung unverändert.
## Erweiterung um den Tag (S68)
Seit dem 2026-08-17 markiert der Workflow jede von ihm neu angelegte Verkaufschance mit dem festen Tag (Wortlaut exakt, Vorgabe Thomas):
```
Auto: Durch Suchagent-Treffer erstellt
```
Das Präfix `Auto:` bildet den Namensraum für maschinell gesetzte Tags. Bestehende Verkaufschancen wurden absichtlich nicht nachgepflegt (kein Backfill), für den Bestand existiert weiterhin der gespeicherte Listen-Filter "Via cowork-api erstellt", der unverändert blieb.
**Tag-Datensatz:** Die Tag-Entität heißt API-seitig `CTag` (verifiziert, `GET /CTag` liefert die bestehenden Tags). Ein Tag mit dem Zielnamen existierte noch nicht und wurde per `POST /CTag` angelegt:
- **CTag-ID: `6a83292a186dce7b2`**, angelegt 2026-08-17 15:30 UTC durch `cowork-api`.
Das Anlegen ist reiner Dateninhalt, keine GUI-Customization, ein `snapshot_espocrm_custom.sh`-Lauf war daher nicht nötig (es wurde keine Customization angefasst).
**Umsetzung:** Der Tag wird direkt im anlegenden `POST /Opportunity` über das Feld `cTagsIds` mitgegeben. Das funktioniert, EspoCRM akzeptiert das `linkMultiple`-Feld beim Create (vorab mit einem sofort wieder gelöschten Wegwerf-Datensatz verifiziert, anschließend im Funktionstest über die echte Pipeline bestätigt). Der im Auftrag genannte `PUT`-Fallback war deshalb nicht nötig. Die bestehende Verifikations-Leseprüfung nach dem Anlegen wurde erweitert: fehlt die Tag-ID im zurückgelesenen `cTagsIds`, gilt die Anlage als fehlgeschlagen (`CRM-Verifikation fehlgeschlagen: ['cTagsIds']`).
**Ablage der Tag-ID:** Die ID ist bewusst nirgends hinterlegt. Stattdessen wird sie zur Laufzeit über den Tag-Namen aufgelöst: die neue Client-Methode `EspoClient.ensure_ctag(name)` sucht `CTag` per exaktem Namen und legt ihn an, wenn er fehlt. Der Name steht an genau einer Stelle, in der Konstante `AUTO_TAG_NAME` in `projektmatch/stages.py`. Begründung dieser Wahl statt einer abgelegten ID: erstens folgt sie exakt dem bestehenden Muster der Team-Auflösung (`team_id(name)`), zweitens überleben CRM-seitige IDs eine Neuaufsetzung des CRM nicht, der Name ist der eigentliche Vertrag, drittens macht `ensure_ctag` die Wiederherstellung selbstheilend. Kosten: ein zusätzlicher `GET /CTag` je angelegter Verkaufschance (im Mittel rund 7 pro Tag, vernachlässigbar). Kehrseite: eine Umbenennung des Tags im CRM führt zur Neuanlage unter dem alten Namen (als Schwäche 10 dokumentiert).
**Geänderte Dateien** (alle in `/home/lwc/bin/projekt-matching/`, deployt über `deploy/deploy_files.sh`, Teststand 65 von 65 pytest grün):
| Datei | Änderung |
|---|---|
| `projektmatch/espocrm.py` | neue Methode `ensure_ctag(name)` (suchen, sonst anlegen, ID zurück) |
| `projektmatch/stages.py` | Konstante `AUTO_TAG_NAME`, `cTagsIds: [<ID>]` im Create-Payload von `stage_crm`, Tag-Prüfung in der Verifikations-Leseprüfung |
| `tests/test_espocrm.py` | neuer Test `test_ensure_ctag_find_and_create` |
| `tests/test_stages.py` | Tag-Zusicherungen im bestehenden CRM-Test, neuer Negativtest `test_stage_crm_tag_missing_in_readback_fails_verification` |
Die Änderungen sind deployt und live, aber noch nicht committet. Die Flow-Definitionen in Langflow selbst blieben unverändert, weil die gesamte CRM-Logik im Python-Paket liegt, die Vorher- und Nachher-Exporte sind deshalb inhaltsgleich (erwartet und so verifiziert).
**Flow-Exporte** (JSON, per `GET /api/v1/flows/<id>`), abgelegt neben den Flow-Daten:
- `~/.local/share/langflow_pod/langflow-data/flow-exports/PM-Projekt-bewerten_vorher_20260817-173040.json`
- `~/.local/share/langflow_pod/langflow-data/flow-exports/PM-Ingest_vorher_20260817-173040.json`
- `~/.local/share/langflow_pod/langflow-data/flow-exports/PM-Projekt-bewerten_nachher_20260817-173352.json`
- `~/.local/share/langflow_pod/langflow-data/flow-exports/PM-Ingest_nachher_20260817-173352.json`
**Funktionstest** (2026-08-17, voller Pipeline-Durchlauf mit nachgestellter Suchagenten-Mail):
1. Da das historische Referenzprojekt auf freelancermap nicht mehr existiert (404), wurde eine synthetische Suchagenten-Mail mit einer aktuellen, real erreichbaren Projekt-URL an `chancen@destengs.com` gesendet (`tests/e2e/send_test_mail.py`). Damit die Anlage unabhängig vom Match-Ergebnis deterministisch erfolgt, wurde `PM_THRESHOLD` für die Dauer des Tests per Variablen-PATCH auf `-1` gesetzt und unmittelbar danach auf `85` zurückgestellt (Rückstellung per `GET` verifiziert, Wert `85`). Im Testfenster wurde laut Langfuse kein weiteres Projekt verarbeitet, es gab keinen Kollateraleffekt.
2. Der Timer-Tick verarbeitete die Mail, Flow 2 legte die Verkaufschance an: **Datensatz-ID `6a832b8f29cc86bdf`** (`AWS Data Engineer (m/w/d) DWH/BI & ETL`, Must-Match 20 Prozent, wegen Testschwelle angelegt).
3. `GET /api/v1/Opportunity/6a832b8f29cc86bdf` lieferte den Beweis: `cTagsIds: ["6a83292a186dce7b2"]` und `cTagsNames: {"6a83292a186dce7b2": "Auto: Durch Suchagent-Treffer erstellt"}`. **Ergebnis: bestanden.**
4. Aufräumen: die Test-Verkaufschance wurde anschließend gelöscht (`tests/e2e/cleanup_crm.py`, das echte Projekt war zu Recht als `rejected` bewertet worden), die Trigger-Mail liegt regulär im Trash. Die dabei verknüpfte Firma und der Kontakt existierten schon vorher und blieben unberührt. Nebenwirkung: der Testlauf hat eine reguläre Benachrichtigungsmail `[Projekt-Match] Freelancermap — 20 % — AWS Data Engineer (m/w/d) DWH/BI & ETL` an `Thomas.Langer@destengs.com` versendet, sie kann gelöscht werden.
**Rückbau der Erweiterung:** die drei Code-Stellen in `espocrm.py` und `stages.py` entfernen (Methode `ensure_ctag`, Konstante `AUTO_TAG_NAME`, `cTagsIds` in Payload und Verifikation), die zugehörigen Teständerungen zurücknehmen, `deploy/deploy_files.sh` ausführen. Der `CTag`-Datensatz `6a83292a186dce7b2` kann als Dateninhalt gefahrlos bestehen bleiben oder per `DELETE /CTag/6a83292a186dce7b2` entfernt werden, bereits getaggte Verkaufschancen verlieren die Zuordnung dann automatisch.

View File

@@ -74,6 +74,12 @@ class EspoClient:
json={"firstName": first, "lastName": last,
"accountId": account_id})["id"]
def ensure_ctag(self, name):
hits = self.search("CTag", "equals", "name", name)
if hits:
return hits[0]["id"]
return self._req("POST", "CTag", json={"name": name})["id"]
def unique_opportunity_name(self, name):
existing = {h["name"] for h in self.search(
"Opportunity", "startsWith", "name", name, max_size=100)}

View File

@@ -19,6 +19,10 @@ from .mailparse import BROWSER_UA
TEAM_BY_OFFER = {"Projekt": "DesTEngS",
"Arbeitnehmer-Angebot": "Arbeitnehmer",
"ANÜ": "ANÜ"}
# Fester CTag für maschinell angelegte Verkaufschancen (Vorgabe Thomas,
# Wortlaut exakt). Auflösung zur Laufzeit über den Namen — wie team_id():
# IDs überleben keine CRM-Neuaufsetzung, der Name ist der Vertrag.
AUTO_TAG_NAME = "Auto: Durch Suchagent-Treffer erstellt"
MIN_PAGE_CHARS = 200
QUOTES = str.maketrans({c: "'" for c in "„“”\"«»‹›"})
@@ -132,8 +136,10 @@ def stage_crm(ctx, cfg, espo=None):
first, last = espocrm.split_person(ex["contactPerson"])
contact_id = espo.ensure_contact(first, last, account_id)
name = espo.unique_opportunity_name(ex["projectName"])
tag_id = espo.ensure_ctag(AUTO_TAG_NAME)
payload = {"name": name, "description": ctx["description"],
"cProjektlink": ctx["canonical"], "teamsIds": [team]}
"cProjektlink": ctx["canonical"], "teamsIds": [team],
"cTagsIds": [tag_id]}
if account_id:
payload["cAccount1Id" if agency else "accountId"] = account_id
if contact_id:
@@ -150,6 +156,8 @@ def stage_crm(ctx, cfg, espo=None):
problems.append("description")
if team not in (back.get("teamsIds") or []):
problems.append("teamsIds")
if tag_id not in (back.get("cTagsIds") or []):
problems.append("cTagsIds")
if account_id and agency and back.get("cAccount1Id") != account_id:
problems.append("cAccount1Id")
if account_id and not agency and back.get("accountId") != account_id:

View File

@@ -47,6 +47,19 @@ def test_ensure_account_exact_match_and_create():
assert payload == {"name": "Neue GmbH", "type": "Customer"}
def test_ensure_ctag_find_and_create():
hits = {"list": [{"id": "t1", "name": "Auto: Durch Suchagent-Treffer erstellt"}]}
client = make_client([resp(body=hits)])
assert client.ensure_ctag("Auto: Durch Suchagent-Treffer erstellt") == "t1"
params = client.session.request.call_args.kwargs["params"]
assert params["where[0][type]"] == "equals"
assert params["where[0][attribute]"] == "name"
client = make_client([resp(body={"list": []}), resp(body={"id": "t2"})])
assert client.ensure_ctag("Auto: Durch Suchagent-Treffer erstellt") == "t2"
payload = client.session.request.call_args.kwargs["json"]
assert payload == {"name": "Auto: Durch Suchagent-Treffer erstellt"}
def test_unique_opportunity_name_suffix():
hits = {"list": [{"name": "Projekt X"}, {"name": "Projekt X (2)"}]}
client = make_client([resp(body=hits)])

View File

@@ -126,20 +126,45 @@ def test_stage_crm_agency_linking_and_verify():
espo.team_id.return_value = "T1"
espo.ensure_account.return_value = "A1"
espo.ensure_contact.return_value = "C1"
espo.ensure_ctag.return_value = "TAG1"
espo.unique_opportunity_name.return_value = "Python Entwickler KI"
espo.create_opportunity.return_value = {"id": "O1"}
espo.get_opportunity.return_value = {
"id": "O1", "name": "Python Entwickler KI",
"cProjektlink": "https://x/projekt/p",
"description": ctx["description"], "cAccount1Id": "A1",
"accountId": None, "teamsIds": ["T1"]}
"accountId": None, "teamsIds": ["T1"], "cTagsIds": ["TAG1"]}
out = stages.stage_crm(ctx, CFG, espo=espo)
assert out["status"] == "created" and out["opportunityId"] == "O1"
assert out["crmUrl"].endswith("#Opportunity/view/O1")
payload = espo.create_opportunity.call_args.args[0]
assert payload["cAccount1Id"] == "A1" and "accountId" not in payload
assert payload["teamsIds"] == ["T1"]
assert payload["cTagsIds"] == ["TAG1"]
espo.ensure_account.assert_called_with("Aristo Group", "Reseller")
espo.ensure_ctag.assert_called_with(stages.AUTO_TAG_NAME)
assert stages.AUTO_TAG_NAME == "Auto: Durch Suchagent-Treffer erstellt"
def test_stage_crm_tag_missing_in_readback_fails_verification():
ctx = ctx_after_match([{"nr": 1, "rating": "yes", "reason": "ok"},
{"nr": 2, "rating": "yes", "reason": "ok"}])
ctx = stages.stage_rules(ctx, CFG)
espo = mock.Mock()
espo.team_id.return_value = "T1"
espo.ensure_account.return_value = "A1"
espo.ensure_contact.return_value = "C1"
espo.ensure_ctag.return_value = "TAG1"
espo.unique_opportunity_name.return_value = "Python Entwickler KI"
espo.create_opportunity.return_value = {"id": "O1"}
espo.get_opportunity.return_value = {
"id": "O1", "name": "Python Entwickler KI",
"cProjektlink": "https://x/projekt/p",
"description": ctx["description"], "cAccount1Id": "A1",
"accountId": None, "teamsIds": ["T1"], "cTagsIds": []}
out = stages.run_stage("crm", stages.stage_crm, ctx, CFG, espo=espo)
assert out["status"] == "failed"
assert "cTagsIds" in out["error"]
def test_stage_crm_skips_when_rejected():