diff --git a/brancheneinstufung.py b/brancheneinstufung.py index 6ef14576..0aaad8cd 100644 --- a/brancheneinstufung.py +++ b/brancheneinstufung.py @@ -1,25 +1,18 @@ #!/usr/bin/env python3 """ -v1.6.4: Implementiere ML-Modelltraining zur Technikerschätzung +v1.6.5: Refactor logging & integrate improved WikipediaScraper Git-Änderungsbeschreibung: -- Füge neuen Betriebsmodus `--mode train_technician_model` hinzu. -- Implementiere Datenvorbereitung in `DataProcessor.prepare_data_for_modeling`: - - Lädt relevante Spalten. - - Konsolidiert Umsatz/Mitarbeiter (Wiki > CRM Priorität). - - Filtert nach gültiger Technikerzahl (>0). - - Erstellt Zielvariable `Techniker_Bucket` (7 Kategorien). - - Führt One-Hot Encoding für Branchen durch. -- Implementiere Logik im `train_technician_model`-Modus in `main`: - - Führt Train/Test-Split durch (stratifiziert). - - Imputiert fehlende numerische Werte mit Median (fittet auf Train, transformiert Train/Test). - - Trainiert einen `DecisionTreeClassifier` mittels `GridSearchCV` zur Hyperparameter-Optimierung (Fokus auf `f1_weighted`). - - Evaluiert das beste Modell auf dem Test-Set (Accuracy, Classification Report, Confusion Matrix). - - Extrahiert Baumregeln mittels `export_text`. - - Speichert den trainierten Imputer, das beste Modell (`.pkl`) und die extrahierten Regeln (`.txt`). -- Füge notwendige Imports für `pandas`, `numpy`, `sklearn`, `pickle`, `json` hinzu. -- Ergänze neue Konfigurationsparameter für ML in `Config` (Worker, Limits). -- Füge Kommandozeilenargumente für Modell-Ausgabedateien hinzu. +- Replace custom `debug_print` function with standard Python `logging` module calls throughout the codebase. + - Use appropriate logging levels (DEBUG, INFO, WARNING, ERROR, CRITICAL, EXCEPTION). + - Refactor logging setup in `main` for clarity and proper handler initialization. +- Integrate updated `WikipediaScraper` class (previously developed as v1.6.5 logic): + - Implement more robust infobox parsing (`_extract_infobox_value`) using flexible selectors, keyword checking (`in`), and improved value cleaning (incl. `sup` removal). + - Remove old infobox fallback functions. + - Enhance article validation (`_validate_article`) with better link checking via `_get_page_soup`. + - Improve reliability of article search (`search_company_article`) with direct match attempt and better error handling. + - Apply `@retry_on_failure` decorator to network-dependent scraper methods (`_get_page_soup`, `search_company_article`). +- Ensure `Config.VERSION` reflects the logical state (v1.6.5 for this commit). """ import os @@ -99,18 +92,39 @@ class Config: API_KEYS = {} @classmethod - def load_api_keys(cls): # unverändert + def load_api_keys(cls): + """Lädt API-Schlüssel aus den definierten Dateien.""" + logging.info("Lade API-Schlüssel...") cls.API_KEYS['openai'] = cls._load_key_from_file(API_KEY_FILE) cls.API_KEYS['serpapi'] = cls._load_key_from_file(SERP_API_KEY_FILE) cls.API_KEYS['genderize'] = cls._load_key_from_file(GENDERIZE_API_KEY_FILE) - if cls.API_KEYS.get('openai'): openai.api_key = cls.API_KEYS['openai'] - else: debug_print("⚠️ OpenAI API Key konnte nicht geladen werden.") + if cls.API_KEYS.get('openai'): + openai.api_key = cls.API_KEYS['openai'] + logging.info("OpenAI API Key erfolgreich geladen.") + else: + # Dies ist eine Warnung, keine blockierende Fehlermeldung + logging.warning("OpenAI API Key konnte nicht geladen werden (Datei fehlt oder ist leer?).") @staticmethod - def _load_key_from_file(filepath): # unverändert + def _load_key_from_file(filepath): + """Hilfsfunktion zum Laden eines Schlüssels aus einer Datei.""" try: - with open(filepath, "r") as f: return f.read().strip() - except Exception as e: debug_print(f"Fehler Keys aus '{filepath}': {e}"); return None + with open(filepath, "r", encoding="utf-8") as f: # Encoding hinzugefügt + key = f.read().strip() + if key: + logging.debug(f"Schlüssel aus '{filepath}' erfolgreich geladen.") + return key + else: + logging.warning(f"Datei '{filepath}' ist leer.") + return None + except FileNotFoundError: + # Info, da das Fehlen eines Keys nicht immer ein Fehler sein muss + logging.info(f"API-Schlüsseldatei '{filepath}' nicht gefunden.") + return None + except Exception as e: + # Error, wenn beim Lesen ein anderer Fehler auftritt + logging.error(f"Fehler beim Lesen der Schlüsseldatei '{filepath}': {e}") + return None # Globales Mapping-Dictionary und Schema-String BRANCH_MAPPING = {} @@ -202,20 +216,20 @@ def prepare_data_for_modeling(sheet_handler): pandas.DataFrame: Vorbereiteter DataFrame für Training/Test-Split, oder None bei Fehlern. """ - debug_print("Starte Datenvorbereitung für Modellierung...") + logging.info("Starte Datenvorbereitung für Modellierung...") try: # --- 1. Daten laden & Spalten auswählen --- all_data = sheet_handler.get_all_data_with_headers() if len(all_data) <= 5: # Annahme: 5 Header-Zeilen - debug_print("Fehler: Nicht genügend Datenzeilen im Sheet gefunden.") + logging.error("Fehler: Nicht genügend Datenzeilen im Sheet gefunden für Modellierung.") return None headers = all_data[0] # Nimm die erste Zeile als Header für Pandas data_rows = all_data[5:] # Daten ohne die ersten 5 Header-Zeilen # Erstelle DataFrame df = pd.DataFrame(data_rows, columns=headers) - debug_print(f"DataFrame erstellt mit {len(df)} Zeilen und {len(df.columns)} Spalten.") + logging.info(f"DataFrame für Modellierung erstellt mit {len(df)} Zeilen und {len(df.columns)} Spalten.") # Wähle benötigte Spalten aus ( passe die Schlüssel an deine COLUMN_MAP an!) required_cols_keys = [ @@ -227,79 +241,70 @@ def prepare_data_for_modeling(sheet_handler): "Wiki Mitarbeiter", "CRM Anzahl Techniker" # ÄNDERE DIESEN SCHLÜSSEL, falls die bekannte Zahl woanders steht! ] - - # Finde die tatsächlichen Spaltennamen aus den Headern basierend auf COLUMN_MAP Beschreibung (Zeile 4) - # ODER verwende direkt die Spaltennamen, wenn sie stabil sind. - # Hier vereinfacht angenommen, dass die Schlüssel oben die Spaltennamen sind: + try: - # Konvertiere Spaltennamen aus COLUMN_MAP zu echten Spaltennamen im DataFrame (falls nötig) - # Dies ist ein Platzhalter - im echten Code müsstest du die Header-Zeilen parsen - # oder dich darauf verlassen, dass die Schlüssel oben die exakten Spaltennamen sind. - df_subset = df[required_cols_keys].copy() # Kopie erstellen, um SettingWithCopyWarning zu vermeiden + # Verwende direkt die Schlüssel als Spaltennamen (Annahme) + # oder implementiere hier eine robustere Zuordnung über Header + df_subset = df[required_cols_keys].copy() # Kopie erstellen except KeyError as e: - debug_print(f"FEHLER: Benötigte Spalte nicht im DataFrame gefunden: {e}. Verfügbare Spalten: {list(df.columns)}") + logging.error(f"FEHLER: Benötigte Spalte nicht im DataFrame gefunden: {e}. Verfügbare Spalten: {list(df.columns)}") return None - - debug_print(f"Benötigte Spalten ausgewählt.") + + logging.info(f"Benötigte Spalten für Modellierung ausgewählt.") # --- 2. Features konsolidieren (Umsatz, Mitarbeiter) --- - # Hilfsfunktion zur Validierung und Konvertierung def get_valid_numeric(value_str): - if value_str is None or pd.isna(value_str): return np.nan + # (Implementierung wie gehabt, aber mit Logging bei Fehlern) + if value_str is None or pd.isna(value_str) or str(value_str).strip() == '': return np.nan + original_value = value_str # Für Logging speichern try: - # Versuche direkt float zu konvertieren - val = float(value_str) + cleaned_str = str(value_str).replace('.', '').replace(',', '.') # Tausender raus, Komma zu Punkt + val = float(cleaned_str) return val if val > 0 else np.nan # Nur Werte > 0 sind gültig except (ValueError, TypeError): - # Wenn nicht direkt float, versuche es über extract_numeric_value - # Diese Funktion muss dafür angepasst werden, float oder np.nan zurückzugeben - # num_val_str = extract_numeric_value(str(value_str), is_umsatz=True) # Bsp. Umsatz - # if num_val_str != "k.A.": - # try: - # val = float(num_val_str) - # return val if val > 0 else np.nan - # except ValueError: return np.nan - # else: return np.nan - # --- VEREINFACHUNG für jetzt: Nur direkt konvertierbare Werte --- - cleaned_str = re.sub(r'[^\d.,]', '', str(value_str)).replace(',', '.') # Einfache Reinigung + # Logging auf DEBUG-Level, da dies häufig vorkommen kann + logging.debug(f"Konntze Wert '{original_value}' nicht direkt in Float umwandeln.") + # Fallback über extract_numeric_value (falls vorhanden und float zurückgibt) + # try: + # num_val = extract_numeric_value(str(value_str)) # Annahme: gibt float/int oder Exception/None/NaN zurück + # if isinstance(num_val, (int, float)) and num_val > 0: return float(num_val) + # except Exception: pass # Fehler ignorieren, Fallback weiter unten + # --- VEREINFACHTER FALLBACK --- + cleaned_str = re.sub(r'[^\d.]', '', str(value_str)) # Nur Ziffern und Punkt behalten + if not cleaned_str: return np.nan try: val = float(cleaned_str) return val if val > 0 else np.nan except ValueError: + logging.debug(f"Konntze auch bereinigten String '{cleaned_str}' aus '{original_value}' nicht umwandeln.") return np.nan - - # Konvertiere Quellen-Spalten und wende Priorisierung an cols_to_process = { 'Umsatz': ('Wiki Umsatz', 'CRM Umsatz', 'Finaler_Umsatz'), 'Mitarbeiter': ('Wiki Mitarbeiter', 'CRM Anzahl Mitarbeiter', 'Finaler_Mitarbeiter') } for base_name, (wiki_col, crm_col, final_col) in cols_to_process.items(): - debug_print(f"Verarbeite '{base_name}' (Wiki: {wiki_col}, CRM: {crm_col})...") + logging.info(f"Verarbeite und konsolidiere '{base_name}' (Priorität: Wiki > CRM)...") wiki_numeric = df_subset[wiki_col].apply(get_valid_numeric) crm_numeric = df_subset[crm_col].apply(get_valid_numeric) - - # Priorisierung: Wiki > CRM + df_subset[final_col] = np.where( - wiki_numeric.notna() & (wiki_numeric > 0), # Wenn Wiki gültig + wiki_numeric.notna(), # & (wiki_numeric > 0) ist durch get_valid_numeric abgedeckt wiki_numeric, np.where( - crm_numeric.notna() & (crm_numeric > 0), # Sonst, wenn CRM gültig + crm_numeric.notna(), # & (crm_numeric > 0) crm_numeric, - np.nan # Sonst NaN + np.nan ) ) - # Logge, wie viele Werte gefunden wurden - debug_print(f" -> {df_subset[final_col].notna().sum()} gültige '{final_col}' Werte erstellt.") - - # Entferne die Originalspalten (optional) - # df_subset = df_subset.drop(columns=[wiki_col, crm_col]) + # Info-Log über Ergebnis + logging.info(f" -> {df_subset[final_col].notna().sum()} gültige '{final_col}' Werte erstellt.") # --- 3. Zielvariable vorbereiten (Technikerzahl) --- techniker_col = "CRM Anzahl Techniker" # ÄNDERE DAS WENN NÖTIG! - debug_print(f"Verarbeite Zielvariable '{techniker_col}'...") - + logging.info(f"Verarbeite Zielvariable '{techniker_col}'...") + # Konvertiere zu Numerisch (Fehler -> NaN) df_subset['Anzahl_Servicetechniker_Numeric'] = pd.to_numeric(df_subset[techniker_col], errors='coerce') @@ -310,71 +315,71 @@ def prepare_data_for_modeling(sheet_handler): (df_subset['Anzahl_Servicetechniker_Numeric'] > 0) ].copy() filtered_rows = len(df_filtered) - debug_print(f"{initial_rows - filtered_rows} Zeilen entfernt aufgrund fehlender/ungültiger Technikerzahl.") - debug_print(f"Verbleibende Zeilen für Modellierung: {filtered_rows}") + removed_rows = initial_rows - filtered_rows + # Info, wenn Zeilen entfernt wurden + if removed_rows > 0: + logging.info(f"{removed_rows} Zeilen entfernt aufgrund fehlender/ungültiger Technikerzahl (Wert <= 0 oder nicht numerisch).") + logging.info(f"Verbleibende Zeilen für Modellierung: {filtered_rows}") if filtered_rows == 0: - debug_print("FEHLER: Keine Zeilen mit gültiger Technikerzahl übrig!") + logging.error("FEHLER: Keine Zeilen mit gültiger Technikerzahl (>0) übrig für Modellierung!") return None # --- 4. Techniker-Buckets erstellen --- - bins = [-1, 0, 19, 49, 99, 249, 499, float('inf')] # -1 um 0 einzuschließen - # Labels sollten keine Sonderzeichen enthalten, die Probleme machen könnten - labels = ['Bucket_1_0', 'Bucket_2_<20', 'Bucket_3_<50', 'Bucket_4_<100', 'Bucket_5_<250', 'Bucket_6_<500', 'Bucket_7_>499'] + bins = [-1, 0, 19, 49, 99, 249, 499, float('inf')] # -1 um 0 einzuschließen (obwohl wir >0 filtern) + labels = ['Bucket_1_(0)', 'Bucket_2_(<20)', 'Bucket_3_(<50)', 'Bucket_4_(<100)', 'Bucket_5_(<250)', 'Bucket_6_(<500)', 'Bucket_7_(>499)'] # Namen angepasst df_filtered['Techniker_Bucket'] = pd.cut( df_filtered['Anzahl_Servicetechniker_Numeric'], bins=bins, labels=labels, - right=True # 19 gehört zu <20, 49 zu <50 etc. + right=True # 19 gehört zu <20 etc. ) - debug_print("Techniker-Buckets erstellt.") - debug_print(f"Verteilung der Buckets:\n{df_filtered['Techniker_Bucket'].value_counts()}") + logging.info("Techniker-Buckets erstellt.") + # Verteilung als Debug-Info + logging.debug(f"Verteilung der Buckets:\n{df_filtered['Techniker_Bucket'].value_counts(normalize=True).round(3)}") # --- 5. Kategoriale Features vorbereiten (Branche) --- branche_col = "CRM Branche" # Annahme: CRM Branche ist die zu verwendende - debug_print(f"Verarbeite kategoriales Feature '{branche_col}'...") - + logging.info(f"Verarbeite kategoriales Feature '{branche_col}' für One-Hot Encoding...") + # Stelle sicher, dass die Spalte String ist und fülle evtl. NaNs - df_filtered[branche_col] = df_filtered[branche_col].astype(str).fillna('Unbekannt') - + df_filtered[branche_col] = df_filtered[branche_col].astype(str).fillna('Unbekannt').str.strip() # .str.strip() hinzugefügt + # One-Hot Encoding - df_encoded = pd.get_dummies(df_filtered, columns=[branche_col], prefix='Branche', dummy_na=False) # dummy_na=False: keine extra Spalte für NaN - debug_print(f"One-Hot Encoding für Branche durchgeführt. Neue Spaltenanzahl: {len(df_encoded.columns)}") + df_encoded = pd.get_dummies(df_filtered, columns=[branche_col], prefix='Branche', dummy_na=False) # dummy_na=False + logging.info(f"One-Hot Encoding für '{branche_col}' durchgeführt. Neue Spaltenanzahl: {len(df_encoded.columns)}") # --- 6. Finale Auswahl der Features für das Modell --- - # Liste aller Feature-Spalten (One-Hot Branchen + numerische) feature_columns = [col for col in df_encoded.columns if col.startswith('Branche_')] feature_columns.extend(['Finaler_Umsatz', 'Finaler_Mitarbeiter']) - - # Zielspalte target_column = 'Techniker_Bucket' - # Erstelle den finalen DataFrame - df_model_ready = df_encoded[feature_columns + [target_column]].copy() - + # Erstelle den finalen DataFrame (nur benötigte Spalten) + # Behalte Originaldaten für spätere Analyse / Zuordnung + original_data_cols = ['CRM Name', 'Anzahl_Servicetechniker_Numeric'] + df_model_ready = df_encoded[original_data_cols + feature_columns + [target_column]].copy() + # Optional: Spalten auf einfache Typen reduzieren (kann Speicher sparen) for col in ['Finaler_Umsatz', 'Finaler_Mitarbeiter']: df_model_ready[col] = pd.to_numeric(df_model_ready[col], errors='coerce') - + # Reset Index für saubere Verarbeitung im nächsten Schritt df_model_ready = df_model_ready.reset_index(drop=True) - debug_print("Datenvorbereitung abgeschlossen.") - debug_print(f"Finaler DataFrame für Modellierung hat {len(df_model_ready)} Zeilen und {len(df_model_ready.columns)} Spalten.") - debug_print(f"Feature-Spalten: {feature_columns}") - debug_print(f"Ziel-Spalte: {target_column}") - - # WICHTIG: Dieser DataFrame enthält noch NaNs in 'Finaler_Umsatz'/'Finaler_Mitarbeiter'! - # Die Imputation sollte NACH dem Train/Test Split erfolgen. + logging.info("Datenvorbereitung für Modellierung abgeschlossen.") + logging.debug(f"Finaler DataFrame für Modellierung hat {len(df_model_ready)} Zeilen und {len(df_model_ready.columns)} Spalten.") + logging.debug(f"Feature-Spalten: {feature_columns}") + logging.debug(f"Ziel-Spalte: {target_column}") + + # WICHTIG: Info über fehlende Werte vor Imputation nan_counts = df_model_ready[['Finaler_Umsatz', 'Finaler_Mitarbeiter']].isna().sum() - debug_print(f"Fehlende Werte in numerischen Features:\n{nan_counts}") + logging.info(f"Fehlende Werte in numerischen Features vor Imputation:\n{nan_counts}") return df_model_ready except Exception as e: - debug_print(f"FEHLER während der Datenvorbereitung: {e}") - import traceback - debug_print(traceback.format_exc()) + # exception loggt automatisch den Traceback + logging.exception(f"FEHLER während der Datenvorbereitung: {e}") return None # --- Beispielhafter Aufruf (zum Testen) --- @@ -465,26 +470,55 @@ def simple_normalize_url(url): if not url or not isinstance(url, str): return "k.A." url = url.strip() - if not url: + if not url or url.lower() == 'k.a.': # Prüfe auch auf 'k.a.' return "k.A." - # Falls kein Schema vorhanden ist, hinzufügen + + # Falls kein Schema vorhanden ist, hinzufügen (HTTPS bevorzugen) if not url.lower().startswith(("http://", "https://")): url = "https://" + url try: parsed = urlparse(url) domain_part = parsed.netloc + if not domain_part: # Wenn netloc leer ist (z.B. bei relativen Pfaden oder mailto:) + logging.warning(f"URL '{url}' konnte nicht sinnvoll geparst werden (leerer netloc).") + return "k.A." + # Entferne einen eventuellen Port (z.B. ":8080") domain_part = domain_part.split(":", 1)[0] - # Wenn die Domain nicht mit "www." beginnt, hinzufügen (außer bei sehr kurzen Domains) - if not domain_part.lower().startswith("www.") and '.' in domain_part: + # Entferne evtl. User/Passwort-Teile (user:pass@domain) + if '@' in domain_part: + domain_part = domain_part.split('@', 1)[1] + + # Wandle Punycode (IDN) in Unicode um für Lesbarkeit (optional, aber oft sinnvoll) + try: + domain_part = domain_part.encode('ascii').decode('idna') + except UnicodeDecodeError: + logging.warning(f"Fehler bei IDNA-Dekodierung für Domain '{domain_part}' aus URL '{url}'. Behalte Original.") + # Behalte den ursprünglichen domain_part, wenn Dekodierung fehlschlägt + pass + + domain_part = domain_part.lower() # Einheitliche Kleinschreibung + + # Wenn die Domain nicht mit "www." beginnt, hinzufügen (außer bei sehr kurzen Domains oder IPs) + if not domain_part.startswith("www.") and '.' in domain_part: # Ausnahme für IP-Adressen oder ungewöhnliche Namen ohne TLD - if re.match(r"^\d{1,3}(\.\d{1,3}){3}$", domain_part): - pass # IP-Adresse behalten - else: - domain_part = "www." + domain_part - return domain_part.lower() # Einheitliche Kleinschreibung + if not re.match(r"^\d{1,3}(\.\d{1,3}){3}$", domain_part): + # Prüfe, ob es eine bekannte TLD ist (einfache Prüfung) + if domain_part.split('.')[-1].isalpha() and len(domain_part.split('.')[-1]) > 1: + domain_part = "www." + domain_part + + # Optional: Unerwünschte Pfade entfernen (alles nach dem ersten /) + # return domain_part # Gibt nur www.domain.tld zurück + + # Oder: Normalisierte URL mit Schema zurückgeben? + # return f"{parsed.scheme}://{domain_part}" + + # Aktuell: Nur Domain zurückgeben + return domain_part + except Exception as e: - debug_print(f"Fehler bei URL-Normalisierung '{url}': {e}") + # Error loggen, da Parsen fehlschlug + logging.error(f"Fehler bei URL-Normalisierung für '{url}': {e}") return "k.A." def normalize_string(s): @@ -515,17 +549,28 @@ def normalize_string(s): return s def clean_text(text): - """Bereinigt Text von Wikipedia etc.""" - if not text: - return "k.A." + """Bereinigt Text von Wikipedia etc. (Unicode, Referenzen, Whitespace).""" + if text is None: return "k.A." # Behandle None explizit try: text = str(text) # Sicherstellen, dass es ein String ist - text = unicodedata.normalize("NFKC", text) # Normalisiert Whitespace, Ligaturen etc. - text = re.sub(r'\[\d+\]', '', text) # Entfernt [1], [2] etc. - text = re.sub(r'\s+', ' ', text).strip() # Reduziert multiple Leerzeichen + if not text.strip(): return "k.A." # Leere oder nur Whitespace-Strings + + # Normalisiert Whitespace, Ligaturen etc. + # NFKC ist oft aggressiver, NFKD zerlegt mehr, NFD ist oft gut für Akzententfernung + text = unicodedata.normalize("NFC", text) # NFC ist oft ein guter Kompromiss + + # Entfernt Referenz-Tags wie [1], [2], [Bearbeiten | Quelltext bearbeiten] etc. + text = re.sub(r'\[\d+\]', '', text) + text = re.sub(r'\[Bearbeiten\s*\|\s*Quelltext bearbeiten\]', '', text, flags=re.IGNORECASE) + + # Ersetzt multiple Leerzeichen/Tabs/Newlines durch ein einzelnes Leerzeichen + text = re.sub(r'\s+', ' ', text).strip() + + # Wenn nach Bereinigung leer, gib k.A. zurück return text if text else "k.A." except Exception as e: - debug_print(f"Fehler bei clean_text: {e}") + # Fehlermeldung beim Bereinigen + logging.error(f"Fehler bei clean_text für Input '{str(text)[:50]}...': {e}") return "k.A." @@ -592,33 +637,36 @@ def is_valid_wikipedia_article_url(wiki_url): Returns: bool: True, wenn es ein valider Artikel zu sein scheint, sonst False. """ - if not wiki_url or not wiki_url.lower().startswith(("http://", "https://")) or "wikipedia.org/wiki/" not in wiki_url: + if not wiki_url or not isinstance(wiki_url, str) or not wiki_url.lower().startswith(("http://", "https://")) or "wikipedia.org/wiki/" not in wiki_url.lower(): # lower() für Robustheit + logging.debug(f"is_valid_wikipedia_article_url: Ungültiges Format oder keine Wikipedia-URL: '{wiki_url}'") return False + title = "URL_PARSE_ERROR" # Default für Logging try: # Extrahiere den Artikel-Titel aus der URL - # Beispiel: https://de.wikipedia.org/wiki/B._Braun_Melsungen -> B._Braun_Melsungen - title = wiki_url.split('/wiki/', 1)[1] + title_part = wiki_url.split('/wiki/', 1)[1] # Dekodiere URL-kodierte Zeichen (z.B. %C3%BC -> ü) - title = unquote(title) + title = unquote(title_part) # Ersetze Unterstriche durch Leerzeichen für die API-Suche title = title.replace('_', ' ') # Baue die API URL (für deutsche Wikipedia) - # Doku: https://www.mediawiki.org/wiki/API:Query api_url = "https://de.wikipedia.org/w/api.php" params = { "action": "query", "titles": title, "format": "json", "formatversion": 2, # Moderneres JSON-Format - "redirects": 1 # Folge Weiterleitungen (optional, aber oft sinnvoll) + "prop": "info|pageprops", # Info und Page Properties abfragen + "redirects": 1 # Folge Weiterleitungen } + logging.debug(f"is_valid_wikipedia_article_url: Prüfe Titel '{title}' via MediaWiki API...") # Führe den API Call durch - response = requests.get(api_url, params=params, timeout=5) + response = requests.get(api_url, params=params, timeout=10, headers={'User-Agent': Config.USER_AGENT}) # Timeout und UserAgent response.raise_for_status() data = response.json() + logging.debug(f" -> API Antwort für '{title}': {str(data)[:200]}...") # Logge Anfang der Antwort # Analysiere die Antwort if 'query' in data and 'pages' in data['query']: @@ -627,33 +675,39 @@ def is_valid_wikipedia_article_url(wiki_url): page_info = pages[0] # Nimm die erste (und einzige) Seite # Prüfe auf 'missing': Seite existiert nicht if page_info.get('missing', False): - debug_print(f" API Check für '{title}': Seite fehlt (missing=True).") + logging.debug(f" API Check für '{title}': Seite fehlt (missing=True).") return False # Prüfe auf 'invalid': Titel ist ungültig if page_info.get('invalid', False): - debug_print(f" API Check für '{title}': Titel ungültig (invalid=True).") + logging.debug(f" API Check für '{title}': Titel ungültig (invalid=True).") return False # Prüfe auf 'disambiguation': Ist eine Begriffsklärungsseite - # (Hinweis: 'pageprops' ist nicht immer vorhanden) if 'pageprops' in page_info and 'disambiguation' in page_info['pageprops']: - debug_print(f" API Check für '{title}': Seite ist eine Begriffsklärung.") + logging.debug(f" API Check für '{title}': Seite ist eine Begriffsklärung.") return False # Wenn nichts davon zutrifft, scheint es ein valider Artikel zu sein - debug_print(f" API Check für '{title}': Scheint ein valider Artikel zu sein.") + logging.info(f" API Check für '{title}': Scheint ein valider Artikel zu sein.") return True else: - debug_print(f" API Check für '{title}': Leere 'pages'-Liste in Antwort.") + # Warnung, da unerwartet + logging.warning(f" API Check für '{title}': Leere 'pages'-Liste in API-Antwort.") return False # Unerwartete Antwort else: - debug_print(f" API Check für '{title}': Unerwartetes Format der API-Antwort: {data}") + # Warnung, da unerwartet + logging.warning(f" API Check für '{title}': Unerwartetes Format der API-Antwort (fehlendes 'query' oder 'pages').") return False except requests.exceptions.RequestException as e: - debug_print(f" API Check für '{title}': Netzwerkfehler - {e}") - return False # Im Zweifel als ungültig werten + # Error bei Netzwerkproblemen + logging.error(f" API Check für '{title}': Netzwerkfehler - {e}") + # Fehler weitergeben, damit retry_on_failure greift + raise e except Exception as e: - debug_print(f" API Check für '{title}': Allgemeiner Fehler - {e}") - return False # Im Zweifel als ungültig werten + # Error bei anderen Problemen + logging.error(f" API Check für '{title}': Allgemeiner Fehler - {e}") + # Im Zweifel als ungültig werten, aber keinen Fehler für Retry werfen? Oder doch? + # Besser Fehler weitergeben, falls es ein temporäres Problem ist. + raise e # Fehler weitergeben # NEUE Funktion für Wiki-Updates basierend auf ChatGPT Vorschlägen # NEUE Funktion für Wiki-Updates basierend auf ChatGPT Vorschlägen (mit Status-Update in S) @@ -665,208 +719,274 @@ def process_wiki_updates_from_chatgpt(sheet_handler, data_processor, row_limit=N - Wenn nein (U keine URL, U==M, oder U ungültig): LÖSCHT den Inhalt von U und markiert S als 'X (Invalid Suggestion)'. Verarbeitet maximal row_limit Zeilen. """ - debug_print("Starte Modus: Wiki-Updates (URL-Validierung & Löschen ungültiger Vorschläge)...") + logging.info("Starte Modus: Wiki-Updates (URL-Validierung & Löschen ungültiger Vorschläge)...") + if row_limit is not None: + logging.info(f"Zeilenlimit für diesen Lauf: {row_limit}") - if not sheet_handler.load_data(): return + if not sheet_handler.load_data(): return # load_data loggt intern all_data = sheet_handler.get_all_data_with_headers() - if not all_data or len(all_data) <= 5: return + if not all_data or len(all_data) <= 5: + logging.warning("Keine oder zu wenige Daten im Sheet für Wiki-Updates gefunden.") + return header_rows = 5 data_rows = all_data[header_rows:] - # --- Indizes holen (Korrigierte Schleife) --- + # --- Indizes holen --- required_keys = [ "Chat Wiki Konsistenzprüfung", "Chat Vorschlag Wiki Artikel", "Wiki URL", "Wikipedia Timestamp", "Wiki Verif. Timestamp", "Timestamp letzte Prüfung", "Version", - "ReEval Flag" # Spalte A für ReEval-Flag + "ReEval Flag" ] col_indices = {} all_keys_found = True - # --- KORRIGIERTE SYNTAX --- for key in required_keys: idx = COLUMN_MAP.get(key) - col_indices[key] = idx # Speichere den Index (kann auch None sein) + col_indices[key] = idx if idx is None: - debug_print(f"FEHLER: Schlüssel '{key}' für Spaltenindex fehlt in COLUMN_MAP!") + logging.error(f"FEHLER: Schlüssel '{key}' für Spaltenindex fehlt in COLUMN_MAP!") all_keys_found = False - # --- ENDE KORRIGIERTE SYNTAX --- if not all_keys_found: - debug_print("Breche Wiki-Updates ab, da Spaltenindizes fehlen.") + logging.error("Breche Wiki-Updates ab, da Spaltenindizes fehlen.") return # --- Ende Indizes holen --- all_sheet_updates = [] - processed_rows_count = 0 # Zählt alle Zeilen, die geprüft werden + processed_rows_count = 0 # Zählt Zeilen, die geprüft werden updated_url_count = 0 # Zählt Zeilen, wo URL kopiert wurde cleared_suggestion_count = 0 # Zählt Zeilen, wo Vorschlag gelöscht wurde - error_rows_count = 0 # Behalte Fehlerzählung bei + # Iteriere durch die Datenzeilen for idx, row in enumerate(data_rows): row_num_in_sheet = idx + header_rows + 1 if row_limit is not None and processed_rows_count >= row_limit: - debug_print(f"Zeilenlimit ({row_limit}) erreicht.") + logging.info(f"Zeilenlimit ({row_limit}) erreicht.") break # --- Hilfsfunktion für sicheren Zugriff --- def get_value(key): index = col_indices.get(key) - # Prüfe ob Index existiert UND ob die Zeile lang genug ist - if index is not None and len(row) > index: - return row[index] - # Falls Index nicht existiert ODER Zeile zu kurz, leeren String zurückgeben - # debug_print(f"Warnung Zeile {row_num_in_sheet}: Index für '{key}' ({index}) nicht gefunden oder Zeilenlänge ({len(row)}) zu kurz.") + if index is not None and len(row) > index: return row[index] + # Logge nur auf Debug-Level, wenn ein Wert fehlt + # logging.debug(f"Zeile {row_num_in_sheet}: Wert für '{key}' nicht vorhanden (Index: {index}, Zeilenlänge: {len(row)}).") return "" - # --- Ende Hilfsfunktion --- konsistenz_s = get_value("Chat Wiki Konsistenzprüfung") vorschlag_u = get_value("Chat Vorschlag Wiki Artikel") url_m = get_value("Wiki URL") # Bedingungen prüfen - is_update_candidate = False; new_url = "" konsistenz_s_upper = konsistenz_s.strip().upper() vorschlag_u_cleaned = vorschlag_u.strip() url_m_cleaned = url_m.strip() - condition1_status_nok = konsistenz_s_upper not in ["OK", "X (UPDATED)", "X (URL COPIED)", "X (INVALID SUGGESTION)", ""] - condition2_u_is_url = vorschlag_u_cleaned.lower().startswith(("http://", "https://")) and "wikipedia.org/wiki/" in vorschlag_u_cleaned.lower() - condition3_u_differs_m = False; condition4_u_is_valid = False - if condition1_status_nok and condition2_u_is_url: - new_url = vorschlag_u_cleaned - condition3_u_differs_m = new_url != url_m_cleaned - if condition3_u_differs_m: - # debug_print(f"Zeile {row_num_in_sheet}: Potenzieller Kandidat. Prüfe Validität von URL: {new_url}...") # Weniger Lärm - condition4_u_is_valid = is_valid_wikipedia_article_url(new_url) # Annahme: Funktion existiert - # if not condition4_u_is_valid: debug_print(f"Zeile {row_num_in_sheet}: URL '{new_url}' ist KEIN valider Artikel.") # Weniger Lärm + # Zustand, der eine Prüfung/Aktion auslöst: + # Status S ist gesetzt UND NICHT einer der End-/Bearbeitungszustände + is_candidate_for_check = konsistenz_s_upper and konsistenz_s_upper not in ["OK", "X (UPDATED)", "X (URL COPIED)", "X (INVALID SUGGESTION)"] - is_update_candidate = condition1_status_nok and condition2_u_is_url and condition3_u_differs_m and condition4_u_is_valid - clear_invalid_suggestion = condition1_status_nok and not is_update_candidate - - # --- Verarbeitung des Kandidaten ODER Löschen des Vorschlags --- - if is_update_candidate: - # Fall 1: Gültiges Update durchführen - debug_print(f"Zeile {row_num_in_sheet}: Update-Kandidat VALIDIERUNG ERFOLGREICH. Setze ReEval-Flag und bereite Updates vor.") + if is_candidate_for_check: + logging.debug(f"Zeile {row_num_in_sheet}: Kandidat für Wiki-Update-Prüfung (Status S = '{konsistenz_s}'). Vorschlag U = '{vorschlag_u_cleaned}'") processed_rows_count += 1 # Zähle geprüfte Zeile - updated_url_count += 1 # Zähle erfolgreiches Update - # Updates sammeln (M, S, U, Timestamps/Version löschen, A setzen) - m_l=sheet_handler._get_col_letter(col_indices["Wiki URL"]+1); s_l=sheet_handler._get_col_letter(col_indices["Chat Wiki Konsistenzprüfung"]+1); u_l=sheet_handler._get_col_letter(col_indices["Chat Vorschlag Wiki Artikel"]+1); an_l=sheet_handler._get_col_letter(col_indices["Wikipedia Timestamp"]+1); ax_l=sheet_handler._get_col_letter(col_indices["Wiki Verif. Timestamp"]+1); ao_l=sheet_handler._get_col_letter(col_indices["Timestamp letzte Prüfung"]+1); ap_l=sheet_handler._get_col_letter(col_indices["Version"]+1); a_l=sheet_handler._get_col_letter(col_indices["ReEval Flag"]+1) - row_updates = [ - {'range': f'{m_l}{row_num_in_sheet}', 'values': [[new_url]]}, - {'range': f'{s_l}{row_num_in_sheet}', 'values': [["X (URL Copied)"]]}, - {'range': f'{u_l}{row_num_in_sheet}', 'values': [["URL übernommen"]]}, - {'range': f'{an_l}{row_num_in_sheet}', 'values': [[""]]}, - {'range': f'{ax_l}{row_num_in_sheet}', 'values': [[""]]}, - {'range': f'{ao_l}{row_num_in_sheet}', 'values': [[""]]}, - {'range': f'{ap_l}{row_num_in_sheet}', 'values': [[""]]}, - {'range': f'{a_l}{row_num_in_sheet}', 'values': [["x"]]}, - ] - all_sheet_updates.extend(row_updates) - elif clear_invalid_suggestion: - # Fall 2: Ungültigen Vorschlag löschen/markieren - debug_print(f"Zeile {row_num_in_sheet}: Status S war '{konsistenz_s}', aber Vorschlag U ('{vorschlag_u_cleaned}') ist ungültig/identisch. Lösche U und setze Status S.") - processed_rows_count += 1 # Zähle geprüfte Zeile - cleared_suggestion_count += 1 - s_l=sheet_handler._get_col_letter(col_indices["Chat Wiki Konsistenzprüfung"]+1) - u_l=sheet_handler._get_col_letter(col_indices["Chat Vorschlag Wiki Artikel"]+1) - row_updates = [ - {'range': f'{s_l}{row_num_in_sheet}', 'values': [["X (Invalid Suggestion)"]]}, - {'range': f'{u_l}{row_num_in_sheet}', 'values': [[""]]} - ] - all_sheet_updates.extend(row_updates) - # Kein ReEval-Flag setzen + # Prüfe, ob Vorschlag U eine valide URL ist und sich von M unterscheidet + is_update_candidate = False + new_url = "" + condition2_u_is_url = vorschlag_u_cleaned.lower().startswith(("http://", "https://")) and "wikipedia.org/wiki/" in vorschlag_u_cleaned.lower() + + if condition2_u_is_url: + new_url = vorschlag_u_cleaned + condition3_u_differs_m = new_url != url_m_cleaned + if condition3_u_differs_m: + logging.debug(f" -> Prüfe Validität der neuen URL: {new_url}...") + condition4_u_is_valid = is_valid_wikipedia_article_url(new_url) # Nutzt die überarbeitete Funktion + if condition4_u_is_valid: + is_update_candidate = True + else: + logging.debug(f" -> URL '{new_url}' ist KEIN valider Artikel laut API Check.") + else: + logging.debug(f" -> Vorschlag U ist identisch mit URL M.") + else: + logging.debug(f" -> Vorschlag U ist keine Wikipedia URL.") + + # --- Verarbeitung des Kandidaten ODER Löschen des Vorschlags --- + if is_update_candidate: + # Fall 1: Gültiges Update durchführen + logging.info(f"Zeile {row_num_in_sheet}: Update-Kandidat VALIDIERUNG ERFOLGREICH. Setze ReEval-Flag 'x' und bereite Updates vor für URL: {new_url}") + updated_url_count += 1 + # Updates sammeln (M, S, U, Timestamps/Version löschen, A setzen) + m_l=sheet_handler._get_col_letter(col_indices["Wiki URL"]+1); s_l=sheet_handler._get_col_letter(col_indices["Chat Wiki Konsistenzprüfung"]+1); u_l=sheet_handler._get_col_letter(col_indices["Chat Vorschlag Wiki Artikel"]+1) + an_l=sheet_handler._get_col_letter(col_indices["Wikipedia Timestamp"]+1); ax_l=sheet_handler._get_col_letter(col_indices["Wiki Verif. Timestamp"]+1); ao_l=sheet_handler._get_col_letter(col_indices["Timestamp letzte Prüfung"]+1) + ap_l=sheet_handler._get_col_letter(col_indices["Version"]+1); a_l=sheet_handler._get_col_letter(col_indices["ReEval Flag"]+1) + row_updates = [ + {'range': f'{m_l}{row_num_in_sheet}', 'values': [[new_url]]}, + {'range': f'{s_l}{row_num_in_sheet}', 'values': [["X (URL Copied)"]]}, # Neuer Status + {'range': f'{u_l}{row_num_in_sheet}', 'values': [["URL übernommen"]]}, # Info in U + # Timestamps löschen, damit reeval greift + {'range': f'{an_l}{row_num_in_sheet}', 'values': [[""]]}, + {'range': f'{ax_l}{row_num_in_sheet}', 'values': [[""]]}, + {'range': f'{ao_l}{row_num_in_sheet}', 'values': [[""]]}, + {'range': f'{ap_l}{row_num_in_sheet}', 'values': [[""]]}, + {'range': f'{a_l}{row_num_in_sheet}', 'values': [["x"]]}, # ReEval Flag setzen! + ] + all_sheet_updates.extend(row_updates) + else: + # Fall 2: Ungültigen Vorschlag löschen/markieren + logging.info(f"Zeile {row_num_in_sheet}: Status S ('{konsistenz_s}') erfordert Prüfung, aber Vorschlag U ('{vorschlag_u_cleaned}') ist ungültig/identisch. Lösche U und setze Status S.") + cleared_suggestion_count += 1 + s_l=sheet_handler._get_col_letter(col_indices["Chat Wiki Konsistenzprüfung"]+1) + u_l=sheet_handler._get_col_letter(col_indices["Chat Vorschlag Wiki Artikel"]+1) + row_updates = [ + {'range': f'{s_l}{row_num_in_sheet}', 'values': [["X (Invalid Suggestion)"]]}, # Neuer Status + {'range': f'{u_l}{row_num_in_sheet}', 'values': [[""]]} # Vorschlag löschen + ] + all_sheet_updates.extend(row_updates) + # Kein ReEval-Flag setzen # --- Batch Update am Ende --- if all_sheet_updates: - debug_print(f"BEREIT ZUM SENDEN: Batch-Update für {processed_rows_count} geprüfte Zeilen ({len(all_sheet_updates)} Zellen)...") - success = sheet_handler.batch_update_cells(all_sheet_updates) - if success: debug_print(f"Sheet-Update für Wiki-Updates erfolgreich.") - else: debug_print(f"FEHLER beim Sheet-Update für Wiki-Updates.") + # Info-Log über Anzahl der Updates + logging.info(f"Sende Batch-Update für {processed_rows_count} geprüfte Zeilen ({len(all_sheet_updates)} Zellen)...") + success = sheet_handler.batch_update_cells(all_sheet_updates) # Nutzt intern Logging + if success: + logging.info(f"Sheet-Update für Wiki-Updates erfolgreich.") + # Der else-Fall wird von batch_update_cells geloggt else: - debug_print("Keine Zeilen gefunden, die eine Wiki-URL-Korrektur oder Vorschlagsbereinigung benötigen.") + logging.info("Keine Zeilen gefunden, die eine Wiki-URL-Korrektur oder Vorschlagsbereinigung benötigen.") - debug_print(f"Wiki-Updates abgeschlossen. {processed_rows_count} Zeilen geprüft. {updated_url_count} URLs kopiert & für ReEval markiert, {cleared_suggestion_count} ungültige Vorschläge gelöscht/markiert.") + logging.info(f"Wiki-Updates abgeschlossen. {processed_rows_count} Zeilen geprüft. {updated_url_count} URLs kopiert & für ReEval markiert, {cleared_suggestion_count} ungültige Vorschläge gelöscht/markiert.") def extract_numeric_value(raw_value, is_umsatz=False): """Extrahiert und normalisiert Zahlenwerte (Umsatz in Mio, Mitarbeiter).""" - if not raw_value or not isinstance(raw_value, str): return "k.A." - raw_value = clean_text(raw_value) - if raw_value == "k.A.": return "k.A." + if not raw_value: return "k.A." + # Stelle sicher, dass raw_value ein String ist + raw_value_str = str(raw_value) + # Frühe Prüfung auf leeren String oder bekannte "Nicht verfügbar"-Werte + if not raw_value_str.strip() or raw_value_str.strip().lower() in ['k.a.', 'n/a', '-']: + return "k.A." - # Entferne Präfixe wie ca., über, etc. und Währungssymbole (€, $, etc.) und Punkte als Tausendertrenner - processed_value = re.sub(r'(?i)\b(ca\.?|circa|über|unter|rund|etwa|mehr als|weniger als|bis zu)\b', '', raw_value) - processed_value = re.sub(r'[€$£¥]', '', processed_value) - processed_value = processed_value.replace('.', '') # Tausenderpunkte entfernen - processed_value = processed_value.replace(',', '.') # Komma als Dezimaltrenner + # Bereinige Text vor der Verarbeitung + processed_value = clean_text(raw_value_str) # clean_text sollte "k.A." zurückgeben, wenn nichts übrig bleibt + if processed_value == "k.A.": return "k.A." - # Finde die erste Zahl (kann Dezimalpunkt enthalten) - match = re.search(r'([\d\.]+)', processed_value) + # Debug-Log des zu verarbeitenden Werts + logging.debug(f"extract_numeric_value: Verarbeite Wert: '{processed_value}' (is_umsatz={is_umsatz})") + + # Entferne Präfixe wie ca., über, etc. und Währungssymbole (€, $, etc.) + processed_value = re.sub(r'(?i)^\s*(ca\.?|circa|rund|etwa|über|unter|mehr als|weniger als|bis zu)\s+', '', processed_value) + processed_value = re.sub(r'[€$£¥]', '', processed_value).strip() + + # Behandle Bereiche (z.B. "100 - 200 Mio") -> Nimm die erste Zahl + processed_value = re.split(r'\s*(-|–|bis)\s*', processed_value, 1)[0].strip() + + # Entferne Tausendertrennzeichen (Punkte oder Kommas, abhängig von Lokalisierung - hier generell Punkte) + # Behalte Komma als mögliches Dezimaltrennzeichen + processed_value = processed_value.replace('.', '') # Entferne Punkte GANZ + processed_value = processed_value.replace(',', '.') # Ersetze Komma durch Punkt + + # Suche nach der ersten Zahl (kann Dezimalpunkt enthalten) + # Erlaube optional ein Leerzeichen vor Einheiten wie Mio/Mrd etc. + match = re.search(r'([\d.]+)', processed_value) if not match: - debug_print(f"Keine numerischen Zeichen gefunden in Rohtext: '{raw_value}'") + # Warne, wenn keine Zahl gefunden wurde + logging.warning(f"Keine numerischen Zeichen gefunden nach Bereinigung von: '{raw_value_str}' -> Ergibt: '{processed_value}'") return "k.A." num_str = match.group(1) try: num = float(num_str) except ValueError: - debug_print(f"Fehler bei Float-Umwandlung von '{num_str}' (aus '{raw_value}')") - return "k.A." # Gib k.A. zurück, wenn die Zahl selbst ungültig ist + # Fehler loggen + logging.error(f"Fehler bei Float-Umwandlung des extrahierten Strings '{num_str}' (aus '{processed_value}')") + return "k.A." # Multiplikatoren anwenden (Groß/Kleinschreibung ignorieren) - raw_lower = raw_value.lower() + original_lower = raw_value_str.lower() # Nutze Original für Keyword-Suche multiplier = 1.0 - if "mrd" in raw_lower or "milliarden" in raw_lower or "billion" in raw_lower: # Englisch Billion = Deutsch Milliarde - multiplier = 1000.0 # Für Umsatz: Ergebnis wird in Mio sein - elif "mio" in raw_lower or "millionen" in raw_lower or "mill." in raw_lower: - multiplier = 1.0 # Für Umsatz: Ergebnis ist bereits in Mio - elif "tsd" in raw_lower or "tausend" in raw_lower: - multiplier = 0.001 # Für Umsatz: Umrechnung Tausend in Mio + unit_found = None + + # Priorisiere spezifischere Einheiten zuerst (Mrd. vor Mio.) + if "mrd" in original_lower or "milliarden" in original_lower or "billion" in original_lower: # Englisch Billion = Deutsch Milliarde + multiplier = 1000000000.0 + unit_found = "Mrd" + elif "mio" in original_lower or "millionen" in original_lower or "mill." in original_lower: + multiplier = 1000000.0 + unit_found = "Mio" + elif "tsd" in original_lower or "tausend" in original_lower: + multiplier = 1000.0 + unit_found = "Tsd" num = num * multiplier + if unit_found: + logging.debug(f" -> Multiplikator '{unit_found}' ({multiplier}) angewendet, Ergebnis: {num}") + # Finale Formatierung if is_umsatz: - # Umsatz immer auf Millionen runden (Ganzzahl) - return str(int(round(num))) + # Umsatz in Millionen Euro (als String ohne Nachkommastellen) + umsatz_mio = round(num / 1000000.0) + logging.debug(f" -> Finaler Umsatz (Mio): {umsatz_mio}") + return str(int(umsatz_mio)) else: - # Mitarbeiter als Ganzzahl - return str(int(round(num))) + # Mitarbeiter als ganze Zahl (als String) + mitarbeiter_int = round(num) + logging.debug(f" -> Finale Mitarbeiterzahl: {mitarbeiter_int}") + return str(int(mitarbeiter_int)) def get_gender(firstname): """Ermittelt Geschlecht via gender-guesser und Fallback Genderize API.""" if not firstname or not isinstance(firstname, str): return "unknown" - firstname = firstname.strip().split(" ")[0] # Nur ersten Teil des Vornamens verwenden - if not firstname: return "unknown" + # Nimm nur den ersten Teil des Vornamens und bereinige ihn + firstname_clean = firstname.strip().split(" ")[0] + if not firstname_clean: return "unknown" - d = gender.Detector(case_sensitive=False) - result = d.get_gender(firstname, 'germany') # Land hinzufügen kann helfen - if result in ["andy", "unknown", "mostly_male", "mostly_female"]: + # 1. Versuch: gender-guesser + try: + d = gender.Detector(case_sensitive=False) + # Länderkennung kann helfen, ist aber nicht immer nötig/korrekt + result_gg = d.get_gender(firstname_clean) # Ohne Land versuchen? Oder 'germany'? + logging.debug(f"GenderGuesser für '{firstname_clean}': {result_gg}") + except Exception as e_gg: + logging.warning(f"Fehler bei gender-guesser für '{firstname_clean}': {e_gg}") + result_gg = "unknown" # Fallback bei Fehler + + # 2. Fallback: Genderize API (nur wenn gender-guesser unsicher ist) + if result_gg in ["andy", "unknown", "mostly_male", "mostly_female"]: genderize_key = Config.API_KEYS.get('genderize') if not genderize_key: - debug_print("Genderize API-Schlüssel nicht verfügbar, Fallback nicht möglich.") - return result if result not in ["andy", "unknown"] else "unknown" # Gib mostly_ zurück + logging.warning("Genderize API-Schlüssel nicht verfügbar, Fallback nicht möglich.") + # Gib das Ergebnis von gender-guesser zurück, wenn es "mostly_" war, sonst unknown + return result_gg if result_gg.startswith("mostly_") else "unknown" - params = {"name": firstname, "apikey": genderize_key, "country_id": "DE"} + params = {"name": firstname_clean, "apikey": genderize_key, "country_id": "DE"} try: + logging.debug(f"Genderize API-Anfrage für '{firstname_clean}'...") response = requests.get("https://api.genderize.io", params=params, timeout=5) response.raise_for_status() # Fehler bei HTTP-Status != 200 data = response.json() - # Genderize gibt 'male'/'female' oder null zurück + logging.debug(f" -> Genderize Antwort: {data}") + api_gender = data.get("gender") probability = data.get("probability", 0) - if api_gender and probability > 0.6: # Nur bei ausreichender Sicherheit übernehmen + # Nur bei hoher Sicherheit und wenn Genderize ein Ergebnis liefert + if api_gender and probability and probability > 0.7: # Schwelle ggf. anpassen + logging.debug(f" -> Übernehme Genderize Ergebnis '{api_gender}' (Prob: {probability})") return api_gender else: - # Wenn Genderize unsicher ist, behalte das Ergebnis von gender-guesser, wenn es "mostly_" war - return result if result not in ["andy", "unknown"] else "unknown" + # Wenn Genderize unsicher ist oder null liefert, nimm gender-guesser Ergebnis (falls mostly_) + logging.debug(f" -> Genderize unsicher/kein Ergebnis. Nutze Fallback: '{result_gg}'") + return result_gg if result_gg.startswith("mostly_") else "unknown" + except requests.exceptions.RequestException as e: - debug_print(f"Fehler bei der Genderize API-Anfrage für '{firstname}': {e}") - return result if result not in ["andy", "unknown"] else "unknown" - except Exception as e: - debug_print(f"Allgemeiner Fehler bei Genderize für '{firstname}': {e}") - return result if result not in ["andy", "unknown"] else "unknown" - else: # male, female - return result + logging.error(f"Fehler bei der Genderize API-Anfrage für '{firstname_clean}': {e}") + # Fallback auf gender-guesser Ergebnis (falls mostly_) + return result_gg if result_gg.startswith("mostly_") else "unknown" + except Exception as e: # Z.B. JSONDecodeError + logging.error(f"Allgemeiner Fehler bei Genderize für '{firstname_clean}': {e}") + # Fallback auf gender-guesser Ergebnis (falls mostly_) + return result_gg if result_gg.startswith("mostly_") else "unknown" + else: + # Wenn gender-guesser sicher war (male, female), gib das Ergebnis direkt zurück + return result_gg def get_email_address(firstname, lastname, website): """Generiert E-Mail: vorname.nachname@domain.tld.""" @@ -931,37 +1051,35 @@ def evaluate_branche_chatgpt(crm_branche, beschreibung, wiki_branche, wiki_kateg "consistency" ('ok', 'X', 'fallback_crm_valid', 'fallback_invalid') und "justification" (Begründung von ChatGPT oder Fallback-Info). """ - # Globale Variablen für Schema und erlaubte Branches verwenden global ALLOWED_TARGET_BRANCHES, TARGET_SCHEMA_STRING # Grundlegende Prüfung: Ist das Schema überhaupt geladen? if not ALLOWED_TARGET_BRANCHES: - debug_print("FEHLER in evaluate_branche_chatgpt: Ziel-Branchenschema (ALLOWED_TARGET_BRANCHES) ist leer. Abbruch.") - # Gib den CRM-Wert zurück, aber markiere als Fehler + # Kritischer Fehler, da Kernfunktion nicht möglich + logging.critical("FEHLER in evaluate_branche_chatgpt: Ziel-Branchenschema (ALLOWED_TARGET_BRANCHES) ist leer. Abbruch der Funktion.") return {"branch": crm_branche, "consistency": "error_schema_missing", "justification": "Fehler: Ziel-Schema nicht geladen"} - # Erstelle ein Set/Dict der erlaubten Branches in Kleinbuchstaben für effizientes Nachschlagen - # Speichert die Originalschreibweise als Wert. + # Erstelle Lookup für erlaubte Branches allowed_branches_lookup = {b.lower(): b for b in ALLOWED_TARGET_BRANCHES} # --- Prompt für ChatGPT erstellen --- - # Beginne mit den Regeln und der Liste der gültigen Kurzformen - prompt_parts = [TARGET_SCHEMA_STRING] # TARGET_SCHEMA_STRING sollte bereits die klare Anweisung enthalten + prompt_parts = [TARGET_SCHEMA_STRING] prompt_parts.append("\nOrdne das Unternehmen anhand folgender Angaben exakt einer Branche des Ziel-Branchenschemas (Kurzformen) zu:") - # Füge nur vorhandene Informationen hinzu und kürze sie ggf. - if crm_branche and crm_branche != "k.A.": prompt_parts.append(f"- CRM-Branche (Referenz): {crm_branche}") - if beschreibung and beschreibung != "k.A.": prompt_parts.append(f"- Beschreibung: {beschreibung[:500]}") # Kürzen - if wiki_branche and wiki_branche != "k.A.": prompt_parts.append(f"- Wikipedia-Branche: {wiki_branche}") - if wiki_kategorien and wiki_kategorien != "k.A.": prompt_parts.append(f"- Wikipedia-Kategorien: {wiki_kategorien[:500]}") # Kürzen - if website_summary and website_summary != "k.A.": prompt_parts.append(f"- Website-Zusammenfassung: {website_summary[:500]}") # Kürzen + # Sammle vorhandene Infos + info_count = 0 + if crm_branche and crm_branche != "k.A.": prompt_parts.append(f"- CRM-Branche (Referenz): {crm_branche}"); info_count += 1 + if beschreibung and beschreibung != "k.A.": prompt_parts.append(f"- Beschreibung: {beschreibung[:500]}..."); info_count += 1 # Kürzen + if wiki_branche and wiki_branche != "k.A.": prompt_parts.append(f"- Wikipedia-Branche: {wiki_branche[:300]}"); info_count += 1 # Kürzen + if wiki_kategorien and wiki_kategorien != "k.A.": prompt_parts.append(f"- Wikipedia-Kategorien: {wiki_kategorien[:500]}..."); info_count += 1 # Kürzen + if website_summary and website_summary != "k.A.": prompt_parts.append(f"- Website-Zusammenfassung: {website_summary[:500]}..."); info_count += 1 # Kürzen - # Fallback, wenn gar keine spezifischen Infos da sind - if len(prompt_parts) <= 2: - debug_print("Warnung in evaluate_branche_chatgpt: Zu wenige Informationen für Branchenevaluierung.") + # Fallback, wenn zu wenige Infos da sind + if info_count < 2: # Mindestens 2 Info-Punkte sollten vorhanden sein + logging.warning("Warnung in evaluate_branche_chatgpt: Zu wenige Informationen (<2) für Branchenevaluierung.") + # Gib ursprüngliche CRM-Branche zurück, aber markiere als Fehler return {"branch": crm_branche, "consistency": "error_no_info", "justification": "Fehler: Zu wenige Informationen für eine Einschätzung"} - # Füge die strengen Anweisungen für das Antwortformat hinzu prompt_parts.append("\nWICHTIG: Antworte NUR mit dem exakten Kurznamen einer Branche aus der obigen Liste. Verwende KEINE Präfixe wie 'Hersteller / Produzenten >' oder 'Service provider (Dienstleister) >'.") prompt_parts.append("\nAntworte ausschließlich im folgenden Format (keine Einleitung, kein Schlusssatz):") prompt_parts.append("Branche: ") @@ -969,32 +1087,34 @@ def evaluate_branche_chatgpt(crm_branche, beschreibung, wiki_branche, wiki_kateg prompt_parts.append("Begründung: ") prompt = "\n".join(prompt_parts) + logging.debug(f"Erstellter Prompt für Branchenevaluierung:\n---\n{prompt}\n---") # --- ChatGPT aufrufen --- - chat_response = call_openai_chat(prompt, temperature=0.0) # Niedrige Temperatur für konsistente Zuordnung + chat_response = call_openai_chat(prompt, temperature=0.0) # Niedrige Temperatur if not chat_response: - debug_print("Fehler in evaluate_branche_chatgpt: Keine Antwort von OpenAI erhalten.") + # Fehler loggen + logging.error("Fehler in evaluate_branche_chatgpt: Keine Antwort von OpenAI erhalten.") return {"branch": crm_branche, "consistency": "error_api_no_response", "justification": "Fehler: Keine Antwort von API"} + logging.debug(f"OpenAI Antwort für Branchenevaluierung: {chat_response}") + # --- Antwort parsen --- lines = chat_response.strip().split("\n") - result = {"branch": None, "consistency": None, "justification": ""} # Initialisiere mit None + result = {"branch": None, "consistency": None, "justification": ""} suggested_branch = "" + parsed_branch = False for line in lines: line_lower = line.lower() if line_lower.startswith("branche:"): - suggested_branch = line.split(":", 1)[1].strip() - # Entferne mögliche Anführungszeichen - suggested_branch = suggested_branch.strip('"\'') - elif line_lower.startswith("übereinstimmung:"): - # Wir überschreiben die Konsistenz später basierend auf unserer Logik - pass + suggested_branch = line.split(":", 1)[1].strip().strip('"\'') # Trimme Leerzeichen und Anführungszeichen + parsed_branch = True elif line_lower.startswith("begründung:"): result["justification"] = line.split(":", 1)[1].strip() + # 'Übereinstimmung' wird ignoriert und später selbst berechnet - if not suggested_branch: - debug_print(f"Fehler in evaluate_branche_chatgpt: Konnte 'Branche:' nicht aus Antwort parsen: {chat_response}") + if not parsed_branch or not suggested_branch: # Prüfe, ob Branch geparst wurde UND nicht leer ist + logging.error(f"Fehler in evaluate_branche_chatgpt: Konnte 'Branche:' nicht oder nur leer aus Antwort parsen: {chat_response}") return {"branch": crm_branche, "consistency": "error_parsing", "justification": f"Fehler: Parsing der API Antwort fehlgeschlagen. Antwort: {chat_response}"} # --- Validierung des ChatGPT-Vorschlags --- @@ -1003,84 +1123,74 @@ def evaluate_branche_chatgpt(crm_branche, beschreibung, wiki_branche, wiki_kateg if suggested_branch_lower in allowed_branches_lookup: final_branch = allowed_branches_lookup[suggested_branch_lower] # Nimm korrekte Schreibweise - debug_print(f"ChatGPT-Branchenvorschlag '{suggested_branch}' ist gültig ('{final_branch}').") - # Konsistenz wird später gesetzt + logging.debug(f"ChatGPT-Branchenvorschlag '{suggested_branch}' ist gültig ('{final_branch}').") result["consistency"] = "pending_comparison" # Temporärer Status else: # --- Fallback-Logik --- - debug_print(f"ChatGPT-Branchenvorschlag '{suggested_branch}' ist NICHT im Ziel-Schema ({len(ALLOWED_TARGET_BRANCHES)} Einträge) enthalten. Starte Fallback...") + logging.debug(f"ChatGPT-Branchenvorschlag '{suggested_branch}' ist NICHT im Ziel-Schema ({len(ALLOWED_TARGET_BRANCHES)} Einträge). Starte Fallback...") # Versuche Kurzform aus CRM-Branche zu extrahieren crm_short_branch = "k.A." if crm_branche and ">" in crm_branche: crm_short_branch = crm_branche.split(">", 1)[1].strip() - elif crm_branche and crm_branche != "k.A.": # Wenn CRM schon Kurzform sein könnte + elif crm_branche and crm_branche != "k.A.": crm_short_branch = crm_branche.strip() - - debug_print(f"Fallback Debug: Prüfe CRM-Kurzform.") - debug_print(f" -> Extrahierte CRM-Kurzform: '{crm_short_branch}' (Typ: {type(crm_short_branch)})") + logging.debug(f" Fallback: Prüfe extrahierte CRM-Kurzform: '{crm_short_branch}'") crm_short_branch_lower = crm_short_branch.lower() - debug_print(f" -> CRM-Kurzform (lower): '{crm_short_branch_lower}'") - # Zeige einige Lookup-Keys (nur wenn nicht zu viele) - lookup_keys_sample = list(allowed_branches_lookup.keys()) - if len(lookup_keys_sample) < 20: - debug_print(f" -> Prüfe gegen Lookup-Keys: {lookup_keys_sample}") - else: - debug_print(f" -> Prüfe gegen Lookup-Keys (erste 10): {lookup_keys_sample[:10]}") - + # Logge nur wenige Lookup-Keys zur Kontrolle + # lookup_keys_sample = list(allowed_branches_lookup.keys())[:5] + # logging.debug(f" -> Prüfe gegen Lookup-Keys (erste 5): {lookup_keys_sample}...") - # Der eigentliche Check if crm_short_branch != "k.A." and crm_short_branch_lower in allowed_branches_lookup: - debug_print(f" -> ERFOLG: '{crm_short_branch_lower}' in allowed_branches_lookup gefunden!") # NEU - final_branch = allowed_branches_lookup[crm_short_branch_lower] # Nimm korrekte Schreibweise - result["consistency"] = "fallback_crm_valid" # Setze Fallback-Status - # Kombiniere ChatGPT Begründung (falls vorhanden) mit Fallback-Info + final_branch = allowed_branches_lookup[crm_short_branch_lower] + result["consistency"] = "fallback_crm_valid" fallback_reason = f"Fallback: Ungültiger ChatGPT-Vorschlag ('{suggested_branch}'). Gültige CRM-Kurzform '{final_branch}' verwendet." result["justification"] = f"{fallback_reason} (ChatGPT Begründung war: {result.get('justification', 'Keine')})" - debug_print(f"Fallback auf gültige CRM-Kurzform erfolgreich: '{final_branch}'") + # Info statt Debug, da dies eine wichtige Entscheidung ist + logging.info(f"Fallback auf gültige CRM-Kurzform erfolgreich: '{final_branch}'") else: - debug_print(f" -> FEHLER: '{crm_short_branch_lower}' NICHT in allowed_branches_lookup gefunden!") # NEU - # Wenn auch CRM-Kurzform ungültig oder nicht extrahierbar - final_branch = suggested_branch # Behalte ungültigen Vorschlag - result["consistency"] = "fallback_invalid" # Setze Fehler-Fallback-Status + # Wenn auch CRM-Kurzform ungültig + final_branch = suggested_branch # Behalte ungültigen Vorschlag temporär + result["consistency"] = "fallback_invalid" error_reason = f"Fehler: Ungültiger ChatGPT-Vorschlag ('{suggested_branch}') und keine gültige CRM-Kurzform ('{crm_short_branch}') als Fallback verfügbar." result["justification"] = f"{error_reason} (ChatGPT Begründung war: {result.get('justification', 'Keine')})" - debug_print(f"Fallback fehlgeschlagen. Ungültiger Vorschlag: '{final_branch}', Ungültige CRM-Kurzform: '{crm_short_branch}'") - # Alternativ: Gib einen speziellen Fehlerwert zurück - # final_branch = "FEHLER - UNGÜLTIGE ZUWEISUNG" + # Warnung, da keine gültige Branche gefunden wurde + logging.warning(f"Fallback fehlgeschlagen. Ungültiger Vorschlag: '{final_branch}', Ungültige CRM-Kurzform: '{crm_short_branch}'") + final_branch = "FEHLER - UNGÜLTIGE ZUWEISUNG" # Setze finalen Branch auf Fehler # Setze den finalen Branch im Ergebnis-Dictionary - result["branch"] = final_branch if final_branch else "FEHLER" + result["branch"] = final_branch # --- Konsistenzprüfung (Finale Bewertung) --- - # Extrahiere CRM-Kurzform für den Vergleich (erneut oder Variable von oben) + # Extrahiere CRM-Kurzform für den Vergleich erneut crm_short_to_compare = "k.A." if crm_branche and ">" in crm_branche: crm_short_to_compare = crm_branche.split(">", 1)[1].strip() elif crm_branche and crm_branche != "k.A.": crm_short_to_compare = crm_branche.strip() - # Vergleiche finalen Branch (falls nicht FEHLER) mit CRM-Kurzform (case-insensitive) - if result["branch"] != "FEHLER" and result["branch"].lower() == crm_short_to_compare.lower(): - # Wenn sie übereinstimmen UND *kein* Fallback stattgefunden hat, ist es 'ok'. - if result["consistency"] == "pending_comparison": - result["consistency"] = "ok" - # Wenn Fallback auf gültige CRM stattfand (Status 'fallback_crm_valid'), bleibt dieser Status. - elif result["consistency"] == "pending_comparison": - # Wenn sie nicht übereinstimmen und kein Fallback stattfand, ist es 'X'. - result["consistency"] = "X" - # Wenn der Status bereits 'fallback_crm_valid' oder 'fallback_invalid' ist, bleibt er unverändert. - elif result["consistency"] is None: # Sollte nicht passieren, aber zur Sicherheit - result["consistency"] = "error_unknown_state" + # Vergleiche finalen Branch (falls nicht FEHLER) mit CRM-Kurzform + if result["branch"] != "FEHLER - UNGÜLTIGE ZUWEISUNG": + if result["branch"].lower() == crm_short_to_compare.lower(): + if result["consistency"] == "pending_comparison": + result["consistency"] = "ok" + # Wenn Fallback auf gültige CRM stattfand ('fallback_crm_valid'), bleibt dieser Status. + elif result["consistency"] == "pending_comparison": + # Wenn sie nicht übereinstimmen und kein Fallback stattfand, ist es 'X'. + result["consistency"] = "X" + # Wenn der Status bereits 'fallback_crm_valid' oder 'fallback_invalid' ist oder Branch ein Fehler ist, bleibt er. - - # Entferne den temporären Status, falls er noch da ist + # Entferne den temporären Status sicherheitshalber if result["consistency"] == "pending_comparison": + logging.warning("Konsistenzprüfung blieb im Status 'pending_comparison', setze auf 'error_comparison_failed'.") result["consistency"] = "error_comparison_failed" + elif result["consistency"] is None: # Sollte nicht passieren + logging.error("Konsistenz blieb unerwartet None, setze auf 'error_unknown_state'.") + result["consistency"] = "error_unknown_state" - # Debug-Ausgabe des finalen Ergebnisses vor Rückgabe - debug_print(f"Finale Branch-Evaluation: {result}") + # Debug-Ausgabe des finalen Ergebnisses + logging.debug(f"Finale Branch-Evaluation: {result}") return result