diff --git a/projekt-matching/projektmatch/llm.py b/projekt-matching/projektmatch/llm.py index c88fa8f..1657d9f 100644 --- a/projekt-matching/projektmatch/llm.py +++ b/projekt-matching/projektmatch/llm.py @@ -7,6 +7,7 @@ from __future__ import annotations import json import re +import time import requests @@ -131,19 +132,23 @@ def _post(base, model, messages, schema): timeout=1500) -def chat_json(base, model, messages, schema): - resp = _post(base, model, messages, schema) - if resp.status_code != 200: - raise LlmError(f"vLLM HTTP {resp.status_code}: " - f"{getattr(resp, 'text', '')[:300]}") - content = resp.json()["choices"][0]["message"]["content"] or "" - content = THINK_RE.sub("", content).strip() - if content.startswith("```"): - content = re.sub(r"^```[a-z]*\s*|\s*```$", "", content).strip() - try: - return json.loads(content) - except json.JSONDecodeError as exc: - raise LlmError(f"LLM lieferte kein JSON: {exc}: {content[:200]}") +def chat_json(base, model, messages, schema, attempts=3): + last = None + for attempt in range(attempts): + resp = _post(base, model, messages, schema) + if resp.status_code != 200: + raise LlmError(f"vLLM HTTP {resp.status_code}: " + f"{getattr(resp, 'text', '')[:300]}") + content = resp.json()["choices"][0]["message"]["content"] or "" + content = THINK_RE.sub("", content).strip() + if content.startswith("```"): + content = re.sub(r"^```[a-z]*\s*|\s*```$", "", content).strip() + try: + return json.loads(content) + except json.JSONDecodeError as exc: + last = LlmError(f"LLM lieferte kein JSON: {exc}: {content[:200]}") + time.sleep(10 * (attempt + 1)) + raise last def extract_project(base, model, page_text): diff --git a/projekt-matching/tests/test_llm.py b/projekt-matching/tests/test_llm.py index 6535f52..8771e68 100644 --- a/projekt-matching/tests/test_llm.py +++ b/projekt-matching/tests/test_llm.py @@ -35,6 +35,20 @@ def test_chat_json_strips_markdown_fences(): 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": [