From dcf33501e938fa5b37017145c368720ab039cbe2 Mon Sep 17 00:00:00 2001 From: Floke Date: Tue, 22 Apr 2025 13:58:36 +0000 Subject: [PATCH] bugfix --- brancheneinstufung.py | 1060 +++++++++++++++++++++++++++++------------ 1 file changed, 754 insertions(+), 306 deletions(-) diff --git a/brancheneinstufung.py b/brancheneinstufung.py index 07824bd0..3abc42ce 100644 --- a/brancheneinstufung.py +++ b/brancheneinstufung.py @@ -3903,6 +3903,9 @@ class DataProcessor: # Erstelle eine Instanz des Scrapers für diesen Prozessor # Annahme: WikipediaScraper ist importiert und korrekt try: + # Annahme: WikipediaScraper benötigt keinen Parameter mehr, + # oder kann mit einem Default initialisiert werden, + # und Config.USER_AGENT existiert self.wiki_scraper = WikipediaScraper() # Geht davon aus, dass Config etc. verfügbar ist except NameError: logging.critical("DataProcessor Init FEHLER: WikipediaScraper Klasse nicht gefunden/importiert!") @@ -3913,115 +3916,164 @@ class DataProcessor: logging.info("DataProcessor initialisiert.") - # Die zentrale Methode zur Verarbeitung einer einzelnen Zeile -# @retry_on_failure -def _process_single_row(self, row_num_in_sheet, row_data, - process_wiki=True, process_chatgpt=True, process_website=True, - force_reeval=False): - """ - Verarbeitet die Daten für eine einzelne Zeile. Korrigierte Logik für reeval. - """ - logging.info(f"--- Starte Verarbeitung für Zeile {row_num_in_sheet} {'(Re-Eval)' if force_reeval else ''} ---") - updates = [] - now_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - any_processing_done = False - wiki_data_updated_in_this_run = False + # --- Methode: Verarbeitung einer einzelnen Zeile --- + # Diese Methode gehört in die Klasse + # @retry_on_failure # Achtung: retry_on_failure macht bei dieser Methode WENIG Sinn, + # da sie interne Logik steuert, keine externen Calls. + # Besser: retry auf den einzelnen Schritten (API/Scrape) + def _process_single_row(self, row_num_in_sheet, row_data, + process_wiki=True, process_chatgpt=True, process_website=True, + force_reeval=False): + """ + Verarbeitet die Daten für eine einzelne Zeile im Sheet. + Führt Website-Scraping/Lookup, Wikipedia-Extraktion/Validierung + und ChatGPT-Evaluationen durch, basierend auf Timestamps/Status + oder dem force_reeval Flag. Schreibt Ergebnisse zurück. - def get_cell_value(key): - idx = COLUMN_MAP.get(key) - if idx is not None and len(row_data) > idx: - return row_data[idx] if row_data[idx] is not None else '' - return "" + Args: + row_num_in_sheet (int): Die 1-basierte Zeilennummer im Google Sheet. + row_data (list): Die rohen Listendaten für diese Zeile. + process_wiki (bool, optional): Soll Wiki-Verarbeitung durchgeführt werden?. Defaults to True. + process_chatgpt (bool, optional): Sollen ChatGPT-Evaluationen durchgeführt werden?. Defaults to True. + process_website (bool, optional): Soll Website-Verarbeitung durchgeführt werden?. Defaults to True. + force_reeval (bool, optional): Ignoriert Timestamps und erzwingt Neuverarbeitung. Defaults to False. + """ + logging.info(f"--- Starte Verarbeitung für Zeile {row_num_in_sheet} {'(Re-Eval)' if force_reeval else ''} ---") + updates = [] + now_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + any_processing_done = False + wiki_data_updated_in_this_run = False # Flag, ob Wiki-Daten NEU extrahiert/gesetzt wurden - # Initiale Werte lesen - company_name = get_cell_value("CRM Name") - website_url = get_cell_value("CRM Website"); original_website = website_url - crm_branche = get_cell_value("CRM Branche"); crm_beschreibung = get_cell_value("CRM Beschreibung") - konsistenz_s = get_cell_value("Chat Wiki Konsistenzprüfung") - website_raw = get_cell_value("Website Rohtext") or "k.A." - website_summary = get_cell_value("Website Zusammenfassung") or "k.A." + # Hilfsfunktion für sicheren Zellenzugriff + def get_cell_value(key): + idx = COLUMN_MAP.get(key) + if idx is not None and len(row_data) > idx: + # Stelle sicher, dass der Wert nicht None ist, falls Sheet-Zelle leer ist + return row_data[idx] if row_data[idx] is not None else '' + return "" # Gebe leeren String für fehlende Spalten zurück - final_wiki_data = { - 'url': get_cell_value("Wiki URL") or 'k.A.', 'first_paragraph': get_cell_value("Wiki Absatz") or 'k.A.', - 'branche': get_cell_value("Wiki Branche") or 'k.A.', 'umsatz': get_cell_value("Wiki Umsatz") or 'k.A.', - 'mitarbeiter': get_cell_value("Wiki Mitarbeiter") or 'k.A.', 'categories': get_cell_value("Wiki Kategorien") or 'k.A.' - } + # Initiale Werte lesen + company_name = get_cell_value("CRM Name") + website_url = get_cell_value("CRM Website"); original_website = website_url + crm_branche = get_cell_value("CRM Branche"); crm_beschreibung = get_cell_value("CRM Beschreibung") + konsistenz_s = get_cell_value("Chat Wiki Konsistenzprüfung").strip() # Trimme hier schon - # --- 1. Website Handling (Prüft AT oder force_reeval) --- - website_ts_missing = not get_cell_value("Website Scrape Timestamp").strip() - website_processing_needed = process_website and (force_reeval or website_ts_missing) - if website_processing_needed: - any_processing_done = True - logging.info(f"Zeile {row_num_in_sheet}: Starte Website Verarbeitung (Grund: {'Re-Eval' if force_reeval else 'AT fehlt'})...") - # ... (Website Lookup/Scrape/Summarize Logik bleibt gleich) ... - if not website_url or website_url.strip().lower() == "k.a.": - logging.debug(" -> Suche Website via SERP...") - new_website = serp_website_lookup(company_name) - if new_website != "k.A.": - website_url = new_website - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["CRM Website"] + 1)}{row_num_in_sheet}', 'values': [[website_url]]}) - if website_url and website_url.strip().lower() != "k.a.": - logging.debug(f" -> Scrape Rohtext von {website_url}...") - new_website_raw = get_website_raw(website_url) - logging.debug(f" -> Fasse Rohtext zusammen (Länge: {len(str(new_website_raw))})...") - new_website_summary = summarize_website_content(new_website_raw) - website_raw = new_website_raw - website_summary = new_website_summary - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Rohtext"] + 1)}{row_num_in_sheet}', 'values': [[website_raw]]}) - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Zusammenfassung"] + 1)}{row_num_in_sheet}', 'values': [[website_summary]]}) - else: - logging.warning(f" -> Keine gültige Website gefunden/vorhanden für {company_name}.") - website_raw, website_summary = "k.A.", "k.A." - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Rohtext"] + 1)}{row_num_in_sheet}', 'values': [['k.A.']]}) - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Zusammenfassung"] + 1)}{row_num_in_sheet}', 'values': [['k.A.']]}) - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Scrape Timestamp"] + 1)}{row_num_in_sheet}', 'values': [[now_timestamp]]}) - elif process_website: - logging.debug(f"Zeile {row_num_in_sheet}: Überspringe Website (AT vorhanden und kein Re-Eval).") + # Lade vorhandene Wiki-Daten (könnten alt sein, werden ggf. überschrieben) + final_wiki_data = { + 'url': get_cell_value("Wiki URL") or 'k.A.', 'first_paragraph': get_cell_value("Wiki Absatz") or 'k.A.', + 'branche': get_cell_value("Wiki Branche") or 'k.A.', 'umsatz': get_cell_value("Wiki Umsatz") or 'k.A.', + 'mitarbeiter': get_cell_value("Wiki Mitarbeiter") or 'k.A.', 'categories': get_cell_value("Wiki Kategorien") or 'k.A.' + } - # --- 2. Wikipedia Verarbeitung (NEUE Logik für reeval) --- - wiki_ts_an_missing = not get_cell_value("Wikipedia Timestamp").strip() - status_s_indicates_reparse = konsistenz_s.strip().upper() == "X (URL COPIED)" - # Trigger prüfen - wiki_processing_needed = process_wiki and (force_reeval or wiki_ts_an_missing or status_s_indicates_reparse) + # --- 1. Website Handling (Prüft AT oder force_reeval) --- + website_ts_missing = not get_cell_value("Website Scrape Timestamp").strip() + # Website Verarbeitung notwendig, wenn: + # - process_website True ist UND + # ( force_reeval True ist ODER Timestamp AT fehlt ) + website_processing_needed = process_website and (force_reeval or website_ts_missing) - if wiki_processing_needed: - any_processing_done = True - logging.info(f"Zeile {row_num_in_sheet}: Starte Wikipedia Verarbeitung (Grund: {'Re-Eval' if force_reeval else f'AN fehlt? {wiki_ts_an_missing}, S=X(Copied)? {status_s_indicates_reparse}'})...") + if website_processing_needed: + any_processing_done = True + logging.info(f"Zeile {row_num_in_sheet}: Starte Website Verarbeitung (Grund: {'Re-Eval' if force_reeval else 'AT fehlt'})...") + # Website Lookup nur, wenn URL leer ist + if not website_url or website_url.strip().lower() == "k.a.": + logging.debug(" -> Suche Website via SERP...") + # Annahme: serp_website_lookup existiert und nutzt logging/retry + new_website = serp_website_lookup(company_name) + if new_website != "k.A.": + website_url = new_website # Update die lokale Variable für den weiteren Schritt + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["CRM Website"] + 1)}{row_num_in_sheet}', 'values': [[website_url]]}) + logging.info(f" -> Neue Website gefunden und für Update vorgemerkt: {website_url}") + else: + logging.warning(f" -> Keine neue Website via SERP gefunden für '{company_name}'.") - url_in_m = get_cell_value("Wiki URL").strip() - url_to_extract = None - search_was_needed = False # Flag, ob eine neue Suche durchgeführt wurde + if website_url and website_url.strip().lower() != "k.a.": + logging.debug(f" -> Scrape Rohtext von {website_url}...") + # Annahme: get_website_raw existiert und nutzt logging/retry + new_website_raw = get_website_raw(website_url) + website_raw = new_website_raw # Lokale Variable aktualisieren + + # Zusammenfassung nur, wenn Rohtext extrahiert wurde + if website_raw != "k.A." and website_raw.strip(): + logging.debug(f" -> Fasse Rohtext zusammen (Länge: {len(str(website_raw))})...") + # Annahme: summarize_website_content existiert und nutzt logging/retry + new_website_summary = summarize_website_content(website_raw) + website_summary = new_website_summary # Lokale Variable aktualisieren + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Zusammenfassung"] + 1)}{row_num_in_sheet}', 'values': [[website_summary]]}) + else: + logging.warning(" -> Kein gültiger Rohtext zum Zusammenfassen vorhanden.") + website_summary = "k.A." + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Zusammenfassung"] + 1)}{row_num_in_sheet}', 'values': [['k.A.']]}) + + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Rohtext"] + 1)}{row_num_in_sheet}', 'values': [[website_raw]]}) # Rohtext immer schreiben (k.A. oder Inhalt) - # --- Kernlogik für Re-Eval oder Initiallauf --- - if force_reeval: - logging.debug(" -> Re-Eval Modus aktiv.") - if url_in_m and url_in_m.lower() not in ["k.a.", "kein artikel gefunden"] and url_in_m.lower().startswith("http"): - # Im Re-Eval Modus nehmen wir die URL aus M an, ohne erneute Validierung oder Suche! - logging.info(f" -> Re-Eval: Nutze vorhandene URL aus Spalte M direkt: {url_in_m}") - url_to_extract = url_in_m else: - # Wenn M leer/ungültig ist, auch im Re-Eval Modus neu suchen - logging.warning(f" -> Re-Eval: Spalte M ist leer oder ungültig ('{url_in_m}'). Starte neue Suche...") - search_was_needed = True - validated_page = self.wiki_scraper.search_company_article(company_name, website_url) - if validated_page: - url_to_extract = validated_page.url - else: # Suche erfolglos - final_wiki_data = {'url': 'Kein Artikel gefunden', 'first_paragraph': 'k.A.', 'branche': 'k.A.', 'umsatz': 'k.A.', 'mitarbeiter': 'k.A.', 'categories': 'k.A.'} - wiki_data_updated_in_this_run = True - else: # Normalbetrieb (nicht reeval) - if status_s_indicates_reparse: + logging.warning(f" -> Keine gültige Website URL vorhanden/gefunden für '{company_name}'. Website Verarbeitung übersprungen.") + website_raw, website_summary = "k.A.", "k.A." + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Rohtext"] + 1)}{row_num_in_sheet}', 'values': [['k.A.']]}) + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Zusammenfassung"] + 1)}{row_num_in_sheet}', 'values': [['k.A.']]}) + + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Website Scrape Timestamp"] + 1)}{row_num_in_sheet}', 'values': [[now_timestamp]]}) # Timestamp AT immer setzen, wenn Verarbeitung versucht wurde + + elif process_website: + logging.debug(f"Zeile {row_num_in_sheet}: Überspringe Website Verarbeitung (AT vorhanden und kein Re-Eval).") + + # --- 2. Wikipedia Verarbeitung (Prüft AN, Status S='X (URL Copied)' oder force_reeval) --- + wiki_ts_an_missing = not get_cell_value("Wikipedia Timestamp").strip() + status_s_indicates_reparse = konsistenz_s.upper() == "X (URL COPIED)" # Prüfe getrimmten Wert + # Wiki Verarbeitung notwendig, wenn: + # - process_wiki True ist UND + # ( force_reeval True ist ODER Timestamp AN fehlt ODER Status S ist 'X (URL Copied)' ) + wiki_processing_needed = process_wiki and (force_reeval or wiki_ts_an_missing or status_s_indicates_reparse) + + + if wiki_processing_needed: + any_processing_done = True + logging.info(f"Zeile {row_num_in_sheet}: Starte Wikipedia Verarbeitung (Grund: {'Re-Eval' if force_reeval else f'AN fehlt? {wiki_ts_an_missing}, S=\'X (URL Copied)\'? {status_s_indicates_reparse}'})...") + + url_in_m = get_cell_value("Wiki URL").strip() + url_to_extract = None + search_was_needed = False # Flag, ob eine neue Suche durchgeführt wurde + + # --- Kernlogik für Re-Eval oder Initiallauf / S="X (URL Copied)" --- + # Priorität: + # 1. force_reeval: Nimm M, wenn gültig. Sonst Suche. + # 2. S == "X (URL Copied)": Ignoriere M, mache Suche. + # 3. AN fehlt: Wenn M gültig, valide M. Sonst Suche. + # 4. Sonst (AN da, S nicht "X (URL Copied)", kein reeval): Überspringe. + + if force_reeval: + logging.debug(" -> Re-Eval Modus aktiv.") + if url_in_m and url_in_m.lower() not in ["k.a.", "kein artikel gefunden"] and url_in_m.lower().startswith("http"): + # Im Re-Eval Modus nehmen wir die URL aus M an, ohne erneute Validierung oder Suche (Vertrauen auf M)! + logging.info(f" -> Re-Eval: Nutze vorhandene URL aus Spalte M direkt: {url_in_m}") + url_to_extract = url_in_m + else: + # Wenn M leer/ungültig ist, auch im Re-Eval Modus neu suchen + logging.warning(f" -> Re-Eval: Spalte M ist leer oder ungültig ('{url_in_m}'). Starte neue Suche...") + search_was_needed = True + validated_page = self.wiki_scraper.search_company_article(company_name, website_url) # Annahme: self.wiki_scraper existiert + if validated_page: + url_to_extract = validated_page.url + else: # Suche erfolglos + final_wiki_data = {'url': 'Kein Artikel gefunden', 'first_paragraph': 'k.A.', 'branche': 'k.A.', 'umsatz': 'k.A.', 'mitarbeiter': 'k.A.', 'categories': 'k.A.'} + wiki_data_updated_in_this_run = True + elif status_s_indicates_reparse: logging.warning(f" -> Status S ist 'X (URL Copied)', ignoriere URL '{url_in_m}' in M und starte neue Suche...") search_was_needed = True validated_page = self.wiki_scraper.search_company_article(company_name, website_url) if validated_page: url_to_extract = validated_page.url else: final_wiki_data = {'url': 'Kein Artikel gefunden', 'first_paragraph': 'k.A.', 'branche': 'k.A.', 'umsatz': 'k.A.', 'mitarbeiter': 'k.A.', 'categories': 'k.A.'}; wiki_data_updated_in_this_run = True - elif wiki_ts_an_missing: # Nur wenn AN fehlt und S nicht 'X(Copied)' ist + elif wiki_ts_an_missing: # Nur wenn AN fehlt und S nicht 'X(Copied)' ist, UND kein reeval if url_in_m and url_in_m.lower() not in ["k.a.", "kein artikel gefunden"] and url_in_m.lower().startswith("http"): # Prüfe Validität nur im Initiallauf, wenn M schon befüllt ist logging.debug(f" -> AN fehlt, prüfe Validität der URL aus M: {url_in_m}") try: - page_from_m = wikipedia.page(url_in_m.split('/wiki/')[-1].replace('_', ' '), auto_suggest=False, preload=True) + # Extrahieren des Titels aus der URL für wikipedia.page + # Hier könnte ein Fehler passieren, wenn URL kein '/wiki/' hat + title_from_url = url_in_m.split('/wiki/')[-1].replace('_', ' ') + page_from_m = wikipedia.page(title_from_url, auto_suggest=False, preload=True) + # Validierung des Artikels if self.wiki_scraper._validate_article(page_from_m, company_name, website_url): url_to_extract = page_from_m.url logging.info(f" -> Vorhandene URL aus M '{url_to_extract}' ist valide und wird verwendet.") @@ -4043,8 +4095,8 @@ def _process_single_row(self, row_num_in_sheet, row_data, validated_page = self.wiki_scraper.search_company_article(company_name, website_url) if validated_page: url_to_extract = validated_page.url else: final_wiki_data = {'url': 'Kein Artikel gefunden', 'first_paragraph': 'k.A.', 'branche': 'k.A.', 'umsatz': 'k.A.', 'mitarbeiter': 'k.A.', 'categories': 'k.A.'}; wiki_data_updated_in_this_run = True - except Exception as e_val_m: - logging.error(f" -> Fehler beim Prüfen der URL aus M '{url_in_m}': {e_val_m}. Starte neue Suche...") + except Exception as e_val_m: # Fängt auch URL parsing Fehler hier ab + logging.exception(f" -> Fehler beim Prüfen der URL aus M '{url_in_m}': {e_val_m}. Starte neue Suche...") search_was_needed = True validated_page = self.wiki_scraper.search_company_article(company_name, website_url) if validated_page: url_to_extract = validated_page.url @@ -4060,101 +4112,143 @@ def _process_single_row(self, row_num_in_sheet, row_data, final_wiki_data = {'url': 'Kein Artikel gefunden', 'first_paragraph': 'k.A.', 'branche': 'k.A.', 'umsatz': 'k.A.', 'mitarbeiter': 'k.A.', 'categories': 'k.A.'} wiki_data_updated_in_this_run = True - # Datenextraktion, wenn eine URL bestimmt wurde - if url_to_extract: - logging.info(f" -> Extrahiere Daten von URL: {url_to_extract}...") - extracted_data = self.wiki_scraper.extract_company_data(url_to_extract) - if extracted_data: - final_wiki_data = extracted_data - wiki_data_updated_in_this_run = True - logging.info(f" -> Datenextraktion erfolgreich.") - else: - logging.error(f" -> Fehler bei Datenextraktion von {url_to_extract}. Setze Daten auf 'k.A.'") - final_wiki_data = {'url': url_to_extract, 'first_paragraph': 'k.A.', 'branche': 'k.A.', 'umsatz': 'k.A.', 'mitarbeiter': 'k.A.', 'categories': 'k.A.'} - wiki_data_updated_in_this_run = True # Markieren, dass überschrieben wird + # Datenextraktion, wenn eine URL bestimmt wurde + if url_to_extract and url_to_extract != 'Kein Artikel gefunden': + logging.info(f" -> Extrahiere Daten von URL: {url_to_extract}...") + # Annahme: self.wiki_scraper.extract_company_data existiert und nutzt logging + extracted_data = self.wiki_scraper.extract_company_data(url_to_extract) + if extracted_data: + final_wiki_data = extracted_data + wiki_data_updated_in_this_run = True # Markieren, dass extrahierte Daten da sind + logging.info(f" -> Datenextraktion erfolgreich.") + else: + logging.error(f" -> Fehler bei Datenextraktion von {url_to_extract}. Setze Daten auf 'k.A.'") + # Behalte die URL, aber setze alle anderen Felder auf k.A. + final_wiki_data = {'url': url_to_extract, 'first_paragraph': 'k.A.', 'branche': 'k.A.', 'umsatz': 'k.A.', 'mitarbeiter': 'k.A.', 'categories': 'k.A.'} + wiki_data_updated_in_this_run = True # Markieren, dass überschrieben wird - # Sheet Updates für M-R und AN (nur wenn Wiki-Verarbeitung lief) - if wiki_processing_needed: - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wiki URL"] + 1)}{row_num_in_sheet}', 'values': [[final_wiki_data.get('url', 'k.A.')]]}) - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wiki Absatz"] + 1)}{row_num_in_sheet}', 'values': [[final_wiki_data.get('first_paragraph', 'k.A.')]]}) - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wiki Branche"] + 1)}{row_num_in_sheet}', 'values': [[final_wiki_data.get('branche', 'k.A.')]]}) - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wiki Umsatz"] + 1)}{row_num_in_sheet}', 'values': [[final_wiki_data.get('umsatz', 'k.A.')]]}) - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wiki Mitarbeiter"] + 1)}{row_num_in_sheet}', 'values': [[final_wiki_data.get('mitarbeiter', 'k.A.')]]}) - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wiki Kategorien"] + 1)}{row_num_in_sheet}', 'values': [[final_wiki_data.get('categories', 'k.A.')]]}) - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wikipedia Timestamp"] + 1)}{row_num_in_sheet}', 'values': [[now_timestamp]]}) + # Sheet Updates für M-R und AN (nur wenn Wiki-Verarbeitung lief) + if wiki_processing_needed: # Hier wurde bereits geprüft, ob Wiki-Verarbeitung notwendig war + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wiki URL"] + 1)}{row_num_in_sheet}', 'values': [[final_wiki_data.get('url', 'k.A.')]]}) + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wiki Absatz"] + 1)}{row_num_in_sheet}', 'values': [[final_wiki_data.get('first_paragraph', 'k.A.')]]}) + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wiki Branche"] + 1)}{row_num_in_sheet}', 'values': [[final_wiki_data.get('branche', 'k.A.')]]}) + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wiki Umsatz"] + 1)}{row_num_in_sheet}', 'values': [[final_wiki_data.get('umsatz', 'k.A.')]]}) + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wiki Mitarbeiter"] + 1)}{row_num_in_sheet}', 'values': [[final_wiki_data.get('mitarbeiter', 'k.A.')]]}) + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wiki Kategorien"] + 1)}{row_num_in_sheet}', 'values': [[final_wiki_data.get('categories', 'k.A.')]]}) + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Wikipedia Timestamp"] + 1)}{row_num_in_sheet}', 'values': [[now_timestamp]]}) # AN Timestamp setzen - # Setze S zurück, wenn nötig (force_reeval, URL geändert, oder S war X(Copied)) - if force_reeval or status_s_indicates_reparse or (url_in_m != final_wiki_data.get('url')): - s_idx = COLUMN_MAP.get("Chat Wiki Konsistenzprüfung") - if s_idx is not None: - s_let = self.sheet_handler._get_col_letter(s_idx + 1) - updates.append({'range': f'{s_let}{row_num_in_sheet}', 'values': [["?"]]}) # Fragezeichen für Neubewertung - logging.info(f" -> Status S zurückgesetzt auf '?' für erneute Verifikation.") + # Setze S ('Chat Wiki Konsistenzprüfung') zurück, wenn eine Neubewertung nötig ist: + # - Immer bei force_reeval + # - Wenn die URL in M geändert wurde (entweder durch Suche oder weil M vorher leer war) + # - Wenn Status S zuvor "X (URL Copied)" war + url_changed = (url_in_m != final_wiki_data.get('url')) # Prüft ob die NEUE URL anders ist als die ursprünglich in M + if force_reeval or status_s_indicates_reparse or url_changed: + s_idx = COLUMN_MAP.get("Chat Wiki Konsistenzprüfung") + if s_idx is not None: + s_let = self.sheet_handler._get_col_letter(s_idx + 1) + updates.append({'range': f'{s_let}{row_num_in_sheet}', 'values': [["?"]]}) # Fragezeichen für Neubewertung + logging.info(f" -> Status S zurückgesetzt auf '?' für erneute Verifikation (Grund: {'Re-Eval' if force_reeval else ('S=\'X (URL Copied)\'' if status_s_indicates_reparse else 'URL geändert')}).") - elif process_wiki: - logging.debug(f"Zeile {row_num_in_sheet}: Überspringe Wikipedia Verarbeitung (AN vorhanden, kein S=X(Copied) und kein Re-Eval).") - # --- 3. ChatGPT Evaluationen (Branch etc.) --- - chat_ts_ao_missing = not get_cell_value("Timestamp letzte Prüfung").strip() - run_chat_eval = process_chatgpt and (force_reeval or chat_ts_ao_missing or wiki_data_updated_in_this_run) + elif process_wiki: + logging.debug(f"Zeile {row_num_in_sheet}: Überspringe Wikipedia Verarbeitung (AN vorhanden, kein S=\'X (URL Copied)\' und kein Re-Eval).") - if run_chat_eval: - logging.info(f"Zeile {row_num_in_sheet}: Starte ChatGPT Evaluationen (Grund: {'Re-Eval' if force_reeval else f'AO fehlt? {chat_ts_ao_missing}, Wiki gerade aktualisiert? {wiki_data_updated_in_this_run}'})...") - any_processing_done = True - # Annahme: evaluate_branche_chatgpt existiert und nutzt logging - branch_result = evaluate_branche_chatgpt( - crm_branche, crm_beschreibung, - final_wiki_data.get('branche', 'k.A.'), - final_wiki_data.get('categories', 'k.A.'), - website_summary - ) - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Vorschlag Branche"] + 1)}{row_num_in_sheet}', 'values': [[branch_result.get('branch', 'Fehler')]]}) - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Konsistenz Branche"] + 1)}{row_num_in_sheet}', 'values': [[branch_result.get('consistency', 'Fehler')]]}) - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Begründung Abweichung Branche"] + 1)}{row_num_in_sheet}', 'values': [[branch_result.get('justification', 'Fehler')]]}) + # --- 3. ChatGPT Evaluationen (Branch etc.) (Prüft AO oder force_reeval oder wiki_data_updated_in_this_run) --- + chat_ts_ao_missing = not get_cell_value("Timestamp letzte Prüfung").strip() + # Chat Evaluationen notwendig, wenn: + # - process_chatgpt True ist UND + # ( force_reeval True ist ODER Timestamp AO fehlt ODER Wiki Daten gerade aktualisiert wurden ) + run_chat_eval = process_chatgpt and (force_reeval or chat_ts_ao_missing or wiki_data_updated_in_this_run) - # --- Hier Platz für weitere ChatGPT-Calls --- + if run_chat_eval: + any_processing_done = True + logging.info(f"Zeile {row_num_in_sheet}: Starte ChatGPT Evaluationen (Grund: {'Re-Eval' if force_reeval else f'AO fehlt? {chat_ts_ao_missing}, Wiki gerade aktualisiert? {wiki_data_updated_in_this_run}'})...") - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Timestamp letzte Prüfung"] + 1)}{row_num_in_sheet}', 'values': [[now_timestamp]]}) + # Annahme: evaluate_branche_chatgpt existiert und nutzt logging/retry + # Nutze die (ggf. neu extrahierten) final_wiki_data + branch_result = evaluate_branche_chatgpt( + crm_branche, crm_beschreibung, + final_wiki_data.get('branche', 'k.A.'), + final_wiki_data.get('categories', 'k.A.'), + website_summary + ) + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Vorschlag Branche"] + 1)}{row_num_in_sheet}', 'values': [[branch_result.get('branch', 'Fehler')]]}) + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Konsistenz Branche"] + 1)}{row_num_in_sheet}', 'values': [[branch_result.get('consistency', 'Fehler')]]}) + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Begründung Abweichung Branche"] + 1)}{row_num_in_sheet}', 'values': [[branch_result.get('justification', 'Fehler')]]}) - elif process_chatgpt: - logging.debug(f"Zeile {row_num_in_sheet}: Überspringe ChatGPT Evaluationen (AO vorhanden, Wiki nicht gerade aktualisiert und kein Re-Eval).") + # --- Hier Platz für weitere ChatGPT-Calls, die AO als Trigger nutzen --- + # z.B. FSM Relevanz, Mitarbeiter/Umsatz Schätzung etc. + # Stelle sicher, dass diese Funktionen existieren und die benötigten Daten nutzen - # --- 4. Abschließende Updates --- - if any_processing_done: - # Annahme: Config ist verfügbar - updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Version"] + 1)}{row_num_in_sheet}', 'values': [[Config.VERSION]]}) + # Beispiel (Pseudo-Code, implementiere diese Funktionen falls nötig): + # fsm_result = evaluate_fsm_suitability(company_name, {'wiki': final_wiki_data, 'web_summary': website_summary, 'crm_desc': crm_beschreibung}) + # updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Prüfung FSM Relevanz"] + 1)}{row_num_in_sheet}', 'values': [[fsm_result.get('suitability', 'Fehler')]]}) + # updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Begründung für FSM Relevanz"] + 1)}{row_num_in_sheet}', 'values': [[fsm_result.get('justification', 'Fehler')]]}) - # --- 5. Batch Update für diese Zeile --- - if updates: - logging.info(f"Zeile {row_num_in_sheet}: Sende Batch-Update mit {len(updates)} Operationen...") - success = self.sheet_handler.batch_update_cells(updates) # Annahme: nutzt logging - if not success: logging.error(f"Zeile {row_num_in_sheet}: FEHLER beim Batch-Update.") - else: - if not any_processing_done: - logging.info(f"Zeile {row_num_in_sheet}: Keine Updates zum Schreiben (alle Schritte übersprungen).") + # emp_estimate_result = evaluate_employee_chatgpt(...) + # updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Schätzung Anzahl Mitarbeiter"] + 1)}{row_num_in_sheet}', 'values': [[emp_estimate_result.get('estimate', 'Fehler')]]}) + # ... etc. - logging.info(f"--- Verarbeitung für Zeile {row_num_in_sheet} abgeschlossen ---") - # Annahme: Config ist verfügbar - time.sleep(max(0.1, getattr(Config, 'RETRY_DELAY', 5) / 20)) + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Timestamp letzte Prüfung"] + 1)}{row_num_in_sheet}', 'values': [[now_timestamp]]}) # AO Timestamp setzen - # Methode für den Re-Eval Modus (ruft _process_single_row MIT force_reeval) + elif process_chatgpt: + logging.debug(f"Zeile {row_num_in_sheet}: Überspringe ChatGPT Evaluationen (AO vorhanden, Wiki nicht gerade aktualisiert und kein Re-Eval).") + + + # --- 4. Abschließende Updates --- + # Version wird gesetzt, wenn IRGENDEINE Verarbeitung in dieser Zeile stattgefunden hat + if any_processing_done: + # Annahme: Config ist verfügbar + updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Version"] + 1)}{row_num_in_sheet}', 'values': [[Config.VERSION]]}) + + # --- 5. Batch Update für diese Zeile --- + if updates: + # Info-Log über die Anzahl der Updates für diese spezifische Zeile + logging.info(f"Zeile {row_num_in_sheet}: Sende Batch-Update mit {len(updates)} Operationen für diese Zeile...") + success = self.sheet_handler.batch_update_cells(updates) # Annahme: batch_update_cells nutzt logging intern + if not success: + logging.error(f"Zeile {row_num_in_sheet}: FEHLER beim Batch-Update.") + else: + # Info-Log, wenn nichts zu tun war + if not any_processing_done: + logging.info(f"Zeile {row_num_in_sheet}: Keine Updates zum Schreiben (alle relevanten Schritte übersprungen).") + + + logging.info(f"--- Verarbeitung für Zeile {row_num_in_sheet} abgeschlossen ---") + # Kleine Pause nach der Verarbeitung jeder Zeile, um API-Limits zu respektieren, auch bei sequenziellen Modi + # Der Wert kann in Config angepasst werden. 0.1s ist sehr kurz, 0.5-1.0s ist realistischer. + # Annahme: Config.RETRY_DELAY ist in Sekunden, also durch 10 oder 20 teilen + # logging.debug(f"Wartezeit nach Zeile {row_num_in_sheet}: {max(0.1, getattr(Config, 'RETRY_DELAY', 5) / 20):.2f}s") + time.sleep(max(0.1, getattr(Config, 'RETRY_DELAY', 5) / 20)) + + + # --- Methode für den Re-Eval Modus --- + # Diese Methode gehört in die Klasse def process_reevaluation_rows(self, row_limit=None, clear_flag=True): """ Verarbeitet nur Zeilen, die in Spalte A mit 'x' markiert sind. Ruft _process_single_row für jede dieser Zeilen auf mit force_reeval=True. Verarbeitet maximal row_limit Zeilen. Löscht optional das 'x'-Flag nach erfolgreicher Verarbeitung. + + Args: + row_limit (int, optional): Maximale Anzahl zu verarbeitender Zeilen. Defaults to None. + clear_flag (bool, optional): Flag 'x' nach erfolgreicher Verarbeitung löschen. Defaults to True. """ logging.info(f"Starte Re-Evaluierungsmodus (Spalte A = 'x'). Max. Zeilen: {row_limit if row_limit is not None else 'Unbegrenzt'}") - if not self.sheet_handler.load_data(): return + # Daten neu laden vor der Verarbeitung + if not self.sheet_handler.load_data(): + logging.error("Fehler beim Laden der Daten für Re-Evaluation.") + return + all_data = self.sheet_handler.get_all_data_with_headers() if not all_data or len(all_data) <= 5: logging.warning("Keine Daten für Re-Evaluation gefunden.") return header_rows = 5 - data_rows = all_data[header_rows:] + # data_rows wird hier nicht direkt benötigt, wir nutzen all_data und den sheet index # Annahme: COLUMN_MAP ist global verfügbar reeval_col_idx = COLUMN_MAP.get("ReEval Flag") @@ -4163,10 +4257,12 @@ def _process_single_row(self, row_num_in_sheet, row_data, return rows_to_process = [] - for idx, row in enumerate(data_rows): - if len(row) > reeval_col_idx and row[reeval_col_idx].strip().lower() == "x": - row_num_in_sheet = idx + header_rows + 1 - rows_to_process.append({'row_num': row_num_in_sheet, 'data': row}) + # Iteriere über alle Datenzeilen, um 'x' zu finden + for idx_in_list in range(header_rows, len(all_data)): + row_data = all_data[idx_in_list] + row_num_in_sheet = idx_in_list + 1 # 1-basierte Zeilennummer + if len(row_data) > reeval_col_idx and str(row_data[reeval_col_idx]).strip().lower() == "x": + rows_to_process.append({'row_num': row_num_in_sheet, 'data': row_data}) found_count = len(rows_to_process) logging.info(f"{found_count} Zeilen mit ReEval-Flag 'x' gefunden.") @@ -4177,7 +4273,7 @@ def _process_single_row(self, row_num_in_sheet, row_data, processed_count = 0 updates_clear_flag = [] - rows_actually_processed = [] + rows_actually_processed = [] # Liste der Zeilennummern, die wirklich verarbeitet wurden for task in rows_to_process: # Limit-Prüfung VOR der Verarbeitung @@ -4186,268 +4282,620 @@ def _process_single_row(self, row_num_in_sheet, row_data, break row_num = task['row_num'] - row_data = task['data'] + row_data = task['data'] # Verwende die direkt geladenen Daten für die Zeile try: # Rufe _process_single_row mit force_reeval=True auf self._process_single_row(row_num, row_data, process_wiki=True, process_chatgpt=True, process_website=True, - force_reeval=True) # WICHTIG! + force_reeval=True) # WICHTIG: Erzwingt Verarbeitung aller Schritte! processed_count += 1 - rows_actually_processed.append(row_num) + rows_actually_processed.append(row_num) # Füge Zeilennummer hinzu + # Vorbereiten des Updates zum Löschen des 'x'-Flags if clear_flag: flag_col_letter = self.sheet_handler._get_col_letter(reeval_col_idx + 1) - if flag_col_letter: + if flag_col_letter: # Prüfe, ob der Spaltenbuchstabe ermittelt werden konnte updates_clear_flag.append({'range': f'{flag_col_letter}{row_num}', 'values': [['']]}) + else: + logging.error(f"Fehler: Konnte Spaltenbuchstaben für 'ReEval Flag' nicht ermitteln.") + except Exception as e_proc: + # Logge den spezifischen Fehler für diese Zeile logging.exception(f"FEHLER bei Re-Evaluation von Zeile {row_num}: {e_proc}") + # Das 'x'-Flag wird in diesem Fall NICHT gelöscht, damit die Zeile erneut versucht werden kann. - # Lösche Flags am Ende + # Lösche Flags am Ende in einem Batch-Update if clear_flag and updates_clear_flag: logging.info(f"Lösche ReEval-Flags für {len(updates_clear_flag)} erfolgreich verarbeitete Zeilen ({rows_actually_processed})...") + # Annahme: sheet_handler.batch_update_cells existiert und nutzt logging/retry success = self.sheet_handler.batch_update_cells(updates_clear_flag) - if not success: + if success: + logging.info("ReEval-Flags erfolgreich gelöscht.") + else: + # Fehlermeldung wird von batch_update_cells geloggt logging.error("FEHLER beim Löschen der ReEval-Flags.") + logging.info(f"Re-Evaluierung abgeschlossen. {processed_count} Zeilen verarbeitet (Limit war: {row_limit}, Gefunden: {found_count}).") - # Methode für SERP API Website Lookup - def process_serp_website_lookup_for_empty(self): - """ Füllt fehlende Websites via SERP API. """ - logging.info("Starte Modus: SERP API Website Lookup für leere Zellen in Spalte D.") - if not self.sheet_handler.load_data(): return - data_rows = self.sheet_handler.get_data() - header_rows = 5 - rows_processed = 0 + # --- Methode für SERP API Website Lookup --- + # Diese Methode gehört in die Klasse + def process_serp_website_lookup_for_empty(self): + """ + Sucht fehlende Websites (Spalte D ist leer oder "k.A.") via SERP API + (Google Search) und trägt gefundene URLs in Spalte D ein. + """ + logging.info("Starte Modus: SERP API Website Lookup für leere Zellen in Spalte D.") + # Daten neu laden + if not self.sheet_handler.load_data(): + logging.error("Fehler beim Laden der Daten für Website Lookup.") + return + + data_rows = self.sheet_handler.get_data() # Datenzeilen ohne Header + header_rows = 5 # Annahme + total_rows_in_sheet = len(self.sheet_handler.get_all_data_with_headers()) # Gesamtzahl Zeilen + + rows_processed_count = 0 # Zählt Zeilen, wo ein Lookup versucht wurde + updates = [] + # Definiere die Spaltenindizes innerhalb der Methode try: website_col_idx = COLUMN_MAP["CRM Website"] name_col_idx = COLUMN_MAP["CRM Name"] website_col_letter = self.sheet_handler._get_col_letter(website_col_idx + 1) except KeyError as e: - logging.critical(f"FEHLER: Benötigte Spalte '{e}' für Modus nicht in COLUMN_MAP.") + logging.critical(f"FEHLER: Benötigte Spalte '{e}' für Modus 'website_lookup' nicht in COLUMN_MAP.") return except Exception as e: - logging.critical(f"FEHLER beim Holen der Spaltenbuchstaben: {e}") + logging.critical(f"FEHLER beim Holen der Spaltenbuchstaben für 'website_lookup': {e}") return - updates = [] - for i, row in enumerate(data_rows): - row_num_in_sheet = i + header_rows + 1 - current_website = "" - if len(row) > website_col_idx: current_website = row[website_col_idx] + # Iteriere über die Datenzeilen (ab der ersten möglichen Zeile, standard 7) + # min_start_row = 7 # Ggf. als Parameter übergeben + # search_start_data_index = max(0, min_start_row - header_rows - 1) - if not current_website or current_website.strip().lower() == "k.a.": - company_name = "" - if len(row) > name_col_idx: company_name = row[name_col_idx] - if not company_name: - logging.warning(f"Zeile {row_num_in_sheet}: Übersprungen (kein Firmenname für Lookup).") + # Annahme: Wir wollen alle Zeilen prüfen, nicht nur ab einer bestimmten + for i, row in enumerate(data_rows): + row_num_in_sheet = i + header_rows + 1 # 1-basierte Zeilennummer im Sheet + + # Sicherstellen, dass die Zeile lang genug ist, um auf die benötigten Spalten zuzugreifen + if len(row) <= max(website_col_idx, name_col_idx): + logging.debug(f"Zeile {row_num_in_sheet}: Übersprungen (Zeile zu kurz für benötigte Spalten).") + continue + + current_website = row[website_col_idx] if len(row) > website_col_idx else "" + + # Prüfe, ob die Website-Spalte (D) leer, "k.A." oder nur Whitespace ist + if not current_website or str(current_website).strip().lower() == "k.a.": + company_name = row[name_col_idx] if len(row) > name_col_idx else "" + if not company_name or str(company_name).strip() == "": + logging.warning(f"Zeile {row_num_in_sheet}: Übersprungen (kein Firmenname in Spalte B für Lookup vorhanden).") continue - logging.info(f"Zeile {row_num_in_sheet}: Suche Website für '{company_name}'...") - # Annahme: serp_website_lookup existiert und nutzt logging + logging.info(f"Zeile {row_num_in_sheet}: Suche Website für '{company_name}' in Spalte {self.sheet_handler._get_col_letter(name_col_idx+1)}...") + # Annahme: serp_website_lookup existiert und nutzt logging/retry new_website = serp_website_lookup(company_name) - time.sleep(getattr(Config, 'RETRY_DELAY', 5) * 0.3) + rows_processed_count += 1 # Zähle jede Zeile, für die ein Lookup versucht wurde if new_website != "k.A.": + # Füge Update für Spalte D hinzu updates.append({'range': f'{website_col_letter}{row_num_in_sheet}', 'values': [[new_website]]}) logging.info(f"Zeile {row_num_in_sheet}: Neue Website '{new_website}' gefunden und zum Update hinzugefügt.") - rows_processed += 1 else: - logging.info(f"Zeile {row_num_in_sheet}: Keine Website gefunden.") + # Optional: Markiere, dass kein Ergebnis gefunden wurde, falls nötig + # updates.append({'range': f'{website_col_letter}{row_num_in_sheet}', 'values': [['k.A. (kein SERP Ergebnis)']}) # Beispiel + logging.info(f"Zeile {row_num_in_sheet}: Keine Website via SERP gefunden.") + # Kleine Pause nach jedem SERP-Aufruf + time.sleep(getattr(Config, 'RETRY_DELAY', 5) * 0.3) + + + # Sende gesammelte Updates in einem Batch if updates: - logging.info(f"Sende Batch-Update für {rows_processed} gefundene Websites...") - self.sheet_handler.batch_update_cells(updates) + logging.info(f"Sende Batch-Update für {len(updates)} Zellen ({rows_processed_count} Zeilen geprüft)...") + # Annahme: sheet_handler.batch_update_cells existiert und nutzt logging/retry + success = self.sheet_handler.batch_update_cells(updates) + if success: + logging.info("Batch-Update für 'website_lookup' erfolgreich.") + # Der Fehlerfall wird von batch_update_cells geloggt else: - logging.info("Keine fehlenden Websites gefunden zum Aktualisieren.") - logging.info(f"Modus 'website_lookup' abgeschlossen. {rows_processed} Websites ergänzt.") + logging.info("Keine fehlenden Websites gefunden oder keine Updates nötig.") - # Methode für experimentelle Website Details + logging.info(f"Modus 'website_lookup' abgeschlossen. {rows_processed_count} Zeilen geprüft.") + + + # --- Methode für experimentelle Website Details --- + # Diese Methode gehört in die Klasse def process_website_details_for_marked_rows(self): - """ EXPERIMENTELL: Extrahiert Website-Details für markierte Zeilen. """ + """ + EXPERIMENTELL: Extrahiert Website-Details für Zeilen, die mit 'x' in Spalte A markiert sind. + Schreibt die Details in eine definierte Spalte (Website Details oder AR als Fallback). + Löscht NICHT das 'x'-Flag. + """ logging.info("Starte Modus (EXPERIMENTELL): Website Detail Extraction für Zeilen mit 'x' in Spalte A.") - if not self.sheet_handler.load_data(): return - data_rows = self.sheet_handler.get_data() - header_rows = 5 - rows_processed = 0 + # Daten neu laden + if not self.sheet_handler.load_data(): + logging.error("Fehler beim Laden der Daten für Website Details Extraction.") + return + + data_rows = self.sheet_handler.get_data() # Datenzeilen ohne Header + header_rows = 5 # Annahme + total_rows_in_sheet = len(self.sheet_handler.get_all_data_with_headers()) # Gesamtzahl Zeilen + + rows_processed_count = 0 # Zählt Zeilen, wo eine Extraktion versucht wurde + updates = [] + + # Definiere die Spaltenindizes try: reeval_col_idx = COLUMN_MAP["ReEval Flag"] website_col_idx = COLUMN_MAP["CRM Website"] - details_col_idx = COLUMN_MAP.get("Website Details", None) # Versuche neue Spalte oder Fallback + # Versuche zuerst die dedizierte Spalte 'Website Details' + details_col_idx = COLUMN_MAP.get("Website Details", None) if details_col_idx is None: - details_col_idx = COLUMN_MAP["Website Rohtext"] # Fallback auf AR - logging.warning("Keine Spalte 'Website Details' in COLUMN_MAP, nutze 'Website Rohtext' (AR).") + # Fallback auf 'Website Rohtext' (AR) wenn 'Website Details' nicht in COLUMN_MAP + details_col_idx = COLUMN_MAP.get("Website Rohtext") + if details_col_idx is None: + logging.critical("FEHLER: Weder 'Website Details' noch 'Website Rohtext' Spaltenindex in COLUMN_MAP gefunden.") + return + logging.warning("Keine Spalte 'Website Details' in COLUMN_MAP, nutze 'Website Rohtext' (AR) als Fallback.") + details_col_letter = self.sheet_handler._get_col_letter(details_col_idx + 1) + except KeyError as e: - logging.critical(f"FEHLER: Benötigte Spalte '{e}' für Modus nicht in COLUMN_MAP.") + logging.critical(f"FEHLER: Benötigte Spalte '{e}' für Modus 'website_details' nicht in COLUMN_MAP.") return except Exception as e: - logging.critical(f"FEHLER beim Holen der Spaltenbuchstaben: {e}") + logging.critical(f"FEHLER beim Holen der Spaltenbuchstaben für 'website_details': {e}") return - updates = [] + + # Iteriere über die Datenzeilen (ab der ersten möglichen Zeile, standard 7) for i, row in enumerate(data_rows): - row_num_in_sheet = i + header_rows + 1 - if len(row) > reeval_col_idx and row[reeval_col_idx].strip().lower() == "x": - website_url = "" - if len(row) > website_col_idx: website_url = row[website_col_idx] - if not website_url or website_url.strip().lower() == "k.a.": - logging.warning(f"Zeile {row_num_in_sheet}: Keine gültige Website in Spalte D vorhanden, überspringe.") - continue + row_num_in_sheet = i + header_rows + 1 # 1-basierte Zeilennummer im Sheet - logging.info(f"Zeile {row_num_in_sheet}: Extrahiere Website Details von {website_url}...") - try: - # Annahme: Funktion scrape_website_details existiert - details = scrape_website_details(website_url) - except NameError: - logging.error("Funktion 'scrape_website_details' ist nicht definiert!") - details = "FEHLER: Funktion nicht definiert" - except Exception as e_detail: - logging.exception(f"Fehler bei scrape_website_details für {website_url}: {e_detail}") - details = f"FEHLER: {e_detail}" + # Prüfen, ob die Zeile mit 'x' in Spalte A markiert ist + # Stelle sicher, dass die Zeile lang genug ist für Spalte A + if len(row) <= reeval_col_idx or str(row[reeval_col_idx]).strip().lower() != "x": + # Logging kann hier sehr laut sein, nur bei Bedarf aktivieren + # logging.debug(f"Zeile {row_num_in_sheet}: Übersprungen (Kein 'x' in Spalte A).") + continue - updates.append({'range': f'{details_col_letter}{row_num_in_sheet}', 'values': [[str(details)]]}) - rows_processed += 1 - time.sleep(getattr(Config, 'RETRY_DELAY', 5) * 0.2) + # Prüfen, ob eine gültige Website-URL vorhanden ist + website_url = "" + if len(row) > website_col_idx: website_url = row[website_col_idx] + if not website_url or str(website_url).strip().lower() == "k.a.": + logging.warning(f"Zeile {row_num_in_sheet}: Übersprungen (keine gültige Website in Spalte {self.sheet_handler._get_col_letter(website_col_idx+1)} vorhanden).") + continue + + logging.info(f"Zeile {row_num_in_sheet}: Extrahiere Website Details von {website_url}...") + rows_processed_count += 1 # Zähle jede Zeile, für die Extraktion versucht wird + + try: + # Annahme: Funktion scrape_website_details existiert + # Diese Funktion MUSS außerhalb der Klasse definiert sein, + # es sei denn, sie wird auch als Methode des DataProcessors gesehen. + # Angenommen, sie ist eine unabhängige Helper-Funktion. + details = scrape_website_details(website_url) + except NameError: + logging.critical("FEHLER: Funktion 'scrape_website_details' ist nicht definiert! Kann Details nicht extrahieren.") + details = "FEHLER: Funktion 'scrape_website_details' nicht definiert" + # Fehler hier abfangen, damit der Prozess nicht abstürzt, aber trotzdem loggen + except Exception as e_detail: + logging.exception(f"Fehler bei scrape_website_details für {website_url}: {e_detail}") + details = f"FEHLER: {e_detail}" + + + # Füge Update für die Details-Spalte hinzu + # Stelle sicher, dass der Wert in einen String konvertiert wird, falls scrape_website_details z.B. ein Dict zurückgibt + updates.append({'range': f'{details_col_letter}{row_num_in_sheet}', 'values': [[str(details)]]}) + logging.info(f"Zeile {row_num_in_sheet}: Details extrahiert und zum Update für Spalte {details_col_letter} hinzugefügt.") + + + # Kleine Pause nach jeder Extraktion + time.sleep(getattr(Config, 'RETRY_DELAY', 5) * 0.2) + + + # Sende gesammelte Updates in einem Batch if updates: - logging.info(f"Sende Batch-Update für {rows_processed} Detail-Extraktionen...") - self.sheet_handler.batch_update_cells(updates) + logging.info(f"Sende Batch-Update für {len(updates)} Zellen ({rows_processed_count} Zeilen geprüft)...") + # Annahme: sheet_handler.batch_update_cells existiert und nutzt logging/retry + success = self.sheet_handler.batch_update_cells(updates) + if success: + logging.info("Batch-Update für 'website_details' erfolgreich.") + # Der Fehlerfall wird von batch_update_cells geloggt else: - logging.info("Keine Zeilen mit 'x' gefunden für Detail-Extraktion.") - logging.info(f"Modus 'website_details' abgeschlossen. {rows_processed} Zeilen verarbeitet.") + logging.info("Keine mit 'x' markierten Zeilen gefunden oder keine Updates nötig.") - # Methode zur Datenvorbereitung für ML + logging.info(f"Modus 'website_details' abgeschlossen. {rows_processed_count} Zeilen geprüft.") + + + # --- Methode zur Datenvorbereitung für ML --- + # Diese Methode gehört in die Klasse def prepare_data_for_modeling(self): """ Lädt Daten aus dem Google Sheet über den sheet_handler, - bereitet sie für das Decision Tree Modell vor. + bereitet sie für das Decision Tree Modell vor: + - Wählt relevante Spalten aus. + - Konsolidiert Umsatz/Mitarbeiter (Wiki > CRM Priorität). + - Filert nach gültiger Technikerzahl (> 0). + - Erstellt die Zielvariable (Techniker-Bucket). + - Bereitet Features auf (One-Hot Encoding für Branche). + - Behält NaNs in numerischen Features für spätere Imputation. + + Args: + # Kein sheet_handler Parameter mehr nötig, da es eine Methode ist und self.sheet_handler nutzt + + Returns: + pandas.DataFrame: Vorbereiteter DataFrame für Training/Test-Split, + oder None bei Fehlern. """ logging.info("Starte Datenvorbereitung für Modellierung...") + # Nutze den self.sheet_handler der Klasse if not self.sheet_handler or not self.sheet_handler.sheet_values: - logging.error("Fehler: Sheet Handler nicht initialisiert oder keine Daten geladen für prepare_data.") + logging.error("Fehler: Sheet Handler nicht initialisiert oder keine Daten geladen für prepare_data_for_modeling.") + # Versuche die Daten einmalig innerhalb dieser Methode zu laden, falls sie fehlen if not self.sheet_handler.load_data(): - logging.critical("Konnte Daten auch nach erneutem Versuch nicht laden.") + logging.critical("Konnte Daten auch nach erneutem Versuch nicht laden. Abbruch der Datenvorbereitung.") return None - all_data = self.sheet_handler.sheet_values - if len(all_data) <= 5: - logging.error("Fehler: Nicht genügend Datenzeilen im Sheet gefunden für Modellierung.") + all_data = self.sheet_handler.get_all_data_with_headers() # Nutze die im Handler geladenen Daten + # Prüfe auf ausreichende Zeilenzahl (Header + mindestens eine Datenzeile) + min_required_rows = 6 # 5 Header + 1 Datenzeile + if not all_data or len(all_data) < min_required_rows: + logging.error(f"Fehler: Nicht genügend Datenzeilen ({len(all_data)}) im Sheet gefunden für Modellierung (mindestens {min_required_rows} benötigt).") return None try: + # Die erste Zeile sollte die Spaltennamen enthalten headers = all_data[0] + # Stelle sicher, dass die Header-Zeile auch die erwartete Mindestlänge hat, + # um die Spaltenindizes aus COLUMN_MAP zu finden + if len(headers) <= max(COLUMN_MAP.values()): + logging.critical(f"FEHLER: Header-Zeile ({len(headers)} Spalten) ist kürzer als der höchste Index in COLUMN_MAP ({max(COLUMN_MAP.values())}). COLUMN_MAP passt nicht zum Sheet.") + return None + except IndexError: - logging.critical("FEHLER: Sheet scheint leer zu sein, keine Header gefunden.") + logging.critical("FEHLER: Sheet scheint leer zu sein oder hat keine erste Zeile, keine Header gefunden.") return None - data_rows = all_data[5:] + except ValueError as e: + logging.critical(f"FEHLER: Ungültiger Wert in COLUMN_MAP. Kann max Index nicht ermitteln: {e}") + return None + except Exception as e: + logging.critical(f"FEHLER beim Zugriff auf Header oder Prüfen der Spaltenlänge: {e}") + return None + + data_rows = all_data[5:] # Annahme: Die ersten 5 Zeilen sind Header + + # Erstelle DataFrame df = pd.DataFrame(data_rows, columns=headers) - logging.info(f"DataFrame für Modellierung erstellt mit {len(df)} Zeilen und {len(df.columns)} Spalten.") + logging.info(f"Initialen DataFrame für Modellierung erstellt mit {len(df)} Zeilen und {len(df.columns)} Spalten.") + + # --- Spaltenauswahl und Umbenennung --- + # Definiere die notwendigen Spalten und ihre gewünschten Namen im DataFrame + col_keys = { + "name": "CRM Name", # Zur Identifikation, wird später entfernt + "branche": "CRM Branche", # Für One-Hot Encoding + "umsatz_crm": "CRM Umsatz", # Für Konsolidierung + "umsatz_wiki": "Wiki Umsatz", # Für Konsolidierung + "ma_crm": "CRM Anzahl Mitarbeiter", # Für Konsolidierung + "ma_wiki": "Wiki Mitarbeiter", # Für Konsolidierung + "techniker": "CRM Anzahl Techniker" # DIE ZIELVARIABLE (Bekannte Technikerzahl) + } + + # Überprüfe, ob alle benötigten Spalten in der COLUMN_MAP vorhanden sind + missing_keys = [key for key in col_keys.values() if key not in COLUMN_MAP] + if missing_keys: + logging.critical(f"FEHLER: Folgende benötigte Spalten-Schlüssel fehlen in COLUMN_MAP für prepare_data_for_modeling: {missing_keys}.") + return None + + # Erstelle das Mapping von Header-Namen zu internen Schlüsseln + header_to_internal_key = {headers[COLUMN_MAP[key]]: internal_key for internal_key, key in col_keys.items()} + + # Wähle nur die benötigten Spalten im DataFrame aus + # Verwende die tatsächlichen Header-Namen aus dem Sheet für die Auswahl + cols_to_select_by_header = [headers[COLUMN_MAP[key]] for key in col_keys.values()] try: - col_keys = { - "name": "CRM Name", "branche": "CRM Branche", "umsatz_crm": "CRM Umsatz", - "umsatz_wiki": "Wiki Umsatz", "ma_crm": "CRM Anzahl Mitarbeiter", - "ma_wiki": "Wiki Mitarbeiter", "techniker": "CRM Anzahl Techniker" - } - col_indices = {key: COLUMN_MAP[val] for key, val in col_keys.items()} - cols_to_select = [headers[idx] for idx in col_indices.values()] - rename_map = {headers[idx]: key for key, idx in col_indices.items()} + df_subset = df[cols_to_select_by_header].copy() # Kopie erstellen + # Benenne die Spalten um + df_subset.rename(columns=header_to_internal_key, inplace=True) except KeyError as e: - logging.critical(f"FEHLER: Konnte Mapping für Schlüssel '{e}' nicht in COLUMN_MAP finden.") + # Dieser Fehler sollte eigentlich durch die obige Prüfung abgefangen werden, + # aber zur Sicherheit ein weiterer Check + logging.critical(f"FEHLER beim Auswählen/Umbenennen der Spalten (KeyError: {e}). Verfügbare Header im DF: {list(df.columns)}") return None - except IndexError as e: - logging.critical(f"FEHLER: Spaltenindex aus COLUMN_MAP ({e}) außerhalb der Grenzen der Header-Zeile ({len(headers)} Spalten). Prüfe COLUMN_MAP!") + except Exception as e: + logging.critical(f"Unerwarteter FEHLER beim Auswählen/Umbenennen der Spalten: {e}") return None - try: - df_subset = df[cols_to_select].copy() - df_subset.rename(columns=rename_map, inplace=True) - except KeyError as e: - logging.critical(f"FEHLER beim Auswählen/Umbenennen der Spalten: {e}. Verfügbare Spalten: {list(df.columns)}") - return None logging.info(f"Benötigte Spalten für Modellierung ausgewählt und umbenannt: {list(df_subset.columns)}") - # --- Konsolidierung --- + # --- Features konsolidieren (Umsatz, Mitarbeiter) --- + # Annahme: extract_numeric_value existiert und kann pd.Series verarbeiten (oder wird per apply genutzt) + # (Ihre Implementierung nutzt apply, was korrekt ist) def get_valid_numeric(value_str): - # ... (Implementierung wie zuletzt, mit Apostroph-Entfernung) ... + """Hilfsfunktion zur sicheren Konvertierung mit Fehlerbehandlung.""" if value_str is None or pd.isna(value_str) or str(value_str).strip() == '': return np.nan - original_value = value_str + # Annahme: extract_numeric_value existiert und gibt string 'k.A.' oder Zahl-String zurück + # Wir brauchen aber einen numerischen Wert oder np.nan try: - cleaned_str = str(value_str).replace('.', '').replace("'", "").replace(',', '.') - val = float(cleaned_str) - return val if val > 0 else np.nan - except (ValueError, TypeError): - logging.debug(f"Konntze Wert '{original_value}' nicht direkt in Float umwandeln.") - cleaned_str = re.sub(r'[^\d.]', '', str(value_str)) - 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 + # Versuche direkt die logik aus extract_numeric_value hier zu verwenden + raw_value_str = str(value_str) + processed_value = clean_text(raw_value_str) # Annahme: clean_text existiert + if processed_value == "k.A.": return np.nan + + # Anpassung hier: Entferne auch Apostroph (tausendertrenner) + 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() + # Split bei Bindestrich (Umsatzspanne), nur ersten Teil nehmen + processed_value = re.split(r'\s*(-|–|bis)\s*', processed_value, 1)[0].strip() + processed_value = processed_value.replace('.', '').replace("'", "") # Entferne Punkte UND Apostrophe als Tausendertrenner + processed_value = processed_value.replace(',', '.') # Ersetze Komma durch Punkt für Dezimaltrennung + + match = re.search(r'([\d.]+)', processed_value) + if not match: return np.nan # Keine numerischen Zeichen gefunden + + num_str = match.group(1) + # Zusätzliche Prüfung: String darf nicht nur ein Punkt sein + if not num_str or num_str == '.': return np.nan + + num = float(num_str) # Konvertiere zum float + + # --- Einheiten-Multiplikatoren (Mrd, Mio, Tsd) --- + multiplier = 1.0 + original_lower = raw_value_str.lower() # Nutze den Originalstring für Einheiten + if "mrd" in original_lower or "milliarden" in original_lower or "billion" in original_lower: multiplier = 1000000000.0 + elif "mio" in original_lower or "millionen" in original_lower or "mill." in original_lower: multiplier = 1000000.0 + elif "tsd" in original_lower or "tausend" in original_lower: multiplier = 1000.0 + + num = num * multiplier + + # Optional: Runden auf ganze Zahlen für Mitarbeiter, Umsatz in Mio. + # Die extract_numeric_value Funktion hat das gemacht. Hier brauchen wir rohe Zahlen für Imputation. + # Also einfach den num zurückgeben + return num if num > 0 else np.nan # Nur positive Werte sind gültig + + except (ValueError, TypeError) as e: + # Logge auf DEBUG, da dies oft vorkommt + # logging.debug(f"Konntze Wert '{str(value_str)[:50]}...' nicht als gültige Zahl parsen: {e}") + return np.nan + except Exception as e: + logging.warning(f"Unerwarteter Fehler in get_valid_numeric für Wert '{str(value_str)[:50]}...': {e}") + return np.nan + cols_to_process = { 'Umsatz': ('umsatz_wiki', 'umsatz_crm', 'Finaler_Umsatz'), 'Mitarbeiter': ('ma_wiki', 'ma_crm', 'Finaler_Mitarbeiter') } + for base_name, (wiki_col, crm_col, final_col) in cols_to_process.items(): logging.info(f"Verarbeite und konsolidiere '{base_name}' (Priorität: Wiki > CRM)...") - if wiki_col not in df_subset.columns: df_subset[wiki_col] = np.nan - if crm_col not in df_subset.columns: df_subset[crm_col] = np.nan - wiki_numeric = df_subset[wiki_col].apply(get_valid_numeric) - crm_numeric = df_subset[crm_col].apply(get_valid_numeric) - df_subset[final_col] = np.where(wiki_numeric.notna(), wiki_numeric, crm_numeric) - logging.info(f" -> {df_subset[final_col].notna().sum()} gültige '{final_col}' Werte erstellt.") + # Sicherstellen, dass Spalten existieren (get_valid_numeric behandelt None) + wiki_series = df_subset[wiki_col].apply(get_valid_numeric) if wiki_col in df_subset.columns else pd.Series(np.nan, index=df_subset.index) + crm_series = df_subset[crm_col].apply(get_valid_numeric) if crm_col in df_subset.columns else pd.Series(np.nan, index=df_subset.index) - # --- Zielvariable vorbereiten --- - techniker_col = "techniker" + # np.where wählt den ersten Wert, wenn er nicht NaN ist, sonst den zweiten + df_subset[final_col] = np.where( + wiki_series.notna(), + wiki_series, + crm_series + ) + # Info-Log über Ergebnis + logging.info(f" -> {df_subset[final_col].notna().sum()} gültige '{final_col}' Werte erstellt (von {len(df_subset)} Zeilen).") + + # --- Zielvariable vorbereiten (Technikerzahl) --- + techniker_col = "techniker" # Interne Spaltenname nach Umbenennung logging.info(f"Verarbeite Zielvariable '{techniker_col}'...") + + # Konvertiere zu Numerisch (Fehler -> NaN) + # Sicherstellen, dass die Spalte existiert + if techniker_col not in df_subset.columns: + logging.critical(f"FEHLER: Zielvariable '{techniker_col}' (CRM Anzahl Techniker) nicht im DataFrame gefunden nach Umbenennung.") + return None + df_subset['Anzahl_Servicetechniker_Numeric'] = pd.to_numeric(df_subset[techniker_col], errors='coerce') + + # Filtere Zeilen: Behalte nur die mit gültiger, positiver Technikerzahl initial_rows = len(df_subset) - df_filtered = df_subset[df_subset['Anzahl_Servicetechniker_Numeric'].notna() & (df_subset['Anzahl_Servicetechniker_Numeric'] > 0)].copy() + df_filtered = df_subset[ + df_subset['Anzahl_Servicetechniker_Numeric'].notna() & + (df_subset['Anzahl_Servicetechniker_Numeric'] > 0) + ].copy() # WICHTIG: .copy() um SettingWithCopyWarning zu vermeiden filtered_rows = len(df_filtered) removed_rows = initial_rows - filtered_rows - if removed_rows > 0: logging.info(f"{removed_rows} Zeilen entfernt (fehlende/ungültige Technikerzahl).") - logging.info(f"Verbleibende Zeilen für Modellierung: {filtered_rows}") - if filtered_rows == 0: logging.error("FEHLER: Keine Zeilen mit gültiger Technikerzahl übrig!"); return None + # 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 (mit gültiger Technikerzahl > 0): {filtered_rows}") + + if filtered_rows == 0: + logging.error("FEHLER: Keine Zeilen mit gültiger Technikerzahl (>0) übrig für Modellierung!") + return None # --- Techniker-Buckets erstellen --- + # Die Bins und Labels müssen die gefilterten Daten widerspiegeln (die jetzt alle > 0 sind) + # Wenn die Buckets 0 beinhalten, muss die Bin-Definition angepasst werden. + # Aktuelle Definition: [-1, 0, 19, 49, 99, 249, 499, float('inf')] + # labels = ['Bucket_1_(0)', 'Bucket_2_(<20)', 'Bucket_3_(<50)', 'Bucket_4_(<100)', 'Bucket_5_(<250)', 'Bucket_6_(<500)', 'Bucket_7_(>499)'] + # Da wir auf > 0 filtern, wird Bucket_1_(0) nie erreicht. + # Bins und Labels anpassen, wenn 0 ignoriert wird? + # Nein, die Labels repräsentieren Bereiche, auch wenn ein Bereich im Trainingsset nicht vorkommt. + # Wichtig ist, dass die Bins Sinn ergeben. -1 bis 0 fängt 0, 0 bis 19 fängt 1-19 etc. + # Wenn wir auf >0 filtern, wird alles < 19 in den 2. Bucket fallen, alles >=1 und <20. + # Die Bin-Definition [-1, 0, 19, 49, ...] bedeutet eigentlich: + # (-1, 0] -> <= 0 + # (0, 19] -> >0 und <= 19 + # (19, 49] -> >19 und <= 49 + # ... + # Passt zur Filterung > 0. bins = [-1, 0, 19, 49, 99, 249, 499, float('inf')] labels = ['Bucket_1_(0)', 'Bucket_2_(<20)', 'Bucket_3_(<50)', 'Bucket_4_(<100)', 'Bucket_5_(<250)', 'Bucket_6_(<500)', 'Bucket_7_(>499)'] - df_filtered['Techniker_Bucket'] = pd.cut(df_filtered['Anzahl_Servicetechniker_Numeric'], bins=bins, labels=labels, right=True) + # Ensure labels match expected categories if buckets are used differently + # For DecisionTree classification, the target should be discrete labels. + # Let's assume the labels are the desired outcome categories. + df_filtered['Techniker_Bucket'] = pd.cut( + df_filtered['Anzahl_Servicetechniker_Numeric'], + bins=bins, + labels=labels, + right=True, # Das Intervall ist (linker, rechter]. also (0, 19] + include_lowest=True # Wenn bins mit -1 starten, inkludiere den niedrigsten Wert (nicht relevant bei >0 Filterung) + ) logging.info("Techniker-Buckets erstellt.") - logging.debug(f"Verteilung der Buckets:\n{df_filtered['Techniker_Bucket'].value_counts(normalize=True).round(3)}") + # Verteilung als Info-Log + logging.info(f"Verteilung der Techniker-Buckets im Trainingsdatensatz:\n{df_filtered['Techniker_Bucket'].value_counts(normalize=True).round(3)}") + # Prüfe, ob NaNs in Buckets erstellt wurden (sollte bei >0 Filterung nicht passieren) + if df_filtered['Techniker_Bucket'].isna().any(): + logging.warning("WARNUNG: NaNs in Techniker-Buckets erstellt. Überprüfen Sie die bins/labels und die Filterung.") + # Optional: Zeilen mit NaN im Bucket entfernen + df_filtered.dropna(subset=['Techniker_Bucket'], inplace=True) + logging.info(f"Nach Entfernung von NaN Buckets: {len(df_filtered)} Zeilen verbleiben.") + if len(df_filtered) == 0: + logging.error("FEHLER: Keine Zeilen übrig nach Entfernung von NaN Buckets.") + return None - # --- Kategoriale Features (Branche) --- - branche_col = "branche" + + # --- Kategoriale Features vorbereiten (Branche) --- + branche_col = "branche" # Interne Spaltenname logging.info(f"Verarbeite kategoriales Feature '{branche_col}' für One-Hot Encoding...") - df_filtered[branche_col] = df_filtered[branche_col].astype(str).fillna('Unbekannt').str.strip() - df_encoded = pd.get_dummies(df_filtered, columns=[branche_col], prefix='Branche', dummy_na=False) - logging.info(f"One-Hot Encoding für '{branche_col}' durchgeführt.") - # --- Finale Auswahl --- - feature_columns = [col for col in df_encoded.columns if col.startswith('Branche_')] - feature_columns.extend(['Finaler_Umsatz', 'Finaler_Mitarbeiter']) - target_column = 'Techniker_Bucket' - original_data_cols = ['name', 'Anzahl_Servicetechniker_Numeric'] + # Stelle sicher, dass die Spalte String ist und fülle evtl. NaNs mit 'Unbekannt' + if branche_col not in df_filtered.columns: + logging.warning(f"Spalte '{branche_col}' nicht im DataFrame, One-Hot Encoding wird übersprungen.") + # Erstelle eine leere Spalte oder überspringe die One-Hot Encoding + # Lassen Sie es hier abstürzen, da Branche ein wichtiges Feature ist. + logging.critical(f"FEHLER: Spalte '{branche_col}' nicht im DataFrame für One-Hot Encoding gefunden.") + return None + + df_filtered[branche_col] = df_filtered[branche_col].astype(str).fillna('Unbekannt').str.strip() # .str.strip() hinzugefügt + + # One-Hot Encoding + # dummy_na=False, da wir NaNs gefüllt haben. + # prefix='Branche' ist gut. + df_encoded = pd.get_dummies(df_filtered, columns=[branche_col], prefix='Branche', dummy_na=False) + logging.info(f"One-Hot Encoding für '{branche_col}' durchgeführt. Neue Spaltenanzahl: {len(df_encoded.columns)}") + + # --- Finale Auswahl der Features für das Modell --- + # Merke dir die Feature-Spalten, die tatsächlich für das Training verwendet werden sollen + feature_columns = [col for col in df_encoded.columns if col.startswith('Branche_')] # Alle One-Hot Branch-Spalten + feature_columns.extend(['Finaler_Umsatz', 'Finaler_Mitarbeiter']) # Hinzufügen der numerischen Features + + # Prüfe, ob die Final-Spalten existieren (sollten sie, wurden oben erstellt) + if not all(col in df_encoded.columns for col in ['Finaler_Umsatz', 'Finaler_Mitarbeiter']): + logging.critical("FEHLER: Konsolidierte numerische Spalten 'Finaler_Umsatz' oder 'Finaler_Mitarbeiter' fehlen im DataFrame.") + return None + + + target_column = 'Techniker_Bucket' # Zielvariable + + # Erstelle den finalen DataFrame mit den Features, dem Target und Identifikationsspalten + # Behalte Originaldaten (Name, tatsächliche Technikerzahl) für spätere Analyse / Zuordnung + original_data_cols = ['name', 'Anzahl_Servicetechniker_Numeric'] # 'name' nach Umbenennung + + # Stelle sicher, dass die original_data_cols auch existieren + if not all(col in df_encoded.columns for col in original_data_cols): + logging.critical(f"FEHLER: Originaldaten-Spalten {original_data_cols} fehlen im DataFrame.") + return None + + df_model_ready = df_encoded[original_data_cols + feature_columns + [target_column]].copy() - for col in ['Finaler_Umsatz', 'Finaler_Mitarbeiter']: - df_model_ready[col] = pd.to_numeric(df_model_ready[col], errors='coerce') + + # Optional: Konvertiere numerische Spalten explizit zu Float64 + for col in ['Finaler_Umsatz', 'Finaler_Mitarbeiter', 'Anzahl_Servicetechniker_Numeric']: + if col in df_model_ready.columns: # Sicherheitscheck + df_model_ready[col] = pd.to_numeric(df_model_ready[col], errors='coerce') # errors='coerce' wandelt Fehler in NaN + + # Reset Index für saubere Verarbeitung im nächsten Schritt (z.B. Train/Test-Split) df_model_ready = df_model_ready.reset_index(drop=True) logging.info("Datenvorbereitung für Modellierung abgeschlossen.") + logging.info(f"Finaler DataFrame für Modellierung hat {len(df_model_ready)} Zeilen und {len(df_model_ready.columns)} Spalten.") + # Logge die Anzahl der Feature-Spalten, nicht die Liste + logging.info(f"Anzahl Feature-Spalten: {len(feature_columns)}") + logging.info(f"Ziel-Spalte: {target_column}") + + # WICHTIG: Info über fehlende Werte in den finalen numerischen Features vor Imputation nan_counts = df_model_ready[['Finaler_Umsatz', 'Finaler_Mitarbeiter']].isna().sum() logging.info(f"Fehlende Werte in numerischen Features vor Imputation:\n{nan_counts}") + # Logge auch, wie viele Zeilen *mindestens* einen NaN haben + rows_with_nan = df_model_ready[['Finaler_Umsatz', 'Finaler_Mitarbeiter']].isna().any(axis=1).sum() + logging.info(f"Anzahl Zeilen mit mindestens einem fehlenden numerischen Feature: {rows_with_nan}") + return df_model_ready + # --- Methode für sequenzielle Verarbeitung (full_run) --- + # Diese Methode gehört in die Klasse + def process_rows_sequentially(self, start_data_index, num_to_process, + process_wiki=True, process_chatgpt=True, process_website=True): + """ + Verarbeitet eine feste Anzahl von Zeilen beginnend bei einem bestimmten Datenindex + sequenziell, eine nach der anderen, unter Verwendung von _process_single_row. + Prüft KEINE Timestamps oder ReEval-Flags intern, _process_single_row tut dies. + + Args: + start_data_index (int): Der 0-basierte Index in der Datenliste (ohne Header). + num_to_process (int): Die maximale Anzahl der zu verarbeitenden Zeilen. + process_wiki (bool, optional): Soll Wiki-Verarbeitung durchgeführt werden?. Defaults to True. + process_chatgpt (bool, optional): Sollen ChatGPT-Evaluationen durchgeführt werden?. Defaults to True. + process_website (bool, optional): Soll Website-Verarbeitung durchgeführt werden?. Defaults to True. + """ + header_rows = 5 # Annahme + + logging.info(f"Starte sequenzielle Verarbeitung von {num_to_process} Zeilen ab Daten-Index {start_data_index}...") + + # Lade Daten einmalig vor der Verarbeitung + if not self.sheet_handler.load_data(): + logging.error("Fehler beim Laden der Daten für sequenzielle Verarbeitung.") + return + + all_data = self.sheet_handler.get_all_data_with_headers() + total_data_rows = len(all_data) - header_rows + + if start_data_index >= total_data_rows: + logging.warning(f"Start-Datenindex {start_data_index} liegt außerhalb der verfügbaren Daten ({total_data_rows} Datenzeilen). Keine Verarbeitung.") + return + + # Berechne den tatsächlichen End-Datenindex (exklusiv) + end_data_index = min(start_data_index + num_to_process, total_data_rows) + + logging.info(f"Sequenzielle Verarbeitung: Daten-Index Bereich [{start_data_index}, {end_data_index})") + # Übersetze in Sheet-Zeilennummern für Logging + start_sheet_row = start_data_index + header_rows + 1 + end_sheet_row_inclusive = end_data_index + header_rows # Das Ende ist exklusiv, also ist die letzte Zeile am Index end_data_index-1 + + logging.info(f"Entsprechende Sheet-Zeilen (1-basiert): {start_sheet_row} bis {end_sheet_row_inclusive}") + + + processed_count = 0 + # Iteriere über die Datenzeilen im angegebenen Bereich + for i in range(start_data_index, end_data_index): + row_num_in_sheet = i + header_rows + 1 # 1-basierte Zeilennummer + row_data = all_data[i + header_rows] # Tatsächliche Zeilendaten aus der Gesamtliste + + try: + # Rufe die Methode zur Verarbeitung einer einzelnen Zeile auf + # _process_single_row wird intern die Timestamps prüfen (außer force_reeval) + self._process_single_row(row_num_in_sheet, row_data, + process_wiki=process_wiki, + process_chatgpt=process_chatgpt, + process_website=process_website, + force_reeval=False) # Im full_run Modus normalerweise KEIN Re-Eval erzwingen + + processed_count += 1 + + except Exception as e_proc: + # Logge den spezifischen Fehler für diese Zeile, fahre aber fort + logging.exception(f"FEHLER bei sequenzieller Verarbeitung von Zeile {row_num_in_sheet}: {e_proc}") + # Optional: Hier könnte man ein Flag in der Zeile setzen, um den Fehler zu markieren + + logging.info(f"Sequenzielle Verarbeitung abgeschlossen. {processed_count} Zeilen verarbeitet im Bereich [{start_data_index}, {end_data_index}).") + # ==================== MAIN FUNCTION ==================== # ==================== MAIN FUNCTION ====================