From 7eb4c57f028b290c9f1cfdbb7e69dc82ab7b9c44 Mon Sep 17 00:00:00 2001 From: Floke Date: Sun, 11 May 2025 18:43:33 +0000 Subject: [PATCH] v1.7.3: Code-Duplikation in DataProcessor entfernt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Entfernung redundanter Methodendefinitionen am Ende der DataProcessor-Klasse. - Betroffene Methoden: _predict_technician_bucket, _load_ml_model, prepare_data_for_modeling, train_technician_model, process_website_details, process_wiki_updates_from_chatgpt, process_wiki_reextract_missing_an. - Redundanten lokalen Import von 'openai' in Config.load_api_keys entfernt. Der globale Import ist ausreichend. - Temporären Platzhalter für die Validierung von ChatGPT-Wiki-URL-Vorschlägen in 'process_wiki_updates_from_chatgpt' durch Aufruf von 'is_valid_wikipedia_article_url' ersetzt. --- brancheneinstufung.py | 1648 +---------------------------------------- 1 file changed, 10 insertions(+), 1638 deletions(-) diff --git a/brancheneinstufung.py b/brancheneinstufung.py index e549c614..d20d3e88 100644 --- a/brancheneinstufung.py +++ b/brancheneinstufung.py @@ -140,17 +140,18 @@ class Config: @classmethod def load_api_keys(cls): """Laedt API-Schluessel aus den definierten Dateien.""" - # Verwenden Sie print, da der Logger hier noch nicht vollstaendig konfiguriert ist. print("Lade API-Schluessel...") cls.API_KEYS['openai'] = cls._load_key_from_file(API_KEY_FILE) cls.API_KEYS['serpapi'] = cls._load_key_from_file(SERP_API_KEY_FILE) cls.API_KEYS['genderize'] = cls._load_key_from_file(GENDERIZE_API_KEY_FILE) - # Stelle sicher, dass das 'openai' Modul im Scope dieser Methode bekannt ist - import openai # Importiere hier, um sicherzustellen, dass es verfuegbar ist + # import openai # <--- DIESER IMPORT IST NUN ENTFERNT if cls.API_KEYS.get('openai'): - # Setze den OpenAI API Key global fuer die Bibliothek + # Stelle sicher, dass das 'openai' Modul hier im Scope ist, + # indem wir auf den globalen Import zugreifen. + # Da 'openai' schon global importiert wurde (ganz oben im Skript), + # ist es hier direkt verfügbar. openai.api_key = cls.API_KEYS['openai'] print("OpenAI API Key erfolgreich geladen.") else: @@ -8923,24 +8924,14 @@ class DataProcessor: # Wenn sich der Vorschlag U von der aktuellen M-URL unterscheidet if condition2_u_differs_m: - self.logger.debug(f" -> Vorschlag U ({new_url[:100]}...) unterscheidet sich von M ({url_m[:100]}). Pruefe Validitaet...") # <<< GEÄNDERT - # Kriterium 3: Ist die vorgeschlagene URL ein valider Wikipedia-Artikel (nicht Weiterleitung, Begriffsklaerung, Fehler)? - # Nutzt globale Funktion is_valid_wikipedia_article_url (Block 12) mit Retry Decorator (Block 2). - # is_valid_wikipedia_article_url wirft Exception bei endgueltigem Fehler. + self.logger.debug(f" -> Vorschlag U ({new_url[:100]}...) unterscheidet sich von M ({url_m[:100]}). Pruefe Validitaet...") try: - # is_valid_wikipedia_article_url ist keine vorhandene Funktion, dies muss ggf. angepasst werden - # Annahme: Wir brauchen eine Funktion, die prüft, ob eine URL zu einem validen Artikel führt. - # Wir könnten hier die search_company_article Methode vom scraper nutzen und prüfen, ob sie die URL zurückgibt. - # Temporär setzen wir es auf True für den Logikfluss, dies muss später überarbeitet werden! - # BESSERE LÖSUNG: WikipediaScraper braucht eine Methode check_article_validity(url) - condition3_u_is_valid = True # TEMPORÄRER PLATZHALTER! - # if self.wiki_scraper: # Prüfe ob scraper existiert - # condition3_u_is_valid = self.wiki_scraper.check_article_validity(new_url) # Beispiel für zukünftige Methode - # else: condition3_u_is_valid = False + # Nutze die globale Funktion 'is_valid_wikipedia_article_url' (definiert in Block 12) + # Diese Funktion ist bereits mit @retry_on_failure dekoriert. + condition3_u_is_valid = is_valid_wikipedia_article_url(new_url, lang=getattr(Config, 'LANG', 'de')) # lang Argument hinzugefügt für Konsistenz - # Wenn die vorgeschlagene URL ein valider Artikel ist if condition3_u_is_valid: - is_update_candidate = True # Alle Kriterien erfuellt! Der Vorschlag kann uebernommen werden. + is_update_candidate = True self.logger.debug(f" -> URL '{new_url[:100]}...' ist ein VALIDER Artikel laut API Check.") # <<< GEÄNDERT else: # Wenn die vorgeschlagene URL nicht valide ist @@ -9928,1625 +9919,6 @@ if __name__ == '__main__': main() - # ========================================================================== - # === Utility Methods (ML Data Prep & Training) ============================ - # ========================================================================== - - # --- Methode fuer ML Vorhersage (AU) --- - # Diese Methode wird in _process_single_row (Block 21) aufgerufen, wenn der ML-Schritt angefordert ist und noetig ist. - # Sie fuehrt eine Vorhersage des Servicetechniker-Buckets fuer eine einzelne Zeile mit dem trainierten ML-Modell durch. - # Sie nutzt das geladene Modell und den Imputer (Attribute der DataProcessor Instanz). - # Nutzt interne Helfer: _get_cell_value_safe, _load_ml_model (denselben Block). - # Nutzt globale Helfer: COLUMN_MAP (Block 1), logger, pandas, numpy, re, clean_text (Block 4), get_valid_numeric (Block 5). - def _predict_technician_bucket(self, row_data): - """ - Fuehrt eine Vorhersage des Servicetechniker-Buckets fuer eine einzelne Zeile - mit dem trainierten ML-Modell durch. Laedt das Modell und den Imputer bei Bedarf. - - Args: - row_data (list): Die Rohdaten fuer die Zeile. - - Returns: - str: Der vorhergesagte Bucket-Label oder "FEHLER Schaetzung" bei Fehler/kein Ergebnis. - """ - # Verwenden Sie logger, da das Logging jetzt konfiguriert ist - # Logge den Start der ML-Schaetzung fuer diese Zeile - company_name = self._get_cell_value_safe(row_data, 'CRM Name').strip() # Block 1 Column Map - self.logger.debug(f"Versuche ML-Schaetzung fuer Zeile ({company_name[:50]}...)") # Gekuerzt loggen - - # Laden Sie das Modell, den Imputer und die erwarteten Feature-Spalten, falls noch nicht geschehen. - # Diese werden als Attribute der DataProcessor Instanz gespeichert (_load_ml_model denselben Block). - if self.model is None or self.imputer is None or self._expected_features is None: - self.logger.info("Lade ML-Modell, Imputer und Feature-Spalten...") - try: - # Der Aufruf von _load_ml_model (denselben Block) ist nicht mit retry_on_failure dekoriert, - # da das Laden lokaler Dateien nicht wiederholt werden muss. Fehler deuten auf ein permanentes Problem hin. - self._load_ml_model(MODEL_FILE, IMPUTER_FILE) # Nutzt globale Konstanten (Block 1) - - # Pruefe erneut, ob das Laden erfolgreich war. - if self.model is None or self.imputer is None or self._expected_features is None: - self.logger.error("Laden von Modell, Imputer oder Feature-Spalten fehlgeschlagen. Kann ML-Schaetzung nicht durchfuehren.") - return "FEHLER Schaetzung" # Gebe Fehlerwert zurueck, wenn Laden fehlschlug - - self.logger.info("ML-Modell, Imputer und Feature-Spalten erfolgreich geladen.") - - except Exception as e: - # Fange Fehler beim Laden ab und logge sie. - self.logger.error(f"FEHLER beim Laden von ML-Modell/Imputer/Feature-Spalten: {e}") - # Logge den Traceback. - self.logger.debug(traceback.format_exc()) - # Geben Sie einen Fehlerwert zurueck. - return f"FEHLER Laden: {str(e)[:100]}..." # Signalisiert Ladefehler (gekuerzt) - - - # --- Bereiten Sie die Daten fuer DIESE EINE ZEILE fuer die Vorhersage vor --- - try: - # Diese Logik ist aehnlich wie in prepare_data_for_modeling (denselben Block), - # aber nur fuer eine einzelne Zeile und muss mit den exakt gleichen - # Spaltennamen, Normalisierungs- und Encoding-Schritten arbeiten wie das Training. - - # Hole die benoetigten Spaltenwerte fuer diese Zeile (basierend auf COLUMN_MAP keys Block 1) - row_values = { - # "CRM Name": self._get_cell_value_safe(row_data, "CRM Name"), # Nicht benoetigt fuer Vorhersage - "CRM Branche": self._get_cell_value_safe(row_data, "CRM Branche"), # Block 1 Column Map - "CRM Umsatz": self._get_cell_value_safe(row_data, "CRM Umsatz"), # Block 1 Column Map - "Wiki Umsatz": self._get_cell_value_safe(row_data, "Wiki Umsatz"), # Block 1 Column Map - "CRM Anzahl Mitarbeiter": self._get_cell_value_safe(row_data, "CRM Anzahl Mitarbeiter"), # Block 1 Column Map - "Wiki Mitarbeiter": self._get_cell_value_safe(row_data, "Wiki Mitarbeiter"), # Block 1 Column Map - # Technikerzahl wird fuer die Vorhersage NICHT benoetigt - # "CRM Anzahl Techniker": self._get_cell_value_safe(row_data, "CRM Anzahl Techniker"), - } - - # Erstellen Sie einen temporaeren DataFrame fuer diese eine Zeile aus den extrahierten Werten - df_single_row = pd.DataFrame([row_values]) - - - # --- Konsolidieren Umsatz/Mitarbeiter (Wiki > CRM) --- - # Nutzt globale Funktion get_valid_numeric (Block 5) fuer die Konvertierung. - # Diese Funktion gibt numerische Werte (Float/Int) oder NaN zurueck. - # Stellen Sie sicher, dass die Spalten existieren, bevor apply aufgerufen wird. - # Diese Spalten sollten aus row_values extrahiert worden sein, wenn COLUMN_MAP korrekt ist. - crm_umsatz_series = df_single_row['CRM Umsatz'].apply(get_valid_numeric) if 'CRM Umsatz' in df_single_row.columns else pd.Series(np.nan, index=df_single_row.index) - wiki_umsatz_series = df_single_row['Wiki Umsatz'].apply(get_valid_numeric) if 'Wiki Umsatz' in df_single_row.columns else pd.Series(np.nan, index=df_single_row.index) - crm_ma_series = df_single_row['CRM Anzahl Mitarbeiter'].apply(get_valid_numeric) if 'CRM Anzahl Mitarbeiter' in df_single_row.columns else pd.Series(np.nan, index=df_single_row.index) - wiki_ma_series = df_single_row['Wiki Mitarbeiter'].apply(get_valid_numeric).astype(float) if 'Wiki Mitarbeiter' in df_single_row.columns else pd.Series(np.nan, index=df_single_row.index) # Muss Float sein wie andere numerische - - # np.where waehlt den Wiki-Wert, wenn nicht NaN, sonst den CRM-Wert. - df_single_row['Finaler_Umsatz'] = np.where( - wiki_umsatz_series.notna(), - wiki_umsatz_series, - crm_umsatz_series - ) - - df_single_row['Finaler_Mitarbeiter'] = np.where( - wiki_ma_series.notna(), - wiki_ma_series, - crm_ma_series - ) - - # Pruefen Sie, ob die konsolidierten numerischen Features NaN sind. - # ML-Vorhersage kann nicht durchgefuehrt werden, wenn diese komplett fehlen (werden vom Imputer erwartet). - if pd.isna(df_single_row['Finaler_Umsatz'].iloc[0]) and pd.isna(df_single_row['Finaler_Mitarbeiter'].iloc[0]): - self.logger.debug(f" -> ML-Schaetzung uebersprungen: Konsolidierter Umsatz und Mitarbeiter fehlen fuer Zeile.") - return "k.A. (Daten fehlen)" # Gebe spezifischen Wert zurueck - - - # --- Kategoriale Features (Branche) --- - branche_col_name = "CRM Branche" # Original Header Name aus COLUMN_MAP (Block 1) - # Stellen Sie sicher, dass die Spalte existiert und ein String ist. Fuellen Sie NaNs mit 'Unbekannt'. - if branche_col_name not in df_single_row.columns: - self.logger.warning(f"Spalte '{branche_col_name}' nicht im DataFrame fuer ML-Vorhersage gefunden. Behandle als 'Unbekannt'.") - df_single_row[branche_col_name] = 'Unbekannt' # Setze einen Default-Wert - - df_single_row[branche_col_name] = df_single_row[branche_col_name].astype(str).fillna('Unbekannt').str.strip() - - - # One-Hot Encoding - # WICHTIG: Muss alle BRANCHEN aus dem TRAININGSDATENSATZ (self._expected_features) enthalten, - # auch wenn diese in der einzelnen Zeile nicht vorkommen. - # pd.get_dummies erstellt Spalten nur fuer die Kategorien in df_single_row. - df_encoded = pd.get_dummies(df_single_row, columns=[branche_col_name], prefix='Branche', dummy_na=False) # dummy_na=False, da NaNs gefuellt - - - # Fugen Sie fehlende Feature-Spalten hinzu (die im Training vorhanden waren, aber in dieser Zeile nicht). - # Stellen Sie die Reihenfolge der Spalten sicher, so wie sie im Training waren (self._expected_features). - # self._expected_features wird von _load_ml_model (denselben Block) geladen. - if self._expected_features is None: - self.logger.error("FEHLER: Erwartete Feature-Spalten fuer ML-Vorhersage nicht geladen. Kann nicht vorhersagen.") - return "FEHLER Schaetzung" # Gebe Fehlerwert zurueck - - # Erstellen Sie einen neuen DataFrame mit allen erwarteten Features und fuellen Sie fehlende mit 0. - # Sicherstellen, dass die Spalten im Ergebnis-DF in der Reihenfolge von self._expected_features sind. - df_processed = pd.DataFrame(columns=self._expected_features) - # Kopieren Sie die Werte aus df_encoded, wo Spalten uebereinstimmen. - for col in self._expected_features: - if col in df_encoded.columns: - df_processed[col] = df_encoded[col] - else: - df_processed[col] = 0 # Fuege fehlende Spalten mit 0 hinzu - - # Stellen Sie sicher, dass die numerischen Spalten Float sind (Imputer erwartet das oft) - numeric_features_for_imputation = ['Finaler_Umsatz', 'Finaler_Mitarbeiter'] - for col in numeric_features_for_imputation: - if col in df_processed.columns: - df_processed[col] = pd.to_numeric(df_processed[col], errors='coerce') # Wandelt NaN in NaN, Fehler in NaN - - - # --- Imputation der fehlenden Werte --- - # Muss konsistent mit dem Imputer aus dem Training sein. - # Der Imputer (self.imputer) wird auf die vorbereiteten Features angewendet. - if self.imputer is None: - self.logger.error("FEHLER: ML-Imputer ist nicht geladen. Kann nicht imputieren/vorhersagen.") - return "FEHLER Schaetzung" # Gebe Fehlerwert zurueck - - # Imputer.transform gibt ein Numpy Array zurueck. - df_imputed_array = self.imputer.transform(df_processed) - # Konvertiere das Ergebnis zurueck zu einem DataFrame mit den erwarteten Spaltennamen. - df_imputed = pd.DataFrame(df_imputed_array, columns=self._expected_features) - - # Optional: Pruefen Sie, ob nach Imputation NaNs verbleiben (sollte nicht passieren bei SimpleImputer) - # if df_imputed.isna().any().any(): - # self.logger.warning("WARNUNG: NaNs verbleiben nach Imputation.") - - - # --- Vorhersage --- - # Das Decision Tree Modell (self.model) erwartet die vorbereiteten und imputierten Features. - if not self.model: - self.logger.error("FEHLER: ML-Modell ist nicht geladen. Kann nicht vorhersagen.") - return "FEHLER Schaetzung" # Gebe Fehlerwert zurueck - - - # Fuehren Sie die Vorhersage durch. - # predict_proba gibt die Wahrscheinlichkeiten fuer jede Klasse zurueck. - prediction_proba = self.model.predict_proba(df_imputed) - # prediction_proba ist ein Array von Wahrscheinlichkeiten pro Klasse fuer jede Eingabezeile (hier nur 1 Zeile). - - # Die Klassen-Labels des Modells (z.B. ['Bucket_1', 'Bucket_2', ...]) - model_classes = self.model.classes_ - - # Finden Sie den Index der Klasse mit der hoechsten Wahrscheinlichkeit fuer die erste (und einzige) Zeile. - predicted_class_index = np.argmax(prediction_proba[0]) - # Holen Sie das entsprechende Label aus den Modell-Klassen. - predicted_bucket_label = model_classes[predicted_class_index] - - # Logge die Vorhersage auf Debug-Level - self.logger.debug(f" -> ML Vorhersage Ergebnis: '{predicted_bucket_label}' (Wahrscheinlichkeiten: {prediction_proba[0]})") - - - return predicted_bucket_label # Gebe das vorhergesagte Bucket-Label zurueck (String) - - except Exception as e: - # Fange alle unerwarteten Fehler ab, die waehrend der Datenvorbereitung oder Vorhersage auftreten. - self.logger.exception(f"FEHLER bei der Datenvorbereitung/Vorhersage fuer Zeile (ML): {e}") # Logge Fehler und Traceback - # Geben Sie einen Fehlerwert zurueck, der im Sheet gespeichert werden kann. - return f"FEHLER Schaetzung: {str(e)[:100]}..." # Signalisiert Fehler bei der Schaetzung (gekuerzt) - - - # --- Methode zum Laden des ML Modells und Imputers --- - # Diese Methode wird von _predict_technician_bucket (denselben Block) und train_technician_model (denselben Block) aufgerufen. - # Sie laedt die serialisierten Modelle von der Festplatte. - # Nutzt globale Helfer: MODEL_FILE (Block 1), IMPUTER_FILE (Block 1), PATTERNS_FILE_JSON (Block 1), - # logger, os, pickle, json. - def _load_ml_model(self, model_path, imputer_path): - """ - Laedt das trainierte ML-Modell, den Imputer und die erwarteten Feature-Spalten - von den definierten Dateipfaden. Speichert sie als Instanzattribute. - - Args: - model_path (str): Dateipfad zum Modell (.pkl). - imputer_path (str): Dateipfad zum Imputer (.pkl). - # Der Pfad zur Feature-Spaltenliste (JSON) wird aus PATTERNS_FILE_JSON (Block 1) geholt. - """ - # Verwenden Sie logger, da das Logging jetzt konfiguriert ist - # Setzen Sie die Instanzattribute zunaechst auf None - self.model = None - self.imputer = None - self._expected_features = None # Liste der erwarteten Feature-Spalten fuer Vorhersage - - try: - # Pruefen Sie, ob die Modelldateien existieren - if not os.path.exists(model_path): - self.logger.error(f"ML-Modell Datei nicht gefunden: {model_path}") - return # Beende die Methode, wenn die Datei fehlt - if not os.path.exists(imputer_path): - self.logger.error(f"Imputer Datei nicht gefunden: {imputer_path}") - return # Beende die Methode, wenn die Datei fehlt - - # Laden Sie das serialisierte Modell - with open(model_path, 'rb') as f: - self.model = pickle.load(f) - self.logger.info(f"ML-Modell '{model_path}' erfolgreich geladen.") - # Loggen Sie die Klassen-Labels des geladenen Modells zur Info - if hasattr(self.model, 'classes_'): - self.logger.debug(f"Geladene Modell-Klassen: {self.model.classes_}") - else: - self.logger.debug("Geladenes Modell hat kein 'classes_' Attribut.") - - - # Laden Sie den serialisierten Imputer - with open(imputer_path, 'rb') as f: - self.imputer = pickle.load(f) - self.logger.info(f"Imputer '{imputer_path}' erfolgreich geladen.") - - - # Laden Sie die Liste der erwarteten Feature-Spalten (JSON-Datei wird empfohlen) - expected_features_path = PATTERNS_FILE_JSON # Nutzt globale Konstante (Block 1) - # Pruefen Sie, ob die Feature-Spalten-Datei existiert - if os.path.exists(expected_features_path): - try: - # Oeffnen Sie die JSON-Datei - with open(expected_features_path, 'r', encoding='utf-8') as f: - # Laden Sie die Daten aus der JSON-Datei - data = json.load(f) - # Annahme: Die JSON-Datei enthaelt eine Liste der Feature-Spalten unter dem Schluessel "feature_columns" - self._expected_features = data.get("feature_columns") - # Pruefen Sie, ob die geladenen Daten eine nicht-leere Liste sind. - if self._expected_features and isinstance(self._expected_features, list): - self.logger.info(f"Erwartete Feature-Spalten ({len(self._expected_features)}) aus '{expected_features_path}' geladen.") - # Loggen Sie die ersten paar erwarteten Features auf Debug - # self.logger.debug(f"Erwartete Features (erste 5): {self._expected_features[:5]}...") # Zu viel Laerm im Debug - else: - # Wenn die geladenen Daten nicht das erwartete Format haben oder leer sind - self.logger.error(f"Formatfehler in '{expected_features_path}' oder Schluessel 'feature_columns' fehlt/ist leer. ML-Vorhersage koennte fehlschlagen.") - self._expected_features = None # Setze auf None bei Fehler - - except Exception as e_json: - # Fangen Sie Fehler beim Laden oder Parsen der JSON-Datei ab - self.logger.error(f"FEHLER beim Laden oder Parsen der Feature-Spalten Datei '{expected_features_path}': {e_json}") - # Logge den Traceback - self.logger.debug(traceback.format_exc()) - self._expected_features = None # Setze auf None bei Fehler - - else: - # Wenn die Feature-Spalten-Datei nicht gefunden wird - self.logger.warning(f"Datei mit erwarteten Feature-Spalten '{expected_features_path}' nicht gefunden. ML-Vorhersage koennte fehlschlagen.") - self._expected_features = None # Setze auf None, da die Datei fehlt - - - # Fallback: Wenn expected_features nicht geladen werden konnte, versuchen Sie es aus Imputer/Modell zu extrahieren (wenn die Bibliothek es unterstuetzt) - if self._expected_features is None: - try: - # Neuere Scikit-learn Versionen haben oft ein feature_names_in_ Attribut - if hasattr(self.imputer, 'feature_names_in_') and self.imputer.feature_names_in_ is not None: - self._expected_features = list(self.imputer.feature_names_in_) - self.logger.info(f"Erwartete Feature-Spalten ({len(self._expected_features)}) aus Imputer geladen (Fallback).") - elif hasattr(self.model, 'feature_names_in_') and self.model.feature_names_in_ is not None: - self._expected_features = list(self.model.feature_names_in_) - self.logger.info(f"Erwartete Feature-Spalten ({len(self._expected_features)}) aus Modell geladen (Fallback).") - else: - # Wenn es nirgends gefunden werden konnte - self.logger.error("Konnte erwartete Feature-Spalten weder aus Datei noch aus Modell/Imputer extrahieren. ML-Vorhersage wird fehlschlagen.") - self._expected_features = None - except Exception as e_extract: - # Fangen Sie Fehler beim Extrahieren aus Modell/Imputer ab - self.logger.error(f"FEHLER beim Extrahieren der Feature-Namen aus Modell/Imputer (Fallback): {e_extract}") - # Logge den Traceback - self.logger.debug(traceback.format_exc()) - self._expected_features = None - - - except Exception as e: - # Fange alle anderen unerwarteten Fehler waehrend des Ladens ab - self.logger.exception(f"FEHLER beim Laden von ML-Artefakten: {e}") # Logge Fehler und Traceback - # Setzen Sie die Attribute auf None bei Fehler - self.model = None - self.imputer = None - self._expected_features = None - # Die Methode endet implizit hier nach dem Fangen der Exception. - - - # Methode zur Datenvorbereitung fuer ML (WIRD VON train_technician_model aufgerufen) - # Diese Methode laedt alle relevanten Daten aus dem Sheet, bereitet sie auf - # und gibt einen DataFrame fuer das Training zurueck. - # Basierend auf prepare_data_for_modeling aus Teil 12/13. - # Nutzt interne Helfer: _get_cell_value_safe. - # Nutzt globale Helfer: COLUMN_MAP (Block 1), logger, pandas, numpy, re, - # clean_text (Block 4), normalize_string (Block 4), get_valid_numeric (Block 5), - # load_target_schema (Block 6 - relevant fuer Branchentypen), traceback. - # Nutzt die uebergeordnete sheet_handler Instanz (Block 14). - def prepare_data_for_modeling(self): - """ - Laedt Daten aus dem Google Sheet ueber den sheet_handler, - bereitet sie fuer das Decision Tree Modell vor: - - Waehlt relevante Spalten aus und benennt sie um. - - Konsolidiert Umsatz/Mitarbeiter (Wiki > CRM Prioritaet). - - Filtert nach gueltiger Technikerzahl (> 0). - - Erstellt die Zielvariable (Techniker-Bucket). - - Bereitet Features auf (One-Hot Encoding fuer Branche). - - Behaelt NaNs in numerischen Features fuer spaetere Imputation. - - Returns: - pandas.DataFrame: Vorbereiteter DataFrame fuer Training/Test-Split, - oder None bei Fehlern oder wenn keine gueltigen Trainingsdaten gefunden wurden. - """ - # Verwenden Sie logger, da das Logging jetzt konfiguriert ist - self.logger.info("Starte Datenvorbereitung fuer Modellierung (Training)...") - # Nutzt den self.sheet_handler der Klasse (Block 15). - # Pruefen Sie, ob der Sheet Handler initialisiert wurde und Daten hat. - if not self.sheet_handler or not self.sheet_handler.sheet_values: - self.logger.error("Fehler: Sheet Handler nicht initialisiert oder keine Daten geladen fuer prepare_data_for_modeling.") - # Versuchen Sie die Daten einmalig innerhalb dieser Methode zu laden, falls sie fehlen. - # Der load_data Aufruf ist mit retry_on_failure dekoriert (Block 2). - if not self.sheet_handler.load_data(): - self.logger.critical("Konnte Daten auch nach erneutem Versuch nicht laden. Abbruch der Datenvorbereitung.") - return None # Gebe None zurueck, wenn Laden fehlschlaegt - - - # Holen Sie die gesamte Datenliste (inklusive Header) aus dem SheetHandler. - all_data = self.sheet_handler.get_all_data_with_headers() - # Annahme: header_rows ist als Attribut im SheetHandler verfuegbar (Block 14). - header_rows = self.sheet_handler._header_rows - # Pruefe auf ausreichende Zeilenzahl (Header + mindestens eine Datenzeile) - min_required_rows = header_rows + 1 - # Wenn nicht genuegend Zeilen da sind - if not all_data or len(all_data) < min_required_rows: - self.logger.error(f"Fehler: Nicht genuegend Datenzeilen ({len(all_data)}) im Sheet gefunden fuer Modellierung (mindestens {min_required_rows} benoetigt).") - return None # Gebe None zurueck, wenn nicht genuegend Daten da sind - - - # --- Header pruefen und DataFrame erstellen --- - try: - # Die erste Zeile sollte die Spaltennamen enthalten. - headers = all_data[0] - # Stellen Sie sicher, dass die Header-Zeile auch die erwartete Mindestlaenge hat, - # um die Spaltenindizes aus COLUMN_MAP (Block 1) zu finden. - try: - max_col_idx_in_map = max(COLUMN_MAP.values()) # Finde den hoechsten Index in COLUMN_MAP - # Pruefen Sie, ob die Anzahl der geladenen Spalten im Header ausreicht - if len(headers) <= max_col_idx_in_map: - # Logge einen kritischen Fehler, wenn das Mapping auf Spalten zeigt, die nicht im Sheet existieren - self.logger.critical(f"FEHLER: Header-Zeile ({len(headers)} Spalten) ist kuerzer als der hoechste Index in COLUMN_MAP ({max_col_idx_in_map}). COLUMN_MAP passt nicht zum Sheet.") - return None # Beende die Methode - except ValueError: # Tritt auf, wenn COLUMN_MAP leer ist - self.logger.critical("FEHLER: COLUMN_MAP scheint leer zu sein oder enthaelt keine Werte. Kann Max Index nicht ermitteln.") - return None # Beende die Methode - except Exception as e: - # Fange andere unerwartete Fehler ab - self.logger.critical(f"FEHLER beim Pruefen der Spaltenlaenge der Header-Zeile: {e}") - return None # Beende die Methode - - except IndexError: - # Wenn das Sheet leer ist oder keine erste Zeile hat - self.logger.critical("FEHLER: Sheet scheint leer zu sein oder hat keine erste Zeile, keine Header gefunden.") - return None # Beende die Methode - except Exception as e: - # Fange andere unerwartete Fehler beim Zugriff auf Header ab - self.logger.critical(f"FEHLER beim Zugriff auf Header: {e}") - # Logge den Traceback - self.logger.debug(traceback.format_exc()) - return None # Beende die Methode - - - # Datenzeilen sind alle Zeilen nach den Header-Zeilen - data_rows = all_data[header_rows:] # Annahme: Die ersten X Zeilen sind Header - - # Erstelle DataFrame aus den Datenzeilen und den Headern - df = pd.DataFrame(data_rows, columns=headers) - self.logger.info(f"Initialen DataFrame fuer Modellierung erstellt mit {len(df)} Zeilen und {len(df.columns)} Spalten.") - - # --- Spaltenauswahl und Umbenennung --- - # Definiere die notwendigen Spalten anhand ihrer COLUMN_MAP Schluessel (Block 1) - # und weisen ihnen interne, einfachere Namen zu, die im DataFrame verwendet werden. - col_keys_mapping = { - "name": "CRM Name", # Zur Identifikation, wird spaeter entfernt - "branche_crm": "CRM Branche", # Fuer One-Hot Encoding - "umsatz_crm": "CRM Umsatz", # Fuer Konsolidierung - "umsatz_wiki": "Wiki Umsatz", # Fuer Konsolidierung - "ma_crm": "CRM Anzahl Mitarbeiter", # Fuer Konsolidierung - "ma_wiki": "Wiki Mitarbeiter", # Fuer Konsolidierung - "techniker": "CRM Anzahl Techniker" # DIE ZIELVARIABLE (Bekannte Technikerzahl) - } - - # Ueberpruefe, ob alle benoetigten Spalten-Schluessel in der COLUMN_MAP (Block 1) vorhanden sind - missing_keys_in_map = [key for key in col_keys_mapping.values() if key not in COLUMN_MAP] - if missing_keys_in_map: - self.logger.critical(f"FEHLER: Folgende benoetigte Spalten-Schluessel fehlen in COLUMN_MAP fuer prepare_data_for_modeling: {missing_keys_in_map}.") - return None # Beende die Methode - - # Erstelle das Mapping von tatsaechlichen Header-Namen zu internen Schluesseln. - # Verwende die Header-Namen aus dem geladenen Sheet und die COLUMN_MAP, um die richtigen Header zu finden. - header_to_internal_key = {} # Dict zum Umbenennen der Spalten - cols_to_select_by_header = [] # Liste der Header-Namen, die aus dem DF ausgewaehlt werden - - try: - # Iteriere ueber das Mapping von internen zu COLUMN_MAP Schluesseln - for internal_key, column_map_key in col_keys_mapping.items(): - # Hole den tatsaechlichen Header-Namen aus dem Sheet - header_name_from_sheet = headers[COLUMN_MAP[column_map_key]] - # Fuege das Mapping hinzu - header_to_internal_key[header_name_from_sheet] = internal_key - # Fuege den Header-Namen zur Liste der auszuwaehlenden Spalten hinzu - cols_to_select_by_header.append(header_name_from_sheet) - - # Waehle nur die benoetigten Spalten im DataFrame aus - df_subset = df[cols_to_select_by_header].copy() # Kopie erstellen, um SettingWithCopyWarning zu vermeiden - # Benenne die Spalten um zu den internen Namen - df_subset.rename(columns=header_to_internal_key, inplace=True) - - except KeyError as e: - # Dieser Fehler sollte eigentlich durch die obige Pruefung abgefangen werden, - # tritt aber auf, wenn ein erwarteter Header-Name nicht im geladenen DF ist (selten, wenn COLUMN_MAP korrekt ist). - self.logger.critical(f"FEHLER beim Auswaehlen/Umbenennen der Spalten (KeyError: '{e}'). Der Header wurde nicht im DataFrame gefunden.") - self.logger.debug(f"Erwartete Header: {cols_to_select_by_header}. Verfuegbare Header im DF: {list(df.columns)}") - return None # Beende die Methode - except IndexError as e: - # Tritt auf, wenn COLUMN_MAP einen Index > Anzahl Spalten im DF hat - self.logger.critical(f"FEHLER beim Auswaehlen/Umbenennen der Spalten (IndexError: '{e}'). COLUMN_MAP zeigt auf Spalten, die nicht im geladenen Sheet existieren.") - self.logger.debug(f"COLUMN_MAP: {COLUMN_MAP}. Sheet hat {len(headers)} Spalten.") - return None # Beende die Methode - except Exception as e: - # Fange andere unerwartete Fehler ab - self.logger.critical(f"Unerwarteter FEHLER beim Auswaehlen/Umbenennen der Spalten: {e}") - # Logge den Traceback - self.logger.debug(traceback.format_exc()) - return None # Beende die Methode - - - self.logger.info(f"Benötigte Spalten fuer Modellierung ausgewaehlt und umbenannt: {list(df_subset.columns)}") - - # --- Features konsolidieren (Umsatz, Mitarbeiter) --- - # Nutzt die globale Hilfsfunktion get_valid_numeric (Block 5), die numerische Werte als Float/Int oder NaN zurueckgibt. - 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(): - self.logger.debug(f"Verarbeite und konsolidiere '{base_name}' (Prioritaet: Wiki > CRM)...") - # Sicherstellen, dass die Spalten im df_subset existieren, bevor apply aufgerufen wird. - # Dies sollte durch die Spaltenauswahl oben garantiert sein, aber zur Sicherheit. - 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) - - - # np.where waehlt den Wiki-Wert, wenn er nicht NaN ist, sonst den CRM-Wert. - df_subset[final_col] = np.where( - wiki_series.notna(), # Wenn Wiki-Wert vorhanden ist (nicht NaN) - wiki_series, # Nimm den Wiki-Wert - crm_series # Sonst nimm den CRM-Wert (der auch NaN sein kann) - ) - # Info-Log ueber Ergebnis - self.logger.info(f" -> {df_subset[final_col].notna().sum()} gueltige '{final_col}' Werte erstellt (von {len(df_subset)} Zeilen).") - - # --- Zielvariable vorbereiten (Technikerzahl) --- - techniker_col_internal = "techniker" # Interne Spaltenname nach Umbenennung (aus col_keys_mapping) - self.logger.info(f"Verarbeite Zielvariable '{techniker_col_internal}'...") - - # Sicherstellen, dass die Spalte existiert - if techniker_col_internal not in df_subset.columns: - self.logger.critical(f"FEHLER: Zielvariable '{techniker_col_internal}' (CRM Anzahl Techniker) nicht im DataFrame gefunden nach Umbenennung.") - return None # Beende die Methode - - # Konvertiere zu Numerisch (Float/Int oder NaN) mit get_valid_numeric (Block 5). - # Dies stellt sicher, dass nur gueltige, positive Zahlen verwendet werden. - df_subset['Anzahl_Servicetechniker_Numeric'] = df_subset[techniker_col_internal].apply(get_valid_numeric) - - - # Filtere Zeilen: Behalte nur die mit gueltiger, positiver Technikerzahl (Float > 0). - initial_rows = len(df_subset) - # Hier filtern wir basierend auf der numerischen Spalte, die durch get_valid_numeric erstellt wurde. - df_filtered = df_subset[ - df_subset['Anzahl_Servicetechniker_Numeric'].notna() & # Nicht NaN - (df_subset['Anzahl_Servicetechniker_Numeric'] > 0) # Und groesser als 0 - ].copy() # WICHTIG: .copy() um SettingWithCopyWarning zu vermeiden - filtered_rows = len(df_filtered) - removed_rows = initial_rows - filtered_rows - - # Info, wenn Zeilen entfernt wurden - if removed_rows > 0: - self.logger.info(f"{removed_rows} Zeilen entfernt aufgrund fehlender/ungueltiger Technikerzahl (Wert <= 0 oder nicht numerisch/parsebar).") - self.logger.info(f"Verbleibende Zeilen fuer Modellierungstraining (mit gueltiger Technikerzahl > 0): {filtered_rows}") - - # Wenn keine Zeilen uebrig bleiben, kann kein Modell trainiert werden. - if filtered_rows == 0: - self.logger.error("FEHLER: Keine Zeilen mit gueltiger Technikerzahl (>0) uebrig fuer Modellierungstraining!") - return None # Beende die Methode - - - # --- Techniker-Buckets erstellen --- - # Die Bins und Labels muessen die gefilterten Daten widerspiegeln (die jetzt alle > 0 sind). - # Die Bin-Definition muss so sein, dass alle Werte > 0 einem Bucket zugeordnet werden. - # Beispiel: (-1, 0] -> Bucket 1 (0), (0, 19] -> Bucket 2 (<20), (19, 49] -> Bucket 3 (<50) etc. - # Da wir auf >0 filtern, landet 0 nie im Trainingsset, aber die Bin-Definition muss trotzdem Sinn ergeben. - bins = [-1, 0, 19, 49, 99, 249, 499, float('inf')] # Definiere die Grenzen der Buckets - labels = ['Bucket_1_(0)', 'Bucket_2_(<20)', 'Bucket_3_(<50)', 'Bucket_4_(<100)', 'Bucket_5_(<250)', 'Bucket_6_(<500)', 'Bucket_7_(>499)'] # Namen fuer die Buckets - - try: - # Erstellen Sie die Bucket-Spalte mit pd.cut - df_filtered['Techniker_Bucket'] = pd.cut( - df_filtered['Anzahl_Servicetechniker_Numeric'], # Die numerische Technikerzahl-Spalte - bins=bins, # Die definierten Grenzen - labels=labels, # Die definierten Labels - right=True, # Intervalle sind (links, rechts]. z.B. (0, 19] inkludiert 19. - include_lowest=True # Inkludiert den niedrigsten Wert der ersten Bin (-1) (relevant, falls 0 moeglich waere) - ) - self.logger.info("Techniker-Buckets erstellt.") - - # Pruefe, ob NaNs in Buckets erstellt wurden (sollte bei >0 Filterung und korrekten Bins nicht passieren). - if df_filtered['Techniker_Bucket'].isna().any(): - nan_bucket_rows = df_filtered['Techniker_Bucket'].isna().sum() - self.logger.warning(f"WARNUNG: {nan_bucket_rows} Zeilen mit NaNs in Techniker-Buckets nach pd.cut erstellt. Ueberpruefen Sie die bins/labels oder die Filterung.") - # Entfernen Sie diese Zeilen, da sie nicht zum Trainieren verwendet werden koennen. - df_filtered.dropna(subset=['Techniker_Bucket'], inplace=True) # Entferne Zeilen mit NaN im Bucket - self.logger.info(f"Nach Entfernung von {nan_bucket_rows} Zeilen mit NaN Buckets: {len(df_filtered)} Zeilen verbleiben fuer Training.") - # Wenn nach Entfernung keine Zeilen mehr uebrig sind - if len(df_filtered) == 0: - self.logger.error("FEHLER: Keine Zeilen uebrig nach Entfernung von NaN Buckets. Modell kann nicht trainiert werden.") - return None # Beende die Methode - - - # Verteilung der Buckets als Info-Log (absolute Haeufigkeit und Prozent) - self.logger.info(f"Verteilung der Techniker-Buckets im Trainingsdatensatz ({len(df_filtered)} Zeilen):\n{df_filtered['Techniker_Bucket'].value_counts(normalize=False).sort_index()}") # Zaehlung - self.logger.info(f"Verteilung (Prozent):\n{df_filtered['Techniker_Bucket'].value_counts(normalize=True).sort_index().round(3)}") # Prozent - - except Exception as e: - # Fange Fehler beim Erstellen der Buckets ab - self.logger.critical(f"FEHLER beim Erstellen der Techniker-Buckets: {e}") - # Logge den Traceback - self.logger.debug(traceback.format_exc()) - return None # Beende die Methode - - - # --- Kategoriale Features vorbereiten (Branche) --- - branche_col_internal = "branche_crm" # Interne Spaltenname nach Umbenennung (aus col_keys_mapping) - self.logger.info(f"Verarbeite kategoriales Feature '{branche_col_internal}' fuer One-Hot Encoding...") - - # Sicherstellen, dass die Spalte existiert - if branche_col_internal not in df_filtered.columns: - self.logger.critical(f"FEHLER: Spalte '{branche_col_internal}' nicht im DataFrame fuer One-Hot Encoding gefunden.") - return None # Beende die Methode - - # Stelle sicher, dass die Spalte String ist und fülle evtl. NaNs mit 'Unbekannt'. - # .str.strip() entfernt führende/endende Leerzeichen. - df_filtered[branche_col_internal] = df_filtered[branche_col_internal].astype(str).fillna('Unbekannt').str.strip() - - - # One-Hot Encoding (pd.get_dummies) - # dummy_na=False, da wir NaNs bereits mit 'Unbekannt' gefuellt haben. - # prefix='Branche' ist gut, um die neuen Spalten zu identifizieren. - df_encoded = pd.get_dummies(df_filtered, columns=[branche_col_internal], prefix='Branche', dummy_na=False) - self.logger.info(f"One-Hot Encoding fuer '{branche_col_internal}' durchgefuehrt. Neue Spaltenanzahl: {len(df_encoded.columns)}") - - - # --- Finale Auswahl der Features fuer das Modell --- - # Identifizieren Sie die Feature-Spalten nach dem Encoding. - # Dies sind alle Spalten, die mit 'Branche_' beginnen (One-Hot), plus die konsolidierten numerischen. - feature_columns = [col for col in df_encoded.columns if col.startswith('Branche_')] # Alle One-Hot Branch-Spalten - # Fuegen Sie die konsolidierten numerischen Spalten hinzu. - feature_columns.extend(['Finaler_Umsatz', 'Finaler_Mitarbeiter']) - - - # Pruefen Sie, ob die konsolidierten numerischen Spalten ('Finaler_Umsatz', 'Finaler_Mitarbeiter') - # tatsaechlich im DataFrame df_encoded vorhanden sind (sollten sie, wurden oben erstellt). - if not all(col in df_encoded.columns for col in ['Finaler_Umsatz', 'Finaler_Mitarbeiter']): - self.logger.critical("FEHLER: Konsolidierte numerische Spalten 'Finaler_Umsatz' oder 'Finaler_Mitarbeiter' fehlen im DataFrame nach Konsolidierung.") - return None # Beende die Methode - - - target_column = 'Techniker_Bucket' # Die Zielvariable - - - # Erstellen Sie den finalen DataFrame nur mit Features und Target (und Identifikationsspalten). - # Behalten Sie 'name' und 'Anzahl_Servicetechniker_Numeric' fuer Reporting/Debugging. - # 'name' ist der interne Name nach Umbenennung (aus col_keys_mapping). - identification_cols = ['name', 'Anzahl_Servicetechniker_Numeric'] - # Sicherstellen, dass diese Identifikationsspalten auch im DataFrame existieren. - if not all(col in df_encoded.columns for col in identification_cols): - self.logger.critical(f"FEHLER: Identifikationsspalten {identification_cols} fehlen im DataFrame.") - return None # Beende die Methode - - - # Erstellen Sie die Liste der finalen Spalten fuer den DataFrame - # Stellen Sie sicher, dass alle Feature-Spalten und die Zielspalte auch wirklich im DataFrame sind - # (Koennte fehlen, wenn z.B. Finaler_Umsatz/Mitarbeiter oben fehlschlug und als NaN resultierte, was aber ok ist fuer Imputer). - final_cols_for_df = identification_cols + feature_columns + [target_column] - missing_final_cols = [col for col in final_cols_for_df if col not in df_encoded.columns] - if missing_final_cols: - self.logger.critical(f"FEHLER: Finale Spalten fuer Modellierung fehlen im DataFrame: {missing_final_cols}") - return None # Beende die Methode - - - # Erstellen Sie den finalen DataFrame mit den ausgewaehlten Spalten. - df_model_ready = df_encoded[final_cols_for_df].copy() # Erstelle eine Kopie - - - # Optional: Konvertieren Sie numerische Spalten explizit zu Float64. - # Dies stellt sicher, dass der Imputer korrekt arbeitet. - for col in ['Finaler_Umsatz', 'Finaler_Mitarbeiter', 'Anzahl_Servicetechniker_Numeric']: - if col in df_model_ready.columns: # Sicherheitscheck, ob Spalte existiert - # errors='coerce' wandelt Fehler bei der Konvertierung in NaN. Wichtig, da Imputer NaNs erwartet. - df_model_ready[col] = pd.to_numeric(df_model_ready[col], errors='coerce') - - - # Setzen Sie den Index des DataFrames zurueck, um eine saubere Verarbeitung in den naechsten Schritten - # (z.B. Train/Test-Split) sicherzustellen. drop=True verhindert, dass der alte Index als neue Spalte hinzugefuegt wird. - df_model_ready = df_model_ready.reset_index(drop=True) - - - # Logge Informationen zum finalen DataFrame - self.logger.info("Datenvorbereitung fuer Modellierung (Training) abgeschlossen.") - self.logger.info(f"Finaler DataFrame fuer Modellierung hat {len(df_model_ready)} Zeilen und {len(df_model_ready.columns)} Spalten.") - # Logge die Anzahl der Feature-Spalten, nicht die Liste selbst (kann sehr lang sein). - self.logger.info(f"Anzahl Feature-Spalten: {len(feature_columns)}") - self.logger.info(f"Ziel-Spalte: {target_column}") - - - # WICHTIG: Info ueber fehlende Werte in den finalen numerischen Features VOR der Imputation. - # Die Imputation selbst erfolgt im Trainingsschritt (train_technician_model Block 31). - numeric_features_for_imputation = ['Finaler_Umsatz', 'Finaler_Mitarbeiter'] - nan_counts = df_model_ready[numeric_features_for_imputation].isna().sum() - self.logger.info(f"Fehlende Werte in numerischen Features vor Imputation:\n{nan_counts}") - # Logge auch, wie viele Zeilen *mindestens* einen NaN in den numerischen Features haben. - rows_with_nan = df_model_ready[numeric_features_for_imputation].isna().any(axis=1).sum() - self.logger.info(f"Anzahl Zeilen mit mindestens einem fehlenden numerischen Feature (vor Imputation): {rows_with_nan}") - - - return df_model_ready # Gebe den vorbereiteten DataFrame zurueck - - - # Methode zum Trainieren des ML Modells - # Nutzt interne Methode: prepare_data_for_modeling. - # Nutzt globale Helfer: MODEL_FILE, IMPUTER_FILE, PATTERNS_FILE_JSON (Block 1), - # logger, pickle, json, os, - # train_test_split, SimpleImputer, DecisionTreeClassifier, - # accuracy_score, classification_report, confusion_matrix, export_text (sklearn). - def train_technician_model(self, model_out=MODEL_FILE, imputer_out=IMPUTER_FILE, patterns_out=PATTERNS_FILE_JSON): - """ - Trainiert ein Decision Tree Modell zur Schaetzung der Servicetechniker-Buckets. - Speichert das Modell, den Imputer und die Feature-Spalten. - - Args: - model_out (str): Dateipfad zum Speichern des trainierten Modells (.pkl). - imputer_out (str): Dateipfad zum Speichern des trainierten Imputers (.pkl). - patterns_out (str): Dateipfad zum Speichern der Feature-Spaltenliste (.json). - """ - # Verwenden Sie logger, da das Logging jetzt konfiguriert ist - self.logger.info("Starte Training des Servicetechniker Decision Tree Modells...") - - # 1. Daten vorbereiten (nutzt die interne Methode prepare_data_for_modeling denselben Block) - df_model_ready = self.prepare_data_for_modeling() - - # Wenn die Datenvorbereitung fehlschlug oder keinen DataFrame zurueckgab - if df_model_ready is None or df_model_ready.empty: - self.logger.error("Datenvorbereitung fuer Modelltraining fehlgeschlagen oder keine Daten. Training abgebrochen.") - return # Beende die Methode - - - # Separate Features (X) und Target (y) - # Identifikationsspalten und Zielspalte (muss konsistent mit prepare_data_for_modeling sein) - identification_cols = ['name', 'Anzahl_Servicetechniker_Numeric'] - target_column = 'Techniker_Bucket' - - - # Feature Spalten sind alle Spalten im df_model_ready ausser den Identifikations- und der Zielspalte. - feature_columns = [col for col in df_model_ready.columns if col not in identification_cols and col != target_column] - # Stellen Sie sicher, dass es Feature-Spalten gibt (sollte durch prepare_data_for_modeling sichergestellt sein) - if not feature_columns: - self.logger.critical("FEHLER: Keine Feature-Spalten nach Datenvorbereitung gefunden. Training nicht moeglich.") - return # Beende die Methode - - # Erstellen Sie die Feature-Matrix X und den Zielvektor y - X = df_model_ready[feature_columns] - y = df_model_ready[target_column] - - - self.logger.info(f"Daten fuer Training vorbereitet. X Shape: {X.shape}, y Shape: {y.shape}") - # Logge die ersten paar Features auf Debug-Level (kann sehr lang sein) - # self.logger.debug(f"Feature Spalten fuer Training ({len(feature_columns)}): {feature_columns[:10]}...") - - - # 2. Split in Training und Test Set - # test_size (z.B. 0.25 für 25% Testdaten), random_state fuer Reproduzierbarkeit. - # stratify=y ist wichtig bei Klassifikationsproblemen mit ungleichen Klassen, um die - # Klassenverteilung in Trainings- und Testset aehnlich zu halten. - X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.25, random_state=42, stratify=y) - - - self.logger.info(f"Daten gesplittet. Train Set: {len(X_train)} Zeilen, Test Set: {len(X_test)} Zeilen.") - - # 3. Imputation (Fehlende Werte ersetzen) - # Verwenden Sie SimpleImputer (z.B. Median), um NaN-Werte zu ersetzen. - # Median ist robust gegenueber Ausreissern. Alternativ: 'mean' oder 'most_frequent'. - imputer = SimpleImputer(strategy='median') - self.logger.info(f"Fitte Imputer mit Strategie '{imputer.strategy}' auf Trainingsdaten...") - # Fitten Sie den Imputer NUR auf den Trainingsdaten, um Data Leakage zu vermeiden. - imputer.fit(X_train) # Fitten Sie den Imputer auf X_train - - - # Speichern Sie den Imputer (wird fuer Vorhersagen benoetigt). - self.imputer = imputer # Speichern Sie ihn in der Instanz fuer spaetere Vorhersagen - try: - # Stellen Sie sicher, dass das Verzeichnis fuer den Output existiert. - os.makedirs(os.path.dirname(imputer_out), exist_ok=True) # Erstellt das Verzeichnis, falls es nicht existiert - # Speichern Sie den Imputer mit pickle - with open(imputer_out, 'wb') as f: - pickle.dump(imputer, f) - self.logger.info(f"Imputer erfolgreich gespeichert in '{imputer_out}'.") - except Exception as e: - # Fange Fehler beim Speichern ab und logge sie. - self.logger.error(f"FEHLER beim Speichern des Imputers in '{imputer_out}': {e}") - # Logge den Traceback. - self.logger.debug(traceback.format_exc()) - # Fahren Sie fort, aber loggen Sie den Fehler - - - # Transformieren Sie Trainings- und Testdaten mit dem gefitteten Imputer. - X_train_imputed = imputer.transform(X_train) - X_test_imputed = imputer.transform(X_test) - - # Konvertieren Sie die Ergebnisse (Numpy Arrays) zurueck zu DataFrames, behalten Sie die Spaltennamen. - X_train_imputed = pd.DataFrame(X_train_imputed, columns=feature_columns) - X_test_imputed = pd.DataFrame(X_test_imputed, columns=feature_columns) - self.logger.info("Numerische Features imputiert.") - - - # 4. Decision Tree Training - # Definieren Sie das Decision Tree Modell. - # Wichtige Hyperparameter zum Tunen: max_depth, min_samples_split, min_samples_leaf. - # class_weight='balanced' ist hilfreich bei ungleicher Klassenverteilung (wahrscheinlich bei Buckets). - dt_classifier = DecisionTreeClassifier(random_state=42, class_weight='balanced') - # Optional: Hyperparameter-Tuning mit GridSearchCV - # param_grid = {'max_depth': [None, 10, 20, 30], 'min_samples_split': [2, 5, 10], 'min_samples_leaf': [1, 2, 5]} - # grid_search = GridSearchCV(dt_classifier, param_grid, cv=5, scoring='accuracy') - # grid_search.fit(X_train_imputed, y_train) - # dt_classifier = grid_search.best_estimator_ - # self.logger.info(f"Beste Parameter gefunden durch GridSearchCV: {grid_search.best_params_}") - - - self.logger.info("Starte Training des Decision Tree Modells...") - # Fitten Sie das Modell auf den imputierten Trainingsdaten. - dt_classifier.fit(X_train_imputed, y_train) - self.logger.info("Modelltraining abgeschlossen.") - - - # Speichern Sie das trainierte Modell. - self.model = dt_classifier # Speichern Sie es in der Instanz fuer spaetere Vorhersagen - try: - # Stellen Sie sicher, dass das Verzeichnis fuer den Output existiert. - os.makedirs(os.path.dirname(model_out), exist_ok=True) # Erstellt das Verzeichnis, falls es nicht existiert - # Speichern Sie das Modell mit pickle - with open(model_out, 'wb') as f: - pickle.dump(dt_classifier, f) - self.logger.info(f"Decision Tree Modell erfolgreich gespeichert in '{model_out}'.") - except Exception as e: - # Fange Fehler beim Speichern ab und logge sie. - self.logger.error(f"FEHLER beim Speichern des Modells in '{model_out}': {e}") - # Logge den Traceback. - self.logger.debug(traceback.format_exc()) - # Fahren Sie fort - - - # Speichern Sie die Liste der Feature-Spalten (fuer die Vorhersage) - self._expected_features = feature_columns # Speichern Sie diese Liste in der Instanz fuer _predict_technician_bucket - try: - # Speichern als JSON fuer bessere Lesbarkeit und um zusaetzliche Infos (wie Klassen) zu speichern. - # PATTERNS_FILE_JSON wird aus Config (Block 1) geholt. - patterns_data = {"feature_columns": feature_columns, "target_classes": list(dt_classifier.classes_)} - # Stellen Sie sicher, dass das Verzeichnis fuer den Output existiert. - os.makedirs(os.path.dirname(patterns_out), exist_ok=True) # Erstellt das Verzeichnis, falls es nicht existiert - # Speichern Sie die JSON-Datei - with open(patterns_out, 'w', encoding='utf-8') as f: - json.dump(patterns_data, f, indent=4, ensure_ascii=False) - self.logger.info(f"Erwartete Feature-Spalten und Klassen erfolgreich gespeichert in '{patterns_out}'.") - - # Optional: Speichern als einfache Textdatei (wie im Originalcode) - # patterns_out_txt = patterns_out.replace('.json', '.txt') - # with open(patterns_out_txt, 'w', encoding='utf-8') as f: - # for col in feature_columns: f.write(f"{col}\n") - # self.logger.info(f"Erwartete Feature-Spalten (txt) erfolgreich gespeichert in '{patterns_out_txt}'.") - - except Exception as e: - # Fange Fehler beim Speichern ab und logge sie. - self.logger.error(f"FEHLER beim Speichern der Feature-Spalten in '{patterns_out}': {e}") - # Logge den Traceback. - self.logger.debug(traceback.format_exc()) - # Fahren Sie fort - - - # 5. Evaluation (Optional, aber empfohlen, um die Modellleistung zu bewerten) - self.logger.info("Starte Modellevaluation...") - - # Vorhersagen auf dem Testset - y_pred = dt_classifier.predict(X_test_imputed) - - # Metriken berechnen und loggen - accuracy = accuracy_score(y_test, y_pred) - self.logger.info(f"Modell Genauigkeit auf dem Testset: {accuracy:.4f}") - - # Klassifikationsbericht - # zero_division='warn' ist Standard, '0' gibt 0 fuer nicht vorhandene Klassen, 'none' wirft Fehler. - class_report = classification_report(y_test, y_pred, zero_division=0, labels=dt_classifier.classes_, target_names=[str(c) for c in dt_classifier.classes_]) # Stelle sicher, dass Labels und Target-Namen konsistent sind - self.logger.info(f"Klassifikationsbericht auf dem Testset:\n{class_report}") - - # Konfusionsmatrix - # display_labels=dt_classifier.classes_ sorgt fuer korrekte Beschriftung - cm = confusion_matrix(y_test, y_pred, labels=dt_classifier.classes_) - self.logger.info(f"Konfusionsmatrix auf dem Testset (Zeilen=Wahr, Spalten=Vorhersage):\n{cm}") - - # Entscheidungsregeln extrahieren (Optional, fuer Verstaendnis) - try: - # Beschraenken Sie die Tiefe fuer die Ausgabe, falls der Baum sehr tief ist - # feature_names muessen der Reihenfolge in X_train_imputed entsprechen - tree_rules = export_text(dt_classifier, feature_names=feature_columns, max_depth=7) # max_depth anpassen - self.logger.info(f"Erste Regeln des Decision Tree (max Tiefe 7):\n{tree_rules}") - except Exception as e: - # Fange Fehler beim Exportieren der Regeln ab - self.logger.warning(f"FEHLER beim Exportieren der Baumregeln: {e}") - # Logge den Traceback. - self.logger.debug(traceback.format_exc()) - - self.logger.info("Modelltraining und -evaluation abgeschlossen.") - -# ============================================================================== -# Ende DataProcessor Klasse Utility: ML Prep & Training Block -# ============================================================================== - - # ========================================================================== - # === Utility Methods (Other Specific Tasks) =============================== - # ========================================================================== - - # --- Methode fuer experimentelle Website Details --- - # Diese Methode extrahiert Details von Websites fuer Zeilen mit 'x'. - # Basierend auf process_website_details_for_marked_rows aus Teil 12. - # Nutzt interne Helfer: _get_cell_value_safe, _get_col_letter. - # Nutzt globale Helfer: COLUMN_MAP (Block 1), logger, - # scrape_website_details (Block 13). - # Nutzt die uebergeordnete sheet_handler Instanz (Block 14). - def process_website_details(self, start_sheet_row=None, end_sheet_row=None, limit=None): - """ - EXPERIMENTELL: Extrahiert Website-Details fuer Zeilen, die in Spalte A mit 'x' markiert sind. - Schreibt die Details in eine definierte Spalte (Website Details oder AR als Fallback). - Loescht NICHT das 'x'-Flag. - - Args: - start_sheet_row (int, optional): Die 1-basierte Startzeile im Sheet. Defaults to None (ab Zeile 7). - end_sheet_row (int, optional): Die 1-basierte Endzeile im Sheet. Defaults to None (bis Ende Sheet). - limit (int, optional): Maximale Anzahl ZU VERARBEITENDER (nicht uebersprungener) Zeilen. Defaults to None (Unbegrenzt). - """ - # Verwenden Sie logger, da das Logging jetzt konfiguriert ist - # Logge den Start des Modus auf Warning, da es experimentell ist. - self.logger.warning(f"Starte Modus (EXPERIMENTELL): Website Detail Extraction fuer Zeilen mit 'x' in Spalte A. Bereich: {start_sheet_row if start_sheet_row is not None else 'Start'}-{end_sheet_row if end_sheet_row is not None else 'Ende'}, Limit: {limit if limit is not None else 'Unbegrenzt'}...") - self.logger.warning("Hinweis: Dieser Modus nutzt die globale Funktion 'scrape_website_details' (Block 13), deren Implementierung je nach Zielwebsites angepasst werden muss.") - - - # --- Daten laden --- - # Laden Sie Daten neu. Kein automatischer Startindex-Check noetig hier, - # da wir explizit nach dem 'x'-Flag suchen. - # Der load_data Aufruf ist mit retry_on_failure dekoriert (Block 2). - if not self.sheet_handler.load_data(): - self.logger.error("Fehler beim Laden der Daten fuer Website Details Extraction.") - return # Beende die Methode, wenn das Laden fehlschlaegt - - - # Holen Sie die gesamte Datenliste (inklusive Header) aus dem SheetHandler. - all_data = self.sheet_handler.get_all_data_with_headers(); - # Annahme: header_rows ist als Attribut im SheetHandler verfuegbar (Block 14). - header_rows = self.sheet_handler._header_rows; - total_sheet_rows = len(all_data) # Gesamtzahl der Zeilen im Sheet - - - # Standard Startzeile, wenn nicht manuell gesetzt - if start_sheet_row is None: start_sheet_row = header_rows + 1 # Standardmaessig ab erster Datenzeile (Zeile nach Headern) - - # Berechne Endzeile, wenn nicht manuell gesetzt - if end_sheet_row is None: end_sheet_row = total_sheet_rows # Bis zur letzten Zeile - - - # Logge den Suchbereich fuer das 'x'-Flag - self.logger.info(f"Suchbereich fuer 'x'-Flag: Sheet-Zeilen {start_sheet_row} bis {end_sheet_row}. Gesamtzeilen im Sheet: {total_sheet_rows}") - - # Pruefe, ob der Bereich gueltig ist - if start_sheet_row > end_sheet_row or start_sheet_row > total_sheet_rows: - self.logger.info("Berechneter Start liegt nach dem Ende des Bereichs oder Sheets. Keine Zeilen zu verarbeiten.") - return # Beende die Methode, wenn der Bereich leer ist - - - # --- Indizes und Buchstaben --- - # Stellen Sie sicher, dass alle benoetigten Spalten in COLUMN_MAP (Block 1) vorhanden sind - required_keys = ["ReEval Flag", "CRM Website"] # A, D - # Erstellen Sie ein Dictionary mit Schluesseln und Indizes - col_indices = {key: COLUMN_MAP.get(key) for key in required_keys} - - # Pruefen Sie, ob alle benoetigten Schluessel in COLUMN_MAP gefunden wurden - if None in col_indices.values(): - missing = [k for k, v in col_indices.items() if v is None] - self.logger.critical(f"FEHLER: Benoetigte Spaltenschluessel fehlen in COLUMN_MAP fuer process_website_details: {missing}. Breche ab.") - return # Beende die Methode bei kritischem Fehler - - # Ermitteln Sie die Indizes - reeval_col_idx = col_indices["ReEval Flag"] # A - website_col_idx = col_indices["CRM Website"] # D - - # Bestimme die ZIELSPALTE fuer die Details (Website Details ODER AR als Fallback) - details_col_idx = COLUMN_MAP.get("Website Details") # Versuche zuerst die dedizierte Spalte (Block 1 Column Map) - details_col_key_for_logging = "Website Details" # Name fuer Logging - # Wenn die dedizierte Spalte nicht gefunden wurde - if details_col_idx is None: - # Fallback auf 'Website Rohtext' (AR) - details_col_idx = COLUMN_MAP.get("Website Rohtext") # Block 1 Column Map - details_col_key_for_logging = "Website Rohtext" - # Pruefen Sie, ob der Fallback-Schluessel gefunden wurde - if details_col_idx is None: - self.logger.critical("FEHLER: Weder 'Website Details' noch 'Website Rohtext' Spaltenindex in COLUMN_MAP gefunden.") - return # Beende die Methode bei kritischem Fehler - self.logger.warning(f"Keine Spalte 'Website Details' in COLUMN_MAP, nutze '{details_col_key_for_logging}' ({self.sheet_handler._get_col_letter(details_col_idx+1)}) als Fallback.") # Logge Warnung (Block 14 _get_col_letter) - else: - # Logge die Verwendung der dedizierten Spalte - self.logger.info(f"Nutze Spalte '{details_col_key_for_logging}' ({self.sheet_handler._get_col_letter(details_col_idx+1)}) fuer Website Details.") # Logge Info (Block 14 _get_col_letter) - - - # Ermitteln Sie den Spaltenbuchstaben der Zielspalte (nutzt interne Helfer _get_col_letter Block 14) - details_col_letter = self.sheet_handler._get_col_letter(details_col_idx + 1) - - - # --- Verarbeitung --- - # Holen Sie die Batch-Groesse fuer Sheet-Updates aus Config (Block 1). - update_batch_row_limit = getattr(Config, 'UPDATE_BATCH_ROW_LIMIT', 50) - - - all_sheet_updates = [] # Gesammelte Updates fuer Batch-Schreiben ins Sheet (Liste von Dicts) - - - processed_count = 0 # Zaehlt Zeilen, die fuer die Verarbeitung in Frage kommen und in den Batch aufgenommen werden (im Rahmen des Limits). - skipped_count = 0 # Zaehlt Zeilen, die uebersprungen wurden (nicht markiert oder fehlende URL). - - - # Iteriere durch die Datenzeilen im definierten Bereich (1-basierte Sheet-Zeilennummer) - for i in range(start_sheet_row, end_sheet_row + 1): - row_index_in_list = i - 1 # 0-basierter Index in der all_data Liste - # Pruefen Sie, ob das Ende des Sheets erreicht wurde - if row_index_in_list >= total_sheet_rows: break # Ende des Sheets erreicht - - - row = all_data[row_index_in_list] # Die Rohdaten fuer diese Zeile - - - # Stellen Sie sicher, dass die Zeile nicht leer ist - if not any(cell and isinstance(cell, str) and cell.strip() for cell in row): - #self.logger.debug(f"Zeile {i}: Uebersprungen (Leere Zeile).") # Zu viel Laerm im Debug - skipped_count += 1 # Zaehlen als uebersprungene Zeile - continue # Springe zur naechsten Zeile - - - # --- Pruefung, ob Verarbeitung fuer diese Zeile noetig ist --- - # Kriterium: Zeile ist mit 'x' in Spalte A (ReEval Flag) markiert. - # UND Website URL (D) ist vorhanden und gueltig aussehend. - - # Holen Sie den Wert aus Spalte A (ReEval Flag) (nutzt interne Helfer _get_cell_value_safe) - cell_a_value = self._get_cell_value_safe(row, "ReEval Flag").strip().lower() # Block 1 Column Map - # Pruefen Sie, ob die Zelle mit 'x' markiert ist. - is_marked_for_processing = cell_a_value == "x" - - # Wenn die Zeile nicht mit 'x' markiert ist, ueberspringen - if not is_marked_for_processing: - skipped_count += 1 # Zaehlen als uebersprungene Zeile - continue # Springe zur naechsten Zeile - - - # Holen Sie den Wert aus Spalte D (CRM Website) (nutzt interne Helfer _get_cell_value_safe) - website_url = self._get_cell_value_safe(row, "CRM Website").strip() # Block 1 Column Map - # Pruefen Sie, ob die Website URL (D) vorhanden und gueltig aussehend ist. - website_url_is_valid_looking = website_url and isinstance(website_url, str) and website_url.lower() not in ["k.a.", "kein artikel gefunden", "fehler bei suche", "http:"] # Fuege "http:" hinzu basierend auf Log - - - # Verarbeitung ist noetig, wenn die Zeile mit 'x' markiert ist UND die Website URL gueltig ist. - processing_needed_for_row = is_marked_for_processing and website_url_is_valid_looking - - - # Loggen der Pruefergebnisse fuer diese Zeile auf Debug-Level - log_check = (i < start_sheet_row + 5) or (i % 100 == 0) or (processing_needed_for_row) - if log_check: - company_name = self._get_cell_value_safe(row, "CRM Name").strip() # Block 1 Column Map - self.logger.debug(f"Zeile {i} ({company_name[:50]}... Website Details Check): A='x'? {is_marked_for_processing}, D gueltig? {website_url_is_valid_looking}. Benoetigt Verarbeitung? {processing_needed_for_row}") # Gekuerzt loggen - - - # Wenn die Verarbeitung fuer diese Zeile nicht noetig ist (trotz 'x' fehlte die URL) - if not processing_needed_for_row: - skipped_count += 1 # Zaehlen als uebersprungene Zeile - # Optionale Behandlung: Wenn mit 'x' markiert, aber URL fehlt, was tun? - # Derzeit wird sie uebersprungen. Ggf. Fehler in Spalte notieren? - continue # Springe zur naechsten Zeile - - - # --- Wenn Verarbeitung noetig: Fuehre Details-Extraktion aus --- - processed_count += 1 # Zaehle die Zeile, die fuer die Verarbeitung in Frage kommt (im Rahmen des Limits zaehlen) - - # Pruefe das Limit fuer verarbeitete Zeilen - if limit is not None and isinstance(limit, int) and limit > 0 and processed_count > limit: - # Wenn das Limit erreicht ist und es ein positives Limit gibt - self.logger.info(f"Verarbeitungslimit ({limit}) fuer process_website_details erreicht. Breche weitere Zeilenpruefung ab.") - break # Brich die Schleife ab - - - self.logger.info(f"Zeile {i}: Extrahiere Website Details von {website_url[:100]}...") # Logge Start (gekuerzt) - - - details = "FEHLER: Funktion 'scrape_website_details' nicht verfuegbar" # Default Fehler, falls die Funktion nicht existiert (Sollte nicht passieren, wenn Block 13 korrekt ist) - - try: - # Rufe die globale Funktion scrape_website_details auf (Block 13). - # scrape_website_details ist mit retry_on_failure dekoriert (Block 2). - # Wenn scrape_website_details fehlschlaegt, wirft sie eine Exception oder gibt einen Fehlerwert zurueck. - details = scrape_website_details(website_url) # <<< Ruft globale Funktion (Block 13) - - # Wenn die Funktion einen Fehler geloggt hat und einen Fehlerstring im Ergebnis zurueckgibt, - # wird dies in der 'details' Variable gespeichert. - if isinstance(details, str) and (details.startswith("k.A. (Fehler") or details.startswith("FEHLER:")): - # Fehler wurde bereits in scrape_website_details geloggt. - pass # Details enthaelt bereits den Fehlerstring. - - elif not isinstance(details, str) or not details.strip(): - # Wenn die Funktion keinen String oder einen leeren String zurueckgibt. - details = "k.A. (Extraktion leer oder ungueltig)" # Standard-Fehlerwert - - - except NameError: - # Dieser Fehler sollte nicht auftreten, wenn scrape_website_details in Block 13 ist. - self.logger.critical("FEHLER: Funktion 'scrape_website_details' ist nicht definiert! Kann Details nicht extrahieren.") - # Logge den Traceback. - self.logger.debug(traceback.format_exc()) - details = "FEHLER: Funktion nicht definiert" # Setze spezifischen Fehlerwert - - except Exception as e_detail: - # Fange andere unerwartete Fehler ab, die nicht von scrape_website_details behandelt wurden. - self.logger.exception(f"Unerwarteter Fehler bei scrape_website_details fuer {website_url[:100]}...: {type(e_detail).__name__} - {e_detail}") # Logge Fehler (gekuerzt) und Traceback - details = f"k.A. (Unerwarteter Fehler: {str(e_detail)[:100]}...)" # Signalisiert Fehler (gekuerzt) - - - # Fuege Update fuer die Details-Spalte hinzu (nutzt interne Helfer _get_col_letter Block 14) - # Stellen Sie sicher, dass der Wert ein String ist. - updates_for_row = [] # Lokale Liste fuer Updates dieser Zeile - updates_for_row.append({'range': f'{details_col_letter}{i}', 'values': [[str(details)]]}) # Block 1 Column Map - self.logger.debug(f"Zeile {i}: Details extrahiert und zum Update fuer Spalte {details_col_key_for_logging} ({details_col_letter}{i}) hinzugefuegt.") # Gekuerzt loggen - - - # Sammle die Updates fuer diese Zeile in der globalen Liste all_sheet_updates. - all_sheet_updates.extend(updates_for_row) - - - # Sende gesammelte Sheet Updates wenn das Update-Batch-Limit erreicht ist. - # update_batch_row_limit wird aus Config geholt (Block 1). - # Updates pro Zeile ist 1 in diesem Modus. Anzahl der Zeilen = len(all_sheet_updates). - if len(all_sheet_updates) >= update_batch_row_limit: - self.logger.debug(f" Sende gesammelte Sheet-Updates ({len(all_sheet_updates)} Zellen)...") - # Nutzt die batch_update_cells Methode des Sheet Handlers (Block 14) mit Retry. - # Wenn es fehlschlaegt, wird es intern geloggt. - success = self.sheet_handler.batch_update_cells(all_sheet_updates) - if success: - self.logger.info(f" Sheet-Update fuer {len(all_sheet_updates)} Zellen erfolgreich.") - # Der Fehlerfall wird von batch_update_cells geloggt - - # Leere die gesammelten Updates nach dem Senden. - all_sheet_updates = [] - - - # Kleine Pause nach jeder Extraktion (nutzt Config Block 1). - # Dieser Modus macht API calls (ueber scrape_website_details und dessen Helfer), also Pause einbauen. - pause_duration = getattr(Config, 'RETRY_DELAY', 5) * 0.2 - #self.logger.debug(f"Warte {pause_duration:.2f}s nach Extraktion...") # Zu viel Laerm im Debug - time.sleep(pause_duration) - - - # --- Finale Sheet Updates senden --- - # Sende alle verbleibenden gesammelten Updates in einem letzten Batch-Update. - if all_sheet_updates: - self.logger.info(f"Sende FINALE gesammelte Sheet-Updates ({len(all_sheet_updates)} Zellen)...") - # Nutzt die batch_update_cells Methode des Sheet Handlers (Block 14) mit Retry. - success = self.sheet_handler.batch_update_cells(all_sheet_updates) - if success: - self.logger.info(f"FINALES Sheet-Update erfolgreich.") - # Der Fehlerfall wird von batch_update_cells geloggt - - - # Logge den Abschluss des Modus - self.logger.info(f"Modus 'website_details' abgeschlossen. {processed_count} Zeilen verarbeitet (in Batch aufgenommen), {skipped_count} Zeilen uebersprungen.") - # Keine Pause nach diesem Modus noetig, da die naechste Aktion im Dispatcher (Block 34) folgt. - - - # --- Methode zum Verarbeiten von Wiki-Updates basierend auf ChatGPT Vorschlaegen --- - # Diese Methode verarbeitet Zeilen, in denen S gesetzt ist (nicht in Endzustand), - # prueft ob U eine valide und andere Wiki-URL ist und fuehrt entsprechende Updates durch. - # Basierend auf process_wiki_updates_from_chatgpt aus Teil 4. - # Nutzt interne Helfer: _get_cell_value_safe, _get_col_letter. - # Nutzt globale Helfer: COLUMN_MAP (Block 1), logger, Config (Block 1), time, - # is_valid_wikipedia_article_url (Block 12). - # Nutzt die uebergeordnete sheet_handler Instanz (Block 14). - def process_wiki_updates_from_chatgpt(self, start_sheet_row=None, end_sheet_row=None, limit=None): - """ - Identifiziert Zeilen, in denen Status S gesetzt ist, aber NICHT auf einem Endzustand - (OK, X (UPDATED/COPIED/INVALID)), prueft ob U eine *valide* und *andere* Wiki-URL ist. - - Wenn ja: Kopiert U->M, markiert S='X (URL Copied)', U='URL uebernommen', loescht - abhaengige Wiki-Spalten (N-V, AN, AO, AP, AX), setzt ReEval-Flag A='x'. - - Wenn nein (U keine URL, U==M, oder U ungueltig): LOESCHT den Inhalt von U und - markiert S als 'X (Invalid Suggestion)'. - Verarbeitet maximal limit Zeilen. - - Args: - start_sheet_row (int, optional): Die 1-basierte Startzeile im Sheet. Defaults to None (ab Zeile 7). - end_sheet_row (int, optional): Die 1-basierte Endzeile im Sheet. Defaults to None (bis Ende Sheet). - limit (int, optional): Maximale Anzahl ZU PRUEFENDER Zeilen. Defaults to None (Unbegrenzt). - """ - # Verwenden Sie logger, da das Logging jetzt konfiguriert ist - # Logge die Konfiguration des Modus - self.logger.info(f"Starte Modus 'wiki_updates_from_chatgpt' (S, U, M, N-V, AN, AO, AX, AP, A). Bereich: {start_sheet_row if start_sheet_row is not None else 'Start'}-{end_sheet_row if end_sheet_row is not None else 'Ende'}, Limit: {limit if limit is not None else 'Unbegrenzt'}...") - - - # --- Daten laden --- - # Laden Sie Daten neu. Kein automatischer Startindex-Check noetig hier, - # da wir nach Status S suchen. - # Der load_data Aufruf ist mit retry_on_failure dekoriert (Block 2). - if not self.sheet_handler.load_data(): - self.logger.error("Fehler beim Laden der Daten fuer Wiki Updates.") - return # Beende die Methode, wenn das Laden fehlschlaegt - - - # Holen Sie die gesamte Datenliste (inklusive Header) aus dem SheetHandler. - all_data = self.sheet_handler.get_all_data_with_headers() - # Annahme: header_rows ist als Attribut im SheetHandler verfuegbar (Block 14). - header_rows = self.sheet_handler._header_rows - total_sheet_rows = len(all_data) # Gesamtzahl der Zeilen im Sheet - - - # Standard Startzeile, wenn nicht manuell gesetzt - if start_sheet_row is None: start_sheet_row = header_rows + 1 # Standardmaessig ab erster Datenzeile (Zeile nach Headern) - - # Berechne Endzeile, wenn nicht manuell gesetzt - if end_sheet_row is None: end_sheet_row = total_sheet_rows # Bis zur letzten Zeile - - - # Logge den Suchbereich fuer Status S - self.logger.info(f"Suchbereich fuer Status S: Sheet-Zeilen {start_sheet_row} bis {end_sheet_row}. Gesamtzeilen im Sheet: {total_sheet_rows}") - - # Pruefe, ob der Bereich gueltig ist - if start_sheet_row > end_sheet_row or start_sheet_row > total_sheet_rows: - self.logger.info("Berechneter Start liegt nach dem Ende des Bereichs oder Sheets. Keine Zeilen zu verarbeiten.") - return # Beende die Methode, wenn der Bereich leer ist - - - # --- Indizes und Buchstaben --- - # Stellen Sie sicher, dass alle benoetigten Spalten in COLUMN_MAP (Block 1) vorhanden sind - required_keys = [ - "Chat Wiki Konsistenzpruefung", "Chat Vorschlag Wiki Artikel", "Wiki URL", # S, U, M (Pruefkriterien / Daten) - "Wikipedia Timestamp", "Wiki Verif. Timestamp", "Timestamp letzte Pruefung", "Version", # AN, AX, AO, AP (Spalten zum Loeschen) - "ReEval Flag", # A (ReEval Flag setzen) - "Wiki Absatz", "Wiki Branche", "Wiki Umsatz", "Wiki Mitarbeiter", "Wiki Kategorien", # N-R (Spalten zum Loeschen) - "Chat Begruendung Wiki Inkonsistenz", "Begruendung bei Abweichung", # T, V (Spalten zum Loeschen) - # AY (SerpAPI Wiki Search Timestamp) wird ebenfalls geleert, da abhaengig von M. - "SerpAPI Wiki Search Timestamp" # AY (Spalte zum Leeren) - ] - # Erstellen Sie ein Dictionary mit Schluesseln und Indizes - col_indices = {key: COLUMN_MAP.get(key) for key in required_keys} - - # Pruefen Sie, ob alle benoetigten Schluessel in COLUMN_MAP gefunden wurden - if None in col_indices.values(): - missing = [k for k, v in col_indices.items() if v is None] - self.logger.critical(f"FEHLER: Benoetigte Spaltenschluessel fehlen in COLUMN_MAP fuer process_wiki_updates_from_chatgpt: {missing}. Breche ab.") - return # Beende die Methode bei kritischem Fehler - - - # Ermitteln Sie die Spaltenbuchstaben fuer Updates/Leerung (nutzt interne Helfer _get_col_letter Block 14) - s_letter = self.sheet_handler._get_col_letter(col_indices["Chat Wiki Konsistenzpruefung"] + 1) # Status S - u_letter = self.sheet_handler._get_col_letter(col_indices["Chat Vorschlag Wiki Artikel"] + 1) # Vorschlag U - m_letter = self.sheet_handler._get_col_letter(col_indices["Wiki URL"] + 1) # Wiki URL M - a_letter = self.sheet_handler._get_col_letter(col_indices["ReEval Flag"] + 1) # ReEval Flag A - - # Spalten N-V leeren. - # N ist Wiki Absatz, V ist Begruendung bei Abweichung. - n_idx = col_indices["Wiki Absatz"] - v_idx = col_indices["Begruendung bei Abweichung"] - # Erstellen Sie den Bereichsnamen (z.B. "N:V") - n_letter = self.sheet_handler._get_col_letter(n_idx + 1) - v_letter = self.sheet_handler._get_col_letter(v_idx + 1) - nv_range_letter = f'{n_letter}:{v_letter}' # z.B. N:V - # Erstellen Sie eine Liste von leeren Strings fuer diesen Bereich - empty_nv_values = [''] * (v_idx - n_idx + 1) # Anzahl der Spalten = V_Index - N_Index + 1 - - - # Timestamps AN, AO, AP, AX, AY leeren. - # Diese werden von anderen Schritten gesetzt und sollen hier zurueckgesetzt werden. - an_letter = self.sheet_handler._get_col_letter(col_indices["Wikipedia Timestamp"] + 1) # AN (Wiki Extraction TS) - ao_letter = self.sheet_handler._get_col_letter(col_indices["Timestamp letzte Pruefung"] + 1) # AO (Chat Evaluation TS) - ap_letter = self.sheet_handler._get_col_letter(col_indices["Version"] + 1) # AP (Version) - ax_letter = self.sheet_handler._get_col_letter(col_indices["Wiki Verif. Timestamp"] + 1) # AX (Wiki Verif. TS) - ay_letter = self.sheet_handler._get_col_letter(col_indices["SerpAPI Wiki Search Timestamp"] + 1) # AY (SerpAPI Wiki TS) - - - # --- Verarbeitung --- - # Holen Sie die Batch-Groesse fuer Sheet-Updates aus Config (Block 1). - update_batch_row_limit = getattr(Config, 'UPDATE_BATCH_ROW_LIMIT', 50) - - - all_sheet_updates = [] # Gesammelte Updates fuer Batch-Schreiben ins Sheet (Liste von Dicts) - - - processed_rows_count = 0 # Zaehlt Zeilen, die geprueft werden (im Rahmen des Limits zaehlen). - skipped_count = 0 # Zaehlt Zeilen, die uebersprungen werden (Status S im Endzustand etc.). - updated_url_count = 0 # Zaehlt Zeilen, wo U -> M kopiert wurde. - cleared_suggestion_count = 0 # Zaehlt Zeilen, wo Vorschlag U geloescht wurde. - - - # Iteriere durch die Datenzeilen im definierten Bereich (1-basierte Sheet-Zeilennummer) - for i in range(start_sheet_row, end_sheet_row + 1): - row_index_in_list = i - 1 # 0-basierter Index in der all_data Liste - # Pruefen Sie, ob das Ende des Sheets erreicht wurde - if row_index_in_list >= total_sheet_rows: break # Ende des Sheets erreicht - - - row = all_data[row_index_in_list] # Die Rohdaten fuer diese Zeile - - - # Stellen Sie sicher, dass die Zeile nicht leer ist - if not any(cell and isinstance(cell, str) and cell.strip() for cell in row): - #self.logger.debug(f"Zeile {i}: Uebersprungen (Leere Zeile).") # Zu viel Laerm im Debug - skipped_count += 1 # Zaehlen als uebersprungene Zeile - continue # Springe zur naechsten Zeile - - - # --- Pruefung, ob Verarbeitung fuer diese Zeile noetig ist --- - # Kriterium: Status S ist gesetzt (nicht leer) UND NICHT einer der Endzustaende. - # Endzustaende: "OK", "X (UPDATED)", "X (URL COPIED)", "X (INVALID SUGGESTION)" - - # Holen Sie den Wert aus Spalte S (Chat Wiki Konsistenzpruefung) (nutzt interne Helfer _get_cell_value_safe) - s_value = self._get_cell_value_safe(row, "Chat Wiki Konsistenzpruefung").strip() # Block 1 Column Map - s_value_upper = s_value.upper() - - # Definieren Sie die Endzustaende (Grossbuchstaben) - s_end_states = ["OK", "X (UPDATED)", "X (URL COPIED)", "X (INVALID SUGGESTION)"] - - # Verarbeitung ist noetig, wenn S nicht leer ist UND S NICHT im Endzustand ist. - processing_needed_for_row = s_value and s_value_upper not in s_end_states - - - # Loggen der Pruefergebnisse fuer diese Zeile auf Debug-Level - log_check = (i < start_sheet_row + 5) or (i % 100 == 0) or (processing_needed_for_row) - if log_check: - self.logger.debug(f"Zeile {i} (Wiki Update Check): Status S='{s_value}'. Benoetigt Verarbeitung? {processing_needed_for_row}") - - - # Wenn die Verarbeitung fuer diese Zeile nicht noetig ist - if not processing_needed_for_row: - skipped_count += 1 # Zaehlen als uebersprungene Zeile - continue # Springe zur naechsten Zeile - - - # --- Wenn Verarbeitung noetig: Pruefe Vorschlag U und handle --- - processed_rows_count += 1 # Zaehle die Zeile, die geprueft wird (im Rahmen des Limits zaehlen). - - # Pruefe das Limit fuer verarbeitete Zeilen - if limit is not None and isinstance(limit, int) and limit > 0 and processed_rows_count > limit: - # Wenn das Limit erreicht ist und es ein positives Limit gibt - self.logger.info(f"Verarbeitungslimit ({limit}) fuer process_wiki_updates_from_chatgpt erreicht. Breche weitere Zeilenpruefung ab.") - break # Brich die Schleife ab - - - # Holen Sie die Werte aus Spalte U (Chat Vorschlag Wiki Artikel) und M (Wiki URL) (nutzt interne Helfer _get_cell_value_safe) - vorschlag_u = self._get_cell_value_safe(row, "Chat Vorschlag Wiki Artikel").strip() # Block 1 Column Map - url_m = self._get_cell_value_safe(row, "Wiki URL").strip() # Block 1 Column Map - - - self.logger.info(f"Zeile {i}: Pruefe Wiki-Vorschlag U='{vorschlag_u[:100]}...' (aktuell M='{url_m[:100]}...')...") # Gekuerzt loggen - - is_update_candidate = False # Flag, ob U eine gueltige, neue URL ist, die uebernommen werden soll. - new_url = "" # Die URL, die ggf. in M kopiert wird. - - - # Kriterium 1: Ist Vorschlag U ueberhaupt ein String und sieht nach Wikipedia aus? - condition1_u_is_wiki_url = vorschlag_u and isinstance(vorschlag_u, str) and "wikipedia.org/wiki/" in vorschlag_u.lower() and vorschlag_u.lower().startswith(("http://", "https://")) # Check auf Schema hinzugefuegt - - - # Wenn der Vorschlag U wie eine Wikipedia-URL aussieht - if condition1_u_is_wiki_url: - new_url = vorschlag_u # Nehme den Vorschlag als potenzielle neue URL - # Kriterium 2: Unterscheidet sich der Vorschlag U von der aktuellen URL in M? - # Pruefe, ob die neue URL nicht identisch mit der aktuellen M-URL ist. - condition2_u_differs_m = new_url != url_m - - # Wenn sich der Vorschlag U von der aktuellen M-URL unterscheidet - if condition2_u_differs_m: - self.logger.debug(f" -> Vorschlag U ({new_url[:100]}...) unterscheidet sich von M ({url_m[:100]}). Pruefe Validitaet...") # Gekuerzt loggen - # Kriterium 3: Ist die vorgeschlagene URL ein valider Wikipedia-Artikel (nicht Weiterleitung, Begriffsklaerung, Fehler)? - # Nutzt globale Funktion is_valid_wikipedia_article_url (Block 12) mit Retry Decorator (Block 2). - # is_valid_wikipedia_article_url wirft Exception bei endgueltigem Fehler. - try: - condition3_u_is_valid = is_valid_wikipedia_article_url(new_url) # Nutzt globalen Helfer (Block 12) - # Wenn die vorgeschlagene URL ein valider Artikel ist - if condition3_u_is_valid: - is_update_candidate = True # Alle Kriterien erfuellt! Der Vorschlag kann uebernommen werden. - self.logger.debug(f" -> URL '{new_url[:100]}...' ist ein VALIDER Artikel laut API Check.") # Gekuerzt loggen - else: - # Wenn die vorgeschlagene URL nicht valide ist - self.logger.debug(f" -> URL '{new_url[:100]}...' ist KEIN valider Artikel laut API Check.") # Gekuerzt loggen - - except Exception as e_validity_check: - # Wenn is_valid_wikipedia_article_url eine Exception wirft (nach Retries) - # Der Fehler wird bereits vom retry_on_failure Decorator geloggt. - self.logger.error(f"FEHLER bei Validitaetspruefung von Vorschlag U '{new_url[:100]}...': {e_validity_check}") # Gekuerzt loggen - # Bei Fehler bleibt is_update_candidate False. - pass # Faert fort - - - else: - # Wenn der Vorschlag U identisch mit der aktuellen M-URL ist - self.logger.debug(f" -> Vorschlag U ist identisch mit URL M. Wird nicht uebernommen.") - - else: - # Wenn der Vorschlag U nicht wie eine Wikipedia-URL aussieht - self.logger.debug(f" -> Vorschlag U ('{vorschlag_u[:100]}...') ist keine Wikipedia URL. Wird nicht uebernommen.") # Gekuerzt loggen - - - # --- Verarbeitung des Kandidaten ODER Loeschen des ungueltigen Vorschlags --- - updates_for_row = [] # Lokale Liste fuer Updates DIESER Zeile - - if is_update_candidate: - # Fall 1: Gueltiges Update durchfuehren (Vorschlag U wird in M kopiert) - self.logger.info(f"Zeile {i}: Update-Kandidat VALIDIERUNG ERFOLGREICH. Kopiere U->M, setze ReEval-Flag 'x', loesche abhaengige Spalten.") - updated_url_count += 1 # Zaehle die uebernommene URL - - # Updates sammeln (M, S, U, N-V, AN, AO, AP, AX, AY, A) (nutzt interne Helfer _get_col_letter Block 14) - updates_for_row.append({'range': f'{m_letter}{i}', 'values': [[new_url]]}) # Setze die neue URL in Spalte M (Block 1 Column Map) - updates_for_row.append({'range': f'{s_letter}{i}', 'values': [["X (URL Copied)"]]}) # Setze Status S auf "X (URL Copied)" (Block 1 Column Map) - updates_for_row.append({'range': f'{u_letter}{i}', 'values': [["URL uebernommen"]]}) # Schreibe Info in Spalte U (Block 1 Column Map) - updates_for_row.append({'range': f'{a_letter}{i}', 'values': [["x"]]}) # Setze ReEval Flag (A) auf 'x' (Block 1 Column Map) - - # Leere Spalten N-V. - # Fuege das Update zum Leeren des Bereichs V-Y hinzu, falls der Bereichsname ermittelt werden konnte. - if nv_range_letter: # Pruefe, ob der Bereichsname ermittelt werden konnte. - updates_for_row.append({'range': f'{n_letter}{i}:{v_letter}{i}', 'values': [empty_nv_values]}) # Block 1 Column Map, lokale Variable - else: - self.logger.warning(f"Konnte Spaltenbereich N-V ({n_letter}:{v_letter}) fuer Leerung nicht ermitteln fuer Zeile {i}. Leerung uebersprungen.") - - - # Leere Timestamps AN, AO, AP, AX, AY. - # Dies setzt die Zeile zurueck, damit andere Schritte sie spaeter bearbeiten. - updates_for_row.append({'range': f'{an_letter}{i}', 'values': [['']]}) # AN (Wiki Extraction TS) Block 1 Column Map - updates_for_row.append({'range': f'{ao_letter}{i}', 'values': [['']]}) # AO (Chat Evaluation TS) Block 1 Column Map - updates_for_row.append({'range': f'{ap_letter}{i}', 'values': [['']]}) # AP (Version) Block 1 Column Map - updates_for_row.append({'range': f'{ax_letter}{i}', 'values': [['']]}) # AX (Wiki Verif. TS) Block 1 Column Map - updates_for_row.append({'range': f'{ay_letter}{i}', 'values': [['']]}) # AY (SerpAPI Wiki TS) Block 1 Column Map - - - else: - # Fall 2: Ungueltigen Vorschlag loeschen/markieren - # Wenn der Vorschlag U nicht uebernommen wird (weil ungueltig oder identisch mit M). - self.logger.info(f"Zeile {i}: Vorschlag U ('{vorschlag_u[:100]}...') ist ungueltig/identisch. Loesche U und setze Status S auf 'X (Invalid Suggestion)'.") # Gekuerzt loggen - cleared_suggestion_count += 1 # Zaehle den bereinigten Vorschlag - - # Updates sammeln (S, U) (nutzt interne Helfer _get_col_letter Block 14) - updates_for_row.append({'range': f'{s_letter}{i}', 'values': [["X (Invalid Suggestion)"]]}) # Setze Status S auf "X (Invalid Suggestion)" (Block 1 Column Map) - updates_for_row.append({'range': f'{u_letter}{i}', 'values': [[""]]}) # Loesche den Vorschlag in Spalte U (Block 1 Column Map) - # KEIN ReEval-Flag (A) setzen in diesem Fall. - - - # Sammle die Updates fuer diese Zeile in der globalen Liste all_sheet_updates. - all_sheet_updates.extend(updates_for_row) - - - # Sende gesammelte Sheet Updates wenn das Update-Batch-Limit erreicht ist. - # update_batch_row_limit wird aus Config geholt (Block 1). - # Die Anzahl der Updates pro Zeile variiert stark (ca. 2 bei ungueltigem Vorschlag, ca. 10+ bei gueltigem). - # Pruefen Sie einfach die Laenge der gesammelten Liste. - if len(all_sheet_updates) >= update_batch_row_limit * 5: # Grobe Schaetzung: im Schnitt 5 Updates pro Zeile - self.logger.debug(f" Sende gesammelte Sheet-Updates ({len(all_sheet_updates)} Zellen)...") - # Nutzt die batch_update_cells Methode des Sheet Handlers (Block 14) mit Retry. - # Wenn es fehlschlaegt, wird es intern geloggt. - success = self.sheet_handler.batch_update_cells(all_sheet_updates) - if success: - self.logger.info(f" Sheet-Update fuer {len(all_sheet_updates)} Zellen erfolgreich.") - # Der Fehlerfall wird von batch_update_cells geloggt - - # Leere die gesammelten Updates nach dem Senden. - all_sheet_updates = [] - - - # Kleine Pause nach jeder geprueften Zeile (nutzt Config Block 1). - # Dieser Modus macht API calls (ueber is_valid_wikipedia_article_url), also Pause einbauen. - pause_duration = getattr(Config, 'RETRY_DELAY', 5) * 0.2 - #self.logger.debug(f"Warte {pause_duration:.2f}s nach Pruefung...") # Zu viel Laerm im Debug - time.sleep(pause_duration) - - - # --- Finale Sheet Updates senden --- - # Sende alle verbleibenden gesammelten Updates in einem letzten Batch-Update. - if all_sheet_updates: - self.logger.info(f"Sende FINALE gesammelte Sheet-Updates ({len(all_sheet_updates)} Zellen)...") - # Nutzt die batch_update_cells Methode des Sheet Handlers (Block 14) mit Retry. - success = self.sheet_handler.batch_update_cells(all_sheet_updates) - if success: - self.logger.info(f"FINALES Sheet-Update erfolgreich.") - # Der Fehlerfall wird von batch_update_cells geloggt - - - # Logge den Abschluss des Modus - self.logger.info(f"Modus 'wiki_updates_from_chatgpt' abgeschlossen. {processed_rows_count} Zeilen geprueft, {updated_url_count} URLs kopiert & fuer ReEval markiert, {cleared_suggestion_count} ungueltige Vorschlaege geloescht/markiert, {skipped_count} Zeilen uebersprungen.") - # Keine Pause nach diesem Modus noetig, da die naechste Aktion im Dispatcher (Block 34) folgt. - - - # --- Methode zur Re-Extraktion von Wiki-Daten bei fehlendem Timestamp AN --- - # Diese Methode identifiziert Zeilen mit M gefuellt und AN leer und fuehrt _process_single_row (Block 19) fuer diese aus. - # Nutzt interne Helfer: _get_cell_value_safe, _process_single_row. - # Nutzt globale Helfer: COLUMN_MAP (Block 1), logger. - # Nutzt die uebergeordnete sheet_handler Instanz (Block 14). - def process_wiki_reextract_missing_an(self, start_sheet_row=None, end_sheet_row=None, limit=None): - """ - Identifiziert Zeilen, bei denen eine Wiki URL (M) vorhanden ist, aber der - Wikipedia Timestamp (AN) fehlt. Fuehrt _process_single_row fuer diese Zeilen aus, - beschraenkt auf den 'wiki'-Schritt und mit force_reeval=True, um die Extraktion - erneut zu versuchen. - - Args: - start_sheet_row (int, optional): Die 1-basierte Startzeile im Sheet. Defaults to None (automatische Ermittlung basierend auf leeren AN). - end_sheet_row (int, optional): Die 1-basierte Endzeile im Sheet. Defaults to None (bis Ende Sheet). - limit (int, optional): Maximale Anzahl ZU VERARBEITENDER Zeilen. Defaults to None (Unbegrenzt). - """ - # Verwenden Sie logger, da das Logging jetzt konfiguriert ist - # Logge die Konfiguration des Modus - self.logger.info(f"Starte Modus 'wiki_reextract_missing_an' (M gefuellt & AN leer). Bereich: {start_sheet_row if start_sheet_row is not None else 'Start'}-{end_sheet_row if end_sheet_row is not None else 'Ende'}, Limit: {limit if limit is not None else 'Unbegrenzt'}...") - - - # --- Daten laden und Startzeile ermitteln --- - # Automatische Ermittlung der Startzeile, wenn nicht manuell gesetzt. - # Dieser Modus sucht nach leeren AN mit gefuelltem M. Die automatische Startzeile - # basierend auf leeren AN ist ein guter Startpunkt. - if start_sheet_row is None: - self.logger.info("Automatische Ermittlung der Startzeile basierend auf leeren AN...") - # Nutzt get_start_row_index des Sheet Handlers (Block 14). Prueft auf leeren AN (Block 1 Column Map). - # Standardmaessig ab Zeile 7 - start_data_index_no_header = self.sheet_handler.get_start_row_index(check_column_key="Wikipedia Timestamp", min_sheet_row=7) - - # Wenn get_start_row_index -1 zurueckgibt (Fehler) - if start_data_index_no_header == -1: - self.logger.error("FEHLER bei automatischer Ermittlung der Startzeile. Breche Modus ab.") - return # Beende die Methode - - # Berechne die 1-basierte Sheet-Startzeile aus dem 0-basierten Daten-Index - start_sheet_row = start_data_index_no_header + self.sheet_handler._header_rows + 1 # Block 14 SheetHandler Attribut - self.logger.info(f"Automatisch ermittelte Startzeile (erste leere AN Zelle): {start_sheet_row}") - else: - # Wenn start_sheet_row manuell gesetzt wurde, laden Sie die Daten trotzdem neu, um aktuell zu sein. - # Der load_data Aufruf ist mit retry_on_failure dekoriert (Block 2). - if not self.sheet_handler.load_data(): - self.logger.error("Fehler beim Laden der Daten fuer wiki_reextract_missing_an.") - return # Beende die Methode, wenn das Laden fehlschlaegt - - - # Holen Sie die gesamte Datenliste (inklusive Header) aus dem SheetHandler. - all_data = self.sheet_handler.get_all_data_with_headers(); - # Annahme: header_rows ist als Attribut im SheetHandler verfuegbar (Block 14). - header_rows = self.sheet_handler._header_rows; - total_sheet_rows = len(all_data) # Gesamtzahl der Zeilen im Sheet - - - # Berechne Endzeile, wenn nicht manuell gesetzt - if end_sheet_row is None: - end_sheet_row = total_sheet_rows # Bis zur letzten Zeile - - - # Logge den verarbeitungsbereich - self.logger.info(f"Suchbereich fuer M gefuellt & AN leer: Sheet-Zeilen {start_sheet_row} bis {end_sheet_row}. Gesamtzeilen im Sheet: {total_sheet_rows}") - - # Pruefe, ob der Bereich gueltig ist (Start <= Ende und Start nicht ueber Gesamtzeilen) - if start_sheet_row > end_sheet_row or start_sheet_row > total_sheet_rows: - self.logger.info("Berechneter Start liegt nach dem Ende des Bereichs oder Sheets. Keine Zeilen zu verarbeiten.") - return # Beende die Methode, wenn der Bereich leer ist - - - # --- Indizes --- - # Stellen Sie sicher, dass alle benoetigten Spalten in COLUMN_MAP (Block 1) vorhanden sind - required_keys = ["Wiki URL", "Wikipedia Timestamp"] # M, AN (Pruefkriterien) - # Erstellen Sie ein Dictionary mit Schluesseln und Indizes - col_indices = {key: COLUMN_MAP.get(key) for key in required_keys} - - # Pruefen Sie, ob alle benoetigten Schluessel in COLUMN_MAP gefunden wurden - if None in col_indices.values(): - missing = [k for k, v in col_indices.items() if v is None] - self.logger.critical(f"FEHLER: Benoetigte Spaltenschluessel fehlen in COLUMN_MAP fuer wiki_reextract_missing_an: {missing}. Breche ab.") - return # Beende die Methode bei kritischem Fehler - - # Ermitteln Sie die Indizes - m_col_idx = col_indices["Wiki URL"] - an_col_idx = col_indices["Wikipedia Timestamp"] - - - # --- Verarbeitung --- - processed_count = 0 # Zaehlt Zeilen, die an _process_single_row uebergeben wurden (im Rahmen des Limits zaehlen). - skipped_count = 0 # Zaehlt Zeilen, die uebersprungen wurden. - - - # Iteriere durch die Datenzeilen im definierten Bereich (1-basierte Sheet-Zeilennummer) - for i in range(start_sheet_row, end_sheet_row + 1): - row_index_in_list = i - 1 # 0-basierter Index in der all_data Liste - # Pruefen Sie, ob das Ende des Sheets erreicht wurde - if row_index_in_list >= total_sheet_rows: break # Ende des Sheets erreicht - - - row = all_data[row_index_in_list] # Die Rohdaten fuer diese Zeile - - - # Stellen Sie sicher, dass die Zeile nicht leer ist - if not any(cell and isinstance(cell, str) and cell.strip() for cell in row): - #self.logger.debug(f"Zeile {i}: Uebersprungen (Leere Zeile).") # Zu viel Laerm im Debug - skipped_count += 1 # Zaehlen als uebersprungene Zeile - continue # Springe zur naechsten Zeile - - - # --- Pruefung, ob Verarbeitung fuer diese Zeile noetig ist --- - # Kriterium: Wiki URL (M) ist vorhanden und gueltig aussehend. - # UND Wikipedia Timestamp (AN) ist leer. - - # Holen Sie die Werte aus den entsprechenden Spalten (nutzt interne Helfer _get_cell_value_safe) - m_value = self._get_cell_value_safe(row, "Wiki URL").strip() # Block 1 Column Map - an_value = self._get_cell_value_safe(row, "Wikipedia Timestamp").strip() # Block 1 Column Map - - # Pruefen Sie, ob M gefuellt und gueltig aussieht. - is_m_valid_looking = m_value and isinstance(m_value, str) and "wikipedia.org/wiki/" in m_value.lower() and m_value.lower() not in ["k.a.", "kein artikel gefunden", "fehler bei suche", "http:"] # Fuege "http:" hinzu basierend auf Log - - # Pruefen Sie, ob AN leer ist. - is_an_empty = not an_value - - # Verarbeitung ist noetig, wenn M gueltig aussieht UND AN leer ist. - processing_needed_for_row = is_m_valid_looking and is_an_empty - - - # Loggen der Pruefergebnisse fuer diese Zeile auf Debug-Level - log_check = (i < start_sheet_row + 5) or (i % 100 == 0) or (processing_needed_for_row) - if log_check: - company_name = self._get_cell_value_safe(row, "CRM Name").strip() # Block 1 Column Map - self.logger.debug(f"Zeile {i} ({company_name[:50]}... Wiki Re-extract Check): M ('{m_value[:50]}...') gueltig? {is_m_valid_looking}, AN leer? {is_an_empty}. Benoetigt Verarbeitung? {processing_needed_for_row}") # Gekuerzt loggen - - - # Wenn die Verarbeitung fuer diese Zeile nicht noetig ist - if not processing_needed_for_row: - skipped_count += 1 # Zaehlen als uebersprungene Zeile - continue # Springe zur naechsten Zeile - - - # --- Wenn Verarbeitung noetig: Rufe _process_single_row auf --- - processed_count += 1 # Zaehle die Zeile, die an _process_single_row uebergeben wird (im Rahmen des Limits zaehlen) - - # Pruefe das Limit fuer verarbeitete Zeilen - if limit is not None and isinstance(limit, int) and limit > 0 and processed_count > limit: - # Wenn das Limit erreicht ist und es ein positives Limit gibt - self.logger.info(f"Verarbeitungslimit ({limit}) fuer wiki_reextract_missing_an erreicht. Breche weitere Zeilenpruefung ab.") - break # Brich die Schleife ab - - - self.logger.info(f"Zeile {i}: M gefuellt & AN leer. Versuche Wiki-Re-Extraktion ueber _process_single_row...") - - try: - # RUFE _process_single_row AUF (Block 19). - # Mit steps_to_run={'wiki'} und force_reeval=True, - # damit nur der Wiki-Schritt ausgefuehrt wird und Timestamps ignoriert werden. - # Im Re-Extract Modus loeschen wir das 'x'-Flag NICHT automatisch. - self._process_single_row( - row_num_in_sheet = i, - row_data = row, # Uebergibt die aktuellen Rohdaten der Zeile - steps_to_run = {'wiki'}, # <<< NUR der Wiki-Schritt soll laufen - force_reeval = True, # <<< Erzwingt die Ausfuehrung des 'wiki' Schritts (ignoriert AN, S). - clear_x_flag = False # <<< 'x'-Flag wird in diesem Modus NICHT geloescht - ) - # _process_single_row (Block 19) loggt intern den Abschluss und fuehrt das Sheet-Update durch. - - except Exception as e_proc: - # Wenn _process_single_row einen Fehler wirft (nachdem interne Retries aufgaben), - # fangen wir ihn hier, loggen ihn und fahren mit der naechsten Zeile fort. - self.logger.exception(f"FEHLER bei Verarbeitung von Zeile {i} in wiki_reextract_missing_an: {e_proc}") - # Hier koennen Sie z.B. einen Fehlerindikator in eine spezielle Spalte im Sheet schreiben lassen. - # Dieses Update muesste dann separat oder im naechsten Lauf behandelt werden. - - # _process_single_row beinhaltet bereits eine kleine Pause am Ende. - # Hier ist keine zusaetzliche Pause noetig, wenn _process_single_row erfolgreich war. - # Wenn _process_single_row eine Exception wirft, kann hier eine kurze Pause sinnvoll sein. - # time.sleep(0.1) # Optional: Kurze Pause bei Fehler nach Exception - - - # Logge den Abschluss des Modus - self.logger.info(f"Modus 'wiki_reextract_missing_an' abgeschlossen. {processed_count} Zeilen an _process_single_row uebergeben, {skipped_count} Zeilen uebersprungen.") - # Keine Pause nach diesem Modus noetig, da die naechste Aktion im Dispatcher (Block 34) folgt. - - -# ============================================================================== -# Ende DataProcessor Klasse Utility: Other Specific Tasks Block -# ============================================================================== # ============================================================================== # Ende DataProcessor Klasse