From 49aed3b2431eda6fb3da6cfa8dbbadb05cf2a4fe163ef20b29a5a1b29c30505e Mon Sep 17 00:00:00 2001 From: tlg Date: Thu, 9 Jul 2026 20:19:56 +0200 Subject: [PATCH] Add user manual and design description for projekt-matching Co-Authored-By: Claude Fable 5 --- docs/projekt-matching-design.md | 303 +++++++++++++++++++++++++++ docs/projekt-matching-user-manual.md | 229 ++++++++++++++++++++ 2 files changed, 532 insertions(+) create mode 100644 docs/projekt-matching-design.md create mode 100644 docs/projekt-matching-user-manual.md diff --git a/docs/projekt-matching-design.md b/docs/projekt-matching-design.md new file mode 100644 index 0000000..b1999f6 --- /dev/null +++ b/docs/projekt-matching-design.md @@ -0,0 +1,303 @@ +# Projekt-Matching — Design Description & Experiences + +*Companion to the [user manual](projekt-matching-user-manual.md). Built +2026-07-09 via spec-driven, subagent-executed development; merged to `main` +as `72a3b17`. Approved spec: +`docs/superpowers/specs/2026-07-09-langflow-projekt-matching-design.md`; +implementation plan (with deviations preamble): +`docs/superpowers/plans/2026-07-09-langflow-projekt-matching.md`.* + +--- + +## 1. Goal and constraints + +Convert the interactive Claude skill `projekt-anlegen` (evaluate freelance +project postings against Dr.-Ing. Thomas Langer's CV, write results into +EspoCRM) into a fully automated Langflow workflow with these hard +constraints: + +- **Local AI only** — vLLM endpoint on this host, model + `AxionML/Qwen3.5-9B-NVFP4`; no cloud LLM. +- **No human review pause** — the skill's mandatory chat review was replaced + by defaults (ambiguous offer type → `Projekt`, ambiguous buyer → agency) + and by after-the-fact quality monitoring in Langfuse. +- **Numbers are never LLM output** — match calculation, Misc rules + (start-date window, distance via Nominatim/Haversine, security-clearance + K.-o.) and the exact CRM description markdown are deterministic Python. +- **Gate:** Must-have-Match **> 85 %** (strictly greater) → CRM + mail; + otherwise reject silently (trace only). +- **Terminal-state rule:** every project ends in exactly one of + `created / rejected / duplicate / failed` on the **first** attempt; the + trigger mail then always moves to Trash; failures produce one aggregated + alert per trigger mail. No cross-cycle retries (only bounded in-run HTTP + retries) — deliberate protection against retry loops and inbox congestion. +- **Duplicate key:** canonical project URL (redirects followed, query + stripped) stored in and matched against `cProjektlink`. + +## 2. Architecture + +### 2.1 The central decision: logic in a package, Langflow as the runtime + +All behavior lives in a plain-Python package **`projektmatch`** that is +unit-tested on the host (51 pytest tests) and copied unchanged into the +Langflow container. The seven Langflow custom components are ~30-line +wrappers that read configuration from Langflow global variables and call one +package function each. This kept the visual pipeline Langflow promises while +making every business rule testable without a running container — by far the +highest-leverage choice in the project: 8 of 16 plan tasks were pure TDD on +the package, and almost every live-integration bug was fixable by editing +tested package code and re-running one deploy script. + +### 2.2 Runtime topology + +``` +systemd user timer (5 min) ── curl POST ──▶ /api/v1/webhook/pm-ingest + │ + ┌── Flow 1 "PM Ingest" ────────────────────────▼────────┐ + │ Webhook → PMIngest → TextOutput │ + │ PMIngest = ingest.run_ingest(): │ + │ flock lock → IMAP poll (UNKEYWORD $ProjektChecked) │ + │ → own-mail/non-project filter → split projects │ + │ → canonical URL → CRM dedup → per NEW project: │ + │ POST /api/v1/run/ (x-api-key) ───────────┼──┐ + │ → aggregate → ONE alert mail on failures │ │ + │ → move trigger mail to Trash → summary │ │ + └───────────────────────────────────────────────────────┘ │ + ┌── Flow 2 "PM Projekt bewerten" ──────────────────────────▼┐ + │ TextInput → PMFetch → PMExtract → PMMatch → PMRules │ + │ → PMCrm → PMNotify → TextOutput │ + │ ctx dict flows through; status ∈ ok/failed/rejected/ │ + │ created; run_stage() catches exceptions into failed and │ + │ short-circuits later stages; PMNotify runs UNCONDITIONALLY│ + │ (sends mail only on created, posts Langfuse trace always) │ + └───────────────────────────────────────────────────────────┘ +``` + +Flow 1 is a single custom component rather than the spec sketch's three, +because IMAP connections and per-mail state cannot cross Langflow component +boundaries; `run_ingest()` is fully unit-tested instead. Flow 2 keeps the +six visible stages. + +### 2.3 Module inventory + +| Module | Responsibility | +|---|---| +| `rules.py` | Misc rules, half-up match calc, exact description markdown | +| `mailparse.py` | own-mail detection, project splitting (longest-anchor-title dedup by URL path), canonical URL | +| `espocrm.py` | CRM client: dedup searches, team lookup (never guessed), ensure-account/contact, unique naming, retries, `X-Status-Reason` surfacing | +| `llm.py` | vLLM calls (`response_format json_schema`, thinking disabled, no `max_tokens`), German prompts + JSON schemas, retry-on-empty, match completeness retry | +| `mailer.py` | IMAP (custom keyword, Trash detection, MOVE+fallback), SMTP (STARTTLS, Date/Message-ID) | +| `stages.py` | Flow-2 stage functions + ctx state machine + CRM verify read-back | +| `pm_trace.py` | one consolidated Langfuse trace per project via the ingestion API (never raises) | +| `ingest.py` | Flow-1 orchestration: lock, loop, dispatch, alert aggregation, trash | +| `deploy/` | `deploy_files.sh` (rsync + safe restart), `build_flows.py` (variables, API key, programmatic flow construction), `setup_langfuse.py`, systemd units, webhook trigger | +| `tests/` | 51 unit tests + 5 e2e helper scripts | + +### 2.4 Trust boundaries and secrets + +IMAP/SMTP password and EspoCRM API key live in Langflow global variables +(encrypted in Postgres) and chmod-600 files under `~/.config/projekt-matching/`; +they never entered git (verified across all 31 commits in the final review). +Langfuse keys follow the pod script's pre-existing convention (tracked, local +loopback services only — an accepted, user-confirmed trade-off). The CV and +the CRM secrets file are owned by `tlg` and readable by `lwc` via +`setfacl -m u:lwc:r`. + +## 3. Key design decisions and their rationale + +1. **Canonical-URL dedup** (not name-based): deterministic, immune to title + edits; the same job at two agencies intentionally stays two opportunities. +2. **Delete-mail-always + alert** (revised mid-design from + "retry until success"): deterministic failures would otherwise loop every + 5 minutes forever. The alert transfers responsibility to the human; + in-run HTTP retries (2–3 attempts) absorb transient blips. +3. **Deterministic scoring layer**: the LLM only classifies and rates; + Python computes. This survived the model's quality variance — when + ratings flickered, the numbers stayed honest and auditable. +4. **One custom Langfuse trace per project** (via the ingestion REST API, not + the SDK): stable contract, independent of Langflow's own auto-tracing, + filterable by outcome tags, and the object your annotation queue feeds on. + Posted *last* in `stage_notify`, unconditionally — even for failures. +5. **Programmatic flow construction** (`build_flows.py` assembles node/edge + JSON via `POST /api/v1/custom_component` templates): flows are + reproducible from git; the Langflow UI is never the source of truth. +6. **Everything idempotent**: variable upserts, flow delete-and-recreate, + API-key delete-and-recreate, file rsync — any deploy step can be re-run. + +## 4. Experiences — what the plan got wrong and what we learned + +The plan deliberately marked three "known unknowns" with verify-and-adapt +steps; live integration surfaced several more. Each item below is baked into +the code and worth knowing before touching the system. + +### 4.1 vLLM / Qwen3.5 structured output + +- **`guided_json` is silently ignored** by vLLM 0.22.1 — HTTP 200, + unconstrained prose output. The plan's guided_json-primary + + 400-triggered-fallback design could therefore never fall back. Fixed by + using **`response_format {"type": "json_schema"}`** as the sole mechanism + (verified enforced: exact schema keys returned). +- **Reasoning + schema enforcement degenerate**: with the qwen3 reasoning + parser active, `json_schema` decoding sent the model into thinking loops + that consumed the full 63k context (`finish_reason: length`, empty + content). Root-cause fix: `chat_template_kwargs {"enable_thinking": false}` + for structured calls — extraction dropped from minutes/never to ~35 s. + A test locks this parameter in place. +- **Empty completions under load** still occur occasionally (shared GPU, + concurrent runs): `chat_json` retries empty/unparseable content up to 3× + with backoff; non-200 fails fast. +- The old belief "set max_tokens ≥ 1024 for reasoning models" evolved into + **"set no max_tokens at all** and disable thinking where you enforce + schemas". + +### 4.2 Langflow 1.10 internals + +- **`Component.ctx` is a reserved property** — a `DataInput(name="ctx")` + silently shadows the wired edge payload. All data inputs are named + `ctx_in`. +- **The custom-component loader resolves imports statically** — in-file + `sys.path.insert()` hacks don't work. First fix (a `.pth` file in the + container venv) turned out non-durable because the systemd `--new` unit + **recreates the container on every restart**, wiping ephemeral writes. The + durable fix bakes `PYTHONPATH=/app/langflow` into the container definition + in `create_pod_langflow.sh`. +- **Never `podman restart` a systemd-managed `--rm/--new` container** — it + can end up removed with the unit inactive. Always + `systemctl --user restart container-langflow_ctr.service` + (`deploy_files.sh` does this and waits for health). +- Webhook lives in the `input_output` category of `GET /api/v1/all` (not + `data`); `Data` outputs wire as type `JSON`; edge handles use the + `œ`-escaped JSON encoding. `build_flows.py` derives types from live + templates instead of hardcoding — the plan's explicit "verify against a + dumped existing flow first" step paid off. +- `POST /api/v1/api_key` accumulates same-named keys; the builder now + deletes existing `projekt-matching` keys before minting (list shape: + `{"api_keys": [...]}`). +- **`podman logs langflow_ctr` is useless** (a structlog recursion loop + floods it); the monitor/builds API is the working observability path. +- Auth split (from earlier experiments, confirmed): flow CRUD/variables need + the Bearer JWT from `/api/v1/auto_login`; `run` and webhook need + `x-api-key`; responses are gzip (`curl --compressed`). +- Global variables with `load_from_db=True` resolve **at run time** — the + Gate-3 recipient flip needed no restart. + +### 4.3 Langfuse 3.195 (OSS) + +- The **Organization Management API is EE-gated** (routes exist, return 403). + `setup_langfuse.py` falls back to a NextAuth session + tRPC calls to create + org/project/keys — fragile by nature, acceptable for one-shot setup, kept + behind an admin-API-first attempt. +- Score configs can be created via the public API with project keys; + **annotation queues are UI-only** (manual step, done). +- Score-config creation is not idempotent (archive-only API) — documented in + the script. + +### 4.4 E-mail + +- Outgoing `EmailMessage`s **need explicit `Date` and `Message-ID` headers**; + without them clients display 01/01/1970 (user-reported, fixed, and the fix + had to wait for a container restart — a long-lived process keeps the old + module in memory). +- The mail server (Dovecot) supports custom IMAP keywords (`PERMANENTFLAGS \*`) + and UID MOVE; the Trash folder is literally `Trash`. The `$ProjektChecked` + keyword implements "inspect non-project mail exactly once, touch nothing". +- A malformed FETCH must not kill the poll loop: hardened so an unreadable + mail is flagged and skipped (`summary.unreadable`), the connection is + closed in `finally` — otherwise one poison mail would silently stall the + pipeline every 5 minutes with no alert. + +### 4.5 Prompt engineering for a 9B model + +The extraction/matching prompts embed the skill's German calibration rules +verbatim (strictness: PoC ≠ production, "mehrjährig" literal, "wie z. B." +alternatives count). Two tuning iterations were needed against the reference +project (skill result: 86 % Must): + +- **Signal words must bind to their own line only** — trailing unmarked lines + after a "von Vorteil" line were bleeding into Nice; as Must the reference + computes 18✅+3❌ of 21 = 85.71 → 86, exactly reproducing the skill. +- …with an **explicit exception for a literal "Nice-to-haves" heading** + (heading precedence stays above line-level signals) — added after review + caught the over-generalization. +- Residual variance: Must-match stable (86 in all post-fix runs); Nice-match + flips a few points run-to-run; Misc rows occasionally missing. The + deterministic layer plus the >85 gate make this tolerable; the annotation + queue exists to watch it. + +### 4.6 Process experiences + +- **Subagent-driven development with per-task adversarial review** caught + real defects at every altitude: committed build artifacts, undeclared + dependencies, a read-back that verified presence instead of equality, + alert-send blocking the trash guarantee, the prompt over-generalization. + Two verification-only gates were audited as *evidence* (timestamps, IDs, + counts, alternative explanations) rather than trusted — one audit forced a + trace-level proof that an unexpected notification during Gate 2 was + genuine production traffic (it was: a real project, Must 100 %, evaluated + and filed while we were testing). +- **Live systems interleave with tests.** Real project mails arrived and + were processed during every e2e window. Distinguishing test artifacts from + production artifacts by canonical-URL tokens — and writing that discipline + into the helper scripts — was essential. +- The **progress ledger** (`.superpowers/sdd/progress.md`, git-ignored) + carried environment facts between tasks; most of its content graduated + into this document and the memory notes. + +## 5. Verification summary + +- **51 unit tests** (pure package, no network); TDD throughout. +- **Component gates:** Flow 2 driven directly via API — consider project + stable at 86 % across repeated runs with full CRM field verification + (team, agency linking, contact, description byte-format); reject project + at 0 %, no writes. +- **Gate 1 (e2e consider):** re-sent original example-1 mail (5 projects) → + timer → project 4 created (86 %), notification with valid Date, trigger + mail in Trash, 5 traces (1 created / 4 rejected), zero unwanted entries. +- **Gate 2 (e2e reject):** example-2 mail → full table in trace, Must 0 %, + no CRM entry, no mail, mail in Trash. +- **Dedup:** re-sent Gate-1 mail → ingest summary + `{projects: 5, created: 0, rejected: 4, duplicate: 1}`, notification count + unchanged, no ` (2)` entry. +- **Gate 3 (production recipient):** variables flipped live (no restart), + opportunity re-created at 86 %, no mail in the design-phase inbox, SMTP + acceptance proven via trace error-absence, **receipt at + Thomas.Langer@destengs.com confirmed by Thomas**. + +## 6. Known limitations & future work + +- freelancermap-only detection (extension point: `mailparse.PROJECT_URL_RE` + + splitting logic). +- Login-walled postings fail with an alert by design (no snippet fallback). +- Created-but-notification-failed edge case is recorded in the trace, not + re-sent (spec-accepted). +- Worst-case in-run LLM latency (nested retries) can exceed the 1800 s + dispatch timeout — the ingest side then alerts while Flow 2 may still + finish server-side; dedup prevents damage. Cap the budgets if it ever + bites. +- `parse_run_result` anchors on "any embedded JSON with a `status` key" — + robust today, but a Langflow upgrade that serializes intermediate ctx + objects into the run response would need a stricter anchor. +- niceMatch variance / Misc extraction variance: monitor via the + `projekt-matching-review` annotation queue; if quality drifts, tuning + lives in `llm.py`'s prompts and is regression-tested by + `tests/e2e/run_flow2.py` against the two reference projects. + +## 7. Restore from a fresh clone (runbook) + +1. `bash create_pod_langflow.sh` — pod, DBs, Langflow, Langfuse (bootstrap + admin + tracing keys baked in). +2. Ensure ACLs: `setfacl -m u:lwc:r` on the CV and + `.secrets/espocrm-api.md` (as `tlg`); create + `~/.config/projekt-matching/secrets.env` (0600) with `PM_IMAP_PASSWORD`. +3. `projekt-matching/deploy/deploy_files.sh` — package + vorgaben into the + data dir. +4. `set -a; source projekt-matching/deploy/secrets.local.env; set +a` then + `.venv/bin/python projekt-matching/deploy/build_flows.py` — variables, + API key, both flows, `~/.config/projekt-matching/env`. +5. `.venv/bin/python projekt-matching/deploy/setup_langfuse.py` — org, + project, keys, score configs (annotation queue: manual UI step); wire the + printed keys into the pod script and rerun it. +6. `cp projekt-matching/deploy/projekt-matching.{service,timer} ~/.config/systemd/user/` + then `systemctl --user daemon-reload && systemctl --user enable --now projekt-matching.timer`. +7. Optional clean start: `.venv/bin/python projekt-matching/tests/e2e/flag_inbox.py` + to baseline the current INBOX before the first tick. diff --git a/docs/projekt-matching-user-manual.md b/docs/projekt-matching-user-manual.md new file mode 100644 index 0000000..cbadc05 --- /dev/null +++ b/docs/projekt-matching-user-manual.md @@ -0,0 +1,229 @@ +# Projekt-Matching — User Manual + +*The automated pipeline that turns freelancermap project e-mails into evaluated +EspoCRM opportunities. Version of 2026-07-09 (all acceptance gates passed).* + +--- + +## 1. What the system does + +Every 5 minutes, the system checks the mailbox `chancen@destengs.com` for new +e-mails containing freelancermap project descriptions. For every project it +finds, it: + +1. resolves the project's canonical URL and checks whether it already exists + in the CRM (if yes: nothing happens — no duplicates), +2. fetches the full project description from freelancermap, +3. lets the **local AI** (vLLM, `AxionML/Qwen3.5-9B-NVFP4` — nothing leaves + your machine) extract the requirements and classify them as + **Must / Nice / Misc**, +4. lets the AI rate every Must/Nice requirement against your CV + (✅ covered / ❌ not covered / ❔ partial evidence), +5. computes the match values **deterministically in Python** (the AI never + invents the numbers): ✅ = 100, ❔ = 25, ❌ = 0, averaged per category, +6. decides: **Must-have-Match > 85 %** → the project is *considered*; + otherwise it is *rejected*, +7. for considered projects: creates the company (Account), contact person + (Contact) and the opportunity in EspoCRM — with the same description table + the old Claude skill produced — and sends you a notification e-mail, +8. records every project evaluation as a trace in Langfuse for your review, +9. moves the processed trigger e-mail to the server's **Trash** folder. + +``` + e-mail arrives every 5 min (systemd timer) + │ │ + ▼ ▼ + INBOX (chancen@) ◀──── poll ──── Langflow "PM Ingest" + │ │ per project: + │ non-project mail: ▼ + │ left untouched Langflow "PM Projekt bewerten" + │ fetch → AI extract → AI match CV + │ → deterministic match calc + ▼ → gate: Must > 85 %? + processed mail │yes │no + → Trash ▼ ▼ + EspoCRM entry (nothing; + + notification trace only) + e-mail + │ + ▼ + Langfuse trace (always) +``` + +## 2. What you receive + +### Notification (a project matched) + +- **To:** `Thomas.Langer@destengs.com` +- **Subject:** `[Projekt-Match] Freelancermap — % — ` +- **Body:** both match values and the direct link to the CRM opportunity + (`https://crm.creature-go.com/#Opportunity/view/`). + +### Alert (something went wrong) + +- **To:** `Thomas.Langer@destengs.com` +- **Subject:** `[Projekt-Match-Fehler] Freelancermap — Projekt(e)` +- **Body:** one entry per failed project with title, URL and the error reason. + +**Important:** a failed project is **not retried**. The alert e-mail is your +record — if the project matters, evaluate it manually (the URL is in the +alert). This is deliberate: it prevents endless retry loops and inbox +congestion. + +### What you will NOT see + +- Rejected projects produce no e-mail and no CRM entry. Their full evaluation + (table + match values) is in the Langfuse trace. +- Non-project e-mails in the inbox are inspected once, invisibly marked with + an IMAP keyword (`$ProjektChecked`), and left untouched forever after. +- The system never processes its own notification/alert mails (recognized by + the subject prefix). + +## 3. The CRM entry + +Considered projects get the full structure the Claude skill used to create: + +| Element | Rule | +|---|---| +| Opportunity name | project title; duplicate names get ` (2)`, ` (3)` … | +| Description | exact skill format: match line + `\| Nr. \| Kat. \| ❔ \| Anforderung \|` table, Must → Nice → Misc | +| cProjektlink | canonical project URL (also the duplicate-detection key) | +| Team | Projekt → `DesTEngS` · Arbeitnehmer-Angebot → `Arbeitnehmer` · ANÜ → `ANÜ` | +| Account | deduplicated by name; agency → `Reseller` + linked via *Über Agentur* (`cAccount1`), direct client → `Customer` + linked via `account` | +| Contact | created and linked when the posting names a person and a company | + +Ambiguities the skill used to ask you about are resolved by defaults: +unclear offer type → `Projekt`; unclear buyer type → agency. + +## 4. Reviewing quality in Langfuse + +Open **http://127.0.0.1:8091** (login `admin@example.com` / +`langfuse-admin-pw` unless you changed it), project **projekt-matching**. + +- Every evaluated project is one trace named **`projekt-match`**, tagged with + its outcome (`created` / `rejected` / `failed`). The trace shows the + canonical URL, the full requirements table, both match values and — for + created projects — the CRM link. +- **Your evaluation workflow:** open the annotation queue + **`projekt-matching-review`** and score each trace on the two dimensions: + - `extraction-correct` — were Must/Nice/Misc correctly derived from the + posting? (correct / partially-correct / wrong) + - `matching-correct` — are the ✅/❌/❔ ratings against your CV right? +- The scores are your quality record. Nothing acts on them automatically — + they tell you whether the local 9B model stays good enough over time. + +Langflow's own execution traces (component level, LLM prompts/outputs) land +in the same project and are useful for debugging. + +## 5. Everyday operations + +All commands run as user `lwc` on this host. + +### Is it running? + +```bash +systemctl --user list-timers | grep projekt-matching # next/last tick +curl -s http://127.0.0.1:8090/health # Langflow: {"status":"ok"} +journalctl --user -u projekt-matching.service -n 5 # last trigger output +``` + +To see what recent runs did, use the Langfuse traces (section 4). Do **not** +use `podman logs langflow_ctr` — it is flooded by a known harmless logging +loop and tells you nothing. + +### Pause / resume the automation + +```bash +systemctl --user stop projekt-matching.timer # pause (mail queues up safely in INBOX) +systemctl --user start projekt-matching.timer # resume; next tick processes the backlog +``` + +### Restart Langflow (e.g. after a hang) + +```bash +systemctl --user restart container-langflow_ctr.service +``` + +**Never** use `podman restart langflow_ctr` — with the systemd-managed +container this can leave the container removed and the pipeline dead. + +### Update your CV or the Rahmenbedingungen + +Edit the originals in `/home/tlg/mkt/bewerb/vorgaben/`, then: + +```bash +/home/lwc/bin/projekt-matching/deploy/deploy_files.sh +``` + +The script copies the files, redeploys the code, restarts Langflow safely and +waits until it is healthy again (≈ 1 minute). + +### Change the notification recipient or the 85 % threshold + +Both live in Langflow **global variables** (UI: http://127.0.0.1:8090 → +Settings → Global Variables, or via API): +`PM_NOTIFY_TO`, `PM_ALERT_TO`, `PM_THRESHOLD` (also: `PM_VLLM_BASE`, +`PM_VLLM_MODEL`, `PM_ESPO_BASE`). Changes take effect on the next run — no +restart needed. + +### After a reboot + +Everything is systemd-managed and starts automatically (`pod-langflow_pod` +for the services, `projekt-matching.timer` for the polling). Nothing to do. + +## 6. Troubleshooting + +| Symptom | Likely cause / what to do | +|---|---| +| No mails processed, INBOX grows | `systemctl --user list-timers` — timer active? Langflow healthy (`curl …/health`)? Restart per section 5. Mails are never lost: the next successful tick processes the backlog. | +| Alert mail: `Abruf fehlgeschlagen` / `Seitentext zu kurz` | freelancermap page unreachable or behind a login wall. Evaluate that project manually via the URL in the alert. | +| Alert mail: `vLLM HTTP …` / `LLM lieferte kein JSON` / `Read timed out` | the local AI was down or overloaded. Occasional single failures are absorbed by built-in retries; if alerts pile up, check the vLLM service on port 8081. | +| Alert mail: `Team … nicht gefunden` | the CRM user `cowork-api` lost read access to Teams, or a team was renamed. Fix in EspoCRM admin. | +| A project you expected is missing | check Langfuse: if its trace says `rejected`, the Must-match was ≤ 85 % — the table shows why. If there is no trace at all, check the alert mails. | +| Duplicate opportunity suspected | duplicates are keyed on the canonical URL (`cProjektlink`). The same project posted by two different agencies has two different URLs and is intentionally kept as two opportunities. | +| Everything looks dead after an experiment | `systemctl --user start container-langflow_ctr.service`, then check health. | + +## 7. Test utilities (use with care) + +In `/home/lwc/bin/projekt-matching/tests/e2e/` (run with the project venv, +secrets sourced: `set -a && source deploy/secrets.local.env && set +a`): + +- `check_state.py [token]` — shows CRM hits for a URL token plus the last + INBOX/Trash subjects. Safe, read-only. +- `run_flow2.py ` — evaluates one project directly + (bypasses e-mail). Writes to the CRM if it matches! +- `send_test_mail.py "<subject>" [fallback-url]` — re-sends a stored example + mail to yourself to trigger the full pipeline. +- `cleanup_crm.py [tokens…]` — **deletes** opportunities whose cProjektlink + matches the tokens. Default tokens are the two historical test projects. + Never run it with a token that matches real opportunities. +- `flag_inbox.py` — marks every current INBOX mail as "already checked" + (used once at go-live; rarely needed again). + +## 8. Where things live + +| What | Where | +|---|---| +| Code + tests | `/home/lwc/bin/projekt-matching/` (git) | +| Flows (source of truth) | built by `deploy/build_flows.py` into Langflow ("PM Ingest", "PM Projekt bewerten") | +| Deployed runtime copy | `~/.local/share/langflow_pod/langflow-data/` (`/app/langflow` in-container) | +| Secrets | `~/.config/projekt-matching/` (0600) + Langflow global variables (encrypted); EspoCRM key sourced from `/home/tlg/mkt/bewerb/.secrets/espocrm-api.md` | +| systemd units | `~/.config/systemd/user/projekt-matching.{service,timer}` | +| Services | Langflow http://127.0.0.1:8090 · Langfuse http://127.0.0.1:8091 · vLLM http://127.0.0.1:8081 · pod script `/home/lwc/bin/create_pod_langflow.sh` | +| Design details | `docs/projekt-matching-design.md`, spec/plan under `docs/superpowers/` | + +## 9. Known limitations + +- **freelancermap only.** Mails from other portals are ignored (left in the + inbox untouched). Extending detection is a small code change in + `projektmatch/mailparse.py`. +- **Login-protected postings fail** with an alert (no page text, no snippet + fallback — a snippet cannot support honest matching). +- **Nice-to-have-Match varies a few points** between runs of the same project + (single ❔/✅ flips by the 9B model). The Must-match — the gate — has been + stable in all verification runs. Watch this in the annotation queue. +- **Rare edge case:** if the CRM write succeeds but the notification mail + fails to send, the opportunity exists but no mail arrives and none is + re-sent. The Langfuse trace records it (`error: notify: …`). +- Misc rows (start date, workload, …) are occasionally missing from the + extraction; they never influence the match values.