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