Files
bin/projekt-matching/projektmatch/stages.py
tlg 36eac66abc fix(projekt-matching): break infinite alert loop (issue 2)
Root cause chain (evidence: 25+ failed traces on nproj/3022414):
straight quote in page text ends the guided-decoding JSON string
early -> grammar deadlock -> whitespace loop until context/token
limit -> chat_json fails 3x (~35 min) -> IMAP conn idles out ->
move_to_trash raises -> mail never trashed -> reprocessed forever.

- ingest/mailer: MailBox.ensure_alive() reconnect before finalize;
  move_to_trash failure falls back to flag_checked, run continues
- stages: page_text() feeds only div.content minus skill-tag cloud
  to the LLM and maps quote chars to apostrophes (the actual
  degeneration trigger, live-verified: extract now OK in 21 s)
- llm: requirements maxItems 40; max_tokens 8192 caps degenerate
  runs at ~100 s instead of ~10 min

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 10:33:52 +02:00

191 lines
7.4 KiB
Python

"""Flow 2 stage functions. Each takes (ctx, cfg) and returns ctx.
ctx["status"]: "ok" -> pipeline continues; "failed" -> all later stages
skip; "rejected" -> only CRM/notify skip; "created" -> set by stage_crm.
"""
from __future__ import annotations
import datetime as dt
import json
import time
from pathlib import Path
import requests
from bs4 import BeautifulSoup
from . import espocrm, llm, mailer, pm_trace, rules
from .mailparse import BROWSER_UA
TEAM_BY_OFFER = {"Projekt": "DesTEngS",
"Arbeitnehmer-Angebot": "Arbeitnehmer",
"ANÜ": "ANÜ"}
MIN_PAGE_CHARS = 200
QUOTES = str.maketrans({c: "'" for c in "„“”\"«»‹›"})
def run_stage(name, fn, ctx, cfg, **kw):
if ctx.get("status") == "failed":
return ctx
try:
return fn(ctx, cfg, **kw)
except Exception as exc: # noqa: BLE001
ctx.update(status="failed", error=f"{name}: {exc}")
return ctx
def page_text(html):
"""Project text for the LLM: header + description, WITHOUT the skill-tag
cloud (div.project-body-badges, ~100 entries on some pages — blows the
requirements list up until the model emits broken JSON) and without
site chrome. Falls back to whole-page text if div.content is missing."""
soup = BeautifulSoup(html, "html.parser")
for tag in soup(["script", "style", "noscript"]):
tag.decompose()
root = soup.find("div", class_="content") or soup
for tag in root.find_all(class_="project-body-badges"):
tag.decompose()
# Anführungszeichen -> Apostroph: ein wörtlich übernommenes " beendet den
# JSON-String vorzeitig und treibt guided decoding in eine Whitespace-
# Endlosschleife (finish_reason "length", live verifiziert an nproj/3022414)
return "\n".join(line for line in
root.get_text("\n", strip=True).splitlines()
if line.strip()).translate(QUOTES)
def stage_fetch(ctx, cfg):
last = None
for attempt in range(3):
try:
resp = requests.get(ctx["canonical"], timeout=30,
headers={"User-Agent": BROWSER_UA})
resp.raise_for_status()
text = page_text(resp.text)
if len(text) < MIN_PAGE_CHARS:
raise ValueError(
f"Seitentext zu kurz ({len(text)} Zeichen) — "
f"Login-Wall oder leere Seite?")
ctx.update(page_text=text[:llm.PAGE_TEXT_LIMIT], status="ok")
return ctx
except Exception as exc: # noqa: BLE001
last = exc
time.sleep(2 * (attempt + 1))
raise RuntimeError(f"Abruf fehlgeschlagen: {last}")
def stage_extract(ctx, cfg):
ctx["extract"] = llm.extract_project(cfg.vllm_base, cfg.vllm_model,
ctx["page_text"])
ctx["projectName"] = ctx["extract"]["projectName"]
return ctx
def stage_match(ctx, cfg):
reqs = [{"nr": i + 1, "text": r["text"]}
for i, r in enumerate(ctx["extract"]["requirements"])
if r["kat"] in ("Must", "Nice")]
if not reqs:
ctx["ratings"] = []
return ctx
cv_path = Path(cfg.data_dir) / "vorgaben" / \
"Lebenslauf_Dr-Ing_Thomas_Langer.md"
cv_text = cv_path.read_text(encoding="utf-8")
ctx["ratings"] = llm.match_cv(cfg.vllm_base, cfg.vllm_model, cv_text, reqs)
return ctx
def stage_rules(ctx, cfg):
today = dt.date.today()
rated = {r["nr"]: r["rating"] for r in ctx.get("ratings", [])}
rows, nr = [], 0
for req in ctx["extract"]["requirements"]:
if req["kat"] in ("Must", "Nice"):
nr += 1
rating = rated.get(nr, rules.UNKNOWN)
else:
rating = rules.eval_misc(req, today)
rows.append({"kat": req["kat"], "rating": rating, "text": req["text"]})
rows = rules.order_rows(rows)
ctx["mustMatch"] = rules.calc_match(rows, "Must")
ctx["niceMatch"] = rules.calc_match(rows, "Nice")
ctx["description"] = rules.build_description(rows)
consider = ctx["mustMatch"] is not None and \
ctx["mustMatch"] > int(cfg.threshold)
ctx["decision"] = "consider" if consider else "rejected"
if not consider:
ctx["status"] = "rejected"
return ctx
def stage_crm(ctx, cfg, espo=None):
if ctx.get("status") != "ok" or ctx.get("decision") != "consider":
return ctx
espo = espo or espocrm.EspoClient(cfg.espo_base, cfg.espo_api_key)
ex = ctx["extract"]
team = espo.team_id(TEAM_BY_OFFER[ex["offerType"]])
agency = ex["buyerType"] == "agency"
account_id = None
if ex.get("companyName"):
account_id = espo.ensure_account(
ex["companyName"], "Reseller" if agency else "Customer")
contact_id = None
if ex.get("contactPerson") and account_id:
first, last = espocrm.split_person(ex["contactPerson"])
contact_id = espo.ensure_contact(first, last, account_id)
name = espo.unique_opportunity_name(ex["projectName"])
payload = {"name": name, "description": ctx["description"],
"cProjektlink": ctx["canonical"], "teamsIds": [team]}
if account_id:
payload["cAccount1Id" if agency else "accountId"] = account_id
if contact_id:
payload["contactsIds"] = [contact_id]
created = espo.create_opportunity(payload)
oid = created["id"]
back = espo.get_opportunity(oid)
problems = []
if back.get("name") != name:
problems.append("name")
if back.get("cProjektlink") != ctx["canonical"]:
problems.append("cProjektlink")
if back.get("description") != ctx["description"]:
problems.append("description")
if team not in (back.get("teamsIds") or []):
problems.append("teamsIds")
if account_id and agency and back.get("cAccount1Id") != account_id:
problems.append("cAccount1Id")
if account_id and not agency and back.get("accountId") != account_id:
problems.append("accountId")
if problems:
raise RuntimeError(f"CRM-Verifikation fehlgeschlagen: {problems}")
ctx.update(status="created", opportunityId=oid, projectName=name,
crmUrl=f"{cfg.crm_web_base}/#Opportunity/view/{oid}")
return ctx
def stage_notify(ctx, cfg):
"""Send notification on created; ALWAYS post the Langfuse trace.
Called directly by the component (not via run_stage), so failed runs
still get their trace and an SMTP error cannot lose it."""
try:
if ctx.get("status") == "created":
subject = (f"[Projekt-Match] Freelancermap — {ctx['mustMatch']} % — "
f"{ctx['projectName']}")
body = (f"Neues passendes Projekt gefunden.\n\n"
f"Projekt: {ctx['projectName']}\n"
f"Must-have-Match: {rules.fmt_match(ctx['mustMatch'])}\n"
f"Nice-to-have-Match: {rules.fmt_match(ctx.get('niceMatch'))}\n\n"
f"CRM-Verkaufschance: {ctx['crmUrl']}\n")
mailer.send_mail(cfg.imap_user, cfg.imap_password, cfg.notify_to,
subject, body)
except Exception as exc: # noqa: BLE001
ctx.update(status="failed", error=f"notify: {exc}")
pm_trace.post_trace(ctx)
return ctx
def summary(ctx):
"""Compact JSON result string for TextOutput / Flow 1."""
keys = ("status", "error", "decision", "mustMatch", "niceMatch",
"opportunityId", "crmUrl", "projectName", "canonical")
return json.dumps({k: ctx.get(k) for k in keys}, ensure_ascii=False)