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"