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:
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user