#!/usr/bin/env python3 """ normalizer.py — KI-gestützte Dokumenten-Normalisierung. Ablauf: 1. Dokumenttyp via KI erkennen (Claude Haiku — günstig + schnell) 2. Passendes Template aus templates/ laden 3. Rohtext → Template-Struktur transformieren (Claude Sonnet) 4. Qualitäts-Score berechnen (Anteil befüllter Felder) API-Key: ANTHROPIC_API_KEY in .env.ingest """ import os import re from pathlib import Path import anthropic from dotenv import load_dotenv load_dotenv(".env.ingest") _client = anthropic.Anthropic(api_key=os.getenv("ANTHROPIC_API_KEY")) TEMPLATES_DIR = Path(__file__).parent / "templates" AUSGEFUELLT_DIR = TEMPLATES_DIR / "ausgefuellt" DOC_TYPE_MAP: dict[str, str] = { "projektbeschreibung": "Projektbeschreibung_TEMPLATE.md", "anschreiben": "Muster-Anschreiben_TEMPLATE.md", "karriereziele": "Karriereziele_TEMPLATE.md", "rahmenbedingungen": "Rahmenbedingungen_TEMPLATE.md", "referenzen": "Referenzen_TEMPLATE.md", "technologie": "Technologie-Bewertung_TEMPLATE.md", "elevatorpitch": "Ueber-mich_Elevator-Pitch_TEMPLATE.md", "zertifikate": "Zertifikate_Weiterbildungen_TEMPLATE.md", "zielstellen": "Zielstellen-Profil_TEMPLATE.md", "cv": "Projektbeschreibung_TEMPLATE.md", "lebenslauf": "Projektbeschreibung_TEMPLATE.md", "motivation": "Muster-Anschreiben_TEMPLATE.md", "skills": "Technologie-Bewertung_TEMPLATE.md", "skillmatrix": "Technologie-Bewertung_TEMPLATE.md", "pitch": "Ueber-mich_Elevator-Pitch_TEMPLATE.md", "profil": "Ueber-mich_Elevator-Pitch_TEMPLATE.md", "weiterbildung": "Zertifikate_Weiterbildungen_TEMPLATE.md", "arbeitszeugnis": "Projektbeschreibung_TEMPLATE.md", "zeugnis": "Projektbeschreibung_TEMPLATE.md", } _MISSING_MARKER = "[FEHLT]" _QUALITY_WARN_THRESHOLD = 0.5 def normalize(raw_text: str, filename: str = "", skip_normalization: bool = False) -> dict: if skip_normalization or not raw_text.strip(): return _passthrough(raw_text) print(" 🤖 Erkenne Dokumenttyp...") doc_type = _detect_doc_type(raw_text, filename) print(f" ✅ Erkannter Typ: '{doc_type}'") template_filename = DOC_TYPE_MAP.get(doc_type, "") template_text = _load_template(template_filename) if not template_text: print(f" ⚠️ Kein Template für Typ '{doc_type}' — Rohtext wird unverändert verwendet") return _passthrough(raw_text, doc_type=doc_type) print(f" 🤖 Normalisiere in Template '{template_filename}'...") example_text = _load_best_example(doc_type) normalized = _transform(raw_text, template_text, example_text) missing_count = len(re.findall(re.escape(_MISSING_MARKER), normalized)) placeholder_count = len(re.findall(r"\[.+?\]", template_text)) quality_score = round( max(0.0, 1.0 - (missing_count / max(placeholder_count, 1))), 2, ) if quality_score < _QUALITY_WARN_THRESHOLD: print( f" ⚠️ Niedriger Qualitäts-Score ({quality_score:.0%}) — " f"{missing_count} Felder konnten nicht befüllt werden" ) else: print(f" ✅ Qualitäts-Score: {quality_score:.0%} ({missing_count} fehlende Felder)") return { "normalized_text": normalized, "doc_type": doc_type, "template_file": template_filename, "quality_score": quality_score, "missing_fields": missing_count, "was_normalized": True, } def list_doc_types() -> list[str]: return sorted(set(DOC_TYPE_MAP.keys())) def _detect_doc_type(raw_text: str, filename: str = "") -> str: known_types = ", ".join(sorted(set(DOC_TYPE_MAP.keys()))) filename_hint = f"\nDateiname: {filename}" if filename else "" response = _client.messages.create( model="claude-haiku-4-5-20251001", max_tokens=30, temperature=0, messages=[{ "role": "user", "content": ( f"Bestimme den Dokumenttyp dieses Textes.\n" f"Erlaubte Typen: {known_types}\n" f"{filename_hint}\n\n" f"Antworte NUR mit einem der erlaubten Typen, nichts anderes.\n\n" f"Text (erste 1200 Zeichen):\n{raw_text[:1200]}" ), }], ) detected = response.content[0].text.strip().lower() detected = re.sub(r"[^a-zäöüß]", "", detected) if detected in DOC_TYPE_MAP: return detected for key in DOC_TYPE_MAP: if key in detected or detected in key: return key return "projektbeschreibung" def _load_template(filename: str) -> str: if not filename: return "" path = TEMPLATES_DIR / filename return path.read_text(encoding="utf-8") if path.exists() else "" def _load_best_example(doc_type: str) -> str: if not AUSGEFUELLT_DIR.exists(): return "" template_name = DOC_TYPE_MAP.get(doc_type, "") keyword = template_name.replace("_TEMPLATE.md", "").split("_")[0].lower() for f in AUSGEFUELLT_DIR.glob("*.md"): if keyword in f.name.lower(): return f.read_text(encoding="utf-8")[:800] return "" def _transform(raw_text: str, template: str, example: str = "") -> str: example_block = ( f"\n\nReferenzbeispiel (so soll das Ergebnis aussehen):\n{example}\n" if example else "" ) system_prompt = ( "Du bist ein Dokumenten-Strukturierungs-Assistent für Bewerbungsunterlagen.\n" "Deine Aufgabe: Extrahiere Informationen aus einem Rohtext und fülle sie " "exakt in die vorgegebene Markdown-Template-Struktur ein.\n\n" "REGELN:\n" "1. Übernimm AUSSCHLIESSLICH Informationen die tatsächlich im Rohtext stehen\n" "2. Erfinde KEINE Daten, halluziniere NICHT\n" f"3. Felder die nicht befüllt werden können → mit '{_MISSING_MARKER}' markieren\n" "4. Behalte die EXAKTE Markdown-Struktur (Überschriften, Tabellen, Listen) bei\n" "5. Antworte NUR mit dem ausgefüllten Markdown, KEIN Kommentar davor/danach\n" "6. Sprache: Deutsch (wie im Original-Template)\n" "7. Platzhalterwörter wie [z.B. ...] immer durch echte Daten oder [FEHLT] ersetzen" ) user_prompt = ( f"Template-Struktur:\n{template}" f"{example_block}" f"\n\n---\nRohtext des Dokuments:\n{raw_text}\n\n---\n" "Fülle das Template mit den Informationen aus dem Rohtext aus." ) response = _client.messages.create( model="claude-sonnet-4-6", max_tokens=3000, temperature=0.1, system=system_prompt, messages=[{"role": "user", "content": user_prompt}], ) return response.content[0].text.strip() def _passthrough(raw_text: str, doc_type: str = "unbekannt") -> dict: return { "normalized_text": raw_text, "doc_type": doc_type, "template_file": "", "quality_score": 1.0, "missing_fields": 0, "was_normalized": False, }