Files
bin/docs/projekt-matching-dokumentation.md
tlg 86bd7f6a07 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>
2026-08-17 18:09:06 +02:00

42 KiB
Raw Blame History

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

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):

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):

Ausschreibungstext:

<page_text>

Ausgabeschema Extraktion (llm.EXTRACT_SCHEMA, vollständig):

{
  "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):

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>):

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):

{
  "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-AngebotArbeitnehmer, 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.