---
name: projekt-anlegen
description: >-
  Bewertet Freiberufler-Projektausschreibungen gegen den Lebenslauf von Dr.-Ing. Thomas Langer
  und trägt das Ergebnis als Verkaufschance (Opportunity) ins EspoCRM ein: Anforderungen
  extrahieren, in Must/Nice/Misc einordnen, Abdeckung mit ✅/❌/❔ bewerten, Must-have-Match und
  Nice-to-have-Match berechnen, nach Review im Chat ins CRM schreiben. Verwende diesen Skill
  IMMER, wenn der Nutzer ein Projektangebot, eine Projektausschreibung oder ein Stellenangebot
  für Freiberufler bewerten oder ins CRM eintragen will (z. B. von freelancermap, GULP/Randstad,
  Hays, SThree, etwa „Bewerte das Projekt", „Projekt-Match", „Passt dieses Projekt zu mir?",
  „Anforderungen abgleichen", „Projekt anlegen", „Projekt ins CRM eintragen").
---

# Projekt anlegen: Projektangebote bewerten und ins EspoCRM eintragen

Pipeline: Ausschreibung beschaffen → Anforderungen + Käufer/Firma/Kontakt extrahieren → einordnen → gegen Lebenslauf bewerten → Rahmenbedingungen gegen Vorgaben bewerten → Matches berechnen → **Review im Chat (Pflicht)** → Firma → Kontakt → Verkaufschance ins EspoCRM schreiben (per Shell) → verifizieren.

## Voraussetzungen und feste Pfade

| Zweck | Pfad |
|---|---|
| Lebenslauf (Markdown) | `vorgaben/Lebenslauf_Dr-Ing_Thomas_Langer.md` |
| Rahmenbedingungen (Misc-Bewertung) | `vorgaben/rahmenbedingungen.md` |
| EspoCRM-Zugangsdaten | `.secrets/espocrm-api.md` |

Alle drei mit dem Read-Tool lesen. Ohne Lebenslauf keine fachliche Bewertung, ohne Rahmenbedingungen keine automatische Misc-Bewertung, ohne Zugangsdaten kein CRM-Eintrag.

Die Zugangsdaten-Datei enthält Base-URL, Auth-Header-Schema, API-Key sowie verifizierte Entitäts- und Feldnamen. **Der API-Key darf niemals im Chat angezeigt, in Dateien geschrieben oder in Commits/Doku übernommen werden** — er wird nur zur Laufzeit in eine Shell-Variable gelesen und direkt in `curl`-Aufrufe eingesetzt.

## Schritt 1: Ausschreibung beschaffen und Eckdaten extrahieren

Die Projektausschreibung kommt auf zwei Wegen:
- **URL:** Nennt der Nutzer eine URL, hole den Seitentext mit dem **WebFetch-Tool**.
- **Text:** Fügt der Nutzer den Ausschreibungstext direkt in den Chat ein, nutze diesen.

Bei Login-geschützten Portalen ist das Einfügen von Text nötig. Ist unklar, welche Quelle gilt, nachfragen.

Daraus entnehmen:
- **Projektname**: der Titel der Ausschreibung, ohne Portal-Zusatz (z. B. ohne „auf www.freelancermap.de").
- **Projekt-URL**: die tatsächliche URL der Seite, falls vorhanden.
- **Beschreibung und Aufgaben**: nur Kontext für die Bewertung, keine Tabellenzeilen.
- **Anforderungen**: jede Anforderung einzeln, im Originalwortlaut (behutsam kürzen ist erlaubt, Bedeutung nie verändern). Rahmenbedingungen (Start, Einsatzort/Remote-Anteil, Auslastung, Laufzeit) zählen als Anforderungen der Kategorie Misc.
- **Angebotstyp** (bestimmt das CRM-Team der Verkaufschance, siehe Schritt 7.0):
  - **Projekt** — Freiberufler-/Werkauftrag, ob über Agentur oder als Direktauftrag → Team `DesTEngS`.
  - **Arbeitnehmer-Angebot** — Fest-/Angestelltenverhältnis (Signale: „Festanstellung", „unbefristet", „Anstellung", „Gehalt", „Arbeitsvertrag") → Team `Arbeitnehmer`.
  - **Arbeitnehmerüberlassung (ANÜ)** — Personalüberlassung/Zeitarbeit (Signale: „Arbeitnehmerüberlassung", „ANÜ", „AÜG", „Überlassung") → Team `ANÜ`.
  - Nicht eindeutig zuordenbar → im Review nachfragen.
  - Der Käufer-Typ (Agentur/Direktauftrag) unten ist nur eine Unterscheidung **innerhalb** des Angebotstyps „Projekt".
- **Käufer-Typ**: Agentur (Wiederverkäufer) ODER Endkunde (Direktauftrag).
  - Agentur-Signale: bekannte Personaldienstleister/Vermittler (Hays, GULP/Randstad, SThree/Computer Futures, Aristo …), Formulierungen wie „im Auftrag unseres Kunden", „für unseren Kunden", Vermittler-Kontext des Portals.
  - Nicht eindeutig bestimmbar → im Review nachfragen.
- **Firmenname**: Name der Agentur bzw. des Endkunden.
- **Ansprechperson**: vollständiger Name, falls genannt.

## Schritt 2: Einordnung (Kat.)

Jede Anforderung bekommt genau eine Kategorie:

- **Must** — zwingend. Signale: eigener Abschnitt „Must-haves"/„Anforderungen", Wörter wie „zwingend", „erforderlich", „vorausgesetzt", „sehr gute Kenntnisse".
- **Nice** — optional. Signale: Abschnitt „Nice-to-haves", Wörter wie „von Vorteil", „wünschenswert", „idealerweise", „plus".
- **Misc** — Rahmenbedingungen (Start, Ort, Auslastung, Laufzeit) und alles, was weder Muss noch Wunsch-Qualifikation ist.

Explizite Abschnittsüberschriften der Ausschreibung haben Vorrang vor Signalwörtern. Steht „idealerweise" innerhalb einer Must-Anforderung (z. B. „… und idealerweise Kubernetes"), bleibt die Zeile Must — der idealerweise-Teil ist dann für die Bewertung nicht ausschlaggebend.

## Schritt 3: Bewertung gegen den Lebenslauf

Lebenslauf vollständig lesen und jede Anforderung nüchtern bewerten:

- **✅** nur bei klarer Evidenz im Lebenslauf.
- **❌** wenn der Lebenslauf nichts Belastbares hergibt. Streng bleiben — eine geschönte Bewertung macht die Match-Zahlen wertlos. Kalibrierung aus der Praxis: Proof-of-Concept-Erfahrung deckt „produktiven Betrieb" nicht ab; „mehrjährig" wörtlich nehmen; ein ALM-Produktname (z. B. „Azure DevOps Server") belegt keine Cloud-Plattform-Erfahrung.
- **❔** nur bei echter Teilevidenz, wenn die Entscheidung von Wissen abhängt, das nur Thomas hat — solche Fälle klärt das Review.
- „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).
- Rahmenbedingungen (Misc) werden NICHT hier, sondern in Schritt 3b automatisch gegen `vorgaben/rahmenbedingungen.md` bewertet.

## Schritt 3b: Rahmenbedingungen automatisch bewerten (Misc)

`vorgaben/rahmenbedingungen.md` lesen und jede Misc-Anforderung danach bewerten:

- **Verfügbar ab / Projektstart:** Start ≤ heute + 8 Wochen (56 Tage) → ✅; > 8 Wochen → ❌; nicht genannt → ❔. („heute" = aktuelles Datum.)
- **Auslastung:** 75–100 % → ✅; < 75 % → ❔; nicht genannt → ❔.
- **Laufzeit:** jede → ✅.
- **Einsatzort/Remote:** 100 % Remote → ✅. Bei < 100 % Remote (Hybrid/Onsite) Distanz des Onsite-Orts zu Sauerlach bestimmen (siehe unten): ≤ 50 km → ✅; 50–60 km → ❔; > 60 km → ❌. Einsatzort/Remote-Anteil unklar oder nicht genannt → ❌.
- **Sicherheitsüberprüfung:** Wird eine Sicherheitsüberprüfung gefordert (SÜ, SÜ1/SÜ2/SÜ3, Ü2, Geheimschutz o. Ä.) → **immer ❌** (Dauer ≥ 3 Monate, K.-o.-Kriterium). Im Review als K.-o. hervorheben.

**Distanzbestimmung (Geokodierung, Pflicht bei Onsite/Hybrid):** Onsite-Ort per Nominatim auflösen und Luftlinie zu Sauerlach (47,9721 N / 11,6528 O) per Haversine rechnen. Bei Geokodierungs-Fehler/Mehrdeutigkeit (Ausgabe `UNBEKANNT`) → ❔. Nominatim-Policy: max. 1 Request/s.

```bash
python3 - "<ONSITE_ORT>" <<'PY'
import sys, json, urllib.request, urllib.parse
from math import radians, sin, cos, asin, sqrt
ort = sys.argv[1]
UA = 'bewerb-projekt-anlegen/1.0 (Thomas.Langer@destengs.com)'
def geo(q):
    url = 'https://nominatim.openstreetmap.org/search?' + urllib.parse.urlencode(
        {'q': q, 'format': 'json', 'countrycodes': 'de', 'limit': 1})
    d = json.load(urllib.request.urlopen(
        urllib.request.Request(url, headers={'User-Agent': UA}), timeout=20))
    return (float(d[0]['lat']), float(d[0]['lon'])) if d else None
S = (47.9721357, 11.6528398)
g = geo(ort)
if not g:
    print('UNBEKANNT')
else:
    dlat = radians(g[0]-S[0]); dlon = radians(g[1]-S[1])
    h = sin(dlat/2)**2 + cos(radians(S[0]))*cos(radians(g[0]))*sin(dlon/2)**2
    print(f'{2*6371*asin(sqrt(h)):.1f} km')
PY
```

## Schritt 4: Match-Berechnung

- **Must-have-Match**: Mittelwert über alle Must-Zeilen mit ✅ = 100 %, ❔ = 25 %, ❌ = 0 %.
- **Nice-to-have-Match**: dasselbe über alle Nice-Zeilen.
- Misc-Zeilen gehen in keinen Match ein.
- Auf ganze Prozent runden. Ist eine Kategorie leer, „–" statt einer Zahl schreiben.

## Schritt 5: Ausgabeformat (exakt einhalten)

Der Inhalt des CRM-Feldes „Beschreibung" — genau dieses Format, Spaltenüberschriften wörtlich (`Kat.` und `❔` halten die Spalten schmal):

```markdown
Must-have-Match: 50 % · Nice-to-have-Match: 33 %

| Nr. | Kat. | ❔ | Anforderung |
|---|---|---|---|
| 1 | Must | ❌ | Mehrjährige praktische Erfahrung im Aufbau und Betrieb von AI-/LLM-Plattformen |
| 2 | Must | ✅ | Erfahrung mit RAG-Systemen, Vektordatenbanken und Anbindung relevanter Datenquellen |
| 3 | Nice | ✅ | Kenntnisse in LLM-Inference-Stacks (z. B. vLLM, TGI, Triton, Ray) |
| 4 | Misc | ✅ | Hybrid: ca. 40 % onsite München, 60 % remote |
```

- `Nr.` fortlaufend ab 1, Reihenfolge: erst alle Must-, dann Nice-, dann Misc-Zeilen (innerhalb der Kategorie in Reihenfolge der Ausschreibung).
- `Kat.` ∈ {Must, Nice, Misc}; Bewertungsspalte ∈ {✅, ❌, ❔}.
- Beide Match-Zahlen in einer Zeile, getrennt durch ` · `.

## Schritt 6: Review im Chat — immer vor jedem CRM-Schreibvorgang

Niemals ohne explizite Freigabe ins CRM schreiben. Im Chat zeigen:

1. Vorgesehener **Name** der Opportunity (= Projektname) und **Projektlink**.
2. **Angebotstyp** (Projekt / Arbeitnehmer-Angebot / ANÜ) und resultierendes **Team** (`DesTEngS` / `Arbeitnehmer` / `ANÜ`) mit Kurzbegründung.
3. **Käufer-Typ** (Agentur/Direktauftrag) mit Kurzbegründung — nur bei Angebotstyp „Projekt".
4. **Firma:** Name und `type` (Reseller bei Agentur, Customer bei Direktauftrag); „neu anlegen" oder „bestehende nutzen: <Name/ID>". Bei ähnlichen, nicht identischen Treffern die Kandidatenliste zeigen und entscheiden lassen.
5. **Kontakt:** Name; „neu anlegen" oder „bestehend nutzen: <Name/ID>".
6. **Verknüpfungs-Zuordnung** der Verkaufschance (account vs. cAccount1, Kontakt, Team).
7. Den wörtlichen Beschreibungs-Markdown in einem Codeblock.
8. Kurze Begründungen zu allen ❌- und ❔-Bewertungen (Must/Nice/Misc) außerhalb der Tabelle.

Korrekturen kommen als „Nr. X → ✅/❌/❔" oder als Korrektur zu Firma/Kontakt/Typ. Danach betroffene Matches neu berechnen und geänderte Werte nennen. Erst nach Freigabe weiter zu Schritt 7.

## Schritt 7: CRM-Eintrag per Shell — Reihenfolge Firma → Kontakt → Verkaufschance

Diese Umgebung erreicht die CRM-Domain direkt; der Zugriff läuft per `curl`. Key/Base sicher laden (Werte nicht ausgeben):

```bash
KEY=$(grep -oE '[0-9a-f]{32}' .secrets/espocrm-api.md | head -1)
BASE=$(grep -oE 'https://[^ `]+/api/v1' .secrets/espocrm-api.md | head -1)
```

### 7.0 Team-ID ermitteln (aus Angebotstyp)
Team-Name aus dem Angebotstyp (Schritt 1): Projekt → `DesTEngS`, Arbeitnehmer-Angebot → `Arbeitnehmer`, ANÜ → `ANÜ`. ID per Namens-Suche holen:

```bash
curl -s -G "$BASE/Team" -H "X-Api-Key: $KEY" \
  --data-urlencode 'where[0][type]=equals' \
  --data-urlencode 'where[0][attribute]=name' \
  --data-urlencode 'where[0][value]=<TEAM>' \
  --data-urlencode 'select=name' --data-urlencode 'maxSize=10'
```

`id` aus dem Treffer als TEAM_ID merken. **Niemals eine Team-ID raten.** Kommt die Liste leer zurück oder fehlt der API-Rolle der Team-Zugriff (Lesen leer trotz `200`, `POST /Team` → `403 No create access`), nicht weiterschreiben, sondern im Chat melden: Der CRM-Benutzer `cowork-api` braucht Zugriff auf die Entität `Team` (mindestens Lesen + Team-Zuweisung auf Verkaufschancen), und die Teams `DesTEngS`, `Arbeitnehmer`, `ANÜ` müssen im CRM existieren. Erst dann lässt sich `teamsIds` setzen.

### 7.1 Firma (Account)
Dedup-Suche mit einem Kern-Token des Namens (z. B. „Aristo"):

```bash
curl -s -G "$BASE/Account" -H "X-Api-Key: $KEY" \
  --data-urlencode 'where[0][type]=contains' \
  --data-urlencode 'where[0][attribute]=name' \
  --data-urlencode 'where[0][value]=<KERN>' \
  --data-urlencode 'select=name,type' --data-urlencode 'maxSize=50'
```

Treffer gemäß Review-Entscheidung behandeln. Bestehende Firma → deren `id` als ACCOUNT_ID merken. Sonst neu anlegen — Payload mit dem **Write-Tool** nach `/tmp/account.json` (`type`: `Reseller` bei Agentur, `Customer` bei Direktauftrag):

```json
{"name": "<FIRMENNAME>", "type": "<Reseller|Customer>"}
```

```bash
curl -s -i -X POST "$BASE/Account" -H "X-Api-Key: $KEY" -H 'Content-Type: application/json' --data @/tmp/account.json
```

`id` aus der Antwort als ACCOUNT_ID merken.

### 7.2 Kontakt (Contact)
Dedup-Suche per Nachname:

```bash
curl -s -G "$BASE/Contact" -H "X-Api-Key: $KEY" \
  --data-urlencode 'where[0][type]=contains' \
  --data-urlencode 'where[0][attribute]=name' \
  --data-urlencode 'where[0][value]=<NACHNAME>' \
  --data-urlencode 'select=name,accountName' --data-urlencode 'maxSize=50'
```

Bestehenden Kontakt → dessen `id` als CONTACT_ID merken. Sonst neu anlegen — Payload nach `/tmp/contact.json`; die **Verknüpfung zur Firma über `accountId` ist beim Neu-Anlegen zwingend**:

```json
{"firstName": "<VORNAME>", "lastName": "<NACHNAME>", "accountId": "<ACCOUNT_ID>"}
```

```bash
curl -s -i -X POST "$BASE/Contact" -H "X-Api-Key: $KEY" -H 'Content-Type: application/json' --data @/tmp/contact.json
```

`id` aus der Antwort als CONTACT_ID merken. Namensaufteilung: letztes Token = `lastName`, der Rest = `firstName`; ungewöhnliche Fälle im Review klären.

### 7.3 Verkaufschance (Opportunity)
Duplikat-Prüfung des Namens (bei Treffer Suffix „ (2)", „ (3)", … anhängen):

```bash
curl -s -G "$BASE/Opportunity" -H "X-Api-Key: $KEY" \
  --data-urlencode 'where[0][type]=startsWith' \
  --data-urlencode 'where[0][attribute]=name' \
  --data-urlencode 'where[0][value]=<PROJEKTNAME>' \
  --data-urlencode 'select=name' --data-urlencode 'maxSize=100'
```

Payload nach `/tmp/opp.json`. Verknüpfungen je Käufer-Typ:
- **Agentur:** `cAccount1Id` = ACCOUNT_ID („Über Agentur"); **kein** `accountId`.
- **Direktauftrag:** `accountId` = ACCOUNT_ID; **kein** `cAccount1Id`.

Zusätzlich immer `teamsIds` = `["<TEAM_ID>"]` aus Schritt 7.0 (Angebotstyp-Team) setzen.

```json
{"name": "<EINDEUTIGER_NAME>", "description": "<BESCHREIBUNG_MARKDOWN>", "cProjektlink": "<PROJEKT_URL>", "contactsIds": ["<CONTACT_ID>"], "teamsIds": ["<TEAM_ID>"], "cAccount1Id": "<ACCOUNT_ID — nur bei Agentur, sonst Feld weglassen und stattdessen accountId setzen>"}
```

```bash
curl -s -i -X POST "$BASE/Opportunity" -H "X-Api-Key: $KEY" -H 'Content-Type: application/json' --data @/tmp/opp.json
```

Minimaler Payload genügt (Stage/Wahrscheinlichkeit setzt das CRM). Bei HTTP 400 steht die Ursache im `X-Status-Reason`-Header. Nachträgliche Korrekturen: `PUT "$BASE/Opportunity/<id>"`. Danach `/tmp/account.json /tmp/contact.json /tmp/opp.json` löschen.

## Schritt 8: Verifizieren und berichten

Nach dem POST den Datensatz gegenlesen:

```bash
curl -s -G "$BASE/Opportunity/<id>" -H "X-Api-Key: $KEY"
```

Prüfen: stimmen Name und Projektlink, hat die Beschreibung die Match-Zeile und alle Tabellenzeilen, sind ✅/❌/❔ intakt; sind die Verknüpfungen korrekt — Agentur: `cAccount1Name` gesetzt und `accountName` leer; Direktauftrag: `accountName` gesetzt; `contactsIds`/`contactId` enthält den Kontakt; `teamsNames` enthält das richtige Team (`DesTEngS` / `Arbeitnehmer` / `ANÜ`). Dann im Chat melden: gewählter Name (inkl. evtl. Duplikat-Suffix), beide Match-Zahlen, Firma und Kontakt (jeweils neu/bestehend), zugewiesenes Team und der Direktlink `https://crm.creature-go.com/#Opportunity/view/<id>`.
