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>
This commit is contained in:
@@ -119,6 +119,10 @@ def run_ingest(cfg, mailbox=None, dispatch=None, espo=None, resolve=None):
|
|||||||
or f"unbekannter Status: {result.get('status')!r}")
|
or f"unbekannter Status: {result.get('status')!r}")
|
||||||
results.append(result)
|
results.append(result)
|
||||||
summary[result["status"]] += 1
|
summary[result["status"]] += 1
|
||||||
|
try:
|
||||||
|
box.ensure_alive() # Flow-2-Dispatches können >30 min dauern — Server-Idle-Timeout
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass # Alert/Trash unten haben eigene Fallbacks
|
||||||
failed = [r for r in results if r["status"] == "failed"]
|
failed = [r for r in results if r["status"] == "failed"]
|
||||||
if failed:
|
if failed:
|
||||||
lines = "\n\n".join(
|
lines = "\n\n".join(
|
||||||
@@ -134,7 +138,14 @@ def run_ingest(cfg, mailbox=None, dispatch=None, espo=None, resolve=None):
|
|||||||
"werden:\n\n" + lines + "\n")
|
"werden:\n\n" + lines + "\n")
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
summary["alertFailed"] = summary.get("alertFailed", 0) + 1
|
summary["alertFailed"] = summary.get("alertFailed", 0) + 1
|
||||||
box.move_to_trash(uid)
|
try:
|
||||||
|
box.move_to_trash(uid)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
summary["trashFailed"] = summary.get("trashFailed", 0) + 1
|
||||||
|
try:
|
||||||
|
box.flag_checked(uid) # nie erneut verarbeiten — Terminal-Zustand
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass # nächster Lauf räumt auf
|
||||||
return summary
|
return summary
|
||||||
finally:
|
finally:
|
||||||
box.close()
|
box.close()
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
"""vLLM (OpenAI-compatible) calls with response_format json_schema + German prompts.
|
"""vLLM (OpenAI-compatible) calls with response_format json_schema + German prompts.
|
||||||
|
|
||||||
max_tokens stays UNSET so nothing can truncate the final answer (65k
|
max_tokens is capped at 8192: every valid answer fits comfortably, and an
|
||||||
context window). Thinking is DISABLED via chat_template_kwargs: with the
|
uncapped run lets a degenerate generation grind for ~10 min before failing
|
||||||
|
(observed live: 100+-requirement JSON, broken at ~char 1725, 3 retries =
|
||||||
|
~35 min per cycle). Thinking is DISABLED via chat_template_kwargs: with the
|
||||||
vLLM qwen3 reasoning parser active, json_schema guided decoding plus
|
vLLM qwen3 reasoning parser active, json_schema guided decoding plus
|
||||||
thinking degenerates — the run burns the entire context window
|
thinking degenerates — the run burns the entire context window
|
||||||
(finish_reason "length", ~63k completion tokens) and returns empty or
|
(finish_reason "length", ~63k completion tokens) and returns empty or
|
||||||
@@ -34,6 +36,7 @@ EXTRACT_SCHEMA = {
|
|||||||
"contactPerson": {"type": ["string", "null"]},
|
"contactPerson": {"type": ["string", "null"]},
|
||||||
"requirements": {
|
"requirements": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
|
"maxItems": 40,
|
||||||
"items": {
|
"items": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -139,6 +142,7 @@ z. B. vLLM, TGI, Triton" ab).
|
|||||||
|
|
||||||
def _post(base, model, messages, schema):
|
def _post(base, model, messages, schema):
|
||||||
body = {"model": model, "messages": messages, "temperature": 0.1,
|
body = {"model": model, "messages": messages, "temperature": 0.1,
|
||||||
|
"max_tokens": 8192,
|
||||||
"chat_template_kwargs": {"enable_thinking": False},
|
"chat_template_kwargs": {"enable_thinking": False},
|
||||||
"response_format": {"type": "json_schema", "json_schema": {
|
"response_format": {"type": "json_schema", "json_schema": {
|
||||||
"name": "out", "schema": schema}}}
|
"name": "out", "schema": schema}}}
|
||||||
|
|||||||
@@ -16,12 +16,31 @@ KEYWORD = "$ProjektChecked"
|
|||||||
|
|
||||||
class MailBox:
|
class MailBox:
|
||||||
def __init__(self, user, password, conn=None):
|
def __init__(self, user, password, conn=None):
|
||||||
self.conn = conn or imaplib.IMAP4_SSL(IMAP_HOST, IMAP_PORT)
|
self.user, self.password = user, password
|
||||||
if conn is None:
|
self.conn = conn or self._connect()
|
||||||
self.conn.login(user, password)
|
|
||||||
self.conn.select("INBOX")
|
self.conn.select("INBOX")
|
||||||
self._trash = None
|
self._trash = None
|
||||||
|
|
||||||
|
def _connect(self):
|
||||||
|
conn = imaplib.IMAP4_SSL(IMAP_HOST, IMAP_PORT)
|
||||||
|
conn.login(self.user, self.password)
|
||||||
|
return conn
|
||||||
|
|
||||||
|
def ensure_alive(self):
|
||||||
|
"""Reconnect if the server dropped the connection (idle timeout)."""
|
||||||
|
try:
|
||||||
|
typ, _ = self.conn.noop()
|
||||||
|
if typ == "OK":
|
||||||
|
return
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
self.conn.logout()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
self.conn = self._connect()
|
||||||
|
self.conn.select("INBOX")
|
||||||
|
|
||||||
def unchecked_uids(self):
|
def unchecked_uids(self):
|
||||||
typ, data = self.conn.uid("SEARCH", None, f"UNKEYWORD {KEYWORD}")
|
typ, data = self.conn.uid("SEARCH", None, f"UNKEYWORD {KEYWORD}")
|
||||||
if typ != "OK" or not data or not data[0]:
|
if typ != "OK" or not data or not data[0]:
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ TEAM_BY_OFFER = {"Projekt": "DesTEngS",
|
|||||||
"Arbeitnehmer-Angebot": "Arbeitnehmer",
|
"Arbeitnehmer-Angebot": "Arbeitnehmer",
|
||||||
"ANÜ": "ANÜ"}
|
"ANÜ": "ANÜ"}
|
||||||
MIN_PAGE_CHARS = 200
|
MIN_PAGE_CHARS = 200
|
||||||
|
QUOTES = str.maketrans({c: "'" for c in "„“”\"«»‹›"})
|
||||||
|
|
||||||
|
|
||||||
def run_stage(name, fn, ctx, cfg, **kw):
|
def run_stage(name, fn, ctx, cfg, **kw):
|
||||||
@@ -32,6 +33,25 @@ def run_stage(name, fn, ctx, cfg, **kw):
|
|||||||
return ctx
|
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):
|
def stage_fetch(ctx, cfg):
|
||||||
last = None
|
last = None
|
||||||
for attempt in range(3):
|
for attempt in range(3):
|
||||||
@@ -39,12 +59,7 @@ def stage_fetch(ctx, cfg):
|
|||||||
resp = requests.get(ctx["canonical"], timeout=30,
|
resp = requests.get(ctx["canonical"], timeout=30,
|
||||||
headers={"User-Agent": BROWSER_UA})
|
headers={"User-Agent": BROWSER_UA})
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
soup = BeautifulSoup(resp.text, "html.parser")
|
text = page_text(resp.text)
|
||||||
for tag in soup(["script", "style", "noscript"]):
|
|
||||||
tag.decompose()
|
|
||||||
text = "\n".join(line for line in
|
|
||||||
soup.get_text("\n", strip=True).splitlines()
|
|
||||||
if line.strip())
|
|
||||||
if len(text) < MIN_PAGE_CHARS:
|
if len(text) < MIN_PAGE_CHARS:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Seitentext zu kurz ({len(text)} Zeichen) — "
|
f"Seitentext zu kurz ({len(text)} Zeichen) — "
|
||||||
|
|||||||
@@ -156,6 +156,63 @@ def test_close_called_even_if_unchecked_uids_raises(tmp_path):
|
|||||||
box.close.assert_called_once()
|
box.close.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_ensure_alive_called_before_finalize(tmp_path):
|
||||||
|
mails = {"1": mail("Mail", PROJECT_HTML)}
|
||||||
|
summary, box, sent = run(tmp_path, mails,
|
||||||
|
dispatch_result={"status": "failed",
|
||||||
|
"error": "Read timed out"})
|
||||||
|
names = [c[0] for c in box.method_calls]
|
||||||
|
assert "ensure_alive" in names
|
||||||
|
assert names.index("ensure_alive") < names.index("move_to_trash")
|
||||||
|
box.move_to_trash.assert_called_once_with("1")
|
||||||
|
assert len(sent) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_trash_failure_falls_back_to_flag_and_continues(tmp_path):
|
||||||
|
mails = {"1": mail("Mail A", PROJECT_HTML),
|
||||||
|
"2": mail("Mail B", PROJECT_HTML)}
|
||||||
|
box = mock.Mock()
|
||||||
|
box.unchecked_uids.return_value = ["1", "2"]
|
||||||
|
box.fetch.side_effect = lambda uid: mails[uid]
|
||||||
|
box.move_to_trash.side_effect = [OSError("socket error: EOF"), None]
|
||||||
|
espo = mock.Mock()
|
||||||
|
espo.find_opportunity_by_link.return_value = None
|
||||||
|
with mock.patch("projektmatch.ingest.mailparse.bodies",
|
||||||
|
side_effect=lambda m: (m["html"], "")), \
|
||||||
|
mock.patch("projektmatch.ingest.mailer.send_mail", lambda *a: None):
|
||||||
|
summary = ingest.run_ingest(
|
||||||
|
cfg(tmp_path), mailbox=box, espo=espo,
|
||||||
|
resolve=lambda url: "https://www.freelancermap.de/projekt/eins",
|
||||||
|
dispatch=lambda item: {"status": "created"})
|
||||||
|
assert summary["trashFailed"] == 1
|
||||||
|
assert summary["created"] == 2
|
||||||
|
box.flag_checked.assert_called_once_with("1")
|
||||||
|
assert box.move_to_trash.call_count == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_dead_connection_everywhere_still_finishes_run(tmp_path):
|
||||||
|
mails = {"1": mail("Mail A", PROJECT_HTML),
|
||||||
|
"2": mail("Mail B", PROJECT_HTML)}
|
||||||
|
box = mock.Mock()
|
||||||
|
box.unchecked_uids.return_value = ["1", "2"]
|
||||||
|
box.fetch.side_effect = lambda uid: mails[uid]
|
||||||
|
box.ensure_alive.side_effect = OSError("server unreachable")
|
||||||
|
box.move_to_trash.side_effect = OSError("socket error: EOF")
|
||||||
|
box.flag_checked.side_effect = OSError("socket error: EOF")
|
||||||
|
espo = mock.Mock()
|
||||||
|
espo.find_opportunity_by_link.return_value = None
|
||||||
|
with mock.patch("projektmatch.ingest.mailparse.bodies",
|
||||||
|
side_effect=lambda m: (m["html"], "")), \
|
||||||
|
mock.patch("projektmatch.ingest.mailer.send_mail", lambda *a: None):
|
||||||
|
summary = ingest.run_ingest(
|
||||||
|
cfg(tmp_path), mailbox=box, espo=espo,
|
||||||
|
resolve=lambda url: "https://www.freelancermap.de/projekt/eins",
|
||||||
|
dispatch=lambda item: {"status": "created"})
|
||||||
|
assert summary["trashFailed"] == 2
|
||||||
|
assert summary["created"] == 2
|
||||||
|
box.close.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
def test_parse_run_result_finds_status_json():
|
def test_parse_run_result_finds_status_json():
|
||||||
inner = json.dumps({"status": "created", "mustMatch": 90})
|
inner = json.dumps({"status": "created", "mustMatch": 90})
|
||||||
payload = {"outputs": [{"outputs": [{"results": {"text": {
|
payload = {"outputs": [{"outputs": [{"results": {"text": {
|
||||||
|
|||||||
@@ -24,11 +24,16 @@ def test_chat_json_strips_think_and_parses():
|
|||||||
{"type": "object"})
|
{"type": "object"})
|
||||||
assert out == {"a": 1}
|
assert out == {"a": 1}
|
||||||
body = p.call_args.kwargs["json"]
|
body = p.call_args.kwargs["json"]
|
||||||
assert "max_tokens" not in body
|
assert body["max_tokens"] == 8192
|
||||||
assert body["chat_template_kwargs"] == {"enable_thinking": False}
|
assert body["chat_template_kwargs"] == {"enable_thinking": False}
|
||||||
assert body["response_format"]["json_schema"]["schema"] == {"type": "object"}
|
assert body["response_format"]["json_schema"]["schema"] == {"type": "object"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_schema_caps_requirements_list():
|
||||||
|
reqs = llm.EXTRACT_SCHEMA["properties"]["requirements"]
|
||||||
|
assert reqs["maxItems"] == 40
|
||||||
|
|
||||||
|
|
||||||
def test_chat_json_strips_markdown_fences():
|
def test_chat_json_strips_markdown_fences():
|
||||||
content = "```json\n{\"a\": 2}\n```"
|
content = "```json\n{\"a\": 2}\n```"
|
||||||
with mock.patch("projektmatch.llm.requests.post", fake_post([content])):
|
with mock.patch("projektmatch.llm.requests.post", fake_post([content])):
|
||||||
|
|||||||
@@ -83,6 +83,28 @@ def test_send_mail_survives_append_failure():
|
|||||||
mailer.send_mail("u@x", "pw", "to@x", "Subj", "Body")
|
mailer.send_mail("u@x", "pw", "to@x", "Subj", "Body")
|
||||||
|
|
||||||
|
|
||||||
|
def test_ensure_alive_keeps_healthy_connection():
|
||||||
|
conn = fake_conn()
|
||||||
|
conn.noop.return_value = ("OK", [b""])
|
||||||
|
box = mailer.MailBox("u", "p", conn=conn)
|
||||||
|
box.ensure_alive()
|
||||||
|
assert box.conn is conn
|
||||||
|
conn.noop.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_ensure_alive_reconnects_on_dead_connection():
|
||||||
|
conn = fake_conn()
|
||||||
|
conn.noop.side_effect = OSError("connection closed")
|
||||||
|
box = mailer.MailBox("u", "p", conn=conn)
|
||||||
|
fresh = fake_conn()
|
||||||
|
with mock.patch("projektmatch.mailer.imaplib.IMAP4_SSL",
|
||||||
|
return_value=fresh):
|
||||||
|
box.ensure_alive()
|
||||||
|
assert box.conn is fresh
|
||||||
|
fresh.login.assert_called_once_with("u", "p")
|
||||||
|
fresh.select.assert_called_once_with("INBOX")
|
||||||
|
|
||||||
|
|
||||||
def test_append_sent_uses_special_use_folder():
|
def test_append_sent_uses_special_use_folder():
|
||||||
conn = mock.Mock()
|
conn = mock.Mock()
|
||||||
conn.list.return_value = ("OK", [
|
conn.list.return_value = ("OK", [
|
||||||
|
|||||||
@@ -33,6 +33,68 @@ def ctx_after_match(ratings):
|
|||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
|
PROJECT_PAGE = """
|
||||||
|
<html><body>
|
||||||
|
<nav>Login Registrieren</nav>
|
||||||
|
<div class="content">
|
||||||
|
<div class="project-header">
|
||||||
|
<h1>ISTQB Testautomatisierung (m/w/d)</h1>
|
||||||
|
<span>iSAX Consulting GmbH</span>
|
||||||
|
<span>Start 8/2026</span>
|
||||||
|
</div>
|
||||||
|
<div class="project-body">
|
||||||
|
<div class="project-body-badges badges-container">
|
||||||
|
<a href="#">ISTQB</a><a href="#">Testsuite</a><a href="#">IBM Aix</a>
|
||||||
|
</div>
|
||||||
|
<div class="project-body-description">
|
||||||
|
<p>Beschreibung: Muss-Anforderungen: 3 Jahre Testautomatisierung.
|
||||||
|
Erstellung, Beratung und methodische Unterstuetzung der Projektteams
|
||||||
|
bei Testkonzept und Teststrategie. Organisation der Testkoordination
|
||||||
|
fuer einen uebergreifenden Test. Durchfuehrung von manuellen und
|
||||||
|
automatisierten Tests sowie Last- und Performancetests.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<footer>Impressum Datenschutz</footer>
|
||||||
|
</body></html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def test_page_text_keeps_header_and_description_drops_tag_cloud():
|
||||||
|
text = stages.page_text(PROJECT_PAGE)
|
||||||
|
assert "ISTQB Testautomatisierung (m/w/d)" in text
|
||||||
|
assert "Start 8/2026" in text
|
||||||
|
assert "Muss-Anforderungen: 3 Jahre Testautomatisierung" in text
|
||||||
|
assert "IBM Aix" not in text
|
||||||
|
assert "Testsuite" not in text
|
||||||
|
assert "Impressum" not in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_page_text_replaces_quotes_that_break_json_strings():
|
||||||
|
html = ('<div class="content">Zertifizierung „ISTQB Certified Tester" '
|
||||||
|
'sowie "Scrum" und “Kanban”.</div>')
|
||||||
|
text = stages.page_text(html)
|
||||||
|
assert '"' not in text
|
||||||
|
assert "„" not in text and "“" not in text and "”" not in text
|
||||||
|
assert "'ISTQB Certified Tester'" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_page_text_falls_back_to_whole_page():
|
||||||
|
html = "<html><body><p>Nur Text ohne content-Div.</p></body></html>"
|
||||||
|
assert "Nur Text ohne content-Div." in stages.page_text(html)
|
||||||
|
|
||||||
|
|
||||||
|
def test_stage_fetch_uses_project_section():
|
||||||
|
resp = mock.Mock(status_code=200, text=PROJECT_PAGE)
|
||||||
|
resp.raise_for_status = lambda: None
|
||||||
|
ctx = {"canonical": "https://www.freelancermap.de/projekt/x", "title": "t"}
|
||||||
|
with mock.patch("projektmatch.stages.requests.get", return_value=resp):
|
||||||
|
out = stages.stage_fetch(dict(ctx), CFG)
|
||||||
|
assert out["status"] == "ok"
|
||||||
|
assert "IBM Aix" not in out["page_text"]
|
||||||
|
assert "Muss-Anforderungen" in out["page_text"]
|
||||||
|
|
||||||
|
|
||||||
def test_run_stage_catches_and_skips():
|
def test_run_stage_catches_and_skips():
|
||||||
def boom(ctx, cfg):
|
def boom(ctx, cfg):
|
||||||
raise ValueError("kaputt")
|
raise ValueError("kaputt")
|
||||||
|
|||||||
Reference in New Issue
Block a user