Files
bin/projekt-matching/tests/test_llm.py
tlg 36eac66abc 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>
2026-07-14 10:33:52 +02:00

75 lines
2.8 KiB
Python

import json
from unittest import mock
import pytest
from projektmatch import llm
def fake_post(payloads):
"""Return a mock for requests.post yielding chat completions."""
responses = []
for p in payloads:
r = mock.Mock()
r.status_code = 200
r.json.return_value = {"choices": [{"message": {"content": p}}]}
responses.append(r)
return mock.Mock(side_effect=responses)
def test_chat_json_strips_think_and_parses():
content = "<think>lange Kette</think>{\"a\": 1}"
with mock.patch("projektmatch.llm.requests.post", fake_post([content])) as p:
out = llm.chat_json("http://v/v1", "m", [{"role": "user", "content": "x"}],
{"type": "object"})
assert out == {"a": 1}
body = p.call_args.kwargs["json"]
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])):
out = llm.chat_json("http://v/v1", "m", [], {"type": "object"})
assert out == {"a": 2}
def test_chat_json_retries_on_empty_content():
good = json.dumps({"a": 1})
with mock.patch("projektmatch.llm.time.sleep", lambda s: None), \
mock.patch("projektmatch.llm.requests.post", fake_post(["", good])):
assert llm.chat_json("http://v/v1", "m", [], {"type": "object"}) == {"a": 1}
def test_chat_json_raises_after_exhausted_retries():
with mock.patch("projektmatch.llm.time.sleep", lambda s: None), \
mock.patch("projektmatch.llm.requests.post", fake_post(["", "", ""])):
with pytest.raises(llm.LlmError, match="kein JSON"):
llm.chat_json("http://v/v1", "m", [], {"type": "object"})
def test_match_cv_retries_on_missing_nr_then_raises():
reqs = [{"nr": 1, "text": "Python"}, {"nr": 2, "text": "K8s"}]
partial = json.dumps({"ratings": [
{"nr": 1, "rating": "yes", "reason": "ok"}]})
with mock.patch("projektmatch.llm.requests.post",
fake_post([partial, partial])):
with pytest.raises(llm.LlmError, match="unvollständig"):
llm.match_cv("http://v/v1", "m", "CV", reqs)
def test_match_cv_ok():
reqs = [{"nr": 1, "text": "Python"}]
full = json.dumps({"ratings": [
{"nr": 1, "rating": "unknown", "reason": "Teilevidenz"}]})
with mock.patch("projektmatch.llm.requests.post", fake_post([full])):
out = llm.match_cv("http://v/v1", "m", "CV", reqs)
assert out[0]["rating"] == "unknown"