From 9cbdd2e28cde3e3329c4c3d5cc02b87692ef3cdd9930f98fc616887fd810c8ce Mon Sep 17 00:00:00 2001 From: tlg Date: Thu, 9 Jul 2026 10:55:23 +0200 Subject: [PATCH] fix(projekt-matching): use response_format json_schema (vLLM 0.22 ignores guided_json) --- projekt-matching/projektmatch/llm.py | 15 +++++++-------- projekt-matching/tests/test_llm.py | 16 ++++++---------- 2 files changed, 13 insertions(+), 18 deletions(-) diff --git a/projekt-matching/projektmatch/llm.py b/projekt-matching/projektmatch/llm.py index f9592d5..ac555ef 100644 --- a/projekt-matching/projektmatch/llm.py +++ b/projekt-matching/projektmatch/llm.py @@ -123,24 +123,23 @@ z. B. vLLM, TGI, Triton" ab). - reason: EIN kurzer deutscher Satz mit der Begründung.""" -def _post(base, model, messages, body_extra): - body = {"model": model, "messages": messages, "temperature": 0.1} - body.update(body_extra) +def _post(base, model, messages, schema): + body = {"model": model, "messages": messages, "temperature": 0.1, + "response_format": {"type": "json_schema", "json_schema": { + "name": "out", "schema": schema}}} return requests.post(f"{base.rstrip('/')}/chat/completions", json=body, timeout=1500) def chat_json(base, model, messages, schema): - resp = _post(base, model, messages, {"guided_json": schema}) - if resp.status_code == 400 and "guided_json" in getattr(resp, "text", ""): - resp = _post(base, model, messages, {"response_format": { - "type": "json_schema", - "json_schema": {"name": "out", "schema": 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: diff --git a/projekt-matching/tests/test_llm.py b/projekt-matching/tests/test_llm.py index 0f3f83f..6535f52 100644 --- a/projekt-matching/tests/test_llm.py +++ b/projekt-matching/tests/test_llm.py @@ -25,18 +25,14 @@ def test_chat_json_strips_think_and_parses(): assert out == {"a": 1} body = p.call_args.kwargs["json"] assert "max_tokens" not in body - assert body["guided_json"] == {"type": "object"} + assert body["response_format"]["json_schema"]["schema"] == {"type": "object"} -def test_chat_json_fallback_to_response_format(): - bad = mock.Mock(status_code=400, - text="Unknown parameter: 'guided_json'") - good = mock.Mock(status_code=200) - good.json.return_value = {"choices": [{"message": {"content": "{}"}}]} - with mock.patch("projektmatch.llm.requests.post", - mock.Mock(side_effect=[bad, good])) as p: - assert llm.chat_json("http://v/v1", "m", [], {"type": "object"}) == {} - assert "response_format" in p.call_args.kwargs["json"] +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_match_cv_retries_on_missing_nr_then_raises():