Design doc: new §4.6 post-mortem (quote char -> guided-decoding whitespace loop -> dead IMAP conn -> non-terminal mail), updated module inventory, vLLM/e-mail experience sections, test counts (51 -> 63) and the latency limitation (now bounded by max_tokens). User manual: version note + troubleshooting row for repeating alerts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
21 KiB
Projekt-Matching — Design Description & Experiences
Companion to the user manual. 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.
Updated 2026-07-14 after the poison-mail incident (§4.6, commit 36eac66).
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 / failedon 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 (63 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/<flow2> (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, max_tokens 8192), German prompts + JSON schemas (requirements capped at 40), retry-on-empty, match completeness retry |
mailer.py |
IMAP (custom keyword, Trash detection, MOVE+fallback, ensure_alive() reconnect), SMTP (STARTTLS, Date/Message-ID, Sent copy) |
stages.py |
Flow-2 stage functions + ctx state machine + CRM verify read-back; page_text() (project card minus tag cloud, quote sanitization) |
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 with flag-checked fallback |
deploy/ |
deploy_files.sh (rsync + safe restart), build_flows.py (variables, API key, programmatic flow construction), setup_langfuse.py, systemd units, webhook trigger |
tests/ |
63 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
- Canonical-URL dedup (not name-based): deterministic, immune to title edits; the same job at two agencies intentionally stays two opportunities.
- 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.
- 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.
- 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. - Programmatic flow construction (
build_flows.pyassembles node/edge JSON viaPOST /api/v1/custom_componenttemplates): flows are reproducible from git; the Langflow UI is never the source of truth. - 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_jsonis 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 usingresponse_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_schemadecoding 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_jsonretries empty/unparseable content up to 3× with backoff; non-200 fails fast. - A straight
"in the INPUT text breaks schema-guided decoding (found 2026-07-14, §4.6): the model copies the quote verbatim into a JSON string value, the grammar reads it as end-of-string, the model cannot continue its sentence — and escapes into an infinite whitespace loop (whitespace is always grammar-legal) until the token limit,finish_reason: length. vLLM's request-levelstructured_outputs.disable_any_whitespacedoes NOT help: the loop simply moves inside a string literal, where spaces are legal. The robust fix is input-side:stages.page_text()maps all quote characters („ “ ” " « » ‹ ›) to'before prompting. - The old belief "set max_tokens ≥ 1024 for reasoning models" first evolved into "set no max_tokens at all and disable thinking where you enforce schemas" — and then, after the whitespace-loop incident, into "cap max_tokens at 8192": every valid answer fits comfortably, and a degenerate run now burns ~100 s instead of ~10 min before the retry.
4.2 Langflow 1.10 internals
Component.ctxis a reserved property — aDataInput(name="ctx")silently shadows the wired edge payload. All data inputs are namedctx_in.- The custom-component loader resolves imports statically — in-file
sys.path.insert()hacks don't work. First fix (a.pthfile in the container venv) turned out non-durable because the systemd--newunit recreates the container on every restart, wiping ephemeral writes. The durable fix bakesPYTHONPATH=/app/langflowinto the container definition increate_pod_langflow.sh. - Never
podman restarta systemd-managed--rm/--newcontainer — it can end up removed with the unit inactive. Alwayssystemctl --user restart container-langflow_ctr.service(deploy_files.shdoes this and waits for health). - Webhook lives in the
input_outputcategory ofGET /api/v1/all(notdata);Dataoutputs wire as typeJSON; edge handles use theœ-escaped JSON encoding.build_flows.pyderives 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_keyaccumulates same-named keys; the builder now deletes existingprojekt-matchingkeys before minting (list shape:{"api_keys": [...]}).podman logs langflow_ctris 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;runand webhook needx-api-key; responses are gzip (curl --compressed). - Global variables with
load_from_db=Trueresolve 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.pyfalls 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
EmailMessages need explicitDateandMessage-IDheaders; 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 literallyTrash. The$ProjektCheckedkeyword 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 infinally— otherwise one poison mail would silently stall the pipeline every 5 minutes with no alert. - The IMAP server drops idle connections during long Flow-2 dispatches
(30+ min in the degenerate case, §4.6).
run_ingesttherefore callsMailBox.ensure_alive()(NOOP probe + transparent reconnect) before the per-mail finalization, and a failingmove_to_trashfalls back toflag_checked(summary.trashFailed) so the run continues — the terminal-state rule survives a dead connection.
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 Post-launch incident: the poison-mail loop (2026-07-13/14)
Four days after go-live the pipeline stalled for ~26 hours on one mail,
sending an identical [Projekt-Match-Fehler] alert every ~35 minutes. The
failure chain — each link necessary, none sufficient alone — is instructive:
- Trigger: one posting (
nproj/3022414) contained„ISTQB Certified Tester …"— a typographic opening quote closed by a straight ASCII". Copied verbatim into a JSON string by the model, the"ended the string for the guided-decoding grammar mid-sentence; the model escaped into the whitespace loop (§4.1) and burned the full context on every attempt (~10 min × 3 retries ≈ 35 min per cycle). - Timeout mismatch: the ingest dispatch timeout (1800 s) fired before
Flow 2 finished, producing a premature
Read timed outfailure. - Trap: during the long wait the IMAP connection idled out server-side.
The alert still went out (fresh SMTP connection), but
move_to_trashon the dead connection raised and abortedrun_ingestbefore the mail reached its terminal state — so the next tick reprocessed the same mail, forever. 66 mails queued up behind it.
Fix (commit 36eac66, TDD, live-verified): quote sanitization + feeding only
the project card minus the skill-tag cloud to the LLM (page_text()),
maxItems: 40 on the requirements schema, max_tokens: 8192, and the
finalization hardening from §4.4. The poison extraction went from 25+
consecutive failures to a clean 19-requirement result in 21 s; the backlog
(67 mails) drained in 36 minutes with zero failure alerts and 9 new
opportunities.
Lessons: terminal-state guarantees must hold on every path, including "connection died mid-run" — the guarantee is only as strong as its weakest finalization step; and schema-guided decoding turns bad input characters into decoding pathologies, so sanitize model input against the output grammar, not just for size.
4.7 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
- 63 unit tests (pure package, no network); TDD throughout (51 at go-live, +1 Sent-copy fix, +11 poison-mail fix).
- 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 is bounded since
36eac66(max_tokens: 8192→ a fully degenerate extraction costs ~100 s × 3 retries ≈ 6 min, comfortably below the 1800 s dispatch timeout). Should the timeout ever fire anyway, the ingest side alerts while Flow 2 may still finish server-side; dedup prevents damage, and the finalization fallback (§4.4) keeps the trigger mail terminal. parse_run_resultanchors on "any embedded JSON with astatuskey" — 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-reviewannotation queue; if quality drifts, tuning lives inllm.py's prompts and is regression-tested bytests/e2e/run_flow2.pyagainst the two reference projects.
7. Restore from a fresh clone (runbook)
bash create_pod_langflow.sh— pod, DBs, Langflow, Langfuse (bootstrap admin + tracing keys baked in).- Ensure ACLs:
setfacl -m u:lwc:ron the CV and.secrets/espocrm-api.md(astlg); create~/.config/projekt-matching/secrets.env(0600) withPM_IMAP_PASSWORD. projekt-matching/deploy/deploy_files.sh— package + vorgaben into the data dir.set -a; source projekt-matching/deploy/secrets.local.env; set +athen.venv/bin/python projekt-matching/deploy/build_flows.py— variables, API key, both flows,~/.config/projekt-matching/env..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.cp projekt-matching/deploy/projekt-matching.{service,timer} ~/.config/systemd/user/thensystemctl --user daemon-reload && systemctl --user enable --now projekt-matching.timer.- Optional clean start:
.venv/bin/python projekt-matching/tests/e2e/flag_inbox.pyto baseline the current INBOX before the first tick.