Files
ai.bewerbung---vectorize-do…/normalizer.py
T
cbazza 2a45580f97 docs: Modell-Kommentar in normalizer.py ergänzt
- normalizer.py: Hinweis auf Modell-Alternativen (Sonnet vs. Haiku) bei _transform()
- Sonnet bleibt Standard für beste Normalisierungsqualität
2026-04-29 11:29:48 +02:00

202 lines
7.2 KiB
Python

#!/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."
)
# Modell-Auswahl: sonnet-4-6 für beste Qualität, haiku-4-5 für günstigere Verarbeitung
response = _client.messages.create(
model="claude-sonnet-4-6", # Alternativen: "claude-haiku-4-5-20251001" (günstiger)
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,
}