#!/usr/bin/env python3 """ data_processor.py Zentrale Klasse zur Orchestrierung und Verarbeitung von Unternehmensdaten. Enthält die Logik für die Verarbeitung einzelner Zeilen sowie die Steuerung verschiedener Batch-Modi und Dienstprogramme. """ __version__ = "v2.0.3" import logging import time import traceback import concurrent.futures import threading import pickle import json import os import re from datetime import datetime import pandas as pd import numpy as np from sklearn.model_selection import train_test_split, GridSearchCV from sklearn.impute import SimpleImputer from sklearn.ensemble import RandomForestClassifier from sklearn.metrics import accuracy_score, classification_report, confusion_matrix from imblearn.over_sampling import SMOTE from imblearn.pipeline import Pipeline as ImbPipeline from concurrent.futures import ThreadPoolExecutor, as_completed # Import der abhängigen Module from config import Config, COLUMN_MAP, MODEL_FILE, IMPUTER_FILE, PATTERNS_FILE_JSON from helpers import ( retry_on_failure, serp_website_lookup, get_website_raw, scrape_website_details, summarize_website_content, URL_CHECK_MARKER, evaluate_branche_chatgpt, get_numeric_filter_value, initialize_target_schema, search_linkedin_contacts, is_valid_wikipedia_article_url, verify_wiki_article_chatgpt, generate_fsm_pitch, get_col_idx, summarize_wikipedia_article, evaluate_branches_batch ) # Klassen-Imports from google_sheet_handler import GoogleSheetHandler from wikipedia_scraper import WikipediaScraper class DataProcessor: def __init__(self, sheet_handler, wiki_scraper): self.logger = logging.getLogger(__name__ + ".DataProcessor") self.logger.info("Initialisiere DataProcessor...") if not isinstance(sheet_handler, GoogleSheetHandler): raise ValueError("...") if not isinstance(wiki_scraper, WikipediaScraper): raise ValueError("...") self.sheet_handler = sheet_handler self.wiki_scraper = wiki_scraper self.model = None self.imputer = None self._expected_features = None self.is_setup_complete = False self.schema_data = None # Wichtig: Neues Attribut def setup(self): self.logger.info("Führe DataProcessor-Setup durch...") # Lade das Branchenschema und speichere es in der Instanz self.schema_data = initialize_target_schema() if not self.schema_data: self.logger.error("Setup fehlgeschlagen: Branchenschema konnte nicht geladen werden.") self.is_setup_complete = False return False self._load_ml_model(MODEL_FILE, IMPUTER_FILE) self.is_setup_complete = True self.logger.info("DataProcessor-Setup erfolgreich abgeschlossen.") return True def _get_cell_value_safe(self, row, column_key): """ Greift sicher auf eine Zelle in einer Zeile zu, basierend auf dem Spaltennamen. Angepasst an die neue COLUMN_MAP Struktur. """ col_info = COLUMN_MAP.get(column_key) if col_info is None or 'index' not in col_info: self.logger.error(f"Spalte '{column_key}' oder ihr 'index' nicht im COLUMN_MAP gefunden.") return "" idx = col_info['index'] if len(row) > idx: return row[idx] return "" def _needs_website_processing(self, row_data, force_reeval): """ Prueft, ob Website-Scraping/Summarization fuer diese Zeile noetig ist. """ if force_reeval: return True at_value = self._get_cell_value_safe( row_data, "Website Scrape Timestamp").strip() if not at_value: return True return False def _needs_wiki_processing(self, row_data, force_reeval): """ Prueft, ob Wikipedia-Suche/Extraktion fuer diese Zeile noetig ist. """ if force_reeval: return True an_value = self._get_cell_value_safe( row_data, "Wikipedia Timestamp").strip() if not an_value: return True s_value = self._get_cell_value_safe( row_data, "Chat Wiki Konsistenzpruefung").strip().upper() if s_value == "X (URL COPIED)": return True return False def _needs_wiki_verification(self, row_data, force_reeval): """ Prueft, ob Wikipedia-Verifizierung fuer diese Zeile noetig ist. """ if force_reeval: return True ax_value = self._get_cell_value_safe( row_data, "Wiki Verif. Timestamp").strip() if not ax_value: m_value = self._get_cell_value_safe(row_data, "Wiki URL").strip() if m_value and m_value.lower() not in [ "k.a.", "kein artikel gefunden", "fehler bei suche", "http:"]: return True return False def _needs_chat_evaluations( self, row_data, force_reeval, wiki_data_just_updated): """ Prueft, ob ChatGPT-Evaluationen fuer diese Zeile noetig sind. """ if force_reeval: return True ao_value = self._get_cell_value_safe( row_data, "Timestamp letzte Pruefung").strip() if not ao_value: return True if wiki_data_just_updated: return True return False def _needs_ml_prediction(self, row_data, force_reeval, chat_eval_just_ran): """ Prueft, ob die ML-Schaetzung fuer diese Zeile noetig ist. """ if force_reeval: return True au_value = self._get_cell_value_safe( row_data, "Geschaetzter Techniker Bucket").strip() if not au_value or au_value.lower() in ["k.a.", "fehler schaetzung"]: if chat_eval_just_ran: return True av_value = self._get_cell_value_safe( row_data, "Finaler Umsatz (Wiki>CRM)").strip() aw_value = self._get_cell_value_safe( row_data, "Finaler Mitarbeiter (Wiki>CRM)").strip() if av_value != "k.A." and not av_value.startswith( "FEHLER") and aw_value != "k.A." and not aw_value.startswith("FEHLER"): return True return False def _process_single_row(self, row_num_in_sheet, row_data, steps_to_run, force_reeval=False, clear_x_flag=False): """ Verarbeitet die Daten fuer eine einzelne Zeile im Sheet. Fuehrt ausgewaehlte Anreicherungs- und Analyseprozesse durch. """ self.logger.info( f"--- Starte Verarbeitung fuer Zeile {row_num_in_sheet} {'(Re-Eval)' if force_reeval else ''} (Angefragte Schritte: {', '.join(steps_to_run) if steps_to_run else 'Keine'}) ---") # NEU: Detaillierteres Logging der auszuführenden Schritte run_reasons = [] if 'web' in steps_to_run and self._needs_website_processing(row_data, force_reeval): run_reasons.append("WEB (Timestamp leer oder Re-Eval)") if 'wiki' in steps_to_run and self._needs_wiki_processing(row_data, force_reeval): run_reasons.append("WIKI (Timestamp leer oder Re-Eval)") if 'chat' in steps_to_run and self._needs_chat_evaluations(row_data, force_reeval, False): # False, da wir es nur für die Logik prüfen run_reasons.append("CHAT (Timestamp leer oder Re-Eval)") if 'ml_predict' in steps_to_run and self._needs_ml_prediction(row_data, force_reeval, False): run_reasons.append("ML (Bucket leer oder Re-Eval)") if run_reasons: self.logger.info(f"Zeile {row_num_in_sheet}: Fuehre Schritte aus -> {', '.join(run_reasons)}") updates = [] now_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") any_processing_done = False wiki_data_updated_in_this_run = False chat_eval_just_ran = False # --- Initiale Werte lesen --- company_name = self._get_cell_value_safe(row_data, "CRM Name").strip() website_url = self._get_cell_value_safe( row_data, "CRM Website").strip() crm_kurzform = self._get_cell_value_safe( row_data, "CRM Kurzform").strip() crm_branche = self._get_cell_value_safe( row_data, "CRM Branche").strip() crm_beschreibung = self._get_cell_value_safe( row_data, "CRM Beschreibung").strip() parent_account_name_d = self._get_cell_value_safe( row_data, "Parent Account Name").strip() current_wiki_data = { 'url': self._get_cell_value_safe(row_data, "Wiki URL") or 'k.A.', 'sitz_stadt': self._get_cell_value_safe(row_data, "Wiki Sitz Stadt") or 'k.A.', 'sitz_land': self._get_cell_value_safe(row_data, "Wiki Sitz Land") or 'k.A.', 'first_paragraph': self._get_cell_value_safe(row_data, "Wiki Absatz") or 'k.A.', 'branche': self._get_cell_value_safe(row_data, "Wiki Branche") or 'k.A.', 'umsatz': self._get_cell_value_safe(row_data, "Wiki Umsatz") or 'k.A.', 'mitarbeiter': self._get_cell_value_safe(row_data, "Wiki Mitarbeiter") or 'k.A.', 'categories': self._get_cell_value_safe(row_data, "Wiki Kategorien") or 'k.A.' } final_wiki_data = current_wiki_data.copy() website_raw = self._get_cell_value_safe( row_data, "Website Rohtext") or 'k.A.' website_summary = self._get_cell_value_safe( row_data, "Website Zusammenfassung") or 'k.A.' website_meta_details = self._get_cell_value_safe( row_data, "Website Meta-Details") or 'k.A.' url_pruefstatus = self._get_cell_value_safe( row_data, "URL Prüfstatus") or '' run_website_step = 'web' in steps_to_run website_processing_needed = self._needs_website_processing( row_data, force_reeval) if run_website_step and website_processing_needed: any_processing_done = True grund_message = "Re-Eval" if force_reeval else "Timestamp (AJ) leer" self.logger.info( f"Zeile {row_num_in_sheet}: Fuehre WEBSITE Schritte aus (Grund: {grund_message})...") # WICHTIG: url_pruefstatus muss initialisiert werden, um Fehler zu vermeiden url_pruefstatus = self._get_cell_value_safe(row_data, "URL Prüfstatus") if not website_url or website_url.lower() == "k.a.": self.logger.debug( " -> Website URL (E) leer, suche ueber SERP...") try: new_website = serp_website_lookup(company_name) if new_website and new_website.lower( ) != "k.a." and not new_website.startswith("k.A. (Fehler"): website_url = new_website updates.append( { 'range': f'{self.sheet_handler._get_col_letter(get_col_idx("CRM Website") + 1)}{row_num_in_sheet}', 'values': [ [website_url]]}) url_pruefstatus = "URL_OK_SERP" else: url_pruefstatus = "URL_SERP_FAILED" if not website_url or website_url.lower( ) == "k.a." else url_pruefstatus except Exception as e_serp_lookup_web: self.logger.error( f"FEHLER bei SERP Website Lookup für '{company_name}': {e_serp_lookup_web}") url_pruefstatus = "URL_SERP_ERROR" if website_url and website_url.lower() not in ["k.a.", "http:"]: self.logger.debug( f" -> Scrape Rohtext & Meta von {website_url[:100]}...") try: website_raw = get_website_raw(website_url) if website_raw == URL_CHECK_MARKER: url_pruefstatus = URL_CHECK_MARKER website_summary, website_meta_details = "k.A. (URL prüfen)", "k.A. (URL prüfen)" elif website_raw and str(website_raw).strip().lower() not in ["k.a.", "k.a. (nur cookie-banner erkannt)", "k.a. (fehler)"]: url_pruefstatus = "URL_OK_SCRAPED" website_meta_details = scrape_website_details( website_url) or "k.A. (Keine Meta-Details)" # Verbessert: company_name für besseren Kontext übergeben website_summary = summarize_website_content( website_raw, company_name) or "k.A. (Keine Zusammenfassung erhalten)" updates.append( { 'range': f'{self.sheet_handler._get_col_letter(get_col_idx("Website Meta-Details") + 1)}{row_num_in_sheet}', 'values': [ [website_meta_details]]}) updates.append( { 'range': f'{self.sheet_handler._get_col_letter(get_col_idx("Website Zusammenfassung") + 1)}{row_num_in_sheet}', 'values': [ [website_summary]]}) else: if not str(website_raw).startswith("k.A. (Fehler"): url_pruefstatus = "URL_SCRAPE_EMPTY_OR_BANNER" website_summary, website_meta_details = "k.A.", "k.A." updates.append( { 'range': f'{self.sheet_handler._get_col_letter(get_col_idx("Website Zusammenfassung") + 1)}{row_num_in_sheet}', 'values': [ [website_summary]]}) updates.append( { 'range': f'{self.sheet_handler._get_col_letter(get_col_idx("Website Meta-Details") + 1)}{row_num_in_sheet}', 'values': [ [website_meta_details]]}) except Exception as e_scrape_web: self.logger.error( f"FEHLER beim Website Scraping für '{company_name}': {e_scrape_web}") website_raw, website_summary, website_meta_details = f"k.A. (Fehler Scraping: {str(e_scrape_web)[:50]})", "k.A.", "k.A." url_pruefstatus = "URL_SCRAPE_ERROR" updates.append( { 'range': f'{self.sheet_handler._get_col_letter(get_col_idx("Website Zusammenfassung") + 1)}{row_num_in_sheet}', 'values': [ [website_summary]]}) updates.append( { 'range': f'{self.sheet_handler._get_col_letter(get_col_idx("Website Meta-Details") + 1)}{row_num_in_sheet}', 'values': [ [website_meta_details]]}) updates.append( { 'range': f'{self.sheet_handler._get_col_letter(get_col_idx("Website Rohtext") + 1)}{row_num_in_sheet}', 'values': [ [website_raw]]}) else: self.logger.debug( f" -> Keine gültige Website URL für '{company_name}'. Web-Verarbeitung übersprungen.") website_raw, website_summary, website_meta_details = "k.A. (Keine URL)", "k.A.", "k.A." if not url_pruefstatus: url_pruefstatus = "URL_MISSING" updates.append( { 'range': f'{self.sheet_handler._get_col_letter(get_col_idx("Website Rohtext") + 1)}{row_num_in_sheet}', 'values': [ [website_raw]]}) updates.append( { 'range': f'{self.sheet_handler._get_col_letter(get_col_idx("Website Zusammenfassung") + 1)}{row_num_in_sheet}', 'values': [ [website_summary]]}) updates.append( { 'range': f'{self.sheet_handler._get_col_letter(get_col_idx("Website Meta-Details") + 1)}{row_num_in_sheet}', 'values': [ [website_meta_details]]}) updates.append( { 'range': f'{self.sheet_handler._get_col_letter(get_col_idx("URL Prüfstatus") + 1)}{row_num_in_sheet}', 'values': [ [url_pruefstatus]]}) updates.append( { 'range': f'{self.sheet_handler._get_col_letter(get_col_idx("Website Scrape Timestamp") + 1)}{row_num_in_sheet}', 'values': [ [now_timestamp]]}) # ====================================================================== # === 2. Wikipedia Handling (Search, Extraction, Status Reset) ======== # ====================================================================== run_wiki_step = 'wiki' in steps_to_run wiki_processing_needed = self._needs_wiki_processing( row_data, force_reeval) # ====================================================================== # === 2. Wikipedia Handling (Search, Extraction) ===================== # ====================================================================== run_wiki_step = 'wiki' in steps_to_run wiki_processing_needed = self._needs_wiki_processing(row_data, force_reeval) if run_wiki_step and wiki_processing_needed: any_processing_done = True wiki_data_updated_in_this_run = False self.logger.info(f"Zeile {row_num_in_sheet}: Starte Wikipedia-Pipeline...") # --- Stufe 1: Intelligente URL-Suche (Kaskade) --- url_to_process = None search_origin = "Nicht festgelegt" parent_name_o = self._get_cell_value_safe(row_data, "System Vorschlag Parent Account").strip() parent_name_for_search = parent_name_o if parent_name_o and parent_name_o.lower() != 'k.a.' else None for col_name, origin in [("CRM Vorschlag Wiki URL", "Spalte N"), ("Wiki URL", "Spalte R")]: url = self._get_cell_value_safe(row_data, col_name).strip() if url and "wikipedia.org" in url.lower(): self.logger.debug(f" -> Stufe 1: Nutze URL aus {origin}: {url}") url_to_process = url search_origin = origin break if not url_to_process: self.logger.debug(f" -> Stufe 1: Starte automatische Suche für '{company_name}' (Parent-Kontext: {parent_name_for_search})...") try: page_obj = self.wiki_scraper.search_company_article(company_name, website_url, parent_name_for_search) if page_obj: url_to_process = page_obj.url search_origin = "Automatische Suche" self.logger.info(f" -> Stufe 1: URL durch automatische Suche gefunden: {url_to_process}") except Exception as e: self.logger.error(f" -> Stufe 1: Fehler bei der automatischen Suche: {e}") # --- Stufe 2: Inhaltsextraktion & Aufbereitung --- if url_to_process and "wikipedia.org" in url_to_process.lower(): self.logger.debug(f" -> Stufe 2: Extrahiere Inhalte von '{url_to_process}'...") try: extracted_content = self.wiki_scraper.extract_company_data(url_to_process) self.logger.debug(" -> Erstelle KI-Zusammenfassung des Artikel-Rohtextes...") new_summary = summarize_wikipedia_article(extracted_content.get('full_text'), company_name) extracted_content['first_paragraph'] = new_summary final_wiki_data.update(extracted_content) wiki_data_updated_in_this_run = True except Exception as e: self.logger.error(f" -> Stufe 2: Fehler bei der Inhaltsextraktion: {e}") error_data = {key: 'FEHLER (Extraktion)' for key in final_wiki_data.keys()} final_wiki_data.update(error_data) final_wiki_data['url'] = url_to_process wiki_data_updated_in_this_run = True else: self.logger.warning(f" -> Stufe 1-2: Keine gültige URL gefunden. Setze Wiki-Daten auf 'Kein Artikel gefunden'.") no_article_data = {key: 'Kein Artikel gefunden' for key in final_wiki_data.keys()} final_wiki_data.update(no_article_data) wiki_data_updated_in_this_run = True # --- Stufe 3: Kontextbasierte KI-Verifikation --- verification_needed = "Suche" in search_origin if verification_needed and wiki_data_updated_in_this_run and "FEHLER" not in str(final_wiki_data.get('title')): self.logger.debug(" -> Stufe 3: Automatisch gefundene URL erfordert KI-Verifizierung...") verification_result = verify_wiki_article_chatgpt( company_name=company_name, parent_name=parent_name_for_search, website=website_url, wiki_title=final_wiki_data.get('title', 'k.A.'), wiki_summary=final_wiki_data.get('first_paragraph', 'k.A.') ) updates.append({'range': f'{self.sheet_handler._get_col_letter(get_col_idx("Chat Wiki Konsistenzpruefung") + 1)}{row_num_in_sheet}', 'values': [[verification_result.get('consistency', 'X')]]}) updates.append({'range': f'{self.sheet_handler._get_col_letter(get_col_idx("Chat Begründung Wiki Inkonsistenz") + 1)}{row_num_in_sheet}', 'values': [[verification_result.get('justification', 'Fehler')]]}) updates.append({'range': f'{self.sheet_handler._get_col_letter(get_col_idx("Wiki Verif. Timestamp") + 1)}{row_num_in_sheet}', 'values': [[now_timestamp]]}) # --- Finales Schreiben ins Sheet --- key_mapping = { 'Wiki URL': 'url', 'Wiki Sitz Stadt': 'sitz_stadt', 'Wiki Sitz Land': 'sitz_land', 'Wiki Absatz': 'first_paragraph', 'Wiki Branche': 'branche', 'Wiki Umsatz': 'umsatz', 'Wiki Mitarbeiter': 'mitarbeiter', 'Wiki Kategorien': 'categories' } for sheet_col_name, data_key in key_mapping.items(): col_idx = get_col_idx(sheet_col_name) if col_idx is not None: updates.append({'range': f'{self.sheet_handler._get_col_letter(col_idx + 1)}{row_num_in_sheet}', 'values': [[final_wiki_data.get(data_key, 'k.A.')]]}) updates.append({'range': f'{self.sheet_handler._get_col_letter(get_col_idx("Wikipedia Timestamp") + 1)}{row_num_in_sheet}', 'values': [[now_timestamp]]}) # --- 3. ChatGPT Evaluationen (Branch, FSM, etc.) & Plausi --- run_chat_step = 'chat' in steps_to_run # KORREKTUR: Initialisiere chat_steps_to_run, um den NameError zu beheben chat_steps_to_run = set() chat_processing_needed = self._needs_chat_evaluations( row_data, force_reeval, wiki_data_updated_in_this_run) if run_chat_step and chat_processing_needed: any_processing_done = True chat_eval_just_ran = True grund_message_chat = "Re-Eval" if force_reeval else ( "Wiki-Daten aktualisiert" if wiki_data_updated_in_this_run else "Timestamp (BN) leer") self.logger.info( f"Zeile {row_num_in_sheet}: Fuehre CHATGPT Evaluationen & Plausi aus (Grund: {grund_message_chat})...") # --- 3. ChatGPT Evaluationen (Branch, FSM, etc.) & Plausi --- run_chat_step = 'chat' in steps_to_run chat_processing_needed = self._needs_chat_evaluations( row_data, force_reeval, wiki_data_updated_in_this_run) if run_chat_step and chat_processing_needed: any_processing_done = True chat_eval_just_ran = True grund_message_chat = "Re-Eval" if force_reeval else ( "Wiki-Daten aktualisiert" if wiki_data_updated_in_this_run else "Timestamp (BN) leer") self.logger.info( f"Zeile {row_num_in_sheet}: Fuehre CHATGPT Evaluationen & Plausi aus (Grund: {grund_message_chat})...") # --- 3a. Branchen-Einstufung --- self.logger.info( f" Zeile {row_num_in_sheet}: Starte Branchen-Einstufung ueber ChatGPT...") try: # schema_data wird hier aus der Instanz-Variable übergeben branch_result = evaluate_branche_chatgpt( crm_branche, crm_beschreibung, final_wiki_data.get('branche', 'k.A.'), final_wiki_data.get('categories', 'k.A.'), website_summary, schema_data=self.schema_data ) updates.append({'range': f'{self.sheet_handler._get_col_letter(get_col_idx("Chat Vorschlag Branche") + 1)}{row_num_in_sheet}', 'values': [[branch_result.get('branch', 'FEHLER')]]}) updates.append({'range': f'{self.sheet_handler._get_col_letter(get_col_idx("Chat Branche Konfidenz") + 1)}{row_num_in_sheet}', 'values': [[branch_result.get('confidence', 'N/A')]]}) updates.append({'range': f'{self.sheet_handler._get_col_letter(get_col_idx("Chat Konsistenz Branche") + 1)}{row_num_in_sheet}', 'values': [[branch_result.get('consistency', 'error')]]}) updates.append({'range': f'{self.sheet_handler._get_col_letter(get_col_idx("Chat Begruendung Abweichung Branche") + 1)}{row_num_in_sheet}', 'values': [[branch_result.get('justification', 'k.A.')]]}) except Exception as e_branch_eval: self.logger.error(f"FEHLER bei Branchen-Einstufung für Zeile {row_num_in_sheet}: {e_branch_eval}") # Fehlerwerte in die Spalten schreiben error_updates = [ {"key": "Chat Vorschlag Branche", "value": "FEHLER_CALL"}, {"key": "Chat Branche Konfidenz", "value": "N/A"}, {"key": "Chat Konsistenz Branche", "value": "error"}, {"key": "Chat Begruendung Abweichung Branche", "value": str(e_branch_eval)[:100]} ] for item in error_updates: col_idx = get_col_idx(item["key"]) if col_idx is not None: updates.append({ 'range': f'{self.sheet_handler._get_col_letter(col_idx + 1)}{row_num_in_sheet}', 'values': [[item["value"]]] }) # --- NEUER SCHRITT: FSM Pitch generieren --- if 'fsm_pitch' in chat_steps_to_run and self._needs_fsm_pitch(row_data, force_reeval): self.logger.info(f" -> Generiere FSM Pitch...") try: # Daten für den Pitch sammeln, die in diesem Durchlauf relevant sind # Priorisiere die frisch generierte Branche, falle zurück auf Sheet-Daten ki_branche = branch_result.get("branch") if branch_result else self._get_cell_value_safe(row_data, "Chat Vorschlag Branche") if not ki_branche or "FEHLER" in ki_branche: ki_branche = self._get_cell_value_safe(row_data, "CRM Branche") # Rufe die neue, verbesserte Pitch-Funktion auf fsm_pitch_text = generate_fsm_pitch( company_name=self._get_cell_value_safe(row_data, "CRM Name"), company_short_name=self._get_cell_value_safe(row_data, "CRM Kurzform"), ki_branche=ki_branche, website_summary=self._get_cell_value_safe(row_data, "Website Zusammenfassung"), wiki_absatz=final_wiki_data.get('first_paragraph'), # Nutze die frischen Wiki-Daten anzahl_ma=self._get_cell_value_safe(row_data, "Finaler Mitarbeiter (Wiki>CRM)"), anzahl_techniker=self._get_cell_value_safe(row_data, "CRM Anzahl Techniker"), techniker_bucket_ml=self._get_cell_value_safe(row_data, "Geschaetzter Techniker Bucket") ) updates.append({'range': f'{self.sheet_handler._get_col_letter(get_col_idx("FSM Pitch") + 1)}{row_num_in_sheet}', 'values': [[fsm_pitch_text]]}) updates.append({'range': f'{self.sheet_handler._get_col_letter(get_col_idx("FSM Pitch Timestamp") + 1)}{row_num_in_sheet}', 'values': [[now_timestamp]]}) any_processing_done = True chat_eval_just_ran = True # Signal, dass eine KI-Aktion stattfand except Exception as e_fsm_pitch: self.logger.error(f"FEHLER bei FSM-Pitch-Generierung für Zeile {row_num_in_sheet}: {e_fsm_pitch}") updates.append({'range': f'{self.sheet_handler._get_col_letter(get_col_idx("FSM Pitch") + 1)}{row_num_in_sheet}', 'values': [['FEHLER (Generierung)']]} ) # 3b, 3c, 3d: Weitere ChatGPT-Evaluationen (hier nicht detailliert implementiert, aber Platzhalter) # ... Logik für FSM-Relevanz, Mitarbeiter-Schätzung, Umsatz-Schätzung, etc. ... # 3e. Konsolidierung Umsatz/Mitarbeiter (BD, BE) self.logger.debug(f" Zeile {row_num_in_sheet}: Konsolidiere Umsatz (BD) und Mitarbeiter (BE)...") final_umsatz_str_konsolidiert = "k.A." final_ma_str_konsolidiert = "k.A." try: crm_umsatz_val_str = self._get_cell_value_safe( row_data, "CRM Umsatz") wiki_umsatz_val_str = final_wiki_data.get('umsatz', 'k.A.') crm_ma_val_str = self._get_cell_value_safe( row_data, "CRM Anzahl Mitarbeiter") wiki_ma_val_str = final_wiki_data.get('mitarbeiter', 'k.A.') num_crm_umsatz = get_numeric_filter_value( crm_umsatz_val_str, is_umsatz=True) num_wiki_umsatz = get_numeric_filter_value( wiki_umsatz_val_str, is_umsatz=True) num_crm_ma = get_numeric_filter_value( crm_ma_val_str, is_umsatz=False) num_wiki_ma = get_numeric_filter_value( wiki_ma_val_str, is_umsatz=False) if parent_account_name_d and parent_account_name_d.lower() != 'k.a.': self.logger.info( f" -> Parent D ('{parent_account_name_d}') gesetzt. Konsolidiere primär mit CRM-Daten der Tochter.") final_num_umsatz = num_crm_umsatz if num_crm_umsatz > 0 else num_wiki_umsatz final_num_ma = num_crm_ma if num_crm_ma > 0 else num_wiki_ma else: self.logger.debug( f" -> Parent D leer. Standardkonsolidierung Wiki > CRM.") final_num_umsatz = num_wiki_umsatz if num_wiki_umsatz > 0 else num_crm_umsatz final_num_ma = num_wiki_ma if num_wiki_ma > 0 else num_crm_ma final_umsatz_str_konsolidiert = str( int(round(final_num_umsatz))) if final_num_umsatz > 0 else 'k.A.' final_ma_str_konsolidiert = str( int(round(final_num_ma))) if final_num_ma > 0 else 'k.A.' updates.append( { 'range': f'{self.sheet_handler._get_col_letter(get_col_idx("Finaler Umsatz (Wiki>CRM)") + 1)}{row_num_in_sheet}', 'values': [ [final_umsatz_str_konsolidiert]]}) updates.append( { 'range': f'{self.sheet_handler._get_col_letter(get_col_idx("Finaler Mitarbeiter (Wiki>CRM)") + 1)}{row_num_in_sheet}', 'values': [ [final_ma_str_konsolidiert]]}) except Exception as e_consolidate: self.logger.error( f"FEHLER bei Konsolidierung (BD/BE) für Zeile {row_num_in_sheet}: {e_consolidate}") final_umsatz_str_konsolidiert, final_ma_str_konsolidiert = "FEHLER_KONSO", "FEHLER_KONSO" updates.append( { 'range': f'{self.sheet_handler._get_col_letter(get_col_idx("Finaler Umsatz (Wiki>CRM)") + 1)}{row_num_in_sheet}', 'values': [ ['FEHLER_KONSO']]}) updates.append( { 'range': f'{self.sheet_handler._get_col_letter(get_col_idx("Finaler Mitarbeiter (Wiki>CRM)") + 1)}{row_num_in_sheet}', 'values': [ ['FEHLER_KONSO']]}) # 3f. Plausibilitäts-Checks (BG-BM) self.logger.debug( f" Zeile {row_num_in_sheet}: Führe Plausibilitäts-Checks durch...") try: plausi_input_data = { "Finaler Umsatz (Wiki>CRM)": final_umsatz_str_konsolidiert, "Finaler Mitarbeiter (Wiki>CRM)": final_ma_str_konsolidiert, "CRM Umsatz": self._get_cell_value_safe( row_data, "CRM Umsatz"), "Wiki Umsatz": final_wiki_data.get( 'umsatz', 'k.A.'), "CRM Anzahl Mitarbeiter": self._get_cell_value_safe( row_data, "CRM Anzahl Mitarbeiter"), "Wiki Mitarbeiter": final_wiki_data.get( 'mitarbeiter', 'k.A.'), "Parent Account Name": parent_account_name_d, "System Vorschlag Parent Account": self._get_cell_value_safe( row_data, "System Vorschlag Parent Account"), "Parent Vorschlag Status": self._get_cell_value_safe( row_data, "Parent Vorschlag Status")} plausi_results = self._check_financial_plausibility( plausi_input_data) updates.append( { 'range': f'{self.sheet_handler._get_col_letter(get_col_idx("Plausibilität Umsatz") + 1)}{row_num_in_sheet}', 'values': [ [ plausi_results.get( "plaus_umsatz_flag", "ERR_FLAG")]]}) updates.append( { 'range': f'{self.sheet_handler._get_col_letter(get_col_idx("Plausibilität Mitarbeiter") + 1)}{row_num_in_sheet}', 'values': [ [ plausi_results.get( "plaus_ma_flag", "ERR_FLAG")]]}) updates.append( { 'range': f'{self.sheet_handler._get_col_letter(get_col_idx("Plausibilität Umsatz/MA Ratio") + 1)}{row_num_in_sheet}', 'values': [ [ plausi_results.get( "plaus_ratio_flag", "ERR_FLAG")]]}) updates.append( { 'range': f'{self.sheet_handler._get_col_letter(get_col_idx("Abweichung Umsatz CRM/Wiki") + 1)}{row_num_in_sheet}', 'values': [ [ plausi_results.get( "abweichung_umsatz_flag", "ERR_FLAG")]]}) updates.append( { 'range': f'{self.sheet_handler._get_col_letter(get_col_idx("Abweichung MA CRM/Wiki") + 1)}{row_num_in_sheet}', 'values': [ [ plausi_results.get( "abweichung_ma_flag", "ERR_FLAG")]]}) updates.append( { 'range': f'{self.sheet_handler._get_col_letter(get_col_idx("Plausibilität Begründung") + 1)}{row_num_in_sheet}', 'values': [ [ plausi_results.get( "plausi_begruendung_final", "Fehler Begr.")]]}) except Exception as e_plausi: self.logger.error( f"FEHLER bei Plausi-Checks für Zeile {row_num_in_sheet}: {e_plausi}") updates.append( { 'range': f'{self.sheet_handler._get_col_letter(get_col_idx("Plausibilität Prüfdatum") + 1)}{row_num_in_sheet}', 'values': [ [now_timestamp]]}) updates.append( { 'range': f'{self.sheet_handler._get_col_letter(get_col_idx("Timestamp letzte Pruefung") + 1)}{row_num_in_sheet}', 'values': [ [now_timestamp]]}) # --- 4. Servicetechniker Schaetzung (ML Modell) --- run_ml_step = 'ml_predict' in steps_to_run ml_processing_needed = self._needs_ml_prediction( row_data, force_reeval, chat_eval_just_ran) if run_ml_step and ml_processing_needed: any_processing_done = True self.logger.info( f"Zeile {row_num_in_sheet}: Fuehre ML-Schaetzung aus...") try: predicted_bucket = self._predict_technician_bucket(row_data) if predicted_bucket and isinstance( predicted_bucket, str) and not predicted_bucket.startswith("FEHLER"): updates.append( { 'range': f'{self.sheet_handler._get_col_letter(get_col_idx("Geschaetzter Techniker Bucket") + 1)}{row_num_in_sheet}', 'values': [ [predicted_bucket]]}) self.logger.info( f" -> ML-Schaetzung erfolgreich: Bucket '{predicted_bucket}'.") else: self.logger.warning( f" -> ML-Schaetzung lieferte kein gueltiges Ergebnis: '{predicted_bucket}'.") updates.append( { 'range': f'{self.sheet_handler._get_col_letter(get_col_idx("Geschaetzter Techniker Bucket") + 1)}{row_num_in_sheet}', 'values': [ ['k.A. (Schaetzung fehlgeschlagen)']]}) except Exception as e_ml: self.logger.error( f"FEHLER bei ML-Schaetzung fuer Zeile {row_num_in_sheet}: {e_ml}") self.logger.debug(traceback.format_exc()) updates.append( { 'range': f'{self.sheet_handler._get_col_letter(get_col_idx("Geschaetzter Techniker Bucket") + 1)}{row_num_in_sheet}', 'values': [ [f'FEHLER Schaetzung: {str(e_ml)[:50]}...']]}) # ====================================================================== # === Abschluss der _process_single_row Verarbeitung ================== # ====================================================================== # --- 5. Abschliessende Updates (Version, Tokens, ReEval Flag) --- if any_processing_done: version_col_idx = get_col_idx("Version") # KORRIGIERT if version_col_idx is not None: updates.append( { 'range': f'{self.sheet_handler._get_col_letter(version_col_idx + 1)}{row_num_in_sheet}', 'values': [ [ getattr( Config, 'VERSION', 'unknown')]]}) else: self.logger.error( "FEHLER: Spaltenschluessel 'Version' nicht in COLUMN_MAP gefunden.") if force_reeval and clear_x_flag: # KORRIGIERT: Nutze die sichere get_col_idx Funktion reeval_col_idx = get_col_idx("ReEval Flag") if reeval_col_idx is not None: flag_col_letter = self.sheet_handler._get_col_letter( reeval_col_idx + 1) if flag_col_letter: updates.append( {'range': f'{flag_col_letter}{row_num_in_sheet}', 'values': [['']]}) self.logger.debug( f" -> Update zum Loeschen des ReEval-Flags (A{row_num_in_sheet}) vorgemerkt.") else: self.logger.error( f"FEHLER: Konnte Spaltenbuchstaben fuer 'ReEval Flag' nicht ermitteln.") else: self.logger.error( "FEHLER: 'ReEval Flag' Spaltenindex nicht in COLUMN_MAP gefunden.") # --- 6. Batch Update fuer diese Zeile --- if updates: self.logger.info( f"Zeile {row_num_in_sheet}: Sende Batch-Update mit {len(updates)} Operationen...") success = self.sheet_handler.batch_update_cells(updates) if not success: self.logger.error( f"Zeile {row_num_in_sheet}: ENDGUELTIGER FEHLER beim Batch-Update nach Retries.") else: if not any_processing_done: self.logger.debug( f"Zeile {row_num_in_sheet}: Keine Updates zum Schreiben.") pause_duration = max(0.05, getattr(Config, 'RETRY_DELAY', 5) / 20.0) time.sleep(pause_duration) self.logger.info( f"--- Verarbeitung fuer Zeile {row_num_in_sheet} abgeschlossen ---") # ========================================================================== # === Prozess Methoden (Sequentiell & Re-Evaluation) ===================== # ========================================================================== def process_website_scraping(self, start_sheet_row=None, end_sheet_row=None, limit=None): """ Batch-Prozess NUR für Website-Scraping (Rohtext & Meta-Details). Diese Version ist stabilisiert, nutzt einen robusten Worker (_scrape_website_task_batch) und verarbeitet strukturierte Dictionary-Ergebnisse, inklusive des URL-Prüfstatus. """ self.logger.info(f"Starte Website-Scraping (Batch, v2.0.7). Bereich: {start_sheet_row or 'Start'}-{end_sheet_row or 'Ende'}, Limit: {limit or 'Unbegrenzt'}") # --- 1. Daten laden und Startzeile ermitteln --- if start_sheet_row is None: self.logger.info("Automatische Ermittlung der Startzeile basierend auf leeren 'Website Scrape Timestamp'...") start_data_idx = self.sheet_handler.get_start_row_index(check_column_key="Website Scrape Timestamp") if start_data_idx == -1: self.logger.error("FEHLER bei automatischer Ermittlung der Startzeile. Breche Batch ab.") return start_sheet_row = start_data_idx + self.sheet_handler._header_rows + 1 self.logger.info(f"Automatisch ermittelte Startzeile: {start_sheet_row}") if not self.sheet_handler.load_data(): self.logger.error("FEHLER beim Laden der Daten für Batch-Verarbeitung.") return all_data = self.sheet_handler.get_all_data_with_headers() header_rows = self.sheet_handler._header_rows total_sheet_rows = len(all_data) effective_end_row = end_sheet_row if end_sheet_row is not None else total_sheet_rows self.logger.info(f"Verarbeitungsbereich: Sheet-Zeilen {start_sheet_row} bis {effective_end_row}.") if start_sheet_row > effective_end_row: self.logger.info("Start liegt nach dem Ende. Keine Zeilen zu verarbeiten.") return # --- 2. Spalten-Indizes und Buchstaben vorbereiten --- rohtext_col_letter = self.sheet_handler._get_col_letter(get_col_idx("Website Rohtext") + 1) metadetails_col_letter = self.sheet_handler._get_col_letter(get_col_idx("Website Meta-Details") + 1) pruefstatus_col_letter = self.sheet_handler._get_col_letter(get_col_idx("URL Prüfstatus") + 1) # NEU version_col_letter = self.sheet_handler._get_col_letter(get_col_idx("Version") + 1) timestamp_col_letter = self.sheet_handler._get_col_letter(get_col_idx("Website Scrape Timestamp") + 1) # --- 3. Tasks sammeln --- processing_batch_size = getattr(Config, 'PROCESSING_BATCH_SIZE', 20) max_scraping_workers = getattr(Config, 'MAX_SCRAPING_WORKERS', 10) update_batch_row_limit = getattr(Config, 'UPDATE_BATCH_ROW_LIMIT', 50) tasks_for_processing_batch = [] all_sheet_updates = [] processed_count = 0 skipped_count = 0 for i in range(start_sheet_row, effective_end_row + 1): row_index_in_list = i - 1 if row_index_in_list >= total_sheet_rows: break row = all_data[row_index_in_list] if not any(cell and str(cell).strip() for cell in row): skipped_count += 1 continue if self._needs_website_processing(row, force_reeval=False): website_url = self._get_cell_value_safe(row, "CRM Website").strip() if website_url and website_url.lower() not in ["k.a.", "http:"]: if limit is not None and processed_count >= limit: self.logger.info(f"Verarbeitungslimit ({limit}) erreicht.") break tasks_for_processing_batch.append({"row_num": i, "url": website_url}) processed_count += 1 else: skipped_count += 1 else: skipped_count += 1 # --- 4. Batch-Verarbeitung auslösen --- if len(tasks_for_processing_batch) >= processing_batch_size or (i == effective_end_row and tasks_for_processing_batch): self.logger.info(f"--- Starte Website-Scraping Batch für {len(tasks_for_processing_batch)} Tasks (max. {max_scraping_workers} Worker) ---") scraping_results = {} batch_error_count = 0 with ThreadPoolExecutor(max_workers=max_scraping_workers) as executor: future_to_task = {executor.submit(self._scrape_website_task_batch, task): task for task in tasks_for_processing_batch} for future in as_completed(future_to_task): task = future_to_task[future] try: result_dict = future.result() if isinstance(result_dict, dict) and 'row_num' in result_dict: scraping_results[result_dict['row_num']] = result_dict if result_dict.get('error'): batch_error_count += 1 self.logger.warning(f"Worker meldete Fehler für Zeile {result_dict['row_num']}: {result_dict.get('status_message')}") else: self.logger.error(f"Inkonsistentes Ergebnis für Zeile {task['row_num']}: Erwartete dict mit 'row_num', bekam {type(result_dict)}. Überspringe.") scraping_results[task['row_num']] = {'raw_text': "FEHLER (Inkonsistenter Rückgabetyp)", 'meta_details': 'k.A.', 'url_pruefstatus': 'URL_SCRAPE_ERROR', 'error': True} batch_error_count += 1 except Exception as exc: self.logger.error(f"Unerwarteter Fehler bei Ergebnisabfrage für Zeile {task['row_num']}: {exc}", exc_info=True) scraping_results[task['row_num']] = {'raw_text': "FEHLER (Task Exception)", 'meta_details': 'k.A.', 'url_pruefstatus': 'URL_SCRAPE_ERROR', 'error': True} batch_error_count += 1 self.logger.info(f" -> Scraping für Batch beendet. {len(scraping_results)} Ergebnisse erhalten ({batch_error_count} davon mit Fehlern).") # --- 5. Updates für das Google Sheet vorbereiten --- if scraping_results: current_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") current_version = getattr(Config, 'VERSION', 'unknown') for row_num, res_dict in scraping_results.items(): all_sheet_updates.append({'range': f'{rohtext_col_letter}{row_num}', 'values': [[res_dict.get('raw_text', 'k.A.')]]}) all_sheet_updates.append({'range': f'{metadetails_col_letter}{row_num}', 'values': [[res_dict.get('meta_details', 'k.A.')]]}) all_sheet_updates.append({'range': f'{pruefstatus_col_letter}{row_num}', 'values': [[res_dict.get('url_pruefstatus', 'URL_SCRAPE_ERROR')]]}) # NEU all_sheet_updates.append({'range': f'{timestamp_col_letter}{row_num}', 'values': [[current_timestamp]]}) all_sheet_updates.append({'range': f'{version_col_letter}{row_num}', 'values': [[current_version]]}) tasks_for_processing_batch = [] # --- 6. Sheet-Update auslösen, wenn Update-Batch voll ist --- if len(all_sheet_updates) >= (update_batch_row_limit * 5): # NEU: 5 Updates pro Zeile self.logger.info(f"Sende gesammelte Sheet-Updates ({len(all_sheet_updates) // 5} Zeilen)...") self.sheet_handler.batch_update_cells(all_sheet_updates) all_sheet_updates = [] time.sleep(1) # --- 7. Finale Updates senden --- if all_sheet_updates: self.logger.info(f"Sende finale gesammelte Sheet-Updates ({len(all_sheet_updates) // 5} Zeilen)...") self.sheet_handler.batch_update_cells(all_sheet_updates) self.logger.info(f"Website-Scraping (Batch) abgeschlossen. {processed_count} Zeilen zur Verarbeitung ausgewählt, {skipped_count} Zeilen übersprungen.") def _scrape_website_task_batch(self, task_info): """ Robuste Worker-Funktion für das parallele Scrapen von Websites im Batch-Modus. Diese Funktion holt Rohtext sowie Meta-Details und gibt IMMER ein strukturiertes Dictionary zurück, das auch den programmatischen URL-Prüfstatus enthält. NEU: Enthält Logik zur Erkennung von "Thin Content" und Cookie-Bannern. """ row_num = task_info['row_num'] url = task_info['url'] self.logger.debug(f" -> Batch-Scrape-Task gestartet für Zeile {row_num}: {url}") result = { 'row_num': row_num, 'raw_text': 'k.A. (Fehler im Task)', 'meta_details': 'k.A. (Fehler im Task)', 'error': True, 'status_message': 'Unbekannter Task-Fehler', 'url_pruefstatus': 'URL_SCRAPE_ERROR' # Default-Fehlerstatus } try: # 1. Rohtext abrufen (get_website_raw aus helpers.py) raw_text_result = get_website_raw(url) # 2. Ergebnis des Rohtext-Abrufs auswerten if raw_text_result == URL_CHECK_MARKER: result.update({ 'raw_text': "k.A. (URL prüfen)", 'meta_details': "k.A.", 'error': True, 'status_message': "URL als nicht erreichbar markiert", 'url_pruefstatus': URL_CHECK_MARKER }) elif raw_text_result and not str(raw_text_result).strip().lower().startswith('k.a.'): # --- NEUER BLOCK: Thin Content & Cookie-Banner-Erkennung --- text_len = len(raw_text_result) text_lower_sample = raw_text_result[:600].lower() # Prüfe nur den Anfang cookie_keywords = ['cookie', 'zustimmen', 'akzeptieren', 'einwilligung', 'datenschutz', 'ablehnen', 'einstellungen'] keyword_hits = sum(1 for keyword in cookie_keywords if keyword in text_lower_sample) if text_len < 500 and keyword_hits >= 3: self.logger.warning(f"Zeile {row_num}: Potenzieller Cookie-Banner erkannt (Länge: {text_len}, Keyword-Treffer: {keyword_hits}).") result.update({ 'raw_text': raw_text_result, 'meta_details': "k.A.", 'error': True, 'status_message': "Nur Cookie-Banner erkannt", 'url_pruefstatus': 'URL_SCRAPE_COOKIE_BANNER' }) elif text_len < 200: self.logger.warning(f"Zeile {row_num}: 'Thin Content' erkannt (Länge: {text_len}).") result.update({ 'raw_text': raw_text_result, 'meta_details': "k.A.", 'error': True, 'status_message': "Inhalt zu kurz", 'url_pruefstatus': 'URL_SCRAPE_THIN_CONTENT' }) else: # Dies ist der reguläre Erfolgsfall meta_details_result = scrape_website_details(url) result.update({ 'raw_text': raw_text_result, 'meta_details': meta_details_result if meta_details_result else "k.A.", 'error': False, 'status_message': 'Erfolgreich gescraped', 'url_pruefstatus': 'URL_OK_SCRAPED' }) # --- ENDE NEUER BLOCK --- elif str(raw_text_result).strip().lower().startswith('k.a.'): result['raw_text'] = raw_text_result # Fehlerstring übernehmen result['meta_details'] = "k.A." result['error'] = True match = re.search(r'\((.*?)\)', raw_text_result) result['status_message'] = match.group(1) if match else "Scraping fehlgeschlagen" result['url_pruefstatus'] = "URL_SCRAPE_ERROR" else: # Fallback für unerwartete leere Ergebnisse result.update({ 'raw_text': 'k.A. (Extraktion leer)', 'meta_details': 'k.A.', 'error': True, 'status_message': 'Extraktion lieferte leeren Text', 'url_pruefstatus': "URL_SCRAPE_EMPTY_OR_BANNER" }) return result except Exception as e: self.logger.error(f" -> Kritischer Fehler im Worker-Task `_scrape_website_task_batch` für Zeile {row_num}: {e}") # Das `result` Dictionary wird mit den initialen Fehlerwerten zurückgegeben. return result def _summarize_task_batch(self, task_info): """ Robuste Worker-Funktion für die parallele Website-Zusammenfassung. Wird vom ThreadPoolExecutor in `process_summarize_website` aufgerufen. Gibt IMMER ein strukturiertes Dictionary zurück. """ row_num = task_info['row_num'] raw_text = task_info['raw_text'] company_name = task_info.get('company_name', 'einem Unternehmen') self.logger.debug(f" -> Batch-Summarize-Task gestartet für Zeile {row_num}...") result = { 'row_num': row_num, 'summary': 'k.A. (Fehler im Task)', 'error': True, 'status_message': 'Unbekannter Task-Fehler' } try: # Ruft die gehärtete Single-Item-Funktion aus helpers.py auf summary_text = summarize_website_content(raw_text, company_name) if summary_text and not summary_text.lower().startswith('k.a.'): result['summary'] = summary_text result['error'] = False result['status_message'] = 'Erfolgreich zusammengefasst' else: result['summary'] = summary_text # Fehlergrund übernehmen result['error'] = True result['status_message'] = 'Zusammenfassung fehlgeschlagen oder Text zu kurz' return result except Exception as e: self.logger.error(f" -> Kritischer Fehler im Worker-Task `_summarize_task_batch` für Zeile {row_num}: {e}") result['summary'] = f"FEHLER (API-Fehler bei Zusammenfassung)" result['status_message'] = f"Kritischer Task-Fehler: {type(e).__name__}" return result def process_rows_sequentially( self, start_sheet_row, num_to_process, process_wiki_steps=True, process_chatgpt_steps=True, process_website_steps=True, process_ml_steps=True, force_reeval_in_single_row=False): """ Verarbeitet eine feste Anzahl von Zeilen beginnend bei einer bestimmten Sheet-Zeilennummer sequentiell. """ header_rows = self.sheet_handler._header_rows if num_to_process is None or not isinstance( num_to_process, int) or num_to_process <= 0: self.logger.info( "Sequentielle Verarbeitung uebersprungen: num_to_process ist ungueltig oder <= 0.") return self.logger.info( f"Starte sequentielle Verarbeitung von {num_to_process} Zeilen ab Sheet-Zeile {start_sheet_row}...") selected_steps_log = [] if process_wiki_steps: selected_steps_log.append("Wiki (wiki)") if process_chatgpt_steps: selected_steps_log.append("ChatGPT (chat)") if process_website_steps: selected_steps_log.append("Website (web)") if process_ml_steps: selected_steps_log.append("ML Predict (ml_predict)") self.logger.info( f" Ausgewaehlte Schritte: {', '.join(selected_steps_log) if selected_steps_log else 'Keine'}") if force_reeval_in_single_row: self.logger.warning( " !!! force_reeval=True wird fuer alle Zeilen in _process_single_row gesetzt !!!") steps_to_run_set = set() if process_wiki_steps: steps_to_run_set.add('wiki') if process_chatgpt_steps: steps_to_run_set.add('chat') if process_website_steps: steps_to_run_set.add('web') if process_ml_steps: steps_to_run_set.add('ml_predict') if not steps_to_run_set: self.logger.warning( "Keine Verarbeitungsschritte ausgewaehlt. Modus wird uebersprungen.") return if not self.sheet_handler.load_data(): self.logger.error( "Fehler beim Laden der Daten fuer sequentielle Verarbeitung.") return all_data = self.sheet_handler.get_all_data_with_headers() total_sheet_rows = len(all_data) start_index_in_all_data = start_sheet_row - 1 if start_index_in_all_data >= total_sheet_rows: self.logger.warning( f"Start-Sheet-Zeile {start_sheet_row} liegt ausserhalb der Daten. Keine Verarbeitung.") return if start_index_in_all_data < header_rows: self.logger.warning( f"Start-Sheet-Zeile {start_sheet_row} liegt in Headern. Starte ab Zeile {header_rows + 1}.") start_index_in_all_data = header_rows end_index_in_all_data = min( start_index_in_all_data + num_to_process, total_sheet_rows) self.logger.info( f"Sequentielle Verarbeitung: Index-Bereich [{start_index_in_all_data}, {end_index_in_all_data}).") if start_index_in_all_data >= end_index_in_all_data: self.logger.info( f"Keine Zeilen im definierten Bereich zu verarbeiten.") return processed_count = 0 for i in range(start_index_in_all_data, end_index_in_all_data): row_num_in_sheet = i + 1 row_data = all_data[i] if row_num_in_sheet <= header_rows: continue if not any(cell and isinstance(cell, str) and cell.strip() for cell in row_data): self.logger.debug( f"Ueberspringe leere Zeile {row_num_in_sheet}.") continue try: self._process_single_row( row_num_in_sheet=row_num_in_sheet, row_data=row_data, steps_to_run=steps_to_run_set, force_reeval=force_reeval_in_single_row, clear_x_flag=False ) processed_count += 1 except Exception as e_proc: self.logger.exception( f"FEHLER bei sequentieller Verarbeitung von Zeile {row_num_in_sheet}: {e_proc}") self.logger.info( f"Sequentielle Verarbeitung abgeschlossen. {processed_count} Zeilen bearbeitet.") def process_reevaluation_rows( self, row_limit=None, clear_flag=True, process_wiki_steps=True, process_chatgpt_steps=True, process_website_steps=True, process_ml_steps=True): """ Verarbeitet nur Zeilen, die in Spalte A mit 'x' markiert sind. Leert zuerst alle abgeleiteten Spalten für eine saubere Neubewertung. """ self.logger.info( f"Starte Re-Evaluierungsmodus (Spalte A = 'x'). Max. Zeilen: {row_limit if row_limit is not None else 'Unbegrenzt'}") if not self.sheet_handler.load_data(): self.logger.error("Fehler beim Laden der Daten fuer Re-Evaluation.") return all_data = self.sheet_handler.get_all_data_with_headers() header_rows = self.sheet_handler._header_rows if not all_data or len(all_data) <= header_rows: self.logger.warning("Keine Datenzeilen fuer Re-Evaluation gefunden.") return column_names = list(COLUMN_MAP.keys()) try: reeval_col_idx = column_names.index("ReEval Flag") except ValueError: self.logger.critical("FEHLER: 'ReEval Flag' nicht in COLUMN_MAP. Breche ab.") return rows_to_process = [] for idx, row_data in enumerate(all_data): if idx < header_rows: continue if self._get_cell_value_safe(row_data, "ReEval Flag").strip().lower() == "x": rows_to_process.append({'row_num': idx + 1, 'data': row_data}) found_count = len(rows_to_process) self.logger.info(f"{found_count} Zeilen mit ReEval-Flag 'x' gefunden.") if found_count == 0: return # Spalten definieren, die vor der Neubewertung geleert werden sollen cols_to_clear_keys = [ "Wiki URL", "Wiki Sitz Stadt", "Wiki Sitz Land", "Wiki Absatz", "Wiki Branche", "Wiki Umsatz", "Wiki Mitarbeiter", "Wiki Kategorien", "Wikipedia Timestamp", "Wiki Verif. Timestamp", "SerpAPI Wiki Search Timestamp", "Chat Wiki Konsistenzpruefung", "Chat Begründung Wiki Inkonsistenz", "Chat Vorschlag Wiki Artikel", "Begründung bei Abweichung", "Website Rohtext", "Website Zusammenfassung", "Website Meta-Details", "Website Scrape Timestamp", "URL Prüfstatus", "Chat Vorschlag Branche", "Chat Branche Konfidenz", "Chat Konsistenz Branche", "Chat Begruendung Abweichung Branche", "Finaler Umsatz (Wiki>CRM)", "Finaler Mitarbeiter (Wiki>CRM)", "Geschaetzter Techniker Bucket", "Plausibilität Umsatz", "Plausibilität Mitarbeiter", "Plausibilität Umsatz/MA Ratio", "Abweichung Umsatz CRM/Wiki", "Abweichung MA CRM/Wiki", "Plausibilität Begründung", "Plausibilität Prüfdatum", "Timestamp letzte Pruefung", "Version", "Tokens", "FSM Pitch", "FSM Pitch Timestamp" ] clear_updates = [] for task in rows_to_process: row_num = task['row_num'] for key in cols_to_clear_keys: try: col_idx = column_names.index(key) col_letter = self.sheet_handler._get_col_letter(col_idx + 1) clear_updates.append({'range': f'{col_letter}{row_num}', 'values': [['']]}) except ValueError: self.logger.warning(f"Spalte '{key}' zum Leeren im Re-Eval-Modus nicht in COLUMN_MAP gefunden. Überspringe.") if clear_updates: self.logger.info(f"Leere {len(clear_updates)} Zellen für {found_count} Re-Eval-Zeilen zur Vorbereitung...") self.sheet_handler.batch_update_cells(clear_updates) self.logger.info("Vorbereitung abgeschlossen. Starte eigentliche Verarbeitung...") time.sleep(2) self.sheet_handler.load_data() processed_count_actual = 0 steps_to_run_set = set(key for key, value in {'wiki': process_wiki_steps, 'chat': process_chatgpt_steps, 'web': process_website_steps, 'ml_predict': process_ml_steps}.items() if value) fresh_data = self.sheet_handler.get_all_data_with_headers() for task in rows_to_process: if row_limit is not None and processed_count_actual >= row_limit: self.logger.info(f"Zeilenlimit ({row_limit}) fuer Re-Evaluation erreicht.") break current_row_data = fresh_data[task['row_num'] - 1] self.logger.info(f"Bearbeite Re-Eval Zeile {task['row_num']}...") processed_count_actual += 1 try: self._process_single_row( row_num_in_sheet=task['row_num'], row_data=current_row_data, steps_to_run=steps_to_run_set, force_reeval=True, clear_x_flag=clear_flag ) except Exception as e_proc_reval: self.logger.exception(f"FEHLER bei Re-Evaluation von Zeile {task['row_num']}: {e_proc_reval}") self.logger.info(f"Re-Evaluierung abgeschlossen. {processed_count_actual} Zeilen verarbeitet.") def process_wiki_verify(self, limit=None, start_sheet_row=None, end_sheet_row=None): """ Iteriert durch die Zeilen und führt eine ChatGPT-basierte Verifizierung des in Spalte R ("Wiki URL") gefundenen Artikels durch. Validiert von der KI vorgeschlagene URLs auf Existenz. """ BATCH_SIZE = 20 self.logger.info(f"Starte Modus: Wiki-Verifizierung via ChatGPT (Batch-Größe: {BATCH_SIZE})...") if not self.sheet_handler.load_data(): return all_data = self.sheet_handler.get_all_data_with_headers() header_rows = self.sheet_handler._header_rows start_row_idx = (start_sheet_row - 1) if start_sheet_row is not None else header_rows end_row_idx = (end_sheet_row - 1) if end_sheet_row is not None else len(all_data) - 1 rows_to_process = all_data[start_row_idx : end_row_idx + 1] processed_count_in_batch = 0 total_processed_since_start = 0 all_updates = [] for idx, row_data in enumerate(rows_to_process): current_row_num = start_row_idx + idx + 1 if limit is not None and total_processed_since_start >= limit: self.logger.info(f"Globales Limit von {limit} Zeilen erreicht. Stoppe.") break verif_timestamp = self._get_cell_value_safe(row_data, "Wiki Verif. Timestamp").strip() wiki_url = self._get_cell_value_safe(row_data, "Wiki URL").strip() if not verif_timestamp and wiki_url and "wikipedia.org" in wiki_url.lower(): self.logger.info(f"Zeile {current_row_num}: Verifizierung nötig, füge zur Queue hinzu.") try: # --- START KORREKTURBLOCK --- # 1. Sammle alle notwendigen Daten für den neuen, kontextreichen Aufruf company_name = self._get_cell_value_safe(row_data, "CRM Name") website = self._get_cell_value_safe(row_data, "CRM Website") parent_name = self._get_cell_value_safe(row_data, "System Vorschlag Parent Account") # 2. Lade den Artikel-Kontext self.logger.debug(f" Lade Kontext für Artikel: {wiki_url}") page_content = self.wiki_scraper.extract_company_data(wiki_url) if not page_content or 'FEHLER' in str(page_content.get('title')): self.logger.error(f" Konnte Kontext für URL {wiki_url} nicht laden. Überspringe KI-Verifizierung für diese Zeile.") continue # Gehe zur nächsten Zeile # 3. Rufe die neue, korrekte Funktion auf verification_result = verify_wiki_article_chatgpt( company_name=company_name, parent_name=parent_name, website=website, wiki_title=page_content.get('title', 'k.A.'), wiki_summary=page_content.get('first_paragraph', 'k.A.') ) # --- ENDE KORREKTURBLOCK --- # Ihre bestehende Logik zur Validierung der vorgeschlagenen URL bleibt erhalten final_suggested_url = verification_result.get("suggestion", "") # Geändert von "suggested_url" if final_suggested_url and "wikipedia.org" in final_suggested_url.lower(): if not is_valid_wikipedia_article_url(final_suggested_url): self.logger.warning(f" -> KI-Vorschlag '{final_suggested_url}' ist eine ungültige URL.") final_suggested_url = f"Vorschlag (ungültig): {final_suggested_url}" else: self.logger.info(f" -> KI-Vorschlag '{final_suggested_url}' ist eine gültige URL.") now_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") # Ihre bestehende Update-Logik, aber mit der sicheren get_col_idx Funktion updates_for_row = [ {'range': f'{self.sheet_handler._get_col_letter(get_col_idx("Chat Wiki Konsistenzpruefung") + 1)}{current_row_num}', 'values': [[verification_result.get("consistency")]]}, {'range': f'{self.sheet_handler._get_col_letter(get_col_idx("Chat Begründung Wiki Inkonsistenz") + 1)}{current_row_num}', 'values': [[verification_result.get("justification")]]}, {'range': f'{self.sheet_handler._get_col_letter(get_col_idx("Chat Vorschlag Wiki Artikel") + 1)}{current_row_num}', 'values': [[final_suggested_url]]}, {'range': f'{self.sheet_handler._get_col_letter(get_col_idx("Wiki Verif. Timestamp") + 1)}{current_row_num}', 'values': [[now_timestamp]]} ] all_updates.extend(updates_for_row) processed_count_in_batch += 1 total_processed_since_start += 1 if processed_count_in_batch >= BATCH_SIZE: self.logger.info(f"Batch-Größe ({BATCH_SIZE}) erreicht. Schreibe {len(all_updates)} Zell-Updates...") self.sheet_handler.batch_update_cells(all_updates) self.logger.info("Batch erfolgreich geschrieben.") all_updates = [] processed_count_in_batch = 0 time.sleep(1) except Exception as e: self.logger.error(f"Unerwarteter Fehler bei Verarbeitung von Zeile {current_row_num}: {e}", exc_info=True) else: self.logger.debug(f"Zeile {current_row_num}: Übersprungen (Timestamp vorhanden oder keine URL).") if all_updates: self.logger.info(f"Schleife beendet. Schreibe die letzten {len(all_updates)} Zell-Updates...") self.sheet_handler.batch_update_cells(all_updates) self.logger.info("Finaler Batch erfolgreich geschrieben.") self.logger.info(f"Wiki-Verifizierung abgeschlossen. {total_processed_since_start} Zeilen insgesamt verarbeitet.") def process_fsm_pitch(self, start_sheet_row=None, end_sheet_row=None, limit=None): """ Generiert FSM-Pitches für alle Zeilen, bei denen der Pitch oder der Timestamp fehlt. Nutzt die neue, verbesserte generate_fsm_pitch Funktion. """ self.logger.info(f"Starte Batch-Modus 'fsm_pitch'. Bereich: {start_sheet_row or 'Start'}-{end_sheet_row or 'Ende'}, Limit: {limit or 'Unbegrenzt'}") if not self.sheet_handler.load_data(): self.logger.error("Fehler beim Laden der Daten für FSM-Pitch-Generierung.") return all_data = self.sheet_handler.get_all_data_with_headers() header_rows = self.sheet_handler._header_rows effective_start = start_sheet_row if start_sheet_row is not None else header_rows + 1 effective_end = end_sheet_row if end_sheet_row is not None else len(all_data) tasks = [] for i in range(effective_start - 1, effective_end): if limit is not None and len(tasks) >= limit: break row_data = all_data[i] timestamp = self._get_cell_value_safe(row_data, "FSM Pitch Timestamp").strip() pitch = self._get_cell_value_safe(row_data, "FSM Pitch").strip() if not timestamp or "fehler" in pitch.lower(): company_name = self._get_cell_value_safe(row_data, "CRM Name").strip() if company_name: tasks.append({'row_num': i + 1, 'data': row_data}) if not tasks: self.logger.info("Keine Zeilen gefunden, die eine FSM-Pitch-Generierung erfordern.") return self.logger.info(f"{len(tasks)} Zeilen für FSM-Pitch-Generierung identifiziert. Starte Verarbeitung...") all_sheet_updates = [] processed_count = 0 update_batch_size = getattr(Config, 'UPDATE_BATCH_ROW_LIMIT', 50) now_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") for idx, task in enumerate(tasks): row_num = task['row_num'] row_data = task['data'] self.logger.debug(f"Verarbeite FSM-Pitch für Zeile {row_num}...") try: # Rufe die neue, verbesserte Pitch-Funktion mit Daten aus dem Sheet auf fsm_pitch_text = generate_fsm_pitch( company_name=self._get_cell_value_safe(row_data, "CRM Name"), company_short_name=self._get_cell_value_safe(row_data, "CRM Kurzform"), ki_branche=self._get_cell_value_safe(row_data, "Chat Vorschlag Branche"), website_summary=self._get_cell_value_safe(row_data, "Website Zusammenfassung"), wiki_absatz=self._get_cell_value_safe(row_data, "Wiki Absatz"), anzahl_ma=self._get_cell_value_safe(row_data, "Finaler Mitarbeiter (Wiki>CRM)"), anzahl_techniker=self._get_cell_value_safe(row_data, "CRM Anzahl Techniker"), techniker_bucket_ml=self._get_cell_value_safe(row_data, "Geschaetzter Techniker Bucket") ) all_sheet_updates.append({'range': f'{self.sheet_handler._get_col_letter(get_col_idx("FSM Pitch") + 1)}{row_num}', 'values': [[fsm_pitch_text]]}) except Exception as e: self.logger.error(f"FEHLER bei FSM-Pitch-Generierung für Zeile {row_num}: {e}") all_sheet_updates.append({'range': f'{self.sheet_handler._get_col_letter(get_col_idx("FSM Pitch") + 1)}{row_num}', 'values': [['FEHLER (Generierung)']]} ) # IMMER den Timestamp setzen, auch bei Fehler, um Endlosschleifen zu vermeiden all_sheet_updates.append({'range': f'{self.sheet_handler._get_col_letter(get_col_idx("FSM Pitch Timestamp") + 1)}{row_num}', 'values': [[now_timestamp]]}) processed_count += 1 # Batch-Update Logik if (idx + 1) % update_batch_size == 0 or (idx + 1) == len(tasks): if all_sheet_updates: num_rows_in_batch = len(all_sheet_updates) // 2 self.logger.info(f"Sende Batch-Update für {num_rows_in_batch} FSM-Pitches (Verarbeitet bisher: {processed_count}/{len(tasks)})...") self.sheet_handler.batch_update_cells(all_sheet_updates) all_sheet_updates = [] time.sleep(1) self.logger.info(f"FSM-Pitch-Generierung abgeschlossen. {processed_count} Zeilen bearbeitet.") def reclassify_all_branches(self, start_sheet_row=None, limit=None, batch_size=50): """ Führt für alle relevanten Zeilen eine neue Brancheneinstufung (v2.0) in Batches durch. Nutzt nun auch die externe Branchenbeschreibung. """ self.logger.info(f"Starte Modus 'reclassify_branches' im Batch-Modus (Größe: {batch_size}). Bereich: {start_sheet_row or 'Start'}, Limit: {limit or 'Unbegrenzt'}") if not self.sheet_handler.load_data(): return all_data = self.sheet_handler.get_all_data_with_headers() header_rows = self.sheet_handler._header_rows # Definiere den Startpunkt. Wenn kein start_sheet_row übergeben wird, starte nach dem Header. effective_start = start_sheet_row if start_sheet_row is not None else header_rows + 1 tasks = [] # Die Schleife beginnt beim korrekten Startpunkt for i in range(effective_start - 1, len(all_data)): # Das Limit wird HIER geprüft, nicht vorher if limit is not None and len(tasks) >= limit: self.logger.info(f"Limit von {limit} Zeilen erreicht. Stoppe das Sammeln von Tasks.") break row_data = all_data[i] company_name = self._get_cell_value_safe(row_data, "CRM Name").strip() # Der Check auf "firmennamen" ist gut und bleibt if company_name and "firmennamen" not in company_name.lower(): tasks.append({'row_num': i + 1, 'data': row_data}) if not tasks: self.logger.info("Keine Zeilen zur Neubewertung gefunden.") return self.logger.info(f"{len(tasks)} Zeilen für die Neubewertung der Branche identifiziert.") all_sheet_updates = [] now_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") for i in range(0, len(tasks), batch_size): batch_tasks = tasks[i:i + batch_size] self.logger.info(f"Verarbeite Batch {i//batch_size + 1}/{(len(tasks) + batch_size - 1)//batch_size} (Zeilen {batch_tasks[0]['row_num']} bis {batch_tasks[-1]['row_num']})...") companies_data_for_prompt = [] for task in batch_tasks: row_data = task['data'] companies_data_for_prompt.append({ "row_num": task['row_num'], "name": self._get_cell_value_safe(row_data, "CRM Name"), # NEU: Spalte J hinzufügen "external_branch_desc": self._get_cell_value_safe(row_data, "CRM Beschreibung Branche extern"), "summary": self._get_cell_value_safe(row_data, "Website Zusammenfassung"), "wiki": self._get_cell_value_safe(row_data, "Wiki Absatz") }) batch_results = evaluate_branches_batch(companies_data_for_prompt) # ... (Rest der Funktion zum Verarbeiten der Ergebnisse und Schreiben der Updates bleibt unverändert) ... if batch_results: results_by_row = {res['row_num']: res for res in batch_results} for task in batch_tasks: row_num = task['row_num'] result = results_by_row.get(row_num) if result: all_sheet_updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Vorschlag Branche"]["index"] + 1)}{row_num}', 'values': [[result.get('Branche')]]}) all_sheet_updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Branche Konfidenz"]["index"] + 1)}{row_num}', 'values': [[result.get('Konfidenz')]]}) all_sheet_updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Begruendung Abweichung Branche"]["index"] + 1)}{row_num}', 'values': [[result.get('Begruendung')]]}) else: all_sheet_updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Vorschlag Branche"]["index"] + 1)}{row_num}', 'values': [['FEHLER (Batch-Antwort)']]} ) all_sheet_updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Timestamp letzte Pruefung"]["index"] + 1)}{row_num}', 'values': [[now_timestamp]]}) else: self.logger.error(f"Batch-Verarbeitung fehlgeschlagen. Setze Fehlerstatus.") for task in batch_tasks: row_num = task['row_num'] all_sheet_updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Chat Vorschlag Branche"]["index"] + 1)}{row_num}', 'values': [['FEHLER (Batch-API)']]} ) all_sheet_updates.append({'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP["Timestamp letzte Pruefung"]["index"] + 1)}{row_num}', 'values': [[now_timestamp]]}) if all_sheet_updates: self.logger.info(f"Sende finales Batch-Update für {len(tasks)} bewertete Branchen...") self.sheet_handler.batch_update_cells(all_sheet_updates) self.logger.info("Branchen-Neubewertung im Batch-Modus abgeschlossen.") # ========================================================================== # === Batch Processing Methods =========================================== # ========================================================================== @retry_on_failure def _process_verification_openai_batch(self, batch_data): """ Verarbeitet einen Batch von Wikipedia-Verifizierungsanfragen ueber OpenAI. """ if not batch_data: return {} self.logger.debug( f"Sende OpenAI-Batch fuer Wiki-Verifizierung ({len(batch_data)} Zeilen)...") aggregated_prompt = ( "Du bist ein Experte in der Verifizierung von Wikipedia-Artikeln fuer Unternehmen. " "Fuer jeden der folgenden Eintraege pruefe, ob der vorhandene Wikipedia-Artikel plausibel zum Firmennamen und zur Beschreibung passt. " "Gib das Ergebnis fuer jeden Eintrag ausschliesslich im folgenden Format auf einer neuen Zeile aus:\n" "Eintrag : \n\n" "Moegliche Antworten:\n" "- 'OK' (wenn der Artikel gut passt)\n" "- 'X | Alternativer Artikel: | Begruendung: '\n" "- 'X | Kein passender Artikel gefunden | Begruendung: '\n\n" "Eintraege zur Pruefung:\n--------------------\n") max_desc_length = 200 for item in batch_data: entry_text = ( f"Eintrag {item['row_num']}:\n" f" Firmenname: {str(item.get('company_name', 'k.A.'))}\n" f" CRM-Beschreibung: {str(item.get('crm_desc', 'k.A.'))[:max_desc_length]}\n" f" Wikipedia-URL: {str(item.get('wiki_url', 'k.A.'))}\n" f" Wiki-Absatz: {str(item.get('wiki_paragraph', 'k.A.'))[:max_desc_length]}\n" f" Wiki-Kategorien: {str(item.get('wiki_categories', 'k.A.'))[:max_desc_length]}\n----\n") aggregated_prompt += entry_text try: # KORREKTUR: Direkter Aufruf des robusten Helpers 'call_openai_chat' aus helpers.py from helpers import call_openai_chat chat_response = call_openai_chat( aggregated_prompt, temperature=0.0) if not chat_response: raise APIError( "Keine Antwort von OpenAI erhalten.", request=None, body=None) except Exception as e: self.logger.error( f"Endgueltiger FEHLER beim OpenAI-Batch-Aufruf fuer Wiki-Verifizierung: {e}") return { item['row_num']: f"FEHLER API: {str(e)[:100]}" for item in batch_data} answers = {} original_batch_row_nums = {item['row_num'] for item in batch_data} lines = chat_response.strip().split('\n') for line in lines: match = re.match(r"Eintrag (\d+): (.*)", line.strip()) if match: row_num, answer_text = int( match.group(1)), match.group(2).strip() if row_num in original_batch_row_nums: answers[row_num] = answer_text for row_num in original_batch_row_nums: if row_num not in answers: answers[row_num] = "FEHLER: Antwort nicht geparst" return answers def process_verification_batch( self, start_sheet_row=None, end_sheet_row=None, limit=None): """ Batch-Prozess nur fuer Wikipedia-Verifizierung. """ self.logger.info( f"Starte Wikipedia-Verifizierungsmodus (Batch). Bereich: {start_sheet_row if start_sheet_row else 'Start'}-{end_sheet_row if end_sheet_row else 'Ende'}, Limit: {limit if limit else 'Unbegrenzt'}") if start_sheet_row is None: start_data_index = self.sheet_handler.get_start_row_index( check_column_key="Wiki Verif. Timestamp") if start_data_index == -1: return start_sheet_row = start_data_index + self.sheet_handler._header_rows + 1 else: if not self.sheet_handler.load_data(): return all_data = self.sheet_handler.get_all_data_with_headers() header_rows = self.sheet_handler._header_rows total_sheet_rows = len(all_data) if end_sheet_row is None: end_sheet_row = total_sheet_rows if start_sheet_row > end_sheet_row or start_sheet_row > total_sheet_rows: return # ... (Implementation of task collection, batching, and updating remains similar to the original code) ... # This is a simplified placeholder as the logic is very long and # already captured in the original file. """ Verarbeitet einen Batch von Wikipedia-Verifizierungsanfragen ueber OpenAI. Sammelt die Ergebnisse und gibt sie zurueck. Aktualisiert NICHT das Sheet direkt. Args: batch_data (list): Liste von Dictionaries, jedes enthaelt: {'row_num': int, 'company_name': str, 'crm_desc': str, 'wiki_url': str, 'wiki_paragraph': str, 'wiki_categories': str} Returns: dict: Ein Dictionary, das Zeilennummern auf die rohe ChatGPT-Antwort mappt. z.B. {2122: "OK", 2123: "X | ..."} Bei Fehlern oder fehlenden Antworten wird ein Fehlerstring verwendet. Wirft Exception bei endgueltigen API-Fehlern nach Retries. """ # Verwenden Sie logger, da das Logging jetzt konfiguriert ist if not batch_data: return {} # Gebe leeres Dictionary zurueck, wenn keine Tasks da sind self.logger.debug( f"Sende OpenAI-Batch fuer Wiki-Verifizierung ({len(batch_data)} Zeilen, Start: {batch_data[0]['row_num'] if batch_data else 'N/A'})...") # <<< GEÄNDERT # --- Prompt Erstellung --- # Verwenden Sie klare Anweisungen und das definierte Antwortformat. # Vermeiden Sie Umlaute im Prompt, um Encoding-Probleme zu minimieren. aggregated_prompt = ( "Du bist ein Experte in der Verifizierung von Wikipedia-Artikeln fuer Unternehmen. " "Fuer jeden der folgenden Eintraege pruefe, ob der vorhandene Wikipedia-Artikel (URL, Absatz, Kategorien) plausibel zum Firmennamen und zur Beschreibung passt. " "Gib das Ergebnis fuer jeden Eintrag ausschliesslich im folgenden Format auf einer neuen Zeile aus:\n" "Eintrag : \n\n" "Moegliche Antworten:\n" "- 'OK' (wenn der Artikel gut passt)\n" "- 'X | Alternativer Artikel: | Begruendung: ' (wenn der Artikel nicht passt, aber ein besserer gefunden wurde)\n" "- 'X | Kein passender Artikel gefunden | Begruendung: ' (wenn der Artikel nicht passt und kein besserer gefunden wurde)\n" # Der Fall "Kein Wikipedia-Eintrag vorhanden" wird vom Skript VOR diesem Call behandelt # und sollte hier nicht vom KI-Modell generiert werden. "Stelle sicher, dass du nur EINE Zeile pro Eintrag im Format 'Eintrag X: Antwort' ausgibst.\n\n" "Eintraege zur Pruefung:\n" "--------------------\n" ) # Fuegen Sie die Daten fuer jeden Eintrag im Batch hinzu. # Kuerzen Sie die Beschreibungen und Kategorien, um das Prompt-Limit zu reduzieren. # Stellen Sie sicher, dass die Werte Strings sind und "k.A." richtig # behandelt wird. max_desc_length = 200 # Maximale Laenge fuer Beschreibungsteile im Prompt for item in batch_data: row_num = item['row_num'] # Holen und Kuerzen Sie die Werte sicher. Ersetzen Sie None durch # "k.A.". company_name = str(item.get('company_name', 'k.A.')) crm_desc = str(item.get('crm_desc', 'k.A.')) wiki_url = str(item.get('wiki_url', 'k.A.')) wiki_paragraph = str(item.get('wiki_paragraph', 'k.A.')) wiki_categories = str(item.get('wiki_categories', 'k.A.')) # Kuerzen Sie die Laengen und fuegen Sie "..." hinzu, wenn gekuerzt # wurde. crm_desc_short = crm_desc[:max_desc_length] + \ '...' if len(crm_desc) > max_desc_length else crm_desc wiki_paragraph_short = wiki_paragraph[:max_desc_length] + '...' if len( wiki_paragraph) > max_desc_length else wiki_paragraph wiki_categories_short = wiki_categories[:max_desc_length] + '...' if len( wiki_categories) > max_desc_length else wiki_categories entry_text = ( f"Eintrag {row_num}:\n" f" Firmenname: {company_name}\n" f" CRM-Beschreibung: {crm_desc_short}\n" f" Wikipedia-URL: {wiki_url}\n" f" Wiki-Absatz: {wiki_paragraph_short}\n" f" Wiki-Kategorien: {wiki_categories_short}\n" f"----\n" ) aggregated_prompt += entry_text # Fuegen Sie den Abschluss des Prompts hinzu. aggregated_prompt += "--------------------\nBitte nur die 'Eintrag X: Antwort'-Zeilen ausgeben." # Optional: Token zaehlen fuer den Prompt. # try: prompt_tokens = token_count(aggregated_prompt, model=getattr(Config, 'TOKEN_MODEL', 'gpt-3.5-turbo')); self.logger.debug(f"Geschaetzt Prompt-Tokens fuer Batch: {prompt_tokens}."); # except Exception as e_tc: self.logger.debug(f"Fehler beim # Token-Zaehlen: {e_tc}"); # --- ChatGPT Aufruf --- # call_openai_chat (Block 8) nutzt den retry_on_failure Decorator und wirft bei endgueltigem Fehler eine Exception. # Der retry_on_failure Decorator auf dieser Funktion faengt die Exception # von call_openai_chat und fuehrt die Retries fuer die GESAMTE # Batch-Funktion durch. chat_response = None try: # Rufe die zentrale OpenAI Chat API Funktion auf (Block 8). # Standard Temperatur 0.0 fuer Klassifizierung/Verifizierung. chat_response = call_openai_chat( aggregated_prompt, temperature=0.0) # Wenn call_openai_chat erfolgreich ist, gibt es den String zurueck. # Exceptions werden nach Retries von call_openai_chat geworfen und # vom aeusseren retry_on_failure dieser Funktion gefangen. if not chat_response: # Dieser Fall sollte nach der Aenderung in call_openai_chat # (wirft Exception) nicht mehr auftreten. self.logger.error( "call_openai_chat gab unerwarteterweise None zurueck fuer Wiki-Verifizierungs-Batch.") # <<< GEÄNDERT # Werfen Sie eine spezifische Exception, damit der aeussere # Decorator sie faengt. raise openai.error.APIError( "Keine Antwort von OpenAI erhalten fuer Wiki-Verifizierungs-Batch.") except Exception as e: # Wenn call_openai_chat oder der aeussere retry_on_failure eine Exception wirft (nach Retries) # Die Exception wird hier gefangen, bevor sie an den Aufrufer # (process_verification_batch) weitergeleitet wird. self.logger.error( f"Endgueltiger FEHLER beim OpenAI-Batch-Aufruf fuer Wiki-Verifizierung (innerhalb Batch Decorator): {e}") # <<< GEÄNDERT # Logge den Traceback self.logger.debug(traceback.format_exc()) # <<< GEÄNDERT # Geben Sie ein Dictionary zurueck, das signalisiert, dass fuer # alle Zeilen im Batch ein Fehler aufgetreten ist return { item['row_num']: f"FEHLER API: {str(e)[:100]}" for item in batch_data} # --- Antwort parsen --- answers = {} # Initialisieren Sie das Ergebnis-Dictionary # Liste der Zeilennummern, die im ursprünglichen Batch angefragt wurden original_batch_row_nums = {item['row_num'] for item in batch_data} lines = chat_response.strip().split('\n') parsed_count = 0 for line in lines: # Matcht "Eintrag :" und den Rest der Zeile match = re.match(r"Eintrag (\d+): (.*)", line.strip()) if match: row_num = int(match.group(1)) answer_text = match.group(2).strip() # Pruefen Sie, ob die Zeilennummer im urspruenglichen Batch # angefragt wurde if row_num in original_batch_row_nums: answers[row_num] = answer_text parsed_count += 1 # else: self.logger.debug(f"Warnung: Antwort fuer unerwartete # Zeilennummer {row_num} im Batch erhalten: # {answer_text[:100]}...") # Zu viel Laerm (gekuerzt loggen) # Logge das Ergebnis des Parsens self.logger.debug( f"OpenAI-Batch-Antwort geparst: {parsed_count} von {len(original_batch_row_nums)} Zeilen erfolgreich zugeordnet.") # <<< GEÄNDERT # Fuegen Sie einen Fehlerwert fuer Zeilen hinzu, die nicht geparst # werden konnten (z.B. falsches Antwortformat) if parsed_count < len(original_batch_row_nums): self.logger.warning( f"Nicht alle Zeilen aus dem Batch ({len(original_batch_row_nums)}) konnten in der OpenAI-Antwort ({len(lines)} Zeilen) geparst werden.") # <<< GEÄNDERT # Logge den Anfang der unvollstaendigen Antwort auf Debug self.logger.debug( f"Unerwartete Antwortteile (erste 500 Zeichen): {chat_response[:500]}") # <<< GEÄNDERT for row_num in original_batch_row_nums: if row_num not in answers: answers[row_num] = "FEHLER: Antwort nicht geparst" # Die 'answers' Dictionary enthaelt nun Ergebnisse fuer alle Zeilen, # entweder geparst oder mit einem Fehlerstring. return answers # Rueckgabe des Dictionarys mit Ergebnissen oder Fehlern # --- Methode fuer den Wiki-Verifizierungs-Batchmodus (AX) --- # Diese Methode koordiniert die Auswahl der Zeilen, die Batch-Verarbeitung durch OpenAI, # und das Schreiben der Ergebnisse (S, T, U, V-Y, AX, AP) ins Sheet. # Basierend auf process_verification_only und _process_batch aus Teil 8. # Nutzt interne Helfer: _get_cell_value_safe, _get_col_letter, _process_verification_openai_batch (derselben Block). # Nutzt globale Helfer: COLUMN_MAP (Block 1), logger, Config (Block 1), datetime, time. # Nutzt die uebergeordnete sheet_handler Instanz (Block 14). def process_verification_batch( self, start_sheet_row=None, end_sheet_row=None, limit=None): """ Batch-Prozess nur fuer Wikipedia-Verifizierung (Spalten S-U, V-Y werden geleert). Laedt Daten neu, prueft fuer jede Zeile im Bereich, ob Timestamp AX (Wiki Verif.) bereits gesetzt ist, ob eine Wiki URL (M) vorhanden ist und ob Status S nicht bereits 'OK', 'X (URL Copied)' oder 'X (Invalid Suggestion)' ist. Setzt AX + AP fuer bearbeitete Zeilen und schreibt S-U in Batches. Args: start_sheet_row (int, optional): Die 1-basierte Startzeile im Sheet. Defaults to None (automatische Ermittlung basierend auf leeren AX). 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 die Konfiguration des Batch-Laufs self.logger.info( f"Starte Wikipedia-Verifizierungsmodus (Batch S-U, AX). 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'}...") # <<< GEÄNDERT # --- Daten laden und Startzeile ermitteln --- # Automatische Ermittlung der Startzeile, wenn nicht manuell gesetzt if start_sheet_row is None: self.logger.info( "Automatische Ermittlung der Startzeile basierend auf leeren AX...") # <<< GEÄNDERT # Nutzt get_start_row_index des Sheet Handlers (Block 14). Prueft auf leeren AX (Block 1 Column Map). # Standardmaessig ab Zeile 7 start_data_index_no_header = self.sheet_handler.get_start_row_index( check_column_key="Wiki Verif. 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 Batch ab.") # <<< GEÄNDERT 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 AX Zelle): {start_sheet_row}") # <<< GEÄNDERT 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 process_verification_batch.") # <<< GEÄNDERT 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"Verarbeitungsbereich: Sheet-Zeilen {start_sheet_row} bis {end_sheet_row}. Gesamtzeilen im Sheet: {total_sheet_rows}") # <<< GEÄNDERT # 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.") # <<< GEÄNDERT 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 = [ # Pruefkriterien / Timestamp (AX, M, S) "Wiki Verif. Timestamp", "Wiki URL", "Chat Wiki Konsistenzpruefung", # Daten fuer Prompt (B, F, N, R) "CRM Name", "CRM Beschreibung", "Wiki Absatz", "Wiki Kategorien", # Ergebnisspalten (T, U) "Chat Begründung Wiki Inkonsistenz", "Chat Vorschlag Wiki Artikel", # Spalten V-Y zum Leeren "Begruendung bei Abweichung", "Chat Begruendung Abweichung Branche", "Wikipedia Timestamp", "Timestamp letzte Pruefung", # Spalten AN, AO zum Leeren "Version", "SerpAPI Wiki Search Timestamp" # Spalten AP, AY 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_verification_batch: {missing}. Breche ab.") # <<< GEÄNDERT return # Beende die Methode bei kritischem Fehler # Ermitteln Sie die Spaltenbuchstaben fuer Updates und Leerung (nutzt # interne Helfer _get_col_letter Block 14) ts_ax_letter = self.sheet_handler._get_col_letter( col_indices["Wiki Verif. Timestamp"] + 1) # Timestamp zu setzen (AX) s_letter = self.sheet_handler._get_col_letter( col_indices["Chat Wiki Konsistenzpruefung"] + 1) # Status S t_letter = self.sheet_handler._get_col_letter( col_indices["Chat Begründung Wiki Inkonsistenz"] + 1) # Begruendung T u_letter = self.sheet_handler._get_col_letter( col_indices["Chat Vorschlag Wiki Artikel"] + 1) # Vorschlag U # Spalten V-Y leeren (werden in diesem Modus nicht neu befuellt). # V ist Begruendung bei Abweichung (von Wiki-URL Pruefung CRM vs Wiki). # Y ist Begruendung Abweichung Branche (von Chat). v_idx = col_indices["Begruendung bei Abweichung"] # Block 1 Column Map y_idx = col_indices["Chat Begruendung Abweichung Branche"] # Erstellen Sie den Bereichsnamen (z.B. "V:Y") v_letter = self.sheet_handler._get_col_letter(v_idx + 1) y_letter = self.sheet_handler._get_col_letter(y_idx + 1) v_y_range_letter = f'{v_letter}:{y_letter}' # z.B. V:Y # Erstellen Sie eine Liste von leeren Strings fuer diesen Bereich # Anzahl der Spalten = Y_Index - V_Index + 1 empty_vy_values = [''] * (y_idx - v_idx + 1) # Timestamps AN, AO, AP, AY leeren. # Diese werden von anderen Schritten gesetzt und sollen hier zurueckgesetzt werden, # um sicherzustellen, dass die Zeile bei Bedarf von diesen anderen # Schritten erneut bearbeitet wird. 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) 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 OpenAI-Aufrufe aus Config (Block 1) # Nutzt dieselbe Batch-Groesse wie Scraping/Summarization openai_batch_size = getattr(Config, 'PROCESSING_BATCH_SIZE', 20) # Holen Sie die Batch-Groesse fuer Sheet-Updates aus Config (Block 1) update_batch_row_limit = getattr(Config, 'UPDATE_BATCH_ROW_LIMIT', 50) # Daten fuer den aktuellen OpenAI Batch (Liste von Dicts) current_openai_batch_data = [] # 1-basierte Zeilennummern im aktuellen OpenAI Batch rows_in_current_openai_batch = [] # Gesammelte Updates fuer Batch-Schreiben ins Sheet (Liste von Dicts) all_sheet_updates = [] # Zaehlt Zeilen, die fuer die Verarbeitung in Frage kommen und in den # Batch aufgenommen werden (im Rahmen des Limits). processed_count = 0 # Zaehlt Zeilen, die uebersprungen wurden (wegen Status, fehlender # Daten etc.). skipped_count = 0 # Zaehlt Zeilen, die speziell wegen fehlender M-URL uebersprungen # wurden. skipped_no_wiki_url = 0 # Iteriere ueber die Sheet-Zeilen 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 (mindestens Name vorhanden) # Nutzt interne Helfer _get_cell_value_safe company_name = self._get_cell_value_safe( row, "CRM Name").strip() # Block 1 Column Map if not company_name: self.logger.debug( f"Zeile {i}: Uebersprungen (Kein Firmenname in Spalte B).") # <<< GEÄNDERT skipped_count += 1 # Zaehlen als uebersprungen continue # Springe zur naechsten Zeile # --- Pruefung, ob Verarbeitung fuer diese Zeile noetig ist --- # Kriterium: Wiki Verif. Timestamp (AX) ist leer # UND Wiki URL (M) ist gefuellt und gueltig aussehend (nicht k.A., Fehler etc.) # UND Status S ist NICHT bereits in einem Endzustand (OK, X # (UPDATED/COPIED/INVALID)). # Holen Sie die Werte aus den entsprechenden Spalten (nutzt interne # Helfer) ax_value = self._get_cell_value_safe( row, "Wiki Verif. Timestamp").strip() # Block 1 Column Map m_value = self._get_cell_value_safe( row, "Wiki URL").strip() # Block 1 Column Map s_value_upper = self._get_cell_value_safe( row, "Chat Wiki Konsistenzpruefung").strip().upper() # Block 1 Column Map # Pruefen Sie, ob die Wiki URL (M) gueltig aussieht is_wiki_url_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 # Definieren Sie die Endzustaende von Status S (Grossbuchstaben) s_end_states = [ "OK", "X (UPDATED)", "X (URL COPIED)", "X (INVALID SUGGESTION)"] # Pruefen Sie, ob Status S in einem Endzustand ist is_s_in_endstate = s_value_upper in s_end_states # Bugfix: Korrekte Zuweisung # Verarbeitung ist noetig, wenn AX leer UND M gefuellt/gueltig # aussieht UND S NICHT im Endzustand ist. processing_needed_for_row = not ax_value and is_wiki_url_valid_looking and not is_s_in_endstate # 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} ({company_name[:50]}... Wiki Verif. Check): AX leer? {not ax_value}, M gueltig? {is_wiki_url_valid_looking}, S ('{s_value_upper}') Endzustand? {is_s_in_endstate}. Benötigt Verarbeitung? {processing_needed_for_row}") # <<< GEÄNDERT # Wenn die Verarbeitung fuer diese Zeile nicht noetig ist if not processing_needed_for_row: skipped_count += 1 # Zaehlen als uebersprungene Zeile # Zaehlen Sie separat, wenn die Zeile speziell wegen fehlender # M-URL uebersprungen wurde if not is_wiki_url_valid_looking: skipped_no_wiki_url += 1 continue # Springe zur naechsten Zeile # --- Wenn Verarbeitung noetig: Fuege zur Batch-Liste fuer OpenAI hinzu --- # Zaehle die Zeile, die fuer die Verarbeitung in Frage kommt (im # Rahmen des Limits zaehlen) processed_count += 1 # 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_verification_batch erreicht. Breche weitere Zeilenpruefung ab.") # <<< GEÄNDERT break # Brich die Schleife ab # Sammle die benoetigten Daten fuer den OpenAI Prompt (Block 26 - _process_verification_openai_batch). # Diese Daten werden in einem Dictionary fuer den Batch gesammelt. crm_desc = self._get_cell_value_safe( row, "CRM Beschreibung") # Block 1 Column Map wiki_paragraph = self._get_cell_value_safe( row, "Wiki Absatz") # Block 1 Column Map wiki_categories = self._get_cell_value_safe( row, "Wiki Kategorien") # Block 1 Column Map # Fuege die Daten dieser Zeile zur aktuellen Batch-Liste fuer # OpenAI hinzu current_openai_batch_data.append({ 'row_num': i, # Die 1-basierte Sheet-Zeilennummer 'company_name': company_name, # Nutzt den initial geladenen Namen 'crm_desc': crm_desc, 'wiki_url': m_value, # Nutzt die M-URL aus dem Sheet 'wiki_paragraph': wiki_paragraph, 'wiki_categories': wiki_categories }) # Fuege die Zeilennummer zur Liste der Zeilennummern im Batch hinzu rows_in_current_openai_batch.append(i) # --- Verarbeite den Batch, wenn voll --- # Pruefe, ob die aktuelle Batch-Liste die definierte Groesse erreicht hat. # openai_batch_size wird aus Config geholt (Block 1). if len(current_openai_batch_data) >= openai_batch_size: # Logge den Start der Batch-Verarbeitung batch_start_row = current_openai_batch_data[0]['row_num'] batch_end_row = current_openai_batch_data[-1]['row_num'] self.logger.debug( f"\n--- Starte Wiki-Verifizierungs-Batch ({len(current_openai_batch_data)} Tasks, Zeilen {batch_start_row}-{batch_end_row}) ---") # <<< GEÄNDERT # Rufe die interne Methode auf, die den OpenAI Call fuer den Batch macht. # _process_verification_openai_batch (Block 26) ist mit retry_on_failure dekoriert. # Wenn _process_verification_openai_batch eine Exception wirft # (nach Retries), wird diese hier gefangen. batch_results = self._process_verification_openai_batch( current_openai_batch_data) # Ergebnisse sollten ein Dictionary {row_num: # raw_chatgpt_answer} sein, auch bei Fehlern. # Sammle Sheet Updates basierend auf den Batch-Ergebnissen. # Setze immer den Timestamp AX und die Werte in S, T, U und V-Y. # Der aktuelle Zeitstempel fuer den Batch current_batch_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") batch_sheet_updates = [] # Updates fuer DIESEN spezifischen Batch von Zeilen # Iteriere ueber die Zeilennummern, die in DIESEM OpenAI Batch # waren for row_num in rows_in_current_openai_batch: # Hole das Ergebnis fuer diese Zeile aus dem Ergebnis-Dictionary. # Fallback auf einen Fehlerstring, wenn das Ergebnis fehlt # (sollte nicht passieren, wenn # _process_verification_openai_batch korrekt ist). answer = batch_results.get( row_num, "FEHLER: Batch-Ergebnis fehlt") # self.logger.debug(f"Zeile {row_num} # Verifizierungsantwort: '{answer[:100]}...'") # Zu viel # Laerm (gekuerzt) # Logik zur Bestimmung der Werte fuer S, T, U basierend auf # 'answer' (aehnlich wie in altem _process_batch) # Initialisiere mit leeren Strings wiki_confirm, alt_article, wiki_explanation = "", "", "" # Pruefe auf Standard-Antworten und Fehler-Antworten if isinstance(answer, str) and answer.upper() == "OK": wiki_confirm = "OK" wiki_explanation = "Passt laut KI zur Firma." # Standard Begruendung bei OK elif isinstance(answer, str) and answer.startswith("X |"): # Parse die Antwort im Format "X | | # " # Teile maximal in 3 Teile parts = answer.split("|", 2) wiki_confirm = "X" # Status ist X if len(parts) > 1: # Zweiter Teil ist Detail (Alternative URL oder # "Kein passender Artikel gefunden") detail = parts[1].strip() if detail.lower().startswith("alternativer artikel:"): alt_article = detail.split( ":", 1)[1].strip() # Extrahiere URL elif detail.lower() == "kein passender artikel gefunden": alt_article = detail # Text "Kein passender Artikel gefunden" else: alt_article = detail # Unbekanntes Detail if len(parts) > 2: # Dritter Teil ist Begruendung reason_part = parts[2].strip() if reason_part.lower().startswith("begruendung:"): wiki_explanation = reason_part.split( ":", 1)[1].strip() # Extrahiere Begruendungstext else: wiki_explanation = reason_part # Unbekannte Begruendung # Fuege ggf. den rohen Antworttext zur Begruendung # hinzu, wenn Parsing unvollstaendig war if not alt_article or not wiki_explanation: wiki_explanation += f" (Rohantwort: {answer[:100]}...)" elif isinstance(answer, str) and answer.startswith("FEHLER"): # Wenn die Batch-Verarbeitung einen Fehler # zurueckgegeben hat wiki_confirm = "FEHLER" wiki_explanation = answer # Fehlermeldung in Begruendung schreiben alt_article = "Siehe Begruendung" # Verweis auf Begruendung # Unerwartetes Format der Antwort (weder OK noch X | noch # FEHLER) else: wiki_confirm = "?" # Setze Status auf unbekannt # Speichere Anfang der Antwort in Begruendung # (gekuerzt) wiki_explanation = f"Unerwartetes Format: {str(answer)[:100]}..." alt_article = "Siehe Begruendung" # Verweis auf Begruendung # Spalten V-Y (Begruendung bei Abweichung etc.) werden in diesem Modus geleert # Fuer jede Zeile im Batch fuegen wir das Update hinzu. # empty_vy_values wurde oben vorbereitet. v_y_values = empty_vy_values # Liste von leeren Strings # Fuege Update zum Leeren von V-Y hinzu, falls Index # gefunden wurde if v_y_range_letter: # Pruefe, ob der Bereichsname ermittelt werden konnte batch_sheet_updates.append({'range': f'{v_letter}{row_num}:{y_letter}{row_num}', 'values': [ v_y_values]}) # Block 1 Column Map, interne Helfer # Fuege Updates fuer S, T, U und AX hinzu (nutzt interne # Helfer) batch_sheet_updates.append({'range': f'{s_letter}{row_num}', 'values': [ [wiki_confirm]]}) # Block 1 Column Map batch_sheet_updates.append({'range': f'{t_letter}{row_num}', 'values': [ [alt_article]]}) # Block 1 Column Map batch_sheet_updates.append({'range': f'{u_letter}{row_num}', 'values': [ [wiki_explanation]]}) # Block 1 Column Map # Setze AX Timestamp fuer diese Zeile batch_sheet_updates.append({'range': f'{ts_ax_letter}{row_num}', 'values': [ [current_batch_timestamp]]}) # Block 1 Column Map # --- Sende gesammelte Updates fuer diesen Batch --- # Sammle die Updates fuer diesen Batch in der globalen Liste. # all_sheet_updates.extend(batch_sheet_updates) # Nicht hier # sammeln, sondern direkt senden # Sende die gesammelten Updates fuer DIESEN Batch sofort. if batch_sheet_updates: self.logger.debug( f" Sende Sheet-Update fuer {len(rows_in_current_openai_batch)} Zeilen ({len(batch_sheet_updates)} Zellen) dieses Batches...") # <<< GEÄNDERT # 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( batch_sheet_updates) if success: self.logger.info( f" Sheet-Update fuer Wiki-Verifizierungs-Batch Zeilen {batch_start_row}-{batch_end_row} erfolgreich.") # <<< GEÄNDERT # Der Fehlerfall wird von batch_update_cells geloggt # Setze Batch-Listen zurueck fuer die naechste Iteration current_openai_batch_data = [] rows_in_current_openai_batch = [] # Pause nach jedem OpenAI Batch (nutzt Config Block 1). # Dies ist wichtig, um Rate Limits zu vermeiden. # Nutze Config.RETRY_DELAY, ggf. kuerzer, da es ein Batch war pause_duration = getattr( Config, 'RETRY_DELAY', 5) * 0.5 # 50% der Retry-Wartezeit self.logger.debug( f"--- Batch Zeilen {batch_start_row}-{batch_end_row} abgeschlossen. Warte {pause_duration:.2f}s vor naechstem Batch ---") # <<< GEÄNDERT time.sleep(pause_duration) # --- Verarbeitung des letzten unvollstaendigen Batches nach der Schleife --- # Wenn nach der Hauptschleife noch Tasks in der Batch-Liste sind if current_openai_batch_data: # Logge den Start des finalen Batches batch_start_row = current_openai_batch_data[0]['row_num'] batch_end_row = current_openai_batch_data[-1]['row_num'] self.logger.debug( f"\n--- Starte FINALEN Wiki-Verifizierungs-Batch ({len(current_openai_batch_data)} Tasks, Zeilen {batch_start_row}-{batch_end_row}) ---") # <<< GEÄNDERT # Rufe die interne Methode auf, die den OpenAI Call macht batch_results = self._process_verification_openai_batch( current_openai_batch_data) # Ergebnisse sollten ein Dictionary {row_num: raw_chatgpt_answer} # sein, auch bei Fehlern. # Sammle Sheet Updates (S, T, U, V-Y, AX) fuer diesen finalen Batch current_batch_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") batch_sheet_updates = [] # Updates fuer DIESEN spezifischen Batch von Zeilen # Iteriere ueber die Zeilennummern, die in DIESEM finalen OpenAI # Batch waren for row_num in rows_in_current_openai_batch: # Hole das Ergebnis fuer diese Zeile aus dem # Ergebnis-Dictionary. answer = batch_results.get( row_num, "FEHLER: Batch-Ergebnis fehlt") # Fallback # Logik zur Bestimmung der Werte fuer S, T, U basierend auf # 'answer' wiki_confirm, alt_article, wiki_explanation = "", "", "" # Leere V-Y Spalten v_y_values = empty_vy_values # Liste von leeren Strings if isinstance(answer, str) and answer.upper() == "OK": wiki_confirm = "OK" wiki_explanation = "Passt laut KI zur Firma." elif isinstance(answer, str) and answer.startswith("X |"): parts = answer.split("|", 2) wiki_confirm = "X" if len(parts) > 1: detail = parts[1].strip() alt_article = detail.split(":", 1)[1].strip() if detail.lower( ).startswith("alternativer artikel:") else detail if len(parts) > 2: reason_part = parts[2].strip() wiki_explanation = reason_part.split(":", 1)[1].strip( ) if reason_part.lower().startswith("begruendung:") else reason_part if not alt_article or not wiki_explanation: wiki_explanation += f" (Rohantwort: {answer[:100]}...)" elif isinstance(answer, str) and answer.startswith("FEHLER"): wiki_confirm = "FEHLER" wiki_explanation = answer alt_article = "Siehe Begruendung" else: wiki_confirm = "?" wiki_explanation = f"Unerwartetes Format: {str(answer)[:100]}..." alt_article = "Siehe Begruendung" # Fuege Updates fuer S, T, U und AX hinzu batch_sheet_updates.append({'range': f'{s_letter}{row_num}', 'values': [ [wiki_confirm]]}) # Block 1 Column Map batch_sheet_updates.append({'range': f'{t_letter}{row_num}', 'values': [ [alt_article]]}) # Block 1 Column Map batch_sheet_updates.append({'range': f'{u_letter}{row_num}', 'values': [ [wiki_explanation]]}) # Block 1 Column Map # Setze AX Timestamp batch_sheet_updates.append({'range': f'{ts_ax_letter}{row_num}', 'values': [ [current_batch_timestamp]]}) # Block 1 Column Map # Fuege Update zum Leeren von V-Y hinzu, falls Index gefunden # wurde if v_y_range_letter: batch_sheet_updates.append({'range': f'{v_letter}{row_num}:{y_letter}{row_num}', 'values': [ v_y_values]}) # Block 1 Column Map, interne Helfer # Sende die gesammelten Updates fuer DIESEN finalen Batch. if batch_sheet_updates: self.logger.debug( f" Sende FINALES Sheet-Update fuer {len(rows_in_current_openai_batch)} Zeilen ({len(batch_sheet_updates)} Zellen)...") # <<< GEÄNDERT # Nutzt die batch_update_cells Methode des Sheet Handlers # (Block 14) mit Retry. success = self.sheet_handler.batch_update_cells( batch_sheet_updates) if success: self.logger.info( f" FINALES Sheet-Update fuer Wiki-Verifizierungs-Batch erfolgreich.") # <<< GEÄNDERT # Der Fehlerfall wird von batch_update_cells geloggt # Logge den Abschluss des Modus self.logger.info( f"Wikipedia-Verifizierungs-Batch abgeschlossen. {processed_count} Zeilen verarbeitet (in Batch aufgenommen), {skipped_count} Zeilen uebersprungen ({skipped_no_wiki_url} wegen fehlender M-URL).") # <<< GEÄNDERT def process_summarize_website(self, start_sheet_row=None, end_sheet_row=None, limit=None): """ Batch-Prozess NUR für Website-Zusammenfassung. Nutzt einen ThreadPoolExecutor für echte parallele Verarbeitung und verbesserte Stabilität. """ self.logger.info(f"Starte Website-Zusammenfassung (Parallel Batch, v2.0.3). Bereich: {start_sheet_row or 'Start'}-{end_sheet_row or 'Ende'}, Limit: {limit or 'Unbegrenzt'}") # --- 1. Daten laden und Startzeile ermitteln --- if start_sheet_row is None: self.logger.info("Automatische Ermittlung der Startzeile basierend auf leeren 'Website Zusammenfassung'...") start_data_idx = self.sheet_handler.get_start_row_index(check_column_key="Website Zusammenfassung") if start_data_idx == -1: self.logger.error("FEHLER bei automatischer Ermittlung der Startzeile. Breche Batch ab.") return start_sheet_row = start_data_idx + self.sheet_handler._header_rows + 1 self.logger.info(f"Automatisch ermittelte Startzeile: {start_sheet_row}") if not self.sheet_handler.load_data(): self.logger.error("FEHLER beim Laden der Daten für Summarization-Batch.") return all_data = self.sheet_handler.get_all_data_with_headers() header_rows = self.sheet_handler._header_rows total_sheet_rows = len(all_data) effective_end_row = end_sheet_row if end_sheet_row is not None else total_sheet_rows self.logger.info(f"Verarbeitungsbereich: Sheet-Zeilen {start_sheet_row} bis {effective_end_row}.") if start_sheet_row > effective_end_row: self.logger.info("Start liegt nach dem Ende. Keine Zeilen zu verarbeiten.") return # --- 2. Spalten-Indizes und Buchstaben vorbereiten --- summary_col_letter = self.sheet_handler._get_col_letter(get_col_idx("Website Zusammenfassung") + 1) version_col_letter = self.sheet_handler._get_col_letter(get_col_idx("Version") + 1) # Wir benötigen auch einen Timestamp für die Zusammenfassung. Da keiner existiert, nutzen wir den Scrape-Timestamp neu. timestamp_col_letter = self.sheet_handler._get_col_letter(get_col_idx("Website Scrape Timestamp") + 1) # --- 3. Tasks sammeln --- openai_batch_size = getattr(Config, 'OPENAI_BATCH_SIZE_LIMIT', 10) # Kann höher sein, da parallel max_openai_workers = getattr(Config, 'MAX_SCRAPING_WORKERS', 10) # Gleiche Worker-Anzahl wie beim Scraping update_batch_row_limit = getattr(Config, 'UPDATE_BATCH_ROW_LIMIT', 50) tasks_for_processing_batch = [] all_sheet_updates = [] processed_count = 0 skipped_count = 0 for i in range(start_sheet_row, effective_end_row + 1): row_index_in_list = i - 1 if row_index_in_list >= total_sheet_rows: break row = all_data[row_index_in_list] if not any(cell and str(cell).strip() for cell in row): skipped_count += 1 continue # Kriterium: Zusammenfassung ist leer/default UND Rohtext ist valide summary_value = self._get_cell_value_safe(row, "Website Zusammenfassung") summary_is_empty_or_default = not summary_value or str(summary_value).strip().lower() in ["k.a.", "k.a. (keine zusammenfassung erhalten)"] raw_text = self._get_cell_value_safe(row, "Website Rohtext") raw_text_is_valid = raw_text and isinstance(raw_text, str) and len(raw_text) > 100 and not str(raw_text).strip().lower().startswith('k.a.') if summary_is_empty_or_default and raw_text_is_valid: if limit is not None and processed_count >= limit: self.logger.info(f"Verarbeitungslimit ({limit}) erreicht.") break company_name = self._get_cell_value_safe(row, "CRM Name") tasks_for_processing_batch.append({"row_num": i, "raw_text": raw_text, "company_name": company_name}) processed_count += 1 else: skipped_count += 1 # --- 4. Batch-Verarbeitung auslösen --- if len(tasks_for_processing_batch) >= openai_batch_size or (i == effective_end_row and tasks_for_processing_batch): self.logger.info(f"--- Starte Website-Summarization Batch für {len(tasks_for_processing_batch)} Tasks (max. {max_openai_workers} Worker) ---") summarization_results = {} batch_error_count = 0 with ThreadPoolExecutor(max_workers=max_openai_workers) as executor: future_to_task = {executor.submit(self._summarize_task_batch, task): task for task in tasks_for_processing_batch} for future in as_completed(future_to_task): task = future_to_task[future] try: result_dict = future.result() if isinstance(result_dict, dict) and 'row_num' in result_dict: summarization_results[result_dict['row_num']] = result_dict if result_dict.get('error'): batch_error_count += 1 self.logger.warning(f"Worker meldete Fehler bei Zusammenfassung für Zeile {result_dict['row_num']}: {result_dict.get('status_message')}") else: self.logger.error(f"Inkonsistentes Ergebnis für Zeile {task['row_num']}: Erwartete dict mit 'row_num', bekam {type(result_dict)}. Überspringe.") summarization_results[task['row_num']] = {'summary': "FEHLER (Inkonsistenter Rückgabetyp)", 'error': True} batch_error_count += 1 except Exception as exc: self.logger.error(f"Unerwarteter Fehler bei Ergebnisabfrage für Zeile {task['row_num']}: {exc}", exc_info=True) summarization_results[task['row_num']] = {'summary': "FEHLER (Task Exception)", 'error': True} batch_error_count += 1 self.logger.info(f" -> Zusammenfassung für Batch beendet. {len(summarization_results)} Ergebnisse erhalten ({batch_error_count} davon mit Fehlern).") # --- 5. Updates für das Google Sheet vorbereiten --- if summarization_results: current_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") current_version = getattr(Config, 'VERSION', 'unknown') for row_num, res_dict in summarization_results.items(): # Zusammenfassung, Timestamp (wir überschreiben den alten Scrape-TS) und Version werden zum Update hinzugefügt all_sheet_updates.append({'range': f'{summary_col_letter}{row_num}', 'values': [[res_dict.get('summary', 'k.A.')]]}) all_sheet_updates.append({'range': f'{timestamp_col_letter}{row_num}', 'values': [[current_timestamp]]}) all_sheet_updates.append({'range': f'{version_col_letter}{row_num}', 'values': [[current_version]]}) tasks_for_processing_batch = [] # Batch leeren # --- 6. Sheet-Update auslösen, wenn Update-Batch voll ist --- # Pro Zeile gibt es 3 Updates if len(all_sheet_updates) >= (update_batch_row_limit * 3): self.logger.info(f"Sende gesammelte Sheet-Updates für Zusammenfassungen ({len(all_sheet_updates) // 3} Zeilen)...") self.sheet_handler.batch_update_cells(all_sheet_updates) all_sheet_updates = [] time.sleep(1) # --- 7. Finale Updates senden --- if all_sheet_updates: self.logger.info(f"Sende finale gesammelte Sheet-Updates für Zusammenfassungen ({len(all_sheet_updates) // 3} Zeilen)...") self.sheet_handler.batch_update_cells(all_sheet_updates) self.logger.info(f"Website-Zusammenfassung (Batch) abgeschlossen. {processed_count} Zeilen zur Verarbeitung ausgewählt, {skipped_count} Zeilen übersprungen.") def evaluate_branch_task(self, task_data, openai_semaphore): """ Führt die Branchenevaluation fuer eine einzelne Zeile aus. """ logger = logging.getLogger(__name__ + ".evaluate_branch_task") row_num = task_data['row_num'] result = { "branch": "k.A. (Fehler Task)", "consistency": "error", "justification": "Fehler in Worker-Task"} error = None try: with openai_semaphore: result = evaluate_branche_chatgpt( task_data['crm_branche'], task_data['beschreibung'], task_data['wiki_branche'], task_data['wiki_kategorien'], task_data['website_summary'] ) except Exception as e: error = f"Fehler bei Branchenevaluation Zeile {row_num}: {e}" logger.error(error) logger.debug(traceback.format_exc()) result = {"branch": "FEHLER", "consistency": "error_task", "justification": error[:500]} return {"row_num": row_num, "result": result, "error": error} def process_branch_batch(self, start_sheet_row=None, end_sheet_row=None, limit=None): """ Batch-Prozess NUR fuer Brancheneinschaetzung. """ global ALLOWED_TARGET_BRANCHES self.logger.info( f"Starte Brancheneinschaetzung (Parallel Batch). Bereich: {start_sheet_row if start_sheet_row else 'Start'}-{end_sheet_row if end_sheet_row else 'Ende'}, Limit: {limit if limit else 'Unbegrenzt'}") if not ALLOWED_TARGET_BRANCHES: load_target_schema() if not ALLOWED_TARGET_BRANCHES: self.logger.critical( "FEHLER: Ziel-Branchenschema konnte nicht geladen werden. Breche Batch ab.") return self.logger.info( f"Starte Brancheneinschaetzung (Parallel Batch). 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 --- if start_sheet_row is None: self.logger.info( "Automatische Ermittlung der Startzeile basierend auf leeren Timestamp letzte Pruefung (BC)...") start_data_index_no_header = self.sheet_handler.get_start_row_index( check_column_key="Timestamp letzte Pruefung", min_sheet_row=7) if start_data_index_no_header == -1: self.logger.error( "FEHLER bei automatischer Ermittlung der Startzeile. Breche Batch ab.") return start_sheet_row = start_data_index_no_header + \ self.sheet_handler._header_rows + 1 self.logger.info( f"Automatisch ermittelte Startzeile (erste leere BC Zelle): {start_sheet_row}") else: if not self.sheet_handler.load_data(): self.logger.error( "FEHLER beim Laden der Daten fuer process_branch_batch.") return all_data = self.sheet_handler.get_all_data_with_headers() header_rows = self.sheet_handler._header_rows total_sheet_rows = len(all_data) if end_sheet_row is None: end_sheet_row = total_sheet_rows self.logger.info( f"Verarbeitungsbereich: Sheet-Zeilen {start_sheet_row} bis {end_sheet_row}. Gesamtzeilen im Sheet: {total_sheet_rows}") 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 # --- Indizes und Buchstaben --- required_keys = [ "Timestamp letzte Pruefung", "CRM Branche", "CRM Beschreibung", "Wiki Branche", "Wiki Kategorien", "Website Zusammenfassung", "Version", "Chat Vorschlag Branche", "Chat Branche Konfidenz", "Chat Konsistenz Branche", "Chat Begruendung Abweichung Branche", "CRM Name"] col_indices = {key: COLUMN_MAP.get(key) for key in required_keys} 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_branch_batch: {missing}. Breche ab.") return MAX_BRANCH_WORKERS = getattr(Config, 'MAX_BRANCH_WORKERS', 10) OPENAI_CONCURRENCY_LIMIT = getattr( Config, 'OPENAI_CONCURRENCY_LIMIT', 3) processing_batch_size = getattr( Config, 'PROCESSING_BRANCH_BATCH_SIZE', 20) tasks_for_current_batch = [] processed_tasks_count = 0 # Zählt Tasks, die tatsächlich verarbeitet wurden skipped_count = 0 if not ALLOWED_TARGET_BRANCHES: load_target_schema() if not ALLOWED_TARGET_BRANCHES: self.logger.critical( "FEHLER: Ziel-Branchenschema konnte nicht geladen werden. Breche Batch ab.") return # Funktion zum Verarbeiten eines einzelnen Batches (um Code-Duplikation # zu reduzieren) def _execute_and_write_batch(batch_tasks_to_run): nonlocal processed_tasks_count # Zugriff auf die äußere Variable if not batch_tasks_to_run: return batch_start_log = batch_tasks_to_run[0]['row_num'] batch_end_log = batch_tasks_to_run[-1]['row_num'] self.logger.debug( f"\n--- Verarbeite Branch-Evaluation Batch ({len(batch_tasks_to_run)} Tasks, Zeilen {batch_start_log}-{batch_end_log}) ---") current_batch_results = [] current_batch_errors = 0 openai_sem = threading.Semaphore(OPENAI_CONCURRENCY_LIMIT) with concurrent.futures.ThreadPoolExecutor(max_workers=MAX_BRANCH_WORKERS) as executor: future_map = { executor.submit( self.evaluate_branch_task, task, openai_sem): task for task in batch_tasks_to_run} for future in concurrent.futures.as_completed(future_to_task): task = future_to_task[future] try: # HIER KOMMT JETZT GARANTIERT EIN DICTIONARY AN result_dict = future.result() # Prüfe explizit, ob es wirklich ein Dictionary ist (doppelte Sicherheit) if not isinstance(result_dict, dict): self.logger.error(f"Fehlerhaftes Ergebnis für Zeile {task['row_num']}: Worker gab keinen Dictionary zurück. Bekam {type(result_dict)}. Überspringe.") batch_error_count += 1 continue # Lese die Werte aus dem Dictionary, anstatt das ganze Objekt zu speichern row_num_from_result = result_dict['row_num'] raw_text_res = result_dict['raw_text'] scraping_results[row_num_from_result] = raw_text_res if result_dict.get('error'): batch_error_count += 1 except Exception as exc: row_num = task['row_num'] err_msg = f"Unerwarteter Fehler bei Ergebnisabfrage für Zeile {row_num} ({task['url'][:100]}): {exc}" self.logger.error(err_msg) scraping_results[row_num] = "k.A. (Unerwarteter Fehler Task)" batch_error_count += 1 self.logger.error( f"Exception im Future für Zeile {task_info['row_num']}: {exc_future}") current_batch_results.append( { "row_num": task_info['row_num'], "result": { "branch": "FEHLER FUTURE", "consistency": "error_task", "justification": str(exc_future)[ :100]}, "error": str(exc_future)}) current_batch_errors += 1 self.logger.debug( f" Batch ({batch_start_log}-{batch_end_log}) beendet. {len(current_batch_results)} Ergebnisse, {current_batch_errors} Fehler.") if current_batch_results: ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S") ver = getattr(Config, 'VERSION', 'unknown') updates_this_batch = [] current_batch_results.sort(key=lambda x: x['row_num']) for item in current_batch_results: rn, res = item['row_num'], item['result'] self.logger.debug(f" Zeile {rn} (Ergebnis): {res}") updates_this_batch.append( { 'range': f'{self.sheet_handler._get_col_letter(get_col_idx("Chat Vorschlag Branche") + 1)}{rn}', 'values': [ [ res.get( "branch", "ERR BR")]]}) updates_this_batch.append( { 'range': f'{self.sheet_handler._get_col_letter(get_col_idx("Chat Branche Konfidenz") + 1)}{rn}', 'values': [ [ res.get( "confidence", "N/A CO")]]}) updates_this_batch.append( { 'range': f'{self.sheet_handler._get_col_letter(get_col_idx("Chat Konsistenz Branche") + 1)}{rn}', 'values': [ [ res.get( "consistency", "err CO")]]}) updates_this_batch.append( { 'range': f'{self.sheet_handler._get_col_letter(get_col_idx("Chat Begruendung Abweichung Branche") + 1)}{rn}', 'values': [ [ res.get( "justification", "No JU")]]}) updates_this_batch.append( { 'range': f'{self.sheet_handler._get_col_letter(get_col_idx("Timestamp letzte Pruefung") + 1)}{rn}', 'values': [ [ts]]}) updates_this_batch.append( { 'range': f'{self.sheet_handler._get_col_letter(get_col_idx("Version") + 1)}{rn}', 'values': [ [ver]]}) if updates_this_batch: self.logger.debug( f" Sende Sheet-Update für {len(current_batch_results)} Zeilen dieses Batches...") s_upd = self.sheet_handler.batch_update_cells( updates_this_batch) if s_upd: self.logger.info( f" Sheet-Update für Batch Zeilen {batch_start_log}-{batch_end_log} erfolgreich.") # Zähle verarbeitete Tasks processed_tasks_count += len(batch_tasks_to_run) pause_dur = getattr(Config, 'RETRY_DELAY', 5) * 0.8 self.logger.debug( f"--- Batch ({batch_start_log}-{batch_end_log}) abgeschlossen. Warte {pause_dur:.2f}s ---") time.sleep(pause_dur) # Ende der Hilfsfunktion _execute_and_write_batch # Hauptschleife über die Zeilen for i in range(start_sheet_row, end_sheet_row + 1): # Prüfe Limit für *tatsächlich verarbeitete* Tasks if limit is not None and processed_tasks_count >= limit: self.logger.info( f"Verarbeitungslimit ({limit}) erreicht. Stoppe weitere Zeilenprüfung.") break row_index_in_list = i - 1 if row_index_in_list >= total_sheet_rows: break row = all_data[row_index_in_list] if not any(cell and isinstance(cell, str) and cell.strip() for cell in row): skipped_count += 1 continue company_name_log = self._get_cell_value_safe( row, "CRM Name").strip() ao_value = self._get_cell_value_safe( row, "Timestamp letzte Pruefung").strip() if ao_value: # Wenn Timestamp gesetzt ist, überspringen skipped_count += 1 continue # --- DEBUG BLOCK für info_sources_count (wie gehabt) --- crm_branche_val = self._get_cell_value_safe( row, "CRM Branche").strip() crm_beschreibung_val = self._get_cell_value_safe( row, "CRM Beschreibung").strip() wiki_branche_val = self._get_cell_value_safe( row, "Wiki Branche").strip() wiki_kategorien_val = self._get_cell_value_safe( row, "Wiki Kategorien").strip() website_summary_val = self._get_cell_value_safe( row, "Website Zusammenfassung").strip() # ... (kompletter detaillierter Debug-Block für info_sources_count hier einfügen) ... self.logger.debug( f"Zeile {i} ({company_name_log[:30]}...) - Rohwerte für Info-Quellen:") sources_to_check = { "CRM Branche": crm_branche_val, "CRM Beschreibung": crm_beschreibung_val, "Wiki Branche": wiki_branche_val, "Wiki Kategorien": wiki_kategorien_val, "Website Zusammenfassung": website_summary_val} info_sources_count = 0 counted_sources = [] for source_name, val in sources_to_check.items(): cond1 = bool(val) cond2 = isinstance(val, str) cond3 = False cond4 = False cond5 = False if cond1 and cond2: stripped_val = val.strip() cond3 = bool(stripped_val) if cond3: lower_stripped_val = stripped_val.lower() cond4 = lower_stripped_val != "k.a." cond5 = not stripped_val.upper().startswith("FEHLER") is_valid_source = cond1 and cond2 and cond3 and cond4 and cond5 if is_valid_source: info_sources_count += 1 counted_sources.append(source_name) self.logger.debug( f" Prüfe Quelle '{source_name}': Wert='{str(val)[:30]}...', c1?{cond1}, c2?{cond2}, c3?{cond3}, c4?{cond4}, c5?{cond5} -> Gültig? {is_valid_source}") self.logger.debug( f"Zeile {i} ({company_name_log[:30]}...) - Gezählte valide Quellen: {info_sources_count} - {counted_sources}") if info_sources_count < 2: self.logger.info( f"Zeile {i} ({company_name_log[:30]}...) (Branch Check): Uebersprungen (Timestamp BC leer, aber nur {info_sources_count} Informationsquellen verfuegbar: {counted_sources}). Mindestens 2 benoetigt.") skipped_count += 1 continue # Task zur Liste hinzufügen, wenn alle Kriterien erfüllt sind UND # Limit noch nicht erreicht if limit is None or ( processed_tasks_count + len(tasks_for_current_batch)) < limit: tasks_for_current_batch.append({ "row_num": i, "crm_branche": crm_branche_val, "beschreibung": crm_beschreibung_val, "wiki_branche": wiki_branche_val, "wiki_kategorien": wiki_kategorien_val, "website_summary": website_summary_val }) elif limit is not None and (processed_tasks_count + len(tasks_for_current_batch)) >= limit: # Wenn das Hinzufügen dieses Tasks das Limit erreichen oder überschreiten würde, # füge ihn noch hinzu (wird im nächsten Batch-Check gekürzt) # und beende dann die Schleife tasks_for_current_batch.append({ "row_num": i, "crm_branche": crm_branche_val, "beschreibung": crm_beschreibung_val, "wiki_branche": wiki_branche_val, "wiki_kategorien": wiki_kategorien_val, "website_summary": website_summary_val }) self.logger.info( f"Zeile {i} wurde als letzter Task vor Erreichen des Limits ({limit}) gesammelt.") # Die execute_and_write_batch Logik wird getriggert, wenn die # Schleife endet oder der Batch voll ist. # Batch ausführen, wenn voll ODER es die letzte Zeile ist if len( tasks_for_current_batch) >= processing_batch_size or i == end_sheet_row: _execute_and_write_batch(tasks_for_current_batch) tasks_for_current_batch = [] # Batch-Liste für den nächsten Durchlauf leeren # Sicherstellen, dass ein eventuell nicht voller letzter Batch auch noch verarbeitet wird, # falls die Schleife durch das Limit beendet wurde und noch Tasks übrig # sind. if tasks_for_current_batch: self.logger.debug( f"Verarbeite verbleibenden Rest-Batch von {len(tasks_for_current_batch)} Tasks...") _execute_and_write_batch(tasks_for_current_batch) self.logger.info( f"Brancheneinschaetzung (Parallel Batch) abgeschlossen. {processed_tasks_count} Zeilen verarbeitet, {skipped_count} Zeilen uebersprungen.") def process_find_wiki_serp( self, start_sheet_row=None, end_sheet_row=None, limit=None, min_employees=500, min_umsatz=200): """ Sucht fehlende Wikipedia-URLs ueber SerpAPI. """ self.logger.info( f"Starte Modus 'find_wiki_serp'. Filter: (Umsatz > {min_umsatz} MIO € ODER MA > {min_employees}). Bereich: {start_sheet_row if start_sheet_row else 'Start'}-{end_sheet_row if end_sheet_row else 'Ende'}, Limit: {limit if limit else 'Unbegrenzt'}") """ Sucht fehlende Wikipedia-URLs (Spalte M = k.A.) ueber SerpAPI fuer Unternehmen mit (Umsatz CRM > min_umsatz MIO € ODER Mitarbeiter CRM > min_employees) UND wenn der SerpAPI Wiki Search Timestamp (AY) leer ist. Traegt gefundene URLs in Spalte M ein. Setzt ReEval-Flag (A) und loescht abhaengige Wiki-Spalten (N-V, AN, AO, AP, AX). Setzt Timestamp in Spalte AY, wann die Suche durchgefuehrt wurde (unabhaengig vom Ergebnis). Args: start_sheet_row (int, optional): Die 1-basierte Startzeile im Sheet. Defaults to None (automatische Ermittlung basierend auf leeren AY). 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). min_employees (int, optional): Mindestanzahl Mitarbeiter (Spalte K) als Teilfilter. Defaults to 500. min_umsatz (int, optional): Mindestumsatz in MIO € (Spalte J) als Teilfilter. Defaults to 200. """ # Verwenden Sie logger, da das Logging jetzt konfiguriert ist # Logge die Konfiguration des Batch-Laufs self.logger.info( f"Starte Modus 'find_wiki_serp' (AY, M, A). Filter: (Umsatz CRM > {min_umsatz} MIO € ODER Mitarbeiter CRM > {min_employees}). 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'}...") # <<< GEÄNDERT # --- Daten laden und Startzeile ermitteln --- # Automatische Ermittlung der Startzeile, wenn nicht manuell gesetzt if start_sheet_row is None: self.logger.info( "Automatische Ermittlung der Startzeile basierend auf leeren AY...") # <<< GEÄNDERT # Nutzt get_start_row_index des Sheet Handlers (Block 14). Prueft auf leeren AY (Block 1 Column Map). # Standardmaessig ab Zeile 7 start_data_index_no_header = self.sheet_handler.get_start_row_index( check_column_key="SerpAPI Wiki Search 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 Batch ab.") # <<< GEÄNDERT 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 AY Zelle): {start_sheet_row}") # <<< GEÄNDERT 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 process_find_wiki_serp.") # <<< GEÄNDERT 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"Verarbeitungsbereich: Sheet-Zeilen {start_sheet_row} bis {end_sheet_row}. Gesamtzeilen im Sheet: {total_sheet_rows}") # <<< GEÄNDERT # 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.") # <<< GEÄNDERT 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 = [ # AY, M, J, K (Pruefkriterien / Timestamp) "SerpAPI Wiki Search Timestamp", "Wiki URL", "CRM Umsatz", "CRM Anzahl Mitarbeiter", # A, B, D (Daten fuer Suche / Updates) "ReEval Flag", "CRM Name", "CRM Website", # N-R (Spalten zum Leeren) "Wiki Absatz", "Wiki Branche", "Wiki Umsatz", "Wiki Mitarbeiter", "Wiki Kategorien", # S-U (Spalten zum Leeren) "Chat Wiki Konsistenzpruefung", "Chat Begründung Wiki Inkonsistenz", "Chat Vorschlag Wiki Artikel", # V, AN, AO (Spalten zum Leeren) "Begruendung bei Abweichung", "Wikipedia Timestamp", "Timestamp letzte Pruefung", "Version", "Wiki Verif. Timestamp" # AP, AX (Spalten 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_find_wiki_serp: {missing}. Breche ab.") # <<< GEÄNDERT return # Beende die Methode bei kritischem Fehler # Ermitteln Sie die Spaltenbuchstaben fuer Updates und Leerung (nutzt # interne Helfer _get_col_letter Block 14) ts_ay_letter = self.sheet_handler._get_col_letter( col_indices["SerpAPI Wiki Search Timestamp"] + 1) # Timestamp zu setzen (AY) m_letter = self.sheet_handler._get_col_letter( col_indices["Wiki URL"] + 1) # Wiki URL Spalte (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 # Anzahl der Spalten = V_Index - N_Index + 1 empty_nv_values = [''] * (v_idx - n_idx + 1) # Timestamps AN, AO, AP, AX leeren. 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) # --- Verarbeitung --- # Holen Sie die Batch-Groesse fuer Sheet-Updates aus Config (Block 1) update_batch_row_limit = getattr(Config, 'UPDATE_BATCH_ROW_LIMIT', 50) # Gesammelte Updates fuer Batch-Schreiben ins Sheet (Liste von Dicts) all_sheet_updates = [] # Zaehlt Zeilen, fuer die SerpAPI versucht wurde (im Rahmen des Limits # zaehlen). processed_count = 0 # Zaehlt Zeilen, die uebersprungen wurden (verschiedene Gruende). skipped_count = 0 # Zaehlt Zeilen, wo eine URL gefunden und eingetragen wurde. found_urls_count = 0 # Aktueller Zeitstempel fuer den AY Timestamp now_timestamp_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S") # 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 uebersprungen continue # Springe zur naechsten Zeile # --- Pruefung, ob Verarbeitung fuer diese Zeile noetig ist --- # Kriterium: SerpAPI Wiki Search Timestamp (AY) ist leer. # UND Wiki URL (M) ist leer oder "k.A.". # UND (Umsatz CRM (J) > min_umsatz MIO € ODER Mitarbeiter CRM (K) > # min_employees). # Holen Sie die Werte aus den entsprechenden Spalten (nutzt interne # Helfer _get_cell_value_safe) ay_value = self._get_cell_value_safe( row, "SerpAPI Wiki Search Timestamp").strip() # Block 1 Column Map m_value = self._get_cell_value_safe( row, "Wiki URL").strip() # Block 1 Column Map umsatz_val_str = self._get_cell_value_safe( row, "CRM Umsatz") # Block 1 Column Map ma_val_str = self._get_cell_value_safe( row, "CRM Anzahl Mitarbeiter") # Block 1 Column Map # Pruefen Sie, ob AY leer ist. is_ay_empty = not ay_value # Pruefen Sie, ob M leer oder "k.A." ist. is_m_empty_or_ka = not m_value or (isinstance( m_value, str) and m_value.lower() == "k.a.") # Nutze die globale Hilfsfunktion (Block 5), um die Werte fuer den Groessen-Filter zu bekommen. # get_numeric_filter_value gibt 0 fuer ungueltige/leere Werte # zurueck. umsatz_val_mio = get_numeric_filter_value( umsatz_val_str, is_umsatz=True) ma_val_num = get_numeric_filter_value(ma_val_str, is_umsatz=False) # Pruefen Sie, ob das Groessen-Kriterium erfuellt ist. size_criteria_met = ( umsatz_val_mio > min_umsatz) or ( ma_val_num > min_employees) # Verarbeitung ist noetig, wenn AY leer ist UND M leer/k.A. ist UND # das Groessen-Kriterium erfuellt ist. processing_needed_for_row = is_ay_empty and is_m_empty_or_ka and size_criteria_met # 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]}... SerpAPI Wiki Search Check): AY leer? {is_ay_empty}, M leer/k.A.? {is_m_empty_or_ka}, Groesse ({umsatz_val_mio:.1f} Mio, {ma_val_num} MA) Kriterium ({min_umsatz} Mio, {min_employees} MA)? {size_criteria_met}. Benötigt Verarbeitung? {processing_needed_for_row}") # <<< GEÄNDERT # 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: Fuehre SerpAPI Suche aus --- # Zaehle die Zeile, fuer die SerpAPI versucht wird (im Rahmen des # Limits zaehlen) processed_count += 1 # 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_find_wiki_serp erreicht. Breche weitere Zeilenpruefung ab.") # <<< GEÄNDERT break # Brich die Schleife ab # Hole Firmenname und Website fuer die Suche (nutzt interne Helfer # _get_cell_value_safe) company_name = self._get_cell_value_safe( row, "CRM Name").strip() # Block 1 Column Map # Block 1 Column Map (Website kann fuer SerpAPI Kontext hilfreich # sein) website_url = self._get_cell_value_safe(row, "CRM Website").strip() # Wenn kein Firmenname vorhanden ist, kann die Suche nicht # durchgefuehrt werden if not company_name: self.logger.warning( f"Zeile {i}: Uebersprungen (kein Firmenname fuer Suche vorhanden in Spalte B).") # <<< GEÄNDERT skipped_count += 1 # Zaehlen als uebersprungene Zeile, da Suche nicht moeglich # Setze AY Timestamp auch hier, um nicht immer wieder zu versuchen # Erstelle leeres Update-Dict, damit extend funktioniert updates = [] updates.append({'range': f'{ts_ay_letter}{i}', 'values': [ [now_timestamp_str]]}) # Block 1 Column Map # Fuege dieses einzelne Update zur Liste hinzu all_sheet_updates.extend(updates) updates = [] # Leere die lokale Liste continue # Springe zur naechsten Zeile self.logger.info( f"Zeile {i}: Suche Wiki-URL fuer '{company_name[:100]}...' (Umsatz (Mio): {umsatz_val_mio:.1f}, MA: {ma_val_num}) ueber SerpAPI...") # <<< GEÄNDERT # Führe die SerpAPI Suche durch (nutzt globale Funktion Block 10 mit Retry). # serp_wikipedia_lookup ist mit retry_on_failure dekoriert (Block 2). # Wenn serp_wikipedia_lookup nach Retries fehlschlaegt, wirft er # eine Exception. wiki_url_found = None # Initialisiere mit None try: wiki_url_found = serp_wikipedia_lookup( company_name, website=website_url) # Nutzt globalen Helfer (Block 10) # Wenn serp_wikipedia_lookup erfolgreich ist, gibt es die URL # oder None zurueck. except Exception as e_serp_wiki: # Wenn serp_wikipedia_lookup eine Exception wirft (nach # Retries) self.logger.error( f"FEHLER bei serp_wikipedia_lookup fuer Zeile {i} ('{company_name[:100]}...'): {e_serp_wiki}") # <<< GEÄNDERT # wiki_url_found bleibt None. Fahren Sie fort. pass # Fahren Sie fort, um Timestamp zu setzen und Updates vorzubereiten # --- Updates vorbereiten --- # Timestamp AY IMMER setzen, nachdem der Versuch gemacht wurde, # unabhaengig vom Ergebnis der Suche. updates_for_row = [] # Lokale Liste fuer Updates dieser Zeile updates_for_row.append({'range': f'{ts_ay_letter}{i}', 'values': [ [now_timestamp_str]]}) # Block 1 Column Map # Wenn eine URL gefunden wurde, bereite weitere Updates vor. # Eine gefundene URL ist ein String, der nicht None ist und nicht # "k.A." oder Fehlerstring ist. if wiki_url_found and isinstance( wiki_url_found, str) and wiki_url_found.lower() not in [ "k.a.", "kein artikel gefunden"] and not wiki_url_found.startswith("FEHLER"): # Korrektur Pruefung self.logger.info( f" -> URL gefunden: {wiki_url_found[:100]}... Bereite Update vor (Setze M, A; Loesche N-V, AN, AO, AP, AX).") # <<< GEÄNDERT found_urls_count += 1 # Zaehle den Fund # Setze M (Wiki URL) mit der gefundenen URL updates_for_row.append({'range': f'{m_letter}{i}', 'values': [ [wiki_url_found]]}) # Block 1 Column Map # Setze ReEval Flag (A) auf 'x' (signalisiert, dass eine # Re-Evaluation noetig ist) updates_for_row.append({'range': f'{a_letter}{i}', 'values': [ ['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.") # <<< GEÄNDERT # Leere Timestamps AN, AO, AP, AX. # Dies setzt die Zeile zurueck, damit andere Schritte sie # spaeter bearbeiten. # Block 1 Column Map updates_for_row.append( {'range': f'{an_letter}{i}', 'values': [['']]}) # Block 1 Column Map updates_for_row.append( {'range': f'{ao_letter}{i}', 'values': [['']]}) # Block 1 Column Map updates_for_row.append( {'range': f'{ap_letter}{i}', 'values': [['']]}) # Block 1 Column Map updates_for_row.append( {'range': f'{ax_letter}{i}', 'values': [['']]}) else: # Wenn keine Wiki-URL ueber SerpAPI gefunden wurde self.logger.debug( f" -> Keine Wiki-URL fuer '{company_name[:100]}...' ueber SerpAPI gefunden.") # <<< GEÄNDERT # Nur AY Timestamp wird gesetzt, was bereits oben passiert ist. # Keine weiteren Updates fuer M, A, N-V etc. # Sammle die Updates fuer diese Zeile in der globalen Liste. 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 (1 bei nicht gefunden, ca. 10+ bei gefunden). # 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)...") # <<< GEÄNDERT # 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.") # <<< GEÄNDERT # Der Fehlerfall wird von batch_update_cells geloggt # Leere die gesammelten Updates nach dem Senden. all_sheet_updates = [] # Kleine Pause nach jeder SerpAPI-Suche (nutzt Config Block 1). # Der retry_on_failure Decorator (Block 2) kuemmert sich um Retries mit Backoff. # Dies ist eine globale Rate-Limit-Vorsorge zwischen einzelnen # Anfragen. serp_delay = getattr(Config, 'SERPAPI_DELAY', 1.5) # self.logger.debug(f"Warte {serp_delay:.2f}s nach SerpAPI # Suche...") # Zu viel Laerm im Debug time.sleep(serp_delay) # --- 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)...") # <<< GEÄNDERT # 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: # <<< GEÄNDERT 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 'find_wiki_serp' abgeschlossen. {processed_count} Zeilen verarbeitet (in Batch aufgenommen), {found_urls_count} URLs gefunden & eingetragen, {skipped_count} Zeilen uebersprungen.") # <<< GEÄNDERT def process_contact_search( self, start_sheet_row=None, end_sheet_row=None, limit=None): """ Sucht LinkedIn Kontakte ueber SerpAPI. """ self.logger.info( f"Starte Contact Research (Batch). Bereich: {start_sheet_row if start_sheet_row else 'Start'}-{end_sheet_row if end_sheet_row else 'Ende'}, Limit: {limit if limit else 'Unbegrenzt'}") """ Sucht LinkedIn Kontakte ueber SerpAPI fuer Zeilen, bei denen der Contact Search Timestamp (AM) leer ist. Traegt Trefferzahlen in AI-AL und den Timestamp in AM ein. Schreibt Details optional in ein 'Contacts' Blatt. Args: start_sheet_row (int, optional): Die 1-basierte Startzeile im Sheet. Defaults to None (automatische Ermittlung basierend auf leeren AM). 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 die Konfiguration des Batch-Laufs self.logger.info( f"Starte Contact Research (Batch AM, AI-AL). 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'}...") # <<< GEÄNDERT # --- Daten laden und Startzeile ermitteln --- # Automatische Ermittlung der Startzeile, wenn nicht manuell gesetzt if start_sheet_row is None: self.logger.info( "Automatische Ermittlung der Startzeile basierend auf leeren AM...") # <<< GEÄNDERT # Nutzt get_start_row_index des Sheet Handlers (Block 14). Prueft auf leeren AM (Block 1 Column Map). # Standardmaessig ab Zeile 7 start_data_index_no_header = self.sheet_handler.get_start_row_index( check_column_key="Contact Search 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 Batch ab.") # <<< GEÄNDERT 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 AM Zelle): {start_sheet_row}") # <<< GEÄNDERT 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 process_contact_search.") # <<< GEÄNDERT 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"Verarbeitungsbereich: Sheet-Zeilen {start_sheet_row} bis {end_sheet_row}. Gesamtzeilen im Sheet: {total_sheet_rows}") # <<< GEÄNDERT # 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.") # <<< GEÄNDERT 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 = [ "Contact Search Timestamp", # AM - Pruefkriterium / Timestamp # B, C, D (Daten fuer Suche) "CRM Name", "CRM Kurzform", "CRM Website", # AI, AJ (Zielspalten fuer Trefferzahlen) "Linked Serviceleiter gefunden", "Linked It-Leiter gefunden", # AK, AL (Zielspalten fuer Trefferzahlen) "Linked Management gefunden", "Linked Disponent gefunden" ] # 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_contact_search: {missing}. Breche ab.") # <<< GEÄNDERT return # Beende die Methode bei kritischem Fehler # Ermitteln Sie die Spaltenbuchstaben fuer Updates (AI-AL, AM) (nutzt # interne Helfer _get_col_letter Block 14) ts_am_letter = self.sheet_handler._get_col_letter( col_indices["Contact Search Timestamp"] + 1) # AM ai_letter = self.sheet_handler._get_col_letter( col_indices["Linked Serviceleiter gefunden"] + 1) # AI aj_letter = self.sheet_handler._get_col_letter( col_indices["Linked It-Leiter gefunden"] + 1) # AJ ak_letter = self.sheet_handler._get_col_letter( col_indices["Linked Management gefunden"] + 1) # AK al_letter = self.sheet_handler._get_col_letter( col_indices["Linked Disponent gefunden"] + 1) # AL # Positionen, nach denen gesucht wird (kann in Config verschoben werden Block 1) # Die Zuordnung zur Zaehlspalte (AI-AL) muss hier im Code erfolgen. positions_to_search = { "Serviceleiter": ["Serviceleiter", "Leiter Kundendienst", "Einsatzleiter"], "IT-Leiter": ["IT-Leiter", "Leiter IT"], # Management erweitert "Management": ["Geschäftsführer", "Vorstand", "Inhaber", "CEO", "CTO", "COO", "Kaufmännischer Leiter", "Technischer Leiter"], "Disponent": ["Disponent", "Einsatzplaner"] # Disponent erweitert } # Stellen Sie sicher, dass die Schluessel im Dict den COLUMN_MAP Keys (AI-AL) entsprechen, # damit die Zaehlung korrekt zugeordnet werden kann. # --- Kontakte-Blatt oeffnen oder erstellen --- contacts_sheet = None # Initialisiere mit None # Der Zugriff auf das Spreadsheet-Objekt erfolgt ueber den SheetHandler # (Block 14). if self.sheet_handler and self.sheet_handler.sheet and self.sheet_handler.sheet.spreadsheet: try: # Versuche, das Sheet "Contacts" zu oeffnen contacts_sheet = self.sheet_handler.sheet.spreadsheet.worksheet( "Contacts") self.logger.info("Blatt 'Contacts' gefunden.") # <<< GEÄNDERT except gspread.exceptions.WorksheetNotFound: # Wenn nicht gefunden, erstelle es. # <<< GEÄNDERT self.logger.info( "Blatt 'Contacts' nicht gefunden, erstelle neu...") try: # Definieren Sie den Header fuer das neue Blatt contacts_header = [ "Firmenname", "CRM Kurzform", "Website", "Geschlecht", "Vorname", "Nachname", "Position", "Suchbegriffskategorie", "E-Mail-Adresse", "LinkedIn-Link", "Timestamp"] # Schaetzen Sie die Anzahl der Zeilen und Spalten fuer das # neue Blatt (kann angepasst werden) num_cols_contacts_sheet = len(contacts_header) # Erstellen Sie das neue Blatt contacts_sheet = self.sheet_handler.sheet.spreadsheet.add_worksheet( title="Contacts", rows="5000", cols=num_cols_contacts_sheet) # Schreiben Sie den Header in die erste Zeile des neuen Blattes # Nutzt _get_col_letter interne Methode des SheetHandlers # (Block 14) contacts_sheet.update( values=[contacts_header], range_name=f"A1:{self.sheet_handler._get_col_letter(num_cols_contacts_sheet)}1") # <<< GEÄNDERT self.logger.info( "Neues Blatt 'Contacts' erstellt und Header eingetragen.") except Exception as e_create_sheet: # Fange Fehler bei der Erstellung des Blattes ab und logge # sie. self.logger.critical( f"FEHLER: Konnte Blatt 'Contacts' nicht erstellen: {e_create_sheet}. Kontakt-Details koennen NICHT gespeichert werden.") # <<< GEÄNDERT # Logge den Traceback. self.logger.debug(traceback.format_exc()) # <<< GEÄNDERT # Setze contacts_sheet auf None, um spaetere # Schreibversuche zu verhindern contacts_sheet = None else: # Wenn SheetHandler oder Sheet-Objekt nicht verfuegbar war. self.logger.warning( "SheetHandler oder Sheet-Objekt nicht verfuegbar. Kann Blatt 'Contacts' nicht oeffnen/erstellen. Kontakt-Details werden NICHT gespeichert.") # <<< GEÄNDERT contacts_sheet = None # Sicherstellen, dass contacts_sheet None ist # --- Verarbeitung --- # Gesammelte Updates fuer Batch-Schreiben ins Hauptblatt (Liste von # Dicts) all_sheet_updates = [] # Gesammelte Zeilen fuer append_rows ins Contacts-Blatt (Liste von # Listen) all_contact_rows_to_append = [] # append_rows kann grosse Batches handhaben, wir koennen hier mehr sammeln als beim Batch-Update. # Oder wir schreiben pro Firma in das Contacts-Blatt (weniger sammelbar). # Fuer diesen Modus sammeln wir alle Kontaktzeilen und schreiben am # Ende gesammelt mit append_rows. # Zaehlt Zeilen im Hauptblatt, die fuer die Verarbeitung in Frage # kommen und in den Batch aufgenommen werden (im Rahmen des Limits). processed_count = 0 # Zaehlt Zeilen im Hauptblatt, die uebersprungen wurden (wegen AM oder # fehlender Daten). skipped_count = 0 # Aktueller Zeitstempel fuer die AM Timestamp und Kontaktzeilen now_timestamp_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S") # 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 uebersprungen continue # Springe zur naechsten Zeile # --- Pruefung, ob Verarbeitung fuer diese Zeile noetig ist --- # Kriterium: Contact Search Timestamp (AM) ist leer. # ZUSAETZLICH: Pruefen, ob CRM Name, Kurzform und Website vorhanden # und gueltig sind. # Holen Sie den Wert aus Spalte AM (Contact Search Timestamp) # (nutzt interne Helfer _get_cell_value_safe) am_value = self._get_cell_value_safe( row, "Contact Search Timestamp").strip() # Block 1 Column Map # Pruefung basiert darauf, ob AM leer ist. processing_needed_based_on_status = not am_value # Holen Sie die benoetigten Daten fuer die Suche (nutzt interne # Helfer _get_cell_value_safe) company_name = self._get_cell_value_safe( row, "CRM Name").strip() # Block 1 Column Map crm_kurzform = self._get_cell_value_safe( row, "CRM Kurzform").strip() # Block 1 Column Map website = self._get_cell_value_safe( row, "CRM Website").strip() # Block 1 Column Map # Pruefen Sie, ob die Mindestdaten fuer die Suche vorhanden und gueltig sind. # Name und Kurzform duerfen nicht leer sein. Website muss vorhanden # und gueltig aussehen. has_min_data_for_search = company_name and crm_kurzform and website and isinstance( website, str) and website.lower() not in [ "k.a.", "kein artikel gefunden", "fehler bei suche", "http:"] # Fuege "http:" hinzu # Verarbeitung ist noetig, wenn AM leer ist UND die Mindestdaten # fuer die Suche vorhanden sind. processing_needed_for_row = processing_needed_based_on_status and has_min_data_for_search # 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: # Gekuerzt loggen company_name_log = company_name[:50] + \ '...' if len(company_name) > 50 else company_name self.logger.debug( f"Zeile {i} ({company_name_log} Contact Check): AM leer? {processing_needed_based_on_status}, Mindestdaten gueltig? {has_min_data_for_search}. Benötigt Verarbeitung? {processing_needed_for_row}") # <<< GEÄNDERT # 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: Fuehre LinkedIn Suche(n) aus --- # Zaehle die Zeile, die fuer die Verarbeitung in Frage kommt (im # Rahmen des Limits zaehlen) processed_count += 1 # 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_contact_search erreicht. Breche weitere Zeilenpruefung ab.") # <<< GEÄNDERT break # Brich die Schleife ab self.logger.info( f"Zeile {i}: Suche LinkedIn Kontakte fuer '{crm_kurzform[:50]}...' ({website[:50]}...)...") # <<< GEÄNDERT # Liste zum Sammeln aller gefundenen Kontakte fuer DIESE Zeile # (Liste von Dicts) all_found_contacts_for_row = [] # Dictionary zum Zaehlen der Treffer pro Kategorie fuer diese Zeile # (AI-AL) contact_counts_for_row = { key: 0 for key in positions_to_search.keys()} # Führe die Suche fuer jede Positionskategorie durch. # positions_to_search Dictionary ist oben definiert. for category, queries in positions_to_search.items(): # Führe die Suche fuer jede spezifische Abfrage innerhalb der Kategorie durch. # search_linkedin_contacts (Block 10) nutzt den retry_on_failure Decorator (Block 2). # Wenn search_linkedin_contacts fehlschlaegt, wirft es eine # Exception oder gibt eine leere Liste zurueck. # Dictionary zum Sammeln eindeutiger Kontakte {linkedin_url: # contact_data} fuer diese Kategorie found_contacts_in_category = {} for position_query in queries: self.logger.debug( f" -> Suche nach Position: '{position_query}' bei '{crm_kurzform[:50]}'...") # <<< GEÄNDERT try: # Rufe die globale Funktion search_linkedin_contacts auf (Block 10). # Limitieren Sie die Anzahl der SerpAPI Ergebnisse pro # Query, um Kosten zu managen. contacts_from_query = search_linkedin_contacts( company_name=company_name, # Voller Name fuer Kontext (optional genutzt) website=website, # Website fuer Email-Generierung spaeter position_query=position_query, # Die spezifische Position crm_kurzform=crm_kurzform, # Die Kurzform der Firma num_results=getattr( Config, 'SERPAPI_LINKEDIN_RESULTS_PER_QUERY', 5) # Konfigurierbar in Config (Block 1) ) # Fuege die gefundenen Kontakte (mit Suchkategorie) zur # Liste fuer diese Kategorie hinzu, dedupliziert ueber # URL. for contact in contacts_from_query: linkedin_url = contact.get("LinkedInURL") if linkedin_url and isinstance( linkedin_url, str) and linkedin_url.strip(): # Stelle sicher, dass URL gueltig ist if linkedin_url not in found_contacts_in_category: # Wenn die URL noch nicht in dieser # Kategorie gefunden wurde, fuege den # Kontakt hinzu. # Speichere die Kategorie, die den Treffer # brachte contact["Suchbegriffskategorie"] = category found_contacts_in_category[linkedin_url] = contact # else: Wenn die URL bereits gefunden wurde, mache nichts (erste Kategorie wird beibehalten). # self.logger.debug(f" -> Gefunden: # {contact.get('Vorname')} # {contact.get('Nachname')} # ({contact.get('Position')})") # Zu viel Laerm # im Debug except Exception as e_linkedin_search: # Wenn search_linkedin_contacts eine Exception wirft (nach Retries) # Der Fehler wird bereits vom retry_on_failure # Decorator oder search_linkedin_contacts geloggt. self.logger.error( f"FEHLER bei search_linkedin_contacts fuer Zeile {i} (Query: '{position_query}', Firma: '{crm_kurzform[:50]}...'): {e_linkedin_search}") # <<< GEÄNDERT pass # Faert fort mit der naechsten Query oder Kategorie # Pause nach jeder SerpAPI Suche (pro position_query) # Nutzt Config.SERPAPI_DELAY (Block 1). serp_delay = getattr(Config, 'SERPAPI_DELAY', 1.5) # self.logger.debug(f"Warte {serp_delay:.2f}s nach LinkedIn # Suche fuer '{position_query}'...") # Zu viel Laerm im # Debug time.sleep(serp_delay) # Zaehle die eindeutigen Treffer in dieser Kategorie nach allen # Queries innerhalb der Kategorie. contact_counts_for_row[category] = len( found_contacts_in_category) # Fuege die eindeutigen Kontakte DIESER Kategorie zur # Gesamtliste fuer DIESE Zeile hinzu. all_found_contacts_for_row.extend( found_contacts_in_category.values()) # --- Verarbeite gefundene Kontakte und bereite Updates vor --- # Liste von Listen fuer append_rows ins 'Contacts' Blatt rows_to_append_to_contacts_sheet = [] # Updates fuer das Hauptblatt (AI-AL, AM) fuer DIESE Zeile main_sheet_updates_for_row = [] # Timestamp fuer DIESE Zeile/Kontakte timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") # Fuegen Sie die Updates fuer die Trefferzahlen im Hauptblatt hinzu (nutzt interne Helfer _get_col_letter Block 14) # Stellen Sie sicher, dass die Spaltenbuchstaben korrekt sind (AI, # AJ, AK, AL) (aus oben ermittelt) main_sheet_updates_for_row.append({'range': f'{ai_letter}{i}', 'values': [ [str(contact_counts_for_row.get("Serviceleiter", 0))]]}) main_sheet_updates_for_row.append({'range': f'{aj_letter}{i}', 'values': [ [str(contact_counts_for_row.get("IT-Leiter", 0))]]}) main_sheet_updates_for_row.append({'range': f'{ak_letter}{i}', 'values': [ [str(contact_counts_for_row.get("Management", 0))]]}) main_sheet_updates_for_row.append({'range': f'{al_letter}{i}', 'values': [ [str(contact_counts_for_row.get("Disponent", 0))]]}) # Setze den Contact Search Timestamp (AM) fuer DIESE Zeile main_sheet_updates_for_row.append( {'range': f'{ts_am_letter}{i}', 'values': [[timestamp]]}) # Sammeln Sie diese Updates fuer das Hauptblatt in der globalen # Liste all_sheet_updates. all_sheet_updates.extend(main_sheet_updates_for_row) self.logger.info( f"Zeile {i}: Kontaktzahlen gesammelt: {contact_counts_for_row} – Timestamp AM vorgemerkt fuer Update.") # <<< GEÄNDERT # Bereiten Sie die Zeilen fuer das 'Contacts' Blatt vor (falls es existiert). # all_found_contacts_for_row enthaelt alle gefundenen Kontakte fuer # DIESE Zeile (mit evtl. Duplikaten bei URL). # Pruefen Sie, ob das Contacts-Sheet geoeffnet/erstellt werden # konnte (siehe Initialisierung oben) if contacts_sheet: # Führen Sie eine finale Deduplizierung ueber die LinkedIn-URL durch. # Dictionary-Comprehension behält nur das letzte Vorkommen bei # Duplikaten. unique_contacts_for_row_dict = {c['LinkedInURL']: c for c in all_found_contacts_for_row if c.get( 'LinkedInURL')} # Filtere Kontakte ohne URL unique_contacts_for_row = list( unique_contacts_for_row_dict.values()) # Liste der eindeutigen Kontakte # Iteriere ueber die eindeutigen Kontakte fuer diese Zeile for contact in unique_contacts_for_row: # Nutzt den extrahierten Vornamen firstname = contact.get("Vorname", "") # Nutzt den extrahierten Nachnamen lastname = contact.get("Nachname", "") # Generiere Geschlecht und E-Mail-Adresse (nutzt globale Funktionen Block 5). # get_gender und get_email_address behandeln # leere/ungueltige Eingaben. gender_value = get_gender(firstname) # Nutzt die Website der Firma (initial geladen) email = get_email_address(firstname, lastname, website) # Erstellen Sie die Liste der Werte fuer eine Zeile im # 'Contacts' Blatt. contact_row = [ contact.get("Firmenname", ""), # Voller Firmenname contact.get("CRM Kurzform", ""), # Firmenkurzform contact.get("Website", ""), # Website der Firma gender_value, # Generiertes Geschlecht firstname, # Extrahierter Vorname lastname, # Extrahierter Nachname # Extrahierte oder Fallback Position contact.get("Position", ""), # Kategorie, die den Treffer brachte contact.get("Suchbegriffskategorie", ""), email, # Generierte E-Mail-Adresse # URL des LinkedIn Profils contact.get("LinkedInURL", ""), timestamp # Zeitstempel des Suchlaufs ] # Fuegen Sie diese Zeile zur Liste der Zeilen hinzu, die # spaeter ins Contacts-Sheet geschrieben werden. rows_to_append_to_contacts_sheet.append(contact_row) # Wenn Zeilen zum Anfuegen gefunden wurden if rows_to_append_to_contacts_sheet: # Fuegen Sie diese Zeilen zur globalen Liste aller Kontakte # hinzu, die spaeter angefuegt werden. all_contact_rows_to_append.extend( rows_to_append_to_contacts_sheet) self.logger.debug( f" -> {len(rows_to_append_to_contacts_sheet)} eindeutige Kontakte fuer Zeile {i} zum Anfuegen an 'Contacts' vorgemerkt.") # <<< GEÄNDERT else: self.logger.debug( f" -> Keine neuen Kontakte fuer Zeile {i} gefunden.") # <<< GEÄNDERT # Sende gesammelte Sheet Updates (Hauptblatt) wenn das Update-Batch-Limit erreicht ist. # update_batch_row_limit wird aus Config geholt (Block 1). # Updates pro Zeile im Hauptblatt sind 5 (AI-AL + AM). Anzahl der # Zeilen = len(all_sheet_updates) / 5. rows_in_main_sheet_update_batch = len(all_sheet_updates) // 5 if rows_in_main_sheet_update_batch >= update_batch_row_limit: self.logger.debug( f" Sende gesammelte Hauptblatt-Updates ({rows_in_main_sheet_update_batch} Zeilen, {len(all_sheet_updates)} Zellen)...") # <<< GEÄNDERT # 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" Hauptblatt-Update fuer {rows_in_main_sheet_update_batch} Zeilen erfolgreich.") # <<< GEÄNDERT # Der Fehlerfall wird von batch_update_cells geloggt # Leere die gesammelten Updates nach dem Senden. all_sheet_updates = [] # Eine laengere Pause nach der Verarbeitung jeder Firma im Contact Search Modus. # Dieser Modus ist API-intensiv und sollte langsamer laufen. # Nutzt Config.RETRY_DELAY (Block 1). # Laengere Pause, z.B. 80% der Retry-Wartezeit pause_duration = getattr(Config, 'RETRY_DELAY', 10) * 0.8 self.logger.debug( f"Warte {pause_duration:.2f}s nach Verarbeitung von Zeile {i}...") # <<< GEÄNDERT time.sleep(pause_duration) # --- Finale Sheet Updates (Hauptblatt) senden --- # Sende alle verbleibenden gesammelten Updates in einem letzten # Batch-Update. if all_sheet_updates: rows_in_final_main_sheet_update_batch = len(all_sheet_updates) // 5 self.logger.info( f"Sende FINALE gesammelte Hauptblatt-Updates ({rows_in_final_main_sheet_update_batch} Zeilen, {len(all_sheet_updates)} Zellen)...") # <<< GEÄNDERT # 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: # <<< GEÄNDERT self.logger.info(f"FINALES Hauptblatt-Update erfolgreich.") # Der Fehlerfall wird von batch_update_cells geloggt # --- Finale Kontakte-Zeilen (Contacts Sheet) anfuegen --- # Fuege alle gesammelten Kontaktzeilen auf einmal ans Ende des # 'Contacts' Blattes an. if contacts_sheet and all_contact_rows_to_append: self.logger.info( f"Fuege {len(all_contact_rows_to_append)} gesammelte Kontaktzeilen an Blatt 'Contacts' an...") # <<< GEÄNDERT try: # append_rows ist effizienter als batch_update fuer viele neue Zeilen am Ende. # Die gspread.Worksheet.append_rows Methode kann Exceptions werfen (z.B. APIError), # die hier gefangen werden koennen, wenn gewuenscht. # Wenn sie eine Exception wirft, wird diese nicht von retry_on_failure auf # process_contact_search behandelt, da append_rows nicht mit @retry_on_failure # dekoriert ist. Sie muessten append_rows selbst in einen try/except Block packen oder # es mit @retry_on_failure dekorieren (falls gspread es unterstuetzt). # Fuer jetzt, fangen wir die Exception hier. contacts_sheet.append_rows( all_contact_rows_to_append, value_input_option='USER_ENTERED') # Standard Option self.logger.info( f"Anfuegen von {len(all_contact_rows_to_append)} Kontaktzeilen erfolgreich.") # <<< GEÄNDERT except Exception as e_append: # Fange Fehler beim Anfuegen der Zeilen ab und logge sie. self.logger.error( f"FEHLER beim Anfuegen von Kontaktzeilen an Blatt 'Contacts': {type(e_append).__name__} - {e_append}") # <<< GEÄNDERT # Logge den Traceback. self.logger.debug(traceback.format_exc()) # <<< GEÄNDERT pass # Faert fort, der Rest des Skripts sollte nicht blockiert werden # Logge den Abschluss des Modus self.logger.info( f"Modus 'contact_search' abgeschlossen. {processed_count} Zeilen verarbeitet (in Batch aufgenommen), {skipped_count} Zeilen uebersprungen.") # <<< GEÄNDERT def process_url_check( self, start_sheet_row=None, end_sheet_row=None, limit=None): """ Sucht nach Zeilen mit URL_CHECK_MARKER und versucht, eine neue URL zu finden. """ self.logger.info( f"Starte Modus 'check_urls'. Bereich: {start_sheet_row if start_sheet_row else 'Start'}-{end_sheet_row if end_sheet_row else 'Ende'}, Limit: {limit if limit else 'Unbegrenzt'}") """ Sucht nach Zeilen, die in Spalte AR mit URL_CHECK_MARKER oder bekannten "k.A. (Fehler...)" Mustern markiert sind UND bei denen der AY-Timestamp (SerpAPI Wiki Search Timestamp) leer ist (außer bei URL_CHECK_MARKER, der immer eine Suche auslöst). Versucht, eine neue URL ueber SerpAPI zu finden. Wenn erfolgreich und URL ist NEU: Aktualisiert D, loescht AR, setzt ReEval-Flag (A) und loescht Timestamps. Wenn URL identisch oder keine neue URL gefunden: AR wird entsprechend aktualisiert. Setzt immer den AY-Timestamp (als Timestamp der URL-Prüfung). 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 Zeilen. Defaults to None (Unbegrenzt). """ self.logger.info( f"Starte Modus 'check_urls'. Sucht nach '{URL_CHECK_MARKER}' oder 'k.A. (Fehler...)' in AR. 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'}...") # --- Konfiguration holen --- update_batch_row_limit = getattr( Config, 'UPDATE_BATCH_ROW_LIMIT', 50) # <<< NEUE ZEILE HINZUGEFÜGT if not self.sheet_handler.load_data(): self.logger.error("Fehler beim Laden der Daten fuer URL Check.") return all_data = self.sheet_handler.get_all_data_with_headers() header_rows = self.sheet_handler._header_rows total_sheet_rows = len(all_data) if start_sheet_row is None: start_sheet_row = header_rows + 1 if end_sheet_row is None: end_sheet_row = total_sheet_rows self.logger.info( f"Suchbereich fuer URL Checks: Sheet-Zeilen {start_sheet_row} bis {end_sheet_row}.") 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 required_keys = [ "Website Rohtext", "CRM Name", "CRM Website", "ReEval Flag", "Website Scrape Timestamp", "Timestamp letzte Pruefung", "Wikipedia Timestamp", "Wiki Verif. Timestamp", "SerpAPI Wiki Search Timestamp", "Version"] col_indices = {key: COLUMN_MAP.get(key) for key in required_keys} 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_url_check: {missing}. Breche ab.") return ar_letter = self.sheet_handler._get_col_letter( col_indices["Website Rohtext"] + 1) d_letter = self.sheet_handler._get_col_letter( col_indices["CRM Website"] + 1) a_letter = self.sheet_handler._get_col_letter( col_indices["ReEval Flag"] + 1) at_letter = self.sheet_handler._get_col_letter( col_indices["Website Scrape Timestamp"] + 1) ao_letter = self.sheet_handler._get_col_letter( col_indices["Timestamp letzte Pruefung"] + 1) an_letter = self.sheet_handler._get_col_letter( col_indices["Wikipedia Timestamp"] + 1) ax_letter = self.sheet_handler._get_col_letter( col_indices["Wiki Verif. Timestamp"] + 1) ay_letter = self.sheet_handler._get_col_letter( col_indices["SerpAPI Wiki Search Timestamp"] + 1) # Timestamp dieser Funktion ap_letter = self.sheet_handler._get_col_letter( col_indices["Version"] + 1) ka_error_patterns = [ "k.A.", "k.A. (Extraktion leer)", "k.A. (Nur Cookie-Banner erkannt)", "k.A. (Kein Body gefunden)", "k.A. (Fehler Parsing:", "k.A. (Unerwarteter Fehler Task)", "k.A. (Fehler Scraping:", "k.A. (Timeout", "k.A. (SSL Fehler", "k.A. (Connection Error", "k.A. (HTTP Error", URL_CHECK_MARKER] all_sheet_updates = [] processed_count = 0 skipped_count = 0 found_new_url_count = 0 now_timestamp_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S") for i in range(start_sheet_row, end_sheet_row + 1): row_index_in_list = i - 1 if row_index_in_list >= total_sheet_rows: break row = all_data[row_index_in_list] if not any(cell and isinstance(cell, str) and cell.strip() for cell in row): skipped_count += 1 continue ar_value = self._get_cell_value_safe( row, "Website Rohtext").strip() # Verwende den spezifischen Timestamp für diese Funktion ay_timestamp_value = self._get_cell_value_safe( row, "SerpAPI Wiki Search Timestamp").strip() processing_needed_for_row = False is_marker_case = ar_value == URL_CHECK_MARKER is_ka_error_case = any( pattern in ar_value for pattern in ka_error_patterns if pattern != URL_CHECK_MARKER) if is_marker_case: # URL_CHECK_MARKER löst immer eine Suche aus processing_needed_for_row = True # Alte k.A.-Fehler nur, wenn AY-Timestamp noch nicht gesetzt wurde elif is_ka_error_case and not ay_timestamp_value: processing_needed_for_row = True if not processing_needed_for_row: skipped_count += 1 continue processed_count += 1 if limit is not None and isinstance( limit, int) and limit > 0 and processed_count > limit: self.logger.info( f"Verarbeitungslimit ({limit}) fuer process_url_check erreicht.") break company_name = self._get_cell_value_safe(row, "CRM Name").strip() old_crm_website_url = self._get_cell_value_safe( row, "CRM Website").strip() normalized_old_crm_url = simple_normalize_url(old_crm_website_url) if not company_name: self.logger.warning( f"Zeile {i}: Uebersprungen (kein Firmenname fuer Suche vorhanden).") skipped_count += 1 updates_for_row_skip = [{'range': f'{ay_letter}{i}', 'values': [ [now_timestamp_str]]}] # Timestamp trotzdem setzen all_sheet_updates.extend(updates_for_row_skip) continue self.logger.info( f"Zeile {i}: AR='{ar_value[:50]}...'. Suche neue URL für '{company_name[:50]}...' (Aktuell D: '{old_crm_website_url[:50]}...')...") updates_for_row = [] new_url_found_str = "k.A." try: new_url_found_str = serp_website_lookup(company_name) normalized_new_url = simple_normalize_url(new_url_found_str) if new_url_found_str != "k.A." and normalized_new_url != "k.A.": if normalized_new_url != normalized_old_crm_url: self.logger.info( f" -> Neue, andere URL gefunden: {new_url_found_str}. Alte war: '{old_crm_website_url}'. Bereite Update vor.") found_new_url_count += 1 updates_for_row.append( {'range': f'{d_letter}{i}', 'values': [[new_url_found_str]]}) updates_for_row.append( {'range': f'{ar_letter}{i}', 'values': [['']]}) updates_for_row.append( {'range': f'{a_letter}{i}', 'values': [['x']]}) updates_for_row.append( {'range': f'{at_letter}{i}', 'values': [['']]}) updates_for_row.append( {'range': f'{ao_letter}{i}', 'values': [['']]}) updates_for_row.append( {'range': f'{an_letter}{i}', 'values': [['']]}) updates_for_row.append( {'range': f'{ax_letter}{i}', 'values': [['']]}) # Wird unten explizit neu gesetzt updates_for_row.append( {'range': f'{ay_letter}{i}', 'values': [['']]}) updates_for_row.append( {'range': f'{ap_letter}{i}', 'values': [['']]}) else: self.logger.info( f" -> SerpAPI fand URL '{new_url_found_str}', aber diese ist identisch mit der bereits vorhandenen URL in Spalte D. Keine Änderung in D.") updates_for_row.append({'range': f'{ar_letter}{i}', 'values': [ ["k.A. (URL via SerpAPI identisch mit alter URL)"]]}) else: self.logger.warning( f" -> Keine neue gueltige URL via SerpAPI für '{company_name[:50]}...' gefunden. Setze AR auf 'k.A. (Keine URL bei Neusuche)'.") updates_for_row.append({'range': f'{ar_letter}{i}', 'values': [ ["k.A. (Keine URL bei Neusuche)"]]}) except Exception as e_serp_lookup: self.logger.error( f"FEHLER bei SERP Website Lookup für Zeile {i} ('{company_name[:50]}...'): {e_serp_lookup}") updates_for_row.append({'range': f'{ar_letter}{i}', 'values': [ [f"k.A. (Fehler URL Suche)"]]}) pass updates_for_row.append({'range': f'{ay_letter}{i}', 'values': [ [now_timestamp_str]]}) # AY Timestamp immer setzen all_sheet_updates.extend(updates_for_row) if len(all_sheet_updates) >= update_batch_row_limit * \ 3: # Angepasst, da Anzahl Updates variiert self.logger.debug( f" Sende gesammelte Sheet-Updates ({len(all_sheet_updates)} Operationen)...") success = self.sheet_handler.batch_update_cells( all_sheet_updates) if success: self.logger.info( f" Sheet-Update für {len(all_sheet_updates)} Operationen erfolgreich.") all_sheet_updates = [] serp_delay = getattr(Config, 'SERPAPI_DELAY', 1.5) time.sleep(serp_delay) if all_sheet_updates: self.logger.info( f"Sende FINALE gesammelte Sheet-Updates ({len(all_sheet_updates)} Operationen)...") success = self.sheet_handler.batch_update_cells(all_sheet_updates) if success: self.logger.info(f"FINALES Sheet-Update erfolgreich.") self.logger.info( f"Modus 'check_urls' abgeschlossen. {processed_count} Zeilen mit Marker/Fehler verarbeitet, {found_new_url_count} neue URLs gefunden, {skipped_count} Zeilen uebersprungen.") def process_repair_sitz_data( self, start_sheet_row=None, end_sheet_row=None, limit=None): """ Wendet die verbesserte Sitz-Parsing-Logik auf bestehende Daten an. """ self.logger.info( f"Starte Modus 'Sitz-Daten Reparatur'. Bereich: {start_sheet_row if start_sheet_row else 'Start'}-{end_sheet_row if end_sheet_row else 'Ende'}, Limit: {limit if limit else 'Unbegrenzt'}") """ Liest bestehende Sitz-Stadt/Land-Angaben, wendet die verbesserte Parsing-Logik an und aktualisiert das Sheet, falls sich Änderungen ergeben. """ self.logger.info( f"Starte Modus 'Sitz-Daten Reparatur'. Bereich: {start_sheet_row if start_sheet_row is not None else 'Komplett ab Datenstart'}, End: {end_sheet_row if end_sheet_row else 'Sheet-Ende'}, Limit: {limit if limit is not None else 'Unbegrenzt'}...") if not self.sheet_handler.load_data(): self.logger.error( "Konnte Sheet-Daten nicht laden für Sitz-Reparatur. Abbruch.") return all_data = self.sheet_handler.get_all_data_with_headers() header_offset = self.sheet_handler._header_rows stadt_col_idx = COLUMN_MAP.get("Wiki Sitz Stadt") land_col_idx = COLUMN_MAP.get("Wiki Sitz Land") # Optional: Eine Spalte für den originalen Roh-Sitz-String, falls vorhanden # roh_sitz_col_idx = COLUMN_MAP.get("IHRE_ROH_SITZ_SPALTE") if stadt_col_idx is None or land_col_idx is None: self.logger.error( "Spaltenindizes für 'Wiki Sitz Stadt' oder 'Wiki Sitz Land' nicht in COLUMN_MAP. Abbruch.") return updates_fuer_sheet = [] processed_rows_count = 0 updated_rows_count = 0 effective_start_row = start_sheet_row if start_sheet_row is not None else header_offset + 1 effective_end_row = end_sheet_row if end_sheet_row is not None else len( all_data) self.logger.info( f"Prüfe Zeilen {effective_start_row} bis {effective_end_row} für Sitz-Reparatur.") for row_num_sheet in range(effective_start_row, effective_end_row + 1): if limit is not None and processed_rows_count >= limit: self.logger.info( f"Limit von {limit} erreichten Zeilen für Sitz-Reparatur erreicht.") break row_list_idx = row_num_sheet - 1 if row_list_idx >= len(all_data): break # Ende der Daten erreicht row_data = all_data[row_list_idx] aktuelle_stadt = self._get_cell_value_safe( row_data, "Wiki Sitz Stadt") aktuelle_land = self._get_cell_value_safe( row_data, "Wiki Sitz Land") # Erzeuge den Input-String für die Parsing-Funktion # Besser: Wenn Sie den *ursprünglichen* String aus der Wikipedia Infobox # in einer separaten Spalte gespeichert hätten, würden Sie diesen hier verwenden. # Als Fallback kombinieren wir aktuelle Stadt und Land. input_sitz_string = aktuelle_stadt if aktuelle_land and aktuelle_land.lower() not in ["", "k.a."]: if input_sitz_string and input_sitz_string.lower() not in [ "", "k.a."]: # Kombiniere mit Komma input_sitz_string += f", {aktuelle_land}" else: input_sitz_string = aktuelle_land # Wenn Stadt leer/kA, nimm nur Land if not input_sitz_string or not input_sitz_string.strip( ) or input_sitz_string.lower() == 'k.a.': # self.logger.debug(f"Zeile {row_num_sheet}: Keine validen aktuellen Sitzdaten ('{aktuelle_stadt}', '{aktuelle_land}') zum Reparieren.") continue processed_rows_count += 1 try: # Verwende die neue Parsing-Methode des WikipediaScrapers # Stellen Sie sicher, dass self.wiki_scraper eine Instanz von # WikipediaScraper ist parsed_sitz_info = self.wiki_scraper._parse_sitz_string_detailed( input_sitz_string) neue_stadt = parsed_sitz_info.get('sitz_stadt', 'k.A.') neues_land = parsed_sitz_info.get('sitz_land', 'k.A.') # Nur updaten, wenn sich etwas geändert hat if (neue_stadt != aktuelle_stadt and not (neue_stadt == "k.A." and aktuelle_stadt == "")) or ( neues_land != aktuelle_land and not (neues_land == "k.A." and aktuelle_land == "")): self.logger.info( f"Zeile {row_num_sheet}: SITZ-UPDATE. Input: '{input_sitz_string[:60]}...' Alt: '{aktuelle_stadt} / {aktuelle_land}' -> Neu: '{neue_stadt} / {neues_land}'") updates_fuer_sheet.append({ 'range': f'{self.sheet_handler._get_col_letter(stadt_col_idx + 1)}{row_num_sheet}', 'values': [[neue_stadt]] }) updates_fuer_sheet.append({ 'range': f'{self.sheet_handler._get_col_letter(land_col_idx + 1)}{row_num_sheet}', 'values': [[neues_land]] }) updated_rows_count += 1 # else: # self.logger.debug(f"Zeile {row_num_sheet}: Keine Änderung bei Sitzdaten für Input '{input_sitz_string[:60]}...'. Alt: '{aktuelle_stadt} / {aktuelle_land}', Neu: '{neue_stadt} / {neues_land}'") except Exception as e_parse: self.logger.error( f"Fehler beim Parsen des Sitzes für Zeile {row_num_sheet} mit Input '{input_sitz_string}': {e_parse}") # Batch-Update Logik if len(updates_fuer_sheet) >= getattr( Config, 'UPDATE_BATCH_ROW_LIMIT', 50) * 2: # Mal 2, da zwei Spalten pro Zeile self.logger.info( f"Sende Batch-Update für {len(updates_fuer_sheet)//2} Sitzreparaturen...") self.sheet_handler.batch_update_cells(updates_fuer_sheet) updates_fuer_sheet = [] # time.sleep(1) # Optionale Pause # Letzten Batch senden if updates_fuer_sheet: self.logger.info( f"Sende finalen Batch-Update für {len(updates_fuer_sheet)//2} Sitzreparaturen...") self.sheet_handler.batch_update_cells(updates_fuer_sheet) self.logger.info( f"Sitz-Daten Reparatur abgeschlossen. {processed_rows_count} Zeilen geprüft, {updated_rows_count} Zeilen aktualisiert.") def _get_numeric_value_for_plausi(self, value_str, is_umsatz=False): """ Hilfsfunktion, um numerische Werte für Plausibilitätschecks zu extrahieren. """ # ... (Implementation remains similar to the original code) ... # Diese Funktion ist relativ kurz und könnte hier stehen bleiben. from helpers import extract_numeric_value extracted_val_str = extract_numeric_value(value_str, is_umsatz) if extracted_val_str.lower() in ['k.a.', '0']: return np.nan try: num_val = float(extracted_val_str) return num_val * 1000000.0 if is_umsatz else num_val except (ValueError, TypeError): return np.nan def _check_financial_plausibility(self, row_data_dict): """ Führt die Plausibilitätschecks für eine Zeile durch. """ results = { "plaus_umsatz_flag": "NICHT_PRUEFBAR", "plaus_ma_flag": "NICHT_PRUEFBAR", "plaus_ratio_flag": "NICHT_PRUEFBAR", "abweichung_umsatz_flag": "N/A", "abweichung_ma_flag": "N/A", "plausi_begruendung_final": "Plausibilität OK"} temp_begruendungen = [] parent_account_name_d_val = row_data_dict.get( "Parent Account Name", "").strip() is_konzern_tochter_laut_d = bool( parent_account_name_d_val and parent_account_name_d_val.lower() != 'k.a.') # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ # +++ NEU/ERWEITERT: Info aus Spalte O und P für Konzernlogik heranziehen +++ # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ # Diese Keys müssen im row_data_dict vorhanden sein, wenn diese # Funktion aufgerufen wird! parent_o_val = row_data_dict.get( "System Vorschlag Parent Account", "").strip().lower() parent_p_val = row_data_dict.get( "Parent Vorschlag Status", "").strip().lower() is_konzern_tochter_laut_o_und_p = bool( parent_o_val and parent_o_val != 'k.a.' and parent_p_val == 'x') # (Das `wiki_stammt_von_parent_explizit` Flag ist optional, wenn Sie den anderen Ansatz verfolgen) # wiki_stammt_von_parent_explizit = row_data_dict.get("Wiki Daten von Parent", False) # or wiki_stammt_von_parent_explizit is_part_of_a_group_for_plausi = is_konzern_tochter_laut_d or is_konzern_tochter_laut_o_und_p log_msg_group_parts = [] if is_konzern_tochter_laut_d: log_msg_group_parts.append(f"D='{parent_account_name_d_val}'") if is_konzern_tochter_laut_o_und_p: log_msg_group_parts.append(f"O/P='{parent_o_val}/{parent_p_val}'") # if wiki_stammt_von_parent_explizit: # log_msg_group_parts.append("WikiParentFlag=True") if is_part_of_a_group_for_plausi: self.logger.debug( f" PlausiCheck: Unternehmen ist Teil einer Gruppe ({'; '.join(log_msg_group_parts)}). Abweichungs-Checks CRM/Wiki werden als INFO_KONZERN_LOGIK behandelt.") # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ # +++ ENDE NEU/ERWEITERT ++++++++++++++++++++++++++++++++++++++++++++++ # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ # --- 1. Plausibilität Finaler Umsatz (BG) --- final_umsatz_str = row_data_dict.get( "Finaler Umsatz (Wiki>CRM)", "k.A.") umsatz_num_absolut = self._get_numeric_value_for_plausi( final_umsatz_str, is_umsatz=True) exclusion_list_common = ['k.a.', '', 'n/a', '-', '0', '0.0', '0,00', '0.000', '0.00'] if pd.isna(umsatz_num_absolut): if final_umsatz_str.lower().strip( ) not in exclusion_list_common and not final_umsatz_str.startswith("FEHLER"): results["plaus_umsatz_flag"] = "FEHLER_FORMAT" temp_begruendungen.append( f"Finaler Umsatz ('{final_umsatz_str}') konnte nicht als Zahl interpretiert werden.") else: results["plaus_umsatz_flag"] = "OK" if umsatz_num_absolut == 0 and final_umsatz_str != "0": results["plaus_umsatz_flag"] = "WARNUNG_NULL_WERT" temp_begruendungen.append( f"Finaler Umsatz ist numerisch 0 (aus '{final_umsatz_str}').") elif umsatz_num_absolut < getattr(Config, 'PLAUSI_UMSATZ_MIN_WARNUNG', 50000): results["plaus_umsatz_flag"] = "WARNUNG_NIEDRIG" temp_begruendungen.append( f"Finaler Umsatz ({umsatz_num_absolut:,.0f} €) < Min-Schwelle ({getattr(Config, 'PLAUSI_UMSATZ_MIN_WARNUNG', 50000):,.0f} €).") elif umsatz_num_absolut > getattr(Config, 'PLAUSI_UMSATZ_MAX_WARNUNG', 200000000000): results["plaus_umsatz_flag"] = "WARNUNG_HOCH" temp_begruendungen.append( f"Finaler Umsatz ({umsatz_num_absolut:,.0f} €) > Max-Schwelle ({getattr(Config, 'PLAUSI_UMSATZ_MAX_WARNUNG', 200000000000):,.0f} €).") # --- 2. Plausibilität Finale Mitarbeiter (BH) --- final_ma_str = row_data_dict.get( "Finaler Mitarbeiter (Wiki>CRM)", "k.A.") ma_num_absolut = self._get_numeric_value_for_plausi( final_ma_str, is_umsatz=False) if pd.isna(ma_num_absolut): if final_ma_str.lower().strip( ) not in exclusion_list_common and not final_ma_str.startswith("FEHLER"): results["plaus_ma_flag"] = "FEHLER_FORMAT" temp_begruendungen.append( f"Finale MA ('{final_ma_str}') konnte nicht als Zahl interpretiert werden.") else: results["plaus_ma_flag"] = "OK" if ma_num_absolut == 0 and final_ma_str != "0": results["plaus_ma_flag"] = "WARNUNG_NULL_WERT" temp_begruendungen.append( f"Finale MA ist numerisch 0 (aus '{final_ma_str}').") elif ma_num_absolut < getattr(Config, 'PLAUSI_MA_MIN_WARNUNG_ABS', 1): results["plaus_ma_flag"] = "WARNUNG_NIEDRIG" temp_begruendungen.append( f"Finale MA ({ma_num_absolut:.0f}) < Min-Schwelle ({getattr(Config, 'PLAUSI_MA_MIN_WARNUNG_ABS', 1):.0f}).") elif not pd.isna(umsatz_num_absolut) and umsatz_num_absolut >= getattr(Config, 'PLAUSI_UMSATZ_MIN_SCHWELLE_FUER_MA_CHECK', 1000000) and \ ma_num_absolut < getattr(Config, 'PLAUSI_MA_MIN_WARNUNG_BEI_UMSATZ', 3): results["plaus_ma_flag"] = "WARNUNG_ZU_WENIG_MA_BEI_UMSATZ" temp_begruendungen.append( f"Finale MA ({ma_num_absolut:.0f}) auffällig niedrig für Umsatz ({umsatz_num_absolut:,.0f} €).") elif ma_num_absolut > getattr(Config, 'PLAUSI_MA_MAX_WARNUNG', 1000000): results["plaus_ma_flag"] = "WARNUNG_HOCH" temp_begruendungen.append( f"Finale MA ({ma_num_absolut:.0f}) > Max-Schwelle ({getattr(Config, 'PLAUSI_MA_MAX_WARNUNG', 1000000):,.0f}).") # --- 3. Plausibilität Umsatz/MA Ratio (BI) --- if not pd.isna(umsatz_num_absolut) and not pd.isna( ma_num_absolut) and umsatz_num_absolut > 0: if ma_num_absolut > 0: ratio = umsatz_num_absolut / ma_num_absolut results["plaus_ratio_flag"] = "OK" if ratio < getattr( Config, 'PLAUSI_RATIO_UMSATZ_PRO_MA_MIN', 25000): results["plaus_ratio_flag"] = "WARNUNG_RATIO_NIEDRIG" temp_begruendungen.append( f"Umsatz/MA Ratio ({ratio:,.0f} €/MA) < Min-Schwelle ({getattr(Config, 'PLAUSI_RATIO_UMSATZ_PRO_MA_MIN', 25000):,.0f} €/MA).") elif ratio > getattr(Config, 'PLAUSI_RATIO_UMSATZ_PRO_MA_MAX', 1500000): results["plaus_ratio_flag"] = "WARNUNG_RATIO_HOCH" temp_begruendungen.append( f"Umsatz/MA Ratio ({ratio:,.0f} €/MA) > Max-Schwelle ({getattr(Config, 'PLAUSI_RATIO_UMSATZ_PRO_MA_MAX', 1500000):,.0f} €/MA).") elif ma_num_absolut == 0: results["plaus_ratio_flag"] = "FEHLER_MA_NULL_BEI_UMSATZ" temp_begruendungen.append( "Umsatz vorhanden, aber MA ist 0. Ratio nicht berechenbar.") elif pd.isna(umsatz_num_absolut) or umsatz_num_absolut <= 0: results["plaus_ratio_flag"] = "NICHT_PRUEFBAR_UMSATZ_FEHLT" # --- 4. Abgleich CRM vs. Wiki (BJ, BK) --- crm_umsatz_str = row_data_dict.get("CRM Umsatz", "k.A.") wiki_umsatz_str = row_data_dict.get("Wiki Umsatz", "k.A.") crm_ma_str = row_data_dict.get("CRM Anzahl Mitarbeiter", "k.A.") wiki_ma_str = row_data_dict.get("Wiki Mitarbeiter", "k.A.") crm_u_abs = self._get_numeric_value_for_plausi( crm_umsatz_str, is_umsatz=True) wiki_u_abs = self._get_numeric_value_for_plausi( wiki_umsatz_str, is_umsatz=True) crm_m_abs_comp = self._get_numeric_value_for_plausi( crm_ma_str, is_umsatz=False) wiki_m_abs_comp = self._get_numeric_value_for_plausi( wiki_ma_str, is_umsatz=False) abweichung_prozent_config = getattr( Config, 'PLAUSI_ABWEICHUNG_CRM_WIKI_PROZENT', 30) / 100.0 # Umsatz Abweichung (BJ) if pd.notna(crm_u_abs) and pd.notna( wiki_u_abs) and crm_u_abs > 0 and wiki_u_abs > 0: if is_part_of_a_group_for_plausi: # ERWEITERTE BEDINGUNG HIER VERWENDEN results["abweichung_umsatz_flag"] = "INFO_KONZERN_LOGIK" else: diff_umsatz = abs(crm_u_abs - wiki_u_abs) bezugswert_umsatz = max( crm_u_abs, wiki_u_abs) if max( crm_u_abs, wiki_u_abs) > 0 else 1 if (diff_umsatz / bezugswert_umsatz) > abweichung_prozent_config: results["abweichung_umsatz_flag"] = "WARNUNG_SIGNIFIKANT" temp_begruendungen.append( f"Umsatz CRM ({crm_u_abs:,.0f} €) vs. Wiki ({wiki_u_abs:,.0f} €) weicht >{abweichung_prozent_config*100:.0f}% ab.") else: results["abweichung_umsatz_flag"] = "OK" elif pd.notna(crm_u_abs) and crm_u_abs > 0 and (pd.isna(wiki_u_abs) or wiki_u_abs <= 0): results["abweichung_umsatz_flag"] = "WIKI_FEHLT_ODER_NULL" elif (pd.isna(crm_u_abs) or crm_u_abs <= 0) and pd.notna(wiki_u_abs) and wiki_u_abs > 0: results["abweichung_umsatz_flag"] = "CRM_FEHLT_ODER_NULL" else: results["abweichung_umsatz_flag"] = "BEIDE_FEHLEN_ODER_NULL" # Mitarbeiter Abweichung (BK) if pd.notna(crm_m_abs_comp) and pd.notna( wiki_m_abs_comp) and crm_m_abs_comp > 0 and wiki_m_abs_comp > 0: if is_part_of_a_group_for_plausi: # ERWEITERTE BEDINGUNG HIER VERWENDEN results["abweichung_ma_flag"] = "INFO_KONZERN_LOGIK" else: diff_ma = abs(crm_m_abs_comp - wiki_m_abs_comp) bezugswert_ma = max( crm_m_abs_comp, wiki_m_abs_comp) if max( crm_m_abs_comp, wiki_m_abs_comp) > 0 else 1 if (diff_ma / bezugswert_ma) > abweichung_prozent_config: results["abweichung_ma_flag"] = "WARNUNG_SIGNIFIKANT" temp_begruendungen.append( f"MA CRM ({crm_m_abs_comp:.0f}) vs. Wiki ({wiki_m_abs_comp:.0f}) weicht >{abweichung_prozent_config*100:.0f}% ab.") else: results["abweichung_ma_flag"] = "OK" elif pd.notna(crm_m_abs_comp) and crm_m_abs_comp > 0 and (pd.isna(wiki_m_abs_comp) or wiki_m_abs_comp <= 0): results["abweichung_ma_flag"] = "WIKI_FEHLT_ODER_NULL" elif (pd.isna(crm_m_abs_comp) or crm_m_abs_comp <= 0) and pd.notna(wiki_m_abs_comp) and wiki_m_abs_comp > 0: results["abweichung_ma_flag"] = "CRM_FEHLT_ODER_NULL" else: results["abweichung_ma_flag"] = "BEIDE_FEHLEN_ODER_NULL" if temp_begruendungen: results["plausi_begruendung_final"] = "; ".join(temp_begruendungen) return results def run_plausibility_checks_batch( self, start_sheet_row=None, end_sheet_row=None, limit=None): """ Führt Konsolidierung und Plausi-Checks für einen Bereich von Zeilen aus. """ self.logger.info( f"Starte Modus 'plausi_check_data'. Bereich: {start_sheet_row if start_sheet_row else 'Start'}-{end_sheet_row if end_sheet_row else 'Ende'}, Limit: {limit if limit else 'Unbegrenzt'}") self.logger.info( f"Starte Modus 'plausi_check_data' (Konsolidierung & Plausi-Checks). Bereich: {start_sheet_row if start_sheet_row is not None else 'Start'}-{end_sheet_row if end_sheet_row else 'Ende'}, Limit: {limit if limit is not None else 'Unbegrenzt'}") if not self.sheet_handler.load_data(): self.logger.error( "Konnte Sheet-Daten nicht laden für Plausi-Checks. Abbruch.") return all_data = self.sheet_handler.get_all_data_with_headers() header_offset = self.sheet_handler._header_rows total_sheet_rows = len(all_data) effective_start_row = start_sheet_row if start_sheet_row is not None else header_offset + 1 effective_end_row = end_sheet_row if end_sheet_row is not None else total_sheet_rows if effective_start_row > effective_end_row or effective_start_row > total_sheet_rows: self.logger.info( "Start liegt nach Ende oder außerhalb des Sheets. Keine Zeilen zu verarbeiten.") return self.logger.info( f"Verarbeite Zeilen {effective_start_row} bis {effective_end_row} für Konsolidierung und Plausi-Checks.") required_keys_for_plausi_mode = [ # Schlüssel wie gehabt "CRM Umsatz", "Wiki Umsatz", "CRM Anzahl Mitarbeiter", "Wiki Mitarbeiter", "Parent Account Name", "Finaler Umsatz (Wiki>CRM)", "Finaler Mitarbeiter (Wiki>CRM)", "Plausibilität Umsatz", "Plausibilität Mitarbeiter", "Plausibilität Umsatz/MA Ratio", "Abweichung Umsatz CRM/Wiki", "Abweichung MA CRM/Wiki", "Plausibilität Begründung", "Plausibilität Prüfdatum", "CRM Name" # CRM Name für Logging hinzugefügt ] if not all( key in COLUMN_MAP for key in required_keys_for_plausi_mode): missing_k = [ k for k in required_keys_for_plausi_mode if k not in COLUMN_MAP] self.logger.error( f"Nicht alle benötigten Spalten ({missing_k}) für Modus 'plausi_check_data' in COLUMN_MAP. Abbruch.") return all_sheet_updates = [] processed_rows_count = 0 now_timestamp_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S") update_batch_limit_config = getattr( Config, 'UPDATE_BATCH_ROW_LIMIT', 50) for row_num_sheet in range( effective_start_row, effective_end_row + 1): if limit is not None and processed_rows_count >= limit: self.logger.info( f"Verarbeitungslimit von {limit} Zeilen erreicht.") break row_list_idx = row_num_sheet - 1 if row_list_idx >= total_sheet_rows: break row_data = all_data[row_list_idx] crm_name_check = self._get_cell_value_safe( row_data, "CRM Name").strip() if not crm_name_check: continue self.logger.debug( f"Zeile {row_num_sheet} ({crm_name_check[:30]}...): Starte Konsolidierung und Plausi-Check.") current_row_updates = [] # 1. Konsolidierung (BD, BE) final_umsatz_str_konsolidiert = "k.A." final_ma_str_konsolidiert = "k.A." parent_account_name_d_val = self._get_cell_value_safe( row_data, "Parent Account Name").strip() parent_o_val_plausi = self._get_cell_value_safe( row_data, "System Vorschlag Parent Account").strip() # Für Plausi-Check holen parent_p_val_plausi = self._get_cell_value_safe( row_data, "Parent Vorschlag Status").strip() # Für Plausi-Check holen try: crm_umsatz_val_str = self._get_cell_value_safe( row_data, "CRM Umsatz") # Wiki-Werte direkt aus der Zeile (row_data) lesen wiki_umsatz_val_str_sheet = self._get_cell_value_safe( row_data, "Wiki Umsatz") crm_ma_val_str = self._get_cell_value_safe( row_data, "CRM Anzahl Mitarbeiter") wiki_ma_val_str_sheet = self._get_cell_value_safe( row_data, "Wiki Mitarbeiter") num_crm_umsatz = get_numeric_filter_value( crm_umsatz_val_str, is_umsatz=True) num_wiki_umsatz = get_numeric_filter_value( wiki_umsatz_val_str_sheet, is_umsatz=True) # Verwende _sheet Wert num_crm_ma = get_numeric_filter_value( crm_ma_val_str, is_umsatz=False) num_wiki_ma = get_numeric_filter_value( wiki_ma_val_str_sheet, is_umsatz=False) # Verwende _sheet Wert if parent_account_name_d_val and parent_account_name_d_val.lower() != 'k.a.': self.logger.debug( f" -> Parent D ('{parent_account_name_d_val}') ist gesetzt. Konsolidiere primär mit CRM-Daten der Tochter.") final_num_umsatz = num_crm_umsatz if num_crm_umsatz > 0 else num_wiki_umsatz final_num_ma = num_crm_ma if num_crm_ma > 0 else num_wiki_ma else: final_num_umsatz = num_wiki_umsatz if num_wiki_umsatz > 0 else num_crm_umsatz final_num_ma = num_wiki_ma if num_wiki_ma > 0 else num_crm_ma final_umsatz_str_konsolidiert = str( int(round(final_num_umsatz))) if final_num_umsatz > 0 else 'k.A.' final_ma_str_konsolidiert = str( int(round(final_num_ma))) if final_num_ma > 0 else 'k.A.' except Exception as e_conso_batch: self.logger.error( f"Fehler bei Konsolidierung in Plausi-Batch für Zeile {row_num_sheet}: {e_conso_batch}") final_umsatz_str_konsolidiert = "FEHLER_KONSO_PLAUSI" final_ma_str_konsolidiert = "FEHLER_KONSO_PLAUSI" current_row_updates.append( { 'range': f'{self.sheet_handler._get_col_letter(get_col_idx("Finaler Umsatz (Wiki>CRM)") + 1)}{row_num_sheet}', 'values': [ [final_umsatz_str_konsolidiert]]}) current_row_updates.append( { 'range': f'{self.sheet_handler._get_col_letter(get_col_idx("Finaler Mitarbeiter (Wiki>CRM)") + 1)}{row_num_sheet}', 'values': [ [final_ma_str_konsolidiert]]}) # 2. Plausibilitäts-Checks (BG-BM) if not final_umsatz_str_konsolidiert.startswith( "FEHLER") and not final_ma_str_konsolidiert.startswith("FEHLER"): try: plausi_input_data = { "Finaler Umsatz (Wiki>CRM)": final_umsatz_str_konsolidiert, "Finaler Mitarbeiter (Wiki>CRM)": final_ma_str_konsolidiert, "CRM Umsatz": self._get_cell_value_safe(row_data, "CRM Umsatz"), "Wiki Umsatz": self._get_cell_value_safe(row_data, "Wiki Umsatz"), "CRM Anzahl Mitarbeiter": self._get_cell_value_safe(row_data, "CRM Anzahl Mitarbeiter"), "Wiki Mitarbeiter": self._get_cell_value_safe(row_data, "Wiki Mitarbeiter"), "Parent Account Name": parent_account_name_d_val, "System Vorschlag Parent Account": parent_o_val_plausi, # Variable von oben "Parent Vorschlag Status": parent_p_val_plausi # Variable von oben } plausi_results = self._check_financial_plausibility( plausi_input_data) current_row_updates.append( { 'range': f'{self.sheet_handler._get_col_letter(get_col_idx("Plausibilität Umsatz") + 1)}{row_num_sheet}', 'values': [ [ plausi_results.get( "plaus_umsatz_flag", "ERR_FLAG")]]}) current_row_updates.append( { 'range': f'{self.sheet_handler._get_col_letter(get_col_idx("Plausibilität Mitarbeiter") + 1)}{row_num_sheet}', 'values': [ [ plausi_results.get( "plaus_ma_flag", "ERR_FLAG")]]}) current_row_updates.append( { 'range': f'{self.sheet_handler._get_col_letter(get_col_idx("Plausibilität Umsatz/MA Ratio") + 1)}{row_num_sheet}', 'values': [ [ plausi_results.get( "plaus_ratio_flag", "ERR_FLAG")]]}) current_row_updates.append( { 'range': f'{self.sheet_handler._get_col_letter(get_col_idx("Abweichung Umsatz CRM/Wiki") + 1)}{row_num_sheet}', 'values': [ [ plausi_results.get( "abweichung_umsatz_flag", "ERR_FLAG")]]}) current_row_updates.append( { 'range': f'{self.sheet_handler._get_col_letter(get_col_idx("Abweichung MA CRM/Wiki") + 1)}{row_num_sheet}', 'values': [ [ plausi_results.get( "abweichung_ma_flag", "ERR_FLAG")]]}) current_row_updates.append( { 'range': f'{self.sheet_handler._get_col_letter(get_col_idx("Plausibilität Begründung") + 1)}{row_num_sheet}', 'values': [ [ plausi_results.get( "plausi_begruendung_final", "Fehler Begr.")]]}) except Exception as e_plausi_run_batch: self.logger.error( f"Fehler im Plausi-Check Aufruf (Batch-Modus) für Zeile {row_num_sheet}: {e_plausi_run_batch}") for key_flag in [ "Plausibilität Umsatz", "Plausibilität Mitarbeiter", "Plausibilität Umsatz/MA Ratio", "Abweichung Umsatz CRM/Wiki", "Abweichung MA CRM/Wiki"]: current_row_updates.append( { 'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP[key_flag] + 1)}{row_num_sheet}', 'values': [ ['FEHLER_CALL']]}) current_row_updates.append( { 'range': f'{self.sheet_handler._get_col_letter(get_col_idx("Plausibilität Begründung") + 1)}{row_num_sheet}', 'values': [ [f"Systemfehler: {str(e_plausi_run_batch)[:100]}"]]}) else: # Fehler bei Konsolidierung self.logger.warning( f"Zeile {row_num_sheet}: Überspringe Plausi-Checks wegen Fehler bei Konsolidierung.") for key_flag in [ "Plausibilität Umsatz", "Plausibilität Mitarbeiter", "Plausibilität Umsatz/MA Ratio", "Abweichung Umsatz CRM/Wiki", "Abweichung MA CRM/Wiki"]: current_row_updates.append( { 'range': f'{self.sheet_handler._get_col_letter(COLUMN_MAP[key_flag] + 1)}{row_num_sheet}', 'values': [ ['INPUT_FEHLER_KONSO']]}) current_row_updates.append( { 'range': f'{self.sheet_handler._get_col_letter(get_col_idx("Plausibilität Begründung") + 1)}{row_num_sheet}', 'values': [ ["Konsolidierung fehlgeschlagen"]]}) current_row_updates.append( { 'range': f'{self.sheet_handler._get_col_letter(get_col_idx("Plausibilität Prüfdatum") + 1)}{row_num_sheet}', 'values': [ [now_timestamp_str]]}) all_sheet_updates.extend(current_row_updates) processed_rows_count += 1 # Batch-Update auslösen, wenn Limit erreicht if processed_rows_count % update_batch_limit_config == 0 and processed_rows_count > 0: if all_sheet_updates: self.logger.info( f"Plausi-Batch: Sende {len(all_sheet_updates)} Operationen für {update_batch_limit_config} Zeilen...") self.sheet_handler.batch_update_cells( all_sheet_updates) all_sheet_updates = [] # Liste leeren nach dem Senden time.sleep(0.5) # Kurze Pause nach Batch-Update # Ende der for-Schleife über die Zeilen # Sende verbleibende Updates if all_sheet_updates: self.logger.info( f"Plausi-Batch: Sende verbleibende {len(all_sheet_updates)} Operationen...") self.sheet_handler.batch_update_cells(all_sheet_updates) self.logger.info( f"Modus 'plausi_check_data' abgeschlossen. {processed_rows_count} Zeilen verarbeitet.") def _suggest_parent_account_openai_task(self, task_data, openai_semaphore): """ Fragt ChatGPT nach einem Parent Account für ein einzelnes Unternehmen. """ """ Fragt ChatGPT nach einem Parent Account für ein einzelnes Unternehmen. Läuft in einem separaten Thread für den Parent-Suggestion-Batch. Args: task_data (dict): Enthält die Daten für die Zeile (row_num, crm_name, crm_website, crm_beschreibung, wiki_url, wiki_absatz, wiki_kategorien, website_zusammenfassung). openai_semaphore (threading.Semaphore): Semaphore zur Begrenzung gleichzeitiger OpenAI-Calls. Returns: dict: {'row_num': int, 'suggested_parent': str, 'justification': str, 'error': str or None} """ logger = logging.getLogger(__name__ + ".suggest_parent_task") row_num = task_data['row_num'] suggested_parent = "k.A." justification = "Keine Begründung erhalten." error_msg = None # Bereinige Input-Daten für den Prompt crm_name = str(task_data.get('crm_name', 'N/A')).strip() crm_website = str(task_data.get('crm_website', 'N/A')).strip() crm_beschreibung = str(task_data.get( 'crm_beschreibung', 'N/A')).strip()[:800] # Gekürzt wiki_url = str(task_data.get('wiki_url', 'N/A')).strip() wiki_absatz = str(task_data.get('wiki_absatz', 'N/A') ).strip()[:800] # Gekürzt wiki_kategorien = str(task_data.get( 'wiki_kategorien', 'N/A')).strip()[:500] # Gekürzt website_zusammenfassung = str(task_data.get( 'website_zusammenfassung', 'N/A')).strip()[:800] # Gekürzt prompt_parts = [ "Du bist ein Wirtschaftsanalyst und recherchierst Unternehmensstrukturen.", "Basierend auf den folgenden Informationen, identifiziere bitte den Namen der direkten Muttergesellschaft oder des übergeordneten Konzerns für das genannte Unternehmen.", "Wenn keine klare Muttergesellschaft ersichtlich ist oder das Unternehmen selbständig zu sein scheint, antworte mit 'k.A.' für den Parent Account.", "Gib deine Antwort ausschließlich im folgenden Format aus (keine Einleitung, kein Schlusssatz):", "Vorgeschlagener Parent Account: ", "Begründung: ", "\n--- Unternehmensinformationen ---", f"Unternehmen: {crm_name}", ] if crm_website and crm_website.lower() != "n/a": prompt_parts.append(f"Website: {crm_website}") if crm_beschreibung and crm_beschreibung.lower() != "n/a": prompt_parts.append(f"CRM Beschreibung: {crm_beschreibung}") if wiki_url and "wikipedia.org" in wiki_url.lower(): prompt_parts.append(f"Wikipedia URL: {wiki_url}") if wiki_absatz and wiki_absatz.lower() != "n/a": prompt_parts.append(f"Wikipedia Absatz: {wiki_absatz}") if wiki_kategorien and wiki_kategorien.lower() != "n/a": prompt_parts.append(f"Wikipedia Kategorien: {wiki_kategorien}") if website_zusammenfassung and website_zusammenfassung.lower( ) != "n/a" and not website_zusammenfassung.startswith("k.A. (Fehler"): prompt_parts.append( f"Website Zusammenfassung: {website_zusammenfassung}") prompt_parts.append( "\nBitte gib NUR die Antwort im oben genannten Format.") prompt = "\n".join(prompt_parts) # Token Count (optional, zur Info) # try: # pt_count = token_count(prompt) # logger.debug(f"Zeile {row_num}: Prompt für Parent Suggestion ({pt_count} Tokens): {prompt[:200]}...") # except Exception: pass try: with openai_semaphore: # call_openai_chat ist mit @retry_on_failure dekoriert raw_chat_response = call_openai_chat( prompt, temperature=0.1, model=getattr( Config, 'TOKEN_MODEL', 'gpt-3.5-turbo')) if raw_chat_response: parsed_parent = "k.A." parsed_justification = "Keine Begründung extrahiert." parent_match = re.search( r"Vorgeschlagener Parent Account:\s*(.*)", raw_chat_response, re.IGNORECASE) if parent_match: parsed_parent = parent_match.group(1).strip() if not parsed_parent or parsed_parent.lower( ) == "k.a.": # Sicherstellen, dass "k.A." korrekt übernommen wird parsed_parent = "k.A." justification_match = re.search( r"Begründung:\s*(.*)", raw_chat_response, re.IGNORECASE) if justification_match: parsed_justification = justification_match.group(1).strip() suggested_parent = parsed_parent justification = parsed_justification logger.debug( f"Zeile {row_num}: ChatGPT Parent Vorschlag='{suggested_parent}', Begründung='{justification[:100]}...'") else: error_msg = "Leere Antwort von OpenAI erhalten." logger.warning(f"Zeile {row_num}: {error_msg}") justification = error_msg except Exception as e: error_msg = f"Fehler bei OpenAI Call für Parent Suggestion (Zeile {row_num}): {type(e).__name__} - {str(e)[:100]}" logger.error(error_msg) justification = error_msg return { "row_num": row_num, "suggested_parent": suggested_parent, "justification": justification, "error": error_msg} def process_parent_suggestion_batch( self, start_sheet_row=None, end_sheet_row=None, limit=None, re_evaluate_question_mark=False): """ Batch-Prozess zur Generierung von Parent-Account-Vorschlägen. """ self.logger.info( f"Starte Parent Account Suggestion Batch. Bereich: {start_sheet_row if start_sheet_row else 'Start'}-{end_sheet_row if end_sheet_row else 'Ende'}, Limit: {limit if limit else 'Unbegrenzt'}") """ Batch-Prozess zur Generierung von Parent-Account-Vorschlägen mittels ChatGPT. Schreibt Ergebnisse in Spalten O, P, Q. Bearbeitet nur Zeilen, bei denen Spalte Q (Parent Vorschlag Timestamp) leer ist, es sei denn re_evaluate_question_mark ist True und Spalte P ist '?'. Args: start_sheet_row (int, optional): Die 1-basierte Startzeile. end_sheet_row (int, optional): Die 1-basierte Endzeile. limit (int, optional): Maximale Anzahl zu verarbeitender Zeilen. re_evaluate_question_mark (bool, optional): Wenn True, werden auch Zeilen mit '?' in Spalte P (Parent Vorschlag Status) erneut bewertet, AUCH WENN Spalte Q bereits einen Timestamp hat. """ self.logger.info( f"Starte Parent Account Suggestion Batch. Bereich: {start_sheet_row if start_sheet_row else 'Start'}-{end_sheet_row if end_sheet_row else 'Ende'}, Limit: {limit if limit else 'Unbegrenzt'}, Re-Eval ?: {re_evaluate_question_mark}") # --- Daten laden und Startzeile ermitteln --- col_o_key = "System Vorschlag Parent Account" col_p_key = "Parent Vorschlag Status" col_q_key = "Parent Vorschlag Timestamp" # Timestamp-Spalte für die Auswahl if start_sheet_row is None: # Geändert: Start basierend auf leerem Q self.logger.info( f"Automatische Ermittlung der Startzeile basierend auf leerem '{col_q_key}'...") start_data_index_no_header = self.sheet_handler.get_start_row_index( check_column_key=col_q_key, min_sheet_row=7) if start_data_index_no_header == -1: self.logger.error( "FEHLER bei autom. Startzeilenermittlung. Breche ab.") return start_sheet_row = start_data_index_no_header + \ self.sheet_handler._header_rows + 1 self.logger.info( f"Automatisch ermittelte Startzeile: {start_sheet_row}") else: if not self.sheet_handler.load_data(): self.logger.error( "FEHLER beim Laden der Daten für Parent Suggestion Batch.") return all_data = self.sheet_handler.get_all_data_with_headers() header_rows = self.sheet_handler._header_rows total_sheet_rows = len(all_data) if end_sheet_row is None: end_sheet_row = total_sheet_rows self.logger.info( f"Verarbeitungsbereich: Sheet-Zeilen {start_sheet_row} bis {end_sheet_row}.") if start_sheet_row > end_sheet_row or start_sheet_row > total_sheet_rows: self.logger.info( "Start liegt nach Ende oder außerhalb des Sheets. Keine Verarbeitung.") return # --- Indizes --- required_keys = [ col_o_key, col_p_key, col_q_key, "CRM Name", "CRM Website", "CRM Beschreibung", "Wiki URL", "Wiki Absatz", "Wiki Kategorien", "Website Zusammenfassung", "Version"] if not all(key in COLUMN_MAP for key in required_keys): missing = [k for k in required_keys if k not in COLUMN_MAP] self.logger.critical( f"FEHLER: Spaltenschlüssel für Parent Suggestion Batch fehlen: {missing}. Abbruch.") return col_o_letter = self.sheet_handler._get_col_letter( COLUMN_MAP[col_o_key] + 1) col_p_letter = self.sheet_handler._get_col_letter( COLUMN_MAP[col_p_key] + 1) col_q_letter = self.sheet_handler._get_col_letter( COLUMN_MAP[col_q_key] + 1) openai_sem = threading.Semaphore( getattr(Config, 'OPENAI_CONCURRENCY_LIMIT', 3)) max_workers = getattr(Config, 'MAX_BRANCH_WORKERS', 10) processing_batch_size = getattr( Config, 'PROCESSING_BRANCH_BATCH_SIZE', 10) update_batch_row_limit = getattr(Config, 'UPDATE_BATCH_ROW_LIMIT', 50) tasks_for_current_openai_batch = [] all_sheet_updates = [] processed_count = 0 skipped_count = 0 # Funktion zum Verarbeiten und Schreiben eines Batches (bleibt intern # gleich) def _execute_and_write_openai_batch(current_tasks): # ... (Code der inneren Funktion bleibt identisch wie im vorherigen Vorschlag) ... nonlocal processed_count if not current_tasks: return batch_start_log_row = current_tasks[0]['row_num'] batch_end_log_row = current_tasks[-1]['row_num'] self.logger.debug( f"\n--- Starte Parent Suggestion OpenAI Batch ({len(current_tasks)} Tasks, Zeilen {batch_start_log_row}-{batch_end_log_row}) ---") batch_results_list = [] with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor: future_to_task_map = { executor.submit( self._suggest_parent_account_openai_task, task, openai_sem): task for task in current_tasks} for future in concurrent.futures.as_completed( future_to_task_map): task_info_orig = future_to_task_map[future] try: result_data = future.result() batch_results_list.append(result_data) except Exception as e_future: self.logger.error( f"Exception im Future für Parent Suggestion Zeile {task_info_orig['row_num']}: {e_future}") batch_results_list.append({ "row_num": task_info_orig['row_num'], "suggested_parent": "FEHLER_TASK", "justification": str(e_future)[:150], "error": str(e_future) }) self.logger.debug( f" OpenAI Batch ({batch_start_log_row}-{batch_end_log_row}) abgeschlossen. {len(batch_results_list)} Ergebnisse erhalten.") if batch_results_list: now_ts_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S") updates_for_this_batch = [] for res_item in batch_results_list: rn = res_item['row_num'] parent_val = res_item.get( 'suggested_parent', 'k.A. (Fehler)') updates_for_this_batch.append( {'range': f'{col_o_letter}{rn}', 'values': [[parent_val]]}) status_val = "?" if parent_val and parent_val.lower( ) != "k.a." and not parent_val.startswith("FEHLER") else "" updates_for_this_batch.append( {'range': f'{col_p_letter}{rn}', 'values': [[status_val]]}) updates_for_this_batch.append( {'range': f'{col_q_letter}{rn}', 'values': [[now_ts_str]]}) if res_item.get('justification'): self.logger.debug( f"Zeile {rn} - Parent Begründung: {res_item.get('justification')[:200]}...") all_sheet_updates.extend(updates_for_this_batch) # Zähle hier, wenn Tasks tatsächlich an OpenAI gingen processed_count += len(current_tasks) if len(all_sheet_updates) >= update_batch_row_limit * 3: self.logger.info( f"Sende Batch-Updates für Parent Suggestions ({len(all_sheet_updates)//3} Zeilen)...") self.sheet_handler.batch_update_cells(all_sheet_updates) all_sheet_updates.clear() time.sleep(getattr(Config, 'RETRY_DELAY', 5) * 0.5) # Ende der Hilfsfunktion _execute_and_write_openai_batch # Hauptschleife über die Zeilen for i in range(start_sheet_row, end_sheet_row + 1): # Limit-Prüfung erfolgt jetzt innerhalb der _execute_and_write_openai_batch # oder besser hier vor dem Sammeln von Tasks, um nicht unnötig zu # iterieren. if limit is not None and processed_count >= limit: self.logger.info( f"Verarbeitungslimit ({limit}) für Parent Suggestions erreicht.") break row_idx_list = i - 1 if row_idx_list >= total_sheet_rows: break row = all_data[row_idx_list] if not any(cell and str(cell).strip() for cell in row): skipped_count += 1 continue # Kriterien für Verarbeitung val_q_timestamp = self._get_cell_value_safe( row, col_q_key).strip() # Timestamp aus Spalte Q val_p_status = self._get_cell_value_safe( row, col_p_key).strip() # Status aus Spalte P needs_processing = False if not val_q_timestamp: # Spalte Q (Timestamp) ist leer needs_processing = True # Neubewertung für Status "?" auch wenn Timestamp Q gesetzt ist elif re_evaluate_question_mark and val_p_status == "?": needs_processing = True self.logger.debug( f"Zeile {i}: Wird trotz vorhandenem Timestamp in Q ('{val_q_timestamp}') verarbeitet, da P='?' und re_evaluate_question_mark=True.") if not needs_processing: skipped_count += 1 continue # Daten für Task sammeln task_data = { "row_num": i, "crm_name": self._get_cell_value_safe(row, "CRM Name"), "crm_website": self._get_cell_value_safe(row, "CRM Website"), "crm_beschreibung": self._get_cell_value_safe(row, "CRM Beschreibung"), "wiki_url": self._get_cell_value_safe(row, "Wiki URL"), "wiki_absatz": self._get_cell_value_safe(row, "Wiki Absatz"), "wiki_kategorien": self._get_cell_value_safe(row, "Wiki Kategorien"), "website_zusammenfassung": self._get_cell_value_safe(row, "Website Zusammenfassung") } tasks_for_current_openai_batch.append(task_data) if len(tasks_for_current_openai_batch) >= processing_batch_size: _execute_and_write_openai_batch(tasks_for_current_openai_batch) tasks_for_current_openai_batch.clear() if tasks_for_current_openai_batch: _execute_and_write_openai_batch(tasks_for_current_openai_batch) if all_sheet_updates: self.logger.info( f"Sende finale Batch-Updates für Parent Suggestions ({len(all_sheet_updates)//3} Zeilen)...") self.sheet_handler.batch_update_cells(all_sheet_updates) self.logger.info( f"Parent Account Suggestion Batch abgeschlossen. {processed_count} Zeilen verarbeitet, {skipped_count} Zeilen übersprungen.") def _predict_technician_bucket(self, row_data): """ Führt eine Vorhersage des Servicetechniker-Buckets für eine einzelne Zeile durch. Die Feature-Erstellung ist exakt auf den Trainingsprozess abgestimmt. """ company_name = self._get_cell_value_safe(row_data, 'CRM Name').strip() self.logger.debug(f"Versuche ML-Schaetzung fuer Zeile ({company_name[:50]}...)") if not self.is_setup_complete or self.model is None or self.imputer is None or self._expected_features is None: self.logger.error("ML-Artefakte (Modell/Imputer/Features) nicht initialisiert. Überspringe Vorhersage.") return "FEHLER Schaetzung (Setup fehlt)" try: # === Feature Erstellung (exakt wie im Training) === # 1. Numerische Werte holen umsatz_val = get_numeric_filter_value(self._get_cell_value_safe(row_data, "Finaler Umsatz (Wiki>CRM)"), is_umsatz=True) ma_val = get_numeric_filter_value(self._get_cell_value_safe(row_data, "Finaler Mitarbeiter (Wiki>CRM)"), is_umsatz=False) umsatz_val = np.nan if umsatz_val == 0 else umsatz_val ma_val = np.nan if ma_val == 0 else ma_val # 2. 'is_part_of_group' Feature parent_d = self._get_cell_value_safe(row_data, "Parent Account Name").strip().lower() parent_o = self._get_cell_value_safe(row_data, "System Vorschlag Parent Account").strip().lower() parent_p = self._get_cell_value_safe(row_data, "Parent Vorschlag Status").strip().lower() is_group = 1 if (parent_d and parent_d != 'k.a.') or (parent_o and parent_o != 'k.a.' and parent_p == 'x') else 0 # 3. Ratio & Log Features log_umsatz = np.log1p(umsatz_val) if pd.notna(umsatz_val) else np.nan log_ma = np.log1p(ma_val) if pd.notna(ma_val) else np.nan umsatz_pro_ma = (umsatz_val / ma_val) if pd.notna(umsatz_val) and pd.notna(ma_val) and ma_val > 0 else np.nan # 4. Branchen-Gruppen-Feature (entscheidende Korrektur) # Nutze die KI-Branche als Input branche_ki_val = self._get_cell_value_safe(row_data, "Chat Vorschlag Branche") # Erstelle das Mapping von Detail-Branche zu Gruppe branch_group_map = {branch_name: details.get('gruppe', 'Sonstige') for branch_name, details in Config.BRANCH_GROUP_MAPPING.items()} # Führe das Mapping durch branchen_gruppe = branch_group_map.get(branche_ki_val, 'Sonstige') # 5. DataFrame mit allen möglichen Features erstellen (wie im Training) single_row_data = { 'Log_Finaler_Umsatz_ML': log_umsatz, 'Log_Finaler_Mitarbeiter_ML': log_ma, 'Umsatz_pro_MA_ML': umsatz_pro_ma, 'is_part_of_group': is_group } # Füge die One-Hot-Encoded Branchen-Gruppen hinzu for expected_col in self._expected_features: if expected_col.startswith('Gruppe_'): # Extrahiere den Gruppennamen aus dem Spaltennamen gruppe_name = expected_col.replace('Gruppe_', '') single_row_data[expected_col] = 1 if gruppe_name == branchen_gruppe else 0 # Erstelle den finalen DataFrame in der korrekten Spaltenreihenfolge df_for_prediction = pd.DataFrame([single_row_data], columns=self._expected_features) # 6. Vorhersage mit der Pipeline durchführen # Die Pipeline kümmert sich um die Imputation prediction_proba = self.model.predict_proba(df_for_prediction) predicted_bucket_label = self.model.classes_[np.argmax(prediction_proba[0])] self.logger.debug(f" -> ML Vorhersage Ergebnis: '{predicted_bucket_label}'") return predicted_bucket_label except Exception as e_predict: self.logger.exception(f"FEHLER bei der ML-Vorhersage für Zeile ({company_name[:50]}...): {e_predict}") return f"FEHLER Schaetzung: {str(e_predict)[:100]}..." prediction_proba = self.model.predict_proba(df_imputed_array) predicted_bucket_label = self.model.classes_[ np.argmax(prediction_proba[0])] self.logger.debug( f" -> ML Vorhersage Ergebnis: '{predicted_bucket_label}'") return predicted_bucket_label except Exception as e_predict: self.logger.exception( f"FEHLER bei der ML-Vorhersage für Zeile ({company_name[:50]}...): {e_predict}") return f"FEHLER Schaetzung: {str(e_predict)[:100]}..." def _load_ml_model(self, model_path, imputer_path): """ Laedt das trainierte ML-Modell, den Imputer und die Feature-Liste. """ self.model, self.imputer, self._expected_features = None, None, None try: if not os.path.exists( model_path) or not os.path.exists(imputer_path): self.logger.error("Modell- oder Imputer-Datei nicht gefunden.") return with open(model_path, 'rb') as f: self.model = pickle.load(f) with open(imputer_path, 'rb') as f: self.imputer = pickle.load(f) if os.path.exists(PATTERNS_FILE_JSON): with open(PATTERNS_FILE_JSON, 'r', encoding='utf-8') as f: self._expected_features = json.load( f).get("feature_columns") if self._expected_features: self.logger.info("ML-Modell, Imputer und Features geladen.") else: self.logger.error("Konnte erwartete Features nicht laden.") except Exception as e: self.logger.exception(f"FEHLER beim Laden von ML-Artefakten: {e}") def prepare_data_for_modeling(self): """ Laedt und bereitet Daten für das ML-Training vor. """ """ 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 # <<< GEÄNDERT self.logger.info("Starte Datenvorbereitung fuer Modellierung (Training)...") # Prüfen, ob der Sheet Handler initialisiert ist und Daten hat. # Wenn nicht, versuchen, die Daten zu laden. if not self.sheet_handler or not self.sheet_handler.get_all_data_with_headers(): if not self.sheet_handler.load_data(): self.logger.critical("Konnte Daten auch nach erneutem Versuch nicht laden. Abbruch der Datenvorbereitung.") return None self.logger.critical( "Konnte Daten auch nach erneutem Versuch nicht laden. Abbruch der Datenvorbereitung.") # <<< GEÄNDERT 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).") # <<< GEÄNDERT 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: # Finde den hoechsten Index in COLUMN_MAP max_col_idx_in_map = max(d['index'] for d in COLUMN_MAP.values() if 'index' in d) # 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.") # <<< GEÄNDERT 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.") # <<< GEÄNDERT 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}") # <<< GEÄNDERT 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.") # <<< GEÄNDERT 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}") # <<< GEÄNDERT # Logge den Traceback self.logger.debug(traceback.format_exc()) # <<< GEÄNDERT return None # Beende die Methode # Datenzeilen sind alle Zeilen nach den Header-Zeilen # Annahme: Die ersten X Zeilen sind Header data_rows = all_data[header_rows:] # 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.") # <<< GEÄNDERT # DACH-Filter (basierend auf CRM Land - Spalte G) # Holt den tatsächlichen Spaltennamen crm_land_col_header = headers[get_col_idx("CRM Land")] # Erlaubte Werte für DACH-Länder (Groß- und Kleinschreibung wird durch # .str.upper() behandelt) dach_countries = [ "DE", "CH", "AT", "DEUTSCHLAND", "ÖSTERREICH", "SCHWEIZ", "OESTERREICH"] # OESTERREICH hinzugefügt # Sicherstellen, dass die Spalte existiert, bevor gefiltert wird if crm_land_col_header in df.columns: df = df[df[crm_land_col_header].astype(str).str.upper().isin( dach_countries)].copy() # .copy() um Warnung zu vermeiden self.logger.info( f"Nach DACH-Filter (basierend auf '{crm_land_col_header}'): {len(df)} Zeilen verbleiben.") if df.empty: self.logger.error( "Keine DACH-Unternehmen im Datensatz nach Filterung.") return None else: self.logger.error( f"Spalte '{crm_land_col_header}' für DACH-Filter nicht im DataFrame gefunden.") return None # Plausibilitätsfilter (basierend auf Spalten BG und BH) plausi_umsatz_col_header = headers[get_col_idx("Plausibilität Umsatz")] plausi_ma_col_header = headers[get_col_idx("Plausibilität Mitarbeiter")] # Sicherstellen, dass die Spalten existieren if plausi_umsatz_col_header in df.columns and plausi_ma_col_header in df.columns: # Filtere Zeilen, bei denen Plausi-Umsatz oder Plausi-MA einen Fehler anzeigt # Hier gehen wir davon aus, dass Fehler mit "FEHLER_" beginnen. df = df[~df[plausi_umsatz_col_header].astype( str).str.upper().str.startswith('FEHLER')].copy() df = df[~df[plausi_ma_col_header].astype( str).str.upper().str.startswith('FEHLER')].copy() self.logger.info( f"Nach Entfernung von FEHLER-Plausi-Fällen (BG, BH): {len(df)} Zeilen verbleiben.") if df.empty: self.logger.error("Keine Zeilen nach Plausi-Filterung übrig.") return None # Hier könnten Sie noch spezifischere Filter für bestimmte # WARNUNG-Typen einbauen, falls gewünscht else: self.logger.error( f"Plausibilitätsspalten '{plausi_umsatz_col_header}' oder '{plausi_ma_col_header}' nicht im DataFrame gefunden.") return None # --- 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_ki": "Chat Vorschlag 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 # DIE ZIELVARIABLE (Bekannte Technikerzahl) "techniker": "CRM Anzahl Techniker", # Spalte D <- Dies ist Zeile 9012 "parent_d_raw": "Parent Account Name", "parent_o_raw": "System Vorschlag Parent Account", # Spalte O "parent_p_raw": "Parent Vorschlag Status" # Spalte P } # 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}.") # <<< GEÄNDERT 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 # Liste der Header-Namen, die aus dem DF ausgewaehlt werden cols_to_select_by_header = [] 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]['index']] # 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 # Kopie erstellen, um SettingWithCopyWarning zu vermeiden df_subset = df[cols_to_select_by_header].copy() # 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.") # <<< GEÄNDERT self.logger.debug( f"Erwartete Header: {cols_to_select_by_header}. Verfuegbare Header im DF: {list(df.columns)}") # <<< GEÄNDERT 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.") # <<< GEÄNDERT self.logger.debug( f"COLUMN_MAP: {COLUMN_MAP}. Sheet hat {len(headers)} Spalten.") # <<< GEÄNDERT 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}") # <<< GEÄNDERT # Logge den Traceback self.logger.debug(traceback.format_exc()) # <<< GEÄNDERT return None # Beende die Methode self.logger.info( f"Benötigte Spalten fuer Modellierung ausgewaehlt und umbenannt: {list(df_subset.columns)}") # <<< GEÄNDERT self.logger.info("Erstelle Feature 'is_part_of_group'...") # Zugreifen auf die Spalten im DataFrame df_subset # Die Spaltennamen hier müssen den internen Namen entsprechen, # die in col_keys_mapping definiert wurden (z.B. 'parent_d_raw'). parent_d_series = df_subset['parent_d_raw'].astype( str).str.strip().str.lower() parent_o_series = df_subset['parent_o_raw'].astype( str).str.strip().str.lower() parent_p_series = df_subset['parent_p_raw'].astype( str).str.strip().str.lower() cond1 = parent_d_series.notna() & ( parent_d_series != 'k.a.') & ( parent_d_series != '') cond2_o = parent_o_series.notna() & ( parent_o_series != 'k.a.') & ( parent_o_series != '') cond2_p = parent_p_series == 'x' cond2 = cond2_o & cond2_p # .loc verwenden, um die neue Spalte sicher zuzuweisen und SettingWithCopyWarning zu vermeiden df_subset.loc[:, 'is_part_of_group'] = np.where(cond1 | cond2, 1, 0) self.logger.info( f"Feature 'is_part_of_group' erstellt. {df_subset['is_part_of_group'].sum()} Unternehmen als Teil einer Gruppe markiert.") self.logger.debug( f"Verteilung von 'is_part_of_group':\n{df_subset['is_part_of_group'].value_counts(normalize=True, dropna=False)}") # --- Features konsolidieren (Umsatz, Mitarbeiter) --- self.logger.debug( "Konsolidiere Umsatz und Mitarbeiter für ML-Features...") cols_to_process_ml = { 'Umsatz': ('umsatz_wiki', 'umsatz_crm', 'Finaler_Umsatz_ML'), 'Mitarbeiter': ('ma_wiki', 'ma_crm', 'Finaler_Mitarbeiter_ML') } for base_name, (wiki_col_ml, crm_col_ml, final_col_ml) in cols_to_process_ml.items(): is_umsatz_flag = (base_name == 'Umsatz') wiki_series = df_subset[wiki_col_ml].apply( lambda x: get_numeric_filter_value( x, is_umsatz=is_umsatz_flag)) crm_series = df_subset[crm_col_ml].apply( lambda x: get_numeric_filter_value( x, is_umsatz=is_umsatz_flag)) # Wähle Wiki-Wert, wenn vorhanden und > 0, sonst CRM-Wert df_subset.loc[:, final_col_ml] = np.where( (wiki_series.notna()) & (wiki_series > 0), wiki_series, crm_series ) # Ersetze 0 explizit durch NaN, damit es von log1p und Imputer # korrekt behandelt wird df_subset.loc[:, final_col_ml] = df_subset[final_col_ml].replace( 0, np.nan) self.logger.info( f" -> {df_subset[final_col_ml].notna().sum()} gueltige '{final_col_ml}' Werte erstellt (von {len(df_subset)} Zeilen).") # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ # +++ NEUER BLOCK: Feature Engineering (Ratio & Log-Transformationen) + # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ self.logger.info( "Erstelle zusätzliche Features (Ratio, Log-Transformationen)...") if 'Finaler_Umsatz_ML' in df_subset.columns and 'Finaler_Mitarbeiter_ML' in df_subset.columns: # Umsatz pro Mitarbeiter # Hier sind Nullen schon durch NaN ersetzt ma_for_ratio = df_subset['Finaler_Mitarbeiter_ML'] df_subset.loc[:, 'Umsatz_pro_MA_ML'] = df_subset['Finaler_Umsatz_ML'] / ma_for_ratio df_subset['Umsatz_pro_MA_ML'].replace( [np.inf, -np.inf], np.nan, inplace=True) self.logger.debug(f" -> Feature 'Umsatz_pro_MA_ML' erstellt.") # Log-Transformationen (np.log1p(x) berechnet log(1+x), sicher für # NaNs) df_subset.loc[:, 'Log_Finaler_Umsatz_ML'] = np.log1p( df_subset['Finaler_Umsatz_ML']) df_subset.loc[:, 'Log_Finaler_Mitarbeiter_ML'] = np.log1p( df_subset['Finaler_Mitarbeiter_ML']) self.logger.debug(f" -> Log-transformierte Features erstellt.") else: self.logger.warning( "Konsolidierte Umsatz/Mitarbeiter-Spalten nicht gefunden, Feature Engineering übersprungen.") # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ # +++ ENDE NEUER BLOCK ++++++++++++++++++++++++++++++++++++++++++++++++ # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ # --- Zielvariable vorbereiten (Technikerzahl) --- self.logger.info("Verarbeite Zielvariable 'techniker'...") techniker_col_internal = "techniker" df_subset.loc[:, 'Anzahl_Servicetechniker_Numeric'] = df_subset[techniker_col_internal].apply( lambda x: get_numeric_filter_value(x, is_umsatz=False)) initial_rows_before_tech_filter = len(df_subset) df_filtered = df_subset[ df_subset['Anzahl_Servicetechniker_Numeric'].notna() & (df_subset['Anzahl_Servicetechniker_Numeric'] > 0) ].copy() removed_rows_tech_filter = initial_rows_before_tech_filter - \ len(df_filtered) if removed_rows_tech_filter > 0: self.logger.info( f"{removed_rows_tech_filter} Zeilen entfernt aufgrund fehlender/ungueltiger Technikerzahl.") self.logger.info( f"Verbleibende Zeilen fuer Modellierungstraining: {len(df_filtered)}") if df_filtered.empty: self.logger.error( "FEHLER: Keine Zeilen mit gueltiger Technikerzahl (>0) uebrig fuer Modellierungstraining!") return None # --- Techniker-Buckets erstellen (mit reduzierter Klassenanzahl) --- self.logger.info( "Erstelle reduzierte Techniker-Buckets (3 Klassen)...") bins_new = [-1, 49, 249, float('inf')] labels_new = [ 'Techniker_Klein (0-49)', 'Techniker_Mittel (50-249)', 'Techniker_Gross (250+)'] df_filtered.loc[:, 'Techniker_Bucket'] = pd.cut(df_filtered['Anzahl_Servicetechniker_Numeric'], bins=bins_new, labels=labels_new, right=True, include_lowest=True) self.logger.info( f"Verteilung der neuen Techniker-Buckets:\n{df_filtered['Techniker_Bucket'].value_counts(normalize=True, dropna=False).sort_index().round(3)}") # --- Kategoriale Features vorbereiten (Branchen-Gruppen) --- # Wir verwenden konsistent die KI-generierte Spalte 'branche_ki' als Basis für die Gruppierung. branche_col_internal = "branche_ki" self.logger.info(f"Verarbeite kategoriales Feature '{branche_col_internal}' und mappe es zu 'Branchen_Gruppe'...") if branche_col_internal not in df_filtered.columns: self.logger.critical(f"FEHLER: Die für das Mapping benötigte Spalte '{branche_col_internal}' wurde nicht im DataFrame gefunden. Breche ab.") return None # Wende das saubere Mapping aus der Config an, um nur den 'gruppe'-String zu extrahieren. branch_group_map = {branch_name: details.get('gruppe', 'Sonstige') for branch_name, details in Config.BRANCH_GROUP_MAPPING.items()} df_filtered.loc[:, 'Branchen_Gruppe'] = df_filtered[branche_col_internal].map(branch_group_map).fillna('Sonstige') self.logger.info("Mapping zu 'Branchen_Gruppe' durchgeführt.") self.logger.debug( f"Verteilung der Branchen-Gruppen:\n{df_filtered['Branchen_Gruppe'].value_counts(normalize=True).sort_index().round(3)}") # --- One-Hot Encoding --- df_encoded = pd.get_dummies(df_filtered, columns=['Branchen_Gruppe'], prefix='Gruppe', dummy_na=False) self.logger.info("One-Hot Encoding fuer 'Branchen_Gruppe' durchgefuehrt.") # --- Finale Auswahl der Features fuer das Modell --- feature_columns_ml = [col for col in df_encoded.columns if col.startswith('Gruppe_')] feature_columns_ml.extend(['Log_Finaler_Umsatz_ML', 'Log_Finaler_Mitarbeiter_ML', 'Umsatz_pro_MA_ML', 'is_part_of_group']) self.logger.info(f"Finale Feature-Auswahl für das Training: {feature_columns_ml}") target_column_ml = 'Techniker_Bucket' identification_cols_ml = ['name', 'Anzahl_Servicetechniker_Numeric'] final_cols_for_df_ml = identification_cols_ml + feature_columns_ml + [target_column_ml] missing_final_cols_ml = [col for col in final_cols_for_df_ml if col not in df_encoded.columns] if missing_final_cols_ml: self.logger.critical(f"FEHLER: Finale Spalten fuer Modellierung fehlen im DataFrame: {missing_final_cols_ml}") return None df_model_ready = df_encoded[final_cols_for_df_ml].copy() numeric_features_to_convert = ['Log_Finaler_Umsatz_ML', 'Log_Finaler_Mitarbeiter_ML', 'Umsatz_pro_MA_ML', 'Anzahl_Servicetechniker_Numeric'] for col in numeric_features_to_convert: if col in df_model_ready.columns: df_model_ready[col] = pd.to_numeric(df_model_ready[col], errors='coerce') df_model_ready = df_model_ready.reset_index(drop=True) 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.") self.logger.info(f"Anzahl Feature-Spalten: {len(feature_columns_ml)}") numeric_features_for_imputation_ml = ['Log_Finaler_Umsatz_ML', 'Log_Finaler_Mitarbeiter_ML', 'Umsatz_pro_MA_ML'] existing_numeric_features = [col for col in numeric_features_for_imputation_ml if col in df_model_ready.columns] if existing_numeric_features: nan_counts = df_model_ready[existing_numeric_features].isna().sum() self.logger.info(f"Fehlende Werte in numerischen Features vor Imputation:\n{nan_counts}") rows_with_nan = df_model_ready[existing_numeric_features].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 def train_technician_model( self, model_out=MODEL_FILE, imputer_out=IMPUTER_FILE, patterns_out=PATTERNS_FILE_JSON): """ Trainiert, evaluiert und speichert das ML-Modell für die Techniker-Schätzung. """ self.logger.info("Starte Training des Servicetechniker-Modells...") self.logger.info( "Starte Training des Servicetechniker Decision Tree Modells...") # 1. Daten vorbereiten df_model_ready = self.prepare_data_for_modeling() if df_model_ready is None or df_model_ready.empty: self.logger.error( "Datenvorbereitung fuer Modelltraining fehlgeschlagen oder keine Daten. Training abgebrochen.") return # Feature Spalten und Zielspalte definieren identification_cols = ['name', 'Anzahl_Servicetechniker_Numeric'] target_column = 'Techniker_Bucket' feature_columns_ml = [ col for col in df_model_ready.columns if col not in identification_cols and col != target_column] if not feature_columns_ml: self.logger.critical( "FEHLER: Keine Feature-Spalten nach Datenvorbereitung gefunden. Training nicht moeglich.") return X = df_model_ready[feature_columns_ml] y = df_model_ready[target_column] self.logger.info( f"Daten fuer Training vorbereitet. X Shape: {X.shape}, y Shape: {y.shape}") # 2. Split in Training und Test Set 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. Pipeline-Definition (Imputation wird Teil der Pipeline) # Schritt 1: Imputer für fehlende Werte # Schritt 2: SMOTE für Klassen-Balancierung # Schritt 3: RandomForestClassifier als Modell pipeline = ImbPipeline([ ('imputer', SimpleImputer(strategy='median')), ('smote', SMOTE(random_state=42)), ('classifier', RandomForestClassifier(random_state=42, n_jobs=-1)) ]) # 4. Hyperparameter-Grid für GridSearchCV definieren # Die Parameternamen müssen mit dem Namen des Schritts in der Pipeline beginnen. param_grid = { 'classifier__n_estimators': [200, 300], 'classifier__max_depth': [10, 20, None], 'classifier__min_samples_split': [2, 5], 'classifier__min_samples_leaf': [1, 2] } # 5. GridSearchCV initialisieren grid_search = GridSearchCV( estimator=pipeline, param_grid=param_grid, cv=3, scoring='accuracy', verbose=2, n_jobs=-1) self.logger.info("Starte Hyperparameter-Tuning mit GridSearchCV...") start_fit_time = time.time() # 6. Grid auf den Original-Trainingsdaten fitten (ohne vorherige Imputation) # Die Pipeline kümmert sich intern um die korrekte Imputation und SMOTE für jeden Fold. grid_search.fit(X_train, y_train) end_fit_time = time.time() self.logger.info( f"GridSearchCV-Suche abgeschlossen. Dauer: {end_fit_time - start_fit_time:.2f} Sekunden.") # Beste Parameter und bestes Modell ausgeben und speichern self.logger.info( f"Beste gefundene Parameter: {grid_search.best_params_}") self.logger.info( f"Beste Cross-Validation Accuracy: {grid_search.best_score_:.4f}") # Das beste Modell ist das, das mit den besten Parametern auf den # *gesamten* Trainingsdaten trainiert wurde. best_classifier = grid_search.best_estimator_ # Modell speichern self.model = best_classifier # Zuweisung des besten gefundenen Modells try: model_dir = os.path.dirname(model_out) if model_dir and not os.path.exists(model_dir): os.makedirs(model_dir, exist_ok=True) with open(model_out, 'wb') as f: pickle.dump(best_classifier, f) # Das beste Modell speichern self.logger.info( f"Bestes RandomForest Modell erfolgreich gespeichert in '{model_out}'.") except Exception as e: self.logger.error( f"FEHLER beim Speichern des besten Modells in '{model_out}': {e}") # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ # +++ ENDE ANPASSUNG ++++++++++++++++++++++++++++++++++++++++++++++++++ # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ # Feature-Liste speichern (bleibt unverändert) self._expected_features = feature_columns_ml try: # Wir verwenden die .classes_ vom besten gefundenen Modell patterns_data = { "feature_columns": self._expected_features, "target_classes": list( best_classifier.classes_)} # << KORRIGIERT patterns_dir = os.path.dirname(patterns_out) if patterns_dir and not os.path.exists(patterns_dir): os.makedirs(patterns_dir, exist_ok=True) 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}'.") except Exception as e: self.logger.error( f"FEHLER beim Speichern der Feature-Spalten in '{patterns_out}': {e}") # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ # +++ ENDE FEATURE-LISTEN SPEICHERUNG +++++++++++++++++++++++++++++++++ # +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ # 5. Evaluation (Optional, aber empfohlen, um die Modellleistung zu # bewerten) self.logger.info( "Starte Evaluation des besten Modells auf dem ungesehenen Testset...") y_pred = best_classifier.predict(X_test) accuracy = accuracy_score(y_test, y_pred) self.logger.info(f"Finale Modell Genauigkeit auf dem Testset: {accuracy:.4f}") # Erzeuge den Klassifikationsbericht als Dictionary für eine saubere Ausgabe report_dict = classification_report( y_test, y_pred, zero_division=0, labels=self.model.classes_, target_names=[str(c) for c in self.model.classes_], output_dict=True ) # Konvertiere das Dictionary in einen formatierten DataFrame für das Logging report_df = pd.DataFrame(report_dict).transpose() report_df['support'] = report_df['support'].astype(int) # Für saubere Darstellung # Erstelle einen gut lesbaren String aus dem DataFrame report_string = report_df.to_string() self.logger.info(f"Klassifikationsbericht auf dem Testset:\n{report_string}") cm = confusion_matrix(y_test, y_pred, labels=self.model.classes_) cm_df = pd.DataFrame(cm, index=self.model.classes_, columns=self.model.classes_) self.logger.info(f"Konfusionsmatrix auf dem Testset (Zeilen=Wahr, Spalten=Vorhersage):\n{cm_df.to_string()}") # Block für Feature Importance try: # Greife auf den Schritt 'classifier' in der Pipeline zu, um das # finale Modell zu bekommen final_rf_model = best_classifier.named_steps['classifier'] self.logger.info("Feature Importance des besten Modells (Top 15):") importances = final_rf_model.feature_importances_ # << KORRIGIERT feature_importance_df = pd.DataFrame({ 'Feature': feature_columns_ml, 'Importance': importances }).sort_values(by='Importance', ascending=False) self.logger.info( f"\n{feature_importance_df.head(15).to_string(index=False)}") except Exception as e_feat_imp: self.logger.warning( f"FEHLER beim Berechnen/Anzeigen der Feature Importance: {e_feat_imp}") self.logger.info("Modelltraining und -evaluation abgeschlossen.") def process_website_details( self, start_sheet_row=None, end_sheet_row=None, limit=None): """ EXPERIMENTELL: Extrahiert Website-Details für Zeilen mit 'x' in Spalte A. """ """ 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'}...") # <<< GEÄNDERT self.logger.warning( "Hinweis: Dieser Modus nutzt die globale Funktion 'scrape_website_details' (Block 13), deren Implementierung je nach Zielwebsites angepasst werden muss.") # <<< GEÄNDERT # --- 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.") # <<< GEÄNDERT 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: # Standardmaessig ab erster Datenzeile (Zeile nach Headern) start_sheet_row = header_rows + 1 # 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}") # <<< GEÄNDERT # 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.") # <<< GEÄNDERT 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", "CRM Name"] # A, D, B # 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.") # <<< GEÄNDERT 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) # Versuche zuerst die dedizierte Spalte (Block 1 Column Map) details_col_idx = COLUMN_MAP.get("Website Details") 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.") # <<< GEÄNDERT 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.") # <<< GEÄNDERT 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.") # <<< GEÄNDERT # 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) # Gesammelte Updates fuer Batch-Schreiben ins Sheet (Liste von Dicts) all_sheet_updates = [] # Zaehlt Zeilen, die fuer die Verarbeitung in Frage kommen und in den # Batch aufgenommen werden (im Rahmen des Limits). processed_count = 0 # Zaehlt Zeilen, die uebersprungen wurden (nicht markiert oder fehlende # URL). skipped_count = 0 # 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}") # <<< GEÄNDERT # 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 --- # Zaehle die Zeile, die fuer die Verarbeitung in Frage kommt (im # Rahmen des Limits zaehlen) processed_count += 1 # 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.") # <<< GEÄNDERT break # Brich die Schleife ab # <<< GEÄNDERT (war selflogger) self.logger.info( f"Zeile {i}: Extrahiere Website Details von {website_url[:100]}...") # Default Fehler, falls die Funktion nicht existiert (Sollte nicht # passieren, wenn Block 13 korrekt ist) details = "FEHLER: Funktion 'scrape_website_details' nicht verfuegbar" 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. # <<< Ruft globale Funktion (Block 13) details = scrape_website_details(website_url) # 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. # Standard-Fehlerwert details = "k.A. (Extraktion leer oder ungueltig)" 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.") # <<< GEÄNDERT # Logge den Traceback. self.logger.debug(traceback.format_exc()) # <<< GEÄNDERT 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}") # <<< GEÄNDERT # Signalisiert Fehler (gekuerzt) details = f"k.A. (Unerwarteter Fehler: {str(e_detail)[:100]}...)" # 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.") # <<< GEÄNDERT # 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)...") # <<< GEÄNDERT # 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.") # <<< GEÄNDERT # 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)...") # <<< GEÄNDERT # 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: # <<< GEÄNDERT 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.") # <<< GEÄNDERT def process_wiki_updates_from_chatgpt( self, start_sheet_row=None, end_sheet_row=None, limit=None): """ Verarbeitet Wiki-Updates basierend auf ChatGPT-Vorschlägen. """ """ 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'}...") # <<< GEÄNDERT # --- 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.") # <<< GEÄNDERT 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: # Standardmaessig ab erster Datenzeile (Zeile nach Headern) start_sheet_row = header_rows + 1 # 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}") # <<< GEÄNDERT # 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.") # <<< GEÄNDERT 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 = [ # S, U, M (Pruefkriterien / Daten) "Chat Wiki Konsistenzpruefung", "Chat Vorschlag Wiki Artikel", "Wiki URL", # AN, AX, AO, AP (Spalten zum Loeschen) "Wikipedia Timestamp", "Wiki Verif. Timestamp", "Timestamp letzte Pruefung", "Version", "ReEval Flag", # A (ReEval Flag setzen) # N-R (Spalten zum Loeschen) "Wiki Absatz", "Wiki Branche", "Wiki Umsatz", "Wiki Mitarbeiter", "Wiki Kategorien", # T, V (Spalten zum Loeschen) "Chat Begründung Wiki Inkonsistenz", "Begruendung bei Abweichung", # 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.") # <<< GEÄNDERT 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 # Anzahl der Spalten = V_Index - N_Index + 1 empty_nv_values = [''] * (v_idx - n_idx + 1) # Timestamps AN, AO, AX, AP, AY leeren. 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) # Gesammelte Updates fuer Batch-Schreiben ins Sheet (Liste von Dicts) all_sheet_updates = [] # Zaehlt Zeilen, die geprueft werden (im Rahmen des Limits zaehlen). processed_rows_count = 0 # Zaehlt Zeilen, die uebersprungen werden (Status S im Endzustand # etc.). skipped_count = 0 updated_url_count = 0 # Zaehlt Zeilen, wo U -> M kopiert wurde. # Zaehlt Zeilen, wo Vorschlag U geloescht wurde. cleared_suggestion_count = 0 # 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}") # <<< GEÄNDERT # 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 --- # Zaehle die Zeile, die geprueft wird (im Rahmen des Limits # zaehlen). processed_rows_count += 1 # 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.") # <<< GEÄNDERT 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]}...')...") # <<< GEÄNDERT # Flag, ob U eine gueltige, neue URL ist, die uebernommen werden # soll. is_update_candidate = False 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...") try: # 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 if condition3_u_is_valid: 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 self.logger.debug( f" -> URL '{new_url[:100]}...' ist KEIN valider Artikel laut API Check.") # <<< GEÄNDERT except Exception as e_validity_check: # Wenn die Validierungsfunktion 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}") # <<< GEÄNDERT # 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.") # <<< GEÄNDERT 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.") # <<< GEÄNDERT # --- 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.") # <<< GEÄNDERT 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) # Setze die neue URL in Spalte M (Block 1 Column Map) updates_for_row.append( {'range': f'{m_letter}{i}', 'values': [[new_url]]}) # Setze Status S auf "X (URL Copied)" (Block 1 Column Map) updates_for_row.append( {'range': f'{s_letter}{i}', 'values': [["X (URL Copied)"]]}) # Schreibe Info in Spalte U (Block 1 Column Map) updates_for_row.append( {'range': f'{u_letter}{i}', 'values': [["URL uebernommen"]]}) # Setze ReEval Flag (A) auf 'x' (Block 1 Column Map) updates_for_row.append( {'range': f'{a_letter}{i}', 'values': [["x"]]}) # 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.") # <<< GEÄNDERT # Leere Timestamps AN, AO, AP, AX, AY. # Dies setzt die Zeile zurueck, damit andere Schritte sie # spaeter bearbeiten. # AN (Wiki Extraction TS) Block 1 Column Map updates_for_row.append( {'range': f'{an_letter}{i}', 'values': [['']]}) # AO (Chat Evaluation TS) Block 1 Column Map updates_for_row.append( {'range': f'{ao_letter}{i}', 'values': [['']]}) # AP (Version) Block 1 Column Map updates_for_row.append( {'range': f'{ap_letter}{i}', 'values': [['']]}) # AX (Wiki Verif. TS) Block 1 Column Map updates_for_row.append( {'range': f'{ax_letter}{i}', 'values': [['']]}) # AY (SerpAPI Wiki TS) Block 1 Column Map updates_for_row.append( {'range': f'{ay_letter}{i}', 'values': [['']]}) 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)'.") # <<< GEÄNDERT cleared_suggestion_count += 1 # Zaehle den bereinigten Vorschlag # Updates sammeln (S, U) (nutzt interne Helfer _get_col_letter # Block 14) # Setze Status S auf "X (Invalid Suggestion)" (Block 1 Column # Map) updates_for_row.append( {'range': f'{s_letter}{i}', 'values': [["X (Invalid Suggestion)"]]}) # Loesche den Vorschlag in Spalte U (Block 1 Column Map) updates_for_row.append( {'range': f'{u_letter}{i}', 'values': [[""]]}) # 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)...") # <<< GEÄNDERT # 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.") # <<< GEÄNDERT # 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)...") # <<< GEÄNDERT # 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: # <<< GEÄNDERT 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.") # <<< GEÄNDERT def process_wiki_reextract_missing_an( self, start_sheet_row=None, end_sheet_row=None, limit=None): """ Startet eine erneute Wiki-Extraktion für Zeilen mit URL aber ohne Timestamp. """ """ 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'}...") # <<< GEÄNDERT # --- 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...") # <<< GEÄNDERT # 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.") # <<< GEÄNDERT 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}") # <<< GEÄNDERT 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.") # <<< GEÄNDERT 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}") # <<< GEÄNDERT # 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.") # <<< GEÄNDERT 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", "CRM Name"] # M, AN, B (Pruefkriterien + Logging) # 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.") # <<< GEÄNDERT 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 --- # Zaehlt Zeilen, die an _process_single_row uebergeben wurden (im # Rahmen des Limits zaehlen). processed_count = 0 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}. Benötigt Verarbeitung? {processing_needed_for_row}") # <<< GEÄNDERT # 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 --- # Zaehle die Zeile, die an _process_single_row uebergeben wird (im # Rahmen des Limits zaehlen) processed_count += 1 # 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.") # <<< GEÄNDERT break # Brich die Schleife ab self.logger.info( f"Zeile {i}: M gefuellt & AN leer. Versuche Wiki-Re-Extraktion ueber _process_single_row...") # <<< GEÄNDERT 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}") # <<< GEÄNDERT # 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.") # <<< GEÄNDERT