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:
tlg
2026-07-14 10:33:52 +02:00
parent f40a5bb0f8
commit 36eac66abc
8 changed files with 208 additions and 13 deletions

View File

@@ -156,6 +156,63 @@ def test_close_called_even_if_unchecked_uids_raises(tmp_path):
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():
inner = json.dumps({"status": "created", "mustMatch": 90})
payload = {"outputs": [{"outputs": [{"results": {"text": {

View File

@@ -24,11 +24,16 @@ def test_chat_json_strips_think_and_parses():
{"type": "object"})
assert out == {"a": 1}
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["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():
content = "```json\n{\"a\": 2}\n```"
with mock.patch("projektmatch.llm.requests.post", fake_post([content])):

View File

@@ -83,6 +83,28 @@ def test_send_mail_survives_append_failure():
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():
conn = mock.Mock()
conn.list.return_value = ("OK", [

View File

@@ -33,6 +33,68 @@ def ctx_after_match(ratings):
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 boom(ctx, cfg):
raise ValueError("kaputt")