From a7f94914d0fb7586d90d38a6647243cb9bb7015c432cab8c8fe427dbfa68f527 Mon Sep 17 00:00:00 2001 From: tlg Date: Thu, 9 Jul 2026 10:44:59 +0200 Subject: [PATCH] feat(projekt-matching): vLLM guided-json client with extraction and matching prompts --- projekt-matching/projektmatch/llm.py | 177 +++++++++++++++++++++++++++ projekt-matching/tests/test_llm.py | 58 +++++++++ 2 files changed, 235 insertions(+) create mode 100644 projekt-matching/projektmatch/llm.py create mode 100644 projekt-matching/tests/test_llm.py diff --git a/projekt-matching/projektmatch/llm.py b/projekt-matching/projektmatch/llm.py new file mode 100644 index 0000000..f9592d5 --- /dev/null +++ b/projekt-matching/projektmatch/llm.py @@ -0,0 +1,177 @@ +"""vLLM (OpenAI-compatible) calls with guided_json + German prompts. + +The model is a REASONING model: max_tokens stays UNSET so long thinking +chains cannot truncate the final answer (65k context window). +""" +from __future__ import annotations + +import json +import re + +import requests + +THINK_RE = re.compile(r".*?", re.S) +PAGE_TEXT_LIMIT = 24000 + + +class LlmError(RuntimeError): + pass + + +EXTRACT_SCHEMA = { + "type": "object", + "properties": { + "projectName": {"type": "string"}, + "offerType": {"enum": ["Projekt", "Arbeitnehmer-Angebot", "ANÜ"]}, + "buyerType": {"enum": ["agency", "direct"]}, + "companyName": {"type": ["string", "null"]}, + "contactPerson": {"type": ["string", "null"]}, + "requirements": { + "type": "array", + "items": { + "type": "object", + "properties": { + "text": {"type": "string"}, + "kat": {"enum": ["Must", "Nice", "Misc"]}, + "miscType": {"enum": ["start", "workload", "duration", + "location", "security", "other"]}, + "startDate": {"type": ["string", "null"]}, + "workloadPercent": {"type": ["integer", "null"]}, + "remotePercent": {"type": ["integer", "null"]}, + "onsiteLocation": {"type": ["string", "null"]}, + }, + "required": ["text", "kat", "miscType", "startDate", + "workloadPercent", "remotePercent", + "onsiteLocation"], + }, + }, + }, + "required": ["projectName", "offerType", "buyerType", "companyName", + "contactPerson", "requirements"], +} + +MATCH_SCHEMA = { + "type": "object", + "properties": { + "ratings": { + "type": "array", + "items": { + "type": "object", + "properties": { + "nr": {"type": "integer"}, + "rating": {"enum": ["yes", "no", "unknown"]}, + "reason": {"type": "string"}, + }, + "required": ["nr", "rating", "reason"], + }, + }, + }, + "required": ["ratings"], +} + +EXTRACT_SYSTEM = """Du extrahierst Anforderungen aus deutschen \ +Freiberufler-Projektausschreibungen. Antworte NUR mit JSON nach Schema. + +Regeln: +- projectName: Titel der Ausschreibung OHNE Portal-Zusatz (z. B. ohne "auf \ +www.freelancermap.de"). +- requirements: jede Anforderung einzeln, im Originalwortlaut (behutsames \ +Kürzen erlaubt, Bedeutung nie verändern). Rahmenbedingungen (Start, \ +Einsatzort/Remote-Anteil, Auslastung, Laufzeit) sind Anforderungen der \ +Kategorie Misc. +- kat: Must = zwingend (Abschnitt "Must-haves"/"Anforderungen"; "zwingend", \ +"erforderlich", "vorausgesetzt", "sehr gute Kenntnisse"). Nice = optional \ +(Abschnitt "Nice-to-haves"; "von Vorteil", "wünschenswert", "idealerweise", \ +"plus"). Misc = Rahmenbedingungen und alles, was weder Muss noch \ +Wunsch-Qualifikation ist. Explizite Abschnittsüberschriften haben Vorrang \ +vor Signalwörtern; "idealerweise" INNERHALB einer Must-Zeile lässt die \ +Zeile Must bleiben. +- miscType nur für Misc-Zeilen relevant (sonst "other"): start = \ +Projektstart/Verfügbarkeit (startDate als ISO-Datum YYYY-MM-DD, wenn ein \ +konkretes Datum genannt ist, sonst null); workload = Auslastung \ +(workloadPercent 0-100 oder null); duration = Laufzeit; location = \ +Einsatzort/Remote (remotePercent 0-100 oder null; onsiteLocation = \ +Ortsname oder null); security = Sicherheitsüberprüfung (SÜ, SÜ1/SÜ2/SÜ3, \ +Ü2, Geheimschutz). +- offerType: "Projekt" = Freiberufler-/Werkauftrag (auch über Agentur). \ +"Arbeitnehmer-Angebot" bei Festanstellung ("Festanstellung", "unbefristet", \ +"Gehalt", "Arbeitsvertrag"). "ANÜ" bei Arbeitnehmerüberlassung ("ANÜ", \ +"AÜG", "Überlassung", "Zeitarbeit"). Im Zweifel "Projekt". +- buyerType: "agency" bei Personaldienstleistern/Vermittlern (Hays, \ +GULP/Randstad, SThree, Computer Futures, Aristo, freelancermap-Vermittler, \ +"im Auftrag unseres Kunden", "für unseren Kunden"), sonst "direct". Im \ +Zweifel "agency". +- companyName: Name der Agentur bzw. des Endkunden, sonst null. \ +contactPerson: vollständiger Name der Ansprechperson, sonst null.""" + +MATCH_SYSTEM = """Du bewertest nüchtern und streng, ob ein Lebenslauf \ +einzelne Projekt-Anforderungen abdeckt. Antworte NUR mit JSON nach Schema: \ +für JEDE übergebene Nr. genau ein Eintrag in ratings. + +Bewertung: +- "yes" NUR bei klarer Evidenz im Lebenslauf. +- "no" wenn der Lebenslauf nichts Belastbares hergibt. Streng bleiben — \ +eine geschönte Bewertung macht die Match-Zahlen wertlos. Kalibrierung: \ +Proof-of-Concept-Erfahrung deckt "produktiven Betrieb" NICHT ab; \ +"mehrjährig" wörtlich nehmen; ein Produktname (z. B. "Azure DevOps \ +Server") belegt KEINE Cloud-Plattform-Erfahrung. +- "unknown" NUR bei echter Teilevidenz, wenn die Entscheidung von Wissen \ +abhängt, das nur der Kandidat selbst hat. +- "wie z. B."-Aufzählungen: gleichwertige Alternativen zählen als Abdeckung \ +(Beispiel: Ollama/llama.cpp/Transformers decken "LLM-Inference-Stacks wie \ +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) + 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}}}) + 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() + try: + return json.loads(content) + except json.JSONDecodeError as exc: + raise LlmError(f"LLM lieferte kein JSON: {exc}: {content[:200]}") + + +def extract_project(base, model, page_text): + messages = [{"role": "system", "content": EXTRACT_SYSTEM}, + {"role": "user", "content": + "Ausschreibungstext:\n\n" + page_text[:PAGE_TEXT_LIMIT]}] + out = chat_json(base, model, messages, EXTRACT_SCHEMA) + if not out.get("requirements"): + raise LlmError("Extraktion ohne Anforderungen") + return out + + +def match_cv(base, model, cv_text, requirements): + listing = "\n".join(f"{r['nr']}. {r['text']}" for r in requirements) + messages = [{"role": "system", "content": MATCH_SYSTEM}, + {"role": "user", "content": + f"Lebenslauf:\n\n{cv_text}\n\nAnforderungen:\n{listing}"}] + wanted = {r["nr"] for r in requirements} + for attempt in range(2): + out = chat_json(base, model, messages, MATCH_SCHEMA) + got = {r["nr"]: r for r in out.get("ratings", []) if r["nr"] in wanted} + if set(got) == wanted: + return [got[n] for n in sorted(got)] + missing = sorted(wanted - set(got)) + messages = messages + [ + {"role": "assistant", "content": json.dumps(out)}, + {"role": "user", "content": + f"Es fehlen Bewertungen für Nr. {missing}. Antworte erneut " + f"mit ratings für ALLE Nummern."}] + raise LlmError(f"Matching unvollständig, fehlend: {missing}") diff --git a/projekt-matching/tests/test_llm.py b/projekt-matching/tests/test_llm.py new file mode 100644 index 0000000..0f3f83f --- /dev/null +++ b/projekt-matching/tests/test_llm.py @@ -0,0 +1,58 @@ +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 = "lange Kette{\"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 "max_tokens" not in body + assert body["guided_json"] == {"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_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"