"""vLLM (OpenAI-compatible) calls with response_format json_schema + German prompts. max_tokens stays UNSET so nothing can truncate the final answer (65k context window). Thinking is DISABLED via chat_template_kwargs: with the vLLM qwen3 reasoning parser active, json_schema guided decoding plus thinking degenerates — the run burns the entire context window (finish_reason "length", ~63k completion tokens) and returns empty or truncated content (verified live in Task 13). Without thinking the same extract call answers in ~35 s with valid schema-conformant JSON. """ from __future__ import annotations import json import re import time 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. Nice-Signalwörter gelten NUR für die Zeile, in der \ sie selbst stehen — sie färben NIE auf folgende Zeilen ab: Jede Zeile \ eines Anforderungs-Abschnitts OHNE eigenes Nice-Signalwort ist Must, \ auch wenn direkt davor Nice-Zeilen stehen. Beispiel: Auf "Idealerweise \ zusätzlich Erfahrung mit X" (Nice) folgt "Erfahrung mit Y" ohne \ Signalwort → Y ist Must. - 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. Zusammengesetzte Anforderungen \ ("X sowie Y", "X und Y"): klare Evidenz für einen Teil und keine \ Gegen-Evidenz für den anderen → "unknown", nicht "no". - "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, schema): body = {"model": model, "messages": messages, "temperature": 0.1, "chat_template_kwargs": {"enable_thinking": False}, "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, 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): 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}")