From 20796b3a689a6677796450e980e1c5d18103bc6263d6e183864c1d62ac4f6524 Mon Sep 17 00:00:00 2001 From: tlg Date: Thu, 9 Jul 2026 15:33:11 +0200 Subject: [PATCH] fix(projekt-matching): retry chat_json on empty/unparseable vLLM completion --- projekt-matching/projektmatch/llm.py | 31 ++++++++++++++++------------ projekt-matching/tests/test_llm.py | 14 +++++++++++++ 2 files changed, 32 insertions(+), 13 deletions(-) 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": [