Root cause chain (evidence: 25+ failed traces on nproj/3022414): straight quote in page text ends the guided-decoding JSON string early -> grammar deadlock -> whitespace loop until context/token limit -> chat_json fails 3x (~35 min) -> IMAP conn idles out -> move_to_trash raises -> mail never trashed -> reprocessed forever. - ingest/mailer: MailBox.ensure_alive() reconnect before finalize; move_to_trash failure falls back to flag_checked, run continues - stages: page_text() feeds only div.content minus skill-tag cloud to the LLM and maps quote chars to apostrophes (the actual degeneration trigger, live-verified: extract now OK in 21 s) - llm: requirements maxItems 40; max_tokens 8192 caps degenerate runs at ~100 s instead of ~10 min Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
202 lines
9.0 KiB
Python
202 lines
9.0 KiB
Python
"""vLLM (OpenAI-compatible) calls with response_format json_schema + German prompts.
|
|
|
|
max_tokens is capped at 8192: every valid answer fits comfortably, and an
|
|
uncapped run lets a degenerate generation grind for ~10 min before failing
|
|
(observed live: 100+-requirement JSON, broken at ~char 1725, 3 retries =
|
|
~35 min per cycle). 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"<think>.*?</think>", 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",
|
|
"maxItems": 40,
|
|
"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 — AUSNAHME: Zeilen unter einer \
|
|
expliziten Nice-to-haves-Überschrift bleiben Nice. 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,
|
|
"max_tokens": 8192,
|
|
"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):
|
|
if attempts < 1:
|
|
raise ValueError("attempts must be >= 1")
|
|
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}")
|