diff --git a/brancheneinstufung.py b/brancheneinstufung.py index 1522d5abf..24a9f831a 100644 --- a/brancheneinstufung.py +++ b/brancheneinstufung.py @@ -1,18 +1,22 @@ #!/usr/bin/env python3 """ -v1.6.5: Refactor logging & integrate improved WikipediaScraper +v1.6.6: Füge SerpAPI-Suche für fehlende Wiki-URLs großer Firmen hinzu Git-Änderungsbeschreibung: -- 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). +- Füge neuen Betriebsmodus `--mode find_wiki_serp` hinzu. +- Implementiere neue Funktion `serp_wikipedia_lookup`, die SerpAPI nutzt, um gezielt nach Wikipedia-Artikeln für einen Firmennamen zu suchen. +- Implementiere neue Funktion `process_find_wiki_with_serp`: + - Lädt aktuelle Sheet-Daten. + - Filtert Zeilen, bei denen Spalte M (Wiki URL) leer/'k.A.' ist UND Spalte K (CRM Mitarbeiter) einen Schwellenwert (Standard: 500) überschreitet. + - Ruft `serp_wikipedia_lookup` für gefilterte Zeilen auf. + - Bei erfolgreicher URL-Findung: + - Schreibt die gefundene URL in Spalte M. + - Setzt Flag 'x' in Spalte A (ReEval Flag). + - Löscht Timestamps in Spalten AN (Wikipedia Timestamp) und AO (Timestamp letzte Prüfung). + - Führt gebündelte Sheet-Updates am Ende durch. +- Integriere den neuen Modus `find_wiki_serp` in die Argumentenverarbeitung und Ausführungslogik der `main`-Funktion. +- Füge notwendige Imports hinzu und stelle sicher, dass die neuen Funktionen Logging verwenden. +- Aktualisiere Versionsnummer in `Config.VERSION` auf v1.6.6. """ import os @@ -69,7 +73,7 @@ PATTERNS_FILE_JSON = "technician_patterns.json" # Optional # ==================== KONFIGURATION ==================== class Config: # ... (Alle deine bisherigen Config-Einstellungen) ... - VERSION = "v1.6.5" # Versionsnummer erhöhen + VERSION = "v1.6.6" # Versionsnummer erhöhen LANG = "de" SHEET_URL = "https://docs.google.com/spreadsheets/d/1u_gHr9JUfmV1-iviRzbSe3575QEp7KLhK5jFV_gJcgo" MAX_RETRIES = 3 @@ -199,6 +203,200 @@ COLUMN_MAP = { # Beispielhafte Definition (bitte an deine Spalten anpassen!) # COLUMN_MAP = { ... dein komplettes Mapping ... } +@retry_on_failure # Annahme: Decorator existiert +def serp_wikipedia_lookup(company_name, website=None): + """ + Sucht über SerpAPI (Google) nach dem wahrscheinlichsten Wikipedia-Artikel für ein Unternehmen. + + Args: + company_name (str): Der Name des Unternehmens. + website (str, optional): Die Website des Unternehmens zur Kontextverbesserung. Defaults to None. + + Returns: + str: Die URL des ersten gefundenen Wikipedia-Artikels oder None. + """ + serp_key = Config.API_KEYS.get('serpapi') + if not serp_key: + logging.error("SerpAPI Key nicht verfügbar für Wikipedia Lookup.") + return None + if not company_name: + logging.warning("serp_wikipedia_lookup: Kein Firmenname angegeben.") + return None + + # Query Konstruktion: Name + "Wikipedia" sollte meistens gut funktionieren + query = f'"{company_name}" Wikipedia' + # Optional: Website hinzufügen, wenn vorhanden und sinnvoll? Kann aber auch einschränken. + # if website and website != "k.A.": + # query = f'"{company_name}" "{website}" Wikipedia' + + logging.info(f"Starte SerpAPI Wikipedia-Suche für '{company_name}' mit Query: '{query}'") + + params = { + "engine": "google", + "q": query, + "api_key": serp_key, + "hl": "de", # Sprache Deutsch bevorzugen + "gl": "de" # Ergebnisse aus Deutschland bevorzugen + } + api_url = "https://serpapi.com/search" + + try: + response = requests.get(api_url, params=params, timeout=15) # Etwas längerer Timeout + response.raise_for_status() + data = response.json() + + # Durchsuche organische Ergebnisse nach dem ersten Wikipedia-Link + if "organic_results" in data: + for result in data["organic_results"]: + link = result.get("link") + displayed_link = result.get("displayed_link", "").lower() + # Prüfe, ob es ein Wikipedia-Link ist (flexibler Check) + if link and "wikipedia.org" in link.lower(): + # Zusätzliche Prüfung: Ist es wahrscheinlich die Hauptseite des Artikels? + # '/wiki/' ist ein starker Indikator. + if "/wiki/" in link: + logging.info(f" -> SerpAPI: Wikipedia-Link gefunden: {link}") + # Keine weitere Normalisierung hier, gib die gefundene URL zurück + return link + else: + logging.debug(f" -> SerpAPI: Überspringe Link ohne '/wiki/': {link}") + # Manchmal ist der Link selbst keine Wiki-URL, aber der angezeigte Link schon + elif displayed_link and "wikipedia.org" in displayed_link and link: + if "/wiki/" in link: # Stelle sicher, dass der *echte* Link auch auf einen Artikel zeigt + logging.info(f" -> SerpAPI: Wikipedia-Link über Displayed Link gefunden: {link}") + return link + + logging.warning(f" -> SerpAPI: Kein passender Wikipedia-Link in organischen Ergebnissen für '{company_name}' gefunden.") + return None + + except requests.exceptions.RequestException as e: + logging.error(f"Fehler bei der SerpAPI Wikipedia Suche für '{company_name}': {e}") + raise e # Fehler weitergeben für Retry + except Exception as e: + logging.error(f"Allgemeiner Fehler bei der SerpAPI Wikipedia Suche für '{company_name}': {e}") + return None # Bei unerwarteten Fehlern None zurückgeben + +# Kann als eigenständige Funktion oder Methode in DataProcessor implementiert werden +def process_find_wiki_with_serp(sheet_handler, row_limit=None, min_employees=500): + """ + Sucht fehlende Wikipedia-URLs (Spalte M = k.A.) für Unternehmen mit > min_employees + über SerpAPI und trägt gefundene URLs in Spalte M ein. Setzt ReEval-Flag (A) + und löscht Timestamps (AN, AO) für gefundene Einträge. + + Args: + sheet_handler (GoogleSheetHandler): Initialisierte Instanz. + row_limit (int, optional): Maximale Anzahl zu prüfender Zeilen. Defaults to None. + min_employees (int, optional): Mindestanzahl Mitarbeiter (Spalte K) als Filter. Defaults to 500. + """ + logging.info(f"Starte Modus 'find_wiki_serp': Suche fehlende Wiki-URLs für Firmen > {min_employees} MA...") + + if not sheet_handler.load_data(): return + all_data = sheet_handler.get_all_data_with_headers() + if not all_data or len(all_data) <= 5: + logging.warning("Keine oder zu wenige Daten im Sheet für 'find_wiki_serp' gefunden.") + return + header_rows = 5 + data_rows = all_data[header_rows:] + + # Benötigte Spaltenindizes holen + try: + col_indices = { + "A": COLUMN_MAP["ReEval Flag"], + "K": COLUMN_MAP["CRM Anzahl Mitarbeiter"], + "M": COLUMN_MAP["Wiki URL"], + "B": COLUMN_MAP["CRM Name"], + "AN": COLUMN_MAP["Wikipedia Timestamp"], + "AO": COLUMN_MAP["Timestamp letzte Prüfung"] + } + col_letters = {key: sheet_handler._get_col_letter(idx + 1) for key, idx in col_indices.items()} + except KeyError as e: + logging.critical(f"FEHLER: Benötigter Spaltenschlüssel '{e}' nicht in COLUMN_MAP gefunden! Modus abgebrochen.") + return + except Exception as e: + logging.critical(f"FEHLER beim Holen der Spaltenbuchstaben: {e}") + return + + + all_sheet_updates = [] + processed_rows = 0 + found_urls = 0 + skipped_employee_count = 0 + skipped_m_filled_count = 0 + + for idx, row in enumerate(data_rows): + row_num_in_sheet = idx + header_rows + 1 + + if row_limit is not None and processed_rows >= row_limit: + logging.info(f"Zeilenlimit ({row_limit}) erreicht.") + break + + # Prüfe, ob Zeile überhaupt verarbeitet werden soll + try: + # 1. Mitarbeiterzahl prüfen + ma_val_str = row[col_indices["K"]] if len(row) > col_indices["K"] else "0" + try: + # Versuche, die Zahl zu extrahieren (vereinfacht, ohne extract_numeric_value) + ma_val_str_cleaned = re.sub(r"[^\d]", "", ma_val_str) # Nur Ziffern + ma_val = int(ma_val_str_cleaned) if ma_val_str_cleaned else 0 + except ValueError: + ma_val = 0 # Im Zweifel als 0 werten + + if ma_val <= min_employees: + skipped_employee_count += 1 + continue # Nächste Zeile + + # 2. Prüfen, ob Wiki URL (M) leer oder "k.A." ist + m_value = row[col_indices["M"]] if len(row) > col_indices["M"] else "" + if m_value and m_value.strip().lower() != "k.a.": + skipped_m_filled_count += 1 + continue # Nächste Zeile + + # Wenn wir hier sind, ist die Zeile ein Kandidat + processed_rows += 1 + company_name = row[col_indices["B"]] if len(row) > col_indices["B"] else "" + if not company_name: + logging.warning(f"Zeile {row_num_in_sheet}: Übersprungen, kein Firmenname für Suche vorhanden.") + continue + + # --- SerpAPI Suche durchführen --- + logging.info(f"Zeile {row_num_in_sheet}: Suche Wiki-URL für '{company_name}' (MA: {ma_val})...") + wiki_url_found = serp_wikipedia_lookup(company_name) # Annahme: nutzt logging + time.sleep(1.5) # Pause zwischen SerpAPI-Aufrufen + + if wiki_url_found: + logging.info(f" -> URL gefunden: {wiki_url_found}. Bereite Update vor.") + found_urls += 1 + # Updates für diese Zeile sammeln + row_updates = [ + {'range': f'{col_letters["M"]}{row_num_in_sheet}', 'values': [[wiki_url_found]]}, # URL in M schreiben + {'range': f'{col_letters["A"]}{row_num_in_sheet}', 'values': [['x']]}, # ReEval Flag setzen + {'range': f'{col_letters["AN"]}{row_num_in_sheet}', 'values': [['']]}, # AN löschen + {'range': f'{col_letters["AO"]}{row_num_in_sheet}', 'values': [['']]} # AO löschen + ] + all_sheet_updates.extend(row_updates) + else: + logging.info(f" -> Keine Wiki-URL für '{company_name}' via SerpAPI gefunden.") + # Optional: Status in eine separate Spalte schreiben? Vorerst nicht. + + except Exception as e: + logging.exception(f"Unerwarteter Fehler bei Verarbeitung von Zeile {row_num_in_sheet}: {e}") + # Mache mit der nächsten Zeile weiter + + # --- Batch Update am Ende --- + if all_sheet_updates: + logging.info(f"Sende Batch-Update für {found_urls} gefundene Wiki-URLs ({len(all_sheet_updates)} Zellen)...") + success = sheet_handler.batch_update_cells(all_sheet_updates) + if success: + logging.info(f"Sheet-Update für 'find_wiki_serp' erfolgreich.") + else: + logging.info("Keine neuen Wiki-URLs gefunden zum Eintragen.") + + logging.info(f"Modus 'find_wiki_serp' abgeschlossen.") + logging.info(f" Geprüfte Kandidaten (MA>{min_employees}, M leer): {processed_rows}") + logging.info(f" Gefundene & eingetragene URLs: {found_urls}") + logging.info(f" Übersprungen (MA <= {min_employees}): {skipped_employee_count}") + logging.info(f" Übersprungen (M bereits gefüllt): {skipped_m_filled_count}") + def prepare_data_for_modeling(sheet_handler): """ Lädt Daten aus dem Google Sheet, bereitet sie für das Decision Tree Modell vor: @@ -4126,37 +4324,40 @@ def main(): parser = argparse.ArgumentParser(description="Firmen-Datenanreicherungs-Skript v1.6.5") # Version aktualisiert valid_modes = ["combined", "wiki", "website", "branch", "summarize", "reeval", "website_lookup", "website_details", "contacts", "full_run", - "alignment", "train_technician_model", "update_wiki"] + "alignment", "train_technician_model", "update_wiki", + "find_wiki_serp"] # <-- NEUER MODUS HINZUGEFÜGT parser.add_argument("--mode", type=str, help=f"Betriebsmodus ({', '.join(valid_modes)})") parser.add_argument("--limit", type=int, help="Maximale Anzahl zu verarbeitender Zeilen", default=None) - parser.add_argument("--start_row", type=int, help="Startzeile im Sheet (1-basiert) für sequenzielle Modi", default=None) # Optionaler Startpunkt + parser.add_argument("--start_row", type=int, help="Startzeile im Sheet (1-basiert) für sequenzielle Modi (full_run)", default=None) # Optionaler Startpunkt für full_run parser.add_argument("--model_out", type=str, default=MODEL_FILE, help=f"Pfad für Modell (.pkl)") parser.add_argument("--imputer_out", type=str, default=IMPUTER_FILE, help=f"Pfad für Imputer (.pkl)") parser.add_argument("--patterns_out", type=str, default=PATTERNS_FILE_TXT, help=f"Pfad für Regeln (.txt)") args = parser.parse_args() # Lade API Keys direkt am Anfang - Config.load_api_keys() # Nutzt jetzt logging.debug intern + Config.load_api_keys() # Nutzt jetzt logging.debug/info/warning intern # Betriebsmodus ermitteln mode = None if args.mode and args.mode.lower() in valid_modes: mode = args.mode.lower() - logging.info(f"Betriebsmodus (aus Kommandozeile): {mode}") + # Logge erst NACHDEM FileHandler konfiguriert ist + # logging.info(f"Betriebsmodus (aus Kommandozeile): {mode}") # Wird später geloggt + print(f"Betriebsmodus (aus Kommandozeile): {mode}") # Frühes Feedback für User else: # Interaktive Abfrage print("\nBitte wählen Sie den Betriebsmodus:") - # ... (print-Anweisungen für Modi bleiben gleich) ... - print(" combined: Wiki(AX), Website-Scrape(AR), Summarize(AS), Branch(AO) (Batch, Start bei leerem AO)") + print(" combined: Wiki(AX), Website(AR), Summarize(AS), Branch(AO) (Batch, Start bei leerem AO)") print(" wiki: Nur Wikipedia-Verifizierung (AX) (Batch, Start bei leerem AX)") print(" website: Nur Website-Scraping Rohtext (AR) (Batch, Start bei leerem AR)") print(" summarize: Nur Website-Zusammenfassung (AS) (Batch, Start bei leerem AS)") print(" branch: Nur Branchen-Einschätzung (AO) (Batch, Start bei leerem AO)") print(" update_wiki: Wiki-URL aus Spalte U nach M übernehmen & ReEval-Flag setzen") print(" reeval: Verarbeitet Zeilen mit 'x' in A (volle Verarbeitung)") - print(" website_lookup: Sucht fehlende Websites (D)") - print(" website_details:Extrahiert Details für Zeilen mit 'x' (AR) - EXPERIMENTELL") # Ggf. anpassen + print(" find_wiki_serp: Sucht fehlende Wiki-URLs (M=k.A.) für große Firmen (>500 MA) via SerpAPI") # Neuer Modus erklärt + print(" website_lookup: Sucht fehlende Websites (D) via SerpAPI") + # print(" website_details:Extrahiert Details für Zeilen mit 'x' (AR) - EXPERIMENTELL") # Ggf. ausblenden print(" contacts: Sucht LinkedIn Kontakte (AM)") - print(" full_run: Verarbeitet sequentiell ab erster Zeile ohne AO (alle TS prüfen)") + print(" full_run: Verarbeitet sequenziell ab erster Zeile ohne AO (alle TS prüfen)") print(" alignment: Schreibt Header A1:AX5 (!)") print(" train_technician_model: Trainiert Decision Tree zur Technikerschätzung") @@ -4170,18 +4371,21 @@ def main(): except Exception as e: print(f"Fehler Modus-Eingabe ({e}) -> Standard: combined") mode = "combined" - logging.info(f"Betriebsmodus (interaktiv gewählt): {mode}") + # logging.info(f"Betriebsmodus (interaktiv gewählt): {mode}") # Wird später geloggt # Zeilenlimit ermitteln row_limit = None if args.limit is not None: if args.limit >= 0: row_limit = args.limit - logging.info(f"Zeilenlimit (aus Kommandozeile): {row_limit}") + # logging.info(f"Zeilenlimit (aus Kommandozeile): {row_limit}") # Wird später geloggt + print(f"Zeilenlimit (aus Kommandozeile): {row_limit}") # Frühes Feedback else: - logging.warning("Warnung: Negatives Limit ignoriert.") + print("Warnung: Negatives Limit ignoriert.") + # logging.warning("Warnung: Negatives Limit ignoriert.") # Wird später geloggt row_limit = None - elif mode in ["combined", "wiki", "website", "branch", "summarize", "full_run", "reeval"]: # Relevante Modi + # Frage nur bei Modi, wo es sinnvoll ist (inkl. neuer Modus) + elif mode in ["combined", "wiki", "website", "branch", "summarize", "full_run", "reeval", "update_wiki", "find_wiki_serp"]: try: limit_input = input(f"Maximale Anzahl Zeilen für Modus '{mode}'? (Enter=alle): ") if limit_input.strip(): @@ -4189,54 +4393,60 @@ def main(): limit_val = int(limit_input) if limit_val >= 0: row_limit = limit_val - logging.info(f"Zeilenlimit (interaktiv): {row_limit}") + print(f"Zeilenlimit (interaktiv): {row_limit}") else: - logging.warning("Negatives Limit -> Kein Limit") + print("Negatives Limit -> Kein Limit") row_limit = None except ValueError: - logging.warning("Ungültige Zahl -> Kein Limit") + print("Ungültige Zahl -> Kein Limit") row_limit = None else: - logging.info("Kein Zeilenlimit angegeben.") + print("Kein Zeilenlimit angegeben.") row_limit = None except Exception as e: - logging.error(f"Fehler Limit-Eingabe ({e}) -> Kein Limit") + print(f"Fehler Limit-Eingabe ({e}) -> Kein Limit") row_limit = None + # Logging der Limit-Info erfolgt nach FileHandler-Setup # --- Logdatei-Konfiguration abschließen --- - LOG_FILE = create_log_filename(mode) # Annahme: Funktion existiert und gibt Pfad zurück + # Annahme: Funktion existiert und gibt Pfad zurück + LOG_FILE = create_log_filename(mode) try: file_handler = logging.FileHandler(LOG_FILE, mode='a', encoding='utf-8') file_handler.setLevel(log_level) # Nimm das globale Level file_handler.setFormatter(logging.Formatter(log_format)) - logging.getLogger('').addHandler(file_handler) # Füge zum Root-Logger hinzu + # Füge FileHandler zum Root-Logger hinzu + logging.getLogger('').addHandler(file_handler) logging.info(f"Logging wird jetzt auch in Datei geschrieben: {LOG_FILE}") except Exception as e: + # Logge Fehler nur auf Konsole, da FileHandler fehlgeschlagen ist + print(f"[ERROR] Konnte FileHandler für Logdatei '{LOG_FILE}' nicht erstellen: {e}") + logging.getLogger('').handlers = [h for h in logging.getLogger('').handlers if not isinstance(h, logging.FileHandler)] # Entferne evtl. defekten Handler logging.error(f"Konnte FileHandler für Logdatei '{LOG_FILE}' nicht erstellen: {e}") - # Programm kann weiterlaufen, loggt aber nur auf Konsole - # --- Ende Logdatei-Konfiguration --- - # --- Logge finale Startinfos (jetzt auch in Datei) --- + # --- JETZT die Startmeldungen loggen (gehen jetzt in Konsole UND Datei) --- logging.info(f"===== Skript gestartet =====") logging.info(f"Version: {Config.VERSION}") # Sollte jetzt v1.6.5 sein logging.info(f"Betriebsmodus: {mode}") limit_log_text = str(row_limit) if row_limit is not None else 'N/A für diesen Modus' - if mode in ["combined", "wiki", "website", "branch", "summarize", "full_run", "reeval"]: + if mode in ["combined", "wiki", "website", "branch", "summarize", "full_run", "reeval", "update_wiki", "find_wiki_serp"]: limit_log_text = str(row_limit) if row_limit is not None else 'Unbegrenzt' if row_limit == 0: limit_log_text = '0 (Keine Verarbeitung geplant)' logging.info(f"Zeilenlimit: {limit_log_text}") logging.info(f"Logdatei: {LOG_FILE}") # --- Ende finale Startinfos --- - # --- Vorbereitung (Sheet Handler etc.) --- - load_target_schema() # Nutzt jetzt logging intern + # --- Vorbereitung (Schema, Sheet Handler etc.) --- + # Annahme: Diese Funktionen verwenden jetzt logging intern + load_target_schema() try: - sheet_handler = GoogleSheetHandler() # Nutzt jetzt logging intern + sheet_handler = GoogleSheetHandler() except Exception as e: logging.critical(f"FATAL: Initialisierung des GoogleSheetHandlers fehlgeschlagen: {e}") logging.critical(f"Bitte Logdatei prüfen: {LOG_FILE}") return # Beende Skript, wenn Sheet nicht geladen werden kann - data_processor = DataProcessor(sheet_handler) # Nutzt jetzt logging intern + # Annahme: DataProcessor verwendet jetzt logging intern + data_processor = DataProcessor(sheet_handler) # --- Modusausführung --- start_time = time.time() @@ -4247,51 +4457,65 @@ def main(): if row_limit == 0: logging.info("Limit 0 angegeben -> Überspringe Dispatcher für Batch-Modus.") else: - run_dispatcher(mode, sheet_handler, row_limit) # Nutzt jetzt logging intern + # Annahme: run_dispatcher verwendet logging intern + run_dispatcher(mode, sheet_handler, row_limit) # Einzelne Zeilen Modi (kein Batch-Dispatcher) elif mode == "reeval": - data_processor.process_reevaluation_rows(row_limit=row_limit) # Nutzt jetzt logging intern + # Annahme: process_reevaluation_rows verwendet logging intern + data_processor.process_reevaluation_rows(row_limit=row_limit) elif mode == "website_lookup": - data_processor.process_serp_website_lookup_for_empty() # Nutzt jetzt logging intern + # Annahme: process_serp_website_lookup_for_empty verwendet logging intern + data_processor.process_serp_website_lookup_for_empty() elif mode == "website_details": logging.warning("Modus 'website_details' ist experimentell.") - data_processor.process_website_details_for_marked_rows() # Nutzt jetzt logging intern + # Annahme: process_website_details_for_marked_rows verwendet logging intern + data_processor.process_website_details_for_marked_rows() elif mode == "contacts": - process_contact_research(sheet_handler) # Nutzt jetzt logging intern + # Annahme: process_contact_research verwendet logging intern + process_contact_research(sheet_handler) elif mode == "full_run": if row_limit == 0: logging.info("Limit 0 angegeben -> Überspringe full_run.") else: # Prüfe, ob eine explizite Startzeile übergeben wurde + start_data_index = -1 # Initialisieren if args.start_row and args.start_row > 5: - start_data_index = args.start_row - 5 - 1 # Konvertiere zu 0-basiertem Datenindex + header_rows = 5 # Standard-Annahme + start_data_index = args.start_row - header_rows - 1 # Konvertiere zu 0-basiertem Datenindex logging.info(f"Nutze expliziten Start-Datenindex {start_data_index} (Sheet Zeile {args.start_row}) für 'full_run'.") else: logging.info("Ermittle Startindex für 'full_run' (erste Zeile ohne AO)...") + # Annahme: get_start_row_index verwendet logging intern start_data_index = sheet_handler.get_start_row_index(check_column_key="Timestamp letzte Prüfung") - if start_data_index != -1 and start_data_index < len(sheet_handler.get_data()): - num_available = len(sheet_handler.get_data()) - start_data_index - num_to_process = min(row_limit, num_available) if row_limit is not None and row_limit >= 0 else num_available - if num_to_process > 0: - logging.info(f"'full_run': Verarbeite {num_to_process} Zeilen ab Daten-Index {start_data_index}.") - # Übergebe Flags an process_rows_sequentially - data_processor.process_rows_sequentially( - start_data_index, - num_to_process, - process_wiki=True, - process_chatgpt=True, - process_website=True - ) # Nutzt jetzt logging intern - else: - logging.info("Keine Zeilen für 'full_run' zu verarbeiten (Limit/Startindex).") + # Prüfe, ob get_start_row_index einen gültigen Index zurückgab + if start_data_index != -1: + current_data = sheet_handler.get_data() # Hole aktuelle Daten + if start_data_index < len(current_data): + num_available = len(current_data) - start_data_index + num_to_process = min(row_limit, num_available) if row_limit is not None and row_limit >= 0 else num_available + if num_to_process > 0: + logging.info(f"'full_run': Verarbeite {num_to_process} Zeilen ab Daten-Index {start_data_index}.") + # Annahme: process_rows_sequentially verwendet logging intern + data_processor.process_rows_sequentially( + start_data_index, + num_to_process, + process_wiki=True, + process_chatgpt=True, + process_website=True + ) + else: + logging.info("Keine Zeilen für 'full_run' zu verarbeiten (Limit/Startindex).") + else: + logging.warning(f"Startindex {start_data_index} liegt hinter der letzten Datenzeile ({len(current_data)}). Keine Verarbeitung.") else: - logging.warning(f"Startindex {start_data_index} für 'full_run' ungültig oder keine Zeilen mehr.") + # Fehlermeldung wird von get_start_row_index erwartet + logging.warning(f"Startindex für 'full_run' ungültig (Fehler bei Ermittlung oder Spalte nicht gefunden).") elif mode == "alignment": print("\nACHTUNG: Dieser Modus überschreibt die Header-Zeilen A1:AX5!") @@ -4302,30 +4526,41 @@ def main(): confirm = 'n' if confirm == 'j': logging.info("Starte Alignment Demo...") - alignment_demo(sheet_handler.sheet) # Annahme: nutzt logging intern + # Annahme: alignment_demo verwendet logging intern + alignment_demo(sheet_handler.sheet) else: logging.info("Alignment Demo abgebrochen.") - # --- Wiki Update Modus --- elif mode == "update_wiki": logging.info("Starte Modus 'update_wiki'...") - process_wiki_updates_from_chatgpt(sheet_handler, data_processor, row_limit=row_limit) # Limit optional übergeben + # Annahme: process_wiki_updates_from_chatgpt verwendet logging intern + process_wiki_updates_from_chatgpt(sheet_handler, data_processor, row_limit=row_limit) + + # --- NEUER MODUS --- + elif mode == "find_wiki_serp": + logging.info(f"Starte Modus '{mode}'...") + min_employees_for_serp = 500 # Standardwert, ggf. über Argument steuerbar machen + # Annahme: process_find_wiki_with_serp verwendet logging intern + process_find_wiki_with_serp(sheet_handler, row_limit=row_limit, min_employees=min_employees_for_serp) + # --- ENDE NEUER MODUS --- # Block für Modelltraining elif mode == "train_technician_model": logging.info(f"Starte Modus: {mode}") - # (Dieser Block verwendet jetzt logging intern, keine Anpassungen hier nötig, - # außer sicherzustellen, dass prepare_data_for_modeling logging nutzt) + # Annahme: prepare_data_for_modeling verwendet logging intern prepared_df = data_processor.prepare_data_for_modeling() if prepared_df is not None and not prepared_df.empty: logging.info("Aufteilen der Daten für das Modelltraining...") try: - # Definition von X und y (wie im Original, Annahme: prepare_data... hat Spalten 'name' etc.) - X = prepared_df.drop(columns=['Techniker_Bucket', 'name', 'Anzahl_Servicetechniker_Numeric']) + # Definition von X und y + X = prepared_df.drop(columns=['Techniker_Bucket', 'CRM Name', 'Anzahl_Servicetechniker_Numeric']) # CRM Name statt 'name' y = prepared_df['Techniker_Bucket'] X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.25, random_state=42, stratify=y) split_successful = True logging.info(f"Train/Test Split: {len(X_train)} Train, {len(X_test)} Test samples.") + except KeyError as e: + logging.error(f"FEHLER beim Train/Test Split: Spalte nicht gefunden - {e}. Stellen Sie sicher, dass prepare_data_for_modeling die Spalten korrekt zurückgibt.") + split_successful = False except Exception as e: logging.error(f"FEHLER beim Train/Test Split: {e}") split_successful = False @@ -4335,7 +4570,6 @@ def main(): numeric_features = ['Finaler_Umsatz', 'Finaler_Mitarbeiter'] try: imputer = SimpleImputer(strategy='median') - # Stelle sicher, dass Spalten existieren bevor Transformation if all(nf in X_train.columns for nf in numeric_features): X_train[numeric_features] = imputer.fit_transform(X_train[numeric_features]) X_test[numeric_features] = imputer.transform(X_test[numeric_features]) @@ -4352,16 +4586,12 @@ def main(): if imputation_successful: logging.info("Starte Decision Tree Training mit GridSearchCV...") - # Parameter Grid (wie im Original) param_grid = { - 'criterion': ['gini', 'entropy'], - 'max_depth': [6, 8, 10, 12, 15], - 'min_samples_split': [20, 40, 60], - 'min_samples_leaf': [10, 20, 30], - 'ccp_alpha': [0.0, 0.001, 0.005] # Cost-Complexity Pruning + 'criterion': ['gini', 'entropy'], 'max_depth': [6, 8, 10, 12, 15], + 'min_samples_split': [20, 40, 60], 'min_samples_leaf': [10, 20, 30], + 'ccp_alpha': [0.0, 0.001, 0.005] } dtree = DecisionTreeClassifier(random_state=42, class_weight='balanced') - # GridSearchCV (wie im Original) grid_search = GridSearchCV(estimator=dtree, param_grid=param_grid, cv=5, scoring='f1_weighted', n_jobs=-1, verbose=1) try: @@ -4371,14 +4601,12 @@ def main(): logging.info(f"Beste Parameter: {grid_search.best_params_}") logging.info(f"Bester F1-Score (gewichtet, CV): {grid_search.best_score_:.4f}") - # Speichere das beste Modell model_filename = args.model_out with open(model_filename, 'wb') as f_mod: pickle.dump(best_estimator, f_mod) logging.info(f"Bestes Modell gespeichert: '{model_filename}'.") training_successful = True except Exception as e_train: - logging.error(f"FEHLER während des Trainings: {e_train}") - logging.exception("Traceback Training:") # Loggt Traceback + logging.exception(f"FEHLER während des Trainings: {e_train}") training_successful = False if training_successful: @@ -4386,23 +4614,24 @@ def main(): try: y_pred = best_estimator.predict(X_test) test_accuracy = accuracy_score(y_test, y_pred) + # Sicherstellen, dass Klassen als Liste von Strings übergeben werden + class_labels = [str(cls) for cls in best_estimator.classes_] report = classification_report(y_test, y_pred, zero_division=0, - labels=best_estimator.classes_, target_names=best_estimator.classes_) + labels=best_estimator.classes_, target_names=class_labels) conf_matrix = confusion_matrix(y_test, y_pred, labels=best_estimator.classes_) - conf_matrix_df = pd.DataFrame(conf_matrix, index=best_estimator.classes_, columns=best_estimator.classes_) + conf_matrix_df = pd.DataFrame(conf_matrix, index=class_labels, columns=class_labels) logging.info(f"\n--- Evaluation Test-Set ---") logging.info(f"Genauigkeit: {test_accuracy:.4f}") logging.info(f"Classification Report:\n{report}") logging.info(f"Confusion Matrix:\n{conf_matrix_df}") - print(f"\nModell Genauigkeit (Test): {test_accuracy:.4f}") # Auch auf Konsole + print(f"\nModell Genauigkeit (Test): {test_accuracy:.4f}") logging.info("Extrahiere Baumregeln...") try: feature_names = list(X_train.columns) - class_names = list(best_estimator.classes_) # Sicherstellen, dass es eine Liste ist rules_text = export_text(best_estimator, feature_names=feature_names, - show_weights=True, spacing=3) # class_names optional + show_weights=True, spacing=3) patterns_filename = args.patterns_out with open(patterns_filename, 'w', encoding='utf-8') as f_rules: f_rules.write(rules_text) @@ -4410,7 +4639,7 @@ def main(): except Exception as e_export: logging.error(f"Fehler beim Exportieren der Regeln: {e_export}") except Exception as e_eval: - logging.error(f"Fehler bei der Evaluation des Test-Sets: {e_eval}") + logging.exception(f"Fehler bei der Evaluation des Test-Sets: {e_eval}") else: logging.warning("Datenvorbereitung für Modelltraining fehlgeschlagen oder ergab keine Daten.") @@ -4421,9 +4650,8 @@ def main(): logging.warning("Skript durch Benutzer unterbrochen (KeyboardInterrupt).") print("\n! Skript wurde manuell beendet.") except Exception as e: - # Fange alle unerwarteten Fehler im Hauptblock ab logging.critical(f"FATAL: Unerwarteter Fehler im Haupt-Ausführungsblock des Modus '{mode}': {e}") - logging.exception("Traceback des kritischen Fehlers:") # Loggt den Traceback + logging.exception("Traceback des kritischen Fehlers:") # --- Abschluss --- end_time = time.time() @@ -4432,7 +4660,7 @@ def main(): logging.info(f"Gesamtdauer: {duration:.2f} Sekunden.") logging.info(f"===== Skript beendet =====") - # Schließe Logging Handler explizit (optional, aber sauber) + # Schließe Logging Handler explizit logging.shutdown() print(f"\nVerarbeitung abgeschlossen. Logfile: {LOG_FILE}") @@ -4440,4 +4668,43 @@ def main(): # Führt die main-Funktion aus, wenn das Skript direkt gestartet wird if __name__ == '__main__': + # --- WICHTIG: Fehlende Imports hier hinzufügen --- + import functools # Für retry decorator nötig + import pandas as pd + import numpy as np + from sklearn.model_selection import train_test_split, GridSearchCV + from sklearn.impute import SimpleImputer + from sklearn.tree import DecisionTreeClassifier, export_text + from sklearn.metrics import accuracy_score, classification_report, confusion_matrix + import json + import pickle + import concurrent.futures + import threading + # --- Ende fehlende Imports --- + + # --- Annahme: Decorator Definition ist hier oder importiert --- + # Beispielhafte Decorator Definition (falls nicht in separater Datei) + def retry_on_failure(func): + @functools.wraps(func) + def wrapper(*args, **kwargs): + # ... (Implementierung des Decorators wie zuvor) ... + # Minimalistische Version zur Kompilierung: + try: + return func(*args, **kwargs) + except Exception as e: + logging.warning(f"Retry wird übersprungen (Dummy-Decorator): Fehler in {func.__name__}: {e}") + raise e # Fehler weitergeben + return wrapper + # --- Ende Decorator Annahme --- + + # --- Annahme: Restliche Funktionen/Klassen sind definiert --- + # z.B. Config, COLUMN_MAP, create_log_filename, load_target_schema, + # GoogleSheetHandler, WikipediaScraper, DataProcessor, + # Helper-Funktionen (simple_normalize_url etc.), + # Batch-Funktionen (run_dispatcher etc.), + # API-Funktionen (call_openai_chat etc.), + # Neue Funktionen (serp_wikipedia_lookup, process_find_wiki_with_serp) + # ... + # --- Ende Funktions/Klassen Annahmen --- + main() \ No newline at end of file