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>
191 lines
7.4 KiB
Python
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)
|